haskoin-store-1.9.0: test/Haskoin/Store/Logic/LayeredSpec.hs
module Haskoin.Store.Logic.LayeredSpec (spec) where
import Control.Monad (foldM, forM_, void)
import Data.ByteString (ByteString)
import Data.ByteString qualified as B
import Data.Either (isLeft)
import Data.List (sortOn)
import Data.Maybe (isJust)
import Data.Serialize (decode)
import Haskoin
( Block (..),
BlockHash,
Ctx,
Network,
OutPoint (..),
Tx (..),
TxHash,
TxIn (..),
TxOut (..),
bchRegTest,
btcRegTest,
headerHash,
prepareContext,
txHash,
)
import Haskoin.Store.Common (Start (..), StoreRead (..))
import Haskoin.Store.Data (TxData (..), Unspent (..))
import Haskoin.Store.Database.Mempool qualified as M
import Haskoin.Store.Database.Migrate (upgradeDatabase)
import Haskoin.Store.Database.Query qualified as Q
import Haskoin.Store.Database.Query.Layered qualified as L
import Haskoin.Store.Database.Reader
( DatabaseReader (..),
blockCF,
heightCF,
mempoolAddrCF,
mempoolCF,
txCF,
)
import Haskoin.Store.Histories
import Haskoin.Store.Logic (ImportException (..))
import Haskoin.Store.TestBlocks (coinbaseTx, script)
import Test.Hspec
import Test.Hspec.QuickCheck (modifyMaxSuccess)
import Test.QuickCheck (choose, forAll, ioProperty, property, tabulate, vectorOf)
import Test.QuickCheck.Gen (unGen)
import Test.QuickCheck.Random (mkQCGen)
import UnliftIO (withSystemTempDirectory)
spec :: Spec
spec = prepareContext $ \ctx -> describe "rules of the layered layout" $ do
forM_ [("Bitcoin", btcRegTest), ("Bitcoin Cash, without replace-by-fee", bchRegTest)] $ \(name, net) ->
modifyMaxSuccess (const 40) $
it ("leave the database that today's rules and the migration leave, on " <> name) $
-- the number says when the third store is upgraded, and which
-- pages are asked for at the end
property . forAll ((,) <$> vectorOf 70 anyNumber <*> choose (0, 1000000)) $ \(seeds, number) -> ioProperty $ do
(outcomes, hist) <- sameHistory ctx net number [\db h -> plan db h seed | seed <- seeds]
let count p = length (filter p outcomes)
bucket n
| n == 0 = "none"
| n <= 3 = "1 to 3"
| n <= 9 = "4 to 9"
| otherwise = "10 or more" :: String
return
. tabulate "operations refused" [bucket (count (isLeft . (.result)))]
. tabulate "operations that removed from the mempool" [bucket (count (not . null . (.removed)))]
$ tabulate "blocks at the end" [bucket (length hist.chain)] True
it "give a transaction that is confirmed twice the place of the later block, as today's" $ do
-- the coinbase of the first block again, in the second; then its output
-- is spent, the spend confirmed, and both blocks reverted
let twice = coinbaseTx 1
spend = Tx 2 [TxIn (OutPoint (txHash twice) 0) "" maxBound] [TxOut 4000 (p2pkh 1)] [] 0
mine txs db h = (h,) . Just <$> blockOn db txs
pay tx _ h = return (h, Just (Pay tx h.clock))
revert _ h = return $ case h.chain of
(b, _) : _ -> (h, Just (Revert (headerHash b.header)))
[] -> (h, Nothing)
-- the third store is upgraded with the spend in its mempool
(outcomes, _) <- sameHistory ctx btcRegTest 2 [mine [twice], pay spend, mine [spend], revert, revert]
map (either show (const "done") . (.result)) outcomes `shouldBe` replicate 5 "done"
it "refuse a block that spends an output of the mempool, which today's rules import" $
withSystemTempDirectory "haskoin-store-layered-" $ \dir ->
openLayered btcRegTest ctx dir $ \db -> do
hist <- start layeredRules db
let parent = Tx 2 [TxIn (coinOf hist) "" maxBound] [TxOut 4000 script] [] 0
child = Tx 2 [TxIn (OutPoint (txHash parent) 0) "" maxBound] [TxOut 3000 (p2pkh 1)] [] 0
(.result) <$> apply layeredRules db (Pay parent 1000) `shouldReturn` Right "True"
untouched <- everything db
-- the parent is not in the block, so its output is not in the chain
block <- blockOn db [child]
(.result) <$> apply layeredRules db block `shouldReturn` Left Orphan
everything db `shouldReturn` untouched
it "leave the keys of a mempool transaction alone when a block confirms what it spends" $
withSystemTempDirectory "haskoin-store-layered-" $ \dir ->
openLayered btcRegTest ctx dir $ \db -> do
hist <- start layeredRules db
let parent = Tx 2 [TxIn (coinOf hist) "" maxBound] [TxOut 4000 script, TxOut 5000 (p2pkh 2)] [] 0
child = Tx 2 [TxIn (OutPoint (txHash parent) 0) "" maxBound] [TxOut 3000 (p2pkh 1)] [] 0
keysOf tx = do
Just td <- M.getMempoolTx db (txHash tx)
let r = M.mempoolRecords db.net db.ctx 1000 td
return (fst (M.recordEntries r), M.addressArea [r])
forM_ [parent, child] $ \tx ->
(.result) <$> apply layeredRules db (Pay tx 1000) `shouldReturn` Right "True"
(general, byAddress) <- keysOf child
block <- blockOn db [parent]
confirmed <- apply layeredRules db block
confirmed.removed `shouldBe` [txHash parent]
-- the child's keys are all that is left of the mempool, unchanged
dump db (mempoolCF db.db) `shouldReturn` sortOn fst general
dump db (mempoolAddrCF db.db) `shouldReturn` sortOn fst byAddress
-- and the spent output of the parent, now in the chain, is still
-- spent to anyone who asks
L.withView db (\v -> L.getUnspent v (OutPoint (txHash parent) 0)) `shouldReturn` Nothing
(fmap (.value) <$> Q.getUnspent db (OutPoint (txHash parent) 0)) `shouldReturn` Just 4000
-- | Give the same history to three stores, each with a database of its
-- own:
--
-- * one that runs the rules of today's layout;
--
-- * one that runs the rules of the layered layout;
--
-- * one that is upgraded on the way: it runs today's rules for the first
-- operations, as many as a number says, then its database is migrated,
-- and it runs the rules of the layered layout from there on.
--
-- Each operation is chosen from the first database and the history so far.
-- Every operation must have the same outcome in all of them, and leave the
-- same answers to every query. At the end all are asked for the same pages,
-- chosen from the number. Then the first database is migrated: it must equal
-- the second key for key, and the third in everything but the bodies of
-- deleted transactions, which each set of rules leaves in its own place.
-- Gives the outcomes and the history.
sameHistory ::
Ctx ->
Network ->
Int ->
[DatabaseReader -> History -> IO (History, Maybe Op)] ->
IO ([Outcome], History)
sameHistory ctx net number choices =
withSystemTempDirectory "haskoin-store-mixed-" $ \dirOld ->
withSystemTempDirectory "haskoin-store-layered-" $ \dirNew ->
withSystemTempDirectory "haskoin-store-upgraded-" $ \dirUp -> do
-- the store to upgrade begins with a database of version 20
open20 net ctx dirUp (\_ -> return ())
openLayered net ctx dirNew $ \new ->
openLayered net ctx dirUp $ \up -> do
(outcomes, hist) <- open20 net ctx dirOld $ \old -> do
hist0 <- start mixedRules old
void (start layeredRules new)
void (start mixedRules up)
let (earlier, later) = splitAt (number `mod` (length choices + 1)) choices
today op _ _ _ = void (apply mixedRules up op)
upgraded op was hist answers = do
now <- apply layeredRules up op
(show op, now) `shouldBe` (show op, was)
M.wrongSummaries up `shouldReturn` []
layered up (ask hist) `shouldReturn` answers
half <- foldM (next old new today) ([], hist0) earlier
void (upgradeDatabase (\_ -> return ()) up)
done@(_, hist) <- foldM (next old new upgraded) half later
-- pages, of databases that the new rules built or carried on
-- with, against today's
let asked = unGen (questions net ctx hist 60) (mkQCGen number) 30
was <- mixed old (\queries -> mapM (answer queries) asked)
-- but for pages that start at a deleted transaction, which
-- the layered layout does not know
known <- mapM (startsKnown old) asked
let both :: [x] -> [x]
both xs = [x | (x, True) <- zip xs known]
forM_ [new, up] $ \db -> do
now <- layered db (\queries -> mapM (answer queries) asked)
take 2 (differences (both asked) (both was) (both now)) `shouldBe` []
-- the queries of a fleet do not know deleted transactions, so
-- they are not asked for pages that start at one
let live queries q = case startOf q of
Just (Just (AtTx h)) -> isJust <$> queries.getTxData h
_ -> return True
fair <- layered new (\queries -> mapM (live queries) asked)
let kept :: [x] -> [x]
kept xs = [x | (x, True) <- zip xs fair]
shared <- pooled new (\queries -> mapM (answer queries) (kept asked))
take 2 (differences (kept asked) (kept was) shared) `shouldBe` []
return done
migrated <- openLayered net ctx dirOld $ \old -> upgradeDatabase (\_ -> return ()) old >> everything old
mine <- everything new
-- the same, but for the bodies of deleted transactions, which
-- version 20 left in the chain: the layered rules keep none
mine `shouldBe` migrated {inChain = [], deleted = []}
-- the upgraded store has those that it deleted before the
-- upgrade in the chain, as version 20 left them, even when the
-- transaction came back later: the queries, compared after
-- every operation, do not answer with those
theirs <- everything up
theirs {deleted = [], inChain = []} `shouldBe` migrated {deleted = [], inChain = []}
return (reverse outcomes, hist)
where
next old new third (outcomes, hist) chosen =
chosen old hist >>= \case
(hist', Nothing) -> return (outcomes, hist')
(hist', Just op) -> do
was <- apply mixedRules old op
now <- apply layeredRules new op
(show op, now) `shouldBe` (show op, was)
-- each summary is what the entries of its address add up to
M.wrongSummaries new `shouldReturn` []
-- and every answer is the same after it
let hist'' = record old hist' op was
answers <- mixed old (ask hist'')
layered new (ask hist'') `shouldReturn` answers
-- from the queries of a fleet as well, whose pool is here the
-- store's own mempool
pooled new (ask hist'') `shouldReturn` answers
third op was hist'' answers :: IO ()
return (was : outcomes, hist'')
-- | Everything in a database of the layered layout.
data Everything = Everything
{ best :: !(Maybe BlockHash),
blocks :: ![(ByteString, ByteString)],
heights :: ![(ByteString, ByteString)],
chain :: !Chain,
mempool :: ![(ByteString, ByteString)],
mempoolByAddress :: ![(ByteString, ByteString)],
-- | transactions that left the mempool unconfirmed, wherever their
-- bodies are kept
deleted :: ![(TxHash, TxData)],
-- | those of them that are kept in the chain, as version 20 did
inChain :: ![TxHash]
}
deriving (Eq, Show)
everything :: DatabaseReader -> IO Everything
everything db = do
best <- Q.getBestBlock db
blocks <- dump db (blockCF db.db)
heights <- dump db (heightCF db.db)
chain <- chainOf db
area <- dump db (mempoolCF db.db)
mempoolByAddress <- dump db (mempoolAddrCF db.db)
bodies <- dump db (txCF db.db) >>= mapM (decoded . snd)
let (gone, mempool) = span' ((== "\x0e") . B.take 1 . fst) area
old = [td | td <- bodies, td.deleted]
new <- mapM (decoded . snd) gone
return
Everything
{ best,
blocks,
heights,
chain,
mempool,
mempoolByAddress,
deleted = sortOn fst [(txHash td.tx, td) | td <- old <> new],
inChain = map (txHash . (.tx)) old
}
where
decoded :: ByteString -> IO TxData
decoded = either fail return . decode
span' p xs = (filter p xs, filter (not . p) xs)