packages feed

haskoin-store-data 1.10.0 → 1.11.0

raw patch · 7 files changed

+281/−6 lines, 7 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ Haskoin.Store.Data: EventAll :: EventCommand
+ Haskoin.Store.Data: EventFilterAdd :: !ByteString -> EventCommand
+ Haskoin.Store.Data: EventFilterClear :: EventCommand
+ Haskoin.Store.Data: EventFilterLoad :: !BloomFilter -> EventCommand
+ Haskoin.Store.Data: EventNone :: EventCommand
+ Haskoin.Store.Data: EventUnwatch :: ![Address] -> EventCommand
+ Haskoin.Store.Data: EventUnwatchXPub :: !XPubSpec -> EventCommand
+ Haskoin.Store.Data: EventWatch :: ![Address] -> EventCommand
+ Haskoin.Store.Data: EventWatchXPub :: !XPubSpec -> EventCommand
+ Haskoin.Store.Data: data EventCommand
+ Haskoin.Store.Data: instance Control.DeepSeq.NFData Haskoin.Store.Data.EventCommand
+ Haskoin.Store.Data: instance GHC.Classes.Eq Haskoin.Store.Data.EventCommand
+ Haskoin.Store.Data: instance GHC.Internal.Generics.Generic Haskoin.Store.Data.EventCommand
+ Haskoin.Store.Data: instance GHC.Internal.Show.Show Haskoin.Store.Data.EventCommand
+ Haskoin.Store.Data: instance Haskoin.Util.Marshal.Marshal (Haskoin.Network.Data.Network, Crypto.Secp256k1.Internal.Context.Ctx) Haskoin.Store.Data.EventCommand
+ Haskoin.Store.Data: instance Haskoin.Util.Marshal.MarshalJSON (Haskoin.Network.Data.Network, Crypto.Secp256k1.Internal.Context.Ctx) Haskoin.Store.Data.EventCommand
+ Haskoin.Store.WebClient: apiEventsAfter :: ApiConfig -> Event -> (Event -> IO Bool) -> IO (Either Except ())

Files

CHANGELOG.md view
@@ -6,6 +6,25 @@  ## [Unreleased] +## [1.11.0] - 2026-11-10++### Added++- `EventCommand`: what a client sends over the WebSocket at `/events` to+  choose the transactions it hears about. It watches addresses+  (`EventWatch`, `EventUnwatch`), extended public keys with a type of+  address (`EventWatchXPub`, `EventUnwatchXPub`), or what matches a Bloom+  filter as in BIP 37 (`EventFilterLoad`, `EventFilterAdd`,+  `EventFilterClear`); `EventAll` goes back to every transaction, and+  `EventNone` to blocks alone. It has+  `Marshal` for binary frames and `MarshalJSON` for text frames, both given+  the network. A key is written in binary as in BIP 32, in the 78 bytes+  that its text decodes to. Servers understand it from the release of+  haskoin-store after 1.8.1.+- `apiEventsAfter` listens to the events that came after one that the+  client names, so that a client that lost its connection misses nothing.+  The server answers `ThingNotFound` when it no longer has that event.+ ## [1.10.0] - 2026-11-10  ### Added
README.md view
@@ -15,7 +15,7 @@ - `Haskoin.Store.WebCommon`: the requests of the API, one type per endpoint,   with their paths and parameters. - `Haskoin.Store.WebClient`: `apiCall`, which sends a request and decodes-  the answer.+  the answer, and `apiEvents`, which listens to the events of a server.  ## Example @@ -40,6 +40,40 @@ encoding too, so a server can answer a binary request with a binary error. The client reads the `Content-Type` of an error to tell which it got, and so works with servers of either kind.++## Events++`apiEvents` calls a function with each new block and each transaction that+enters or leaves the mempool, for as long as the function answers `True`.+After a lost connection, `apiEventsAfter` takes up from the last event+heard, so nothing is missed; if the server no longer has that event it+answers `ThingNotFound`, and the client reads what it follows again.++Over the WebSocket at `/events` a client can also choose the transactions+it hears about. `EventCommand` is what it sends: addresses to watch,+extended public keys (one command for each type of address), or a Bloom+filter as in BIP 37. It has a binary encoding and a JSON one, for binary+and text frames. This package has no WebSocket client of its own; send the+encoded command with the one you use:++```haskell+import Haskoin (marshalJSON)+import Haskoin.Store.Data (EventCommand (..))++-- a text frame: {"type":"watch","addresses":["bc1q…"]}+frame = marshalJSON (net, ctx) (EventWatch [addr])+```++Blocks are always told. A connection that has sent no command hears of+every transaction, unless its URL says what to watch, or `quiet=true` for+blocks alone.++The bytes of every type, for someone who writes a client in another+language, are in the "Binary format" section of+[`swagger.yaml`](https://github.com/jprupp/haskoin-store/blob/master/swagger.yaml)+in the haskoin-store repository. In short: integers are big-endian, a list+is its length as 64 bits and then its items, and the events of `/events`+are the one thing sent without a length.  ## Building 
haskoin-store-data.cabal view
@@ -5,7 +5,7 @@ -- see: https://github.com/sol/hpack  name:           haskoin-store-data-version:        1.10.0+version:        1.11.0 synopsis:       Data types and API client of Haskoin Store description:    The types of the data that Haskoin Store serves (blocks, transactions,                 balances, unspent outputs, extended public keys, health checks, events and
src/Haskoin/Store/Data.hs view
@@ -87,6 +87,7 @@     MaxHealth (..),     HealthCheck (..),     Event (..),+    EventCommand (..),     Except (..),      -- * Blockchain.info API@@ -137,7 +138,7 @@ import Control.Applicative (optional, (<|>)) import Control.DeepSeq (NFData) import Control.Exception (Exception)-import Control.Monad (guard, join, mzero, unless)+import Control.Monad (guard, join, mzero, unless, when) import Data.Aeson   ( FromJSON (..),     ToJSON (..),@@ -178,6 +179,7 @@     mapMaybe,     maybeToList,   )+import Data.Sequence qualified as Seq import Data.Serialize (Serialize (..)) import Data.String.Conversions (cs) import Data.Text (Text)@@ -193,7 +195,7 @@     parseTimeM,   ) import Data.Vector qualified as V-import Data.Word (Word32, Word64)+import Data.Word (Word32, Word64, Word8) import GHC.Generics (Generic) import Haskoin import Text.Read (readMaybe)@@ -1999,6 +2001,181 @@           i <- o .: "id"           return $ EventBlock i         _ -> fail $ "Could not recognize event type: " <> t++-- | What a client says to the server over the WebSocket at @\/events@, to+-- choose the transactions it hears about.+--+-- A wallet cares for its own transactions, and a busy mempool has thousands+-- of others, each an event that costs the client a request to find out it+-- was not for it. With these the server does the sorting: it knows the body+-- of every transaction as it comes in, and tells a client only of those that+-- touch what the client watches. It follows the three ways a wallet has of+-- saying what is its own: a list of addresses, an extended public key, and+-- the Bloom filter of BIP 37 that SPV clients send to a node, which hides+-- the addresses among false positives.+--+-- A connection starts unfiltered and hears of every transaction, as it did+-- before these existed, unless the URL that opened it says otherwise. The+-- first of these commands, other than 'EventAll', turns it into a filtered+-- one, which hears of a transaction+-- when it matches the filter or pays to or spends from a watched address or+-- one derived from a watched key. Blocks are always reported: there are few,+-- and they are how a client knows that its transactions confirmed.+--+-- There are no answers. As between a node and an SPV client, a command the+-- server cannot accept ends the connection, with the reason in the close+-- frame.+--+-- In binary a command is one byte, the number given with each below, and+-- then what it carries ('Marshal'). In JSON it is an object with a @type@+-- ('MarshalJSON'). A key is watched under one type of address; a wallet+-- that uses several sends a command for each.+data EventCommand+  = -- | @0x00@, @{"type":"watch","addresses":[...]}@: watch these+    -- addresses as well; a counted list in binary+    EventWatch ![Address]+  | -- | @0x01@, @{"type":"unwatch","addresses":[...]}@: stop watching+    -- these addresses+    EventUnwatch ![Address]+  | -- | @0x02@, @{"type":"watch","xpub":"...","derive":"segwit"}@: watch+    -- the addresses of this key as well, those in use and the gap after+    -- them, which moves on as they get used; @derive@ may be left out for+    -- legacy addresses; in binary the 78 bytes of the key as in BIP 32 and+    -- a byte for the type, @00@ legacy, @01@ compat, @02@ segwit+    EventWatchXPub !XPubSpec+  | -- | @0x03@, @{"type":"unwatch","xpub":"...","derive":"segwit"}@: stop+    -- watching this key+    EventUnwatchXPub !XPubSpec+  | -- | @0x04@,+    -- @{"type":"filterload","filter":"hex","functions":n,"tweak":n,"flags":n}@:+    -- set the Bloom filter of the connection, in place of the one it had;+    -- in binary it is laid out as in the @filterload@ message of the+    -- Bitcoin protocol+    EventFilterLoad !BloomFilter+  | -- | @0x05@, @{"type":"filteradd","data":"hex"}@: add an element of at+    -- most 'maxFilterAddSize' bytes to the filter; bytes with their+    -- length in binary+    EventFilterAdd !ByteString+  | -- | @0x06@, @{"type":"filterclear"}@: remove the filter; the addresses+    -- and keys stay watched+    EventFilterClear+  | -- | @0x07@, @{"type":"all"}@: forget everything watched and the filter,+    -- and hear of every transaction again+    EventAll+  | -- | @0x08@, @{"type":"none"}@: forget everything watched and the filter,+    -- and hear of no transaction, only of blocks, until something is+    -- watched+    EventNone+  deriving (Show, Eq, Generic, NFData)++-- | The binary form, for binary frames. It takes the network and the+-- context because an extended public key is written as BIP 32 writes it,+-- in the 78 bytes that the Base58Check of its text decodes to, which every+-- wallet library has: the bytes that the database uses for a key are those+-- of one C library, and no client in another language could make them.+instance Marshal (Network, Ctx) EventCommand where+  marshalPut s = \case+    EventWatch as -> putWord8 0x00 >> putList serialize as+    EventUnwatch as -> putWord8 0x01 >> putList serialize as+    EventWatchXPub x -> putWord8 0x02 >> marshalPut s x.key >> serialize x.deriv+    EventUnwatchXPub x -> putWord8 0x03 >> marshalPut s x.key >> serialize x.deriv+    EventFilterLoad f -> putWord8 0x04 >> serialize f+    EventFilterAdd d -> putWord8 0x05 >> putLengthBytes d+    EventFilterClear -> putWord8 0x06+    EventAll -> putWord8 0x07+    EventNone -> putWord8 0x08+  marshalGet s =+    getWord8 >>= \case+      0x00 -> EventWatch <$> getList deserialize+      0x01 -> EventUnwatch <$> getList deserialize+      0x02 -> EventWatchXPub <$> xpub+      0x03 -> EventUnwatchXPub <$> xpub+      0x04 -> EventFilterLoad <$> deserialize+      0x05 -> EventFilterAdd <$> (filterElement =<< getLengthBytes)+      0x06 -> return EventFilterClear+      0x07 -> return EventAll+      0x08 -> return EventNone+      _ -> fail "Not an EventCommand"+    where+      xpub = do+        key <- marshalGet s+        deriv <-+          getWord8 >>= \case+            0x00 -> return DeriveNormal+            0x01 -> return DeriveP2SH+            0x02 -> return DeriveP2WPKH+            _ -> fail "Unknown derivation"+        return XPubSpec {..}++-- | An element for a Bloom filter, refused when longer than a node takes.+filterElement :: (MonadFail m) => ByteString -> m ByteString+filterElement d+  | B.length d > maxFilterAddSize = fail "Filter element too long"+  | otherwise = return d++-- | The text of an address depends on the network, and that of an extended+-- public key needs the context of the library that checks it.+instance MarshalJSON (Network, Ctx) EventCommand where+  marshalValue (net, ctx) = \case+    EventWatch as -> addresses "watch" as+    EventUnwatch as -> addresses "unwatch" as+    EventWatchXPub x -> xpub "watch" x+    EventUnwatchXPub x -> xpub "unwatch" x+    EventFilterLoad f ->+      A.object+        [ "type" .= String "filterload",+          "filter" .= encodeHex (B.pack (toList f.array)),+          "functions" .= f.functions,+          "tweak" .= f.tweak,+          "flags" .= B.head (runPutS (serialize f.flags))+        ]+    EventFilterAdd d ->+      A.object ["type" .= String "filteradd", "data" .= encodeHex d]+    EventFilterClear -> A.object ["type" .= String "filterclear"]+    EventAll -> A.object ["type" .= String "all"]+    EventNone -> A.object ["type" .= String "none"]+    where+      addresses t as =+        A.object ["type" .= String t, "addresses" .= map (marshalValue net) as]+      xpub t x =+        A.object+          [ "type" .= String t,+            "xpub" .= marshalValue (net, ctx) x.key,+            "derive" .= deriveTypeToText x.deriv+          ]++  unmarshalValue (net, ctx) =+    A.withObject "EventCommand" $ \o ->+      o .: "type" >>= \case+        "watch" -> watched o EventWatch EventWatchXPub+        "unwatch" -> watched o EventUnwatch EventUnwatchXPub+        "filterload" -> do+          bytes <- hex =<< o .: "filter"+          when (B.length bytes > maxBloomSize) $ fail "Filter too large"+          functions <- o .: "functions"+          when (functions > maxHashFuncs) $ fail "Too many hash functions"+          tweak <- o .:? "tweak" .!= 0+          flags <- o .:? "flags" .!= (0 :: Word8) >>= flag+          return . EventFilterLoad $+            BloomFilter {array = Seq.fromList (B.unpack bytes), ..}+        "filteradd" -> fmap EventFilterAdd $ o .: "data" >>= hex >>= filterElement+        "filterclear" -> return EventFilterClear+        "all" -> return EventAll+        "none" -> return EventNone+        t -> fail $ "Could not recognize command type: " <> t+    where+      watched o as xs =+        o .:? "xpub" >>= \case+          Just v -> do+            key <- unmarshalValue (net, ctx) v+            name <- o .:? "derive" .!= "legacy"+            deriv <- maybe (fail "Unknown derivation") return (textToDeriveType name)+            return $ xs XPubSpec {..}+          Nothing -> fmap as $ o .: "addresses" >>= mapM (unmarshalValue net)+      hex = maybe (fail "Not hexadecimal") return . decodeHex+      flag n+        | n > 2 = fail "Unknown filter flags"+        | otherwise = either fail return $ runGetS deserialize (B.singleton n)  -- | Response that wraps a single value, encoded in JSON as @{\"result\": value}@. newtype GenericResult a = GenericResult {get :: a}
src/Haskoin/Store/WebClient.hs view
@@ -30,6 +30,7 @@     apiCall,     apiBatch,     apiEvents,+    apiEventsAfter,      -- * Blocks     GetBlock (..),@@ -104,6 +105,7 @@ import Data.String.Conversions (cs) import Data.Text (Text) import Data.Text qualified as Text+import Haskoin.Block (blockHashToHex) import Haskoin.Crypto (Ctx) import Haskoin.Network import Haskoin.Store.Data qualified as Store@@ -204,14 +206,34 @@ -- -- > apiEvents def $ \event -> print event >> return True apiEvents :: ApiConfig -> (Store.Event -> IO Bool) -> IO (Either Store.Except ())-apiEvents (ApiConfig net apiHost) handle = do+apiEvents conf = listenEvents conf Nothing++-- | 'apiEvents' from an event on: the function is called with what came+-- after it. A client that lost its connection calls this with the last+-- event it heard, and misses nothing.+--+-- The server keeps its last ten thousand events. When it no longer has the+-- one given, or never had it, the answer is 'Store.ThingNotFound': the+-- client cannot know what it missed, and has to read what it follows again+-- and listen with 'apiEvents'. A transaction has an event when it enters+-- the mempool and another if it leaves unconfirmed, both with its hash, and+-- the server starts after the first: some events may be told twice, none+-- is left out. In a fleet any server takes up from an event of another.+apiEventsAfter :: ApiConfig -> Store.Event -> (Store.Event -> IO Bool) -> IO (Either Store.Except ())+apiEventsAfter conf = listenEvents conf . Just++-- | The events of a server, from now or from after one of them.+listenEvents :: ApiConfig -> Maybe Store.Event -> (Store.Event -> IO Bool) -> IO (Either Store.Except ())+listenEvents (ApiConfig net apiHost) after handle = do   r <- try (try (HTTP.foldGetWith (binaryOpts mempty) step BS.empty url))   return $ case r of     Left Stopped -> Right ()     Right (Left e) -> Left e     Right (Right _) -> Right ()   where-    url = apiHost <> net.name <> "/events"+    url = apiHost <> net.name <> "/events" <> maybe "" (("?after=" <>) . cs . name) after+    name (Store.EventBlock h) = blockHashToHex h+    name (Store.EventTx h) = txHashToHex h     -- an event is a byte that says what it is and a hash     size = 33     step pending chunk = go (pending <> chunk)
test/Haskoin/Store/DataSpec.hs view
@@ -22,6 +22,7 @@ import Haskoin.Store.Laws import Haskoin.Util.Arbitrary import Test.Hspec+import Test.Hspec.QuickCheck (prop) import Test.QuickCheck  spec :: Spec@@ -81,6 +82,11 @@     jsonLaws (arbitrary :: Gen BinfoHeader)     jsonLaws (arbitrary :: Gen BinfoBlockInfos) +  describe "binary of a network" $+    prop "EventCommand" . forAll (elements allNets) $ \net ->+      forAll (arbitraryEventCommand net ctx) $ \x ->+        unmarshal (net, ctx) (marshal (net, ctx) x) === Right x+   -- Balance, Unspent, StoreInput, StoreOutput and the inputs and outputs of   -- BinfoTx are encoded through their own instances inside these.   describe "JSON of a network" $ do@@ -89,6 +95,7 @@     marshalLaws id arbitraryXPubUnspent     marshalLaws id arbitraryBlockData     marshalLaws id arbitraryTransaction+    marshalLaws withCtx (`arbitraryEventCommand` ctx)     marshalLaws withCtx (`arbitraryBinfoMultiAddr` ctx)     marshalLaws withCtx (`arbitraryBinfoBalance` ctx)     marshalLaws withCtx (`arbitraryBinfoBlock` ctx)
test/Haskoin/Store/Gen.hs view
@@ -14,6 +14,7 @@     arbitraryStoreOutput,     arbitraryTransaction,     arbitraryXPubSpec,+    arbitraryEventCommand,     arbitraryDeriveType,     arbitraryBalance,     arbitraryUnspent,@@ -268,6 +269,21 @@       [ EventBlock <$> arbitraryBlockHash,         EventTx <$> arbitraryTxHash       ]++-- | A command for the events of a network, of every kind.+arbitraryEventCommand :: Network -> Ctx -> Gen EventCommand+arbitraryEventCommand net ctx =+  oneof+    [ EventWatch <$> listOf (arbitraryAddress net),+      EventUnwatch <$> listOf (arbitraryAddress net),+      EventWatchXPub <$> arbitraryXPubSpec net ctx,+      EventUnwatchXPub <$> arbitraryXPubSpec net ctx,+      EventFilterLoad <$> arbitraryBloomFilter,+      EventFilterAdd . B.pack <$> (choose (0, maxFilterAddSize) >>= vector),+      return EventFilterClear,+      return EventAll,+      return EventNone+    ]  instance Arbitrary Except where   arbitrary =