haskoin-store 1.8.1 → 1.9.0
raw patch · 76 files changed
+37368/−8170 lines, 76 filesdep +case-insensitivedep +file-embeddep +processdep −scottydep ~QuickCheckdep ~aesondep ~aeson-prettysetup-changedPVP ok
version bump matches the API change (PVP)
Dependencies added: case-insensitive, file-embed, process, wai-extra, warp-tls
Dependencies removed: scotty
Dependency ranges changed: QuickCheck, aeson, aeson-pretty, base, base16, base64, bytes, bytestring, cereal, conduit, containers, data-default, deepseq, fast-logger, filepath, hashable, hashtables, haskoin-core, haskoin-node, haskoin-store-data, hedis, hspec, http-types, lens, monad-control, mtl, network, nqe, optparse-applicative, random, rocksdb-haskell-jprupp, rocksdb-query, statsd-rupp, stm, string-conversions, text, time, transformers, unliftio, unordered-containers, vault, vector, wai, wai-websockets, warp, websockets, wreq
API changes (from Hackage documentation)
Files
- CHANGELOG.md +2828/−0
- README.md +936/−0
- Setup.hs +0/−2
- app/Main.hs +147/−98
- bench/Main.hs +20/−20
- haskoin-store.cabal +418/−184
- integration/Haskoin/Store/Integration/Image.hs +196/−0
- integration/Haskoin/Store/Integration/Node.hs +237/−0
- integration/Haskoin/Store/Integration/Podman.hs +190/−0
- integration/Haskoin/Store/Integration/Valkey.hs +57/−0
- src/Haskoin/Store.hs +42/−25
- src/Haskoin/Store/BlockStore.hs +1217/−1498
- src/Haskoin/Store/BlockStore/Pending.hs +166/−0
- src/Haskoin/Store/BlockStore/Sync.hs +178/−0
- src/Haskoin/Store/Cache.hs +1534/−1761
- src/Haskoin/Store/Cache/Protocol.hs +122/−0
- src/Haskoin/Store/Cache/lua/bodies.lua +25/−0
- src/Haskoin/Store/Cache/lua/commit.lua +92/−0
- src/Haskoin/Store/Cache/lua/delete.lua +18/−0
- src/Haskoin/Store/Cache/lua/evict.lua +23/−0
- src/Haskoin/Store/Cache/lua/page.lua +39/−0
- src/Haskoin/Store/Cache/lua/spends.lua +20/−0
- src/Haskoin/Store/Cache/lua/unlock.lua +5/−0
- src/Haskoin/Store/Common.hs +266/−217
- src/Haskoin/Store/Database/Mempool.hs +505/−0
- src/Haskoin/Store/Database/Migrate.hs +678/−0
- src/Haskoin/Store/Database/Query.hs +696/−0
- src/Haskoin/Store/Database/Query/Layered.hs +807/−0
- src/Haskoin/Store/Database/Query/Pooled.hs +518/−0
- src/Haskoin/Store/Database/Reader.hs +173/−504
- src/Haskoin/Store/Database/Types.hs +412/−55
- src/Haskoin/Store/Database/Views.hs +136/−0
- src/Haskoin/Store/Database/Writer.hs +384/−153
- src/Haskoin/Store/Fleet.hs +201/−0
- src/Haskoin/Store/Fleet/Member.hs +1078/−0
- src/Haskoin/Store/Fleet/Pool.hs +320/−0
- src/Haskoin/Store/Fleet/Redis.hs +400/−0
- src/Haskoin/Store/Fleet/XPub.hs +642/−0
- src/Haskoin/Store/Fleet/lua/move.lua +74/−0
- src/Haskoin/Store/Fleet/lua/pool.lua +477/−0
- src/Haskoin/Store/Fleet/lua/read.lua +172/−0
- src/Haskoin/Store/Fleet/lua/report.lua +54/−0
- src/Haskoin/Store/Fleet/lua/xpub.lua +401/−0
- src/Haskoin/Store/Logic.hs +313/−275
- src/Haskoin/Store/Logic/Layered.hs +792/−0
- src/Haskoin/Store/Manager.hs +211/−154
- src/Haskoin/Store/Web.hs +3566/−3076
- src/Haskoin/Store/Web/Docs.hs +88/−0
- src/Haskoin/Store/Web/Events.hs +380/−0
- src/Haskoin/Store/Web/Router.hs +231/−0
- stress/Main.hs +913/−0
- stress/wrk.lua +32/−0
- swagger.yaml +8042/−0
- test/Haskoin/Store/BlockStore/PendingSpec.hs +94/−0
- test/Haskoin/Store/BlockStore/SyncSpec.hs +129/−0
- test/Haskoin/Store/BlockStoreSpec.hs +0/−16
- test/Haskoin/Store/Cache/ProtocolSpec.hs +158/−0
- test/Haskoin/Store/CacheSpec.hs +30/−8
- test/Haskoin/Store/CommonSpec.hs +154/−7
- test/Haskoin/Store/Database/MigrateSpec.hs +490/−0
- test/Haskoin/Store/Database/Query/LayeredSpec.hs +173/−0
- test/Haskoin/Store/Database/ViewsSpec.hs +67/−0
- test/Haskoin/Store/Fleet/MemberSpec.hs +312/−0
- test/Haskoin/Store/Fleet/PoolSpec.hs +182/−0
- test/Haskoin/Store/Fleet/RedisSpec.hs +423/−0
- test/Haskoin/Store/Fleet/XPubSpec.hs +77/−0
- test/Haskoin/Store/FleetSpec.hs +199/−0
- test/Haskoin/Store/Histories.hs +783/−0
- test/Haskoin/Store/IntegrationSpec.hs +1958/−0
- test/Haskoin/Store/Logic/LayeredSpec.hs +264/−0
- test/Haskoin/Store/LogicSpec.hs +95/−79
- test/Haskoin/Store/TestBlocks.hs +0/−4
- test/Haskoin/Store/Web/EventsSpec.hs +191/−0
- test/Haskoin/Store/Web/RouterSpec.hs +75/−0
- test/Haskoin/Store/WebSpec.hs +20/−5
- test/Haskoin/StoreSpec.hs +22/−29
@@ -0,0 +1,2828 @@+# Changelog+All notable changes to this project will be documented in this file.++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]++## [1.9.0] - 2026-11-10++### Added++- `--fleet` (`FLEET`): servers that share a Redis answer as one. Every+ request is answered from the newest block that all live servers have and+ from one mempool that they share in Redis, so that the same request gets+ the same answer whichever server a load balancer picks, also while a+ block or a transaction is on its way through them.+ - Each server keeps its database as it was at each of its last six+ blocks, reports them under a lease of ten seconds, and offers its+ unconfirmed transactions to the shared pool. The fleet tip moves in one+ step, together with what the blocks in between did to the pool.+ - A server that does not have the block that the fleet answers from says+ 503 with `behind-fleet`. One whose health check fails stops reporting,+ and stops counting when its lease runs out.+ - Without Redis a server answers from its own database and `/health` says+ 203.+ - `Haskoin.Store.Manager` runs the member (`Store.fleet`), the block+ store takes a view of the database right after each block+ (`BlockStoreConfig.onBest`), and the web server answers through+ `Haskoin.Store.Database.Query.Pooled`.+ - An integration test runs two servers as a fleet against a real node and+ Redis and compares their answers with a payment in the pool and after+ the block that confirms it.+ - `docs/layered-database/FleetCost.hs` times a store alone against the+ same store in a fleet, with 40,000 transactions in the pool: a fleet+ adds about one round trip to Redis to a request. A request is answered+ from the tip that the member last read, which its first read of the+ pool checks; a transaction's body comes with what spends its outputs;+ and a first page is as large as the page asked for.++- A cache of xpubs for a fleet (`Haskoin.Store.Fleet.XPub`), under the+ options of the cache of a server alone: `--redis-min-addrs`,+ `--redis-min-txs` and `--redis-max-xpubs`. Until now `--fleet` answered+ every request about an xpub from the database, with a read of the shared+ pool for each address that it touches.+ - An entry has the chain's part of a wallet at one block, which is the+ same for the whole cache, and the part of the shared pool, which the+ script of the pool keeps as it changes the pool. Transactions and+ outputs of both are in one sorted set each, in the order of the API.+ - The cache answers only while its block is the fleet tip. The server+ that moves the tip brings the cache along, with what the new blocks+ did at the addresses that entries have. If it does not, or after a+ reorganisation, the cache is emptied.+ - The cache is at a move of the fleet tip, which the pool counts, and+ not just at a block: the tip can leave a block and come back to it.+ - Every read of balances runs the scan of the xpub over the entry and+ misses unless it reads exactly the addresses of the entry, so a+ payment in the pool to an unused address cannot leave the gap wrong.+ - Entries are built in the background by the server that missed, and+ extended when an xpub gains addresses.+ - A request about a wallet of 464 addresses and 19,499 transactions+ takes 1.3 to 2.2 milliseconds through it, where a fleet took 5 to 38;+ for a wallet with 400 addresses in the pool, 3 to 4 where it took 5 to+ 126 (`docs/layered-database/fleet-cost.txt`).+ - The test of the member compares every answer about a wallet through+ the cache with the uncached one after each step of random histories,+ and the two servers of the integration test do the same over HTTP.+ - StatsD metrics under `fleet.cache.*`: hits, misses, builds and their+ time, builds given up, evictions, and the times that the cache was+ emptied. The log says why the cache was emptied each time, and with+ `--debug` has a line for each entry built and each block that the+ cache was brought to.+- Built with `rocksdb-haskell-jprupp` 2.7.1, and every scan by address+ uses an iterator that RocksDB stops where the address ends, so that a+ scan cannot walk over deleted keys that follow it. The migration reads+ the transactions with an iterator that is asked for the order of them+ all.+- Built with `haskoin-node` 2.5.0, which has the fixes of its security+ review: among them, a block from a peer is checked against its header+ before the store sees it, so a peer can no longer pass off transactions+ of its choice under a real header. It also allows the new binding. On+ regtest its discovery no longer finds a node on this machine: name the+ node with `--peer`.+- **`/events` agrees across a fleet.** The servers of a fleet send the+ events of the block and the mempool that they share, read from a stream+ in Redis that the pool's script writes as it makes each change, and no+ longer those of their own nodes. A block is told when the fleet has+ moved to it, with every block that it moved through.+- **`/events` takes up where a client left.** `?after=` with the hash of+ the last event heard starts the stream after it, over HTTP and over the+ WebSocket. The server keeps its last 10,000 events in one log that all+ clients read (`Haskoin.Store.Web.Events`), in place of a queue for each.+ It answers 404 when it does not have the event. The servers of a fleet+ keep the same events, so a client may come back to another one.+- **A WebSocket client chooses the transactions it hears about**: by+ address, by extended public key with a type of address, or with a Bloom+ filter as in BIP 37, with the commands of `EventCommand` in+ `haskoin-store-data` 1.11. Blocks are always sent, and a client that+ sends no command hears everything, as before. A key is watched up to its+ gap, which moves on with its payments.+- **The choice can be in the URL of `/events`**, so that it holds from+ the first event: `quiet=true` for blocks alone, `addresses`, `xpub` with+ `derive` (one type or several), and `filter` with `functions`, `tweak`+ and `flags`. The HTTP stream takes them too, also under `POST` with a+ form. `{"type":"none"}` goes back to blocks alone.+- **The WebSocket speaks binary too**: binary frames when the handshake+ asks for binary or the client sends a binary frame, for events and+ commands alike.+- **HTTPS**, optional and off by default: `--tls-cert` and `--tls-key`+ (`TLS_CERT`, `TLS_KEY`) make the server speak HTTPS on its port.+- **The reference of the API at the root of the server.** `/` is a page+ that shows `swagger.yaml` as it was when the server was built, and can+ try requests against it; `/swagger.yaml` is the file.+- **`HEAD` and `OPTIONS`** are answered: the headers of what `GET` gives,+ and what a browser may send from another site.+- **Answers are compressed** with gzip for clients that accept it, but for+ the stream of events.+- **Lists in a body.** The endpoints that take a list of addresses,+ blocks, heights or transactions accept `POST` with the parameters in a+ form, so that a long list need not fit in a URL.+- **`Retry-After`** on every 503.+- **Errors in binary.** A request that asks for the binary format gets+ its errors in that format too, the 503 of a server behind its fleet+ included. They were JSON whatever was asked. This needs+ `haskoin-store-data` 1.10, whose client reads both.+- **The `Accept` header is read as a list with weights.** Binary is sent+ when `application/octet-stream` is listed with a weight above 0 that is+ no less than that of `application/json`. The header used to be compared+ with that one string, so a list or a weight silently gave JSON. Every+ answer has `Vary: Accept`.+- **The raw lists start with a count**, in binary: `/transactions/raw`,+ `/block/{block}/transactions/raw` and `/block/height/{height}/raw` were+ the one kind of list without it. A binary client of those three+ endpoints has to change with the server.+- `haskoin-store --help` names the environment variable of every option.+- `swagger.yaml` documents the binary format: how to ask for it, the+ encoding of each type that the API returns, and what differs from JSON.+- Gauges `fleet.cache.xpubs` and `fleet.cache.addresses`: how many xpubs+ the cache of a fleet holds, and how many addresses between them.++### Changed++- A WebSocket client of `/events` that sends something the server cannot+ read as a command is disconnected, with code 1008 and the reason. What+ clients sent used to be ignored.+- The HTTP stream of `/events` no longer holds a view of the database for+ as long as its client listens.++- Since version 2.6.4 the binding reads the key and the value of a row of+ an iterator without the cost of a safe foreign call.+ Every list and every count takes between a half and a third of the time:+ the first 100 transactions of a busy address 221 microseconds instead of+ 575, and the number of transactions of a wallet with 19,499 of them 34+ milliseconds instead of 78.+- **Bloom filters are on by default**, as was always meant: until the+ binding was fixed, `--bloom` built none. `--no-bloom`, or `BLOOM=false`,+ turns them off; `--bloom` is still accepted. An existing database gets+ filters, and the memory that they take in the block cache, as its table+ files are written again.+- **Transactions that leave the mempool unconfirmed are no longer kept.**+ The API already answered 404 for them, but their bodies stayed in the+ database, marked as deleted, for ever, and each server had its own.+ - A server remembers the last hundred thousand that it deleted, in+ memory, so as not to fetch them again from its peers. The fleet's rule+ for conflicting transactions and the cache of a server alone, which+ both asked the database whether a transaction had been deleted, ask+ that memory instead.+ - The cache of a server alone takes a transaction that Redis has as+ unconfirmed for more than ten minutes, and that the database does not+ have, for deleted and not for one that it has not seen yet.+ - Bodies that version 20 marked as deleted stay in a migrated database+ and are ignored. A page that starts at a deleted transaction is empty.+- Removed reports that no longer describe the store: five intermediate+ stress runs, the measurement of a mempool mirrored in memory, and the+ superseded runs in `docs/layered-database/query-cost.txt`.+ `docs/layered-database.md` is rewritten as a description of what+ exists, a quarter of its length.+- **Database version 22: the record of a confirmed transaction is written+ once**, by the block that confirms it, and is the same on every server+ that has the block, so that what the chain's column families hold+ follows from the chain alone.+ - **Its time is the time of its block.** A server that had seen a+ transaction in its mempool used to keep the time of that when a block+ confirmed it: two servers then gave two values of `time` for the same+ confirmed transaction, which the servers of a fleet must not do. A+ transaction that returns to the mempool when its block leaves the main+ chain has the time of that block there.+ - **The spenders of its outputs are under keys of their own**, in the+ `spender` column family, sixteen outputs to a key (`SpenderKey`).+ They were in the record, which each block that spent one of its+ outputs read and wrote whole again. A spend now reads and writes a+ value of a few dozen bytes and leaves the record alone.+ - Reading a confirmed transaction in full takes a second key: 18+ microseconds where it took 11.5, and 0.8 milliseconds for a hundred+ where it took 0.4. Lists by address and by xpub cost the same, and so+ does writing, on the small records of the measurements.+ - A database of version 21 is migrated the first time the store opens+ it (`migrateRecords`). **It reads every transaction of the database+ and writes nearly every one again**, since nothing lists those that+ need it and most have a spender in them: many hours for a database of+ Bitcoin, and room for a second copy of its transactions until RocksDB+ has compacted the first away. The log says how far it is, and it can+ be interrupted and started again.+ - The tests that run the store through random histories now choose them+ from a database of version 20 that goes through the same+ (`withShadow`). Given a layered database, the chooser found next to+ nothing in the mempool, and the blocks of the tests of the fleet+ confirmed almost none of it.+- **Database version 21: the mempool has an area of its own.** Unconfirmed+ transactions are no longer written into the indices of the chain. They+ are kept apart and applied on top when a query is answered+ (`docs/layered-database.md`), so the chain's data changes only when a+ block is imported or reverted.+ - A database of version 20 is migrated the first time the store opens it,+ and the log says how many mempool transactions were moved. **Older+ stores refuse the database afterwards**: copy a production database+ before upgrading.+ - The block store imports with the rules of `Haskoin.Store.Logic.Layered`.+ A block that spends an output of a transaction that is only in the+ mempool is refused as an orphan; no valid block does that.+ - Each request of the web server reads one snapshot of the database for+ everything it answers, so the parts of a response agree with each+ other. `routerScoped` in `Haskoin.Store.Web.Router` is what gives a+ request its snapshot.+ - `--wipe-mempool` deletes the keys of the mempool area, in less time+ than it took to delete each transaction by the rules, and it no longer+ keeps the wiped transactions as deleted ones.+ - `StoreRead` has a new query, `getAddressesTxCount`.+ - Programs that embed the store and read its database with+ `Haskoin.Store.Database.Query` have to move to+ `Haskoin.Store.Database.Query.Layered`: the old queries do not see the+ mempool of a database of version 21.+- Xpub addresses are derived up to an exact gap: every address up to the last+ used one of each chain, then exactly `--xpub-gap` unused ones, and always+ the first `--xpub-gap-init` addresses. Addresses used to be read 32 at a+ time with the gap checked between batches, so the store looked between 33+ and 64 addresses past the last used one, and started counting again at index+ 20. This is why api.haskoin.com saw a payment 34 addresses past the last+ used one, which wallets with a gap of 20 or 32 do not see. Payments that far+ away are no longer seen; raise `--xpub-gap` to see them.+- New Redis cache of xpubs (`Haskoin.Store.Cache`), replacing the old one:+ - Whenever a transaction touches a cached xpub, or a reorganisation changes+ one of its transactions, the store reads the whole xpub again from its+ database and replaces the entry in one Lua script, so cached answers are+ always answers of the database.+ - Everything a response needs is in Redis, transaction bodies included,+ shared between xpubs, with the spenders of their outputs kept up to date.+ A store that has not imported a transaction yet still serves it.+ - Several stores can share one Redis. Each change of a cached xpub is+ claimed and applied once, by the first store to see it; stores that catch+ up find their changes done and do no work. A store never replaces an entry+ that knows more than its database (a higher block, or a transaction it has+ not seen), so stores do not undo each other's work, and every entry is+ written together with the bodies it needs, so a store that is behind can+ serve it in full.+ - The rules for claims and for replacing entries are pure decisions+ (`Haskoin.Store.Cache.Protocol`). A model test runs several stores+ catching up with a chain in random interleavings against a model of+ Redis, and checks that they end with the entry of the tip, that no+ store replaces an entry with one that knows less, that each change is+ written once, and that stores catching up after the first write nothing.+ - A reload writes only the differences between the xpub in the database+ and its entry in Redis, read in chunks first, instead of rewriting the+ whole entry; its script checks that the entry did not change since.+ Rewriting an xpub of 15,000 transactions held Redis for up to 77 ms,+ during which it answered no other client. A store reloads up to four+ xpubs at once.+ - A page of the transactions or unspent outputs of a cached xpub is one+ round trip to Redis: a Lua script finds where it starts, from the+ position of the start transaction in the xpub itself, and reads it.+ It took four or five, and read the whole body of the start+ transaction to find its position.+ - A store marks a cached xpub as used at most every ten seconds, for+ eviction, instead of on every read, which made every read a write.+ - `--redis-min-txs` (`REDIS_MIN_TXS`, default 1000) also caches xpubs+ with at least that many transactions in their addresses, however few+ addresses they have.+ - Keys start with `hs2:<network>:`, so the cache can share a Redis with the+ cache of older versions and with stores of other networks.+ - `--redis-max-xpubs` (`REDIS_MAX_XPUBS`, default 10000) replaces+ `--redis-max-keys`: the least recently used xpubs past this many are+ evicted. `--redis-sync-interval` is removed.+ - StatsD metrics: `cache.hits`, `cache.misses`, `cache.reloads`,+ `cache.stale`, `cache.evictions` and `cache.reload` (milliseconds).+- `n_tx` of `/blockchain/multiaddr` counts every transaction of the shown+ addresses and xpubs, not only those on the page, and `n_tx_filtered` those+ that pass the filter. `n_tx` of `/blockchain/rawaddr` for an xpub is its+ number of transactions, not the size of the page.+- The web server runs directly on WAI and Warp, without Scotty. Routes,+ parameters and passing a request to the next route are in+ `Haskoin.Store.Web.Router`, a small router over WAI; a handler is a plain+ function from the state of the server and the request to a response in+ `IO`, and stops early by throwing an `Except`, which becomes the JSON+ error. Every response carries `Access-Control-Allow-Origin: *`, error+ responses included. The endpoints, their parameters and their responses+ are as before.+- No monad transformers, except conduits, and no functions polymorphic over+ `MonadIO` or `MonadUnliftIO`: everything runs in plain `IO`. Database queries are+ functions that take the `DatabaseReader` (`Haskoin.Store.Database.Query`);+ imports take a `Writer` handle (`Haskoin.Store.Database.Writer`,+ `Haskoin.Store.Logic`); the block store and the manager pass their state+ explicitly. The classes `StoreReadBase` and `StoreReadExtra` are replaced+ by `StoreRead`, a record of the same queries as functions in `IO`, which+ `databaseRead` gives for a database; `getTransaction` and the other+ helpers of `Haskoin.Store.Common` take it as their first argument. The state+ of the web server, which every handler takes as an argument instead of+ reading it from a `ReaderT`, holds the queries to answer with, the cache+ in front of those about xpubs;+ `nocache=true` gives a handler a state whose queries read only the+ database. `withStore`, `runWeb`, `runWebSocket` and the `blockStore*`+ functions are in `IO`. `WriterT`, `ImportM`, the `StoreWrite` class,+ `BlockT`, `CacheT`, `DatabaseReaderT` and `withDB` were removed earlier+ in this release. This changes the library API.+- The package uses the GHC2024 edition, with `DuplicateRecordFields`,+ `NoFieldSelectors`, `OverloadedRecordDot` and `OverloadedStrings` on for+ every module, and explicit deriving strategies.++- haskoin-store-data moves to its own repository,+ [jprupp/haskoin-store-data](https://github.com/jprupp/haskoin-store-data),+ with its history and its own releases, and haskoin-store moves to the root+ of this one; `store/` and `data/` are gone. haskoin-store depends on+ `haskoin-store-data ^>= 1.9`. The package has a full description, PVP+ upper bounds on every dependency, and no `Setup.hs`.+- Legacy addresses are called `legacy` in the `derive` parameter, the default.+ `normal`, the name they had, is still accepted, so old URLs keep working;+ it said nothing about the kind of address. Error messages use the new+ name. Built with haskoin-store-data at commit `b304acd2`.+- The executable gives memory back to the system as soon as its heap no+ longer needs it (`--disable-delayed-os-memory-return` in its default RTS+ options). By default GHC marks such memory free for the kernel to take when+ it runs short, and Linux counts it as resident until then, so the heap of a+ busy store seemed to grow without end, by the size of each peak, with little+ live data. The stress test records the resident GHC heap and how much of it+ is lazily freed. The heap profile and the runs that found this are in+ `stress/results/2026-11-09-memory`, and `scripts/heap-profile-summary.py`+ summarises a heap profile.+- The cache brings an entry up to date from what changed, instead of reading+ the whole xpub from RocksDB and Redis on every change, which the stress test+ showed was almost all the time of a reload, growing to seconds at 100,000+ transactions per xpub. A reload reads the changed transactions, the outputs+ they create and spend at addresses of the xpub, and the balances of its+ addresses, which are compared with Redis's to catch a change the entry+ missed. The whole xpub is still read when it is first cached, when the+ entry is more than a block behind, when an address with transactions enters+ or leaves the gap, when a balance does not match, and every ten minutes.+ Entries keep an index of their unspent outputs by outpoint+ (`x:<id>:uop`), in format version 3; entries of version 2 are upgraded by+ their next reload. New metric `cache.full_reloads`.+- A reload whose balances show changes it was not told of first looks for+ them, instead of reading the whole xpub: the transactions that the database+ has for the addresses whose balances moved, in the mempool or in blocks+ after the one the entry was written at, and the mempool transactions of the+ entry that have since been confirmed or dropped, up to 1,000. Under load the+ database is often ahead of the events being applied, which made about one+ reload in ten of the stress test a full one.+- Bodies that Redis already has, such as those of the transactions that a+ block confirms, are rewritten before the commit of a reload, 200 to a+ script, instead of in it. The commit of a block that confirms a thousand+ transactions of an xpub had about 8,000 arguments and held Redis for 10 to+ 30 ms, the only commands of the stress test that took that long.+- The scheduled full reload of a cached xpub comes every five to ten+ minutes, an interval that depends on the xpub, instead of every ten for+ all. Xpubs cached together were read whole together each time: in the full+ stress test, four xpubs of tens of thousands of transactions raised the+ heap from under 100 MiB to 785 MiB for a few seconds every ten minutes.+- `docs/cache-consistency.md` analyses how a cached xpub can differ from the+ database without any check noticing, including spenders of cached+ transactions that no reload repairs, and what would prevent it.+- `docs/layered-database.md`: a design for keeping the mempool out of the+ chain's indices, in an area of its own that is applied on top when a query+ is answered, with the measurements behind it and what it means for the+ cache. The store still runs on the layout of version 20; this adds the+ parts that could be proved ahead of the switch:+ - `Haskoin.Store.Database.Types` has the keys of the mempool area+ (`MemSpenderKey`, `MemAddrKey`, `MemDeletedKey`) and a marker for+ migrations that work in several writes (`MigrationKey`).+ - `Haskoin.Store.Database.Mempool` has the keys that one mempool+ transaction contributes, and what the mempool does to the balance and+ the unspent outputs of an address.+ - `Haskoin.Store.Database.Migrate` moves a database of version 20 to the+ layered layout without a resync: it takes every mempool transaction out+ of the chain's indices and writes it to the mempool area, in atomic+ batches that can be interrupted. Nothing calls it yet.+ - `withLayeredReader` opens a database with the extra column family,+ `mempool-addr`, that the layout needs.+ - Tests build databases with the store's own rules from random histories+ of payments, blocks, replacements, deletions and reorganisations,+ migrate them, and check that every query answers as before, that the+ chain's column families are those of a database that never saw a+ mempool, and that emptying the mempool area leaves the chain's answers.+- `Haskoin.Store.Logic.Layered`: the rules that import and revert blocks+ and add, replace and delete mempool transactions in the layered layout of+ `docs/layered-database.md`, with the functions, results and errors of+ `Haskoin.Store.Logic`. Blocks are written to the chain's column families+ alone, and the mempool to its own area. The store does not use them yet.+ - `Haskoin.Store.Database.Writer` can write the mempool area in the same+ atomic batch as the chain; batches of today's rules write nothing there.+ - Tests give the same random history to both sets of rules, on Bitcoin and+ on Bitcoin Cash: payments, replacements, deletions, blocks in and out of+ order, reorganisations, and transactions and blocks that must be+ refused. Every operation must have the same result and move the same+ transactions in and out of the mempool, every query must answer the same+ after it, and at the end the database of today's rules, once migrated,+ must equal that of the new rules key for key.+ - One difference is on purpose: a block that spends an output of an+ unconfirmed transaction is refused by the new rules, as that output is+ not in the chain. Today it is imported.+- `Haskoin.Store.Database.Query.Layered`: every query of the store for the+ layered layout, with the names and arguments of+ `Haskoin.Store.Database.Query`, and `layeredRead`, which gives them as the+ record of queries that the web server answers with. The store does not use+ them yet.+ - A query takes a `View`, a RocksDB snapshot that only `withView` makes,+ so that the chain and the mempool are read as they were at one moment.+ - Pages are cut from the mempool's part of a list followed by the chain's+ part, by today's rules for starts, offsets and limits, for one address,+ for several, and for extended public keys.+ - Mempool spenders are looked up only for the outputs of a transaction+ that no block spends, in one multi-get for a whole page of transactions.+ - `MemDeletedKeyP` matches deleted transactions by the start of their+ hash, for the short transaction numbers of the Blockchain.info API.+ - Tests ask today's queries and the new ones for pages with random+ limits, offsets and starts, on databases migrated from today's layout,+ on databases built by the rules of the layered layout, and on databases+ upgraded in the middle of a history. They also check that an open view+ goes on answering from its moment while blocks and transactions are+ written.+- A summary for each address that the mempool touches, in the layered+ layout (`MemSummary`, under `MemAddrSumKey`): the number of its mempool+ transactions, and the amounts and numbers of the outputs that the mempool+ pays to it, spends, and both pays and spends. The measurements showed the+ queries of that layout to be too slow for an address with much in the+ mempool, and for any address right after a block, because they scanned+ everything that the mempool has for an address to answer anything.+ - A balance is now two reads by key, and an address with nothing in the+ mempool is found by a key that is not there.+ - Lists of transactions and of unspent outputs read the mempool's entries+ only for addresses that have some, and only as far as the page goes.+ - The rules keep the summary with every transaction that enters or leaves+ the mempool, and the migration writes all of them from the entries that+ it moved. The tests work every summary out again after each operation.+ - Measured again with `QueryCost.hs`: the balance of an address with+ 6,216 entries in the mempool takes 11 microseconds instead of 35+ milliseconds, a page of its transactions what it takes in version 20,+ the balances of a wallet of 464 busy addresses 1.9 milliseconds instead+ of 110, and nothing gets slower right after a block. Keeping the+ summaries costs about 110 microseconds for each transaction that passes+ through the mempool. `docs/layered-database.md` has the tables.+- `docs/layered-database/QueryCost.hs` writes one synthetic history into a+ database of each layout and times the rules and the queries of both,+ checking that the answers agree. `docs/layered-database.md` has the+ results and what follows from them: writing costs the same when syncing+ and 14% more for a transaction that passes through the mempool; the new+ queries cost 10 to 15 microseconds more for a request when the mempool+ has nothing for what is asked; and they are too slow, as built, for+ addresses that the mempool touches a lot and for any address right after+ a block. The document proposes a summary for each address in the mempool+ area, read by key, and two changes to the RocksDB binding.+- Documentation: a page that starts at a height has no unconfirmed+ transactions, whatever the height. The comment on `AtBlock` said that a+ height above the best block includes them, and the API reference could be+ read that way. The behaviour is unchanged.+- The stress report breaks the commands that blocked Redis down by script,+ and commits by full and incremental reloads, with their numbers of+ arguments.+- `Haskoin.Store.Fleet`: the rules by which stores that share a Redis agree+ on one block to answer from, the fleet tip, as pure decisions with a model+ test. It is the first part of the design for a fleet that answers as one,+ in `docs/layered-database.md`. Nothing uses it yet.+ `Haskoin.Store.Fleet.Redis` carries the rules out against Redis, with a+ Lua script for each step that has to be one step, and a test that takes+ random sequences of steps through a real Redis and through the rules and+ compares them after each. `Haskoin.Store.Fleet.Pool` has the rules of the+ mempool that such stores share, as of the fleet tip: what may be offered,+ which of two conflicting transactions stays, what leaves and why, and how+ the pool follows the tip forwards and across a reorganisation. A model+ test keeps it valid on top of the tip through random offers, blocks and+ reversals. One more Lua script carries these rules out, moving the tip and+ changing the pool in one step, and is compared with them step by step in a+ real Redis like the others; the comparison found a case that the rules had+ left open, a transaction that comes back from an abandoned block and+ spends what the pool already spends, which now wins. The pool also keeps,+ for each address, what its transactions do there: the transactions and+ the outputs paid to it in the order of a list, the outputs of it that are+ spent, and their sums, so that a balance is one read and a list is read+ by the page, as in a store's own mempool area.+- `Haskoin.Store.Database.Query.Pooled`: the queries of the store with a+ mempool that is not its own on top of the chain, for the shared pool of a+ fleet. `read.lua` and `poolRead` read that pool from Redis, each read+ with a check that the fleet tip has not moved. The tests hold these+ queries to those of the layered layout with the store's own mempool as+ the pool. `fleetAdvance` can empty the pool for a store that is alone and+ does not know the way from the old tip.+- `Haskoin.Store.Fleet.Member`: what a store does as a member of a fleet.+ It keeps a view at each recent block, reports them, moves the fleet tip+ with what the blocks in between did to the pool, offers its mempool, and+ answers from the view at the tip with the pool on top. A test with a real+ Redis takes a store that is alone in its fleet through random histories+ and compares every answer with the store's own after each step. It found+ that a body kept in the pool went stale when a parent was confirmed or+ came back, because a transaction inherits from its unconfirmed parents+ whether it can be replaced by fee; the pool now keeps that apart from the+ body and corrects it as transactions come and go.+- `Haskoin.Store.Database.Views` keeps the database as it was at each recent+ block, a RocksDB snapshot for each, so that a store can answer from the+ block that its fleet agrees on and not only from its newest. Views that+ are no longer needed are let go, and one that a request is reading is+ released when the request is done. Nothing uses it yet.+- A store whose Redis does not answer goes on answering from its database,+ instead of failing every request that the cache would have served. A+ request waits at most two seconds for Redis; after that the store leaves+ Redis alone until a health check finds it again. Meanwhile `/health`+ answers 203, Non-Authoritative Information, with the header+ `Haskoin-Degraded: redis`, when every other check passes: a status in the+ 200s, so that load balancers keep the store, and not 200, so that+ monitoring notices. The body of `/health` has a new check, `redis`, for a+ store that has a cache; it does not count towards `ok`. It comes with+ haskoin-store-data 1.9.0, which the store now takes from Hackage.+- Breaking: transactions that were deleted are no longer returned. A+ transaction that was replaced, dropped from the mempool, or was the+ coinbase of a block that left the main chain used to answer by id with+ `"deleted": true`; clients that did not look at that field took it for a+ pending payment. It now gives 404, is left out of lists of transactions+ fetched by id, and a page cannot start at it. The `deleted` field stays in+ responses and is always false. The bodies stay in the database.+- Breaking: parameters are checked. A request with a parameter that its+ endpoint does not accept, such as a misspelt `limt=10` or `pretty` on the+ Blockchain.info endpoints, which never honoured it, gives 400+ `Unknown parameter: …`; and a value that cannot be read, such as+ `notx=yes`, `limit=-1`, `filter=4`, `format=xml` or an unknown currency,+ gives 400 `Invalid value for parameter …`. Both used to be ignored, and+ the client got an answer to a question it did not ask. Requests are+ checked before anything is done. The Blockchain.info endpoints accept+ `format=json`, which their clients often send. Each route of the router+ declares the parameters it accepts, and reading an undeclared one is an+ error of the program, so the two cannot drift apart.+- An xpub asked about with the wrong `derive` gives 400, with a message that+ names the right kind, instead of an empty wallet: when the xpub has no+ transactions with the kind asked for, the store checks the first+ `--xpub-gap-init` addresses of the other kinds. This applies to the `/xpub`+ endpoints and to `/blockchain/rawaddr`. A wallet unused with every kind+ still gets an empty answer, and wallets in use cost nothing more.+- `swagger.yaml` explains the API, not only lists it: an introduction to its+ conventions (amounts, ordering, paging, formats, errors, unconfirmed+ transactions, reorganisations, xpubs, live events, Blockchain.info+ compatibility) and the reasons for them, and a description of every+ endpoint, parameter and field. It documents `/dbstats`, the WebSocket at+ `/events` and the `time` field of the health check, which were missing,+ and corrects the health check examples and the version.+- The README tutorial pages with `height`, the parameter the server reads;+ it said `start`, which the server ignores.+- Every line of code is shorter than 120 columns, `swagger.yaml` included,+ whose long hexadecimal and xpub examples are split into equal pieces that+ parse to the same values. `scripts/format` formats `integration/` and+ `stress/` too.+- Scripts live in files of their own: the Lua scripts of the cache in+ `src/Haskoin/Store/Cache/lua/`, embedded at compile time with+ `file-embed`; the script of wrk in `stress/wrk.lua`, one for every+ endpoint, given its paths on the command line; and the shell steps of CI+ in `scripts/check-local-deps.sh` and `scripts/install-system-libraries.sh`.+ The scripts do the same as before.++### Fixed++- `--bloom` builds Bloom filters. It built none when the store was built+ against RocksDB 6.24 or later, as the Docker image is, because+ `rocksdb-haskell-jprupp` passed the size of the filter to RocksDB with the+ wrong type. Nothing failed; a lookup of a key that is not there just went+ on reading data blocks to find that out. The store is now built with+ `rocksdb-haskell-jprupp` 2.6.3, which has the fix. A store that already+ runs with `--bloom` starts building filters when it is upgraded, for the+ table files written from then on: about 1.25 bytes for each key, held in+ the block cache. Size that on a full database, or turn the option off to+ keep things as they were.+- `--bloom` no longer asks for filters on the two indices by address, which+ are scanned and not read by key. With a filter, a scan of an address that+ has nothing steps over every key deleted since the last flush: 810+ microseconds after 5,000 mempool transactions were deleted, against 13+ without filters. No store was affected, because no filter was ever built;+ with the fixed binding they would have been. The option still builds+ filters for transactions, unspent outputs, blocks, balances and the+ mempool.+- `/blockchain/multiaddr` no longer reads every transaction of the wallet to+ count them. `n_tx` of one xpub is its count (a `ZCARD` when it is cached);+ for several xpubs and addresses, the database counts the distinct+ transactions of all their addresses from the keys of its index. The+ transactions of a page are read in pages of about the size asked for, and+ their bodies a page at a time, instead of one by one; `/blockchain/rawaddr`+ does the same. In the stress test, with xpubs of about 15,000+ transactions, the median time of a multiaddr request went from 3.2 seconds+ to 36 milliseconds, and it no longer grows with the wallet.+- Containers left by an integration test run that was killed are removed+ by the next run, as intended: listing them used a template that Podman+ refuses, so they were never found.+- `--peer HOST[:PORT]` works as documented. It was read as a Haskell+ string, so only `--peer '"HOST:PORT"'`, with the quotes, was accepted.+- The syncing peer is disconnected when it sends a block that was not+ requested, as the documentation of the block store said. The block was+ imported if the node knew its header. The state of the block download is+ now a pure state machine (`Haskoin.Store.BlockStore.Sync`) with property+ tests, which also found that a block listed twice in one request was+ outstanding twice.+- The block store no longer starts a thread for every transaction it+ requests, to forget the request twenty seconds later. Requested and+ received mempool transactions are one pure value+ (`Haskoin.Store.BlockStore.Pending`), with property tests, and old+ requests are forgotten on the clock tick of the block store.+- Build against haskoin-node 2.4.0, from the head of its+ `fix-header-sync-stall` branch. The block store reserves its syncing peer+ as an owner of its own (`reservePeer`, `releasePeer`), so no other part of+ the node can release it; header searches run with `chainRunHeaders`. The+ store stopped following the chain+ when a peer announced new blocks with an inventory message instead of+ headers, which peers do when many blocks are found at once; it stayed at+ the old height until the peer reconnected.+- Mempool transactions that entered the mempool in the same second could be+ listed twice in the transactions of an xpub or a multiaddr wallet, and+ their order differed from page to page. Every merge now orders them as the+ database and the cache store them (`txRefKey`, `unspentKey`).+- `nocache=true` on `/xpub/:xpub/transactions` and `/xpub/:xpub/unspent` no+ longer reads, or fills, the cache.+- `confirmations` of `/blockchain/unspent` no longer wraps around for an+ output in a block newer than the chain of the store.++### Added++- `scripts/stress-cache.sh`, a stress test of the Redis cache: a regtest+ chain, Valkey and wrk in Podman, a haskoin-store server, hundreds of+ thousands of transactions between a few wallets, and wrk on their xpubs+ all the while, with a report (`scripts/stress-report.py`) of reloads,+ lag, resources, the commands that blocked Redis (from its slow log) and+ latencies, and a check of the cached answers at the end. The Podman+ helpers of the integration tests are an internal library (`integration`)+ that the stress program shares, and the test image has wrk in a layer of+ its own.+- `stress/results/2026-11-09/`: the results of a full default stress run,+ kept for comparison with later runs.+- `runWebSocket` serves the API on a socket that already listens. The tests+ bind port 0 and pass the socket, so their web servers get free ports with+ no window in which another program could take them; the containers publish+ their ports on random ports of the host.+- Integration tests against regtest chains of Bitcoin Core and Bitcoin Cash+ Node, with Valkey, in Podman containers built from Ubuntu 26.04 and+ release tarballs checked against pinned hashes, one cached layer per+ download, so nothing is downloaded twice. They are skipped when Podman+ cannot run containers or `HASKOIN_STORE_NO_PODMAN` is set.++## [1.8.1] - 2026-11-07++### Changed++- Documentation: the README explains the purpose and design of the store, with+ installation steps, a full configuration reference, monitoring and a+ tutorial, and the Haddock of every module and function says why it works the+ way it does.+- Build against nqe 0.6.9, rocksdb-haskell-jprupp 2.6.1, rocksdb-query 0.6.1,+ statsd-rupp 0.5.0.2, secp256k1-haskell 2.0.1, haskoin-node 2.3.1 and+ Stackage nightly-2026-11-07.++## [1.8.0] - 2026-11-07++### Changed++- Index the mempool by the time each transaction entered it. `/mempool`,+ `/blockchain/unconfirmed-transactions` and the health check read only the+ page they return, newest first, instead of loading and sorting the whole+ mempool on every request. Data version 20: a version 18 or 19 database is+ migrated when it is opened, with no resync.+- The block store no longer reads the whole mempool to publish mempool+ events, to set the mempool gauge or to find expired transactions. Each+ import reports which transactions it added to the mempool or removed from+ it; pruning reads only the oldest entries of the index. Before, every+ block, every batch of new transactions and every clock tick (several per+ second) read the whole mempool at least once.+- Count the transactions of an xpub without building the list of all of them+ when the cache is not used.+- Keep the derived addresses of the 512 to 1024 most recently queried xpubs+ in memory. Every xpub query derived each address again before reading a+ balance. Addresses depend only on the xpub, so the cache never goes stale.+- Page the unspent outputs of an xpub by merging its addresses in order of+ block, stopping once the page is full, instead of decoding up to offset+ plus limit outputs of every address and sorting them all. The pages are+ the same.+- Count the transactions of an xpub by merging the raw keys of its+ addresses, without decoding keys or reading values.+- Open the iterators of a query over many addresses in one bracket instead+ of one bracket each. Every call into RocksDB walked the stack past one+ frame per address.+- Double the page, up to 1024 rows, when streaming a source by offset, as+ `/blockchain/unspent` does. The rows read and skipped grew with the square+ of the rows returned.+- Check that an xpub is cached in the same Redis round trip as the read of+ its balances, transaction count, or a page of its transactions or unspent+ outputs that does not start at a transaction.+- Serialize a transaction at most twice to compute its id, size and weight,+ and stop looking up its witnesses with a quadratic list index.+- Build on haskoin-core 1.6.1, which encodes CashAddr and Base58 addresses+ faster: about 10% less CPU per request on endpoints that return many+ addresses.+- The executable uses every core of the host by default (`-N` instead of+ `-N4`). Set `+RTS -N<n> -RTS` on the command line to choose a number.+- Read the balances of many addresses, and lists of transactions, with one+ RocksDB multi-get instead of one read per key. `StoreReadBase` has a new+ method, `getManyTxData`, which defaults to reading the transactions one by+ one; `getTransactions` reads many transactions with it.+- Give RocksDB a block cache of 1 GiB by default, shared by all column+ families, and keep their index and filter blocks in it at high priority,+ with those of level 0 pinned. Before, each column family had RocksDB's+ default cache of 32 MiB, and index and filter blocks stayed in memory+ outside any cache for every open file. Set the size with `--block-cache-mb`+ or `BLOCK_CACHE_MB`; 0 restores the RocksDB defaults. `StoreConfig` and+ `withDatabaseReader` take the size in bytes. Requires rocksdb-haskell-jprupp+ 2.6.0.++### Added++- `store/bench`: a benchmark server that syncs a synthetic regtest chain and+ serves the web API on it, and times the encoding of a response with the+ argument `micro`. `scripts/bench-web.sh` runs wrk against it and reports+ the CPU time of the server per request.++## [1.7.0] - 2026-11-06++### Changed++- Build on haskoin-core 1.6.0 and haskoin-node 2.3.0, the releases with the+ fixes of the November 2026 security review. Their changelogs list the+ changes to message parsing, header validation and peer handling that the+ store now runs with.+- An output has an address only when the network defines one for its script,+ as in haskoin-core 1.6.0. A SegWit-shaped output has no address on Bitcoin+ Cash. A 256-bit script hash output (`OP_HASH256`, a push of 32 bytes,+ `OP_EQUAL`) has none on Bitcoin. Such an output is no longer indexed under+ an address, and its `address` is `null`. Entries that an older version+ wrote under such an address stay in the database. No request can name them,+ since the address has no text form on the network.+- `POST /transactions` takes a body that holds one transaction and nothing+ else. Bytes after the transaction are refused, as Bitcoin Core does for a+ raw transaction. They used to be ignored.+- `POST /transactions` takes the transaction in the format that peers of the+ network read. A network without SegWit, such as Bitcoin Cash, has the+ format without witness only.+- `GET /peers` gives the `version` of a peer as a signed number, as+ haskoin-core 1.6.0 reads it. A peer that sends version ffffffff has version+ -1, where it used to appear as 4294967295.+- Breaking, in `haskoin-store-data`: `toTransaction` takes the `Network` as+ its first argument, and the `version` field of `PeerInfo` is an `Int32`.+ The binary form of `PeerInfo` is the same bytes as before.+- From haskoin-node 2.3.0: a peer is disconnected when it sends more than+ 50000 entries in an `inv` or `getdata` message. On Bitcoin Cash a message+ may be at most 2 MiB, unless it carries block content. Transactions from+ Bitcoin Cash peers are read in the format without witness only.++### Fixed++- A response no longer fails on a transaction with an output that has no+ address on the network. Since haskoin-core 1.5.0 the JSON encoder of such+ an address threw, which cut the response off. One such output is in Bitcoin+ block 211997 (transaction+ `a4bfa8ab6435ae5f25dae9d89e4eb67dfa94283ca751f393c1ddc5a837bbc31b`,+ output 0).++## [1.6.5] - 2026-11-04++### Fixed++- Build on haskoin-core 1.5.1, which evaluates the ASERT difficulty rule at the+ parent block. With 1.5.0 the store rejected a valid block on Bitcoin Cash+ mainnet at height 975899.++## [1.6.4] - 2026-11-04++### Added++- Support Bitcoin testnet4 (`NET=btctest4`), from haskoin-core 1.4.0.+- Option `--block-window` (`BLOCK_WINDOW`) to set how many blocks to request+ from the syncing peer at a time.+- Option `--write-buffer-mb` (`WRITE_BUFFER_MB`) to cap RocksDB memtables+ across all column families, 256 MiB by default (0 = unlimited). Without a+ cap the nine column families can keep up to 1.1 GiB of memtables.+- Option `--max-wal-mb` (`MAX_WAL_MB`), 1024 MiB by default (0 = RocksDB+ default), to bound the RocksDB write-ahead log: once it is larger, the+ column families holding its oldest file are flushed so the file can be+ deleted.+- `/dbstats` starts with the memory RocksDB uses outside the GHC heap:+ memtables and table readers for each column family, and the block cache.+- The mempool is kept in its own column family, one key per transaction,+ instead of as a single list value that every import batch read, loaded+ into a hash table and wrote back whole. Data version 19: a version 18+ database is migrated when it is opened, with no resync.++### Changed++- Replace monad-logger with the fast-logger `Logger` from haskoin-node 2.0.0.+- Request 64 blocks at a time from the syncing peer instead of 500, and ask+ for more once fewer than 16 remain, to bound memory use while catching up.+- haskoin-store-data builds against secp256k1-haskell 2.0.0, which hides the+ raw `PubKey` constructor: `XPubSpec` reads its stored key with `pubKey`,+ which checks it.+- Build on haskoin-core 1.5.0, haskoin-node 2.1.2 and secp256k1-haskell 2.0.0.+ `genesisBlock` no longer takes a context.+- A pay-to-public-key output has no address any more, as in haskoin-core+ 1.5.0. Its coins cannot be spent with the input script of the P2PKH address+ of the same key, so they are no longer counted under that address.+- The block store no longer reads the whole mempool four extra times on every+ internal ping: it compares mempool snapshots only when there are pending+ transactions to import or expired ones to delete.+- Run with four capabilities (`-N4`) instead of one per core (`-N`), so that+ memory overhead does not grow with the size of the host. Override with+ `+RTS -N<n> -RTS`.+- Docker image: pin the base image to Debian trixie (RocksDB 9.10) by digest+ instead of `debian:latest`, and run with `MALLOC_ARENA_MAX=2` to limit+ malloc fragmentation from RocksDB.+- Each `/events` client, websocket or HTTP stream, gets a queue of at most+ 10,000 events and is disconnected when it fills up, instead of the queue+ growing for as long as the client stays connected. Store events that+ `/events` does not show are no longer queued for its clients.+- A request waiting for another to load an xpub into the cache retries for+ about 30 seconds, with at most 5 seconds between attempts, and then serves+ the xpub from the database. It used to retry forever with a delay that+ doubled each time.++- Continuous integration on GitHub Actions: build, run both test suites,+ and check that the heap stays flat while syncing 6000 regtest blocks.++### Fixed++- Support full-RBF: accept replacements of transactions that do not signal BIP125.+- Correct various space leaks.+- `Unspent` implements `coinInputKind`, new in haskoin-core 1.5.0, from its+ output script, so coin selection with it no longer fails at run time.++### Added++- Support for testnet4 on both networks.++## [1.6.3] - 2026-08-31++### Added++- Allow disabling web server to sync block chain only.++## [1.6.2] - 2026-08-13++### Fixed++- Fix upstream regressions in haskoin-node.++## [1.6.1] - 2026-08-13++### Fixed++- Support latest version of haskoin-node.++## [1.6.0] - 2026-08-12++### Fixed++- Support for Bitcoin Cash 256-bit scripts.+- Address parsing now allows only correct lengths.++## [1.5.16] - 2025-05-08++### Changed++- Move to personal repository.++### Removed++- Disable `NO_SLOW` as an option.++### Added++- Allow to set `NO_XPUB` to disable extended public keys.++## [1.5.14] - 2024-12-04++### Changed++- Move `/dbstats` and `/mempool` to slow endpoints section.++### Added++- Cull old transactions from mempool automatically.++## [1.5.13] - 2024-10-25++### Changed++- Dummy release: update upstream LTS Haskell dependency in `stack.yaml` file.++## [1.5.12] - 2024-07-22++### Added++- Metric to track time to import a new block.+- Parameter `no_compact` allowed for backwards compatiblity.++## [1.5.11] - 2024-05-24++### Fixed++- Parameter `noCompact` corrected for `multiaddr` endpoint.++## [1.5.10] - 2024-05-24++### Fixed++- Prune obsolete transactions from outgoing peer requests.+- Remove unnecessary module imports.++## [1.5.9] - 2024-05-23++### Fixed++- Only sync mempool once for each peer.++## [1.5.8] - 2024-05-23++### Fixed++- Dummy release to bump upstream dependency on `haskoin-node`.++## [1.5.7] - 2024-05-23++### Fixed++- Dummy release to bump upstream dependency on `statsd-rupp`.++## [1.5.6] - 2024-05-22++### Changed++- Allow to toggle RocksDB Bloom filters off.++### Fixed++- Correct command-line option behaviour for some rarely-used parametres.++## [1.5.5] - 2024-05-19++### Changed++- Lock Redis before checking if the database needs to be pruned, rather than after.+- Lock xpub in Redis to avoid computing derivations multiple times.++### Fixed++- Rename redis-min-gap to redis-min-addrs to match environment config variable.++## [1.5.4] - 2024-05-07++### Fixed++- Fix Redis bug where old keys weren't pruned quickly enough.++### Changed++- Remove Nomad-specific environment variable parsing for statsd.++## [1.5.2] - 2024-04-24++### Changed++- Use default GHC garbage collector.++## [1.5.1] - 2024-04-23++### Changed++- Compile with GHC 9.8.2.+- Enable optimisations.++## [1.5.0] - 2024-04-06++### Fixed++- Use an optimised topological sort for importing transactions in bulk.++## [1.4.0] - 2024-03-27++### Changed++- Use default optimisation settings.+- Use default RocksDB parameters.++## [1.3.0] - 2024-03-14++### Changed++- Bump upstream dependencies.++## [1.2.5] - 2023-01-31++### Fixed++- Test suite now working again.++## [1.2.4] - 2023-01-31++### Changed++- Bump upstream dependencies.++## [1.2.3] - 2023-12-06++### Changed++- Transactions received via POST are just forwarded to every peer without delay or validation.+- Bump dependency on NQE to resolve async issue.++## [1.2.2] - 2023-10-21++### Fixed++- Fix comparison function for unspent outputs.++### Changed++- Avoid deprecated `param` function from Scotty.++## [1.2.1] - 2023-10-04++### Fixed++- Correct web exception output format (JSON).+- Correct block path parsing order.++## [1.2.0] - 2023-10-03++### Changed++- Upgrade `scotty` dependency and fix incompatibilities.++## [1.1.0] - 2023-09-15++### Added++- Add cache height stats.+- Add database iterator stats.++### Changed++- Detect xpub gap more strictly.+- Use statsd-rupp instead of ekg-stats.+- All stat reporting keys changed.++### Removed++- Remove unsafeInterleaveIO from Web.hs.+- Remove unnecessary WAI middlewares.+- Remove server and client error reporting per endpoint.++## [1.0.2] - 2023-08-25++### Changed++- Code refactoring and simplification.+- Improve logging output.++### Removed++- Do not impose time limits on REST API responses.++### Fixed++- Remove loop in Blockchain.info unspents function.+- Correct documentation for /blockchain/rawblock endpoint.+- Fix no match in record selector address for coinbases.++## [1.0.1] - 2023-08-03++### Changed++- Add compatibility with latest nightly LTS Haskell.++## [1.0.0] - 2023-07-28++### Changed++- Compatibility with latest haskoin-core and haskoin-node packages.+- Use DuplicateRecordFields and OverloadedRecordDot language extensions.+- Modernize package in a backwards-incompatible manner.+- Refactor package extensively.++## [0.65.10] - 2023-05-12++### Changed++- Revert to older LTS Haskell to attempt to resolve memory leak.++### Removed++- Remove unnecessary lock release.++## [0.65.9] - 2023-05-09++### Changed++- Be more aggressive caching individual transactions.+- Introduce a periodic cache mempool sync task.++## [0.65.8] - 2023-05-08++### Changed++- Perform health check in separate thread.++## [0.65.7] - 2023-05-05++### Changed++- Do not sync mempool to Redis if node is not up-to-date.++## [0.65.6] - 2023-05-04++### Changed++- Improve caching ingestion performance.++## [0.65.5] - 2022-09-19++### Added++- Fee field added to Blockchain.info-style export-history endpoint.++### Changed++- Bump version due to Git mistake.++## [0.65.3] - 2022-04-13++### Added++- IPv6 peer support.++## [0.65.2] - 2022-04-13++### Fixed++- Correct lazy bytestring deserialization algorithm for web client.++## [0.65.1] - 2022-04-13++### Changed++- Simplify function in Cache module.+- Bump depenedencies.++## [0.65.0] - 2022-02-03++### Changed++- Spenders now embedded into tx objects in database for performance.+- Use faster IORef instead of slow TVar when ingesting.+- Use mutable hash tables when ingesting.+- Allow to disable unscalable API endpoints.++## [0.64.19] - 2022-02-01++### Added++- Allow to set query timeouts.++## [0.64.18] - 2022-01-21++### Fixed++- Off-by-one error when importing change addresses to cache.++## [0.64.17] - 2022-01-21++### Fixed++- Add change addresses to cache when necessary.++## [0.64.16] - 2022-01-10++### Fixed++- Correct fee calculation for blocks that do not claim all rewards.++## [0.64.15] - 2022-01-07++### Fixed++- Correct scale of cache pruning code.++## [0.64.14] - 2022-01-07++### Fixed++- Always prune to 80% of max cache size.++## [0.64.13] - 2022-01-07++### Fixed++- Avoid doing a hissy fit on transient cache issue.++## [0.64.12] - 2022-01-07++### Fixed++- Prune a little more cache to allow for new keys.++## [0.64.11] - 2022-01-06++### Fixed++- Prune cache more often.++## [0.64.10] - 2021-12-30++### Changed++- Return empty addresses in xpub balances call.++## [0.64.9] - 2021-12-29++### Fixed++- Avoid notifying same transactions in mempool.++## [0.64.8] - 2021-12-26++### Changed++- Use better Fourmolu format.+- Be more precise when counting retrieved items.++### Fixed++- Avoid transactions duplicated in xpub data.+- Refactor some functions to make them more robust.++## [0.64.7] - 2021-12-26++### Fixed++- Optimisations for various Blockchain endpoints.+- More accurate item counts for various web endpoints.++## [0.64.6] - 2021-12-25++### Fixed++- Record cache index time just once per xpub.++## [0.64.5] - 2021-12-25++### Changed++- Add suffix cached to cache entries to avoid conflicts with data entries.++## [0.64.4] - 2021-12-25++### Fixed+- Bring dots back and avoid stats hacks.++## [0.64.3] - 2021-12-25++### Fixed++- Fix typo.++## [0.64.2] - 2021-12-25++### Changed++- Replace dots for underscores in metrics.++## [0.64.1] - 2021-12-25++### Changed++- Automatic formatting.+- Improve cache debug logging.++### Fixed++- Add item count to 'all' query metrics.++## [0.64.0] - 2021-12-25++### Changed++- Improve metrics.++### Fixed++- Get lock before importing transactions to cache.++## 0.63.0+### Fixed+- Make cache updates lightweight.++## 0.62.1+### Fixed+- Fix idempotent transaction elimination bug.++## 0.62.0+### Added+- Configurable Blockchain price URLs.+- Reuse HTTP connections with wreq sessions.++## 0.61.1+### Changed+- Upstream improvements on Base58 performance.++## 0.61.0+### Fixed+- Fixed recursion bug in Blockchain format conversion function.++## 0.60.0+### Fixed+- Correct notifications subsystem.++## 0.59.0+### Fixed+- Correct internal dependency.++## 0.58.0+### Fixed+- Correct output freeing algorithm.++## 0.57.0+### Changed+- Report errors freeing outputs.++## 0.56.0+### Changed+- Report all deleted mempool transactions.+- Crash on certain data corruption.++## 0.55.0+### Changed+- Always have a previous output object in Blockchain inputs.++## 0.54.0+### Added+- WebSocket support.+- Testnet4 support.++## 0.53.11+### Changed+- Dummy version increase to signal upstream update of Haskoin Core.++## 0.53.10+### Fixed+- Correct test that was reversed in previous version.++## 0.53.9+### Removed+- Mempool is no longer synced by default to fix public Bitcoin Core regression.++## 0.53.8+### Changed+- Put derivations stat inside database.++## 0.53.7+### Added+- Added counters for database retrievals.+- Added counter for xpub derivations.++### Changed+- Removed some buggy or unnecessary stats.++## 0.53.6+### Changed+- Improve web server statistics.++## 0.53.5+### Addded+- Endpoint to delete an xpub from cache.++## 0.53.4+### Added+- Debugging when reverting block.++### Fixed+- Negative (headers - blocks) diff shown incorrectly.+- Infinite loop when removing a block's coinbase transaction.++## 0.53.3+### Added+- Blockchain.info endpoint: `q/pubkeyhash/:addr`.+- Blockchain.info endpoint: `q/getsentbyaddress/:addr`.+- Blockchain.info endpoint: `q/pubkeyaddr/:addr`.+- Configurable request body limit size.++## 0.53.2+### Fixed+- Return output in satoshi for some endpoints.++## 0.53.1+### Fixed+- Return 404 in case of bad address.++## 0.53.0+### Added+- Blockchain.info endpoint: `q/addresstohash/:addr`.+- Blockchain.info endpoint: `q/addrpubkey/:pubkey`.+- Blockchain.info endpoint: `q/hashpubkey/:pubkey`.+- Blockchain.info endpoint: `q/getblockcount`.+- Blockchain.info endpoint: `q/latesthash`.+- Blockchain.info endpoint: `q/bcperblock`.+- Blockchain.info endpoint: `q/txtotalbtcoutput/:txid`.+- Blockchain.info endpoint: `q/txtotalbtcinput/:txid`.+- Blockchain.info endpoint: `q/txfee/:txid`.+- Blockchain.info endpoint: `q/txresult/:txhash/:addr`.+- Blockchain.info endpoint: `q/getreceivedbyaddress/:addr`.+- Blockchain.info endpoint: `q/addressbalance/:addr`.+- Blockchain.info endpoint: `q/addressfirstseen/:addr`.+- Support xpubs in `blockchain/rawaddr` endpoint.++### Fixed+- Various latest blocks endpoint fixes.+- Allow empty parameters in Blockchain.info POST requests.++## 0.52.13+### Fixed+- Upstream fixes for unknown inv types.++## 0.52.12+### Fixed+- Fix limits for legacy endpoints.++## 0.52.11+### Added+- Alias for Blockchain.info raw address endpoint.++### Fixed+- Fix unknown message types without payload upstream.+- Randomise peer timeouts upstream.++## 0.52.10+### Fixed+- Fix binary search algorithm for blocks upstream.++## 0.52.9+### Added+- Add Blockchain.info latest block endpoint.+- Add Blockchain.info unconfirmed-transactions endpoint.+- Add Blockchain.info blocks for one day endpoint.++## 0.52.8+### Fixed+- Fix special case for zero limit.++## 0.52.7+### Fixed+- Fix cache bug when too many transactions in mempool.++## 0.52.6+### Changed+- Use rationals for Blockchain.info export-history endpoint arithmetics.++## 0.52.5+### Added+- Support for Blockchain.info export-history endpoint.++### Fixed+- Use strict bytestring decoder for HTTP API client.++## 0.52.4+### Fixed+- Use Base58 addresses by default in Blockchain.info balance querios.++## 0.52.3+### Fixed+- Errors should also contain CORS headers.++## 0.52.2+### Fixed+- Blockchain.info raw address limit should use `limit` parametre and not `n`.++## 0.52.1+### Changed+- Stream multiple transactions when requested.++### Fixed+- Fix missing unspent outputs.++## 0.52.0+### Changed+- Remove unnecessary performance optimisations.++### Fixed+- Reduce balance retrievals for xpubs.+- Hack UTXO ordering to avoid unwanted behaviours.+- Do not merge conduits when there are too many.++## 0.51.0+### Fixed+- Data type for RequestTooLarge was missing.++## 0.50.3+### Added+- Log HTTP request bodies.++### Removed+- Remove web request timeout.++### Fixed+- Do not ignore unknown start tx anchors in conduits.+- Use fast transaction counting in cache.++### Changed+- Use WAI middlewares to manage stats.++## 0.50.2+### Fixed+- Do not use lazy parsers for incoming data that may throw exceptions.++## 0.50.1+### Fixed+- Do not allow incoming POST requests of unlimited body size.++## 0.50.0+### Added+- Limit number of simultaneous xpubs being cached.++## 0.49.0+### Changed+- Improve conduit merging algorithm.++## 0.48.0+### Added+- Timeouts and token bucket for web requests.+- Configurable price fetch interval.++## 0.47.6+### Fixed+- Fix web.all stat.++## 0.47.5+### Changed+- Simplify stats.++## 0.47.4+### Added+- Implement Blockchain.info block height.+- Implement Blockchain.info raw address.+- Implement Blockchain.info balances.++### Fixed+- Make Blockchain.info unspent endpoint more efficient.+- Make Blockchain.info xpubs endpoint more efficient.++### Changed+- Use compact JSON serialization by default everywhere.++## 0.47.3+### Fixed+- Fix serialization bug with unspents.++## 0.47.2+### Fixed+- Fix serialization bug for health check.++## 0.47.1+### Fixed+- Fix serialization bugs for web data types.++## 0.47.0+### Added+- Support for legacy block endpoints.++### Changed+- Serialization now uses bytes library and does interleaved IO.++## 0.46.6+### Added+- Allow comma-separated values in multiaddr.++## 0.46.5+### Changed+- Throw error when offset exceeded instead of truncating it.++## 0.46.4+### Fixed+- Do not compute balances separately for loose addresses when part of an xpub.++### Changed+- Do not fetch entire transaction list for loose addresses.+- Transaction count in wallet object is just a synonym for filtered tx count.+- Use conduits to prevent over-fetching transactions from database.++## 0.46.3+### Fixed+- Make hex value encoding compatible with Java BigInteger decoder.++## 0.46.2+### Changed+- Allow negative confirmations for compatibility with old API.++## 0.46.1+### Added+- Filters for transactions in multiaddr endpoint.++## 0.46.0+### Fixed+- Release to fix unintentional upload.++## 0.45.0+### Added+- Using a tx_index that results in a txid conflict returns a 409 - Conflict.++## 0.44.0+### Changed+- Numeric txid now 53 bits long and doesn't return transactions when hashes collide.++## 0.43.1+### Fixed+- Do not overcount statistics.++## 0.43.0+### Changed+- Transaction index now simply encodes txid as a long number (sorry JS users).++## 0.42.4+### Changed+- Optimise encoding of JSON data for Blockchain.info API.++## 0.42.3+### Changed+- Narrow down the balance for transactions to onlyShow set.++## 0.42.2+### Changed+- Make unspent algorithm more efficient.++## 0.42.1+### Added+- Support for Blockchain.info API unspent endpoint.++### Fixed+- Fix RBF transactions on Blockchain.info API.++## 0.42.0+### Removed+- No more pruning of old mempool transactions.++## 0.41.4+### Fixed+- Correct error computing wallet balances.++## 0.41.3+### Changed+- Use whole active set when computing results for multiaddr.+- Compute correct count of transactions for wallet.++## 0.41.2+### Changed+- Caching mempool is not much slower when pruning, so reversing that change.++## 0.41.1+### Changed+- Only prune mempool every ten minutes or so.++## 0.41.0+### Removed+- No more cache eviction endpoint.++## 0.40.22+### Added+- More metrics for cache.++## 0.40.21+### Added+- Support HTTP GET for multiaddr endpoint.++## 0.40.20+### Added+- Prefix 'app.' in statsd when using Nomad variables.++## 0.40.19+### Added+- Statsd support and pleny of statistics.++### Removed+- Removed use of sync lock for importing xpubs in cache due to volume.++## 0.40.18+### Added+- New options to control cache lock retry count and delay.++### Changed+- Cache xpubs asyncrhonously.+- Mempool syncs fully every refresh cycle.++## 0.40.17+### Changed+- Delete more than 1000 xpubs from Redis if needed.+- Attempt to get lock ten times in order to index a new xpub.++## 0.40.16+### Fixed+- Fix best block not set.++## 0.40.15+### Fixed+- Do not update cache best block if it is higher.++## 0.40.14+### Fixed+- Always respond to cache pings to avoid getting stuck.++## 0.40.13+### Added+- Be more careful about importing blocks to the cache.++### Removed+- Reduce excessive cache logging.++## 0.40.12+### Fixed+- Correct bad arithmetics in cache cooldown.++## 0.40.11+### Added+- Debug log cache cooldown.++### Fixed+- Unlimited retry lock acquisition now works.+- Make locking tighter++## 0.40.10+### Changed+- Reduce the number of things that cache do when pinged.++## 0.40.9+### Fixed+- Avoid filling the cache actor mailbox with pings.++## 0.40.8+### Fixed+- Correct a typo in cache code.++## 0.40.7+### Changed+- Improve cache mempool sync algorithm.++## 0.40.6+### Changed+- Make cache cooldown a number.++## 0.40.5+### Added+- Log every non-successful query.++## 0.40.4+### Fixed+- Use Redis TTL for cache cooldown.++## 0.40.3+### Fixed+- Mempool no longer gets saved in cache when node is not in sync.++## 0.40.2+### Changed+- Change source of price data to https://blockchain.info/ticker.++## 0.40.1+### Removed+- Do not expose Paths_haskoin_store module explicitly.++## 0.40.0+### Added+- Blockchain.info API compatibility for multiaddr and rawtx endpoints.++### Changed+- Segwit fields are no longer omitted on incompatible networks.++## 0.39.0+### Changed+- Upgrade Haskoin Core to support higher version on witness addresses.++## 0.38.4+### Fixed+- Fix block search by timestamp.++## 0.38.3+### Changed+- Disable low latency garbage collector by default.++## 0.38.2+### Changed+- Use GHC 8.10 and its low latency garbage collector.++## 0.38.1+### Changed+- Optimise algorithm for fork safety check.++## 0.38.0+### Added+- Support for Bitcoin Cash hard fork.++## 0.37.5+### Added+- Configurable cache refresh time.++## 0.37.4+### Fixed+- Now request mempool when connecting even if already in sync.++### Changed+- Add a delay to mempool sync to allow for block headers to catch up.+- Attempt to sync mempool whenever syncing is done or a new peer connects.++## 0.37.3+### Changed+- Correct logic error when deleting superseded transactions.++## 0.37.2+### Changed+- Simplify multiple algorithms to hopefully solve corruption bug.+- Allow to disable mempool transaction indexing via command line.++## 0.37.1+### Changed+- Better locking around caching operations.+- Have a global cooldown on cache mempool updates.++### Fixed+- Fix address unspent retrieval using correct column family.++## 0.37.0+### Added+- Add support for column families and prefixes breaking database backwards compatibility.++## 0.36.2+### Fixed+- Fix another place where mempool sorting was wrong.++## 0.36.1+### Fixed+- Fix wrong sorting of mempool transactions in database.++## 0.36.0+### Changed+- Do not use STM transactions because it is impossible to reason about preloading.+- Make importing blocks after mempool is populated faster.++## 0.35.2+### Changed+- Reduce frequency of cache mempool updates.++## 0.35.1+### Changed+- Remove cache lock retries.+- Do not use lock when caching a new xpub.++## 0.35.0+### Changed+- Use new RocksDB Haskell bindings.+- Make headers - blocks diff configurable in health check.+- Simplify locking in cache.++## 0.34.8+### Changed+- Minor refactoring.+- Only send one mempool message ever.++## 0.34.7+### Changed+- Fix block heights endpoint URL.++## 0.34.6+### Removed+- Remove asynchronous database retrieval permanently due to instability.++## 0.34.5+### Added+- Preload memory before freeing outputs.++## 0.34.4+### Added+- Asynchronous preloading only for blocks.++## 0.34.3+### Removed+- Disable asynchronous preloading to prevent segmentation fault error.++## 0.34.2+### Removed+- No more upper limit for mempool call.++### Fixed+- Health check uses minimum between last block and mempool tx.++## 0.34.1+### Added+- Pending mempool transactions health check.++### Removed+- No more upper limit for mempool call.++## 0.34.0+### Changed+- New format for health check.++## 0.33.1+### Changed+- Upload with updated Cabal files.++## 0.33.0+### Fixed+- Fix outputs showing up as both spent and unspent.++### Changed+- Simplify data structures used to hold uncommitted changes.+- Split StoreRead class in two.+- Improve pre-loading algorithms.++## 0.32.3+### Fixed+- Fix command line argument mappings to env vars.++## 0.32.2+### Fixed+- Do not complain when deleting non-existent unconfirmed txs.++## 0.32.1+### Fixed+- Fix corruption bug when deleting transactions during some reorgs.+- Fix tests on data package.+- Rename upstream address string functions.++## 0.32.0+### Changed+- Concurrently load dependencies when importing transaction data.+- Import each transaction using STM.+- Rework logging.+- Refactor all block chain importing algorithms.+- Use peer locking when importing data.+- Change license to MIT.++### Added+- Make max peer count configurable.++### Fixed+- Prevent premature peer timeouts when importing blocks.++## 0.31.0+### Added+- Add Haskell web client code to data library.+- Use type-safe definitions for web paths and parameters.+- Support JSON pretty encoding and enable by default for some endpoints.+- Allow environment variables for configuration.++### Changed+- Change command-line arguments to better fit GNU style.+- Refactor code to manipulate address balances.+- Improve logging.+- Rate-limit mempool importing.+- Do not add xpubs to cache if node not in sync.+- Do not import transactions to cache if no xpubs in cache.+- Refactor transaction importing logic extremely.+- Refactor memory database and writer.++### Fixed+- Fix BTC double-count transaction bug.+- Add missing xpub derive parameter to xpub evict documentation.++## 0.30.1+### Changed+- Limit mempool API endpoint list.++## 0.30.0+### Changed+- Raw JSON values are now wrapped in an object with a result property.+- Better logging when importing transactions into mempool.++### Fixed+- Transaction limit by block height works now.++## 0.29.3+### Changed+- Revert concurrent retrieval changes from 0.29.1 and 0.29.0 to solve stability+ issues.+- Bitcoin Cash (BCH) replaces BTC as default network in CLI.+- Peer discovery enabled automatically if no peers specified in CLI.+- Use separate Stack configuration file for data package.++## 0.29.2+### Changed+- Increase timeouts for manager and chain health checks.++## 0.29.1+### Added+- Perform xpub operations concurrently.++## 0.29.0+### Changed+- BlockTx renamed to TxRef.+- Remove unnecessary monad transformers to simplify import code.++### Added+- Retrieve balances and unspent outputs concurrently when importing.+- Coin instance for Unspent in data package.++## 0.28.0+### Changed+- Change the way that limits, start, and offset values are handled.++### Fixed+- Fix incorrect limit responses.++## 0.27.0+### Added+- Complete support for offset.++### Changed+- Improve monad stack for cache.++## 0.26.6+### Added+- Support for offset in all endpoints where it makes sense.++## 0.26.5+### Fixed+- Sort after using the nub function or use Data.List.nub in exceptional cases.++## 0.26.4+### Changed+- Don't do certain validity checks on confirmed transactions.++### Fixed+- Do not crash when syncing the blockchain from scratch on a populated cache.++## 0.26.3+### Changed+- Replace nub function everywhere with faster version written using hash sets.++### Fixed+- Fix shared cache locking.++## 0.26.2+### Changed+- Simplify cache block import code.+- Improve cache import performance.++## 0.26.1+### Fixed+- Do not call zadd or zrem in Redis with incorrect argument count.++### Changed+- Do not use slow Transaction type in Logic.+- Improve logging while importing blocks.+- Better transaction import algorithm.++## 0.26.0+### Changed+- Add information to some binary types for feature parity with JSON.++## 0.25.4+### Changed+- Hide segwit data on non-segwit networks.++### Fixed+- Fix work which now always serialises as a number literal.++## 0.25.3+### Changed+- Fix version display on health check.++## 0.25.2+### Changed+- Fix wrong version of Haskoin Store Data dependency.++## 0.25.1+### Changed+- Improve code organisation.+- Split data definitions and serialisation into its own package.++## 0.24.0+### Changed+- Use mocking to simulate peers instead of connecting to the network while testing.+- Depend on latest version of Haskoin Core for faster JSON serialisation.+- Clean up confusing JSON encoding/decoding codebase.++## 0.23.24+### Changed+- Implement faster JSON encoding using toEncoding from the Aeson library.++## 0.23.23+### Changed+- Do not read full mempool so often from cache code.++## 0.23.22+### Changed+- Depend on latest Haskoin Node that fixes debugging regression.++## 0.23.21+### Changed+- Reduce non-debug log verbosity.++## 0.23.20+### Changed+- Better Haskoin Node logging.++## 0.23.19+### Changed+- Use set peer timeout instead of constant in block store. ++## 0.23.18+### Changed+- Allow setting peer timeouts in command line.++### Fixed+- Bump Haskoin Node dependency to fix another premature timeout condition.++## 0.23.17+### Fixed+- Touch syncing peer whenever we process one of its blocks to avoid premature timeout.++## 0.23.16+### Changed+- Better algorithm to avoid importing transaction multiple times in cache.+- Depend on latest Haskoin Node.++## 0.23.15+### Changed+- Do not storm the cache with mempool transactions.++## 0.23.13+### Fixed+- Do not ignore deleted incoming transactions.++## 0.23.12+### Fixed+- Do not do RBF checks when replacing a mempool transaction with a confirmed one.++## 0.23.11+### Fixed+- Streamline mempool transaction importing.++## 0.23.10+### Fixed+- Fix transactions not recorded in cache mempool set.+- Fix transactions being downloaded multiple times.++### Removed+- Do not store orphan transactions in database.++### Changed+- Use sets for incoming transactions instead of lists.+- Do not do anything to the cache if there are no xpubs in it.++## 0.23.9+### Fixed+- Wiping mempool fixed.++## 0.23.8+### Added+- Chunk transactions to be deleted from mempool.++## 0.23.7+### Added+- Mempool improvements.++## 0.23.6+### Added+- Ability to wipe mempool at start.+- Improvements to mempool processing code.++## 0.23.5+### Changed+- Tighten the locking loop to avoid slow cache building.++## 0.23.5+### Fixed+- Wrong error in cache when acquiring lock.++## 0.23.4+### Added+- Add extra debug logging for cache code.++### Fixed+- Fix a bug with xpub growing algorithm.++### Changed+- Use locks instead of transactions to update cache.++## 0.23.3+### Fixed+- Reduce contention when many instances of Haskoin Store share a cache.++## 0.23.2+### Added+- Allow retrieving xpub data without using cache.++## 0.23.1+### Added+- Allow xpub eviction from cache via API.+- Clarify cache address addition code.++### Fixed+- Balances were incorrectly computed in cache when new transactions arrived.++## 0.23.0+### Added+- Support for Redis transactions.+- Use a smaller initial gap for empty xpubs.++### Changed+- Remove custom JSON encoding class.+- Refactor and code simplification.++## 0.22.5+### Fixed+- Cache was being completely pruned.++## 0.22.4+### Fixed+- Cache now prunes correctly.++## 0.22.3+### Fixed+- Bug was making cache get stuck when pruning.++## 0.22.2+### Changed+- More efficient algorithms for caching and cache misses.+- Better debug logging of cache hits and misses.++## 0.22.1+### Added+- More debug logging for cache hits.++### Fixed+- Bug using maximum against empty list.++## 0.22.0+### Changed+- Extreme code refactoring.+- Move all code to Haskoin and drop Network from modules.++### Added+- Use Redis pipelining when importing multiple transactions into cache.+- Implement configurable LRU for Redis cache.+- Import xpubs directly into cache from web worker thread when a key is requested.++### Removed+- Only expose a few modules to external API.++## 0.21.7+### Changed+- Improve build configuration.++### Fixed+- Use minimum used addresses instead of minimum index for xpub cache decision.++## 0.21.6+### Fixed+- Fix missing xpub unspent outputs when using cache.++## 0.21.5+### Added+- Only store xpubs in cache if they have more than a threshold addresses used.++## 0.21.4+### Fixed+- Fix shared cache case where head is set beyond header chain by another node.++## 0.21.3+### Fixed+- Fix bug where best head was not being registered in cache.+- Fix best head in cache being decoded incorrectly.++## 0.21.2+### Added+- Complete support for Redis xpub cache.++## 0.21.1+### Fixed+- Latest version of secp256k1-haskell works with Debian 9.++## 0.21.0+### Fixed+- Fix output of web API calls when issuing limits with offsets.++### Changed+- Massive refactoring of entire codebase.++### Added+- Work in progress Redis caching for extended public keys.++## 0.20.2+### Changed+- Filter xpub address balances on web API to show only addresses that have been used.++### Removed+- Remove paths and addresses from xpub summary.++## 0.20.1+### Changed+- Refactor code greatly.+- Depend on new Haskoin Store package to avoid missing tx broadcasts.+- Merge StoreStream class into StoreRead.+- Move former streaming functions to use lists instead of conduits.+- Remove excessively verbose debugging.++## 0.20.0+### Added+- Set minimum version bounds for some dependencies.+- Now compatible with GHC 8.8.+- Extended key caching system using Redis.++### Changed+- Massively refactored codebase.+- Less verbose debug logging.++### Removed+- Removed conduits for faster queries.++## 0.19.6+### Added+- Use a parallel strategy to compute key derivations.++### Fixed+- Do not fail health check upon transaction timeout while syncing.++### Changed+- Do not use conduits for xpub balance streams.+- Multiple minor refactorings.++## 0.19.5+### Changed+- Minor refactor to block import code.++### Fixed+- Minor fix to transaction timeout check.++## 0.19.4+### Fixed+- Clarify and correct health check algorithm.++## 0.19.3+### Changed+- Add address transactions to cache.+- Improve multi-address transaction retrieval algorithms.++## 0.19.2+### Removed+- Cache-Control header turned out to be unnecessary.++### Fixed+- Fix some minor errors in web module.++## 0.19.1+### Added+- Set Cache-Control header to no-cache.++## 0.19.0+### Changed+- Store mempool in single key/value pair.++## 0.18.11+### Changed+- Do not stream mempool.++## 0.18.10+### Removed+- Disable timeout checks for testnets.++## 0.18.9+### Added+- Endpoint to locate a block by unix timestamp.++### Removed+- No more persistence for peers due to dependency on newest haskoin-node.++## 0.18.8+### Added+- Transaction and block timeouts for health check.+- Raw blocks.++## 0.18.7+### Fixed+- Missing tranasctions on xpub listings.++## 0.18.6+### Removed+- Follow Stack advise removing `-O2` GHC option.++## 0.18.5+### Added+- Compatibility with SegWit on extended public key endpoints.++### Changed+- Fix syncing peer not reset after timeout.+- Use simpler monad for streaming data.++## 0.18.4+### Changed+- Bump Haskoin Node to fix peers not stored and excessively verbose logging.++## 0.18.3+### Changed+- Configurable HTTP request logging. Disabled by default.++## 0.18.2+### Changed+- Fix for memory leak.++## 0.18.1+### Removed+- Remove transaction count from xpub summary object.++## 0.18.0+### Changed+- Simplified limits and start point handling on web API.+- Made transaction streaming algorithm faster for xpub transactions.+- Extended public key summary output contains all addresses that have received transactions.++### Added+- Fine-grained control for maximum limits via command line options.+- Transaction hash as starting point.+- Block hash as starting point.+- Timestamp as starting point.+- Configurable xpub gap limit.+- Transaction count added to xpub summary.+- UTXO count added to xpub summary.++### Removed+- Mempool endpoint now has no limits or offsets and always returns full list.+- Extended public key summary output no longer includes any transactions.+- Offsets not allowed for transaction lists involving multiple addresses or xpubs.+- Confusing block position parameter no longer part of web API.++## 0.17.2+### Changed+- Stream address balances and unspent outputs.+- Add configurable max value for limit and offset which defaults at 10000.++## 0.17.1+### Changed+- When posting a transaction to the network, timeout is now five seconds.+- Improve error message when transaction post timeout reached.+- Remove obsolete `not found` error for transaction post.+- Endpoints for retrieving blocks now do streaming for better performance.+- Improve Swagger API documentation.++## 0.17.0+### Added+- Endpoints for retrieving block transactions.+- Endpoint for retrieving set of latest blocks.++### Changed+- Use standardized JSON and binary serialization schemes for raw transaction endpoints.++## 0.16.6+### Changed+- Now logging info messages too by default.+- Consolidated web logging in middleware.++### Removed+- UUIDs for web requests.++## 0.16.5+### Removed+- Remove concurrency from xpub balance requests to prevent RocksDB segfaults.++## 0.16.4+### Removed+- Remove concurrency from requests using iterators to prevent RocksDB from segfaulting.++## 0.16.3+### Added+- Debugging information for web API.++## 0.16.2+### Changed+- Debugging disabled by default (use `--debug` to enable).++## 0.16.1+### Added+- Cache mempool transactions.+- Improve initial syncing performance.++## 0.16.0+### Added+- Orphan transaction support.+- Full address balance cache in RocksDB.+- Full unspent output cache in RocksDB.++### Changed+- Significantly refactor code.+- Move web stuff to its own module.+- Change types related to databases.+- Make xpub balance, transaction and unspent queries fetch data in parallel.++## 0.15.2+### Added+- Internal data types to support orphan transactions.++### Changed+- Do not spam block actor with pings.+- Fix balance/unspent cache not reverting when importing fails.+- Fix transaction sorting algorithm not including transaction position information.+- Fix conflicting mempool transaction preventing block from importing.++## 0.15.1+### Changed+- Fix duplicate coinbase transaction id bug.++## 0.15.0+### Removed+- Removed `PreciseUnixTime` data type.++### Changed+- Use 64 bits for Unix time representation.+- Data model now uses simplified Unix time representation.++## 0.14.9+### Added+- Last external/change index information to xpub summary object.++## 0.14.8+### Added+- Endpoint for xpub summaries.+- Endpoints for full transactions.+- Ability to query by offset/limit.+- More API documentation.++## 0.14.7+### Added+- Debug information for `publishTx`.++### Changed+- Transaction publishing no longer requests mempool.+- Fixed serialization for `TxId` freezing the entire program by infinitely recursing.++## 0.14.6+### Changed+- Enable full threading again as it was deemed not responsible for the freezing behaviour.++## 0.14.5+### Changed+- Enable threading but leave it at a single thread to be able to open more than 1024 files.++## 0.14.4+### Changed+- Target LTS Haskell 13.20 and disable threading in new attempt to fix freezing bug.++## 0.14.3+### Changed+- Remove `-O2`.++## 0.14.2+### Changed+- Target LTS Haskell 12.26 to attempt to fix freezing bug.++## 0.14.1+### Added+- Extra debugging around code that freezes.++### Changed+- Bump dependency on `haskoin-node`.++## 0.14.0+### Removed+- Dump slow protobuf serialization.++### Added+- Add custom serialization.+- Extra debug logging.++### Changed+- Bump `haskoin-core` and `haskoin-node`.++## 0.13.1+### Changed+- Bump `haskoin-node` in `stack.yaml`.+- Do not send empty `getdata` messages.++## 0.13.0+### Added+- Primitive content negotiation for web exceptions.+- Protobuf support for errors.+- Protobuf support for tranasction ids.++### Changed+- Protobuf format changed in non-backwards-compatible manner for transaction ids.++## 0.12.0+### Added+- Support for binary serialization using Protocol Buffers.+- New endpoints for binary raw transactions (not hex-encoded).++### Changed+- Services field now a hex string instead of a number to avoid overflowing signed 64-bit integer.+- Flatten list of block data objects when responding to request for multiple block heights.+- Errors now reported in plain text without container JSON object.+- Transaction broadcasts are responded to with transaction id in plaintext hex (no JSON).+- Remove database snapshots to improve performance.++## 0.11.2+### Changed+- Fix duplicate mempool transaction announcements in event stream.++## 0.11.1+### Removed+- Removed latest block time check.++## 0.11.0+### Changed+- Improve post transactions endpoint.++## 0.10.1+### Changed+- Fix bug where transaction lists from multiple addresses would sort incorrectly.+- Address gap reduced to 20.++## 0.10.0+### Removed+- Remove addresses from transaction lists.+- No longer use container objects for xpub transactions.++## 0.9.3+### Added+- Permissive CORS headers to allow queries from any domain.+- Improved documentation using real-world examples from the BCH testnet.++## 0.9.2+### Added+- HTTP JSON API switch to turn off transaction list when retrieving blocks.++## 0.9.1+### Added+- Total block fees.+- Total block outputs.+- Block subsidy.++### Changed+- Do not consider the blocks less one block away from headers as out of sync in health check.+- Health check now returns HTTP 503 when not OK or out of sync.++## 0.9.0+### Added+- Version to health check output.+- Block weight for segwit.+- Transaction weight for segwit.++## 0.8.1+### Added+- Health check endpoint.++## 0.8.0+### Added+- Limits and skips.+- Add timestamps to transactions.+- Add transaction count to address balance object.+- Add Merkle root to block data.+- Total funds received by an address now shows up in balance.+- Balances for any address that ever had funds show up in xpub endpoints.++### Changed+- Data model update.+- Performance improvement for xpub calls.+- Transactions are returned in reverse mempool/block order (highest or most recent first).+- Balance objects do not get deleted from database ever.++## 0.6.9+### Changed+- Reduce number of coinbase checks to 10,000 ancestors.++## 0.6.8+### Changed+- Further optimize coinbase after height checks.++## 0.6.7+### Changed+- Impose restrictions on recursion for coinbase after height checks.++## 0.6.6+### Added+- Check whether a transaction can be traced back to a coinbase after certain height.++## 0.6.5+### Changed+- Delete transactions in reverse topological order when reverting a block.++## 0.6.4+### Changed+- Do not fail silently when importing orphan transactions into the mempool.++## 0.6.3+### Changed+- Dummy release to bump haskoin-node in stack.yaml.++## 0.6.2+### Changed+- Correct bug where coinbase transactions weren't properly flagged.++## 0.6.1+### Changed+- Compatibility with Bitcoin Cash hard fork.+- Various bug fixes.++## 0.6.0+### Added+- Address balance cache in memory.++### Changed+- Simplify data model further.+- Fix bug importing outputs with UTXO cache.+- Unspent balances cannot be negative.++## 0.5.0+### Added+- Add UTXO cache in memory.+- Get transactions with witness data in segwit networks.++### Changed+- Paths for derivations in xpubs is a list and no longer a string.+- Various bug fixes.++## 0.4.2+### Removed+- Remove extended public key itself from output of relevant endpoints to save bandwidth.++## 0.4.1+### Changed+- Fix bug when deleting coinbase transactions.+- Extended public key API support.++## 0.4.0+### Changed+- Generate events for mempool transactions.+- Respond with entire block data when querying blocks by height.++## 0.3.1+### Changed+- Do not import transactions to mempool while not synced.+- Only sync mempool against a single peer.+- Allow duplicate transactions to fix re-introduced sync bug.++## 0.3.0+### Added+- Update dependencies.+- Keep orphan blocks and deleted transactions in database.+- Add a `mainchain` field for block data and a `deleted` field for transactions.+- Stream records for performance.+- Show witness data for transaction inputs in SegWit network.+- Support RBF in SegWit network.++### Changed+- Refactor all data access code away from actor.+- Refactor import logic away from actor.+- Abstract data access using typeclasses.+- Implement data access using clean layered architecture.+- Make most of import logic code pure.+- Database now in `db` as opposed to `blocks` directory.+- Use latest `haskoin-node`.++### Removed+- Remove some data from peer information output.+- Remove full transaction from address transaction data.+- Remove limits from address transaction data.+- Remove block data from previous output.+- Remove spender from JSON response when output not spent.+- Remove block hash from block reference.++## 0.2.3+### Removed+- Do not send transaction notifications if a block is being imported.++## 0.2.2+### Added+- Peer information endpoint.++### Changed+- Update `haskoin-node`.++## 0.2.1+### Changed+- Fix tests++## 0.2.0+### Added+- Documentation everywhere.+- Ability to retrieve address transactions.++### Changed+- New versions of NQE and Haskoin Node upstream.+- Improve and simplify API.+- Multi-element endpoints return arrays of arrays.+- Database snapshots for all queries are now mandatory.++### Removed+- Retrieving unspent and spent outputs for an address.+- Redundant API endpoints for multiple elements.++## 0.1.3+### Changed+- Fix a bug with transaction notifications.+- Improve handling orphan transactions.++## 0.1.2+### Changed+- Specify dependencies better.++## 0.1.1+### Changed+- Dependency `secp256k1` is now `secp256k1-haskell`.++## 0.1.0+### Added+- New `CHANGELOG.md` file.+- Bitcoin (BTC) and Bitcoin Cash (BCH) compatibility.+- RocksDB database.+- Mempool support.+- HTTP streaming for events.+- CashAddr support.+- Bech32 support.+- Rate limits.++### Changed+- Split out of former `haskoin` repository.+- Use hpack and `package.yaml`.++### Removed+- Removed Stylish Haskell configuration file.+- Remvoed `haskoin-core` and `haskoin-wallet` packages from this repository.
@@ -0,0 +1,936 @@+# Haskoin Store++[](https://github.com/jprupp/haskoin-store/actions/workflows/ci.yml)+[](https://hackage.haskell.org/package/haskoin-store)+[](https://hackage.haskell.org/package/haskoin-store-data)+[](https://github.com/jprupp/haskoin-store/blob/master/haskoin-store/LICENSE)++A Bitcoin (BTC) and Bitcoin Cash (BCH) block chain indexer with a REST API,+written in Haskell. It connects to the peer-to-peer network directly, so it+does not need a full node next to it, and keeps its indices in RocksDB.++- [API reference](https://github.com/jprupp/haskoin-store/blob/master/swagger.yaml)+- [Library documentation](https://hackage.haskell.org/package/haskoin-store)+ and [client documentation](https://hackage.haskell.org/package/haskoin-store-data)+- [Changelog](https://github.com/jprupp/haskoin-store/blob/master/CHANGELOG.md)+- [Report a bug](https://github.com/jprupp/haskoin-store/issues)++## Contents++- [Why Haskoin Store](#why-haskoin-store)+- [Design principles](#design-principles)+- [Features](#features)+- [Quick start](#quick-start)+- [Building from source](#building-from-source)+- [Running](#running)+- [Tutorial: following a wallet](#tutorial-following-a-wallet)+- [Configuration](#configuration)+- [API](#api)+- [How it works](#how-it-works)+- [Monitoring](#monitoring)+- [Using it from Haskell](#using-it-from-haskell)+- [Packages](#packages)+- [Development](#development)+- [Related projects](#related-projects)+- [License](#license)++## Why Haskoin Store++A Bitcoin node can tell you what is in block 800000, but not what the balance+of an address is, which transactions belong to a wallet, or which outputs it+can spend. Wallets, block explorers, payment processors and accounting tools+all need exactly those answers, for many users at once, in milliseconds.++The usual way to get them is to run a full node and an indexer next to it, and+operate both. Haskoin Store is one program instead. It connects to the+peer-to-peer network itself, downloads blocks and unconfirmed transactions,+keeps indices by address, transaction and extended public key in RocksDB, and+serves them over a REST API. It has been used to serve public Bitcoin and+Bitcoin Cash APIs such as api.haskoin.com.++## Design principles++- **Index for the questions people ask.** Every index is laid out so that an+ API query is one seek and a short scan in RocksDB, newest first, which keeps+ response times flat as the chain grows.+- **Never half-write.** Each block, and each batch of mempool transactions, is+ one atomic database write. A crash leaves the store at the previous block,+ with no repair step on restart.+- **Unconfirmed transactions matter.** The mempool is indexed and persisted like+ the chain, ordered before confirmed history, and handles replace-by-fee on+ Bitcoin. A wallet sees a payment the moment the network does.+- **Extended public keys are first class.** Wallets are mostly xpubs, so the+ store derives their addresses up to an exact gap, remembers the derivations,+ and can keep large wallets ready in a Redis shared by several stores.+- **Bounded resources.** API limits stop one request from monopolising the+ server, slow event clients are dropped, and RocksDB memory has a budget.+- **Operable.** A health endpoint that tells a load balancer when an instance is+ behind, StatsD metrics for everything, configuration through flags or+ environment variables, and a Docker image.+- **Follow the work, not a node.** The store trusts the chain with the most+ proof of work, as reported by [haskoin-node](https://github.com/jprupp/haskoin-node),+ and checks what it needs to keep its indices consistent. It does not+ re-validate scripts; run it against the network you trust, or next to your+ own node with `--peer`.++## Features++| Area | What you get |+| --------------- | ------------------------------------------------------------------------------------------- |+| Networks | Bitcoin and Bitcoin Cash: mainnet, testnets and regtest |+| Addresses | Balances, transactions and unspent outputs (UTXO) of any address, alone or many at once |+| Xpubs | Balances, transactions and UTXO of extended public keys, for legacy, nested SegWit and native SegWit addresses, with an optional Redis cache for large wallets |+| Blocks and txs | Lookup by hash, height or time; raw serialized forms; transactions of a block |+| Mempool | Persistent, indexed by arrival time, with replace-by-fee on BTC and pruning of old entries |+| Formats | JSON, pretty JSON, or a compact binary encoding |+| Live events | New blocks and transactions as a JSON stream or over a WebSocket |+| Compatibility | Endpoints in the style of Blockchain.info under `/blockchain/` |+| Operations | Health check, StatsD metrics, RocksDB memory limits, Docker image |++### What is not included++- No wallet: it never holds keys or signs anything.+- No full validation of scripts and consensus rules. It follows the chain with+ the most work and indexes what peers send.+- No address or transaction search by anything but its hash; no indices on+ scripts without an address.++## Quick start++With [Docker](https://www.docker.com/):++```sh+git clone https://github.com/jprupp/haskoin-store.git+cd haskoin-store+docker build -t haskoin-store .+docker run -d --name haskoin-store -p 3000:3000 \+ -e NET=btc -e DISCOVER=true \+ -v haskoin-store-data:/home/haskoin/.haskoin-store \+ haskoin-store+curl 'http://localhost:3000/block/best?pretty=true'+```++The first sync downloads and indexes the whole chain, which takes a long+time and a lot of disk. The API answers while it syncs; `/health` reports+whether the node is in sync.++## Building from source++You need [Stack](https://docs.haskellstack.org/) and three C libraries:+[zlib](https://zlib.net/),+[libsecp256k1](https://github.com/bitcoin-core/secp256k1) and+[RocksDB](https://rocksdb.org/). Stack installs the right GHC by itself.++**Debian or Ubuntu**++```sh+sudo apt install git pkg-config zlib1g-dev libsecp256k1-dev librocksdb-dev haskell-stack+```++**Fedora**++```sh+sudo dnf install git pkgconf zlib-devel libsecp256k1-devel rocksdb-devel stack+```++Then build and install the `haskoin-store` binary to `~/.local/bin`:++```sh+git clone https://github.com/jprupp/haskoin-store.git+cd haskoin-store+stack build --copy-bins+~/.local/bin/haskoin-store --help+```++**Any distribution, with [Nix](https://nixos.org/)**: Stack fetches the C+libraries listed in `stack.yaml` from Nix instead of the system.++```sh+stack --nix build --copy-bins+```++## Running++```sh+haskoin-store --net btc --discover+```++Give it either `--discover` or at least one `--peer`; with neither it turns+discovery on by itself. Data goes in a directory named after the network inside+`--dir`, so one data directory can hold several networks.++For a big public deployment, also set `MALLOC_ARENA_MAX=2` (the Docker image+does) to keep glibc from fragmenting RocksDB's memory.++## Tutorial: following a wallet++This walks through what a wallet back end typically does with the API, on a+store that is already running on `localhost:3000`.++### 1. Wait for the store to be ready++```sh+curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/health+```++`200` means the index is caught up with the network. While it syncs you get+`503`, and the body says which check fails.++### 2. Get the balance of a wallet++Most wallets are an extended public key. Ask for its summary, and say which+kind of addresses it derives (`legacy`, the default, `compat` for nested+SegWit, `segwit` for native SegWit):++```sh+curl 'localhost:3000/xpub/xpub6C...?derive=segwit&pretty=true'+```++The answer has confirmed and unconfirmed balances, and the index of the last+used receiving and change address, so the wallet knows where to derive the+next address.++If you get `derive` wrong, the store says so: a wallet with no transactions+of the kind you asked for, but some of another kind, gets a 400 whose message+names the right one, instead of an empty wallet that looks like a zero+balance.++### 3. List its transactions, page by page++```sh+curl 'localhost:3000/xpub/xpub6C.../transactions?derive=segwit&limit=50'+```++Results are newest first, unconfirmed ones on top. To get the next page, start+from the last transaction you received instead of using an offset:++```sh+curl 'localhost:3000/xpub/xpub6C.../transactions?derive=segwit&limit=50&offset=1&height=<last txid>'+```++`offset=1` skips the transaction you already have. Paging by starting point+stays fast however deep you go, and does not shift when new transactions+arrive.++### 4. Find spendable outputs++```sh+curl 'localhost:3000/xpub/xpub6C.../unspent?derive=segwit'+```++Each output comes with its derivation path, so the wallet knows which key signs+it.++### 5. Broadcast a payment++```sh+curl -X POST --data-binary @tx.hex localhost:3000/transactions+```++The body is the signed transaction in hex. The store sends it to its peers and+returns the transaction id. It appears in the mempool, and in the wallet's+history, as soon as a peer relays it back.++### 6. Stay up to date without polling++```sh+curl -N localhost:3000/events+```++Each line is a new block or a mempool change. A wallet back end keeps this open+and refreshes the affected wallets when an event arrives, instead of asking+every few seconds.++After a lost connection, come back with the hash of the last event you+heard, and the stream starts with what came after it:++```sh+curl -N "localhost:3000/events?after=9a5c…"+```++The server keeps its last 10,000 events. It answers 404 when it no longer+has the one you name: read your wallets again, and listen without `after`.++Over a WebSocket at the same path, a client can ask for only the+transactions it cares about. It sends one or more of these, as text:++```json+{"type":"watch","addresses":["bc1q…","1A1z…"]}+{"type":"watch","xpub":"xpub6C…","derive":"segwit"}+{"type":"filterload","filter":"b50f","functions":11,"tweak":0,"flags":0}+```++From then on it hears of a transaction only when it touches one of the+addresses, an address of the key, or matches the Bloom filter (BIP 37, as+SPV wallets use). Blocks are always sent. A key is followed as a wallet+uses it: the gap of unused addresses moves on as payments come. For a key+with several types of address, send one message for each type.++A client that connects and then sends its messages gets every transaction+until the first message is read. To start with a choice, put it in the URL,+of the WebSocket or of the HTTP stream:++```sh+curl -N "localhost:3000/events?quiet=true" # blocks only+curl -N "localhost:3000/events?addresses=bc1q…,1A1z…"+curl -N "localhost:3000/events?xpub=xpub6C…&derive=legacy,segwit"+```++`quiet=true` sends blocks alone until the client watches something; a+WebSocket client with a long list connects with it and sends the list in+messages. The [API reference](swagger.yaml) has every message and+parameter, and the binary form of each message.++### 7. Make big wallets fast++Start the store with `--redis` and a Redis (or Valkey) server. Xpubs with at+least `--redis-min-addrs` used addresses, or at least `--redis-min-txs`+transactions in their addresses, are then cached after their first+query and answered from Redis, transactions in full included. Several stores+can share one Redis: they keep each other's cache up to date, and a store that+has not imported a transaction yet still serves it from the cache. Add+`nocache=true` to a query to read the database instead.++Servers behind a load balancer should also be started with `--fleet`+([A fleet of servers](#a-fleet-of-servers)): they then answer every request+alike, and share a cache of xpubs that is built for that.++## Configuration++Every option has an environment variable that sets its default, which is handy+in containers. Booleans in variables accept `true`/`false`, `yes`/`no`,+`on`/`off` and `1`/`0`. `haskoin-store --help` shows the current values.++### Network and peers++| Option | Environment | Default | Meaning |+| --- | --- | --- | --- |+| `-n`, `--net` | `NET` | `bch` | Network: `btc`, `bch`, or a test network (see `--help`) |+| `-d`, `--dir` | `DIR` | `~/.haskoin-store` | Data directory |+| `--discover` | `DISCOVER` | off | Discover peers through DNS seeds and relayed addresses; on regtest it finds none, so name a `--peer` |+| `-p`, `--peer` | `PEER` | none | Connect to `HOST[:PORT]`; repeat for more, space-separated in `PEER` |+| `--max-peers` | `MAX_PEERS` | `20` | Most peers to connect to |+| `--peer-timeout` | `PEER_TIMEOUT` | `120` | Seconds before an unresponsive peer is dropped |+| `--max-peer-life` | `MAX_PEER_LIFE` | `172800` | Seconds after which a peer is dropped, so connections rotate |+| `--block-window` | `BLOCK_WINDOW` | `64` | Blocks to request from the syncing peer at a time |++### Mempool++| Option | Environment | Default | Meaning |+| --- | --- | --- | --- |+| `--no-mempool` | `NO_MEMPOOL` | off | Do not index unconfirmed transactions |+| `--sync-mempool` | `SYNC_MEMPOOL` | off | Ask each peer for its whole mempool once in sync |+| `--wipe-mempool` | `WIPE_MEMPOOL` | off | Empty the mempool area of the database at start |+| `--mempool-timeout` | `MEMPOOL_TIMEOUT` | `14` | Days after which unconfirmed transactions are deleted (0 = never) |++### Web server and limits++| Option | Environment | Default | Meaning |+| --- | --- | --- | --- |+| `--host` / `--port` | `HOST` / `PORT` | `*` / `3000` | Address the API listens on |+| `--noweb` | `NO_WEB` | off | Index only, no API |+| `--tls-cert` / `--tls-key` | `TLS_CERT` / `TLS_KEY` | none | Serve HTTPS with this certificate chain and private key, both files in PEM |+| `--no-xpub` | `NO_XPUB` | off | Disable the xpub endpoints |+| `--no-blockchain-info` | `NO_BLOCKCHAIN_INFO` | off | Disable the `/blockchain/` endpoints and price lookups |+| `--max-item-count` | `MAX_ITEM_COUNT` | `200000` | Most items in a list (0 = no limit) |+| `--max-full-item-count` | `MAX_FULL_ITEM_COUNT` | `5000` | Most full transactions in a list (0 = no limit) |+| `--max-offset` | `MAX_OFFSET` | `50000` | Largest `offset` (0 = no limit) |+| `--def-item-count` | `DEF_ITEM_COUNT` | `100` | Items in a list when no `limit` is given |+| `--xpub-gap` | `XPUB_GAP` | `32` | Unused addresses kept after the last used one of each chain of an xpub; payments further away are not seen |+| `--xpub-gap-init` | `XPUB_GAP_INIT` | `20` | Addresses looked at for an xpub with no transactions; an xpub with none used among them counts as unused |+| `--ticker-url` | `TICKER_URL` | Blockchain.info ticker | Source of current prices |+| `--price-history-url` | `PRICE_HISTORY_URL` | Blockchain.info price index | Source of historical prices |+| `--ticker-refresh` | `TICKER_REFRESH` | `90000000` | Microseconds between price updates |++### Redis cache++| Option | Environment | Default | Meaning |+| --- | --- | --- | --- |+| `--redis` | `REDIS` | off | Cache large xpubs in Redis |+| `--redis-url` | `REDIS_URL` | local Redis | Redis URL, such as `redis://host:6379` |+| `--redis-min-addrs` | `REDIS_MIN_ADDRS` | `100` | Cache xpubs with at least this many used addresses |+| `--redis-min-txs` | `REDIS_MIN_TXS` | `1000` | Or with at least this many transactions in their addresses |+| `--redis-max-xpubs` | `REDIS_MAX_XPUBS` | `10000` | Evict the least recently used xpubs past this many |++Every key of the cache starts with `hs2:<network>:`, so a Redis can be shared+by stores of several networks, and by stores of older versions, whose cache+uses other keys. `--redis-max-keys` and `--redis-sync-interval` of older+versions are gone.++### A fleet of servers++| Option | Environment | Default | Meaning |+| --- | --- | --- | --- |+| `--fleet` | `FLEET` | off | Answer as one of the servers that share the Redis of `--redis` |++Servers behind a load balancer import the same blocks and transactions at+slightly different times, so the same request can get different answers+depending on which of them it reaches. Start every one of them with+`--redis`, the same `--redis-url` and `--fleet`, and they all answer from+the same block and the same mempool:++- **The block** is the newest that every live server has. Each server+ imports blocks when it gets them and keeps its database as it was at each+ of its last six, and a request is answered from the block that the fleet+ agreed on. The fleet moves to a new block in one step, when the last+ server has it.+- **The mempool** is one pool in Redis, which every server offers its+ unconfirmed transactions to and all answer from. A transaction has one+ time of arrival there, so lists come in the same order everywhere.+- **A server that falls behind** answers 503 with `behind-fleet` rather+ than something that the others would not say, and stops counting after+ ten seconds without a report, so that it holds nobody up. A server whose+ health check fails stops reporting.+- **`/events` is the same stream on every server.** What changes the+ shared mempool or moves the fleet to a block writes an event to Redis+ in the same step, and every server sends those events, in that order,+ instead of what its own node saw. A block is told when the whole fleet+ can answer about it. Each server keeps the same last events, so a+ client that reconnects with `after=` may land on any of them.+- **Without Redis** each server answers from its own database, and+ `/health` says 203, as it does for the cache.+- **Large xpubs are cached for the whole fleet**, under the same+ `--redis-min-addrs`, `--redis-min-txs` and `--redis-max-xpubs` as the+ cache of a server alone, which should be the same on every server. An+ entry holds the chain's part of a wallet at the block that the fleet+ answers from, and the pool's part is kept by the pool itself, so a+ cached answer is the one that the database gives, on every server. The+ server that moves the fleet to a new block brings the cache there; in+ the moment before it has, and for a wallet that just gained or lost an+ address, requests are answered from the database. A reorganisation+ empties the cache. With it a request about a wallet of 464 addresses+ and 19,499 transactions takes 1.3 to 2.2 milliseconds, where it took 5+ to 38.++The rules, and the tests that hold Redis to them, are described in "A fleet+that answers as one" in `docs/layered-database.md`.++### Health check++| Option | Environment | Default | Meaning |+| --- | --- | --- | --- |+| `--max-lagging-blocks` | `MAX_LAGGING_BLOCKS` | `3` | Most blocks the index may lag behind the headers |+| `--max-pending-txs` | `MAX_PENDING_TXS` | `10000` | Most transactions waiting to be imported |+| `--min-peers` | `MIN_PEERS` | `1` | Fewest connected peers |+| `--block-timeout` | `BLOCK_TIMEOUT` | `14400` | Most seconds since the last block |+| `--tx-timeout` | `TX_TIMEOUT` | `1800` | Most seconds since the last transaction |+| `--health-check-interval` | `HEALTH_CHECK_INTERVAL` | `30` | Seconds between checks |++### Metrics and logging++| Option | Environment | Default | Meaning |+| --- | --- | --- | --- |+| `--statsd` | `STATSD` | off | Send metrics to StatsD |+| `--statsd-host` / `--statsd-port` | `STATSD_HOST` / `STATSD_PORT` | `localhost` / `8125` | StatsD server |+| `--statsd-prefix` | `STATSD_PREFIX` | `haskoin_store` | Prefix of every metric |+| `--statsd-no-events` | `STATSD_NO_EVENTS` | off | Do not send individual samples |+| `--statsd-aggregates` | `STATSD_AGGREGATES` | off | Also send aggregated statistics every second |+| `--debug` | `DEBUG` | off | Verbose logging |++### RocksDB++| Option | Environment | Default | Meaning |+| --- | --- | --- | --- |+| `--no-bloom` | `BLOOM` | on | No Bloom filters on what is read by key; `BLOOM=false` is the same |+| `--max-open-files` | `MAX_OPEN_FILES` | `2000` | Open files; bounds index and filter memory (0 = unlimited) |+| `--write-buffer-mb` | `WRITE_BUFFER_MB` | `256` | Memtables across all column families (0 = unlimited) |+| `--max-wal-mb` | `MAX_WAL_MB` | `1024` | Write-ahead log size that forces flushes (0 = RocksDB default) |+| `--block-cache-mb` | `BLOCK_CACHE_MB` | `1024` | Shared block cache, holding index and filter blocks too (0 = RocksDB default) |++Memory used by RocksDB is roughly the block cache plus the memtables, on top of+the Haskell heap. `/dbstats` shows where it goes.++Bloom filters are on by default. They let RocksDB skip the table files that+cannot have a key, which is most of what a lookup of a transaction, an+output or a balance would otherwise read.++- **What has them:** the data that is read by key: transactions, spenders,+ unspent outputs, blocks, balances and the mempool. The two indices by+ address, which are scanned, do not.+- **What they cost:** about 1.25 bytes for each key, held in the block+ cache. On a chain with billions of keys that is gigabytes: size+ `--block-cache-mb` for it, or turn them off with `--no-bloom`.+- **An existing database** gets filters for the table files that are+ written from now on, so their memory comes gradually, as RocksDB+ compacts. The migration to database version 22 rewrites nearly every+ transaction, and with it most of the filters arrive at once.+- **`--bloom`** was the way to turn them on, and is still accepted. Up to+ version 1.8.1 it built no filters at all on RocksDB 6.24 or later,+ because of a wrong type in the binding, fixed in+ `rocksdb-haskell-jprupp` 2.6.3.++## API++Full reference: [`swagger.yaml`](swagger.yaml), also served at+[api.haskoin.com](https://api.haskoin.com/). It explains the conventions of+the API (amounts, ordering, paging, formats, errors, unconfirmed transactions+and reorganisations) and the reasons for them, as well as every endpoint.++```sh+curl localhost:3000/address/bc1q…/balance+curl localhost:3000/xpub/xpub6C…/balances+curl localhost:3000/transaction/<txid>+curl -N localhost:3000/events # stream of new blocks and txs+```++- The root of the server, `/`, is the reference of its API as a page that+ can try requests, and `/swagger.yaml` the file behind it.+- Add `?pretty=true` for indented JSON.+- Send `Accept: application/octet-stream` for the binary format: see+ [Binary format](#binary-format) below.+- Lists are paged with `limit` and `offset`, newest first. `height` begins+ a page at a transaction hash, a block hash, a block height or a Unix time+ instead.+- Parameters are checked: a parameter that an endpoint does not accept, or+ a value it cannot read, gives 400 with a message that names it, instead of+ being ignored. The Blockchain.info endpoints accept `format=json`, which+ clients written for them often send.+- Errors come back with an HTTP status, in the format that was asked for:+ 400 for bad input, 404 for unknown things, 409 when a short transaction+ number is ambiguous, 413 for requests that ask for too much.++### HTTP and HTTPS++- **`HEAD` and `OPTIONS`** are answered, the second with what a browser+ may send from another site.+- **Answers are compressed** for a client that sends+ `Accept-Encoding: gzip`, but for the stream of events.+- **Long lists go in a body.** The endpoints that take a list of+ addresses, blocks, heights or transactions also take it under `POST`,+ as a form (`application/x-www-form-urlencoded`), so that it does not+ have to fit in a URL. `/transactions` does not: `POST` there publishes+ a transaction.+- **A 503 says when to try again**, in `Retry-After`.+- **HTTPS is optional and off by default.** With `--tls-cert` and+ `--tls-key` the server speaks HTTPS on its port, and nothing else. A+ public service usually has a proxy in front that holds the certificate;+ this is for a server by itself. The files are read at start, so a+ renewed certificate takes a restart.++### Binary format++Every endpoint but those under `/blockchain/` and `/dbstats` also answers+in a compact binary format, which is smaller and faster to decode than+JSON. The Haskell client in `haskoin-store-data` speaks it, and+`swagger.yaml` has a section, "Binary format", that is enough to write a+client in another language: the primitives, the layout of every type, and+what each endpoint returns.++- **Ask for it** with `Accept: application/octet-stream`. In a list of+ types it wins when its `q` weight is above 0 and no less than that of+ `application/json`; a wildcard or no header gives JSON. The answer has+ `Content-Type: application/octet-stream`, and every answer has+ `Vary: Accept`.+- **Errors come in binary too**: one byte that says which error, and a+ message or a list of transactions for those that have one. Up to+ version 1.8.1 they were JSON whatever was asked, so a client that also+ talks to older servers reads the `Content-Type` of an error.+- **Lists start with their length**, the raw lists of blocks and+ transactions included, which up to version 1.8.1 had none.+- **`/events` is the exception**: a stream of events of 33 bytes each,+ with no count, for as long as the connection is open.+- **Over the WebSocket** binary is binary frames, for the events and for+ the commands that choose transactions. The server answers in the kind+ of frame that it was last sent.+- Everything is big-endian, except what is copied from the peer-to-peer+ protocol, such as a whole transaction or block.+++### Endpoints at a glance++| Area | Endpoints |+| --- | --- |+| Blocks | `/block/best`, `/block/latest`, `/block/:hash`, `/blocks`, `/block/height/:h`, `/block/heights`, `/block/time/:t`, `/block/mtp/:t`, each with a `/raw` variant where it makes sense |+| Transactions | `/transaction/:txid`, `/transactions`, `/transactions/block/:hash`, `/transaction/:txid/after/:height`, `POST /transactions`, `/raw` variants |+| Mempool | `/mempool` |+| Addresses | `/address/:addr/{transactions,transactions/full,balance,unspent}` and the multi-address forms `/address/{transactions,transactions/full,balances,unspent}?addresses=…` |+| Xpubs | `/xpub/:xpub`, `/xpub/:xpub/{transactions,transactions/full,balances,unspent}`, `DELETE /xpub/:xpub` (drop from cache); `derive=legacy\|compat\|segwit` picks the address type (`normal`, the old name of `legacy`, still works) |+| Network | `/peers`, `/health` (503 when unhealthy, 203 without its Redis), `/dbstats` |+| Events | `/events`, as JSON lines over HTTP or as a WebSocket; `after=` resumes, and a WebSocket client can filter by address, xpub or Bloom filter |+| Blockchain.info | `/blockchain/multiaddr`, `/blockchain/balance`, `/blockchain/rawaddr/:addr`, `/blockchain/unspent`, `/blockchain/rawtx/:txid`, `/blockchain/rawblock/:hash`, `/blockchain/latestblock`, `/blockchain/unconfirmed-transactions`, `/blockchain/export-history`, `/blockchain/q/…` |++Turn off the xpub endpoints with `--no-xpub` and the Blockchain.info ones+with `--no-blockchain-info`.++## How it works++```+peers ──► haskoin-node ──► block store ──► RocksDB ──► web server ──► clients+ │ │ ▲+ └─ headers └─ events ──► cache ◄──► Redis ◄──► other stores+```++- **haskoin-node** keeps a pool of peers and syncs block headers.+- The **block store** (`Haskoin.Store.BlockStore`) downloads blocks from one+ peer at a time and mempool transactions from all of them, and imports them+ with the rules in `Haskoin.Store.Logic.Layered`. Each import is one atomic+ RocksDB batch, so a crash never leaves a half-written block.+- Reorganisations are handled by reverting blocks until the best block is+ back on the main chain of headers.+- The **cache** (`Haskoin.Store.Cache`) keeps xpubs with many used addresses+ or many transactions in Redis: their balances, transactions, unspent+ outputs and the bodies of their transactions. It never computes an entry+ from what it guesses changed: when a block, a mempool transaction or a+ reorganisation touches a cached xpub, the store reads from RocksDB, with+ the same queries that answer uncached requests, the transactions that+ changed and the outputs they create and spend, and writes the differences+ in one Lua script, together with every transaction body the entry needs+ that Redis does not have yet (bodies it has, such as those of the+ transactions a block confirms, are rewritten just before, in small+ scripts, so that a large block does not hold Redis for long).+ The time of a reload no longer grows with the size of the xpub. The store+ reads the whole xpub instead when it first caches it, and whenever it+ cannot be sure the entry has every earlier change: when the entry is more+ than a block behind, when an address with transactions enters or leaves+ the gap, when the balance of an address that no change touched differs+ from Redis's and its newest transactions do not explain why, and on a+ schedule, every five to ten minutes. The interval depends on the xpub, so+ that xpubs cached together are not all read whole at the same moment,+ which would hold them all in memory at once. Under load the database is+ often a little ahead of the events being applied, so a reload first adds+ the transactions that the database has for such an address since the+ block the entry was written at, and the mempool transactions of the entry+ that have since been confirmed or dropped. The script+ writes nothing if the entry changed since it was read, so the entry is+ always what the database says; and Redis, which answers one client at a+ time, is held for the size of the change, not of the xpub. A page of a+ cached xpub is one round trip to Redis. The Lua scripts are in+ `src/Haskoin/Store/Cache/lua/`, one file each, embedded in the library+ when it is compiled. Stores sharing a Redis claim each change, so it+ is applied once, by the first store to see it; a store never replaces an+ entry that knows more than its database (a higher block, or a transaction+ it has not seen), and a store that is catching up does no cache work.+ Without Redis, xpub queries read RocksDB directly. The ways an entry can+ still come to differ from the database unnoticed, such as a missed spend of+ an output of a cached transaction, and what would rule them out, are in+ `docs/cache-consistency.md`.+- A **fleet** (`Haskoin.Store.Fleet.Member`, with `--fleet`) has a cache of+ xpubs of another kind (`Haskoin.Store.Fleet.XPub`), because its servers+ do not answer from their own databases but from one block and one shared+ mempool. An entry has two layers: what the chain has for the addresses of+ the xpub at the block that the fleet answers from, and what the shared+ pool has for them, which the script of the pool writes as it changes the+ pool. The first follows from the chain, so any server writes the same,+ and nothing has to be claimed or repaired: the server that moves the+ fleet to a new block applies what the block did, and a cache that was+ not brought along is emptied. `docs/layered-database.md` has the design,+ the tests and the measurements.+- The **web server** (`Haskoin.Store.Web`) answers from RocksDB, and from the+ cache for cached xpubs and for transaction bodies that Redis has.++Xpub addresses are derived up to an exact gap: every address up to the last+used one of each chain, then exactly `--xpub-gap` unused ones, and always the+first `--xpub-gap-init` addresses. A payment further away is not seen, as a+wallet following BIP 44 with the same gap would not see it; raise the gap to+see it.++An xpub does not record which kind of address its wallet derives, so a+request names it with `derive`. When an xpub has no transactions with the+kind asked for, the store reads the first `--xpub-gap-init` addresses of the+other kinds, and if one of them is used it refuses the request with 400 and+names that kind: an empty answer would look like a wallet with no money, a+mistake that is easy to miss. The extra reads happen only for wallets that+look empty, and not at all on networks without SegWit, which have one kind of+address.++The database layout is described in `Haskoin.Store.Database.Types`.++Unconfirmed transactions are kept in an area of their own, and applied on+top of the chain when a query is answered, so that nothing the mempool does+changes the chain's data. This is version 21 of the database, the layered+layout of `docs/layered-database.md`, which has the reasons, the+measurements and the evidence. Up to version 20 they were written into the+same indices as the block chain. A database of version 20 is migrated the+first time this version of the store opens it: its mempool is moved to the+new area, in batches that can be interrupted, and the log says how many+transactions were moved. **There is no way back**: an older store refuses+the database afterwards, so take a copy of a production database first.++Version 22 makes the record of a confirmed transaction something that is+written once, by the block that confirms it, and the same on every server+that has that block.++- **Its time is the time of its block.** Until then a server that had seen+ a transaction in its mempool kept the time of that when a block+ confirmed it, so two servers gave two times for one confirmed+ transaction, and a server that synced later gave a third.+- **The spenders of its outputs are under keys of their own**, sixteen+ outputs to a key. Until then they were in the record, which was read and+ written whole again each time a block spent one of its outputs. Reading+ a confirmed transaction in full now takes a second key: 18 microseconds+ where it took 11.5, measured, and 0.8 milliseconds for a hundred where+ it took 0.4. Lists by address and by xpub do not read records and cost+ the same.++A transaction that leaves the mempool unconfirmed, because it was replaced,+double-spent by a block or too old, is no longer kept: the store forgets+it, and the API answers 404 for it, as it already did. Until version 22+its body stayed in the database, marked as deleted, for ever. The bodies+that are there in a migrated database are left where they are and+ignored, since finding them would mean reading every transaction.++A database of version 21, or of version 20 once its mempool is moved, is+brought to this the first time this version of the store opens it.+**The store reads every transaction that it has, and writes nearly every+one again, before it starts**: nothing lists the records that need it, and+most have a spender in them. The log says how far it is every million+transactions. A database of Bitcoin has more than a billion, so plan for+many hours, and for room on the disk for a second copy of the transactions+until RocksDB has compacted the first away. It can be interrupted, and+starts again from the first transaction, skipping what is done. As before,+**there is no way back**: take a copy first.+The parts:++- the migration from version 20 (`Haskoin.Store.Database.Migrate`);+- the keys that a mempool transaction writes, the summary that is kept for+ each address they touch, and what the mempool does to the balance and the+ unspent outputs of an address (`Haskoin.Store.Database.Mempool`);+- the rules that import blocks and transactions in that layout+ (`Haskoin.Store.Logic.Layered`), which the tests hold to the results of+ the rules of version 20, operation by operation and key by key;+- the queries over both areas (`Haskoin.Store.Database.Query.Layered`), each+ on a snapshot of the database, one for each request of the web server,+ which the tests hold to the answers of the queries of version 20, page by+ page.++The rules and queries of version 20 (`Haskoin.Store.Logic`,+`Haskoin.Store.Database.Query`) stay in the library for those tests, and+nothing in the store calls them.++## Monitoring++- **`/health`** returns the result of the latest check as JSON, with status 200+ when every check passes and 503 otherwise. The checks are: index lag behind+ the headers, age of the last block and of the last transaction, transactions+ waiting to be imported, and connected peers. Point your load balancer at it.+- **Without Redis** a store that has a cache goes on answering, from its own+ database, and `/health` says 203 instead of 200, with the header+ `Haskoin-Degraded: redis` and `"redis": {"ok": false}` in its JSON. A request waits at most two seconds for Redis+ before the store gives up on it, and the store tries again at every health+ check. 203 is in the range that load balancers take for healthy, so the+ store stays in rotation; alert on anything but 200 to hear of it.+- **StatsD**: with `--statsd`, the store reports the timing of every kind of+ request (`web.*`), database reads (`db.*`), cache hits, misses, reloads and+ full reloads (`cache.*`),+ and block store gauges such as block and header heights and mempool size+ (`store.*`). A server of a fleet reports what it does with the fleet's+ cache of xpubs under `fleet.cache.*`:++ | Metric | Kind | Meaning |+ | --- | --- | --- |+ | `fleet.cache.hits` | counter | Requests about an xpub whose balances the cache gave |+ | `fleet.cache.misses` | counter | Requests about an xpub that the database answered |+ | `fleet.cache.builds` | counter | Entries that this server built, or added addresses to |+ | `fleet.cache.build` | timing | Milliseconds to build an entry |+ | `fleet.cache.abandoned` | counter | Builds given up because the fleet or the entry moved on |+ | `fleet.cache.evictions` | counter | Entries deleted for being the least recently used |+ | `fleet.cache.emptied` | counter | Times that this server emptied the cache |+ | `fleet.cache.xpubs` | gauge | Xpubs in the cache, whichever server cached them |+ | `fleet.cache.addresses` | gauge | Addresses that those xpubs have between them |++ Misses include small xpubs, which are never cached, so the share of hits+ says how much of the load the cache takes, not how well it holds what it+ has. `fleet.cache.emptied` should move only at a reorganisation or when a+ server stops at the wrong moment: the log of the server says why each+ time, at the level of information. With `--debug` it also has a line for+ each entry built and each block that the cache was brought to.+- **`/dbstats`** returns RocksDB memory use per column family and RocksDB's own+ statistics, as text.+- **In a fleet** (`--fleet`), any request can be answered 503 with+ `{"error": "behind-fleet"}` by a server that does not have the block that+ the fleet answers from. Have the load balancer try another server, and+ alert if one keeps saying it: its `/health` will say why.+- **Memory.** The resident memory of the store is its GHC heap plus RocksDB:+ the block cache and memtables fill up to `--block-cache-mb` and+ `--write-buffer-mb`, then stay there. The executable is built with+ `--disable-delayed-os-memory-return`, so memory that the heap no longer+ needs goes back to the system at once. Without it, Linux keeps counting+ returned memory as resident (`LazyFree` in `/proc/<pid>/smaps`) until it+ needs it, and the heap looks as if it never shrinks after a peak, such as+ the full reload of a large cached xpub. Pass `+RTS+ --disable-delayed-os-memory-return -RTS` yourself if you build the store+ some other way. To tell such memory from a leak, see+ `stress/results/2026-11-09-memory/README.md`: how to read `smaps`, and a+ heap profile of a busy store, with live data flat at about 10 MiB.+- **Logs** go to standard error, one line per message, tagged with the+ component, such as `[Info#BlockStore] Best block: ...`.++## Using it from Haskell++Query a running server with `haskoin-store-data`:++```haskell+import Control.Monad.Except (runExceptT)+import Data.Default (def)+import Haskoin (btc, textToAddr, withContext)+import Haskoin.Store.WebClient++main :: IO ()+main = withContext $ \ctx -> do+ let cfg = def {net = btc} -- https://api.haskoin.com/btc/...+ Just addr = textToAddr btc "bc1q…"+ r <- runExceptT $ apiCall ctx cfg (GetAddrBalance addr)+ print r+```++The client appends the network name to `host`, as in the layout of+api.haskoin.com. To reach your own server, put it behind a proxy that maps+`/btc/` (or the name of your network) to it.++Or embed the indexer in your own program with `haskoin-store`: `withStore`+starts the node, the block store and the cache from a `StoreConfig`, and the+functions of `Haskoin.Store.Database.Query.Layered` query its database in+`IO`, on a view of it taken with `withView`, such as+`withView store.db (\v -> getBalance v addr)`.+Subscribe to `Store.pub` to receive `StoreEvent`s.++Every module has Haddock documentation; build it with `stack haddock`.++## Packages++Haskoin Store is two Haskell packages, each in its own repository:++- **`haskoin-store`**, this repository: the indexer, the web server and the+ executable.+- **[`haskoin-store-data`](https://github.com/jprupp/haskoin-store-data)**:+ the data types, their JSON and binary encodings, and a client for the API+ (`Haskoin.Store.WebClient`). Use this one to talk to a Haskoin Store server+ from Haskell. It is small to depend on, as it has no database or node.++They were released together from this repository up to 1.8.1. A change to+the wire format is made in haskoin-store-data, released, and then picked up+here; `swagger.yaml` stays here and describes the server.++## Development++```sh+stack build --test --no-run-tests+stack test # includes a 6000-block heap-growth test+scripts/format # ormolu, the code style used here+scripts/bench-web.sh # load-test the API with wrk+scripts/stress-cache.sh # stress the Redis cache, with a report+scripts/check-local-deps.sh # what CI checks: no local paths in stack.yaml+scripts/install-system-libraries.sh # the C libraries, on Ubuntu or Debian+```++`stack.yaml` must pin released versions of every dependency. Pointing+`extra-deps` at a sibling checkout such as `../haskoin-core` is fine for+local testing, but CI rejects it.++The tests use [hspec](https://hspec.github.io/). The unit tests need no network+access. The integration tests (`Haskoin.Store.IntegrationSpec`) run regtest+chains of Bitcoin Core and Bitcoin Cash Node, and Valkey, in+[Podman](https://podman.io/) containers, and check the store against them: the+exact gap, spends and change, mempool order, multiaddr counts, reorganisations,+and that the cache answers exactly like the database on every xpub endpoint.++- They are skipped if Podman cannot run containers, or if+ `HASKOIN_STORE_NO_PODMAN` is set.+- The first run builds the image `localhost/haskoin-store-test`, which needs+ network access: Ubuntu 26.04 pinned by digest, Valkey from a fixed snapshot of+ the Ubuntu archive, and the release tarballs of both nodes, checked against+ SHA-256 hashes pinned in `test/Haskoin/Store/Integration/Image.hs`. No+ third-party image is used.+- Containers are removed when the tests end, data lives in tmpfs mounts and+ under `/dev/shm`, and every container has a memory limit.+- Set `HASKOIN_TEST_LOG=1` to print the log of the store under test.++The test suite runs with at most four cores and a 4 GiB heap, so that a+runaway test fails instead of exhausting the machine.++`package.yaml` is the source of the package description. Change it rather+than `haskoin-store.cabal`, which+[hpack](https://github.com/sol/hpack) generates.++### Stress test of the cache++`scripts/stress-cache.sh [OUT_DIR] [-- OPTIONS]` measures the cache while it+works hard. It runs a regtest chain of Bitcoin Core, Valkey and wrk in Podman+containers from the test image, and the `haskoin-store` binary with the+cache on. A few wallets, whose keys the stress program (`stress/Main.hs`)+holds, make hundreds of thousands of transactions between them, signed by+the program and sent to the node in batches, with a block after each batch.+Every transaction changes two cached xpubs, so the cache reloads them over+and over, while wrk asks the server about the xpubs on five endpoints+(summary, balances, transactions, unspent outputs and multiaddr) the whole+time. At the end it compares the cached answers with those of the database,+and `scripts/stress-report.py` writes `OUT_DIR/report.md`: how fast the+transactions went in, how many reloads the cache did, how many of them read+the whole xpub, and how long they took as the xpubs grew, how far the server+fell behind, its CPU and memory, the+memory of Valkey, the commands that blocked Redis for 10 ms or more (from+its slow log, by script, with the number of arguments of each, which grows+with the size of a commit), and the latencies of each endpoint over time.++- The defaults are 200,000 transactions between 4 wallets of 200 receiving+ and 200 change addresses, 2,000 per block; pass `-- --help` for the+ options, and `-- --txs 20000` for a run of a few minutes. A default run+ takes about half an hour and up to about 7 GiB of memory.+- The raw measurements stay in `OUT_DIR` as JSON lines (`rounds.jsonl`,+ `samples.jsonl`, `wrk.jsonl`), with `summary.json` and the log of the+ server.+- No container is left behind, even if the script is interrupted or+ killed: the program removes its containers when it ends, and the script+ removes any that carry its label and stops the server.+- Results of past runs are kept in `stress/results/<date>/` for comparison:+ the report, the measurements (without the server log), and a README with+ the commits, the machine and what the run found. A new run worth keeping+ goes there too.+- To look for a leak, run the server with a heap profile by type, which needs+ no profiling build, and summarise it: the live heap over time and the types+ of closure that grew most.++ ```sh+ scripts/stress-cache.sh /tmp/run -- --txs 60000 \+ --server-rts '-N4 -M2g -T -hT -i10 -po/tmp/heap'+ scripts/heap-profile-summary.py /tmp/heap.hp+ ```++### Measurements for the layered database++`docs/layered-database/` has the programs behind the numbers in+`docs/layered-database.md`, with their output. They are not part of the+package; each says in its header how to build and run it.++- `MigrateBench.hs` times the migration to the layered layout against+ wiping the same mempool.+- `QueryCost.hs` writes the same synthetic history into a database of each+ layout, with each set of rules, and times both: importing blocks and+ mempool transactions, and every kind of query, for addresses and wallets+ that have nothing, a little and much in the mempool. It checks that both+ databases give the same answers.+- `FleetCost.hs` asks one store the same questions alone and as a member+ of a fleet, with its mempool in a pool in Redis, checks that the answers+ are the same, and times both. It asks about wallets a third way too,+ through the fleet's cache of xpubs.++### Contributing++Pull requests are welcome.++- Format the code with `scripts/format` (Ormolu), and keep every line of+ code shorter than 120 columns.+- Put scripts in files of their own rather than embedding them: Lua for+ Redis under `src/Haskoin/Store/Cache/lua/`, shell under `scripts/`, called+ from CI rather than written into the workflow.+- Add tests for the behaviour you change.+- Describe the change under a new version in `CHANGELOG.md`, which follows+ [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).+- Update `swagger.yaml` when you change the API.++## Related projects++- [haskoin-core](https://github.com/jprupp/haskoin-core) has the Bitcoin data+ types and protocol.+- [haskoin-node](https://github.com/jprupp/haskoin-node) connects to peers and+ syncs headers.+- [nqe](https://github.com/jprupp/nqe),+ [rocksdb-haskell-jprupp](https://github.com/jprupp/rocksdb-haskell),+ [rocksdb-query](https://github.com/jprupp/rocksdb-query) and+ [statsd-rupp](https://github.com/jprupp/statsd-rupp) provide concurrency,+ storage and metrics.++## License++Haskoin Store is released under the+[MIT License](https://github.com/jprupp/haskoin-store/blob/master/haskoin-store/LICENSE).
@@ -1,2 +0,0 @@-import Distribution.Simple-main = defaultMain
@@ -1,20 +1,10 @@ {-# LANGUAGE ApplicativeDo #-} {-# LANGUAGE CPP #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE ImportQualifiedPost #-}-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-}-{-# LANGUAGE TupleSections #-}-{-# LANGUAGE NoFieldSelectors #-} module Main (main) where import Control.Monad (when)-import Control.Monad.Cont (ContT (ContT), runContT)-import Control.Monad.Trans (lift) import Data.Char (toLower) import Data.Default (Default (..)) import Data.List (intercalate)@@ -34,7 +24,6 @@ withLogger, withSource, )-import Haskoin.Store.Common (loopForever) import Haskoin.Store ( Store (..), StoreConfig (..),@@ -43,9 +32,11 @@ runWeb, withStore, )+import Haskoin.Store.Common (loopForever) import NQE (receive, withSubscription) import Options.Applicative- ( Parser,+ ( Mod,+ Parser, auto, execParser, flag,@@ -53,6 +44,7 @@ header, help, helper,+ hidden, info, long, many,@@ -79,7 +71,6 @@ withStats, ) import Text.Read (readMaybe)-import UnliftIO (MonadIO) import UnliftIO.Directory ( createDirectoryIfMissing, getAppUserDataDirectory,@@ -112,10 +103,11 @@ minPeers :: !Int, webLimits :: !WebLimits, redis :: !Bool,+ fleet :: !Bool, redisURL :: !String, redisMinAddrs :: !Int,- redisMaxKeys :: !Integer,- redisSyncInterval :: !Int,+ redisMinTxs :: !Int,+ redisMaxXPubs :: !Int, noMempool :: !Bool, wipeMempool :: !Bool, syncMempool :: !Bool,@@ -136,6 +128,8 @@ noBlockchainInfo :: !Bool, noXPub :: !Bool, healthCheckInterval :: !Int,+ tlsCert :: !String,+ tlsKey :: !String, bloom :: !Bool, noWeb :: !Bool, maxOpenFiles :: !Int,@@ -145,13 +139,13 @@ } -- | Read an environment variable with a parser, or return the default if it is unset or invalid.-env :: (MonadIO m) => String -> a -> (String -> Maybe a) -> m a+env :: String -> a -> (String -> Maybe a) -> IO a env e d p = do ms <- lookupEnv e return $ fromMaybe d $ p =<< ms -- | Default configuration, taken from environment variables where set.-defConfig :: (MonadIO m) => m Config+defConfig :: IO Config defConfig = do dir <- getDir@@ -177,14 +171,16 @@ getWebLimits redis <- env "REDIS" False parseBool+ fleet <-+ env "FLEET" False parseBool redisURL <- env "REDIS_URL" "" pure redisMinAddrs <- env "REDIS_MIN_ADDRS" 100 readMaybe- redisMaxKeys <-- env "REDIS_MAX_KEYS" 100000000 readMaybe- redisSyncInterval <-- env "REDIS_SYNC_INTERVAL" 30 readMaybe+ redisMinTxs <-+ env "REDIS_MIN_TXS" 1000 readMaybe+ redisMaxXPubs <-+ env "REDIS_MAX_XPUBS" 10000 readMaybe noMempool <- env "NO_MEMPOOL" False parseBool wipeMempool <-@@ -226,9 +222,13 @@ healthCheckInterval <- env "HEALTH_CHECK_INTERVAL" 30 readMaybe bloom <-- env "BLOOM" False parseBool+ env "BLOOM" True parseBool noWeb <- env "NO_WEB" False parseBool+ tlsCert <-+ env "TLS_CERT" "" pure+ tlsKey <-+ env "TLS_KEY" "" pure maxOpenFiles <- env "MAX_OPEN_FILES" 2000 readMaybe writeBufferMB <-@@ -283,6 +283,12 @@ "0" -> Just False _ -> Nothing +-- | The help of an option, with the environment variable that sets its+-- default: every option has one, and someone who runs the store in a+-- container reads the help to find its name.+helpEnv :: String -> String -> Mod f a+helpEnv name text = help (text <> " [env: " <> name <> "]")+ -- | Command line parser, with defaults from the given configuration. config :: Config -> Parser Config config c = do@@ -291,21 +297,21 @@ metavar "DIRECTORY" <> long "dir" <> short 'd'- <> help "Data directory"+ <> helpEnv "DIR" "Data directory" <> showDefault <> value c.dir host <- strOption $ metavar "ADDRESS" <> long "host"- <> help "Network address to bind"+ <> helpEnv "HOST" "Network address to bind" <> showDefault <> value c.host port <- option auto $ metavar "PORT" <> long "port"- <> help "REST API listening port"+ <> helpEnv "PORT" "REST API listening port" <> showDefault <> value c.port net <-@@ -313,21 +319,21 @@ metavar netNames <> long "net" <> short 'n'- <> help "Network to connect to"+ <> helpEnv "NET" "Network to connect to" <> showDefault <> value c.net discover <- flag c.discover True $ long "discover"- <> help "Peer discovery"+ <> helpEnv "DISCOVER" "Peer discovery" peers <- fmap (mappend c.peers) $ many $- option auto $+ strOption $ metavar "HOSTNAME[:PORT]" <> long "peer" <> short 'p'- <> help "Network peer (as many as required)"+ <> helpEnv "PEER" "Network peer (as many as required)" version <- switch $ long "version"@@ -336,270 +342,308 @@ debug <- flag c.debug True $ long "debug"- <> help "Show debug messages"+ <> helpEnv "DEBUG" "Show debug messages" maxPendingTxs <- option auto $ metavar "COUNT" <> long "max-pending-txs"- <> help "Maximum pending txs to fail health check"+ <> helpEnv "MAX_PENDING_TXS" "Maximum pending txs to fail health check" <> showDefault <> value c.maxPendingTxs maxLaggingBlocks <- option auto $ metavar "COUNT" <> long "max-lagging-blocks"- <> help "Maximum number of unindexed blocks"+ <> helpEnv "MAX_LAGGING_BLOCKS" "Maximum number of unindexed blocks" <> showDefault <> value c.maxLaggingBlocks minPeers <- option auto $ metavar "COUNT" <> long "min-peers"- <> help "Minimum number of connected peers for health check"+ <> helpEnv "MIN_PEERS" "Minimum number of connected peers for health check" <> showDefault <> value c.minPeers noWeb <- flag c.noWeb True $ long "noweb"- <> help "Disable web server"+ <> helpEnv "NO_WEB" "Disable web server" webLimits <- do maxItemCount <- option auto $ metavar "COUNT" <> long "max-item-count"- <> help "Hard limit for simple listings (0 = inf)"+ <> helpEnv "MAX_ITEM_COUNT" "Hard limit for simple listings (0 = inf)" <> showDefault <> value c.webLimits.maxItemCount maxFullItemCount <- option auto $ metavar "COUNT" <> long "max-full-item-count"- <> help "Hard limit for full listings (0 = inf)"+ <> helpEnv "MAX_FULL_ITEM_COUNT" "Hard limit for full listings (0 = inf)" <> showDefault <> value c.webLimits.maxFullItemCount maxOffset <- option auto $ metavar "OFFSET" <> long "max-offset"- <> help "Hard limit for offsets (0 = inf)"+ <> helpEnv "MAX_OFFSET" "Hard limit for offsets (0 = inf)" <> showDefault <> value c.webLimits.maxOffset defItemCount <- option auto $ metavar "COUNT" <> long "def-item-count"- <> help "Soft default limit (0 = inf)"+ <> helpEnv "DEF_ITEM_COUNT" "Soft default limit (0 = inf)" <> showDefault <> value c.webLimits.defItemCount xpubGap <- option auto $ metavar "GAP" <> long "xpub-gap"- <> help "Max gap for xpub queries"+ <> helpEnv "XPUB_GAP" "Max gap for xpub queries" <> showDefault <> value c.webLimits.xpubGap xpubGapInit <- option auto $ metavar "GAP" <> long "xpub-gap-init"- <> help "Max gap for empty xpubs"+ <> helpEnv "XPUB_GAP_INIT" "Max gap for empty xpubs" <> showDefault <> value c.webLimits.xpubGapInit blockTimeout <- option auto $ metavar "SECONDS" <> long "block-timeout"- <> help "Block lag health timeout"+ <> helpEnv "BLOCK_TIMEOUT" "Block lag health timeout" <> showDefault <> value c.webLimits.blockTimeout txTimeout <- option auto $ metavar "SECONDS" <> long "tx-timeout"- <> help "Last tx received health timeout"+ <> helpEnv "TX_TIMEOUT" "Last tx received health timeout" <> showDefault <> value c.webLimits.txTimeout return WebLimits {..} redis <- flag c.redis True $ long "redis"- <> help "Redis cache for xpub data"+ <> helpEnv "REDIS" "Redis cache for xpub data"+ fleet <-+ flag c.fleet True $+ long "fleet"+ <> helpEnv+ "FLEET"+ ( unwords+ [ "Answer as one of the servers that share the Redis of --redis:",+ "all from the same block and the same mempool"+ ]+ ) redisURL <- strOption $ metavar "URL" <> long "redis-url"- <> help "URL for Redis cache"+ <> helpEnv "REDIS_URL" "URL for Redis cache" <> value c.redisURL redisMinAddrs <- option auto $- metavar "GAP"+ metavar "COUNT" <> long "redis-min-addrs"- <> help "Minimum xpub address count to cache in Redis"+ <> helpEnv "REDIS_MIN_ADDRS" "Cache xpubs with at least this many used addresses" <> showDefault <> value c.redisMinAddrs- redisMaxKeys <-+ redisMinTxs <- option auto $ metavar "COUNT"- <> long "redis-max-keys"- <> help "Maximum number of keys in Redis"+ <> long "redis-min-txs"+ <> helpEnv "REDIS_MIN_TXS" "Cache xpubs with at least this many transactions in their addresses" <> showDefault- <> value c.redisMaxKeys- redisSyncInterval <-+ <> value c.redisMinTxs+ redisMaxXPubs <- option auto $- metavar "SECONDS"- <> long "redis-sync-interval"- <> help "Sync mempool to Redis interval"+ metavar "COUNT"+ <> long "redis-max-xpubs"+ <> helpEnv "REDIS_MAX_XPUBS" "Evict the least recently used xpubs from Redis past this many" <> showDefault- <> value c.redisSyncInterval+ <> value c.redisMaxXPubs noMempool <- flag c.noMempool True $ long "no-mempool"- <> help "Do not index mempool transactions"+ <> helpEnv "NO_MEMPOOL" "Do not index mempool transactions" wipeMempool <- flag c.wipeMempool True $ long "wipe-mempool"- <> help "Wipe indexed mempool at start"+ <> helpEnv "WIPE_MEMPOOL" "Wipe indexed mempool at start" syncMempool <- flag c.syncMempool True $ long "sync-mempool"- <> help "Attempt to download peer mempools"+ <> helpEnv "SYNC_MEMPOOL" "Attempt to download peer mempools" blockWindow <- option auto $ metavar "COUNT" <> long "block-window"- <> help "Blocks to request from the syncing peer at a time"+ <> helpEnv "BLOCK_WINDOW" "Blocks to request from the syncing peer at a time" <> showDefault <> value c.blockWindow mempoolTimeout <- option auto $ metavar "DAYS" <> long "mempool-timeout"- <> help "Cull old mempool transactions"+ <> helpEnv "MEMPOOL_TIMEOUT" "Cull old mempool transactions" <> showDefault <> value c.mempoolTimeout peerTimeout <- option auto $ metavar "SECONDS" <> long "peer-timeout"- <> help "Unresponsive peer timeout"+ <> helpEnv "PEER_TIMEOUT" "Unresponsive peer timeout" <> showDefault <> value c.peerTimeout maxPeerLife <- option auto $ metavar "SECONDS" <> long "max-peer-life"- <> help "Maximum peer connection time"+ <> helpEnv "MAX_PEER_LIFE" "Maximum peer connection time" <> showDefault <> value c.maxPeerLife maxPeers <- option auto $ metavar "COUNT" <> long "max-peers"- <> help "Do not connect to more than this many peers"+ <> helpEnv "MAX_PEERS" "Do not connect to more than this many peers" <> showDefault <> value c.maxPeers statsd <- flag c.statsd True $ long "statsd"- <> help "Enable statsd metrics"+ <> helpEnv "STATSD" "Enable statsd metrics" statsdHost <- strOption $ metavar "HOSTNAME" <> long "statsd-host"- <> help "Host to send statsd metrics"+ <> helpEnv "STATSD_HOST" "Host to send statsd metrics" <> showDefault <> value c.statsdHost statsdPort <- option auto $ metavar "PORT" <> long "statsd-port"- <> help "Port to send statsd metrics"+ <> helpEnv "STATSD_PORT" "Port to send statsd metrics" <> showDefault <> value c.statsdPort statsdPrefix <- strOption $ metavar "PREFIX" <> long "statsd-prefix"- <> help "Prefix for statsd metrics"+ <> helpEnv "STATSD_PREFIX" "Prefix for statsd metrics" <> showDefault <> value c.statsdPrefix statsdNoEvents <- flag c.statsdNoEvents True $ long "statsd-no-events"- <> help "Do not report individual events to statsd"+ <> helpEnv "STATSD_NO_EVENTS" "Do not report individual events to statsd" statsdAggregates <- flag c.statsdAggregates True $ long "statsd-aggregates"- <> help "Send statistical aggregate reports to statsd every second"+ <> helpEnv "STATSD_AGGREGATES" "Send statistical aggregate reports to statsd every second" tickerRefresh <- option auto $ metavar "MICROSECONDS" <> long "ticker-refresh"- <> help "How often to retrieve price information"+ <> helpEnv "TICKER_REFRESH" "How often to retrieve price information" <> showDefault <> value c.tickerRefresh tickerURL <- strOption $ metavar "URL" <> long "ticker-url"- <> help "Blockchain.info price ticker URL"+ <> helpEnv "TICKER_URL" "Blockchain.info price ticker URL" <> showDefault <> value c.tickerURL priceHistoryURL <- strOption $ metavar "URL" <> long "price-history-url"- <> help "Blockchain.info price history URL"+ <> helpEnv "PRICE_HISTORY_URL" "Blockchain.info price history URL" <> showDefault <> value c.priceHistoryURL noBlockchainInfo <- flag c.noBlockchainInfo True $ long "no-blockchain-info"- <> help "Disable Blockchain.info-style API endpoints"+ <> helpEnv "NO_BLOCKCHAIN_INFO" "Disable Blockchain.info-style API endpoints" noXPub <- flag c.noXPub True $ long "no-xpub"- <> help "Disable extended public key support"+ <> helpEnv "NO_XPUB" "Disable extended public key support" healthCheckInterval <- option auto $ metavar "SECONDS" <> long "health-check-interval"- <> help "Background check update interval"+ <> helpEnv "HEALTH_CHECK_INTERVAL" "Background check update interval" <> showDefault <> value c.healthCheckInterval- bloom <-- flag c.bloom True $- long "bloom"- <> help "RocksDB Bloom filters"+ tlsCert <-+ strOption $+ metavar "FILE"+ <> long "tls-cert"+ <> helpEnv "TLS_CERT" "Serve HTTPS with this certificate chain, in PEM; needs --tls-key"+ <> value c.tlsCert+ tlsKey <-+ strOption $+ metavar "FILE"+ <> long "tls-key"+ <> helpEnv "TLS_KEY" "Private key of the certificate of --tls-cert, in PEM"+ <> value c.tlsKey+ noBloom <-+ switch $+ long "no-bloom"+ <> helpEnv "BLOOM=false" "No RocksDB Bloom filters on the data that is read by key, which are on by default"+ -- it was the way to turn them on, and commands that have it must still+ -- start+ _ <- switch (long "bloom" <> hidden) maxOpenFiles <- option auto $ metavar "COUNT" <> long "max-open-files"- <> help "RocksDB max_open_files; bounds index/filter memory (0 = unlimited)"+ <> helpEnv "MAX_OPEN_FILES" "RocksDB max_open_files; bounds index/filter memory (0 = unlimited)" <> showDefault <> value c.maxOpenFiles writeBufferMB <- option auto $ metavar "MB" <> long "write-buffer-mb"- <> help "RocksDB memtable budget across all column families in MiB (0 = unlimited)"+ <> helpEnv "WRITE_BUFFER_MB" "RocksDB memtable budget across all column families in MiB (0 = unlimited)" <> showDefault <> value c.writeBufferMB maxWalMB <- option auto $ metavar "MB" <> long "max-wal-mb"- <> help "RocksDB write-ahead log size in MiB that forces flushes so old log files can be deleted (0 = RocksDB default)"+ <> helpEnv+ "MAX_WAL_MB"+ ( unwords+ [ "RocksDB write-ahead log size in MiB that forces flushes so old",+ "log files can be deleted (0 = RocksDB default)"+ ]+ ) <> showDefault <> value c.maxWalMB blockCacheMB <- option auto $ metavar "MB" <> long "block-cache-mb"- <> help "RocksDB block cache in MiB, shared by all column families, holding index and filter blocks too (0 = RocksDB default of 32 MiB per column family)"+ <> helpEnv+ "BLOCK_CACHE_MB"+ ( unwords+ [ "RocksDB block cache in MiB, shared by all column families,",+ "holding index and filter blocks too (0 = RocksDB default of",+ "32 MiB per column family)"+ ]+ ) <> showDefault <> value c.blockCacheMB- pure Config {..}+ pure Config {bloom = c.bloom && not noBloom, ..} -- | Network by name. networkReader :: String -> Either String Network@@ -632,19 +676,18 @@ run :: Config -> IO () run cfg = withContext $ \ctx ->- withLogger level $ \logger -> flip runContT return $ do+ withLogger level $ \logger -> do net <- either error return $ networkReader cfg.net- stats <- ContT $ with_stats logger net- let dir = cfg.dir </> net.name- logInfo (withSource "haskoin-store" logger) $- "Creating working directory (if not found): " <> cs dir- createDirectoryIfMissing True dir- store <- ContT $ run_store logger net ctx stats dir- if cfg.noWeb- then lift . withSubscription store.pub $ \sub ->- loopForever (receive sub)- else do- lift $ run_web logger stats store+ with_stats logger net $ \stats -> do+ let dir = cfg.dir </> net.name+ logInfo (withSource "haskoin-store" logger) $+ "Creating working directory (if not found): " <> cs dir+ createDirectoryIfMissing True dir+ run_store logger net ctx stats dir $ \store ->+ if cfg.noWeb+ then withSubscription store.pub $ \sub ->+ loopForever (receive sub)+ else run_web logger stats store where run_store logger net ctx stats dir = withStore@@ -656,10 +699,12 @@ net = net, ctx = ctx, redis = if cfg.redis then Just cfg.redisURL else Nothing,+ fleet = cfg.fleet, gap = cfg.webLimits.xpubGap, initGap = cfg.webLimits.xpubGapInit, redisMinAddrs = cfg.redisMinAddrs,- redisMaxKeys = cfg.redisMaxKeys,+ redisMinTxs = cfg.redisMinTxs,+ redisMaxXPubs = cfg.redisMaxXPubs, wipeMempool = cfg.wipeMempool, noMempool = cfg.noMempool, syncMempool = cfg.syncMempool,@@ -669,7 +714,6 @@ maxPeerLife = fromIntegral cfg.maxPeerLife, connect = withConnection, stats = stats,- redisSyncInterval = cfg.redisSyncInterval, bloom = cfg.bloom, maxOpenFiles = if cfg.maxOpenFiles > 0 then Just cfg.maxOpenFiles else Nothing, writeBufferSize =@@ -705,6 +749,11 @@ noXPub = cfg.noXPub, noBlockchainInfo = cfg.noBlockchainInfo, healthCheckInterval = cfg.healthCheckInterval,+ tls = case (cfg.tlsCert, cfg.tlsKey) of+ ("", "") -> Nothing+ ("", _) -> error "--tls-key needs --tls-cert"+ (_, "") -> error "--tls-cert needs --tls-key"+ (certificate, key) -> Just (certificate, key), logger = withSource "Web" logger } with_stats logger net go
@@ -1,9 +1,3 @@-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE ImportQualifiedPost #-}-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-}- -- | Load-test server for the web API. -- -- Syncs a synthetic regtest chain from an in-process mock peer into a fresh@@ -20,29 +14,28 @@ module Main (main) where import Conduit-import Control.Monad (forever, unless)-import Control.Monad.Reader (runReaderT)-import Data.ByteString (ByteString)-import Data.ByteString qualified as B-import Data.HashMap.Strict qualified as HashMap-import Data.List (find) import Control.DeepSeq (NFData, force)+import Control.Monad (forever, unless) import Data.Aeson (toEncoding) import Data.Aeson.Encoding (encodingToLazyByteString)+import Data.ByteString (ByteString)+import Data.ByteString qualified as B import Data.Default (def)+import Data.HashMap.Strict qualified as HashMap import Data.IntMap.Strict qualified as IntMap-import Data.Vector qualified as V-import GHC.Clock (getMonotonicTimeNSec)-import Haskoin.Store.Data+import Data.List (find) import Data.Maybe (fromMaybe, listToMaybe, mapMaybe) import Data.Serialize (decode, encode, runGet, runPut) import Data.Text qualified as T import Data.Text.IO qualified as T import Data.Time.Clock.POSIX (getPOSIXTime)+import Data.Vector qualified as V import Data.Word (Word32)+import GHC.Clock (getMonotonicTimeNSec) import Haskoin import Haskoin.Node import Haskoin.Store+import Haskoin.Store.Database.Query qualified as Q import NQE import Network.Socket (SockAddr (..)) import System.Environment (getArgs)@@ -72,7 +65,7 @@ T.putStrLn $ "hot address: " <> addrText net (hotAddr ctx) T.putStrLn $ "xpub: " <> xPubExport net ctx (benchXPub ctx) T.putStrLn $ "xpub address: " <> addrText net (xpubAddr ctx 0 0)- bh <- runReaderT getBestBlock store.db+ bh <- Q.getBestBlock store.db T.putStrLn $ "best block: " <> maybe "" blockHashToHex bh putStrLn $ "serving on port " <> show port hFlush stdout@@ -83,7 +76,10 @@ waitHeight :: Store -> Inbox StoreEvent -> Int -> IO () waitHeight store sub n = do- h <- maybe 0 (.height) <$> runReaderT (getBestBlock >>= maybe (return Nothing) getBlock) store.db+ h <-+ Q.getBestBlock store.db >>= \case+ Nothing -> return 0+ Just bh -> maybe 0 (.height) <$> Q.getBlock store.db bh unless (fromIntegral h >= n) $ do _ <- receiveMatch sub $ \case StoreBestBlock b -> Just b@@ -104,9 +100,11 @@ gap = 32, initGap = 20, redisMinAddrs = 20,- redisMaxKeys = 100 * 1000 * 1000,+ redisMinTxs = 1000,+ redisMaxXPubs = 10000, noMempool = False, wipeMempool = False,+ fleet = False, syncMempool = False, blockWindow = 64, mempoolTimeout = 14,@@ -114,7 +112,6 @@ maxPeerLife = 48 * 3600, connect = mockPeer blocks requests net ad, stats = Nothing,- redisSyncInterval = 30, bloom = True, maxOpenFiles = Nothing, writeBufferSize = Nothing,@@ -147,6 +144,7 @@ noXPub = False, noBlockchainInfo = False, healthCheckInterval = 30,+ tls = Nothing, logger = quiet } @@ -161,7 +159,9 @@ xpubAddr ctx m i = fst (deriveAddr ctx (pubSubKey ctx (benchXPub ctx) m) i) hotAddr :: Ctx -> Address-hotAddr ctx = pubKeyAddr ctx (derivePublicKey ctx (wrapSecKey True (fromMaybe (error "key") (secKey (B.replicate 32 0x42)))))+hotAddr ctx = pubKeyAddr ctx (derivePublicKey ctx (wrapSecKey True key))+ where+ key = fromMaybe (error "key") (secKey (B.replicate 32 0x42)) scriptOf :: Ctx -> Address -> ByteString scriptOf ctx = fromMaybe (error "script") . addressToScriptBS ctx
@@ -1,204 +1,362 @@-cabal-version: 1.12+cabal-version: 2.0 -- This file has been generated from package.yaml by hpack version 0.39.6. -- -- see: https://github.com/sol/hpack name: haskoin-store-version: 1.8.1+version: 1.9.0 synopsis: Storage and index for Bitcoin and Bitcoin Cash-description: Please see the README on GitHub at <https://github.com/jprupp/haskoin-store#readme>+description: An indexer for Bitcoin and Bitcoin Cash. It downloads blocks and mempool+ transactions from the peer-to-peer network, indexes them in RocksDB, and+ serves a REST API with balances, transactions and unspent outputs of+ addresses and extended public keys, a Blockchain.info compatible API, and+ events over WebSockets. An optional Redis cache, shared by several stores,+ keeps large wallets fast. See the README for installation and+ configuration. category: Bitcoin, Finance, Network-homepage: http://github.com/jprupp/haskoin-store#readme-bug-reports: http://github.com/jprupp/haskoin-store/issues+homepage: https://github.com/jprupp/haskoin-store#readme+bug-reports: https://github.com/jprupp/haskoin-store/issues author: JP Rupp maintainer: jprupp@protonmail.ch+copyright: (c) Haskoin Developers license: MIT license-file: LICENSE build-type: Simple+tested-with:+ GHC == 9.12.4+extra-source-files:+ src/Haskoin/Store/Cache/lua/bodies.lua+ src/Haskoin/Store/Cache/lua/commit.lua+ src/Haskoin/Store/Cache/lua/delete.lua+ src/Haskoin/Store/Cache/lua/evict.lua+ src/Haskoin/Store/Cache/lua/page.lua+ src/Haskoin/Store/Cache/lua/spends.lua+ src/Haskoin/Store/Cache/lua/unlock.lua+ src/Haskoin/Store/Fleet/lua/move.lua+ src/Haskoin/Store/Fleet/lua/pool.lua+ src/Haskoin/Store/Fleet/lua/read.lua+ src/Haskoin/Store/Fleet/lua/report.lua+ src/Haskoin/Store/Fleet/lua/xpub.lua+ stress/wrk.lua+ swagger.yaml+extra-doc-files:+ README.md+ CHANGELOG.md source-repository head type: git- location: https://github.com/jprupp/haskoin-store.git+ location: https://github.com/jprupp/haskoin-store library exposed-modules: Haskoin.Store Haskoin.Store.BlockStore+ Haskoin.Store.BlockStore.Pending+ Haskoin.Store.BlockStore.Sync Haskoin.Store.Cache+ Haskoin.Store.Cache.Protocol Haskoin.Store.Common+ Haskoin.Store.Database.Mempool+ Haskoin.Store.Database.Migrate+ Haskoin.Store.Database.Query+ Haskoin.Store.Database.Query.Layered+ Haskoin.Store.Database.Query.Pooled Haskoin.Store.Database.Reader Haskoin.Store.Database.Types+ Haskoin.Store.Database.Views Haskoin.Store.Database.Writer+ Haskoin.Store.Fleet+ Haskoin.Store.Fleet.Member+ Haskoin.Store.Fleet.Pool+ Haskoin.Store.Fleet.Redis+ Haskoin.Store.Fleet.XPub Haskoin.Store.Logic+ Haskoin.Store.Logic.Layered Haskoin.Store.Manager Haskoin.Store.Web+ Haskoin.Store.Web.Docs+ Haskoin.Store.Web.Events+ Haskoin.Store.Web.Router other-modules: Paths_haskoin_store+ autogen-modules:+ Paths_haskoin_store hs-source-dirs: src+ default-extensions:+ DuplicateRecordFields+ NoFieldSelectors+ OverloadedRecordDot+ OverloadedStrings ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints build-depends:- aeson >=1.4.7.1- , aeson-pretty >=0.8.8- , base >=4.9 && <5- , base16 >=2.0- , bytes >=0.17- , bytestring >=0.10.10.0- , cereal >=0.5.8.1- , conduit >=1.3.2- , containers >=0.6.2.1- , data-default >=0.7.1.1- , deepseq >=1.4.4.0- , hashable >=1.3.0.0- , hashtables >=1.2.4.2- , haskoin-core >=1.6.1- , haskoin-node >=2.3.0- , haskoin-store-data ==1.8.1- , hedis >=0.16.1- , http-types >=0.12.3- , lens >=4.18.1- , monad-control >=1.0.2.3- , mtl >=2.2.2- , network >=3.1.1.1- , nqe >=0.6.5- , random >=1.1- , rocksdb-haskell-jprupp >=2.6.0- , rocksdb-query >=0.6.0- , scotty >=0.22- , statsd-rupp >=0.4- , stm >=2.5.0.0- , string-conversions >=0.4.0.1- , text >=1.2.3.0- , time >=1.9.3- , transformers >=0.5.6.2- , unliftio >=0.2.12.1- , unordered-containers >=0.2.10.0- , vault >=0.3.1.5- , wai >=3.2.2.1- , wai-websockets >=3.0.1.2- , warp >=3.3.10- , websockets >=0.13- , wreq >=0.5.3.2- default-language: Haskell2010+ aeson >=2.2.5.1 && <2.3+ , aeson-pretty >=0.8.11 && <0.9+ , base >=4.21.2.0 && <4.22+ , base16 ==2.0.*+ , bytes >=0.17.5 && <0.18+ , bytestring >=0.12.2.0 && <0.13+ , case-insensitive >=1.2.1.0 && <1.3+ , cereal >=0.5.8.3 && <0.6+ , conduit >=1.3.6.1 && <1.4+ , containers ==0.7.*+ , data-default >=0.8.0.2 && <0.9+ , deepseq >=1.5.1.0 && <1.6+ , file-embed >=0.0.16.0 && <0.1+ , hashable >=1.5.1.0 && <1.6+ , hashtables >=1.4.2 && <1.5+ , haskoin-core >=1.6.1 && <1.7+ , haskoin-node ==2.5.*+ , haskoin-store-data ==1.11.*+ , hedis >=0.16.4 && <0.17+ , http-types >=0.12.6 && <0.13+ , lens >=5.3.6 && <5.4+ , monad-control >=1.0.3.1 && <1.1+ , mtl >=2.3.2 && <2.4+ , network >=3.2.12.0 && <3.3+ , nqe >=0.6.9 && <0.7+ , random >=1.3.1 && <1.4+ , rocksdb-haskell-jprupp >=2.7.1 && <2.8+ , rocksdb-query >=0.6.1 && <0.7+ , statsd-rupp >=0.5.0.2 && <0.6+ , stm >=2.5.3.1 && <2.6+ , string-conversions >=0.4.0.1 && <0.5+ , text >=2.1.4 && <2.2+ , time ==1.14.*+ , transformers >=0.6.3.0 && <0.7+ , unliftio >=0.2.25.1 && <0.3+ , unordered-containers >=0.2.21 && <0.3+ , vault >=0.3.2.0 && <0.4+ , wai >=3.2.5 && <3.3+ , wai-extra >=3.1.18 && <3.2+ , wai-websockets >=3.0.1.2 && <3.1+ , warp >=3.4.17 && <3.5+ , warp-tls >=3.4.14 && <3.5+ , websockets >=0.13.0.0 && <0.14+ , wreq >=0.5.4.5 && <0.6+ default-language: GHC2024 +library integration+ exposed-modules:+ Haskoin.Store.Integration.Image+ Haskoin.Store.Integration.Node+ Haskoin.Store.Integration.Podman+ Haskoin.Store.Integration.Valkey+ other-modules:+ Paths_haskoin_store+ autogen-modules:+ Paths_haskoin_store+ hs-source-dirs:+ integration+ default-extensions:+ DuplicateRecordFields+ NoFieldSelectors+ OverloadedRecordDot+ OverloadedStrings+ ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints+ build-depends:+ aeson >=2.2.5.1 && <2.3+ , aeson-pretty >=0.8.11 && <0.9+ , base >=4.21.2.0 && <4.22+ , base16 ==2.0.*+ , bytes >=0.17.5 && <0.18+ , bytestring >=0.12.2.0 && <0.13+ , case-insensitive >=1.2.1.0 && <1.3+ , cereal >=0.5.8.3 && <0.6+ , conduit >=1.3.6.1 && <1.4+ , containers ==0.7.*+ , data-default >=0.8.0.2 && <0.9+ , deepseq >=1.5.1.0 && <1.6+ , file-embed >=0.0.16.0 && <0.1+ , hashable >=1.5.1.0 && <1.6+ , hashtables >=1.4.2 && <1.5+ , haskoin-core >=1.6.1 && <1.7+ , haskoin-node ==2.5.*+ , haskoin-store-data ==1.11.*+ , hedis >=0.16.4 && <0.17+ , http-types >=0.12.6 && <0.13+ , lens >=5.3.6 && <5.4+ , monad-control >=1.0.3.1 && <1.1+ , mtl >=2.3.2 && <2.4+ , network >=3.2.12.0 && <3.3+ , nqe >=0.6.9 && <0.7+ , process >=1.6.26.1 && <1.7+ , random >=1.3.1 && <1.4+ , rocksdb-haskell-jprupp >=2.7.1 && <2.8+ , rocksdb-query >=0.6.1 && <0.7+ , statsd-rupp >=0.5.0.2 && <0.6+ , stm >=2.5.3.1 && <2.6+ , string-conversions >=0.4.0.1 && <0.5+ , text >=2.1.4 && <2.2+ , time ==1.14.*+ , transformers >=0.6.3.0 && <0.7+ , unliftio >=0.2.25.1 && <0.3+ , unordered-containers >=0.2.21 && <0.3+ , vault >=0.3.2.0 && <0.4+ , wai >=3.2.5 && <3.3+ , wai-extra >=3.1.18 && <3.2+ , wai-websockets >=3.0.1.2 && <3.1+ , warp >=3.4.17 && <3.5+ , warp-tls >=3.4.14 && <3.5+ , websockets >=0.13.0.0 && <0.14+ , wreq >=0.5.4.5 && <0.6+ default-language: GHC2024+ executable haskoin-store main-is: Main.hs other-modules: Paths_haskoin_store+ autogen-modules:+ Paths_haskoin_store hs-source-dirs: app- ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N+ default-extensions:+ DuplicateRecordFields+ NoFieldSelectors+ OverloadedRecordDot+ OverloadedStrings+ ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts "-with-rtsopts=-N --disable-delayed-os-memory-return" build-depends:- aeson >=1.4.7.1- , aeson-pretty >=0.8.8- , base >=4.9 && <5- , base16 >=2.0- , bytes >=0.17- , bytestring >=0.10.10.0- , cereal >=0.5.8.1- , conduit >=1.3.2- , containers >=0.6.2.1- , data-default >=0.7.1.1- , deepseq >=1.4.4.0- , filepath- , hashable >=1.3.0.0- , hashtables >=1.2.4.2- , haskoin-core >=1.6.1- , haskoin-node >=2.3.0+ aeson >=2.2.5.1 && <2.3+ , aeson-pretty >=0.8.11 && <0.9+ , base >=4.21.2.0 && <4.22+ , base16 ==2.0.*+ , bytes >=0.17.5 && <0.18+ , bytestring >=0.12.2.0 && <0.13+ , case-insensitive >=1.2.1.0 && <1.3+ , cereal >=0.5.8.3 && <0.6+ , conduit >=1.3.6.1 && <1.4+ , containers ==0.7.*+ , data-default >=0.8.0.2 && <0.9+ , deepseq >=1.5.1.0 && <1.6+ , file-embed >=0.0.16.0 && <0.1+ , filepath >=1.5.5.0 && <1.6+ , hashable >=1.5.1.0 && <1.6+ , hashtables >=1.4.2 && <1.5+ , haskoin-core >=1.6.1 && <1.7+ , haskoin-node ==2.5.* , haskoin-store- , haskoin-store-data ==1.8.1- , hedis >=0.16.1- , http-types >=0.12.3- , lens >=4.18.1- , monad-control >=1.0.2.3- , mtl >=2.2.2- , network >=3.1.1.1- , nqe >=0.6.5- , optparse-applicative- , random >=1.1- , rocksdb-haskell-jprupp >=2.6.0- , rocksdb-query >=0.6.0- , scotty >=0.22- , statsd-rupp >=0.4- , stm >=2.5.0.0- , string-conversions >=0.4.0.1- , text >=1.2.3.0- , time >=1.9.3- , transformers >=0.5.6.2- , unliftio >=0.2.12.1- , unordered-containers >=0.2.10.0- , vault >=0.3.1.5- , wai >=3.2.2.1- , wai-websockets >=3.0.1.2- , warp >=3.3.10- , websockets >=0.13- , wreq >=0.5.3.2- default-language: Haskell2010+ , haskoin-store-data ==1.11.*+ , hedis >=0.16.4 && <0.17+ , http-types >=0.12.6 && <0.13+ , lens >=5.3.6 && <5.4+ , monad-control >=1.0.3.1 && <1.1+ , mtl >=2.3.2 && <2.4+ , network >=3.2.12.0 && <3.3+ , nqe >=0.6.9 && <0.7+ , optparse-applicative >=0.19.0.0 && <0.20+ , random >=1.3.1 && <1.4+ , rocksdb-haskell-jprupp >=2.7.1 && <2.8+ , rocksdb-query >=0.6.1 && <0.7+ , statsd-rupp >=0.5.0.2 && <0.6+ , stm >=2.5.3.1 && <2.6+ , string-conversions >=0.4.0.1 && <0.5+ , text >=2.1.4 && <2.2+ , time ==1.14.*+ , transformers >=0.6.3.0 && <0.7+ , unliftio >=0.2.25.1 && <0.3+ , unordered-containers >=0.2.21 && <0.3+ , vault >=0.3.2.0 && <0.4+ , wai >=3.2.5 && <3.3+ , wai-extra >=3.1.18 && <3.2+ , wai-websockets >=3.0.1.2 && <3.1+ , warp >=3.4.17 && <3.5+ , warp-tls >=3.4.14 && <3.5+ , websockets >=0.13.0.0 && <0.14+ , wreq >=0.5.4.5 && <0.6+ default-language: GHC2024 test-suite spec type: exitcode-stdio-1.0 main-is: Spec.hs other-modules:- Haskoin.Store.BlockStoreSpec+ Haskoin.Store.BlockStore.PendingSpec+ Haskoin.Store.BlockStore.SyncSpec+ Haskoin.Store.Cache.ProtocolSpec Haskoin.Store.CacheSpec Haskoin.Store.CommonSpec+ Haskoin.Store.Database.MigrateSpec+ Haskoin.Store.Database.Query.LayeredSpec+ Haskoin.Store.Database.ViewsSpec+ Haskoin.Store.Fleet.MemberSpec+ Haskoin.Store.Fleet.PoolSpec+ Haskoin.Store.Fleet.RedisSpec+ Haskoin.Store.Fleet.XPubSpec+ Haskoin.Store.FleetSpec+ Haskoin.Store.Histories+ Haskoin.Store.IntegrationSpec+ Haskoin.Store.Logic.LayeredSpec Haskoin.Store.LogicSpec Haskoin.Store.TestBlocks+ Haskoin.Store.Web.EventsSpec+ Haskoin.Store.Web.RouterSpec Haskoin.Store.WebSpec Haskoin.StoreSpec Paths_haskoin_store+ autogen-modules:+ Paths_haskoin_store hs-source-dirs: test- ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts "-with-rtsopts=-N -T"+ default-extensions:+ DuplicateRecordFields+ NoFieldSelectors+ OverloadedRecordDot+ OverloadedStrings+ ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts "-with-rtsopts=-N4 -T -M4g" build-depends:- QuickCheck >=2.13.2- , aeson >=1.4.7.1- , aeson-pretty >=0.8.8- , base >=4.9 && <5- , base16 >=2.0- , base64 >=0.4.1- , bytes >=0.17- , bytestring >=0.10.10.0- , cereal >=0.5.8.1- , conduit >=1.3.2- , containers >=0.6.2.1- , data-default >=0.7.1.1- , deepseq >=1.4.4.0- , fast-logger >=3.0- , hashable >=1.3.0.0- , hashtables >=1.2.4.2- , haskoin-core >=1.6.1- , haskoin-node >=2.3.0+ QuickCheck >=2.17.1.0 && <2.18+ , aeson >=2.2.5.1 && <2.3+ , aeson-pretty >=0.8.11 && <0.9+ , base >=4.21.2.0 && <4.22+ , base16 ==2.0.*+ , base64 >=1.0.1 && <1.1+ , bytes >=0.17.5 && <0.18+ , bytestring >=0.12.2.0 && <0.13+ , case-insensitive >=1.2.1.0 && <1.3+ , cereal >=0.5.8.3 && <0.6+ , conduit >=1.3.6.1 && <1.4+ , containers ==0.7.*+ , data-default >=0.8.0.2 && <0.9+ , deepseq >=1.5.1.0 && <1.6+ , fast-logger >=3.2.9 && <3.3+ , file-embed >=0.0.16.0 && <0.1+ , hashable >=1.5.1.0 && <1.6+ , hashtables >=1.4.2 && <1.5+ , haskoin-core >=1.6.1 && <1.7+ , haskoin-node ==2.5.* , haskoin-store- , haskoin-store-data ==1.8.1- , hedis >=0.16.1- , hspec >=2.7.1- , http-types >=0.12.3- , lens >=4.18.1- , monad-control >=1.0.2.3- , mtl >=2.2.2- , network >=3.1.1.1- , nqe >=0.6.5- , random >=1.1- , rocksdb-haskell-jprupp >=2.6.0- , rocksdb-query >=0.6.0- , scotty >=0.22- , statsd-rupp >=0.4- , stm >=2.5.0.0- , string-conversions >=0.4.0.1- , text >=1.2.3.0- , time >=1.9.3- , transformers >=0.5.6.2- , unliftio >=0.2.12.1- , unordered-containers >=0.2.10.0- , vault >=0.3.1.5- , wai >=3.2.2.1- , wai-websockets >=3.0.1.2- , warp >=3.3.10- , websockets >=0.13- , wreq >=0.5.3.2- default-language: Haskell2010+ , haskoin-store-data ==1.11.*+ , hedis >=0.16.4 && <0.17+ , hspec >=2.11.18 && <2.12+ , http-types >=0.12.6 && <0.13+ , integration+ , lens >=5.3.6 && <5.4+ , monad-control >=1.0.3.1 && <1.1+ , mtl >=2.3.2 && <2.4+ , network >=3.2.12.0 && <3.3+ , nqe >=0.6.9 && <0.7+ , process >=1.6.26.1 && <1.7+ , random >=1.3.1 && <1.4+ , rocksdb-haskell-jprupp >=2.7.1 && <2.8+ , rocksdb-query >=0.6.1 && <0.7+ , statsd-rupp >=0.5.0.2 && <0.6+ , stm >=2.5.3.1 && <2.6+ , string-conversions >=0.4.0.1 && <0.5+ , text >=2.1.4 && <2.2+ , time ==1.14.*+ , transformers >=0.6.3.0 && <0.7+ , unliftio >=0.2.25.1 && <0.3+ , unordered-containers >=0.2.21 && <0.3+ , vault >=0.3.2.0 && <0.4+ , wai >=3.2.5 && <3.3+ , wai-extra >=3.1.18 && <3.2+ , wai-websockets >=3.0.1.2 && <3.1+ , warp >=3.4.17 && <3.5+ , warp-tls >=3.4.14 && <3.5+ , websockets >=0.13.0.0 && <0.14+ , wreq >=0.5.4.5 && <0.6+ default-language: GHC2024 build-tool-depends: hspec-discover:hspec-discover benchmark haskoin-store-bench@@ -206,51 +364,127 @@ main-is: Main.hs other-modules: Paths_haskoin_store+ autogen-modules:+ Paths_haskoin_store hs-source-dirs: bench+ default-extensions:+ DuplicateRecordFields+ NoFieldSelectors+ OverloadedRecordDot+ OverloadedStrings ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N build-depends:- aeson >=1.4.7.1- , aeson-pretty >=0.8.8- , base >=4.9 && <5- , base16 >=2.0- , bytes >=0.17- , bytestring >=0.10.10.0- , cereal >=0.5.8.1- , conduit >=1.3.2- , containers >=0.6.2.1- , data-default >=0.7.1.1- , deepseq >=1.4.4.0- , hashable >=1.3.0.0- , hashtables >=1.2.4.2- , haskoin-core >=1.6.1- , haskoin-node >=2.3.0+ aeson >=2.2.5.1 && <2.3+ , aeson-pretty >=0.8.11 && <0.9+ , base >=4.21.2.0 && <4.22+ , base16 ==2.0.*+ , bytes >=0.17.5 && <0.18+ , bytestring >=0.12.2.0 && <0.13+ , case-insensitive >=1.2.1.0 && <1.3+ , cereal >=0.5.8.3 && <0.6+ , conduit >=1.3.6.1 && <1.4+ , containers ==0.7.*+ , data-default >=0.8.0.2 && <0.9+ , deepseq >=1.5.1.0 && <1.6+ , file-embed >=0.0.16.0 && <0.1+ , hashable >=1.5.1.0 && <1.6+ , hashtables >=1.4.2 && <1.5+ , haskoin-core >=1.6.1 && <1.7+ , haskoin-node ==2.5.* , haskoin-store- , haskoin-store-data ==1.8.1- , hedis >=0.16.1- , http-types >=0.12.3- , lens >=4.18.1- , monad-control >=1.0.2.3- , mtl >=2.2.2- , network >=3.1.1.1- , nqe >=0.6.5- , random >=1.1- , rocksdb-haskell-jprupp >=2.6.0- , rocksdb-query >=0.6.0- , scotty >=0.22- , statsd-rupp >=0.4- , stm >=2.5.0.0- , string-conversions >=0.4.0.1- , text >=1.2.3.0- , time >=1.9.3- , transformers >=0.5.6.2- , unliftio >=0.2.12.1- , unordered-containers >=0.2.10.0- , vault >=0.3.1.5- , vector- , wai >=3.2.2.1- , wai-websockets >=3.0.1.2- , warp >=3.3.10- , websockets >=0.13- , wreq >=0.5.3.2- default-language: Haskell2010+ , haskoin-store-data ==1.11.*+ , hedis >=0.16.4 && <0.17+ , http-types >=0.12.6 && <0.13+ , lens >=5.3.6 && <5.4+ , monad-control >=1.0.3.1 && <1.1+ , mtl >=2.3.2 && <2.4+ , network >=3.2.12.0 && <3.3+ , nqe >=0.6.9 && <0.7+ , random >=1.3.1 && <1.4+ , rocksdb-haskell-jprupp >=2.7.1 && <2.8+ , rocksdb-query >=0.6.1 && <0.7+ , statsd-rupp >=0.5.0.2 && <0.6+ , stm >=2.5.3.1 && <2.6+ , string-conversions >=0.4.0.1 && <0.5+ , text >=2.1.4 && <2.2+ , time ==1.14.*+ , transformers >=0.6.3.0 && <0.7+ , unliftio >=0.2.25.1 && <0.3+ , unordered-containers >=0.2.21 && <0.3+ , vault >=0.3.2.0 && <0.4+ , vector >=0.13.2.0 && <0.14+ , wai >=3.2.5 && <3.3+ , wai-extra >=3.1.18 && <3.2+ , wai-websockets >=3.0.1.2 && <3.1+ , warp >=3.4.17 && <3.5+ , warp-tls >=3.4.14 && <3.5+ , websockets >=0.13.0.0 && <0.14+ , wreq >=0.5.4.5 && <0.6+ default-language: GHC2024++benchmark haskoin-store-stress+ type: exitcode-stdio-1.0+ main-is: Main.hs+ other-modules:+ Paths_haskoin_store+ autogen-modules:+ Paths_haskoin_store+ hs-source-dirs:+ stress+ default-extensions:+ DuplicateRecordFields+ NoFieldSelectors+ OverloadedRecordDot+ OverloadedStrings+ ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N2+ build-depends:+ aeson >=2.2.5.1 && <2.3+ , aeson-pretty >=0.8.11 && <0.9+ , base >=4.21.2.0 && <4.22+ , base16 ==2.0.*+ , bytes >=0.17.5 && <0.18+ , bytestring >=0.12.2.0 && <0.13+ , case-insensitive >=1.2.1.0 && <1.3+ , cereal >=0.5.8.3 && <0.6+ , conduit >=1.3.6.1 && <1.4+ , containers ==0.7.*+ , data-default >=0.8.0.2 && <0.9+ , deepseq >=1.5.1.0 && <1.6+ , file-embed >=0.0.16.0 && <0.1+ , hashable >=1.5.1.0 && <1.6+ , hashtables >=1.4.2 && <1.5+ , haskoin-core >=1.6.1 && <1.7+ , haskoin-node ==2.5.*+ , haskoin-store+ , haskoin-store-data ==1.11.*+ , hedis >=0.16.4 && <0.17+ , http-types >=0.12.6 && <0.13+ , integration+ , lens >=5.3.6 && <5.4+ , monad-control >=1.0.3.1 && <1.1+ , mtl >=2.3.2 && <2.4+ , network >=3.2.12.0 && <3.3+ , nqe >=0.6.9 && <0.7+ , optparse-applicative >=0.19.0.0 && <0.20+ , process >=1.6.26.1 && <1.7+ , random >=1.3.1 && <1.4+ , rocksdb-haskell-jprupp >=2.7.1 && <2.8+ , rocksdb-query >=0.6.1 && <0.7+ , statsd-rupp >=0.5.0.2 && <0.6+ , stm >=2.5.3.1 && <2.6+ , string-conversions >=0.4.0.1 && <0.5+ , text >=2.1.4 && <2.2+ , time ==1.14.*+ , transformers >=0.6.3.0 && <0.7+ , unliftio >=0.2.25.1 && <0.3+ , unordered-containers >=0.2.21 && <0.3+ , vault >=0.3.2.0 && <0.4+ , wai >=3.2.5 && <3.3+ , wai-extra >=3.1.18 && <3.2+ , wai-websockets >=3.0.1.2 && <3.1+ , warp >=3.4.17 && <3.5+ , warp-tls >=3.4.14 && <3.5+ , websockets >=0.13.0.0 && <0.14+ , wreq >=0.5.4.5 && <0.6+ default-language: GHC2024
@@ -0,0 +1,196 @@+-- |+-- Module : Haskoin.Store.Integration.Image+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- The container image of the integration tests, built from pinned and+-- verified sources only.+--+-- The tests use no third-party images. The image starts from the official+-- Ubuntu 26.04 image, pinned by the digest of its multi-architecture index,+-- installs Valkey (the Redis-compatible server Ubuntu ships) and wrk from the+-- Ubuntu archive at a fixed snapshot date (apt checks the signatures of the+-- archive; the certificates needed to reach the snapshot service over HTTPS+-- come from the regular archive first), and downloads Bitcoin Core and+-- Bitcoin Cash Node from their official release locations, checking each+-- tarball against a SHA-256 hash pinned here. A tarball that does not match+-- stops the build.+--+-- The hashes were taken from the signed checksum files of each release and+-- checked against the release signing keys of each project when they were+-- pinned:+--+-- * Bitcoin Core 32.1: @SHA256SUMS@ from bitcoincore.org, with good+-- signatures from eight of the builders whose keys are in the+-- @bitcoin-core/guix.sigs@ repository;+-- * Bitcoin Cash Node 29.2.0: @SHA256SUMS.29.2.0.asc.*@ from the+-- @bitcoin-cash-node/announcements@ repository, with good signatures from+-- two maintainers whose keys are in @contrib/gitian-signing/pubkeys.txt@.+--+-- To move to new releases, change the versions and hashes below after+-- checking them the same way. The tag of the image is a hash of the whole+-- recipe, so a change builds a new image and the old one is no longer used.+module Haskoin.Store.Integration.Image+ ( testImage,+ coreBin,+ bchnBin,+ valkeyBin,+ wrkBin,+ )+where++import Data.ByteString.Char8 qualified as C8+import Data.List (intercalate)+import Data.Serialize (encode)+import Data.Text qualified as T+import Haskoin (encodeHex, sha256)+import System.Exit (ExitCode (..))+import System.Process (readProcessWithExitCode)+import UnliftIO.Temporary (withSystemTempDirectory)++-- | Ubuntu 26.04, by the digest of its multi-architecture index.+base :: String+base = "docker.io/library/ubuntu@sha256:6a06111c3b0a8600feadb270aec771dc239bf407b25625f93f44ab00e11f4d3c"++-- | Date of the snapshot of the Ubuntu archive to install packages from, so+-- that the same versions are installed every time.+snapshot :: String+snapshot = "20261101T000000Z"++-- | Bitcoin Core release and the hashes of its Linux tarballs for x86_64 and+-- aarch64.+coreVersion, coreAmd64, coreArm64 :: String+coreVersion = "32.1"+coreAmd64 = "61545c17c3120fb7412f896d248b11993af04ec8817aad3649c55b2afb044963"+coreArm64 = "7ed37c7373eb6aa3e3f72842596a1afeba1b223717033cade9842b25773e1922"++-- | Bitcoin Cash Node release and the hashes of its Linux tarballs for x86_64+-- and aarch64.+bchnVersion, bchnAmd64, bchnArm64 :: String+bchnVersion = "29.2.0"+bchnAmd64 = "23bbe9ff749136a9b832a2a59023ce76a5391322c1ea2f4f24b5b91f035081bc"+bchnArm64 = "27e177e5a07dc78c2c0d18798ea2e3925111955509ecdc476e828e7ce8f46383"++-- | Daemon of Bitcoin Core in the image.+coreBin :: String+coreBin = "/opt/bitcoin-core/bin/bitcoind"++-- | Daemon of Bitcoin Cash Node in the image.+bchnBin :: String+bchnBin = "/opt/bitcoin-cash-node/bin/bitcoind"++-- | Valkey server in the image.+valkeyBin :: String+valkeyBin = "/usr/bin/valkey-server"++-- | The HTTP load generator wrk in the image, for the stress test of the+-- cache.+wrkBin :: String+wrkBin = "/usr/bin/wrk"++-- | Recipe of the image.+--+-- Every download is its own layer, ordered from the one that changes least+-- often: the packages of the snapshot, then Bitcoin Core, then Bitcoin Cash+-- Node, then wrk for the stress test, which came last. Podman keeps the+-- layers it builds and reuses each one whose instruction, and those before+-- it, did not change, so moving one node to a new release downloads only+-- that release. Nothing in the image depends on the time it is built.+--+-- The recipe stays here rather than in a Containerfile of its own: it is+-- made from the versions and hashes defined in this module, and the tag of+-- the image is a hash of it, so the tests and the stress program, which may+-- run from any directory, need no file to find. It is written one+-- instruction and one shell command per line.+containerfile :: String+containerfile =+ unlines $+ [ "FROM " <> base,+ "ARG TARGETARCH",+ -- the snapshot archive is only served over HTTPS, so first get the+ -- certificates to reach it from the regular archive (over HTTP, with+ -- the packages checked by the signatures of the archive), then install+ -- everything, the certificates included, from the snapshot+ "RUN apt-get update --error-on=any \\",+ " && apt-get install -y --no-install-recommends ca-certificates \\",+ " && apt-get update --error-on=any --snapshot " <> snapshot <> " \\",+ " && apt-get install -y --no-install-recommends --snapshot "+ <> snapshot+ <> " ca-certificates curl valkey-server \\",+ " && rm -rf /var/lib/apt/lists/*"+ ]+ <> release+ "/opt/bitcoin-core"+ ( "https://bitcoincore.org/bin/bitcoin-core-"+ <> coreVersion+ <> "/bitcoin-"+ <> coreVersion+ <> "-$a-linux-gnu.tar.gz"+ )+ coreAmd64+ coreArm64+ <> release+ "/opt/bitcoin-cash-node"+ ( "https://github.com/bitcoin-cash-node/bitcoin-cash-node/releases/download/v"+ <> bchnVersion+ <> "/bitcoin-cash-node-"+ <> bchnVersion+ <> "-$a-linux-gnu.tar.gz"+ )+ bchnAmd64+ bchnArm64+ <> [ "RUN apt-get update --error-on=any --snapshot " <> snapshot <> " \\",+ " && apt-get install -y --no-install-recommends --snapshot " <> snapshot <> " wrk \\",+ " && rm -rf /var/lib/apt/lists/*"+ ]+ where+ -- download a release for the architecture of the image, check its hash,+ -- and unpack it; @$a@ in the URL stands for the architecture+ release dir url amd64 arm64 =+ [ "RUN set -eu; \\",+ " case \"$TARGETARCH\" in \\",+ " amd64) a=x86_64; h=" <> amd64 <> ";; \\",+ " arm64) a=aarch64; h=" <> arm64 <> ";; \\",+ " *) echo \"unsupported architecture $TARGETARCH\"; exit 1;; \\",+ " esac; \\",+ " curl -fsSL -o /tmp/release.tar.gz \"" <> url <> "\"; \\",+ " echo \"$h /tmp/release.tar.gz\" | sha256sum -c -; \\",+ " mkdir -p " <> dir <> "; \\",+ " tar -xzf /tmp/release.tar.gz -C " <> dir <> " --strip-components=1; \\",+ " rm /tmp/release.tar.gz"+ ]++-- | Tag of the image: a hash of its recipe.+tag :: String+tag =+ "localhost/haskoin-store-test:"+ <> take 16 (T.unpack (encodeHex (encode (sha256 (C8.pack containerfile)))))++-- | Build the image unless it exists already, and return its tag. The first+-- build downloads the base image, the packages and the two releases, and may+-- take a few minutes; later builds of a changed recipe reuse the layers that+-- did not change (see the recipe above). Images and layers are never removed by+-- the tests. The build may use 1 GiB of memory.+testImage :: IO String+testImage = do+ (exists, _, _) <- readProcessWithExitCode "podman" ["image", "exists", tag] ""+ case exists of+ ExitSuccess -> return tag+ _ -> withSystemTempDirectory "haskoin-store-image" $ \ctx -> do+ (code, out, err) <-+ readProcessWithExitCode+ "podman"+ ["build", "--layers", "--memory", "1g", "--tag", tag, "--file", "-", ctx]+ containerfile+ case code of+ ExitSuccess -> return tag+ _ ->+ fail $+ intercalate+ "\n"+ ["Could not build the test image " <> tag <> ":", lastLines out, lastLines err]+ where+ lastLines = unlines . reverse . take 30 . reverse . lines
@@ -0,0 +1,237 @@+-- |+-- Module : Haskoin.Store.Integration.Node+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- Regtest nodes for the integration tests: Bitcoin Core for Bitcoin and+-- Bitcoin Cash Node for Bitcoin Cash, each in a container, driven through+-- JSON-RPC.+--+-- A regtest node gives the tests a chain they fully control: they mine blocks+-- on demand, send transactions from the wallet of the node, and sign+-- transactions that spend from the keys of the test xpubs. The store under+-- test connects to the node over the peer-to-peer protocol, as it would to+-- the real networks.+--+-- Only RPC calls that both nodes have are used. Bitcoin Cash Node has no+-- @generateblock@, so the reorganisation test is built on coinbase maturity+-- instead (see "Haskoin.Store.IntegrationSpec").+module Haskoin.Store.Integration.Node+ ( NodeKind (..),+ Node (..),+ nodeContainer,+ nodeNetwork,+ RpcError (..),+ rpc,+ waitRpc,+ ensureWallet,+ field,+ textField,+ btc,+ sats,+ getNewAddress,+ generateTo,+ sendTo,+ sendMany,+ getRawTx,+ bestBlockHash,+ rawMempool,+ )+where++import Control.Lens ((&), (?~), (^.))+import Control.Monad (void)+import Data.Aeson (Value (..), eitherDecode, object, (.=))+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Foldable (toList)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Word (Word64)+import Haskoin (Network, bchRegTest, btcRegTest)+import Haskoin.Store.Integration.Image (bchnBin, coreBin)+import Haskoin.Store.Integration.Podman (ContainerSpec (..))+import Network.Wreq qualified as Wreq+import UnliftIO (Exception, throwIO, tryAny)+import UnliftIO.Concurrent (threadDelay)++-- | Which node software.+data NodeKind = BitcoinCore | BitcoinCashNode+ deriving (Eq, Show)++-- | A node and how to reach it.+data Node = Node+ { kind :: !NodeKind,+ -- | URL of the JSON-RPC interface+ url :: !String,+ -- | port of the peer-to-peer interface on 127.0.0.1+ p2p :: !Int+ }++-- | Network constants of the regtest chain of a node.+nodeNetwork :: NodeKind -> Network+nodeNetwork BitcoinCore = btcRegTest+nodeNetwork BitcoinCashNode = bchRegTest++-- | Container of a regtest node, from the test image+-- ("Haskoin.Store.Integration.Image"): its chain is in a tmpfs of 512 MiB,+-- it may use 1 GiB of memory, and its caches and mempool are kept small.+--+-- Every peer is whitelisted, so that the store may ask for the mempool and+-- gets transactions relayed without delay.+nodeContainer :: NodeKind -> String -> ContainerSpec+nodeContainer kind image =+ ContainerSpec+ { name = case kind of+ BitcoinCore -> "bitcoin-core"+ BitcoinCashNode -> "bitcoin-cash-node",+ image = image,+ options =+ [ "--memory",+ "1g",+ "--tmpfs",+ "/chain:rw,size=512m,mode=1777",+ "--entrypoint",+ case kind of+ BitcoinCore -> coreBin+ BitcoinCashNode -> bchnBin+ ],+ command =+ [ "-regtest",+ "-datadir=/chain",+ "-printtoconsole",+ "-server",+ "-rpcuser=test",+ "-rpcpassword=test",+ "-rpcbind=0.0.0.0",+ "-rpcallowip=0.0.0.0/0",+ "-bind=0.0.0.0",+ "-listen=1",+ "-whitelist=0.0.0.0/0",+ "-txindex=1",+ "-fallbackfee=0.0002",+ "-dbcache=16",+ "-maxmempool=16"+ ]+ <> case kind of+ -- releases of Bitcoin Cash Node stop serving RPC some months+ -- after they come out unless told not to+ BitcoinCashNode -> ["-expire=0"]+ BitcoinCore -> [],+ ports = [18443, 18444]+ }++-- | Error returned by a node.+data RpcError = RpcError !Text !Value+ deriving (Show)++instance Exception RpcError++-- | Call a method of the node and return its result.+rpc :: Node -> Text -> [Value] -> IO Value+rpc node method params = do+ let opts =+ Wreq.defaults+ & Wreq.auth ?~ Wreq.basicAuth "test" "test"+ & Wreq.checkResponse ?~ (\_ _ -> return ())+ body =+ object+ [ "jsonrpc" .= ("1.0" :: Text),+ "id" .= ("test" :: Text),+ "method" .= method,+ "params" .= params+ ]+ r <- Wreq.postWith opts node.url body+ case eitherDecode (r ^. Wreq.responseBody) of+ Left e -> fail ("Bad answer to " <> T.unpack method <> ": " <> e)+ Right v -> case (field "error" v, field "result" v) of+ (Just Null, Just x) -> return x+ (Nothing, Just x) -> return x+ (Just e, _) -> throwIO (RpcError method e)+ _ -> fail ("No result in answer to " <> T.unpack method)++-- | Wait until the node answers RPC calls, for up to 90 seconds.+waitRpc :: Node -> IO ()+waitRpc node = go (360 :: Int)+ where+ go n =+ tryAny (rpc node "getblockchaininfo" []) >>= \case+ Right _ -> return ()+ Left e+ | n <= 0 -> fail ("The node did not start: " <> show e)+ | otherwise -> threadDelay 250000 >> go (n - 1)++-- | Make sure the node has exactly one wallet loaded, so that wallet calls+-- need no wallet name. Bitcoin Cash Node loads a default wallet; Bitcoin Core+-- has none until one is created.+ensureWallet :: Node -> IO ()+ensureWallet node =+ rpc node "listwallets" [] >>= \case+ Array ws | not (null ws) -> return ()+ _ -> void $ rpc node "createwallet" [String "test"]++-- | Field of a JSON object.+field :: Text -> Value -> Maybe Value+field k (Object o) = KeyMap.lookup (Key.fromText k) o+field _ _ = Nothing++-- | Text field of a JSON object.+textField :: Text -> Value -> Maybe Text+textField k v = case field k v of+ Just (String t) -> Just t+ _ -> Nothing++-- | Amount in satoshi as a JSON number of coins.+btc :: Word64 -> Value+btc s = Number (fromIntegral s / 100000000)++-- | JSON number of coins in satoshi.+sats :: Value -> Word64+sats (Number n) = round (n * 100000000)+sats v = error ("Not an amount: " <> show v)++asText :: Value -> IO Text+asText (String t) = return t+asText v = fail ("Expected text, got " <> show v)++-- | New address of the wallet of the node.+getNewAddress :: Node -> IO Text+getNewAddress node = rpc node "getnewaddress" [] >>= asText++-- | Mine blocks paying to an address. Returns their hashes.+generateTo :: Node -> Int -> Text -> IO [Text]+generateTo node n addr =+ rpc node "generatetoaddress" [Number (fromIntegral n), String addr] >>= \case+ Array hs -> mapM asText (toList hs)+ v -> fail ("Unexpected answer to generatetoaddress: " <> show v)++-- | Send from the wallet of the node to an address. Returns the transaction+-- hash.+sendTo :: Node -> Text -> Word64 -> IO Text+sendTo node addr amount =+ rpc node "sendtoaddress" [String addr, btc amount] >>= asText++-- | Send from the wallet of the node to several addresses in one+-- transaction. Returns the transaction hash.+sendMany :: Node -> [(Text, Word64)] -> IO Text+sendMany node outs =+ rpc node "sendmany" [String "", object [Key.fromText a .= btc v | (a, v) <- outs]]+ >>= asText++-- | Decoded transaction.+getRawTx :: Node -> Text -> IO Value+getRawTx node txid = rpc node "getrawtransaction" [String txid, Bool True]++-- | Hash of the best block.+bestBlockHash :: Node -> IO Text+bestBlockHash node = rpc node "getbestblockhash" [] >>= asText++-- | Hashes of the transactions in the mempool.+rawMempool :: Node -> IO [Text]+rawMempool node =+ rpc node "getrawmempool" [] >>= \case+ Array ts -> mapM asText (toList ts)+ v -> fail ("Unexpected answer to getrawmempool: " <> show v)
@@ -0,0 +1,190 @@+-- |+-- Module : Haskoin.Store.Integration.Podman+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- Containers for the integration tests, run with Podman.+--+-- The integration tests need real nodes and a real Redis, which are easiest+-- to get, at pinned versions and without installing anything, as container+-- images. Podman runs them without a daemon and without root, so a developer+-- or a CI runner can use it as it is.+--+-- Everything is kept off the disk and cleaned up:+--+-- * containers run with @--rm@ and are removed with @podman rm --force@ when+-- the test that started them ends, even if it fails;+-- * the volumes that images declare are mounted as tmpfs+-- (@--image-volume tmpfs@), and data directories are tmpfs mounts of+-- limited size;+-- * every container has a memory limit;+-- * containers carry a label with the process ID of the test run, and+-- containers left by a test run that was killed (whose process is gone) are+-- removed before new ones start.+--+-- Ports are published on random ports of the loopback interface, so several+-- test runs can share a machine.+module Haskoin.Store.Integration.Podman+ ( podmanUsable,+ ContainerSpec (..),+ Container (..),+ withContainer,+ hostPort,+ withRamDir,+ )+where++import Control.Monad (forM_, unless, void)+import Data.Aeson qualified as A+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteString.Char8 qualified as C8+import Data.ByteString.Lazy qualified as BL+import Data.Char (isDigit, isSpace)+import Data.List (dropWhileEnd)+import Data.Text qualified as T+import System.Environment (lookupEnv)+import System.Exit (ExitCode (..))+import System.IO (hPutStrLn, stderr)+import System.Process (getCurrentPid, readProcessWithExitCode)+import UnliftIO (bracket, onException, timeout, tryAny)+import UnliftIO.Directory (doesDirectoryExist, doesPathExist, getTemporaryDirectory)+import UnliftIO.Temporary (withTempDirectory)++-- | Whether Podman can run containers here, or why not. Set+-- @HASKOIN_STORE_NO_PODMAN@ to a value other than @0@ to skip the tests that+-- need it.+podmanUsable :: IO (Either String ())+podmanUsable = do+ skip <- lookupEnv "HASKOIN_STORE_NO_PODMAN"+ case skip of+ Just v | v `notElem` ["", "0"] -> return (Left "HASKOIN_STORE_NO_PODMAN is set")+ _ -> do+ r <-+ tryAny . timeout (60 * 1000 * 1000) $+ readProcessWithExitCode "podman" ["info", "--format", "{{.Host.Arch}}"] ""+ -- containers left by a test run that was killed are removed before+ -- anything else, even if this run fails before starting any+ case r of+ Right (Just (ExitSuccess, _, _)) -> removeStale+ _ -> return ()+ return $ case r of+ Left e -> Left ("Podman is not available: " <> show e)+ Right Nothing -> Left "podman info did not answer within a minute"+ Right (Just (ExitSuccess, _, _)) -> Right ()+ Right (Just (_, _, err)) -> Left ("Podman cannot run containers: " <> trim err)++-- | What to run.+data ContainerSpec = ContainerSpec+ { -- | short name, part of the container name+ name :: !String,+ -- | image, with a pinned tag+ image :: !String,+ -- | options of @podman run@, such as memory limits and tmpfs mounts+ options :: ![String],+ -- | command and arguments, after the image+ command :: ![String],+ -- | TCP ports to publish on random ports of 127.0.0.1+ ports :: ![Int]+ }++-- | A running container.+data Container = Container+ { -- | container ID+ cid :: !String,+ -- | container name+ name :: !String+ }++-- | Label on every container started by the tests. Its value is the process+-- ID of the test run.+testLabel :: String+testLabel = "haskoin-store-test"++-- | Run a container while an action runs, and remove it afterwards. If the+-- action throws, the last lines of the log of the container are printed to+-- help find out why.+withContainer :: ContainerSpec -> (Container -> IO a) -> IO a+withContainer spec act = do+ removeStale+ pid <- show <$> getCurrentPid+ let cname = testLabel <> "-" <> pid <> "-" <> spec.name+ args =+ ["run", "--detach", "--rm", "--name", cname]+ <> ["--label", testLabel <> "=" <> pid, "--image-volume", "tmpfs"]+ <> spec.options+ <> concatMap (\p -> ["--publish", "127.0.0.1::" <> show p]) spec.ports+ <> [spec.image]+ <> spec.command+ bracket (start cname args) stop $ \c -> act c `onException` logs c+ where+ start cname args = do+ (code, out, err) <- readProcessWithExitCode "podman" args ""+ case code of+ ExitSuccess -> return Container {cid = trim out, name = cname}+ _ -> do+ -- a container may exist even though the command failed+ void $ readProcessWithExitCode "podman" ["rm", "--force", cname] ""+ fail ("podman run " <> spec.image <> " failed: " <> trim err)+ stop :: Container -> IO ()+ stop c =+ void $ readProcessWithExitCode "podman" ["rm", "--force", "--time", "2", c.cid] ""+ logs :: Container -> IO ()+ logs c = do+ (_, out, err) <- readProcessWithExitCode "podman" ["logs", "--tail", "40", c.cid] ""+ hPutStrLn stderr $ "--- last lines of the log of " <> c.name <> " ---\n" <> out <> err++-- | Remove the containers of test runs whose process is gone. The list+-- comes as JSON: the Go templates of @podman ps@ cannot index the labels in+-- every version of Podman.+removeStale :: IO ()+removeStale = do+ (code, out, _) <-+ readProcessWithExitCode+ "podman"+ ["ps", "--all", "--filter", "label=" <> testLabel, "--format", "json"]+ ""+ case (code, A.decode (BL.fromStrict (C8.pack out))) of+ (ExitSuccess, Just cs) ->+ forM_ (cs :: [A.Value]) $ \c -> case staleOf c of+ Just (cid, pid) | all isDigit pid -> do+ alive <- doesPathExist ("/proc/" <> pid)+ unless alive . void $ readProcessWithExitCode "podman" ["rm", "--force", cid] ""+ _ -> return ()+ _ -> return ()+ where+ staleOf c = do+ A.Object o <- Just c+ A.String cid <- KeyMap.lookup "Id" o+ A.Object labels <- KeyMap.lookup "Labels" o+ A.String pid <- KeyMap.lookup (Key.fromString testLabel) labels+ return (T.unpack cid, T.unpack pid)++-- | Port of the host where a port of a container is published.+hostPort :: Container -> Int -> IO Int+hostPort c p = do+ (code, out, err) <- readProcessWithExitCode "podman" ["port", c.cid, show p <> "/tcp"] ""+ case (code, lines out) of+ (ExitSuccess, l : _)+ | [(n, "")] <- reads (reverse (takeWhile (/= ':') (reverse (trim l)))) ->+ return n+ _ -> fail ("Cannot find the published port " <> show p <> " of " <> c.name <> ": " <> trim err)++trim :: String -> String+trim = dropWhileEnd isSpace . dropWhile isSpace++-- | A temporary directory in memory: under @/dev/shm@ if there is one, else+-- under @$XDG_RUNTIME_DIR@, else the usual temporary directory.+withRamDir :: (FilePath -> IO a) -> IO a+withRamDir f = do+ shm <- doesDirectoryExist "/dev/shm"+ xdg <- lookupEnv "XDG_RUNTIME_DIR"+ tmp <- getTemporaryDirectory+ let base+ | shm = "/dev/shm"+ | Just x <- xdg = x+ | otherwise = tmp+ withTempDirectory base "haskoin-store-test" f
@@ -0,0 +1,57 @@+-- |+-- Module : Haskoin.Store.Integration.Valkey+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- A Redis server for the integration tests and the stress test: Valkey, the+-- Redis-compatible server that Ubuntu ships, from the test image.+module Haskoin.Store.Integration.Valkey+ ( redisContainer,+ waitRedis,+ )+where++import Database.Redis qualified as Redis+import Haskoin.Store.Integration.Image (valkeyBin)+import Haskoin.Store.Integration.Podman (ContainerSpec (..))+import UnliftIO (bracket, tryAny)+import UnliftIO.Concurrent (threadDelay)++-- | Valkey, the Redis-compatible server of Ubuntu, from the test image, listening on every interface of the container+-- (only its published port is reachable, on 127.0.0.1), with nothing saved+-- to disk and at most 128 MiB of data.+redisContainer :: String -> ContainerSpec+redisContainer image =+ ContainerSpec+ { name = "redis",+ image = image,+ options = ["--memory", "256m", "--entrypoint", valkeyBin],+ command =+ [ "--bind",+ "0.0.0.0",+ "--protected-mode",+ "no",+ "--save",+ "",+ "--appendonly",+ "no",+ "--maxmemory",+ "128mb"+ ],+ ports = [6379]+ }++-- | Wait until Redis answers, for up to 30 seconds.+waitRedis :: String -> IO ()+waitRedis url = go (120 :: Int)+ where+ info = either error id (Redis.parseConnectInfo url)+ go n =+ tryAny (bracket (Redis.checkedConnect info) Redis.disconnect (const (return ()))) >>= \case+ Right () -> return ()+ Left e+ | n <= 0 -> fail ("Redis did not start: " <> show e)+ | otherwise -> threadDelay 250000 >> go (n - 1)
@@ -1,5 +1,3 @@-{-# LANGUAGE DuplicateRecordFields #-}- -- | -- Module : Haskoin.Store -- Copyright : (c) Haskoin Developers@@ -14,33 +12,49 @@ -- <https://hackage.haskell.org/package/haskoin-node haskoin-node>, downloads -- blocks and mempool transactions, and indexes them in RocksDB. The indices -- answer questions about blocks, transactions, address balances, unspent--- outputs and extended public keys. An optional Redis cache speeds up queries--- about extended public keys with many addresses. A REST API built on these--- indices is in "Haskoin.Store.Web".+-- outputs and extended public keys. A REST API built on these indices is in+-- "Haskoin.Store.Web". (The Redis cache of extended public keys is being+-- rewritten; until it is back, every query reads the database.) -- -- = Running a store ----- 'withStore' starts everything from a 'StoreConfig': the database, the node,--- the block store that imports data, and the cache if configured. The 'Store'--- it returns has a 'DatabaseReader' and, if the cache is on, a 'CacheConfig'.--- Run queries with 'withDB', or inside 'withCache' to use the cache.+-- 'withStore' starts everything from a 'StoreConfig': the database, the node+-- and the block store that imports data. The 'Store' it returns has a+-- 'DatabaseReader'; query it with the functions of+-- "Haskoin.Store.Database.Query", which take the reader and run in 'IO'. -- -- @ -- 'withStore' cfg $ \\store ->--- 'withSubscription' store.pub $ \\events -> do--- best <- 'withDB' store.db 'getBestBlock'+-- withSubscription store.pub $ \\events -> do+-- best <- Haskoin.Store.Database.Query.getBestBlock store.db -- ... -- @ -- -- = Modules -- -- * "Haskoin.Store.Data": types of the data returned by the store and the API.--- * "Haskoin.Store.Common": the 'StoreReadBase' and 'StoreReadExtra' query--- interfaces.--- * "Haskoin.Store.Database.Reader": queries against RocksDB.+-- * "Haskoin.Store.Common": helpers for paging, ordering and deriving+-- addresses, and 'StoreRead', the queries of a store as a record of+-- functions in 'IO'.+-- * "Haskoin.Store.Database.Reader": opening the RocksDB database.+-- * "Haskoin.Store.Database.Query": queries against RocksDB, as plain+-- functions.+-- * "Haskoin.Store.Database.Mempool", "Haskoin.Store.Database.Migrate",+-- "Haskoin.Store.Logic.Layered" and+-- "Haskoin.Store.Database.Query.Layered": a layout of the database in+-- which the mempool is kept apart from the chain, the migration to it, the+-- rules that import blocks and transactions in it, and its queries. The+-- store does not use them yet; see @docs\/layered-database.md@.+-- * "Haskoin.Store.Fleet": how stores that share a Redis agree on the block+-- that they all answer from, as pure decisions, and+-- "Haskoin.Store.Fleet.Redis", which carries them out.+-- "Haskoin.Store.Fleet.Pool" has the rules of the mempool that they share.+-- Nothing uses them yet.+-- * "Haskoin.Store.Database.Views": the database as it was at each recent+-- block, for a store that answers from the block its fleet agrees on.+-- Nothing uses it yet. -- * "Haskoin.Store.BlockStore": the process that imports blocks and -- transactions.--- * "Haskoin.Store.Cache": the Redis cache for extended public keys. -- * "Haskoin.Store.Web": the REST API server. -- = Purpose --@@ -64,9 +78,11 @@ -- query is a seek and a short scan, newest first; see -- "Haskoin.Store.Database.Types". ----- * __One query interface, several back ends.__ The database, the import buffer--- and the Redis cache all implement 'StoreReadBase' and 'StoreReadExtra', so--- the same query code runs against any of them.+-- * __Plain functions in 'IO'.__ Queries take the database reader, or a+-- 'StoreRead' record of them, which the web server builds with the Redis+-- cache in front of the queries about xpubs. Nothing runs in a stack of+-- monad transformers, except the conduits that merge sorted streams. The+-- web server runs on WAI with a router of its own. -- -- * __The mempool is a first-class citizen.__ Unconfirmed transactions are indexed -- like confirmed ones, ordered before them, and kept across restarts, because@@ -84,16 +100,15 @@ module Haskoin.Store.Data, -- * Cache+ Cache, CacheConfig (..),- CacheT,- CacheError (..),- withCache,- connectRedis,- isInCache,+ cacheIsCached,+ cacheDelete,+ cacheEvict, -- * Store Reader- StoreReadBase (..),- StoreReadExtra (..),+ StoreRead (..),+ databaseRead, Limits (..), Start (..), @@ -110,6 +125,8 @@ import Haskoin.Store.Cache import Haskoin.Store.Common import Haskoin.Store.Data+-- the instances of the store classes for the database reader+import Haskoin.Store.Database.Query (databaseRead) import Haskoin.Store.Database.Reader import Haskoin.Store.Database.Types import Haskoin.Store.Manager
@@ -1,1502 +1,1221 @@ {-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE ImportQualifiedPost #-}-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE NamedFieldPuns #-}-{-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RecordWildCards #-}-{-# LANGUAGE TupleSections #-}-{-# LANGUAGE NoFieldSelectors #-}-{-# OPTIONS_GHC -Wno-ambiguous-fields #-}---- |--- Module : Haskoin.Store.BlockStore--- Copyright : (c) Haskoin Developers--- License : MIT--- Maintainer : jprupp@protonmail.ch--- Stability : experimental--- Portability : POSIX------ The block store process downloads blocks and transactions from peers and--- imports them into the database.------ = Blocks------ When the best header of the node is ahead of the best block in the database,--- the block store picks one online peer as its syncing peer and asks it for--- blocks in batches of 'blockWindow'. Each block is imported as soon as it--- arrives. If the best block of the database is no longer in the main chain of--- headers, blocks are reverted until it is. A syncing peer that sends a block--- that was not requested, cannot find a block, or stays silent for longer than--- 'peerTimeout' is disconnected.------ = Mempool------ Once the database is in sync, the block store asks each peer for its mempool--- (if 'syncMempool' is set) and requests every transaction that peers--- announce. Received transactions are queued and imported in batches on every--- clock tick, in dependency order. Transactions that spend unknown outputs are--- kept for up to an hour, waiting for their parents. Mempool transactions older--- than 'mempoolTimeout' days are deleted.------ = Feeding the block store------ The block store does not listen to the node by itself.--- "Haskoin.Store.Manager" forwards node events to it with the--- @blockStore*@ functions of this module.--- = Why it works this way------ * __Blocks come from one peer at a time.__ Blocks must be imported in order,--- so downloading from several peers at once would only buffer blocks that cannot--- be imported yet. One syncing peer, kept busy with a sliding window of--- requests, uses the network well without unbounded buffering.------ * __The window refills early.__ The next batch is requested when fewer than a--- quarter of the window are still outstanding, so the peer never sits idle--- while the store imports, and the backlog never grows much beyond one window.------ * __Mempool sync waits for the tip.__ Unconfirmed transactions only make sense--- against the current chain, so they are requested once the database is in--- sync.------ * __Orphans get a grace period.__ A transaction can arrive before its parent.--- It is held for up to an hour, waiting for the parent, instead of being--- dropped and requested again.------ * __Imports are batched.__ Mempool transactions are imported up to 2000 at a--- time on each clock tick, which amortises the cost of a database write over--- many transactions during bursts.-module Haskoin.Store.BlockStore- ( -- * Block Store- BlockStore,- BlockStoreConfig (..),- withBlockStore,- blockStorePeerConnect,- blockStorePeerConnectSTM,- blockStorePeerDisconnect,- blockStorePeerDisconnectSTM,- blockStoreHead,- blockStoreHeadSTM,- blockStoreBlock,- blockStoreBlockSTM,- blockStoreNotFound,- blockStoreNotFoundSTM,- blockStoreTx,- blockStoreTxSTM,- blockStoreTxHash,- blockStoreTxHashSTM,- blockStorePendingTxs,- blockStorePendingTxsSTM,- needsMoreBlocks,- )-where--import Control.Monad- ( forM,- forM_,- mzero,- unless,- void,- when,- )-import Control.Monad.Reader- ( MonadReader,- ReaderT (..),- ask,- asks,- )-import Control.Monad.Trans (lift)-import Control.Monad.Trans.Maybe (MaybeT (MaybeT), runMaybeT)-import Data.Bool (bool)-import Data.ByteString qualified as B-import Data.HashMap.Strict (HashMap)-import Data.HashMap.Strict qualified as HashMap-import Data.HashSet (HashSet)-import Data.HashSet qualified as HashSet-import Data.List (delete)-import Data.Maybe- ( catMaybes,- fromJust,- fromMaybe,- isJust,- mapMaybe,- )-import Data.Serialize (encode)-import Data.String (fromString)-import Data.String.Conversions (cs)-import Data.Text (Text)-import Data.Text qualified as T-import Data.Time.Clock- ( NominalDiffTime,- UTCTime,- diffUTCTime,- getCurrentTime,- )-import Data.Time.Clock.POSIX- ( getPOSIXTime,- posixSecondsToUTCTime,- utcTimeToPOSIXSeconds,- )-import Data.Time.Format- ( defaultTimeLocale,- formatTime,- )-import Haskoin- ( Block (..),- BlockHash (..),- BlockHeader (..),- BlockHeight,- BlockNode (..),- Ctx,- GetData (..),- InvType (..),- InvVector (..),- Message (..),- Network (..),- OutPoint (..),- Tx (..),- TxHash (..),- TxIn (..),- blockHashToHex,- headerHash,- txHash,- txHashToHex,- )-import Haskoin.Node- ( Chain,- LogLevel (..),- Logger,- OnlinePeer (..),- Peer (..),- PeerMgr,- chainBlockMain,- chainGetAncestor,- chainGetBest,- chainGetBlock,- chainGetParents,- getPeers,- killPeer,- logAt,- sendMessage,- setBusy,- setFree,- )-import Haskoin.Store.Common-import Haskoin.Store.Data-import Haskoin.Store.Database.Reader-import Haskoin.Store.Database.Writer-import Haskoin.Store.Logic- ( ImportException (Orphan),- ImportM,- deleteUnconfirmedTx,- importBlock,- initBest,- newMempoolTx,- revertBlock,- )-import NQE- ( InChan,- Listen,- Mailbox,- Publisher,- inboxToMailbox,- newInbox,- publish,- query,- receive,- send,- sendSTM,- )-import System.Metrics.StatsD-import System.Random (randomRIO)-import UnliftIO- ( Exception,- MonadIO,- MonadUnliftIO,- STM,- TVar,- async,- atomically,- catch,- liftIO,- link,- modifyTVar,- modifyTVar',- newTVarIO,- readTVar,- readTVarIO,- throwIO,- try,- withAsync,- writeTVar,- )-import UnliftIO.Concurrent (threadDelay)---- | Messages handled by the block store process.-data BlockStoreMessage- = -- | the node has a new best header- BlockNewBest !BlockNode- | -- | a peer connected- BlockPeerConnect !Peer- | -- | a peer disconnected- BlockPeerDisconnect !Peer- | -- | a peer sent a block- BlockReceived !Peer !Block- | -- | a peer could not find blocks- BlockNotFound !Peer ![BlockHash]- | -- | a peer sent a transaction- TxRefReceived !Peer !Tx- | -- | a peer announced transactions- TxRefAvailable !Peer ![TxHash]- | -- | clock tick for housekeeping; reply when done- BlockPing !(Listen ())---- | Fatal errors of the block store.-data BlockException- = -- | the header of a block in the database is not known to the node- BlockNotInChain !BlockHash- | -- | the database has no best block- Uninitialized- | -- | the database is inconsistent- CorruptDatabase- | -- | the node has no header at this height below this block- AncestorNotInChain !BlockHeight !BlockHash- | -- | importing mempool transactions failed- MempoolImportFailed- deriving (Show, Eq, Ord, Exception)---- | State of the sync against the syncing peer.-data Syncing = Syncing- { -- | peer that blocks are downloaded from- peer :: !Peer,- -- | when it last delivered a block, or when syncing started- time :: !UTCTime,- -- | blocks requested and not yet imported, in chain order- blocks :: ![BlockHash]- }---- | Transaction received from a peer and waiting to be imported.-data PendingTx = PendingTx- { -- | when it was received- time :: !UTCTime,- -- | the transaction- tx :: !Tx,- -- | parent transactions that are missing; empty if it can be imported- deps :: !(HashSet TxHash)- }- deriving (Show, Eq, Ord)---- | Block store process state.-data BlockStore = BlockStore- { -- | mailbox of the process- mailbox :: !(Mailbox BlockStoreMessage),- -- | configuration- config :: !BlockStoreConfig,- -- | syncing peer, if any- peer :: !(TVar (Maybe Syncing)),- -- | transactions received and waiting to be imported- txs :: !(TVar (HashMap TxHash PendingTx)),- -- | transactions requested from peers in the last twenty seconds- requested :: !(TVar (HashSet TxHash)),- -- | StatsD metrics, if enabled- metrics :: !(Maybe StoreMetrics),- -- | labels of the peers whose mempool was already requested- syncedMempool :: !(TVar (HashSet Text))- }---- | StatsD metrics of the block store.-data StoreMetrics = StoreMetrics- { -- | height of the best block (@store.blocks@)- blocks :: !StatGauge,- -- | height of the best header (@store.headers@)- headers :: !StatGauge,- -- | transactions waiting to be imported (@store.queued_txs@)- queuedTxs :: !StatGauge,- -- | online peers (@store.peers@)- peers :: !StatGauge,- -- | transactions in the mempool (@store.mempool@)- mempool :: !StatGauge,- -- | milliseconds to import a block (@store.block@)- block :: !StatTiming- }---- | Register the block store metrics, if StatsD is configured, with their current values.-newStoreMetrics :: (MonadIO m) => BlockStoreConfig -> m (Maybe StoreMetrics)-newStoreMetrics cfg =- forM cfg.stats $ \s -> liftIO $ do- m <- withDB cfg.db getMempool- b <-- fmap (maybe 0 (.height)) $- withDB cfg.db $- runMaybeT $- MaybeT getBestBlock >>= MaybeT . getBlock- h <- chainGetBest cfg.chain- p <- getPeers cfg.peerMgr- blocks <- g s "blocks" (fromIntegral b)- headers <- g s "headers" (fromIntegral h.height)- queuedTxs <- g s "queued_txs" 0- peers <- g s "peers" (length p)- mempool <- g s "mempool" (length m)- block <- t s "block" 0- return StoreMetrics {..}- where- g s x = newStatGauge s ("store." <> x)- t s x = newStatTiming s ("store." <> x)---- | Update the gauge of the best block height.-setStoreHeight :: (MonadIO m) => BlockT m ()-setStoreHeight = void $ runMaybeT $ do- m <- MaybeT (asks (.metrics))- h <- MaybeT getBestBlock- b <- MaybeT (getBlock h)- setGauge m.blocks (fromIntegral b.height)---- | Update the gauge of the best header height.-setHeadersHeight :: (MonadIO m) => BlockT m ()-setHeadersHeight = void $ runMaybeT $ do- m <- MaybeT (asks (.metrics))- n <- liftIO . chainGetBest =<< asks (.config.chain)- setGauge m.headers (fromIntegral n.height)---- | Update the gauge of transactions waiting to be imported.-setPendingTxs :: (MonadIO m) => BlockT m ()-setPendingTxs = void $ runMaybeT $ do- m <- MaybeT (asks (.metrics))- p <- readTVarIO =<< asks (.txs)- setGauge m.queuedTxs (HashMap.size p)---- | Update the gauge of online peers.-setPeersConnected :: (MonadIO m) => BlockT m ()-setPeersConnected = void $ runMaybeT $ do- m <- MaybeT (asks (.metrics))- p <- liftIO . getPeers =<< asks (.config.peerMgr)- setGauge m.peers (length p)---- | Record how long a successful block import took.-withBlockTiming :: (MonadIO m) => BlockT m (Either l r) -> BlockT m (Either l r)-withBlockTiming go = do- asks (.metrics) >>= \case- Just m -> do- start <- round . (* 1000) <$> liftIO getPOSIXTime- go >>= \case- Right x -> do- end <- round . (* 1000) <$> liftIO getPOSIXTime- addTiming m.block (end - start)- return (Right x)- Left y -> return (Left y)- Nothing -> go---- | Configuration for a block store.-data BlockStoreConfig = BlockStoreConfig- { -- | secp256k1 context- ctx :: !Ctx,- -- | peer manager from running node- peerMgr :: !PeerMgr,- -- | chain from a running node- chain :: !Chain,- -- | listener for store events- pub :: !(Publisher StoreEvent),- -- | RocksDB database handle- db :: !DatabaseReader,- -- | network constants- net :: !Network,- -- | do not index new mempool transactions- noMempool :: !Bool,- -- | wipe mempool at start- wipeMempool :: !Bool,- -- | sync mempool from peers- syncMempool :: !Bool,- -- | blocks to request from the syncing peer in one batch- blockWindow :: !Int,- -- | delete transactions older than this number of days from mempool- mempoolTimeout :: !Int,- -- | disconnect syncing peer if inactive for this long- peerTimeout :: !NominalDiffTime,- -- | StatsD client, if metrics are enabled- stats :: !(Maybe Stats),- -- | log messages here- logger :: !Logger- }---- | Monad transformer of the block store process.-type BlockT m = ReaderT BlockStore m---- | Log a message with the logger of the block store.-logDebug, logInfo, logWarn, logError ::- (MonadIO m, MonadReader BlockStore m) => Text -> m ()-logDebug = blockLog LevelDebug-logInfo = blockLog LevelInfo-logWarn = blockLog LevelWarn-logError = blockLog LevelError---- | Log a message at a level with the logger of the block store.-blockLog :: (MonadIO m, MonadReader BlockStore m) => LogLevel -> Text -> m ()-blockLog l msg = asks (.config.logger) >>= \lg -> logAt l lg msg---- | Run an import against the database, returning the import error if it fails.-runImport ::- (MonadIO m) =>- Network ->- Ctx ->- ImportM a ->- BlockT m (Either ImportException a)-runImport net ctx f = fmap fst <$> runImportMempool net ctx f---- | Like 'runImport', but also return how the import changed the mempool.--- The mempool gauge is kept up to date from those changes instead of--- counting the whole mempool on every clock tick.-runImportMempool ::- (MonadIO m) =>- Network ->- Ctx ->- ImportM a ->- BlockT m (Either ImportException (a, MempoolChanges))-runImportMempool net ctx f = do- r <- ReaderT $ \r ->- liftIO . try $ runWriterMempool net ctx r.config.db r.config.logger f- forM_ r $ \(_, c) -> asks (.metrics) >>= mapM_ (\m -> do- incrementGauge m.mempool (length c.added)- decrementGauge m.mempool (length c.removed))- return r---- | Run an import and publish the transactions that it added to the--- mempool or removed from it, then the new best block if there is one.--- Transactions of the block are not published.-runImportNotify ::- (MonadIO m) =>- Maybe Block ->- ImportM a ->- BlockT m (Either ImportException a)-runImportNotify block f = do- net <- getNetwork- ctx <- getCtx- runImportMempool net ctx f >>= \case- Left e -> return (Left e)- Right (x, c) -> do- l <- asks (.config.pub)- forM_ (filter notInBlock c.removed) $ \h ->- publish (StoreMempoolDelete h) l- forM_ (filter notInBlock c.added) $ \h ->- publish (StoreMempoolNew h) l- forM_ block $ \b ->- publish (StoreBestBlock (headerHash b.header)) l- return (Right x)- where- inBlock = maybe HashSet.empty (HashSet.fromList . map txHash . (.txs)) block- notInBlock = not . (`HashSet.member` inBlock)---- | Run a database query from the block store.-runRocksDB :: ReaderT DatabaseReader m a -> BlockT m a-runRocksDB f =- ReaderT $ runReaderT f . (.config.db)--instance (MonadIO m) => StoreReadBase (BlockT m) where- getCtx =- asks (.config.ctx)- getNetwork =- runRocksDB getNetwork- getBestBlock =- runRocksDB getBestBlock- getBlocksAtHeight =- runRocksDB . getBlocksAtHeight- getBlock =- runRocksDB . getBlock- getTxData =- runRocksDB . getTxData- getManyTxData =- runRocksDB . getManyTxData- getSpender =- runRocksDB . getSpender- getUnspent =- runRocksDB . getUnspent- getBalance =- runRocksDB . getBalance- getMempool =- runRocksDB getMempool- getMempoolPage =- runRocksDB . getMempoolPage--instance (MonadUnliftIO m) => StoreReadExtra (BlockT m) where- getMaxGap =- runRocksDB getMaxGap- getInitialGap =- runRocksDB getInitialGap- getAddressesTxs as =- runRocksDB . getAddressesTxs as- getAddressesUnspents as =- runRocksDB . getAddressesUnspents as- getAddressUnspents a =- runRocksDB . getAddressUnspents a- getAddressTxs a =- runRocksDB . getAddressTxs a- getNumTxData =- runRocksDB . getNumTxData- getBalances =- runRocksDB . getBalances- xPubBals =- runRocksDB . xPubBals- xPubUnspents x l =- runRocksDB . xPubUnspents x l- xPubTxs x l =- runRocksDB . xPubTxs x l- xPubTxCount x =- runRocksDB . xPubTxCount x---- | Run block store process.-withBlockStore ::- (MonadUnliftIO m) =>- BlockStoreConfig ->- (BlockStore -> m a) ->- m a-withBlockStore cfg action = do- pb <- newTVarIO Nothing- ts <- newTVarIO HashMap.empty- rq <- newTVarIO HashSet.empty- sy <- newTVarIO HashSet.empty- inbox <- newInbox- metrics <- newStoreMetrics cfg- let r =- BlockStore- { mailbox = inboxToMailbox inbox,- config = cfg,- peer = pb,- txs = ts,- requested = rq,- metrics = metrics,- syncedMempool = sy- }- withAsync (runReaderT (go inbox) r) $ \a -> do- link a- action r- where- go inbox = do- ini- wipe- run inbox- del txs = do- logAt LevelInfo cfg.logger $- "Deleting " <> cs (show (length txs)) <> " transactions"- forM_ txs $ \(_, th) -> deleteUnconfirmedTx False th- wipe_it txs = do- net <- getNetwork- ctx <- getCtx- let (txs1, txs2) = splitAt 1000 txs- unless (null txs1) $- runImport net ctx (del txs1) >>= \case- Left e -> do- logError $- "Could not wipe mempool: " <> cs (show e)- throwIO e- Right () -> wipe_it txs2- wipe- | cfg.wipeMempool =- getMempool >>= wipe_it- | otherwise =- return ()- ini = do- net <- getNetwork- ctx <- getCtx- runImport net ctx initBest >>= \case- Left e -> do- logError $- "Could not initialize: " <> cs (show e)- throwIO e- Right () -> return ()- run inbox =- withAsync (pingMe (inboxToMailbox inbox)) $ \a ->- link a >> runBlockStoreLoop inbox---- | Handle messages forever.-runBlockStoreLoop ::- (InChan mbox, MonadUnliftIO m) =>- mbox BlockStoreMessage ->- ReaderT BlockStore m b-runBlockStoreLoop inbox =- loopForever $ do- logDebug "Waiting for new event..."- msg <- receive inbox- ReaderT $ runReaderT $ processBlockStoreMessage msg---- | Whether the best block in the database is the best header of the node. If--- so, release the syncing peer.-isInSync :: (MonadIO m) => BlockT m Bool-isInSync =- getBestBlock >>= \case- Nothing -> do- logError "Block database uninitialized"- throwIO Uninitialized- Just bb -> do- cb <- asks (.config.chain) >>= liftIO . chainGetBest- if headerHash cb.header == bb- then clearSyncingState >> return True- else return False---- | Run an action only if the database is in sync.-guardSync :: (MonadIO m) => BlockT m () -> BlockT m ()-guardSync f = isInSync >>= \s -> when s f---- | Run an action only if the mempool is indexed.-guardMempool :: (Monad m) => BlockT m () -> BlockT m ()-guardMempool f = asks (.config.noMempool) >>= \n -> unless n f---- | Ask a peer for its mempool, once per peer, if in sync and mempool sync is enabled.-syncMempool :: (MonadUnliftIO m) => Peer -> BlockT m ()-syncMempool p =- guardMempool . guardSync $ do- s <- asks (.config.syncMempool)- m <- asks (.syncedMempool)- y <- not . HashSet.member p.label <$> readTVarIO m- when (s && y) $ do- logDebug $- "Requesting mempool from peer: " <> p.label- atomically . modifyTVar m $ HashSet.insert p.label- liftIO $ MMempool `sendMessage` p---- | Import a block from the syncing peer, then ask for more blocks or, if now in--- sync, sync the mempool. Disconnects the peer if it is not the syncing peer, if--- the block is unknown to the node, or if the block cannot be imported.-processBlock ::- (MonadUnliftIO m) =>- Peer ->- Block ->- BlockT m ()-processBlock peer block = void . runMaybeT $ do- checkPeer peer >>= \case- True -> return ()- False -> do- logError $- "Non-syncing peer "- <> peer.label- <> " sent block "- <> blockHashToHex blockhash- killPeer peer- mzero- node <-- getBlockNode blockhash >>= \case- Just b -> return b- Nothing -> do- logError $- "Peer "- <> peer.label- <> " sent unknown block "- <> blockHashToHex blockhash- killPeer peer- mzero- logDebug $- "Processing block "- <> blockText node Nothing- <> " from peer "- <> peer.label- lift $ do- ret <- withBlockTiming $ runImportNotify (Just block) (importBlock block node)- case ret of- Left e -> failure e- Right _ -> success node- where- header = block.header- blockhash = headerHash header- hexhash = blockHashToHex blockhash- success node = do- logInfo $- "Best block: " <> blockText node (Just block)- removeSyncingBlock $ headerHash node.header- touchPeer- isInSync >>= \case- False -> syncMe- True -> do- updateOrphans- syncMempool peer- failure e = do- logError $- "Error importing block "- <> hexhash- <> " from peer: "- <> peer.label- <> ": "- <> cs (show e)- killPeer peer---- | Set the blocks requested from the syncing peer.-setSyncingBlocks ::- (MonadReader BlockStore m, MonadIO m) =>- [BlockHash] ->- m ()-setSyncingBlocks hs =- asks (.peer) >>= \box ->- atomically $- modifyTVar box $ \case- Nothing -> Nothing- Just x -> Just (x :: Syncing) {blocks = hs}---- | Blocks requested from the syncing peer and not yet imported.-getSyncingBlocks :: (MonadReader BlockStore m, MonadIO m) => m [BlockHash]-getSyncingBlocks =- asks (.peer) >>= readTVarIO >>= \case- Nothing -> return []- Just x -> return x.blocks---- | Add blocks to those requested from the syncing peer.-addSyncingBlocks ::- (MonadReader BlockStore m, MonadIO m) =>- [BlockHash] ->- m ()-addSyncingBlocks hs =- asks (.peer) >>= \box ->- atomically $- modifyTVar box $ \case- Nothing -> Nothing- Just x -> Just (x :: Syncing) {blocks = x.blocks <> hs}---- | Remove a block from those requested from the syncing peer.-removeSyncingBlock ::- (MonadReader BlockStore m, MonadIO m) =>- BlockHash ->- m ()-removeSyncingBlock h = do- box <- asks (.peer)- atomically $- modifyTVar box $ \case- Nothing -> Nothing- Just x -> Just (x :: Syncing) {blocks = delete h x.blocks}---- | Whether a peer is the syncing peer.-checkPeer :: (MonadIO m, MonadReader BlockStore m) => Peer -> m Bool-checkPeer p = fmap (fromMaybe False) <$> runMaybeT $ do- Syncing {peer} <- MaybeT getSyncingState- return $ peer == p---- | Header of a block from the node.-getBlockNode ::- (MonadIO m, MonadReader BlockStore m) =>- BlockHash ->- m (Maybe BlockNode)-getBlockNode blockhash = do- ch <- asks (.config.chain)- liftIO $ chainGetBlock ch blockhash---- | Disconnect a peer that could not find requested blocks.-processNoBlocks ::- (MonadIO m) =>- Peer ->- [BlockHash] ->- BlockT m ()-processNoBlocks p hs = do- forM_ (zip [(1 :: Int) ..] hs) $ \(i, h) ->- logError $- "Block "- <> cs (show i)- <> "/"- <> cs (show (length hs))- <> " "- <> blockHashToHex h- <> " not found by peer: "- <> p.label- killPeer p---- | Queue a transaction received from a peer for import.-processTx :: (MonadIO m) => Peer -> Tx -> BlockT m ()-processTx p tx = guardMempool $ do- t <- liftIO getCurrentTime- logDebug $- "Received tx "- <> txHashToHex (txHash tx)- <> " by peer: "- <> p.label- addPendingTx $ PendingTx t tx HashSet.empty---- | Drop queued transactions received more than an hour ago.-pruneOrphans :: (MonadIO m) => BlockT m ()-pruneOrphans = guardMempool $ do- ts <- asks (.txs)- now <- liftIO getCurrentTime- -- keep pending transactions received within the last hour, drop older ones- atomically . modifyTVar' ts . HashMap.filter $ \p ->- now `diffUTCTime` p.time <= 3600---- | Queue a transaction for import.-addPendingTx :: (MonadIO m) => PendingTx -> BlockT m ()-addPendingTx p = do- ts <- asks (.txs)- rq <- asks (.requested)- atomically $ do- modifyTVar ts $ HashMap.insert th p- modifyTVar rq $ HashSet.delete th- setPendingTxs- where- th = txHash p.tx---- | Remember for twenty seconds that a transaction was requested, so that it is not requested again.-addRequestedTx :: (MonadIO m) => TxHash -> BlockT m ()-addRequestedTx th = do- qbox <- asks (.requested)- atomically $ modifyTVar qbox $ HashSet.insert th- liftIO . void . async $ do- threadDelay 20000000- atomically $ modifyTVar qbox $ HashSet.delete th---- | Whether a transaction is queued or was requested recently.-isPending :: (MonadIO m) => TxHash -> BlockT m Bool-isPending th = do- tbox <- asks (.txs)- qbox <- asks (.requested)- atomically $ do- ts <- readTVar tbox- rs <- readTVar qbox- return $- th `HashMap.member` ts- || th `HashSet.member` rs---- | Take up to this many queued transactions that have no missing parents, in--- dependency order, out of the queue.-pendingTxs :: (MonadIO m) => Int -> BlockT m [PendingTx]-pendingTxs i = do- selected <-- asks (.txs) >>= \box -> atomically $ do- pending <- readTVar box- let (selected, rest) = select pending- writeTVar box rest- return selected- setPendingTxs- return selected- where- select pend =- let eligible = HashMap.filter (null . (.deps)) pend- orphans = HashMap.difference pend eligible- selected = take i $ sortit eligible- remaining = HashMap.filter (`notElem` selected) eligible- in (selected, remaining <> orphans)- sortit m =- let sorted = sortTxs $ map (.tx) $ HashMap.elems m- txids = map (txHash . snd) sorted- in mapMaybe (`HashMap.lookup` m) txids---- | Mark a transaction as no longer missing for the queued transactions that spend it.-fulfillOrphans :: (MonadIO m) => BlockStore -> TxHash -> m ()-fulfillOrphans block_read th =- atomically $ modifyTVar box (HashMap.map fulfill)- where- box = block_read.txs- fulfill p = p {deps = HashSet.delete th p.deps}---- | Drop queued transactions that are already in the database, and recompute the--- missing parents of the others.-updateOrphans ::- ( StoreReadBase m,- MonadIO m,- MonadReader BlockStore m- ) =>- m ()-updateOrphans = do- box <- asks (.txs)- pending <- readTVarIO box- let orphans = HashMap.filter (not . null . (.deps)) pending- updated <- forM orphans $ \p -> do- let tx = p.tx- exists (txHash tx) >>= \case- True -> return Nothing- False -> Just <$> fill_deps p- let pruned = HashMap.map fromJust $ HashMap.filter isJust updated- atomically $ writeTVar box pruned- where- exists th =- getTxData th >>= \case- Nothing -> return False- Just TxData {deleted = True} -> return False- Just TxData {deleted = False} -> return True- prev_utxos tx = catMaybes <$> mapM (getUnspent . (.outpoint)) tx.inputs- fulfill p unspent =- let unspent_hash = unspent.outpoint.hash- new_deps = HashSet.delete unspent_hash p.deps- in p {deps = new_deps}- fill_deps p = do- let tx = p.tx- unspents <- prev_utxos tx- return $ foldl fulfill p unspents---- | Queue a transaction that spends unknown outputs, recording which parents are missing.-newOrphanTx ::- (MonadIO m) =>- BlockStore ->- UTCTime ->- Tx ->- WriterT m ()-newOrphanTx block_read time tx = do- logAt LevelDebug block_read.config.logger $- "Import tx "- <> txHashToHex (txHash tx)- <> ": Orphan"- let box = block_read.txs- unspents <- catMaybes <$> mapM getUnspent prevs- let unspent_set = HashSet.fromList (map (.outpoint) unspents)- missing_set = HashSet.difference prev_set unspent_set- missing_txs = HashSet.map (.hash) missing_set- atomically . modifyTVar box $- HashMap.insert- (txHash tx)- PendingTx- { time = time,- tx = tx,- deps = missing_txs- }- where- prev_set = HashSet.fromList prevs- prevs = map (.outpoint) tx.inputs---- | Import a transaction into the mempool. Returns 'True' if imported. A--- transaction that spends unknown outputs is queued as an orphan; other errors--- are ignored.-importMempoolTx :: BlockStore -> UTCTime -> Tx -> ImportM Bool-importMempoolTx block_read time tx =- catch new_mempool_tx handle_error- where- tx_hash = txHash tx- handle_error :: ImportException -> ImportM Bool- handle_error Orphan = do- newOrphanTx block_read time tx- return False- handle_error _ = return False- seconds = floor (utcTimeToPOSIXSeconds time)- new_mempool_tx = do- t <- newMempoolTx tx seconds- logAt LevelInfo block_read.config.logger $- bool "Already have" "Imported" t- <> " tx "- <> txHashToHex (txHash tx)- when t $ fulfillOrphans block_read tx_hash- return t---- | Import up to 2000 queued transactions.-processMempool :: (MonadIO m) => BlockT m ()-processMempool = guardMempool $ do- txs <- pendingTxs 2000- block_read <- ask- unless (null txs) $ import_txs block_read txs- where- run_import block_read p =- importMempoolTx block_read p.time p.tx- import_txs block_read txs =- runImportNotify Nothing (mapM (run_import block_read) txs) >>= \case- Left e -> report_error e- Right _ -> return ()- report_error e = do- logError $- "Error processing mempool: " <> cs (show e)- throwIO e---- | Delete up to 1000 mempool transactions older than the configured number of days.-pruneMempool :: (MonadIO m) => BlockT m ()-pruneMempool =- guardMempool $ do- days <- asks (.config.mempoolTimeout)- when (days > 0) $ do- time <- (floor . utcTimeToPOSIXSeconds) <$> liftIO getCurrentTime- let thresh = time - (fromIntegral days * 24 * 60 * 60)- -- runs on every clock tick: read only the expired entries, oldest- -- first, from the index by time- txs <- map snd <$> runRocksDB (getMempoolBefore thresh 1000)- unless (null txs) $ do- logInfo $- "Deleting " <> cs (show (length txs)) <> " old transactions from mempool"- runImportNotify Nothing (mapM_ (deleteUnconfirmedTx False) txs) >>= \case- Left e ->- logError $- "Error pruning mempool: " <> cs (show e)- Right () -> return ()---- | Request from a peer the announced transactions that are neither queued,--- recently requested nor in the database. Only while in sync.-processTxs ::- (MonadIO m) =>- Peer ->- [TxHash] ->- BlockT m ()-processTxs p hs = guardMempool . guardSync $ do- logDebug $- "Received inventory with "- <> cs (show (length hs))- <> " transactions from peer: "- <> p.label- xs <- catMaybes <$> zip_counter process_tx- unless (null xs) $ go xs- where- len = length hs- zip_counter = forM (zip [(1 :: Int) ..] hs) . uncurry- process_tx i h =- isPending h >>= \case- True -> do- logDebug $- "Tx "- <> cs (show i)- <> "/"- <> cs (show len)- <> " "- <> txHashToHex h- <> ": "- <> "Already Pending"- return Nothing- False ->- getTxData h >>= \case- Just _ -> do- logDebug $- "Tx "- <> cs (show i)- <> "/"- <> cs (show len)- <> " "- <> txHashToHex h- <> ": "- <> "Already Imported"- return Nothing- Nothing -> do- logDebug $- "Tx "- <> cs (show i)- <> "/"- <> cs (show len)- <> " "- <> txHashToHex h- <> ": "- <> "Requesting"- return (Just h)- go xs = do- mapM_ addRequestedTx xs- net <- asks (.config.net)- let inv = if net.segWit then InvWitnessTx else InvTx- vec = map (InvVector inv . (.get)) xs- msg = MGetData (GetData vec)- liftIO $ msg `sendMessage` p---- | Record that the syncing peer just delivered something.-touchPeer ::- ( MonadIO m,- MonadReader BlockStore m- ) =>- m ()-touchPeer =- getSyncingState >>= \case- Nothing -> return ()- Just _ -> do- box <- asks (.peer)- now <- liftIO getCurrentTime- atomically $- modifyTVar box $- fmap $- \x -> (x :: Syncing) {time = now}---- | Disconnect the syncing peer if it has been silent for too long.-checkTime :: (MonadIO m) => BlockT m ()-checkTime =- asks (.peer) >>= readTVarIO >>= \case- Nothing -> return ()- Just- Syncing- { time = t,- peer = p- } -> do- now <- liftIO getCurrentTime- peer_time_out <- asks (.config.peerTimeout)- when (now `diffUTCTime` t > peer_time_out) $ do- logError $- "Syncing peer timeout: " <> p.label- killPeer p---- | Revert blocks until the best block in the database is in the main chain of headers.-revertToMainChain :: (MonadIO m) => BlockT m ()-revertToMainChain = do- h <- headerHash . (.header) <$> getBest- ch <- asks (.config.chain)- net <- getNetwork- ctx <- getCtx- liftIO (chainBlockMain ch h) >>= \x -> unless x $ do- logWarn $- "Reverting best block: "- <> blockHashToHex h- runImport net ctx (revertBlock h) >>= \case- Left e -> do- logError $- "Could not revert block "- <> blockHashToHex h- <> ": "- <> cs (show e)- throwIO e- Right () -> setSyncingBlocks []- revertToMainChain---- | Header of the best block in the database.-getBest :: (MonadIO m) => BlockT m BlockNode-getBest = do- bb <-- getBestBlock >>= \case- Just b -> return b- Nothing -> do- logError "No best block set"- throwIO Uninitialized- ch <- asks (.config.chain)- liftIO (chainGetBlock ch bb) >>= \case- Just x -> return x- Nothing -> do- logError $- "Header not found for best block: "- <> blockHashToHex bb- throwIO (BlockNotInChain bb)---- | Header of the last block requested from the syncing peer, or of the best block if none.-getSyncBest :: (MonadIO m) => BlockT m BlockNode-getSyncBest = do- bb <-- getSyncingBlocks >>= \case- [] ->- getBestBlock >>= \case- Just b -> return b- Nothing -> do- logError "No best block set"- throwIO Uninitialized- hs -> return $ last hs- ch <- asks (.config.chain)- liftIO (chainGetBlock ch bb) >>= \case- Just x -> return x- Nothing -> do- logError $- "Header not found for block: "- <> blockHashToHex bb- throwIO (BlockNotInChain bb)---- | The syncing peer, when it is time to ask it for the next batch of blocks.-shouldSync :: (MonadIO m) => BlockT m (Maybe Peer)-shouldSync =- isInSync >>= \case- True -> return Nothing- False ->- getSyncingState >>= \case- Nothing -> return Nothing- Just Syncing {peer = p, blocks = bs} -> do- window <- blockWindow- return $- if needsMoreBlocks window (length bs)- then Just p- else Nothing---- | Blocks to request from the syncing peer in one batch. Never below one,--- or the store would never ask for any block.-blockWindow :: (Monad m) => BlockT m Int-blockWindow = asks (max 1 . (.config.blockWindow))---- | Whether to ask for the next batch, given the window size and how many--- requested blocks are still waiting to be imported. Asking once fewer than--- a quarter of the window remain keeps the peer busy, while the queue of--- downloaded blocks never grows much beyond one window. The threshold is at--- least one, so that a window smaller than four still refills once empty.-needsMoreBlocks :: Int -> Int -> Bool-needsMoreBlocks window outstanding =- outstanding < max 1 (window `div` 4)---- | Ask the syncing peer for the next batch of blocks, if the window has room.-syncMe :: (MonadIO m) => BlockT m ()-syncMe = do- revertToMainChain- shouldSync >>= \case- Nothing -> return ()- Just p -> do- bb <- getSyncBest- bh <- getbh- when (bb /= bh) $ do- bns <- sel bb bh- iv <- getiv bns- logDebug $- "Requesting "- <> fromString (show (length iv))- <> " blocks from peer: "- <> p.label- addSyncingBlocks $ map (headerHash . (.header)) bns- liftIO $ MGetData (GetData iv) `sendMessage` p- where- getiv bns = do- w <- asks (.config.net.segWit)- let i = if w then InvWitnessBlock else InvBlock- f = InvVector i . (.get) . headerHash . (.header)- return $ map f bns- getbh =- liftIO . chainGetBest =<< asks (.config.chain)- -- The blocks right after the last requested one, up to one window or- -- the chain tip, whichever comes first.- sel bb bh = do- window <- blockWindow- top <- findAncestor (min (bb.height + fromIntegral window) bh.height) bh- ch <- asks (.config.chain)- ps <- liftIO $ chainGetParents ch (bb.height + 1) top- return $ ps <> [top]---- | Ancestor of a header at a height. Throws 'AncestorNotInChain' if missing.-findAncestor ::- (MonadIO m, MonadReader BlockStore m) =>- BlockHeight ->- BlockNode ->- m BlockNode-findAncestor height target = do- ch <- asks (.config.chain)- liftIO (chainGetAncestor ch height target) >>= \case- Just ancestor -> return ancestor- Nothing -> do- let h = headerHash target.header- logError $- "Could not find header for ancestor of block "- <> blockHashToHex h- <> " at height "- <> cs (show target.height)- throwIO $ AncestorNotInChain height h---- | Forget a peer: release it if it was the syncing peer, and forget that its mempool was requested.-finishPeer ::- (MonadIO m, MonadReader BlockStore m) =>- Peer ->- m ()-finishPeer p = do- box <- asks (.peer)- readTVarIO box >>= \case- Just Syncing {peer = p'} | p == p' -> reset_it box- _ -> return ()- y <- asks (.syncedMempool)- atomically $ modifyTVar y $ HashSet.delete p.label- where- reset_it box = do- atomically $ writeTVar box Nothing- logDebug $ "Releasing peer: " <> p.label- liftIO $ setFree p---- | Make a peer the syncing peer if there is none and the peer is not busy.-trySetPeer :: (MonadIO m) => Peer -> BlockT m Bool-trySetPeer p =- getSyncingState >>= \case- Just _ -> return False- Nothing -> set_it- where- set_it =- liftIO (setBusy p) >>= \case- False -> return False- True -> do- logDebug $- "Locked peer: " <> p.label- box <- asks (.peer)- now <- liftIO getCurrentTime- atomically . writeTVar box $- Just- Syncing- { peer = p,- time = now,- blocks = []- }- return True---- | If not in sync and there is no syncing peer, pick the fastest free peer and start syncing.-trySyncing :: (MonadIO m) => BlockT m ()-trySyncing =- isInSync >>= \case- True -> return ()- False ->- getSyncingState >>= \case- Just _ -> return ()- Nothing -> online_peer- where- recurse [] = return ()- recurse (p : ps) =- trySetPeer p >>= \case- False -> recurse ps- True -> syncMe- online_peer = do- ops <- liftIO . getPeers =<< asks (.config.peerMgr)- let ps = map (.mailbox) ops- recurse ps---- | Start syncing against a newly connected peer if needed, or request its mempool if in sync.-trySyncingPeer :: (MonadUnliftIO m) => Peer -> BlockT m ()-trySyncingPeer p =- isInSync >>= \case- True -> syncMempool p- False ->- trySetPeer p >>= \case- False -> return ()- True -> syncMe---- | State of the sync against the syncing peer, if any.-getSyncingState ::- (MonadIO m, MonadReader BlockStore m) => m (Maybe Syncing)-getSyncingState =- readTVarIO =<< asks (.peer)---- | Release the syncing peer, if any.-clearSyncingState ::- (MonadIO m, MonadReader BlockStore m) => m ()-clearSyncingState =- asks (.peer) >>= readTVarIO >>= \case- Nothing -> return ()- Just Syncing {peer = p} -> finishPeer p---- | Handle a message sent to the block store.-processBlockStoreMessage ::- (MonadUnliftIO m) =>- BlockStoreMessage ->- BlockT m ()-processBlockStoreMessage (BlockNewBest node) = do- logDebug $- "New best block mined at height "- <> cs (show node.height)- <> ": "- <> blockHashToHex (headerHash node.header)- trySyncing-processBlockStoreMessage (BlockPeerConnect p) = do- logDebug $- "New peer connected: " <> p.label- trySyncingPeer p-processBlockStoreMessage (BlockPeerDisconnect p) = do- logDebug $- "Peer disconnected: " <> p.label- finishPeer p-processBlockStoreMessage (BlockReceived p b) = do- logDebug $- "Received block: "- <> blockHashToHex (headerHash b.header)- processBlock p b-processBlockStoreMessage (BlockNotFound p bs) = do- logDebug $- "Blocks not found by peer "- <> p.label- <> ": "- <> T.unwords (map blockHashToHex bs)- processNoBlocks p bs-processBlockStoreMessage (TxRefReceived p tx) = do- logDebug $- "Transaction received from peer "- <> p.label- <> ": "- <> txHashToHex (txHash tx)- processTx p tx-processBlockStoreMessage (TxRefAvailable p ts) = do- logDebug $- "Transactions available from peer "- <> p.label- <> ": "- <> T.unwords (map txHashToHex ts)- processTxs p ts-processBlockStoreMessage (BlockPing r) = do- logDebug "Internal clock event"- setStoreHeight- setHeadersHeight- setPendingTxs- setPeersConnected- trySyncing- processMempool- pruneOrphans- pruneMempool- checkTime- atomically (r ())---- | Send a clock tick to the block store every 0.1 to 1 second, waiting for each to be handled.-pingMe :: (MonadIO m) => Mailbox BlockStoreMessage -> m ()-pingMe mbox =- loopForever $ do- BlockPing `query` mbox- delay <- liftIO $ randomRIO (100 * 1000, 1000 * 1000)- threadDelay delay---- | Tell the block store that a peer connected.-blockStorePeerConnect :: (MonadIO m) => Peer -> BlockStore -> m ()-blockStorePeerConnect peer store =- BlockPeerConnect peer `send` store.mailbox---- | Tell the block store that a peer disconnected.-blockStorePeerDisconnect ::- (MonadIO m) => Peer -> BlockStore -> m ()-blockStorePeerDisconnect peer store =- BlockPeerDisconnect peer `send` store.mailbox---- | Tell the block store that the node has a new best header.-blockStoreHead ::- (MonadIO m) => BlockNode -> BlockStore -> m ()-blockStoreHead node store =- BlockNewBest node `send` store.mailbox---- | Give the block store a block received from a peer.-blockStoreBlock ::- (MonadIO m) => Peer -> Block -> BlockStore -> m ()-blockStoreBlock peer block store =- BlockReceived peer block `send` store.mailbox---- | Tell the block store that a peer could not find blocks.-blockStoreNotFound ::- (MonadIO m) => Peer -> [BlockHash] -> BlockStore -> m ()-blockStoreNotFound peer blocks store =- BlockNotFound peer blocks `send` store.mailbox---- | Give the block store a transaction received from a peer.-blockStoreTx ::- (MonadIO m) => Peer -> Tx -> BlockStore -> m ()-blockStoreTx peer tx store =- TxRefReceived peer tx `send` store.mailbox---- | Tell the block store that a peer announced transactions.-blockStoreTxHash ::- (MonadIO m) => Peer -> [TxHash] -> BlockStore -> m ()-blockStoreTxHash peer txhashes store =- TxRefAvailable peer txhashes `send` store.mailbox---- | 'STM' version of 'blockStorePeerConnect'.-blockStorePeerConnectSTM ::- Peer -> BlockStore -> STM ()-blockStorePeerConnectSTM peer store =- BlockPeerConnect peer `sendSTM` store.mailbox---- | 'STM' version of 'blockStorePeerDisconnect'.-blockStorePeerDisconnectSTM ::- Peer -> BlockStore -> STM ()-blockStorePeerDisconnectSTM peer store =- BlockPeerDisconnect peer `sendSTM` store.mailbox---- | 'STM' version of 'blockStoreHead'.-blockStoreHeadSTM ::- BlockNode -> BlockStore -> STM ()-blockStoreHeadSTM node store =- BlockNewBest node `sendSTM` store.mailbox---- | 'STM' version of 'blockStoreBlock'.-blockStoreBlockSTM ::- Peer -> Block -> BlockStore -> STM ()-blockStoreBlockSTM peer block store =- BlockReceived peer block `sendSTM` store.mailbox---- | 'STM' version of 'blockStoreNotFound'.-blockStoreNotFoundSTM ::- Peer -> [BlockHash] -> BlockStore -> STM ()-blockStoreNotFoundSTM peer blocks store =- BlockNotFound peer blocks `sendSTM` store.mailbox---- | 'STM' version of 'blockStoreTx'.-blockStoreTxSTM ::- Peer -> Tx -> BlockStore -> STM ()-blockStoreTxSTM peer tx store =- TxRefReceived peer tx `sendSTM` store.mailbox---- | 'STM' version of 'blockStoreTxHash'.-blockStoreTxHashSTM ::- Peer -> [TxHash] -> BlockStore -> STM ()-blockStoreTxHashSTM peer txhashes store =- TxRefAvailable peer txhashes `sendSTM` store.mailbox---- | Number of transactions queued or requested by the block store.-blockStorePendingTxs ::- (MonadIO m) => BlockStore -> m Int-blockStorePendingTxs =- atomically . blockStorePendingTxsSTM---- | 'STM' version of 'blockStorePendingTxs'.-blockStorePendingTxsSTM ::- BlockStore -> STM Int-blockStorePendingTxsSTM BlockStore {..} = do- x <- HashMap.keysSet <$> readTVar txs- y <- readTVar requested- return $ HashSet.size $ x `HashSet.union` y+{-# LANGUAGE RecordWildCards #-}+{-# OPTIONS_GHC -Wno-ambiguous-fields #-}++-- |+-- Module : Haskoin.Store.BlockStore+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- The block store process downloads blocks and transactions from peers and+-- imports them into the database.+--+-- = Blocks+--+-- When the best header of the node is ahead of the best block in the database,+-- the block store picks one online peer as its syncing peer and asks it for+-- blocks in batches of 'blockWindow'. Each block is imported as soon as it+-- arrives. If the best block of the database is no longer in the main chain of+-- headers, blocks are reverted until it is. A syncing peer that sends a block+-- that was not requested, cannot find a block, or stays silent for longer than+-- 'peerTimeout' is disconnected. The state of the download, and every rule+-- about it, is the pure state machine of "Haskoin.Store.BlockStore.Sync";+-- this module carries out what its transitions decide.+--+-- = Mempool+--+-- Once the database is in sync, the block store asks each peer for its mempool+-- (if 'syncMempool' is set) and requests every transaction that peers+-- announce. Received transactions are queued and imported in batches on every+-- clock tick, in dependency order. Transactions that spend unknown outputs are+-- kept for up to an hour, waiting for their parents. Mempool transactions older+-- than 'mempoolTimeout' days are deleted. The transactions on their way in,+-- requested or received, are the pure value of+-- "Haskoin.Store.BlockStore.Pending".+--+-- = Feeding the block store+--+-- The block store does not listen to the node by itself.+-- "Haskoin.Store.Manager" forwards node events to it with the+-- @blockStore*@ functions of this module.+-- = Why it works this way+--+-- * __Blocks come from one peer at a time.__ Blocks must be imported in order,+-- so downloading from several peers at once would only buffer blocks that cannot+-- be imported yet. One syncing peer, kept busy with a sliding window of+-- requests, uses the network well without unbounded buffering.+--+-- * __The window refills early.__ The next batch is requested when fewer than a+-- quarter of the window are still outstanding, so the peer never sits idle+-- while the store imports, and the backlog never grows much beyond one window.+--+-- * __Mempool sync waits for the tip.__ Unconfirmed transactions only make sense+-- against the current chain, so they are requested once the database is in+-- sync.+--+-- * __Orphans get a grace period.__ A transaction can arrive before its parent.+-- It is held for up to an hour, waiting for the parent, instead of being+-- dropped and requested again.+--+-- * __Imports are batched.__ Mempool transactions are imported up to 2000 at a+-- time on each clock tick, which amortises the cost of a database write over+-- many transactions during bursts.+module Haskoin.Store.BlockStore+ ( -- * Block Store+ BlockStore,+ BlockStoreConfig (..),+ withBlockStore,+ blockStorePeerConnect,+ blockStorePeerConnectSTM,+ blockStorePeerDisconnect,+ blockStorePeerDisconnectSTM,+ blockStoreHead,+ blockStoreHeadSTM,+ blockStoreBlock,+ blockStoreBlockSTM,+ blockStoreNotFound,+ blockStoreNotFoundSTM,+ blockStoreTx,+ blockStoreTxSTM,+ blockStoreTxHash,+ blockStoreTxHashSTM,+ blockStorePendingTxs,+ blockStorePendingTxsSTM,+ needsMoreBlocks,+ )+where++import Control.Monad+ ( forM,+ forM_,+ unless,+ void,+ when,+ (>=>),+ )+import Data.Bool (bool)+import Data.ByteString qualified as B+import Data.HashMap.Strict qualified as HashMap+import Data.HashSet (HashSet)+import Data.HashSet qualified as HashSet+import Data.Maybe+ ( catMaybes,+ isJust,+ )+import Data.Serialize (encode)+import Data.String (fromString)+import Data.String.Conversions (cs)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Time.Clock+ ( NominalDiffTime,+ UTCTime,+ getCurrentTime,+ )+import Data.Time.Clock.POSIX+ ( getPOSIXTime,+ posixSecondsToUTCTime,+ utcTimeToPOSIXSeconds,+ )+import Data.Time.Format+ ( defaultTimeLocale,+ formatTime,+ )+import Haskoin+ ( Block (..),+ BlockHash (..),+ BlockHeader (..),+ BlockHeight,+ BlockNode (..),+ Ctx,+ GetData (..),+ InvType (..),+ InvVector (..),+ Message (..),+ Network (..),+ OutPoint (..),+ Tx (..),+ TxHash (..),+ TxIn (..),+ blockHashToHex,+ headerHash,+ txHash,+ txHashToHex,+ )+import Haskoin.Node+ ( Chain,+ LogLevel (..),+ Logger,+ OnlinePeer (..),+ Owner,+ Peer (..),+ PeerMgr,+ chainBlockMain,+ chainGetAncestor,+ chainGetBest,+ chainGetBlock,+ chainGetParents,+ getPeers,+ killPeer,+ logAt,+ newOwner,+ releasePeer,+ reservePeer,+ sendMessage,+ )+import Haskoin.Store.BlockStore.Pending (Pending, PendingTx (..))+import Haskoin.Store.BlockStore.Pending qualified as Pending+import Haskoin.Store.BlockStore.Sync+ ( Accept (..),+ Sync (..),+ needsMoreBlocks,+ )+import Haskoin.Store.BlockStore.Sync qualified as Sync+import Haskoin.Store.Common (StoreEvent (..), loopForever)+import Haskoin.Store.Data+import Haskoin.Store.Database.Mempool (wipeMempoolArea)+import Haskoin.Store.Database.Query (getMempoolBefore)+import Haskoin.Store.Database.Query qualified as Q+import Haskoin.Store.Database.Query.Layered qualified as L+import Haskoin.Store.Database.Reader+import Haskoin.Store.Database.Writer (MempoolChanges (..), Writer, runWriterMempool)+import Haskoin.Store.Database.Writer qualified as W+import Haskoin.Store.Logic (ImportException (..))+import Haskoin.Store.Logic.Layered+ ( deleteUnconfirmedTx,+ importBlock,+ initBest,+ newMempoolTx,+ revertBlock,+ )+import NQE+ ( InChan,+ Listen,+ Mailbox,+ Publisher,+ inboxToMailbox,+ newInbox,+ publish,+ query,+ receive,+ send,+ sendSTM,+ )+import System.Metrics.StatsD+import System.Random (randomRIO)+import UnliftIO+ ( Exception,+ STM,+ TVar,+ atomically,+ catch,+ link,+ modifyTVar,+ modifyTVar',+ newTVarIO,+ readTVar,+ readTVarIO,+ stateTVar,+ throwIO,+ try,+ withAsync,+ )+import UnliftIO.Concurrent (threadDelay)++-- | Messages handled by the block store process.+data BlockStoreMessage+ = -- | the node has a new best header+ BlockNewBest !BlockNode+ | -- | a peer connected+ BlockPeerConnect !Peer+ | -- | a peer disconnected+ BlockPeerDisconnect !Peer+ | -- | a peer sent a block+ BlockReceived !Peer !Block+ | -- | a peer could not find blocks+ BlockNotFound !Peer ![BlockHash]+ | -- | a peer sent a transaction+ TxRefReceived !Peer !Tx+ | -- | a peer announced transactions+ TxRefAvailable !Peer ![TxHash]+ | -- | clock tick for housekeeping; reply when done+ BlockPing !(Listen ())++-- | Fatal errors of the block store.+data BlockException+ = -- | the header of a block in the database is not known to the node+ BlockNotInChain !BlockHash+ | -- | the database has no best block+ Uninitialized+ | -- | the database is inconsistent+ CorruptDatabase+ | -- | the node has no header at this height below this block+ AncestorNotInChain !BlockHeight !BlockHash+ | -- | importing mempool transactions failed+ MempoolImportFailed+ deriving (Show, Eq, Ord, Exception)++-- | Block store process state.+data BlockStore = BlockStore+ { -- | mailbox of the process+ mailbox :: !(Mailbox BlockStoreMessage),+ -- | configuration+ config :: !BlockStoreConfig,+ -- | state of the block download ("Haskoin.Store.BlockStore.Sync")+ sync :: !(TVar (Sync Peer)),+ -- | transactions received and waiting to be imported+ -- ("Haskoin.Store.BlockStore.Pending")+ pending :: !(TVar Pending),+ -- | StatsD metrics, if enabled+ metrics :: !(Maybe StoreMetrics),+ -- | labels of the peers whose mempool was already requested+ syncedMempool :: !(TVar (HashSet Text)),+ -- | who reserves the syncing peer, so that no one else releases it+ owner :: !Owner,+ -- | transactions that left the mempool unconfirmed since the store+ -- started, as many as 'mostForgotten'. The database keeps nothing of+ -- them, and peers go on announcing them: without this the store would+ -- fetch each one again, only to refuse it again.+ forgotten :: !(TVar (HashSet TxHash))+ }++-- | How many deleted transactions the store remembers not to fetch again.+-- Past that many it forgets them all and starts over, which costs a few+-- fetches that come to nothing.+mostForgotten :: Int+mostForgotten = 100000++-- | StatsD metrics of the block store.+data StoreMetrics = StoreMetrics+ { -- | height of the best block (@store.blocks@)+ blocks :: !StatGauge,+ -- | height of the best header (@store.headers@)+ headers :: !StatGauge,+ -- | transactions waiting to be imported (@store.queued_txs@)+ queuedTxs :: !StatGauge,+ -- | online peers (@store.peers@)+ peers :: !StatGauge,+ -- | transactions in the mempool (@store.mempool@)+ mempool :: !StatGauge,+ -- | milliseconds to import a block (@store.block@)+ block :: !StatTiming+ }++-- | Register the block store metrics, if StatsD is configured, with their current values.+newStoreMetrics :: BlockStoreConfig -> IO (Maybe StoreMetrics)+newStoreMetrics cfg =+ forM cfg.stats $ \s -> do+ m <- Q.getMempool cfg.db+ b <-+ Q.getBestBlock cfg.db >>= \case+ Nothing -> return 0+ Just h -> maybe 0 (.height) <$> Q.getBlock cfg.db h+ h <- chainGetBest cfg.chain+ p <- getPeers cfg.peerMgr+ blocks <- g s "blocks" (fromIntegral b)+ headers <- g s "headers" (fromIntegral h.height)+ queuedTxs <- g s "queued_txs" 0+ peers <- g s "peers" (length p)+ mempool <- g s "mempool" (length m)+ block <- t s "block" 0+ return StoreMetrics {..}+ where+ g s x = newStatGauge s ("store." <> x)+ t s x = newStatTiming s ("store." <> x)++-- | Update the gauge of the best block height.+setStoreHeight :: BlockStore -> IO ()+setStoreHeight self =+ forM_ self.metrics $ \m ->+ getBestBlock self >>= mapM_ (getBlock self >=> mapM_ (setGauge m.blocks . fromIntegral . (.height)))++-- | Update the gauge of the best header height.+setHeadersHeight :: BlockStore -> IO ()+setHeadersHeight self =+ forM_ self.metrics $ \m -> do+ n <- chainGetBest self.config.chain+ setGauge m.headers (fromIntegral n.height)++-- | Update the gauge of transactions waiting to be imported.+setPendingTxs :: BlockStore -> IO ()+setPendingTxs self =+ forM_ self.metrics $ \m -> do+ p <- readTVarIO self.pending+ setGauge m.queuedTxs (Pending.queued p)++-- | Update the gauge of online peers.+setPeersConnected :: BlockStore -> IO ()+setPeersConnected self =+ forM_ self.metrics $ \m -> do+ p <- getPeers self.config.peerMgr+ setGauge m.peers (length p)++-- | Record how long a successful block import took.+withBlockTiming :: BlockStore -> IO (Either l r) -> IO (Either l r)+withBlockTiming self go = do+ case self.metrics of+ Just m -> do+ start <- round . (* 1000) <$> getPOSIXTime+ go >>= \case+ Right x -> do+ end <- round . (* 1000) <$> getPOSIXTime+ addTiming m.block (end - start)+ return (Right x)+ Left y -> return (Left y)+ Nothing -> go++-- | Configuration for a block store.+data BlockStoreConfig = BlockStoreConfig+ { -- | secp256k1 context+ ctx :: !Ctx,+ -- | peer manager from running node+ peerMgr :: !PeerMgr,+ -- | chain from a running node+ chain :: !Chain,+ -- | listener for store events+ pub :: !(Publisher StoreEvent),+ -- | RocksDB database handle+ db :: !DatabaseReader,+ -- | network constants+ net :: !Network,+ -- | do not index new mempool transactions+ noMempool :: !Bool,+ -- | wipe mempool at start+ wipeMempool :: !Bool,+ -- | what to do with the best block right after it changes, and when the+ -- store starts, before anything else is written to the database: a+ -- member of a fleet takes a view of the database for the block here+ -- ('Haskoin.Store.Fleet.Member.noteBest')+ onBest :: !(BlockHash -> IO ()),+ -- | sync mempool from peers+ syncMempool :: !Bool,+ -- | blocks to request from the syncing peer in one batch+ blockWindow :: !Int,+ -- | delete transactions older than this number of days from mempool+ mempoolTimeout :: !Int,+ -- | disconnect syncing peer if inactive for this long+ peerTimeout :: !NominalDiffTime,+ -- | StatsD client, if metrics are enabled+ stats :: !(Maybe Stats),+ -- | log messages here+ logger :: !Logger+ }++-- | Log a message with the logger of the block store.+logDebug, logInfo, logWarn, logError :: BlockStore -> Text -> IO ()+logDebug self = logAt LevelDebug self.config.logger+logInfo self = logAt LevelInfo self.config.logger+logWarn self = logAt LevelWarn self.config.logger+logError self = logAt LevelError self.config.logger++-- | Run an import against the database, returning the import error if it fails.+runImport :: BlockStore -> Network -> Ctx -> (Writer -> IO a) -> IO (Either ImportException a)+runImport self net ctx f = fmap fst <$> runImportMempool self net ctx f++-- | Like 'runImport', but also return how the import changed the mempool.+-- The mempool gauge is kept up to date from those changes instead of+-- counting the whole mempool on every clock tick.+runImportMempool :: BlockStore -> Network -> Ctx -> (Writer -> IO a) -> IO (Either ImportException (a, MempoolChanges))+runImportMempool self net ctx f = do+ r <- try $ runWriterMempool net ctx self.config.db self.config.logger f+ forM_ r $ \(_, c) ->+ forM_+ self.metrics+ ( \m -> do+ incrementGauge m.mempool (length c.added)+ decrementGauge m.mempool (length c.removed)+ )+ return r++-- | Run an import and publish the transactions that it added to the+-- mempool or removed from it, then the new best block if there is one.+-- Transactions of the block are not published.+runImportNotify :: BlockStore -> Maybe Block -> (Writer -> IO a) -> IO (Either ImportException a)+runImportNotify self block f = do+ net <- getNetwork self+ ctx <- getCtx self+ runImportMempool self net ctx f >>= \case+ Left e -> return (Left e)+ Right (x, c) -> do+ let l = self.config.pub+ let deleted = filter notInBlock c.removed+ atomically . modifyTVar' self.forgotten $ \seen ->+ let more = foldr HashSet.insert seen deleted+ in if HashSet.size more > mostForgotten then HashSet.fromList deleted else more+ -- one that came back is not forgotten any more+ atomically (modifyTVar' self.forgotten (\seen -> foldr HashSet.delete seen c.added))+ forM_ deleted $ \h ->+ publish (StoreMempoolDelete h) l+ forM_ (filter notInBlock c.added) $ \h ->+ publish (StoreMempoolNew h) l+ forM_ block $ \b -> do+ -- before the event, so that whoever hears of the block finds what+ -- was done for it+ self.config.onBest (headerHash b.header)+ publish (StoreBestBlock (headerHash b.header)) l+ return (Right x)+ where+ inBlock = maybe HashSet.empty (HashSet.fromList . map txHash . (.txs)) block+ notInBlock = not . (`HashSet.member` inBlock)++-- | Run a database query from the block store.+-- | Best block in the database.+getBestBlock :: BlockStore -> IO (Maybe BlockHash)+getBestBlock self = Q.getBestBlock self.config.db++-- | Data of a block in the database.+getBlock :: BlockStore -> BlockHash -> IO (Maybe BlockData)+getBlock self = Q.getBlock self.config.db++-- | Data of a transaction in the database: confirmed, in the mempool, or+-- deleted from it and marked so.+getTxData :: BlockStore -> TxHash -> IO (Maybe TxData)+getTxData self h = L.withView self.config.db (`L.getTxData` h)++-- | Unspent output in the database, of the chain or of the mempool.+getUnspent :: BlockStore -> OutPoint -> IO (Maybe Unspent)+getUnspent self p = L.withView self.config.db (`L.getUnspent` p)++-- | Network of the block store.+getNetwork :: BlockStore -> IO Network+getNetwork self = return self.config.net++-- | Secp256k1 context of the block store.+getCtx :: BlockStore -> IO Ctx+getCtx self = return self.config.ctx++-- | Run block store process.+withBlockStore ::+ BlockStoreConfig ->+ (BlockStore -> IO a) ->+ IO a+withBlockStore cfg action = do+ pb <- newTVarIO Idle+ ts <- newTVarIO Pending.empty+ sy <- newTVarIO HashSet.empty+ inbox <- newInbox+ metrics <- newStoreMetrics cfg+ owner <- (newOwner "block store")+ forgotten <- newTVarIO HashSet.empty+ let r =+ BlockStore+ { mailbox = inboxToMailbox inbox,+ config = cfg,+ sync = pb,+ pending = ts,+ metrics = metrics,+ syncedMempool = sy,+ owner = owner,+ forgotten = forgotten+ }+ withAsync ((go r inbox)) $ \a -> do+ link a+ action r+ where+ go self inbox = do+ ini+ wipe+ run+ where+ -- the mempool is an area of its own, so wiping it is deleting the+ -- keys of that area: nothing of the chain has to be put right+ wipe = when cfg.wipeMempool $ do+ keys <- wipeMempoolArea cfg.db+ forM_ self.metrics $ \m -> setGauge m.mempool 0+ logAt LevelInfo cfg.logger $+ "Wiped the mempool: " <> cs (show keys) <> " keys deleted"+ ini = do+ net <- getNetwork self+ ctx <- getCtx self+ runImport self net ctx initBest >>= \case+ Left e -> do+ logError self $+ "Could not initialize: " <> cs (show e)+ throwIO e+ Right () -> getBestBlock self >>= mapM_ cfg.onBest+ run =+ withAsync (pingMe (inboxToMailbox inbox)) $ \a ->+ link a >> runBlockStoreLoop self inbox++-- | Handle messages forever.+runBlockStoreLoop :: (InChan mbox) => BlockStore -> mbox BlockStoreMessage -> IO b+runBlockStoreLoop self inbox =+ loopForever $ do+ logDebug self "Waiting for new event..."+ msg <- receive inbox+ processBlockStoreMessage self msg++-- | Whether the best block in the database is the best header of the node. If+-- so, release the syncing peer.+isInSync :: BlockStore -> IO Bool+isInSync self =+ getBestBlock self >>= \case+ Nothing -> do+ logError self "Block database uninitialized"+ throwIO Uninitialized+ Just bb -> do+ cb <- chainGetBest self.config.chain+ if headerHash cb.header == bb+ then clearSyncingState self >> return True+ else return False++-- | Run an action only if the database is in sync.+guardSync :: BlockStore -> IO () -> IO ()+guardSync self f = isInSync self >>= \s -> when s f++-- | Run an action only if the mempool is indexed.+guardMempool :: BlockStore -> IO () -> IO ()+guardMempool self f = unless self.config.noMempool f++-- | Ask a peer for its mempool, once per peer, if in sync and mempool sync is enabled.+syncMempool :: BlockStore -> Peer -> IO ()+syncMempool self p =+ guardMempool self . guardSync self $ do+ let s = self.config.syncMempool+ let m = self.syncedMempool+ y <- not . HashSet.member p.label <$> readTVarIO m+ when (s && y) $ do+ logDebug self $+ "Requesting mempool from peer: " <> p.label+ atomically . modifyTVar m $ HashSet.insert p.label+ MMempool `sendMessage` p++-- | Import a block from the syncing peer, then ask for more blocks or, if now in+-- sync, sync the mempool. Disconnects the peer if it is not the syncing peer, if+-- the block is unknown to the node, or if the block cannot be imported.+processBlock :: BlockStore -> Peer -> Block -> IO ()+processBlock self peer block =+ Sync.accept peer blockhash <$> readTVarIO self.sync >>= \case+ NotSyncingPeer -> refuse "Non-syncing peer"+ NotRequested -> refuse "Syncing peer"+ Requested ->+ getBlockNode self blockhash >>= \case+ Nothing -> do+ logError self $+ "Peer "+ <> peer.label+ <> " sent unknown block "+ <> blockHashToHex blockhash+ killPeer peer+ Just node -> do+ logDebug self $+ "Processing block "+ <> blockText node Nothing+ <> " from peer "+ <> peer.label+ ret <- withBlockTiming self $ runImportNotify self (Just block) (\wr -> importBlock wr block node)+ case ret of+ Left e -> failure e+ Right _ -> success node+ where+ header = block.header+ blockhash = headerHash header+ hexhash = blockHashToHex blockhash+ refuse who = do+ logError self $+ who+ <> " "+ <> peer.label+ <> " sent a block that was not requested: "+ <> hexhash+ killPeer peer+ success node = do+ logInfo self $+ "Best block: " <> blockText node (Just block)+ now <- getCurrentTime+ transition self $ \st -> ((), Sync.imported now blockhash st)+ isInSync self >>= \case+ False -> syncMe self+ True -> do+ updateOrphans self+ syncMempool self peer+ failure e = do+ logError self $+ "Error importing block "+ <> hexhash+ <> " from peer: "+ <> peer.label+ <> ": "+ <> cs (show e)+ killPeer peer++-- | Change the state of the block download with a transition of+-- "Haskoin.Store.BlockStore.Sync", atomically, and give its result.+transition :: BlockStore -> (Sync Peer -> (a, Sync Peer)) -> IO a+transition self = atomically . stateTVar self.sync++-- | Header of a block from the node.+getBlockNode :: BlockStore -> BlockHash -> IO (Maybe BlockNode)+getBlockNode self blockhash = do+ let ch = self.config.chain+ chainGetBlock ch blockhash++-- | Disconnect a peer that could not find requested blocks.+processNoBlocks :: BlockStore -> Peer -> [BlockHash] -> IO ()+processNoBlocks self p hs = do+ forM_ (zip [(1 :: Int) ..] hs) $ \(i, h) ->+ logError self $+ "Block "+ <> cs (show i)+ <> "/"+ <> cs (show (length hs))+ <> " "+ <> blockHashToHex h+ <> " not found by peer: "+ <> p.label+ killPeer p++-- | Queue a transaction received from a peer for import.+processTx :: BlockStore -> Peer -> Tx -> IO ()+processTx self p tx = guardMempool self $ do+ t <- getCurrentTime+ logDebug self $+ "Received tx "+ <> txHashToHex (txHash tx)+ <> " by peer: "+ <> p.label+ changePending self (Pending.received t tx HashSet.empty)++-- | Forget old requests, and drop transactions that waited for their parents+-- for too long.+pruneOrphans :: BlockStore -> IO ()+pruneOrphans self = guardMempool self $ do+ now <- getCurrentTime+ changePending self (Pending.prune now)++-- | Change the mempool transactions on their way into the database.+changePending :: BlockStore -> (Pending -> Pending) -> IO ()+changePending self f = do+ atomically $ modifyTVar' self.pending f+ setPendingTxs self++-- | Whether a transaction is queued or was requested recently.+isPending :: BlockStore -> TxHash -> IO Bool+isPending self th = Pending.isPending th <$> readTVarIO self.pending++-- | Take up to this many queued transactions that have no missing parents, in+-- dependency order, out of the queue.+pendingTxs :: BlockStore -> Int -> IO [PendingTx]+pendingTxs self i = do+ selected <- atomically $ stateTVar self.pending (Pending.takeReady i)+ setPendingTxs self+ return selected++-- | Drop queued transactions that are already in the database, and recompute the+-- missing parents of the others.+updateOrphans :: BlockStore -> IO ()+updateOrphans self = do+ orphans <- Pending.waiting <$> readTVarIO self.pending+ updates <- forM orphans $ \p -> do+ let h = txHash p.tx+ exists h >>= \case+ True -> return (h, Nothing)+ False -> do+ found <- catMaybes <$> mapM (getUnspent self . (.outpoint)) p.tx.inputs+ let have = HashSet.fromList (map (.outpoint.hash) found)+ return (h, Just (HashSet.difference p.deps have))+ changePending self (Pending.refresh (HashMap.fromList updates))+ where+ exists th =+ getTxData self th >>= \case+ Nothing -> return False+ Just TxData {deleted = True} -> return False+ Just TxData {deleted = False} -> return True++-- | Whether the store has a transaction, or deleted it not long ago: in+-- both cases there is nothing to fetch.+known :: BlockStore -> TxHash -> IO (Maybe ())+known self h =+ HashSet.member h <$> readTVarIO self.forgotten >>= \case+ True -> return (Just ())+ False -> (() <$) <$> getTxData self h++-- | Queue a transaction that spends unknown outputs, recording which parents are missing.+newOrphanTx ::+ BlockStore ->+ UTCTime ->+ Tx ->+ Writer ->+ IO ()+newOrphanTx self time tx wr = do+ logAt LevelDebug self.config.logger $+ "Import tx "+ <> txHashToHex (txHash tx)+ <> ": Orphan"+ unspents <- catMaybes <$> mapM (W.getUnspent wr) prevs+ let have = HashSet.fromList (map (.outpoint) unspents)+ missing = HashSet.map (.hash) (HashSet.difference (HashSet.fromList prevs) have)+ atomically . modifyTVar' self.pending $ Pending.received time tx missing+ where+ prevs = map (.outpoint) tx.inputs++-- | Import a transaction into the mempool. Returns 'True' if imported. A+-- transaction that spends unknown outputs is queued as an orphan; other errors+-- are ignored.+importMempoolTx :: BlockStore -> UTCTime -> Tx -> Writer -> IO Bool+importMempoolTx block_read time tx wr =+ catch new_mempool_tx handle_error+ where+ tx_hash = txHash tx+ handle_error :: ImportException -> IO Bool+ handle_error Orphan = do+ newOrphanTx block_read time tx wr+ return False+ handle_error _ = return False+ seconds = floor (utcTimeToPOSIXSeconds time)+ new_mempool_tx = do+ t <- newMempoolTx wr tx seconds+ logAt LevelInfo block_read.config.logger $+ bool "Already have" "Imported" t+ <> " tx "+ <> txHashToHex (txHash tx)+ when t . atomically . modifyTVar' block_read.pending $ Pending.imported tx_hash+ return t++-- | Import up to 2000 queued transactions.+processMempool :: BlockStore -> IO ()+processMempool self = guardMempool self $ do+ txs <- pendingTxs self 2000+ let block_read = self+ unless (null txs) $ import_txs block_read txs+ where+ run_import block_read wr p =+ importMempoolTx block_read p.time p.tx wr+ import_txs block_read txs =+ runImportNotify self Nothing (\wr -> mapM (run_import block_read wr) txs) >>= \case+ Left e -> report_error e+ Right _ -> return ()+ report_error e = do+ logError self $+ "Error processing mempool: " <> cs (show e)+ throwIO e++-- | Delete up to 1000 mempool transactions older than the configured number of days.+pruneMempool :: BlockStore -> IO ()+pruneMempool self =+ guardMempool self $ do+ let days = self.config.mempoolTimeout+ when (days > 0) $ do+ time <- (floor . utcTimeToPOSIXSeconds) <$> getCurrentTime+ let thresh = time - (fromIntegral days * 24 * 60 * 60)+ -- runs on every clock tick: read only the expired entries, oldest+ -- first, from the index by time+ let db = self.config.db+ txs <- map snd <$> (getMempoolBefore db thresh 1000)+ unless (null txs) $ do+ logInfo self $+ "Deleting " <> cs (show (length txs)) <> " old transactions from mempool"+ runImportNotify self Nothing (\wr -> mapM_ (deleteUnconfirmedTx wr False) txs) >>= \case+ Left e ->+ logError self $+ "Error pruning mempool: " <> cs (show e)+ Right () -> return ()++-- | Request from a peer the announced transactions that are neither queued,+-- recently requested nor in the database. Only while in sync.+processTxs :: BlockStore -> Peer -> [TxHash] -> IO ()+processTxs self p hs = guardMempool self . guardSync self $ do+ logDebug self $+ "Received inventory with "+ <> cs (show (length hs))+ <> " transactions from peer: "+ <> p.label+ xs <- catMaybes <$> zip_counter process_tx+ unless (null xs) $ go xs+ where+ len = length hs+ zip_counter = forM (zip [(1 :: Int) ..] hs) . uncurry+ process_tx i h =+ isPending self h >>= \case+ True -> do+ logDebug self $+ "Tx "+ <> cs (show i)+ <> "/"+ <> cs (show len)+ <> " "+ <> txHashToHex h+ <> ": "+ <> "Already Pending"+ return Nothing+ False ->+ known self h >>= \case+ Just _ -> do+ logDebug self $+ "Tx "+ <> cs (show i)+ <> "/"+ <> cs (show len)+ <> " "+ <> txHashToHex h+ <> ": "+ <> "Already Imported"+ return Nothing+ Nothing -> do+ logDebug self $+ "Tx "+ <> cs (show i)+ <> "/"+ <> cs (show len)+ <> " "+ <> txHashToHex h+ <> ": "+ <> "Requesting"+ return (Just h)+ go xs = do+ now <- getCurrentTime+ changePending self (Pending.requested now xs)+ let net = self.config.net+ let inv = if net.segWit then InvWitnessTx else InvTx+ vec = map (InvVector inv . (.get)) xs+ msg = MGetData (GetData vec)+ msg `sendMessage` p++-- | Disconnect the syncing peer if it has been silent for too long.+checkTime :: BlockStore -> IO ()+checkTime self = do+ now <- getCurrentTime+ Sync.timedOut self.config.peerTimeout now <$> readTVarIO self.sync >>= \case+ Nothing -> return ()+ Just p -> do+ logError self $ "Syncing peer timeout: " <> p.label+ killPeer p++-- | Revert blocks until the best block in the database is in the main chain of headers.+revertToMainChain :: BlockStore -> IO ()+revertToMainChain self = do+ h <- headerHash . (.header) <$> getBest self+ let ch = self.config.chain+ net <- getNetwork self+ ctx <- getCtx self+ (chainBlockMain ch h) >>= \x -> unless x $ do+ logWarn self $+ "Reverting best block: "+ <> blockHashToHex h+ runImport self net ctx (`revertBlock` h) >>= \case+ Left e -> do+ logError self $+ "Could not revert block "+ <> blockHashToHex h+ <> ": "+ <> cs (show e)+ throwIO e+ Right () -> do+ getBestBlock self >>= mapM_ self.config.onBest+ transition self $ \st -> ((), Sync.forgetRequests st)+ revertToMainChain self++-- | Header of the best block in the database.+getBest :: BlockStore -> IO BlockNode+getBest self = do+ bb <-+ getBestBlock self >>= \case+ Just b -> return b+ Nothing -> do+ logError self "No best block set"+ throwIO Uninitialized+ let ch = self.config.chain+ (chainGetBlock ch bb) >>= \case+ Just x -> return x+ Nothing -> do+ logError self $+ "Header not found for best block: "+ <> blockHashToHex bb+ throwIO (BlockNotInChain bb)++-- | Header of the last block requested from the syncing peer, or of the best block if none.+getSyncBest :: BlockStore -> IO BlockNode+getSyncBest self = do+ bb <-+ Sync.lastRequested <$> readTVarIO self.sync >>= \case+ Just h -> return h+ Nothing ->+ getBestBlock self >>= \case+ Just b -> return b+ Nothing -> do+ logError self "No best block set"+ throwIO Uninitialized+ chainGetBlock self.config.chain bb >>= \case+ Just x -> return x+ Nothing -> do+ logError self $+ "Header not found for block: "+ <> blockHashToHex bb+ throwIO (BlockNotInChain bb)++-- | The syncing peer, when it is time to ask it for the next batch of blocks.+shouldSync :: BlockStore -> IO (Maybe Peer)+shouldSync self =+ isInSync self >>= \case+ True -> return Nothing+ False -> Sync.wantsMore (blockWindow self) <$> readTVarIO self.sync++-- | Blocks to request from the syncing peer in one batch. Never below one,+-- or the store would never ask for any block.+blockWindow :: BlockStore -> Int+blockWindow self = max 1 self.config.blockWindow++-- | Ask the syncing peer for the next batch of blocks, if the window has room.+syncMe :: BlockStore -> IO ()+syncMe self = do+ revertToMainChain self+ shouldSync self >>= \case+ Nothing -> return ()+ Just p -> do+ bb <- getSyncBest self+ bh <- chainGetBest self.config.chain+ when (bb /= bh) $ do+ bns <- sel bb bh+ logDebug self $+ "Requesting "+ <> fromString (show (length bns))+ <> " blocks from peer: "+ <> p.label+ transition self $ \st -> ((), Sync.request (map (headerHash . (.header)) bns) st)+ MGetData (GetData (map inv bns)) `sendMessage` p+ where+ inv = InvVector kind . (.get) . headerHash . (.header)+ kind = if self.config.net.segWit then InvWitnessBlock else InvBlock+ -- The blocks right after the last requested one, up to one window or+ -- the chain tip, whichever comes first.+ sel bb bh = do+ top <- findAncestor self (min (bb.height + fromIntegral (blockWindow self)) bh.height) bh+ ps <- chainGetParents self.config.chain (bb.height + 1) top+ return $ ps <> [top]++-- | Ancestor of a header at a height. Throws 'AncestorNotInChain' if missing.+findAncestor :: BlockStore -> BlockHeight -> BlockNode -> IO BlockNode+findAncestor self height target = do+ let ch = self.config.chain+ (chainGetAncestor ch height target) >>= \case+ Just ancestor -> return ancestor+ Nothing -> do+ let h = headerHash target.header+ logError self $+ "Could not find header for ancestor of block "+ <> blockHashToHex h+ <> " at height "+ <> cs (show target.height)+ throwIO $ AncestorNotInChain height h++-- | Forget a peer: release it if it was the syncing peer, and forget that its mempool was requested.+finishPeer :: BlockStore -> Peer -> IO ()+finishPeer self p = do+ released <- transition self (Sync.release p)+ when released $ do+ logDebug self $ "Releasing peer: " <> p.label+ void (releasePeer self.owner p)+ atomically $ modifyTVar self.syncedMempool $ HashSet.delete p.label++-- | Make a peer the syncing peer if there is none and the peer is not busy.+trySetPeer :: BlockStore -> Peer -> IO Bool+trySetPeer self p =+ isJust . Sync.syncingPeer <$> readTVarIO self.sync >>= \case+ True -> return False+ False ->+ reservePeer self.owner p >>= \case+ False -> return False+ True -> do+ now <- getCurrentTime+ started <- transition self $ \st -> case Sync.start now p st of+ Just st' -> (True, st')+ Nothing -> (False, st)+ if started+ then logDebug self ("Locked peer: " <> p.label)+ else void (releasePeer self.owner p)+ return started++-- | If not in sync and there is no syncing peer, pick the first free peer and start syncing.+trySyncing :: BlockStore -> IO ()+trySyncing self =+ isInSync self >>= \case+ True -> return ()+ False ->+ isJust . Sync.syncingPeer <$> readTVarIO self.sync >>= \case+ True -> return ()+ False -> getPeers self.config.peerMgr >>= recurse . map (.mailbox)+ where+ recurse [] = return ()+ recurse (p : ps) =+ trySetPeer self p >>= \case+ False -> recurse ps+ True -> syncMe self++-- | Start syncing against a newly connected peer if needed, or request its mempool if in sync.+trySyncingPeer :: BlockStore -> Peer -> IO ()+trySyncingPeer self p =+ isInSync self >>= \case+ True -> syncMempool self p+ False ->+ trySetPeer self p >>= \case+ False -> return ()+ True -> syncMe self++-- | Release the syncing peer, if any.+clearSyncingState :: BlockStore -> IO ()+clearSyncingState self =+ Sync.syncingPeer <$> readTVarIO self.sync >>= mapM_ (finishPeer self)++-- | Handle a message sent to the block store.+processBlockStoreMessage :: BlockStore -> BlockStoreMessage -> IO ()+processBlockStoreMessage self (BlockNewBest node) = do+ logDebug self $+ "New best block mined at height "+ <> cs (show node.height)+ <> ": "+ <> blockHashToHex (headerHash node.header)+ trySyncing self+processBlockStoreMessage self (BlockPeerConnect p) = do+ logDebug self $+ "New peer connected: " <> p.label+ trySyncingPeer self p+processBlockStoreMessage self (BlockPeerDisconnect p) = do+ logDebug self $+ "Peer disconnected: " <> p.label+ finishPeer self p+processBlockStoreMessage self (BlockReceived p b) = do+ logDebug self $+ "Received block: "+ <> blockHashToHex (headerHash b.header)+ processBlock self p b+processBlockStoreMessage self (BlockNotFound p bs) = do+ logDebug self $+ "Blocks not found by peer "+ <> p.label+ <> ": "+ <> T.unwords (map blockHashToHex bs)+ processNoBlocks self p bs+processBlockStoreMessage self (TxRefReceived p tx) = do+ logDebug self $+ "Transaction received from peer "+ <> p.label+ <> ": "+ <> txHashToHex (txHash tx)+ processTx self p tx+processBlockStoreMessage self (TxRefAvailable p ts) = do+ logDebug self $+ "Transactions available from peer "+ <> p.label+ <> ": "+ <> T.unwords (map txHashToHex ts)+ processTxs self p ts+processBlockStoreMessage self (BlockPing r) = do+ logDebug self "Internal clock event"+ setStoreHeight self+ setHeadersHeight self+ setPendingTxs self+ setPeersConnected self+ trySyncing self+ processMempool self+ pruneOrphans self+ pruneMempool self+ checkTime self+ atomically (r ())++-- | Send a clock tick to the block store every 0.1 to 1 second, waiting for each to be handled.+pingMe :: Mailbox BlockStoreMessage -> IO ()+pingMe mbox =+ loopForever $ do+ BlockPing `query` mbox+ delay <- randomRIO (100 * 1000, 1000 * 1000)+ threadDelay delay++-- | Tell the block store that a peer connected.+blockStorePeerConnect :: Peer -> BlockStore -> IO ()+blockStorePeerConnect peer store =+ BlockPeerConnect peer `send` store.mailbox++-- | Tell the block store that a peer disconnected.+blockStorePeerDisconnect ::+ Peer -> BlockStore -> IO ()+blockStorePeerDisconnect peer store =+ BlockPeerDisconnect peer `send` store.mailbox++-- | Tell the block store that the node has a new best header.+blockStoreHead ::+ BlockNode -> BlockStore -> IO ()+blockStoreHead node store =+ BlockNewBest node `send` store.mailbox++-- | Give the block store a block received from a peer.+blockStoreBlock ::+ Peer -> Block -> BlockStore -> IO ()+blockStoreBlock peer block store =+ BlockReceived peer block `send` store.mailbox++-- | Tell the block store that a peer could not find blocks.+blockStoreNotFound ::+ Peer -> [BlockHash] -> BlockStore -> IO ()+blockStoreNotFound peer blocks store =+ BlockNotFound peer blocks `send` store.mailbox++-- | Give the block store a transaction received from a peer.+blockStoreTx ::+ Peer -> Tx -> BlockStore -> IO ()+blockStoreTx peer tx store =+ TxRefReceived peer tx `send` store.mailbox++-- | Tell the block store that a peer announced transactions.+blockStoreTxHash ::+ Peer -> [TxHash] -> BlockStore -> IO ()+blockStoreTxHash peer txhashes store =+ TxRefAvailable peer txhashes `send` store.mailbox++-- | 'STM' version of 'blockStorePeerConnect'.+blockStorePeerConnectSTM ::+ Peer -> BlockStore -> STM ()+blockStorePeerConnectSTM peer store =+ BlockPeerConnect peer `sendSTM` store.mailbox++-- | 'STM' version of 'blockStorePeerDisconnect'.+blockStorePeerDisconnectSTM ::+ Peer -> BlockStore -> STM ()+blockStorePeerDisconnectSTM peer store =+ BlockPeerDisconnect peer `sendSTM` store.mailbox++-- | 'STM' version of 'blockStoreHead'.+blockStoreHeadSTM ::+ BlockNode -> BlockStore -> STM ()+blockStoreHeadSTM node store =+ BlockNewBest node `sendSTM` store.mailbox++-- | 'STM' version of 'blockStoreBlock'.+blockStoreBlockSTM ::+ Peer -> Block -> BlockStore -> STM ()+blockStoreBlockSTM peer block store =+ BlockReceived peer block `sendSTM` store.mailbox++-- | 'STM' version of 'blockStoreNotFound'.+blockStoreNotFoundSTM ::+ Peer -> [BlockHash] -> BlockStore -> STM ()+blockStoreNotFoundSTM peer blocks store =+ BlockNotFound peer blocks `sendSTM` store.mailbox++-- | 'STM' version of 'blockStoreTx'.+blockStoreTxSTM ::+ Peer -> Tx -> BlockStore -> STM ()+blockStoreTxSTM peer tx store =+ TxRefReceived peer tx `sendSTM` store.mailbox++-- | 'STM' version of 'blockStoreTxHash'.+blockStoreTxHashSTM ::+ Peer -> [TxHash] -> BlockStore -> STM ()+blockStoreTxHashSTM peer txhashes store =+ TxRefAvailable peer txhashes `sendSTM` store.mailbox++-- | Number of transactions queued or requested by the block store.+blockStorePendingTxs ::+ BlockStore -> IO Int+blockStorePendingTxs =+ atomically . blockStorePendingTxsSTM++-- | 'STM' version of 'blockStorePendingTxs'.+blockStorePendingTxsSTM ::+ BlockStore -> STM Int+blockStorePendingTxsSTM store = Pending.size <$> readTVar store.pending -- | Describe a block for logs: height, time, hash and, if the block is given, size. blockText :: BlockNode -> Maybe Block -> Text
@@ -0,0 +1,166 @@+-- |+-- Module : Haskoin.Store.BlockStore.Pending+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- Mempool transactions on their way into the database, as a pure value with+-- transitions.+--+-- A transaction that a peer announces goes through these states:+--+-- @+-- announced ──► requested ──► received ──► ready ──► taken for import+-- │ │ (missing parents arrive)+-- ▼ ▼+-- forgotten dropped+-- (after 20 s) (after an hour)+-- @+--+-- It is requested once, and not again while the request is fresh. Once+-- received, it waits until no parent is missing, and is then taken for+-- import together with other ready transactions, parents first.+--+-- = Why a pure value+--+-- The block store kept the queue and the recent requests in two variables,+-- changed by several functions, and forgot each request in a thread of its+-- own that slept for twenty seconds: one thread per transaction requested.+-- Here every change is a function, the clock is an argument, and requests+-- are forgotten by 'prune' on the clock tick of the block store, like the+-- transactions that waited too long.+module Haskoin.Store.BlockStore.Pending+ ( -- * State+ Pending,+ PendingTx (..),+ empty,+ isPending,+ queued,+ size,+ waiting,++ -- * Transitions+ requested,+ received,+ takeReady,+ imported,+ refresh,+ prune,++ -- * Timeouts+ requestTimeout,+ orphanTimeout,+ )+where++import Data.HashMap.Strict (HashMap)+import Data.HashMap.Strict qualified as HashMap+import Data.HashSet (HashSet)+import Data.HashSet qualified as HashSet+import Data.Maybe (mapMaybe)+import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime)+import Haskoin (Tx, TxHash, txHash)+import Haskoin.Store.Common (sortTxs)++-- | Transaction received from a peer and waiting to be imported.+data PendingTx = PendingTx+ { -- | when it was received+ time :: !UTCTime,+ -- | the transaction+ tx :: !Tx,+ -- | parent transactions that are missing; empty if it can be imported+ deps :: !(HashSet TxHash)+ }+ deriving stock (Show, Eq, Ord)++-- | Transactions requested from peers and received from them.+data Pending = Pending+ { -- | received, by hash+ txs :: !(HashMap TxHash PendingTx),+ -- | requested and not received yet, with when+ asked :: !(HashMap TxHash UTCTime)+ }+ deriving stock (Show, Eq)++-- | How long a request keeps the transaction from being requested again.+requestTimeout :: NominalDiffTime+requestTimeout = 20++-- | How long a transaction waits for missing parents.+orphanTimeout :: NominalDiffTime+orphanTimeout = 3600++-- | Nothing requested or received.+empty :: Pending+empty = Pending HashMap.empty HashMap.empty++-- | Whether a transaction is received or requested, so that it is not+-- requested again.+isPending :: TxHash -> Pending -> Bool+isPending h p = HashMap.member h p.txs || HashMap.member h p.asked++-- | How many transactions are received and not taken for import yet.+queued :: Pending -> Int+queued p = HashMap.size p.txs++-- | How many transactions are received or requested.+size :: Pending -> Int+size p = HashMap.size (HashMap.union (() <$ p.txs) (() <$ p.asked))++-- | Received transactions that have missing parents.+waiting :: Pending -> [PendingTx]+waiting p = filter (not . null . (.deps)) (HashMap.elems p.txs)++-- | Transactions were requested.+requested :: UTCTime -> [TxHash] -> Pending -> Pending+requested now hs p = p {asked = HashMap.union (HashMap.fromList [(h, now) | h <- hs]) p.asked}++-- | A transaction was received, with the parents that it spends and the+-- database does not have. It is no longer a request.+received :: UTCTime -> Tx -> HashSet TxHash -> Pending -> Pending+received now tx deps p =+ Pending+ { txs = HashMap.insert h (PendingTx now tx deps) p.txs,+ asked = HashMap.delete h p.asked+ }+ where+ h = txHash tx++-- | Take up to this many transactions with no missing parents, parents+-- before children.+takeReady :: Int -> Pending -> ([PendingTx], Pending)+takeReady n p = (taken, p {txs = foldr (HashMap.delete . txHash . (.tx)) p.txs taken})+ where+ ready = HashMap.filter (null . (.deps)) p.txs+ taken =+ take n $+ mapMaybe ((`HashMap.lookup` ready) . txHash . snd) (sortTxs (map (.tx) (HashMap.elems ready)))++-- | A transaction is in the database: transactions waiting for it no longer+-- miss it.+imported :: TxHash -> Pending -> Pending+imported h p = p {txs = HashMap.map (\t -> t {deps = HashSet.delete h t.deps}) p.txs}++-- | After a block, give each waiting transaction what the database now+-- says about it: 'Nothing' if it is in the database already, so it is+-- dropped, or the parents that it still misses.+refresh :: HashMap TxHash (Maybe (HashSet TxHash)) -> Pending -> Pending+refresh updates p = p {txs = HashMap.mapMaybeWithKey update p.txs}+ where+ update h t = case HashMap.lookup h updates of+ Nothing -> Just t+ Just Nothing -> Nothing+ Just (Just deps) -> Just t {deps = deps}++-- | Forget requests older than 'requestTimeout', and drop transactions+-- received longer than 'orphanTimeout' ago.+prune :: UTCTime -> Pending -> Pending+prune now p =+ Pending+ { txs = HashMap.filter (\t -> age t.time <= orphanTimeout) p.txs,+ asked = HashMap.filter (\t -> age t <= requestTimeout) p.asked+ }+ where+ age t = now `diffUTCTime` t
@@ -0,0 +1,178 @@+-- |+-- Module : Haskoin.Store.BlockStore.Sync+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- The state of the block download, as a pure state machine.+--+-- The block store downloads blocks from one peer at a time. It is either+-- 'Idle', with no syncing peer, or 'Syncing' from one peer, with the blocks+-- requested from it and not yet imported, in chain order. Every change of+-- that state is one of the transitions of this module, and the block store+-- only carries out what they decide: lock or release a peer, send a request,+-- disconnect a peer.+--+-- = Why a state machine+--+-- The block store used to keep this state in a @TVar (Maybe Syncing)@ that a+-- dozen functions changed in place, each with its own check of who the peer+-- was and what had been asked. Bugs in such code, like a peer left locked or+-- a block accepted that nobody asked for, show up as a sync that stops for no+-- visible reason. Pure transitions can be tested on their own, with any+-- sequence of events, and the rules are in one place:+--+-- * There is at most one syncing peer, and only 'start' sets one, from 'Idle'.+-- * Only blocks requested from the syncing peer are accepted ('accept'), so a+-- block from another peer, or one nobody asked for, is a reason to+-- disconnect the peer that sent it.+-- * A block is requested at most once while it is outstanding ('request').+-- * The peer times out when it has delivered nothing for a while+-- ('timedOut'); delivering a block or starting resets the clock.+--+-- The peer is a type parameter so that the tests can use plain numbers.+module Haskoin.Store.BlockStore.Sync+ ( -- * State+ Sync (..),+ Download (..),+ syncingPeer,+ outstanding,+ lastRequested,++ -- * Transitions+ start,+ release,+ Accept (..),+ accept,+ imported,+ request,+ forgetRequests,++ -- * Decisions+ wantsMore,+ needsMoreBlocks,+ timedOut,+ )+where++import Data.List (delete)+import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime)+import Haskoin (BlockHash)++-- | State of the block download from peers of type @p@.+data Sync p+ = -- | no syncing peer+ Idle+ | -- | downloading from a peer+ Syncing !(Download p)+ deriving stock (Eq, Show)++-- | A download from the syncing peer.+data Download p = Download+ { -- | the peer, locked for the block store+ peer :: !p,+ -- | when it last delivered a block, or when syncing started+ time :: !UTCTime,+ -- | blocks requested and not yet imported, in chain order+ blocks :: ![BlockHash]+ }+ deriving stock (Eq, Show)++-- | The syncing peer, if any.+syncingPeer :: Sync p -> Maybe p+syncingPeer Idle = Nothing+syncingPeer (Syncing d) = Just d.peer++-- | Blocks requested from the syncing peer and not yet imported.+outstanding :: Sync p -> [BlockHash]+outstanding Idle = []+outstanding (Syncing d) = d.blocks++-- | The last block requested, if any is outstanding: the next request+-- starts after it.+lastRequested :: Sync p -> Maybe BlockHash+lastRequested s = case outstanding s of+ [] -> Nothing+ bs -> Just (last bs)++-- | Start syncing from a peer, if there is no syncing peer yet. The caller+-- locks the peer first, and frees it again if this gives 'Nothing'.+start :: UTCTime -> p -> Sync p -> Maybe (Sync p)+start now p Idle = Just (Syncing (Download p now []))+start _ _ (Syncing _) = Nothing++-- | Stop syncing from a peer, when it disconnects or the store is in sync.+-- Gives the new state, and whether the peer was the syncing peer, in which+-- case the caller frees it. Another peer leaves the state as it is.+release :: (Eq p) => p -> Sync p -> (Bool, Sync p)+release p (Syncing d)+ | d.peer == p = (True, Idle)+release _ s = (False, s)++-- | What to do with a block that a peer sent.+data Accept+ = -- | it was requested from this peer: import it+ Requested+ | -- | the peer is not the syncing peer+ NotSyncingPeer+ | -- | the syncing peer sent a block that is not outstanding+ NotRequested+ deriving stock (Eq, Show)++-- | Whether to import a block that a peer sent. Anything but 'Requested'+-- means the peer misbehaves, or that the requests were forgotten after a+-- reorganisation and the block is from the old chain.+accept :: (Eq p) => p -> BlockHash -> Sync p -> Accept+accept p h = \case+ Syncing d+ | d.peer /= p -> NotSyncingPeer+ | h `elem` d.blocks -> Requested+ | otherwise -> NotRequested+ Idle -> NotSyncingPeer++-- | A requested block was imported: it is no longer outstanding, and the+-- peer is not idle.+imported :: UTCTime -> BlockHash -> Sync p -> Sync p+imported _ _ Idle = Idle+imported now h (Syncing d) = Syncing d {time = now, blocks = delete h d.blocks}++-- | Blocks were requested from the syncing peer. Blocks already outstanding+-- are not added twice. Nothing changes when idle, as there is nobody to ask.+request :: [BlockHash] -> Sync p -> Sync p+request _ Idle = Idle+request hs (Syncing d) = Syncing d {blocks = foldl' add d.blocks hs}+ where+ add bs h = if h `elem` bs then bs else bs <> [h]++-- | Forget the outstanding requests, after blocks were reverted: they+-- continued a chain that is no longer the best. Blocks of them that still+-- arrive are 'NotRequested'.+forgetRequests :: Sync p -> Sync p+forgetRequests Idle = Idle+forgetRequests (Syncing d) = Syncing d {blocks = []}++-- | The syncing peer, if it is time to ask it for more blocks, given the+-- size of the window.+wantsMore :: Int -> Sync p -> Maybe p+wantsMore _ Idle = Nothing+wantsMore window (Syncing d)+ | needsMoreBlocks window (length d.blocks) = Just d.peer+ | otherwise = Nothing++-- | Whether to ask for the next batch, given the window size and how many+-- requested blocks are still waiting to be imported. Asking once fewer than+-- a quarter of the window remain keeps the peer busy, while the queue of+-- downloaded blocks never grows much beyond one window. The threshold is at+-- least one, so that a window smaller than four still refills once empty.+needsMoreBlocks :: Int -> Int -> Bool+needsMoreBlocks window n = n < max 1 (window `div` 4)++-- | The syncing peer, if it has delivered nothing for longer than the+-- timeout.+timedOut :: NominalDiffTime -> UTCTime -> Sync p -> Maybe p+timedOut _ _ Idle = Nothing+timedOut timeout now (Syncing d)+ | now `diffUTCTime` d.time > timeout = Just d.peer+ | otherwise = Nothing
@@ -1,1761 +1,1534 @@-{-# LANGUAGE ApplicativeDo #-}-{-# LANGUAGE BlockArguments #-}-{-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE ImportQualifiedPost #-}-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE MultiWayIf #-}-{-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RecordWildCards #-}-{-# LANGUAGE TupleSections #-}-{-# LANGUAGE NoFieldSelectors #-}---- |--- Module : Haskoin.Store.Cache--- Copyright : (c) Haskoin Developers--- License : MIT--- Maintainer : jprupp@protonmail.ch--- Stability : experimental--- Portability : POSIX------ Redis cache for extended public keys (xpubs).------ Answering a query about an xpub from the database means deriving its--- addresses and reading the index of every one of them, which is slow for--- wallets with many used addresses. When such an xpub is queried, its balances,--- transactions and unspent outputs are copied to Redis, and later queries are--- answered from there. Only xpubs with at least 'minAddrs' used addresses are--- cached, and only while the store is in sync.------ A cache writer process ('cacheWriter') keeps cached xpubs up to date as new--- blocks and mempool transactions arrive. When Redis holds more than 'maxKeys'--- keys, the xpubs used least recently are evicted.------ = Redis keys------ * @b\<xpub\>@: hash of derivation path to balance of each address.--- * @t\<xpub\>@: sorted set of transactions, scored by 'blockRefScore'.--- * @u\<xpub\>@: sorted set of unspent outputs, scored by 'blockRefScore'.--- * @a\<address\>@: xpub and derivation path of a cached address.--- * @max@: sorted set of cached xpubs, scored by time of last use.--- * @mempool@: sorted set of mempool transactions.--- * @head@: last block that the cache has processed.--- * @xlock-\<xpub\>@, @lock@, @i\<xpub\>@: locks.------ Here @\<xpub\>@ and @\<address\>@ stand for their binary serialization.--- = Why a cache, and why Redis------ Most xpubs are small and fast to answer from RocksDB. A few, such as the--- wallets of exchanges and payment processors, have thousands of used addresses--- and are queried constantly. Answering them from the database means deriving and--- reading every address each time. Keeping their balances, transactions and--- unspent outputs ready in sorted sets turns those queries into a single range--- read.------ Redis fits because sorted sets are exactly the shape of these queries, and--- because several store instances can share one cache. Only large xpubs are--- cached ('minAddrs'), because small ones gain nothing, and xpubs not used for a--- while are evicted, so the cache holds the working set rather than everything--- ever queried.------ Locks keep two store instances, or two requests, from building the cache of--- the same xpub at the same time. A request that cannot get a lock in time is--- answered from the database rather than made to wait.-module Haskoin.Store.Cache- ( CacheConfig (..),- CacheMetrics,- CacheT,- CacheError (..),- newCacheMetrics,- withCache,- connectRedis,- blockRefScore,- scoreBlockRef,- CacheWriter,- CacheWriterInbox,- cacheNewBlock,- cacheNewTx,- cacheSyncMempool,- cacheWriter,- cacheDelXPubs,- isInCache,- )-where--import Control.DeepSeq (NFData)-import Control.Monad (forM, forM_, unless, void, when)-import Control.Monad.Reader (ReaderT (..), asks)-import Control.Monad.Trans (lift)-import Data.Bits (shift, (.&.), (.|.))-import Data.ByteString (ByteString)-import Data.Default (def)-import Data.Either (fromRight, isRight, rights)-import Data.Functor ((<&>))-import Data.HashMap.Strict (HashMap)-import Data.HashMap.Strict qualified as HashMap-import Data.HashSet qualified as HashSet-import Data.IntMap.Strict qualified as I-import Data.List (sort)-import Data.List.NonEmpty qualified as NonEmpty-import Data.Map.Strict qualified as Map-import Data.Maybe- ( catMaybes,- mapMaybe,- )-import Data.Serialize (Serialize, decode, encode)-import Data.String.Conversions (cs)-import Data.Text (Text)-import Data.Time.Clock.POSIX (getPOSIXTime)-import Data.Word (Word32, Word64)-import Database.Redis- ( Connection,- Redis,- RedisCtx,- Reply,- checkedConnect,- defaultConnectInfo,- hgetall,- parseConnectInfo,- zadd,- zrangeWithscores,- zrangebyscoreWithscoresLimit,- zrem,- )-import Database.Redis qualified as Redis-import GHC.Generics (Generic)-import Haskoin- ( Address,- BlockHash,- BlockNode (..),- Ctx,- DerivPathI (..),- KeyIndex,- Network,- OutPoint (..),- Tx (..),- TxHash,- TxIn (..),- TxOut (..),- XPubKey,- blockHashToHex,- derivePubPath,- eitherToMaybe,- headerHash,- pathToList,- scriptToAddressBS,- txHash,- txHashToHex,- xPubAddr,- xPubCompatWitnessAddr,- xPubExport,- xPubWitnessAddr,- )-import Haskoin.Node- ( Chain,- LogLevel (..),- Logger,- chainGetBest,- chainGetBlock,- chainGetParents,- chainGetSplitBlock,- logAt,- )-import Haskoin.Store.Common-import Haskoin.Store.Data-import Haskoin.Store.Database.Reader (DatabaseReader, withDB)-import NQE- ( Inbox,- Listen,- Mailbox,- mailboxEmptySTM,- query,- receive,- receiveSTM,- requeueSTM,- send,- )-import System.Metrics.StatsD-import UnliftIO- ( Exception,- MonadIO,- MonadUnliftIO,- atomically,- bracket,- liftIO,- throwIO,- withAsync,- )-import UnliftIO.Concurrent (threadDelay)---- | Run a Redis command. Throws 'RedisError' if Redis returns an error.-runRedis :: (MonadIO m) => Redis (Either Reply a) -> CacheX m a-runRedis action = do- conn <- asks (.redis)- logger <- asks (.logger)- withRedis logger conn action---- | Run a Redis command on a connection. Logs and throws 'RedisError' if Redis returns an error.-withRedis ::- (MonadIO m) => Logger -> Connection -> Redis (Either Reply a) -> m a-withRedis logger conn action =- liftIO (Redis.runRedis conn action) >>= \case- Right x -> return x- Left e -> do- logAt LevelError logger $ "Got error from Redis: " <> cs (show e)- throwIO (RedisError e)---- | Cache configuration.-data CacheConfig = CacheConfig- { -- | connection to Redis- redis :: !Connection,- -- | cache xpubs with at least this many used addresses- minAddrs :: !Int,- -- | evict xpubs when Redis holds more keys than this- maxKeys :: !Integer,- -- | header chain, used to know whether the store is in sync- chain :: !Chain,- -- | StatsD metrics, if enabled- metrics :: !(Maybe CacheMetrics),- -- | log messages here- logger :: !Logger- }---- | StatsD metrics of the cache.-data CacheMetrics = CacheMetrics- { -- | xpub balance queries answered from the cache (@cache.hits@)- hits :: !StatCounter,- -- | xpub balance queries not in the cache (@cache.misses@)- misses :: !StatCounter,- -- | locks acquired (@cache.lock_acquired@)- lockAcquired :: !StatCounter,- -- | locks released (@cache.lock_released@)- lockReleased :: !StatCounter,- -- | locks that could not be acquired (@cache.lock_failed@)- lockFailed :: !StatCounter,- -- | xpub balances served (@cache.xpub_balances@)- xBalances :: !StatCounter,- -- | xpub unspent outputs served (@cache.xpub_unspents@)- xUnspents :: !StatCounter,- -- | xpub transactions served (@cache.xpub_txs@)- xTxs :: !StatCounter,- -- | xpub transaction counts served (@cache.xpub_tx_count@)- xTxCount :: !StatCounter,- -- | milliseconds to cache an xpub (@cache.index@)- indexTime :: !StatTiming,- -- | height of the last block processed by the cache (@cache.height@)- height :: !StatGauge- }---- | Register the cache metrics with a StatsD client.-newCacheMetrics ::- (MonadIO m) =>- Logger ->- Stats ->- Connection ->- DatabaseReader ->- m CacheMetrics-newCacheMetrics logger s r db = do- h <- redisBest- hits <- c "cache.hits"- misses <- c "cache.misses"- lockAcquired <- c "cache.lock_acquired"- lockReleased <- c "cache.lock_released"- lockFailed <- c "cache.lock_failed"- indexTime <- d "cache.index"- xBalances <- c "cache.xpub_balances"- xUnspents <- c "cache.xpub_unspents"- xTxs <- c "cache.xpub_txs"- xTxCount <- c "cache.xpub_tx_count"- height <- newStatGauge s "cache.height" h- return CacheMetrics {..}- where- redisBest =- maybe dbBest dbBlock =<< withRedis logger r redisGetHead- dbBlock h =- maybe 0 (fromIntegral . (.height)) <$> withDB db (getBlock h)- dbBest =- maybe (return 0) dbBlock =<< withDB db getBestBlock- c x = newStatCounter s x 10- d x = newStatTiming s x 10---- | Record how long an action takes in a timing metric.-withTiming ::- (MonadUnliftIO m) =>- (CacheMetrics -> StatTiming) ->- CacheX m a ->- CacheX m a-withTiming df go =- asks (.metrics) >>= \case- Nothing -> go- Just m ->- bracket- time- (end m)- (const go)- where- time = round . (* 1000) <$> liftIO getPOSIXTime- end m t1 = time >>= addTiming (df m) . subtract t1---- | Run an action with the metrics, if they are enabled.-withMetrics ::- (MonadIO m) => (CacheMetrics -> CacheX m a) -> CacheX m ()-withMetrics go = asks (.metrics) >>= mapM_ go---- | Monad transformer that may use the cache. With 'Nothing', every query goes--- to the underlying store. Run it with 'withCache'.-type CacheT = ReaderT (Maybe CacheConfig)---- | Monad transformer that uses the cache.-type CacheX = ReaderT CacheConfig---- | Log a message with the logger of the cache.-logDebug, logInfo, logWarn, logError :: (MonadIO m) => Text -> CacheX m ()-logDebug = cacheLog LevelDebug-logInfo = cacheLog LevelInfo-logWarn = cacheLog LevelWarn-logError = cacheLog LevelError---- | Log a message at a level with the logger of the cache.-cacheLog :: (MonadIO m) => LogLevel -> Text -> CacheX m ()-cacheLog l msg = asks (.logger) >>= \lg -> logAt l lg msg---- | Errors of the cache.-data CacheError- = -- | Redis returned an error- RedisError Reply- | -- | a Redis transaction failed- RedisTxError !String- | -- | the cache is inconsistent with the database- LogicError !String- deriving (Show, Eq, Generic, NFData, Exception)---- | Connect to Redis at a URL such as @redis:\/\/localhost:6379@, or with the--- default settings if the URL is empty. Calls 'error' if the URL is invalid.-connectRedis :: (MonadIO m) => String -> m Connection-connectRedis redisurl = do- conninfo <-- if null redisurl- then return defaultConnectInfo- else case parseConnectInfo redisurl of- Left e -> error e- Right r -> return r- liftIO (checkedConnect conninfo)--instance (StoreReadBase m) => StoreReadBase (CacheX m) where- getCtx = lift getCtx- getNetwork = lift getNetwork- getBestBlock = lift getBestBlock- getBlocksAtHeight = lift . getBlocksAtHeight- getBlock = lift . getBlock- getTxData = lift . getTxData- getManyTxData = lift . getManyTxData- getSpender = lift . getSpender- getBalance = lift . getBalance- getUnspent = lift . getUnspent- getMempool = lift getMempool- getMempoolPage = lift . getMempoolPage--instance (StoreReadBase m) => StoreReadBase (CacheT m) where- getCtx = lift getCtx- getNetwork = lift getNetwork- getBestBlock = lift getBestBlock- getBlocksAtHeight = lift . getBlocksAtHeight- getBlock = lift . getBlock- getTxData = lift . getTxData- getManyTxData = lift . getManyTxData- getSpender = lift . getSpender- getBalance = lift . getBalance- getUnspent = lift . getUnspent- getMempool = lift getMempool- getMempoolPage = lift . getMempoolPage--instance- (MonadUnliftIO m, StoreReadExtra m) =>- StoreReadExtra (CacheX m)- where- getBalances = lift . getBalances- getAddressesTxs addrs = lift . getAddressesTxs addrs- getAddressTxs addr = lift . getAddressTxs addr- getAddressUnspents addr = lift . getAddressUnspents addr- getAddressesUnspents addrs = lift . getAddressesUnspents addrs- getMaxGap = lift getMaxGap- getInitialGap = lift getInitialGap- getNumTxData = lift . getNumTxData- xPubTxs = getXPubTxs- xPubTxCount = getXPubTxCount- xPubBals = getXPubBalances- xPubUnspents = getXPubUnspents--instance- (MonadUnliftIO m, StoreReadExtra m) =>- StoreReadExtra (CacheT m)- where- getBalances = lift . getBalances- getAddressesTxs addrs = lift . getAddressesTxs addrs- getAddressTxs addr = lift . getAddressTxs addr- getAddressUnspents addr = lift . getAddressUnspents addr- getAddressesUnspents addrs = lift . getAddressesUnspents addrs- getMaxGap = lift getMaxGap- getInitialGap = lift getInitialGap- getNumTxData = lift . getNumTxData- xPubBals xpub =- ReaderT $ \case- Nothing -> xPubBals xpub- Just cfg -> runReaderT (xPubBals xpub) cfg- xPubUnspents xpub xbals limits =- ReaderT $ \case- Nothing -> xPubUnspents xpub xbals limits- Just cfg -> runReaderT (xPubUnspents xpub xbals limits) cfg- xPubTxs xpub xbals limits =- ReaderT $ \case- Nothing -> xPubTxs xpub xbals limits- Just cfg -> runReaderT (xPubTxs xpub xbals limits) cfg- xPubTxCount xpub xbals =- ReaderT $ \case- Nothing -> xPubTxCount xpub xbals- Just cfg -> runReaderT (xPubTxCount xpub xbals) cfg---- | Run queries with the cache, or without it if 'Nothing'.-withCache :: Maybe CacheConfig -> CacheT m a -> m a-withCache s f = runReaderT f s---- | Prefix of the keys of xpub balances.-balancesPfx :: ByteString-balancesPfx = "b"---- | Prefix of the keys of xpub transactions.-txSetPfx :: ByteString-txSetPfx = "t"---- | Prefix of the keys of xpub unspent outputs.-utxoPfx :: ByteString-utxoPfx = "u"---- | Prefix of the keys that mark an xpub as being cached.-idxPfx :: ByteString-idxPfx = "i"---- | Key of the lock held while caching an xpub.-xpubLock :: XPubSpec -> ByteString-xpubLock xpub = "xlock-" <> encode xpub---- | Run a read of the cache entries of an xpub in the same round trip to--- Redis as the check that the xpub is cached. Redis pipelines the two--- commands, since the reply of the first is not read before the second is--- sent. The read is 'Nothing' if the xpub is not cached; it then found no--- entries, as the keys of an xpub are all deleted together.-whenXPubCached ::- (MonadIO m) =>- XPubSpec ->- Redis (Either Reply a) ->- CacheX m (Maybe a)-whenXPubCached xpub action = do- (cached, x) <- runRedis $ do- e <- redisIsXPubCached xpub- r <- action- return $ (,) <$> e <*> r- return $ if cached then Just x else Nothing---- | The score to start reading a sorted set of an xpub from, when it does--- not need a database lookup.-startScore :: Maybe Start -> Maybe (Maybe Double)-startScore Nothing = Just Nothing-startScore (Just (AtBlock h)) = Just (Just (blockRefScore (BlockRef h maxBound)))-startScore (Just AtTx {}) = Nothing---- | Transactions of an xpub, from the cache if it is cached, or from the store otherwise.-getXPubTxs ::- (MonadUnliftIO m, StoreReadExtra m) =>- XPubSpec ->- [XPubBal] ->- Limits ->- CacheX m [TxRef]-getXPubTxs xpub xbals limits =- case startScore limits.start of- Just score ->- whenXPubCached xpub (get_txs score) >>= \case- Just xs -> do- touchKeys [xpub]- hit (map (\(t, s) -> TxRef {txid = t, block = scoreBlockRef s}) xs)- Nothing -> lift $ xPubTxs xpub xbals limits- Nothing -> do- cached <- isXPubCached xpub- if cached- then hit =<< cacheGetXPubTxs xpub limits- else lift $ xPubTxs xpub xbals limits- where- get_txs score =- getFromSortedSet- (txSetPfx <> encode xpub)- score- limits.offset- limits.limit- hit txs = do- withMetrics $ \s ->- incrementCounter s.xTxs (length txs)- return txs---- | Number of transactions of an xpub, from the cache if it is cached, or from the store otherwise.-getXPubTxCount ::- (MonadUnliftIO m, StoreReadExtra m) =>- XPubSpec ->- [XPubBal] ->- CacheX m Word32-getXPubTxCount xpub xbals =- whenXPubCached xpub (redisGetXPubTxCount xpub) >>= \case- Just count -> do- withMetrics $ \s ->- incrementCounter s.xTxCount 1- touchKeys [xpub]- return (fromInteger count)- Nothing -> lift $ xPubTxCount xpub xbals---- | Unspent outputs of an xpub, from the cache if it is cached, or from the store otherwise.-getXPubUnspents ::- (MonadUnliftIO m, StoreReadExtra m) =>- XPubSpec ->- [XPubBal] ->- Limits ->- CacheX m [XPubUnspent]-getXPubUnspents xpub xbals limits =- case startScore limits.start of- Just score ->- whenXPubCached xpub (get_ops score) >>= \case- Just xs -> do- touchKeys [xpub]- hit (map fst xs)- Nothing -> lift $ xPubUnspents xpub xbals limits- Nothing -> do- cached <- isXPubCached xpub- if cached- then hit . map snd =<< cacheGetXPubUnspents xpub limits- else lift $ xPubUnspents xpub xbals limits- where- get_ops score =- getFromSortedSet- (utxoPfx <> encode xpub)- score- limits.offset- limits.limit- hit ops = do- uns <- catMaybes <$> lift (mapM getUnspent ops)- net <- lift getNetwork- ctx <- lift getCtx- let f u =- either- (const Nothing)- (\a -> Just (a, u))- (scriptToAddressBS net ctx u.script)- xm =- let j x = (x.balance.address, x)- k = (> 0) . (.balance.utxo)- in HashMap.fromList $ map j $ filter k xbals- g a = HashMap.lookup a xm- h u x =- XPubUnspent- { unspent = u,- path = x.path- }- us = mapMaybe f uns- i a u = h u <$> g a- withMetrics $ \s -> incrementCounter s.xUnspents (length us)- return $ mapMaybe (uncurry i) us---- | Balances of an xpub, from the cache if it is cached. Otherwise read them from--- the store and cache the xpub if it has enough used addresses.-getXPubBalances ::- (MonadUnliftIO m, StoreReadExtra m) =>- XPubSpec ->- CacheX m [XPubBal]-getXPubBalances xpub =- whenXPubCached xpub (redisGetXPubBalances xpub) >>= \case- Just xbals -> do- touchKeys [xpub]- withMetrics $ \s -> do- incrementCounter s.hits 1- incrementCounter s.xBalances (length xbals)- return xbals- Nothing -> do- withMetrics $ \s -> incrementCounter s.misses 1- withRetryLock (xpubLock xpub) (load_and_cache xpub) >>= \case- Just bals -> return bals- Nothing -> do- -- Another thread held the lock for too long: serve from the- -- database without caching rather than wait any longer.- logWarn "Could not acquire xpub lock, serving from database"- lift $ xPubBals xpub- where- load_and_cache x = do- bals <- lift $ xPubBals x- newXPubC x bals- return bals---- | Whether an xpub is cached. Always 'False' without a cache.-isInCache :: (MonadIO m) => XPubSpec -> CacheT m Bool-isInCache xpub =- ReaderT $ \case- Nothing -> return False- Just cfg -> runReaderT (isXPubCached xpub) cfg---- | Whether an xpub is cached.-isXPubCached :: (MonadIO m) => XPubSpec -> CacheX m Bool-isXPubCached xpub = runRedis (redisIsXPubCached xpub)---- | Redis command to check whether an xpub is cached.-redisIsXPubCached :: (RedisCtx m f) => XPubSpec -> m (f Bool)-redisIsXPubCached xpub = Redis.exists (balancesPfx <> encode xpub)---- | Redis command to count the transactions of an xpub.-redisGetXPubTxCount :: (RedisCtx m f) => XPubSpec -> m (f Integer)-redisGetXPubTxCount xpub = Redis.zcard (txSetPfx <> encode xpub)---- | Page of the cached transactions of an xpub.-cacheGetXPubTxs ::- (StoreReadBase m, MonadIO m) =>- XPubSpec ->- Limits ->- CacheX m [TxRef]-cacheGetXPubTxs xpub limits =- case limits.start of- Nothing ->- go1 Nothing- Just (AtTx th) ->- lift (getTxData th) >>= \case- Just TxData {block = b@BlockRef {}} ->- go1 $ Just (blockRefScore b)- _ ->- go2 th- Just (AtBlock h) ->- go1 (Just (blockRefScore (BlockRef h maxBound)))- where- go1 score = do- xs <-- runRedis $- getFromSortedSet- (txSetPfx <> encode xpub)- score- limits.offset- limits.limit- touchKeys [xpub]- return $ map (uncurry f) xs- go2 hash = do- xs <-- runRedis $- getFromSortedSet- (txSetPfx <> encode xpub)- Nothing- 0- 0- touchKeys [xpub]- let xs' =- if any ((== hash) . fst) xs- then dropWhile ((/= hash) . fst) xs- else []- return $- map (uncurry f) $- l $- drop (fromIntegral limits.offset) xs'- l =- if limits.limit > 0- then take (fromIntegral limits.limit)- else id- f t s = TxRef {txid = t, block = scoreBlockRef s}---- | Page of the cached unspent outputs of an xpub, with their positions.-cacheGetXPubUnspents ::- (StoreReadBase m, MonadIO m) =>- XPubSpec ->- Limits ->- CacheX m [(BlockRef, OutPoint)]-cacheGetXPubUnspents xpub limits =- case limits.start of- Nothing ->- go1 Nothing- Just (AtTx th) ->- lift (getTxData th) >>= \case- Just TxData {block = b@BlockRef {}} ->- go1 (Just (blockRefScore b))- _ ->- go2 th- Just (AtBlock h) ->- go1 (Just (blockRefScore (BlockRef h maxBound)))- where- go1 score = do- xs <-- runRedis $- getFromSortedSet- (utxoPfx <> encode xpub)- score- limits.offset- limits.limit- touchKeys [xpub]- return $ map (uncurry f) xs- go2 hash = do- xs <-- runRedis $- getFromSortedSet- (utxoPfx <> encode xpub)- Nothing- 0- 0- touchKeys [xpub]- let xs' =- if any ((== hash) . (.hash) . fst) xs- then dropWhile ((/= hash) . (.hash) . fst) xs- else []- return $- map (uncurry f) $- l $- drop (fromIntegral limits.offset) xs'- l =- if limits.limit > 0- then take (fromIntegral limits.limit)- else id- f o s = (scoreBlockRef s, o)---- | Redis command to read the cached balances of an xpub, sorted by path.-redisGetXPubBalances :: (Functor f, RedisCtx m f) => XPubSpec -> m (f [XPubBal])-redisGetXPubBalances xpub =- fmap (sort . map (uncurry f)) <$> getAllFromMap (balancesPfx <> encode xpub)- where- f p b = XPubBal {path = p, balance = b}---- | Score of a position in a Redis sorted set, such that sorting by score--- ascending sorts from newest to oldest. Mempool transactions get negative--- scores from their time, so they come first; confirmed ones get positive--- scores that decrease with height and position.------ One number has to order mempool and confirmed transactions in a single sorted--- set, newest first, exactly as the database does. Floating point is what Redis--- offers, so the height and position are packed into the 53 bits a double holds--- exactly.-blockRefScore :: BlockRef -> Double-blockRefScore BlockRef {height = h, position = p} =- fromIntegral (0x001fffffffffffff - (h' .|. p'))- where- h' = (fromIntegral h .&. 0x07ffffff) `shift` 26 :: Word64- p' = (fromIntegral p .&. 0x03ffffff) :: Word64-blockRefScore MemRef {timestamp = t} = negate t'- where- t' = fromIntegral (t .&. 0x001fffffffffffff)---- | Inverse of 'blockRefScore'.-scoreBlockRef :: Double -> BlockRef-scoreBlockRef s- | s < 0 = MemRef {timestamp = n}- | otherwise = BlockRef {height = h, position = p}- where- n = truncate (abs s) :: Word64- m = 0x001fffffffffffff - n- h = fromIntegral (m `shift` (-26))- p = fromIntegral (m .&. 0x03ffffff)---- | Redis command to read members of a sorted set, decoded, with their scores,--- from a minimum score if given, skipping an offset, up to a count (@0@ for--- all).-getFromSortedSet ::- (Applicative f, RedisCtx m f, Serialize a) =>- ByteString ->- Maybe Double ->- Word32 ->- Word32 ->- m (f [(a, Double)])-getFromSortedSet key Nothing off 0 = do- xs <- zrangeWithscores key (fromIntegral off) (-1)- return $ do- ys <- map (\(x, s) -> (,s) <$> decode x) <$> xs- return (rights ys)-getFromSortedSet key Nothing off count = do- xs <-- zrangeWithscores- key- (fromIntegral off)- (fromIntegral off + fromIntegral count - 1)- return $ do- ys <- map (\(x, s) -> (,s) <$> decode x) <$> xs- return (rights ys)-getFromSortedSet key (Just score) off 0 = do- xs <-- zrangebyscoreWithscoresLimit- key- score- (1 / 0)- (fromIntegral off)- (-1)- return $ do- ys <- map (\(x, s) -> (,s) <$> decode x) <$> xs- return (rights ys)-getFromSortedSet key (Just score) off count = do- xs <-- zrangebyscoreWithscoresLimit- key- score- (1 / 0)- (fromIntegral off)- (fromIntegral count)- return $ do- ys <- map (\(x, s) -> (,s) <$> decode x) <$> xs- return (rights ys)---- | Redis command to read all fields of a hash, decoded.-getAllFromMap ::- (Functor f, RedisCtx m f, Serialize k, Serialize v) =>- ByteString ->- m (f [(k, v)])-getAllFromMap n = do- fxs <- hgetall n- return $ do- xs <- fxs- return- [ (k, v)- | (k', v') <- xs,- let k = fromRight undefined $ decode k',- let v = fromRight undefined $ decode v'- ]---- | Messages handled by the cache writer.-data CacheWriterMessage- = -- | a new block was imported- CacheNewBlock- | -- | a mempool transaction was added or removed- CacheNewTx !TxHash- | -- | internal: a batch of coalesced 'CacheNewTx' events- CacheNewTxs ![TxHash]- | -- | sync the mempool with the database, then reply- CacheSyncMempool !(Listen ())---- | Inbox of the cache writer process.-type CacheWriterInbox = Inbox CacheWriterMessage---- | Mailbox to send messages to the cache writer.-type CacheWriter = Mailbox CacheWriterMessage---- | Xpub that a cached address belongs to.-data AddressXPub = AddressXPub- { -- | the xpub- spec :: !XPubSpec,- -- | derivation path of the address below the xpub- path :: ![KeyIndex]- }- deriving (Show, Eq, Generic, NFData, Serialize)---- | Key of the sorted set of mempool transactions.-mempoolSetKey :: ByteString-mempoolSetKey = "mempool"---- | Prefix of the keys of cached addresses.-addrPfx :: ByteString-addrPfx = "a"---- | Key of the last block processed by the cache.-bestBlockKey :: ByteString-bestBlockKey = "head"---- | Key of the sorted set of cached xpubs by time of last use.-maxKey :: ByteString-maxKey = "max"---- | Address of an extended public key for a type of address.-xPubAddrFunction :: Ctx -> DeriveType -> XPubKey -> Address-xPubAddrFunction ctx DeriveNormal = xPubAddr ctx-xPubAddrFunction ctx DeriveP2SH = xPubCompatWitnessAddr ctx-xPubAddrFunction ctx DeriveP2WPKH = xPubWitnessAddr ctx---- | Run the cache writer process: bring the cache up to date with the database,--- then handle messages forever. Notifications of new mempool transactions that--- pile up are handled together, up to 1000 at a time.-cacheWriter ::- (MonadUnliftIO m, StoreReadExtra m) =>- CacheConfig ->- CacheWriterInbox ->- m ()-cacheWriter cfg inbox =- runReaderT go cfg- where- go = do- newBlockC- syncMempoolC- loopForever $ do- logDebug "Awaiting event..."- x <- receive inbox- case x of- CacheNewTx txid -> do- -- Coalesce: every queued CacheNewTx is synced in one pass instead- -- of one Redis round-trip sequence per transaction, so the inbox- -- cannot grow without bound when Redis is slower than the mempool.- more <- liftIO (atomically (drainNewTxs []))- cacheWriterReact (CacheNewTxs (txid : more))- _ -> cacheWriterReact x- drainNewTxs acc- | length acc >= 1000 = return (reverse acc)- | otherwise = do- empty <- mailboxEmptySTM inbox- if empty- then return (reverse acc)- else- receiveSTM inbox >>= \case- CacheNewTx t -> drainNewTxs (t : acc)- other -> do- requeueSTM other inbox- return (reverse acc)---- | Try to take a lock that expires after five minutes. Returns 'True' if taken.-lockIt :: (MonadIO m) => ByteString -> CacheX m Bool-lockIt l = do- go >>= \case- Right Redis.Ok -> do- logDebug "Acquired lock"- withMetrics $ \s -> incrementCounter s.lockAcquired 1- return True- Right Redis.Pong -> do- logError "Unexpected pong when acquiring lock"- withMetrics $ \s -> incrementCounter s.lockFailed 1- return False- Right (Redis.Status s) -> do- logError $- "Unexpected status acquiring lock: " <> cs s- withMetrics $ \m -> incrementCounter m.lockFailed 1- return False- Left (Redis.Bulk Nothing) -> do- logDebug "Lock already taken"- withMetrics $ \s -> incrementCounter s.lockFailed 1- return False- Left e -> do- logError "Error when trying to acquire lock"- withMetrics $ \s -> incrementCounter s.lockFailed 1- throwIO (RedisError e)- where- go = do- conn <- asks (.redis)- liftIO . Redis.runRedis conn $ do- let opts =- Redis.SetOpts- { Redis.setSeconds = Just 300,- Redis.setMilliseconds = Nothing,- Redis.setUnixSeconds = Nothing,- Redis.setUnixMilliseconds = Nothing,- Redis.setCondition = Just Redis.Nx,- Redis.setKeepTTL = False- }- Redis.setOpts l "locked" opts---- | Extend a lock for another five minutes.-refreshLock :: (MonadIO m) => ByteString -> CacheX m ()-refreshLock l = void . runRedis $ do- let opts =- Redis.SetOpts- { Redis.setSeconds = Just 300,- Redis.setMilliseconds = Nothing,- Redis.setUnixSeconds = Nothing,- Redis.setUnixMilliseconds = Nothing,- Redis.setCondition = Just Redis.Xx,- Redis.setKeepTTL = False- }- Redis.setOpts l "locked" opts---- | Release a lock.-unlockIt :: (MonadIO m) => ByteString -> CacheX m ()-unlockIt l = void . runRedis . Redis.del $ NonEmpty.singleton l---- | Run an action holding a lock, refreshing it every two and a half minutes. Returns 'Nothing' if the lock is taken.-withLock ::- (MonadUnliftIO m) =>- ByteString ->- CacheX m a ->- CacheX m (Maybe a)-withLock l f =- bracket (lockIt l) (`when` unlockIt l) $ \case- True -> Just <$> go- False -> return Nothing- where- go = withAsync refresh $ const f- refresh = loopForever $ do- threadDelay (150 * 1000 * 1000)- refreshLock l---- | Run the action holding the lock, retrying with exponential backoff--- while another thread holds it. The delay between attempts grows from--- 0.2 s to at most 5 s, and after about 30 s of waiting it gives up and--- returns 'Nothing' instead of keeping the calling thread blocked forever.-withRetryLock ::- (MonadUnliftIO m) =>- ByteString ->- CacheX m a ->- CacheX m (Maybe a)-withRetryLock l f =- go 0 (200 * 1000)- where- max_delay = 5 * 1000 * 1000- max_wait = 30 * 1000 * 1000- go waited n =- withLock l f >>= \case- Just x -> return (Just x)- Nothing- | waited >= max_wait -> return Nothing- | otherwise -> do- threadDelay n- go (waited + n) (min max_delay (n * 2))---- | Evict the least recently used xpubs while Redis holds more than the maximum number of keys.-pruneDB ::- (MonadUnliftIO m, StoreReadBase m) => CacheX m ()-pruneDB = void . withLock "lock" $ do- x <- asks (.maxKeys)- s <- runRedis Redis.dbsize- logDebug "Pruning old xpubs"- when (s > x) $- void . delXPubKeys . map fst- =<< runRedis (getFromSortedSet maxKey Nothing 0 (fromIntegral (s - x)))---- | Record that xpubs were just used.-touchKeys :: (MonadIO m) => [XPubSpec] -> CacheX m ()-touchKeys xpubs = do- now <- round <$> liftIO getPOSIXTime :: (MonadIO m) => m Int- runRedis $ redisTouchKeys now xpubs---- | Redis command to record that xpubs were used at a time.-redisTouchKeys :: (Monad f, RedisCtx m f, Real a) => a -> [XPubSpec] -> m (f ())-redisTouchKeys _ [] = return $ return ()-redisTouchKeys now xpubs =- void <$> Redis.zadd maxKey (map ((realToFrac now,) . encode) xpubs)---- | Handle a message sent to the cache writer.-cacheWriterReact ::- (MonadUnliftIO m, StoreReadExtra m) =>- CacheWriterMessage ->- CacheX m ()-cacheWriterReact CacheNewBlock = do- logDebug "Received new block event"- pruneDB- newBlockC- syncMempoolC-cacheWriterReact (CacheNewTx txid) =- cacheWriterReact (CacheNewTxs [txid])-cacheWriterReact (CacheNewTxs txids) = do- logDebug $- "Received " <> cs (show (length txids)) <> " new transaction event(s)"- pruneDB- syncNewTxC txids-cacheWriterReact (CacheSyncMempool l) = do- logDebug "Received sync mempool event"- pruneDB- newBlockC- syncMempoolC- atomically $ l ()---- | Number of used addresses in a list of xpub balances.-lenNotNull :: [XPubBal] -> Int-lenNotNull = length . filter (not . nullBalance . (.balance))---- | Cache an xpub, if it has enough used addresses, the store is in sync, and no--- other thread is caching it already.-newXPubC ::- (MonadUnliftIO m, StoreReadExtra m) =>- XPubSpec ->- [XPubBal] ->- CacheX m ()-newXPubC xpub xbals =- should_index >>= \i -> when i $- bracket set_index unset_index $ \j -> when j $- withTiming (.indexTime) $ do- xpubtxt <- xpubText xpub- logDebug $- "Caching "- <> xpubtxt- <> ": "- <> cs (show (length xbals))- <> " addresses / "- <> cs (show (lenNotNull xbals))- <> " used"- utxo <- lift $ xPubUnspents xpub xbals def- logDebug $- "Caching "- <> xpubtxt- <> ": "- <> cs (show (length utxo))- <> " utxos"- xtxs <- lift $ xPubTxs xpub xbals def- logDebug $- "Caching "- <> xpubtxt- <> ": "- <> cs (show (length xtxs))- <> " txs"- now <- round <$> liftIO getPOSIXTime :: (MonadIO m) => m Int- runRedis $ do- b <- redisTouchKeys now [xpub]- c <- redisAddXPubBalances xpub xbals- d <- redisAddXPubUnspents xpub (map op utxo)- e <- redisAddXPubTxs xpub xtxs- return $ b >> c >> d >> e >> return ()- logDebug $ "Cached " <> xpubtxt- where- op XPubUnspent {unspent = u} = (u.outpoint, u.block)- should_index =- asks (.minAddrs) >>= \x ->- if x <= lenNotNull xbals- then inSync- else return False- key = idxPfx <> encode xpub- opts =- Redis.SetOpts- { Redis.setSeconds = Just 600,- Redis.setMilliseconds = Nothing,- Redis.setUnixSeconds = Nothing,- Redis.setUnixMilliseconds = Nothing,- Redis.setCondition = Just Redis.Nx,- Redis.setKeepTTL = False- }- red = Redis.setOpts key "1" opts- unset_index y =- when y . void . runRedis . Redis.del $ NonEmpty.singleton key- set_index = do- conn <- asks (.redis)- liftIO (Redis.runRedis conn red) <&> isRight---- | Whether the best block of the store is the best header of the node.-inSync ::- (MonadUnliftIO m, StoreReadExtra m) =>- CacheX m Bool-inSync =- lift getBestBlock >>= \case- Nothing -> return False- Just bb -> do- ch <- asks (.chain)- cb <- liftIO $ chainGetBest ch- return $ cb.height > 0 && headerHash cb.header == bb---- | Bring the cache up to date with the best block of the store, processing every--- block since the last one it processed, across reorganisations.-newBlockC ::- (MonadUnliftIO m, StoreReadExtra m) =>- CacheX m ()-newBlockC =- inSync >>= \s -> when s . void . withLock "lock" $ do- get_best_block_node >>= \case- Nothing -> logError "No best block available"- Just best_block_node ->- cacheGetHead >>= \case- Nothing -> do- logInfo "Initializing best cache block"- importBlockC $ headerHash best_block_node.header- Just cache_head_hash ->- get_block_node cache_head_hash >>= \case- Nothing -> do- logError $- "Could not get best cache block: "- <> blockHashToHex cache_head_hash- Just cache_head_node -> do- blocks <- get_blocks cache_head_node best_block_node- mapM_ importBlockC blocks- where- get_best_block_node =- lift getBestBlock >>= \case- Nothing -> return Nothing- Just best_block_hash -> get_block_node best_block_hash- get_block_node block_hash = do- ch <- asks (.chain)- liftIO $ chainGetBlock ch block_hash- get_blocks left_node right_node = do- ch <- asks (.chain)- split_node <- liftIO $ chainGetSplitBlock ch left_node right_node- let split_node_hash = headerHash split_node.header- right_node_hash = headerHash right_node.header- if split_node_hash == right_node_hash- then return []- else do- let fork_height = split_node.height + 1- left_parents <- liftIO $ chainGetParents ch fork_height left_node- right_parents <- liftIO $ chainGetParents ch fork_height right_node- let blocks = reverse left_parents <> right_parents <> pure right_node- return $ map (headerHash . (.header)) blocks---- | Update cached xpubs with the transactions of a block.-importBlockC ::- (MonadUnliftIO m, StoreReadExtra m) =>- BlockHash ->- CacheX m ()-importBlockC bh =- lift (getBlock bh) >>= \case- Just bd -> do- let ths = bd.txs- tds <- sortTxData . catMaybes <$> mapM (lift . getTxData) ths- logDebug $- "Importing "- <> cs (show (length tds))- <> " transactions from block "- <> blockHashToHex bh- importMultiTxC tds- logDebug $- "Done importing "- <> cs (show (length tds))- <> " transactions from block "- <> blockHashToHex bh- cacheSetHead bd- Nothing -> do- logError $- "Could not get block: "- <> blockHashToHex bh- throwIO . LogicError . cs $- "Could not get block: "- <> blockHashToHex bh---- | Update cached xpubs with transactions: their transaction sets, unspent--- outputs, balances and the mempool. Addresses past the gap are derived and--- added when new addresses get used.-importMultiTxC ::- (MonadUnliftIO m, StoreReadExtra m) =>- [TxData] ->- CacheX m ()-importMultiTxC txs = do- net <- lift getNetwork- ctx <- lift getCtx- logDebug $ "Processing " <> cs (show (length txs)) <> " txs"- logDebug $- "Getting address information for "- <> cs (show (length (alladdrs net ctx)))- <> " addresses"- addrmap <- getaddrmap net ctx- let addrs = HashMap.keys addrmap- logDebug $- "Getting balances for "- <> cs (show (HashMap.size addrmap))- <> " addresses"- balmap <- getbalances addrs- logDebug $- "Getting unspent data for "- <> cs (show (length (allops net ctx)))- <> " outputs"- unspentmap <- getunspents net ctx- gap <- lift getMaxGap- now <- round <$> liftIO getPOSIXTime :: (MonadIO m) => m Int- let xpubs = allxpubsls addrmap- forM_ (zip [(1 :: Int) ..] xpubs) $ \(i, xpub) -> do- xpubtxt <- xpubText xpub- logDebug $- "Affected xpub "- <> cs (show i)- <> "/"- <> cs (show (length xpubs))- <> ": "- <> xpubtxt- addrs' <- do- logDebug $- "Getting xpub balances for "- <> cs (show (length xpubs))- <> " xpubs"- xmap <- getxbals xpubs- let addrmap' = faddrmap (HashMap.keysSet xmap) addrmap- logDebug "Starting Redis import pipeline"- runRedis $ do- x <- redisImportMultiTx net ctx addrmap' unspentmap txs- y <- redisUpdateBalances addrmap' balmap- z <- redisTouchKeys now (HashMap.keys xmap)- return $ x >> y >> z >> return ()- logDebug "Completed Redis pipeline"- return $ getNewAddrs ctx gap xmap (HashMap.elems addrmap')- cacheAddAddresses addrs'- where- alladdrsls net ctx = HashSet.toList (alladdrs net ctx)- faddrmap xmap = HashMap.filter (\a -> a.spec `elem` xmap)- getaddrmap net ctx =- HashMap.fromList- . catMaybes- . zipWith (\a -> fmap (a,)) (alladdrsls net ctx)- <$> cacheGetAddrsInfo (alladdrsls net ctx)- getunspents net ctx =- HashMap.fromList- . catMaybes- . zipWith (\p -> fmap (p,)) (allops net ctx)- <$> lift (mapM getUnspent (allops net ctx))- getbalances addrs =- HashMap.fromList . zip addrs <$> mapM (lift . getDefaultBalance) addrs- getxbals xpubs = do- bals <- runRedis . fmap sequence . forM xpubs $ \xpub -> do- bs <- redisGetXPubBalances xpub- return $ (,) xpub <$> bs- return $ HashMap.filter (not . null) (HashMap.fromList bals)- allops net ctx =- map snd $- concatMap (txInputs net ctx) txs- <> concatMap (txOutputs net ctx) txs- alladdrs net ctx =- HashSet.fromList $- map fst $- concatMap (txInputs net ctx) txs- <> concatMap (txOutputs net ctx) txs- allxpubsls = HashSet.toList . allxpubs- allxpubs =- HashSet.fromList . map (.spec) . HashMap.elems---- | Redis commands to add or remove transactions and unspent outputs of cached xpubs.-redisImportMultiTx ::- (Monad f, RedisCtx m f) =>- Network ->- Ctx ->- HashMap Address AddressXPub ->- HashMap OutPoint Unspent ->- [TxData] ->- m (f ())-redisImportMultiTx net ctx addrmap unspentmap tds = do- xs <- mapM importtxentries tds- return $ sequence_ xs- where- uns p i =- case HashMap.lookup p unspentmap of- Just u ->- redisAddXPubUnspents i.spec [(p, u.block)]- Nothing -> redisRemXPubUnspents i.spec [p]- addtx tx a p =- case HashMap.lookup a addrmap of- Just i -> do- let tr =- TxRef- { txid = txHash tx.tx,- block = tx.block- }- x <- redisAddXPubTxs i.spec [tr]- y <- uns p i- return $ x >> y >> return ()- Nothing -> return (pure ())- remtx tx a p =- case HashMap.lookup a addrmap of- Just i -> do- x <- redisRemXPubTxs i.spec [txHash tx.tx]- y <- uns p i- return $ x >> y >> return ()- Nothing -> return (pure ())- importtxentries td =- if td.deleted- then do- x <-- mapM- (uncurry (remtx td))- (txaddrops td)- y <- redisRemFromMempool [txHash td.tx]- return $ sequence_ x >> void y- else do- a <-- sequence- <$> mapM- (uncurry (addtx td))- (txaddrops td)- b <-- case td.block of- b@MemRef {} ->- let tr =- TxRef- { txid = txHash td.tx,- block = b- }- in redisAddToMempool [tr]- _ -> redisRemFromMempool [txHash td.tx]- return $ a >> b >> return ()- txaddrops td = txInputs net ctx td <> txOutputs net ctx td---- | Redis commands to update the balances of cached addresses.-redisUpdateBalances ::- (Monad f, RedisCtx m f) =>- HashMap Address AddressXPub ->- HashMap Address Balance ->- m (f ())-redisUpdateBalances addrmap balmap =- fmap (fmap mconcat . sequence) . forM (HashMap.keys addrmap) $ \a ->- case (HashMap.lookup a addrmap, HashMap.lookup a balmap) of- (Just ainfo, Just bal) ->- redisAddXPubBalances ainfo.spec [xpubbal ainfo bal]- _ -> return (pure ())- where- xpubbal ainfo bal =- XPubBal {path = ainfo.path, balance = bal}---- | Add newly derived addresses to their cached xpubs, with their balances,--- transactions and unspent outputs, and keep deriving while they are used.-cacheAddAddresses ::- (StoreReadExtra m, MonadUnliftIO m) =>- [(Address, AddressXPub)] ->- CacheX m ()-cacheAddAddresses [] = logDebug "No further addresses to add"-cacheAddAddresses addrs = do- ctx <- lift getCtx- logDebug $- "Adding " <> cs (show (length addrs)) <> " new generated addresses"- logDebug "Getting balances"- balmap <- HashMap.fromListWith (<>) <$> mapM (uncurry getbal) addrs- logDebug "Getting unspent outputs"- utxomap <- HashMap.fromListWith (<>) <$> mapM (uncurry getutxo) addrs- logDebug "Getting transactions"- txmap <- HashMap.fromListWith (<>) <$> mapM (uncurry gettxmap) addrs- logDebug "Running Redis pipeline"- runRedis $ do- a <- forM (HashMap.toList balmap) (uncurry redisAddXPubBalances)- b <- forM (HashMap.toList utxomap) (uncurry redisAddXPubUnspents)- c <- forM (HashMap.toList txmap) (uncurry redisAddXPubTxs)- return $ sequence_ a >> sequence_ b >> sequence_ c- logDebug "Completed Redis pipeline"- let xpubs =- HashSet.toList- . HashSet.fromList- . map (.spec)- $ Map.elems amap- logDebug "Getting xpub balances"- xmap <- getbals xpubs- gap <- lift getMaxGap- let notnulls = getnotnull balmap- addrs' = getNewAddrs ctx gap xmap notnulls- cacheAddAddresses addrs'- where- getbals xpubs = runRedis $ do- bs <- sequence <$> forM xpubs redisGetXPubBalances- return $- HashMap.filter (not . null)- . HashMap.fromList- . zip xpubs- <$> bs- amap = Map.fromList addrs- getnotnull =- let f xpub =- map $ \bal ->- AddressXPub- { spec = xpub,- path = bal.path- }- g = filter (not . nullBalance . (.balance))- in concatMap (uncurry f) . HashMap.toList . HashMap.map g- getbal a i =- let f b =- ( i.spec,- [XPubBal {balance = b, path = i.path}]- )- in f <$> lift (getDefaultBalance a)- getutxo a i =- let f us =- ( i.spec,- map (\u -> (u.outpoint, u.block)) us- )- in f <$> lift (getAddressUnspents a def)- gettxmap a i =- let f ts = (i.spec, ts)- in f <$> lift (getAddressTxs a def)---- | Addresses to derive and add to cached xpubs to keep the gap after the used ones.-getNewAddrs ::- Ctx ->- KeyIndex ->- HashMap XPubSpec [XPubBal] ->- [AddressXPub] ->- [(Address, AddressXPub)]-getNewAddrs ctx gap xpubs =- concatMap $ \a ->- case HashMap.lookup a.spec xpubs of- Nothing -> []- Just bals -> addrsToAdd ctx gap bals a---- | Update cached xpubs with new or deleted transactions, if in sync.-syncNewTxC ::- (MonadUnliftIO m, StoreReadExtra m) =>- [TxHash] ->- CacheX m ()-syncNewTxC ths =- inSync >>= \s -> when s . void . withLock "lock" $ do- txs <- catMaybes <$> mapM (lift . getTxData) ths- unless (null txs) $ do- forM_ txs $ \tx ->- logDebug $- "Synchronizing transaction: " <> txHashToHex (txHash tx.tx)- importMultiTxC txs---- | Update cached xpubs with every mempool difference between the cache and the store, if in sync.-syncMempoolC ::- (MonadUnliftIO m, StoreReadExtra m) =>- CacheX m ()-syncMempoolC =- inSync >>= \s -> when s . void . withLock "lock" $ do- nodepool <- HashSet.fromList . map snd <$> lift getMempool- cachepool <- HashSet.fromList . map snd <$> cacheGetMempool- let diff1 = HashSet.difference nodepool cachepool- let diff2 = HashSet.difference cachepool nodepool- let diffset = diff1 <> diff2- let tids = HashSet.toList diffset- txs <- catMaybes <$> mapM (lift . getTxData) tids- unless (null txs) $ do- logDebug $- "Synchronizing " <> cs (show (length txs)) <> " mempool transactions"- importMultiTxC txs---- | Mempool as seen by the cache, newest first.-cacheGetMempool :: (MonadIO m) => CacheX m [(UnixTime, TxHash)]-cacheGetMempool = runRedis redisGetMempool---- | Last block processed by the cache.-cacheGetHead :: (MonadIO m) => CacheX m (Maybe BlockHash)-cacheGetHead = runRedis redisGetHead---- | Set the last block processed by the cache.-cacheSetHead :: (MonadIO m) => BlockData -> CacheX m ()-cacheSetHead bd = do- logDebug $ "Cache head set to: " <> blockHashToHex bh- void $ runRedis (redisSetHead bh)- withMetrics $ \s -> setGauge s.height (fromIntegral bd.height)- where- bh = headerHash bd.header---- | Xpub and path of each address, or 'Nothing' if the address is not cached.-cacheGetAddrsInfo ::- (MonadIO m) => [Address] -> CacheX m [Maybe AddressXPub]-cacheGetAddrsInfo as = runRedis (redisGetAddrsInfo as)---- | Redis command to add transactions to the mempool set.-redisAddToMempool :: (Applicative f, RedisCtx m f) => [TxRef] -> m (f Integer)-redisAddToMempool [] = return (pure 0)-redisAddToMempool btxs =- zadd mempoolSetKey $- map- (\btx -> (blockRefScore btx.block, encode btx.txid))- btxs---- | Redis command to remove transactions from the mempool set.-redisRemFromMempool ::- (Applicative f, RedisCtx m f) => [TxHash] -> m (f Integer)-redisRemFromMempool xs =- case NonEmpty.nonEmpty xs of- Nothing -> return (pure 0)- Just xs' -> zrem mempoolSetKey $ NonEmpty.map encode xs'---- | Redis command to record the xpub and path of an address.-redisSetAddrInfo ::- (Functor f, RedisCtx m f) => Address -> AddressXPub -> m (f ())-redisSetAddrInfo a i = void <$> Redis.set (addrPfx <> encode a) (encode i)---- | Remove xpubs from the cache. Returns the number of keys deleted.-cacheDelXPubs ::- (MonadIO m, StoreReadBase m) =>- [XPubSpec] ->- CacheT m Integer-cacheDelXPubs xpubs = ReaderT $ \case- Just cache -> runReaderT (delXPubKeys xpubs) cache- Nothing -> return 0---- | Remove xpubs from the cache. Returns the number of keys deleted.-delXPubKeys ::- (MonadIO m, StoreReadBase m) =>- [XPubSpec] ->- CacheX m Integer-delXPubKeys [] = return 0-delXPubKeys xpubs = do- forM_ xpubs $ \x -> do- xtxt <- xpubText x- logDebug $ "Deleting xpub: " <> xtxt- xbals <-- runRedis . fmap sequence . forM xpubs $ \xpub -> do- bs <- redisGetXPubBalances xpub- return $ (xpub,) <$> bs- runRedis $ fmap sum . sequence <$> forM xbals (uncurry redisDelXPubKeys)---- | Redis commands to remove an xpub and its addresses from the cache.-redisDelXPubKeys ::- (Monad f, RedisCtx m f) => XPubSpec -> [XPubBal] -> m (f Integer)-redisDelXPubKeys xpub bals = go $ map (.balance.address) bals- where- go addrs = do- addrcount <-- case NonEmpty.nonEmpty addrs of- Nothing -> return (pure 0)- Just addrs' -> Redis.del $ NonEmpty.map ((addrPfx <>) . encode) addrs'- txsetcount <- Redis.del $ NonEmpty.singleton (txSetPfx <> encode xpub)- utxocount <- Redis.del $ NonEmpty.singleton (utxoPfx <> encode xpub)- balcount <- Redis.del $ NonEmpty.singleton (balancesPfx <> encode xpub)- x <- Redis.zrem maxKey $ NonEmpty.singleton (encode xpub)- return $ do- _ <- x- addrs' <- addrcount- txset' <- txsetcount- utxo' <- utxocount- bal' <- balcount- return $ addrs' + txset' + utxo' + bal'---- | Redis command to add transactions to an xpub.-redisAddXPubTxs ::- (Applicative f, RedisCtx m f) => XPubSpec -> [TxRef] -> m (f Integer)-redisAddXPubTxs _ [] = return (pure 0)-redisAddXPubTxs xpub btxs =- zadd (txSetPfx <> encode xpub) $- map (\t -> (blockRefScore t.block, encode t.txid)) btxs---- | Redis command to remove transactions from an xpub.-redisRemXPubTxs ::- (Applicative f, RedisCtx m f) => XPubSpec -> [TxHash] -> m (f Integer)-redisRemXPubTxs xpub txhs =- case NonEmpty.nonEmpty txhs of- Nothing -> return (pure 0)- Just txhs' -> zrem (txSetPfx <> encode xpub) (NonEmpty.map encode txhs')---- | Redis command to add unspent outputs to an xpub.-redisAddXPubUnspents ::- (Applicative f, RedisCtx m f) =>- XPubSpec ->- [(OutPoint, BlockRef)] ->- m (f Integer)-redisAddXPubUnspents _ [] =- return (pure 0)-redisAddXPubUnspents xpub utxo =- zadd (utxoPfx <> encode xpub) $- map (\(p, r) -> (blockRefScore r, encode p)) utxo---- | Redis command to remove unspent outputs from an xpub.-redisRemXPubUnspents ::- (Applicative f, RedisCtx m f) => XPubSpec -> [OutPoint] -> m (f Integer)-redisRemXPubUnspents _ [] =- return (pure 0)-redisRemXPubUnspents xpub ops =- case NonEmpty.nonEmpty ops of- Nothing -> return (pure 0)- Just ops' -> zrem (utxoPfx <> encode xpub) (NonEmpty.map encode ops')---- | Redis commands to set balances of an xpub and record its addresses.-redisAddXPubBalances ::- (Monad f, RedisCtx m f) => XPubSpec -> [XPubBal] -> m (f ())-redisAddXPubBalances xpub bals =- case NonEmpty.nonEmpty bals of- Nothing -> return (pure ())- Just bals' -> do- let bs = NonEmpty.map (\b -> (encode b.path, encode b.balance)) bals'- Redis.hset (balancesPfx <> encode xpub) bs- ys <- forM bals' $ \b ->- redisSetAddrInfo- b.balance.address- AddressXPub- { spec = xpub,- path = b.path- }- return $ sequence_ ys---- | Redis command to set the last block processed by the cache.-redisSetHead :: (RedisCtx m f) => BlockHash -> m (f Redis.Status)-redisSetHead bh = Redis.set bestBlockKey (encode bh)---- | Redis commands to read the xpub and path of addresses.-redisGetAddrsInfo ::- (Monad f, RedisCtx m f) => [Address] -> m (f [Maybe AddressXPub])-redisGetAddrsInfo [] = return (pure [])-redisGetAddrsInfo as = do- is <- mapM (\a -> Redis.get (addrPfx <> encode a)) as- return $ do- is' <- sequence is- return $ map (eitherToMaybe . decode =<<) is'---- | Addresses to derive after a newly used address, so that the gap of unused--- addresses after the last used one on its chain is kept.-addrsToAdd ::- Ctx ->- KeyIndex ->- [XPubBal] ->- AddressXPub ->- [(Address, AddressXPub)]-addrsToAdd ctx gap xbals addrinfo- | null fbals =- []- | not haschange =- zipWith f addrs list- <> zipWith f changeaddrs changelist- | otherwise =- zipWith f addrs list- where- haschange = any ((== 1) . (!! 0) . (.path)) xbals- f a p = (a, AddressXPub {spec = xpub, path = p})- dchain = addrinfo.path !! 0- fbals = filter ((== dchain) . (!! 0) . (.path)) xbals- maxidx = maximum (map ((!! 1) . (.path)) fbals)- xpub = addrinfo.spec- aidx = addrinfo.path !! 1- ixs =- if gap > maxidx - aidx- then [maxidx + 1 .. aidx + gap]- else []- paths = map (Deriv :/ dchain :/) ixs- keys = map (\p -> derivePubPath ctx p xpub.key)- list = map pathToList paths- xpubf = xPubAddrFunction ctx xpub.deriv- addrs = map xpubf (keys paths)- changepaths = map (Deriv :/ 1 :/) [0 .. gap - 1]- changeaddrs = map xpubf (keys changepaths)- changelist = map pathToList changepaths---- | Sort transactions so that each comes after those it spends.-sortTxData :: [TxData] -> [TxData]-sortTxData tds =- let txm = Map.fromList (map (\d -> (txHash d.tx, d)) tds)- ths = map (txHash . snd) (sortTxs (map (.tx) tds))- in mapMaybe (`Map.lookup` txm) ths---- | Addresses and outpoints of the outputs spent by a transaction.-txInputs :: Network -> Ctx -> TxData -> [(Address, OutPoint)]-txInputs net ctx td =- let is = td.tx.inputs- ps = I.toAscList td.prevs- as = map (scriptToAddressBS net ctx . (.script) . snd) ps- f (Right a) i = Just (a, i.outpoint)- f (Left _) _ = Nothing- in catMaybes (zipWith f as is)---- | Addresses and outpoints of the outputs of a transaction.-txOutputs :: Network -> Ctx -> TxData -> [(Address, OutPoint)]-txOutputs net ctx td =- let ps =- zipWith- ( \i _ ->- OutPoint- { hash = txHash td.tx,- index = i- }- )- [0 ..]- td.tx.outputs- as = map (scriptToAddressBS net ctx . (.script)) td.tx.outputs- f (Right a) p = Just (a, p)- f (Left _) _ = Nothing- in catMaybes (zipWith f as ps)---- | Redis command to read the last block processed by the cache.-redisGetHead :: (Functor f, RedisCtx m f) => m (f (Maybe BlockHash))-redisGetHead = do- x <- Redis.get bestBlockKey- return $ (eitherToMaybe . decode =<<) <$> x---- | Redis command to read the mempool set, newest first.-redisGetMempool :: (Applicative f, RedisCtx m f) => m (f [(UnixTime, TxHash)])-redisGetMempool = do- xs <- getFromSortedSet mempoolSetKey Nothing 0 0- return $ map (uncurry f) <$> xs- where- f t s = ((scoreBlockRef s).timestamp, t)---- | Text form of an xpub for logs, with a suffix for its address type.-xpubText ::- (StoreReadBase m) =>- XPubSpec ->- CacheX m Text-xpubText xpub = do- net <- lift getNetwork- let suffix = case xpub.deriv of- DeriveNormal -> ""- DeriveP2SH -> "/p2sh"- DeriveP2WPKH -> "/p2wpkh"- ctx <- lift getCtx- return . cs $ suffix <> xPubExport net ctx xpub.key---- | Tell the cache writer that a new block was imported.-cacheNewBlock :: (MonadIO m) => CacheWriter -> m ()-cacheNewBlock = send CacheNewBlock---- | Tell the cache writer that a mempool transaction was added or removed.-cacheNewTx :: (MonadIO m) => TxHash -> CacheWriter -> m ()-cacheNewTx = send . CacheNewTx---- | Ask the cache writer to sync the mempool, and wait until it is done.-cacheSyncMempool :: (MonadIO m) => CacheWriter -> m ()-cacheSyncMempool = query CacheSyncMempool+{-# LANGUAGE MultiWayIf #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TemplateHaskell #-}+{-# OPTIONS_GHC -Wno-ambiguous-fields #-}++-- |+-- Module : Haskoin.Store.Cache+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- Redis cache of extended public keys (xpubs), shared by several stores.+--+-- An xpub with many used addresses is slow to answer from the database: every+-- address has to be derived and read. Wallets of exchanges and payment+-- processors are like that, and are asked about all the time. The cache keeps+-- everything that the web server needs to answer about such an xpub in Redis:+-- the balances of its addresses up to the gap, its transactions, its unspent+-- outputs, and the bodies of its transactions with who spends their outputs.+--+-- = One source of truth+--+-- The data of a cached xpub always comes from the database, read with the+-- same functions that answer uncached queries+-- ("Haskoin.Store.Database.Query"), and is never computed from what the+-- cache guesses a change did, as the cache that this one replaced did, and+-- drifted. Whenever a block or a mempool transaction touches one of its+-- addresses, or a reorganisation changes one of its transactions, the store+-- that saw it reads from the database what that change affects, the+-- transactions and the outputs they create and spend, and writes the+-- differences in one atomic step ('cacheRefresh'). It reads the whole xpub+-- instead when it caches it, and whenever it cannot be sure that the entry+-- has every earlier change: checks against the balances of every address of+-- the xpub, and a full reload every ten minutes, keep a missed change from+-- staying. Reloads of one xpub are coalesced, so a burst of payments costs at+-- most one reload in progress and one waiting.+--+-- Everything a response needs is in Redis, including the bodies of the+-- transactions: a store that has not yet seen a transaction of a cached xpub+-- still serves it in full. Bodies are kept once per transaction and shared by+-- the xpubs that refer to them; the last xpub to let go of one deletes it.+-- Bodies are stored without the spenders of their outputs, which change+-- without touching the xpub (an output paying someone else is spent by a+-- transaction that has nothing to do with the xpub). The spenders are in a+-- separate hash per transaction, kept up to date by every store as it imports+-- transactions, and filled in when a body is read.+--+-- = Several stores, one Redis+--+-- Stores index the same chain at slightly different times, and see mempool+-- transactions in different orders. Three rules keep them from getting in+-- each other's way:+--+-- * __Each change is applied once.__ A store claims each change it sees, per+-- cached xpub that it touches (@ev:\<event\>:\<id\>@). The first store to+-- claim it reloads the xpub and marks the claim done; the others skip it. A+-- store that is catching up finds its events done and does no work, and a+-- store whose database does not have the best header ignores events+-- altogether.+--+-- * __No store writes what knows less than Redis.__ A store does not replace+-- an entry stamped with a higher block than its own best block, nor one+-- with a transaction that its database has never seen; it gives the change+-- up for a store that knows more. So stores never undo each other's work.+--+-- * __No entry refers to what Redis does not have.__ An entry is written in+-- one Lua script together with the bodies it needs, and the script writes+-- nothing if a body would be missing. A store that is behind can serve a+-- cached xpub in full, transactions it has not imported included. Bodies+-- that Redis already has are rewritten just before, in small scripts, so+-- that a large block does not hold Redis for long; a body may so be a+-- moment newer than an entry that has it, never older.+--+-- The first two rules are the pure decisions of+-- "Haskoin.Store.Cache.Protocol", where a model test checks them with several+-- stores catching up in random interleavings.+--+-- A lock per xpub keeps two stores from reloading it at the same time, and+-- every change of several keys of an entry is a Lua script, so readers never+-- see half of one. Stores that answer from Redis serve the same data; stores whose+-- databases disagree can still give different answers for xpubs that are not+-- cached.+--+-- = Keys+--+-- Every key starts with @hs2:\<network\>:@, so this cache can share a Redis with+-- the cache of older versions of the store, whose keys have no such prefix,+-- and with stores of other networks.+--+-- * @x:\<id\>:meta@: hash with the serialized xpub (@spec@), the height of+-- the best block it was read at (@height@), the format version (@ver@,+-- now 3), the generation of the entry (@gen@), which every reload+-- increments, and the time of its last full reload in milliseconds+-- (@full@).+-- @\<id\>@ is the hexadecimal of the first 16 bytes of the SHA-256 of the+-- serialized xpub.+-- * @x:\<id\>:bal@: balances of the addresses up to the gap, serialized.+-- * @x:\<id\>:txs@: sorted set of transaction hashes, scored by 'blockRefScore'.+-- * @x:\<id\>:utxo@: sorted set of serialized unspent outputs, scored the same.+-- * @x:\<id\>:uop@: hash of serialized outpoint to its member of @utxo@, so+-- that an output can be removed by its outpoint alone, though its member+-- changes with its block. Entries of version 2 have none; their next full+-- reload builds it.+-- * @x:\<id\>:addrs@: set of the addresses of the balances.+-- * @a:\<address\>@: set of the ids of the cached xpubs with this address.+-- * @tx:\<txid\>@: transaction body, serialized 'TxData' without spenders.+-- * @sp:\<txid\>@: hash of output index to spender.+-- * @txref:\<txid\>@: set of the ids of the cached xpubs with this transaction.+-- * @lru@: sorted set of ids by time of last use, for eviction.+-- * @lock:\<id\>@: lock held while an xpub is reloaded.+-- * @ev:\<event\>:\<id\>@: claim of a change of a cached xpub: @busy@ while a+-- store applies it, for two minutes at most, then @done@ for an hour.+module Haskoin.Store.Cache+ ( -- * Running the cache+ Cache,+ CacheConfig (..),+ withCache,+ cacheAlive,++ -- * Reading+ cacheXPubBals,+ cacheXPubTxs,+ cacheXPubTxCount,+ cacheXPubUnspents,+ cacheTxData,+ cacheIsCached,+ CacheCounters (..),+ cacheCounters,++ -- * Changing+ cacheRequest,+ Reload (..),+ cacheRefresh,+ cacheDelete,+ cacheEvict,++ -- * Internals++ -- | Exported for tests.+ cachePrefix,+ xpubId,+ blockRefScore,+ scoreBlockRef,+ fullReloadEvery,+ fullReloadAfter,+ )+where++import Control.Monad (forM, forM_, join, unless, void, when)+import Data.Bits (shift, (.&.), (.|.))+import Data.ByteString (ByteString)+import Data.ByteString qualified as BS+import Data.ByteString.Char8 qualified as C8+import Data.Either (rights)+import Data.FileEmbed (embedFile, makeRelativeToProject)+import Data.Functor ((<&>))+import Data.HashMap.Strict (HashMap)+import Data.HashMap.Strict qualified as HashMap+import Data.HashSet (HashSet)+import Data.HashSet qualified as HashSet+import Data.IntMap.Strict qualified as IntMap+import Data.List (nub)+import Data.List.NonEmpty qualified as NonEmpty+import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, maybeToList)+import Data.Serialize (decode, encode)+import Data.String.Conversions (cs)+import Data.Text (Text)+import Data.Time.Clock.POSIX (getPOSIXTime)+import Data.Word (Word32, Word64)+import Database.Redis (Connection, Redis, Reply)+import Database.Redis qualified as R+import Haskoin+ ( Address,+ BlockHash,+ BlockHeader (..),+ BlockNode (..),+ Network (..),+ OutPoint (..),+ Tx (..),+ TxHash,+ TxIn (..),+ TxOut (..),+ encodeHex,+ headerHash,+ scriptToAddressBS,+ sha256,+ txHash,+ )+import Haskoin.Node (Chain, LogLevel (..), Logger, chainGetBest, logAt)+import Haskoin.Store.Cache.Protocol+ ( Claim (..),+ OnClaim (..),+ OnReload (..),+ Reload (..),+ afterClaim,+ afterReload,+ mayReplace,+ )+import Haskoin.Store.Common (Limits (..), Start (..), StoreEvent (..), loopForever)+import Haskoin.Store.Data+import Haskoin.Store.Database.Query qualified as Q+import Haskoin.Store.Database.Query.Layered qualified as L+import Haskoin.Store.Database.Reader (DatabaseReader (..))+import NQE (Inbox, Publisher, receive, withSubscription)+import System.Metrics.StatsD+ ( StatCounter,+ StatTiming,+ Stats,+ addTiming,+ incrementCounter,+ newStatCounter,+ newStatTiming,+ )+import System.Random (randomIO)+import Text.Read (readMaybe)+import UnliftIO+ ( IORef,+ TVar,+ atomicModifyIORef',+ atomically,+ checkSTM,+ handleAny,+ link,+ modifyTVar',+ newIORef,+ newTVarIO,+ pooledForConcurrentlyN_,+ readIORef,+ readTVar,+ timeout,+ tryAny,+ withAsync,+ writeIORef,+ writeTVar,+ )+import UnliftIO.Concurrent (forkIO, threadDelay)++-- | Settings of the cache.+data CacheConfig = CacheConfig+ { -- | Redis URL, such as @redis:\/\/localhost:6379@; empty for the default+ url :: !String,+ -- | cache xpubs with at least this many used addresses+ minAddrs :: !Int,+ -- | or with at least this many transactions in their addresses+ minTxs :: !Int,+ -- | evict the least recently used xpubs past this many+ maxXPubs :: !Int,+ -- | StatsD client, to report the metrics of the cache+ stats :: !(Maybe Stats)+ }++-- | StatsD metrics of the cache.+data CacheMetrics = CacheMetrics+ { -- | xpubs answered from the cache (@cache.hits@)+ hits :: !StatCounter,+ -- | xpubs asked about that were not cached (@cache.misses@)+ misses :: !StatCounter,+ -- | xpubs read again from the database and written (@cache.reloads@)+ reloads :: !StatCounter,+ -- | reloads refused because the cache had newer data (@cache.stale@)+ stale :: !StatCounter,+ -- | xpubs evicted (@cache.evictions@)+ evictions :: !StatCounter,+ -- | reloads that read the whole xpub rather than what changed+ -- (@cache.full_reloads@)+ fullReloads :: !StatCounter,+ -- | milliseconds to reload an xpub (@cache.reload@)+ reloadTime :: !StatTiming+ }++-- | Register the metrics of the cache.+newCacheMetrics :: Stats -> IO CacheMetrics+newCacheMetrics s = do+ hits <- c "hits"+ misses <- c "misses"+ reloads <- c "reloads"+ stale <- c "stale"+ evictions <- c "evictions"+ fullReloads <- c "full_reloads"+ reloadTime <- newStatTiming s "cache.reload" 10+ return CacheMetrics {..}+ where+ c x = newStatCounter s ("cache." <> x) 10++-- | Read the database through a view of it as it is now: the mempool is+-- kept apart from the chain and laid over it when read, and the two have to+-- be read as they were at one moment+-- ("Haskoin.Store.Database.Query.Layered"). Transactions that left the+-- mempool unconfirmed are among what these reads give, marked as deleted:+-- the cache has to hear of them to take them out.+viewed :: Cache -> (L.View -> IO a) -> IO a+viewed c = L.withView c.db++-- | A running cache.+data Cache = Cache+ { redis :: !Connection,+ prefix :: !ByteString,+ db :: !DatabaseReader,+ chain :: !Chain,+ minAddrs :: !Int,+ minTxs :: !Int,+ maxXPubs :: !Int,+ logger :: !Logger,+ metrics :: !(Maybe CacheMetrics),+ -- | what this store did to the cache since it started+ counters :: !(IORef CacheCounters),+ -- | work for the worker, newest first+ jobs :: !(TVar [Job]),+ -- | when this store last marked each xpub as used, by id, in+ -- milliseconds; see 'touchEvery'+ touched :: !(IORef (HashMap ByteString Integer)),+ -- | transactions that this store deleted since it started, as far as+ -- the cache heard of them, up to a hundred thousand. The database+ -- keeps nothing of a deleted transaction, so without this one that+ -- Redis has and the database does not could as well be one that this+ -- store has not seen yet, which an entry must not lose.+ lost :: !(IORef (HashSet TxHash))+ }++-- | What a store did to the cache since it started.+data CacheCounters = CacheCounters+ { -- | xpubs read again from the database and written+ reloads :: !Int,+ -- | reloads given up because Redis knew more+ behind :: !Int,+ -- | reloads that read the whole xpub rather than what changed+ fullReloads :: !Int+ }+ deriving (Eq, Show)++-- | What this store did to the cache since it started.+cacheCounters :: Cache -> IO CacheCounters+cacheCounters c = readIORef c.counters++-- | Work for the worker of the cache.+data Job+ = -- | an event of the store, which may change cached xpubs+ JobEvent !StoreEvent+ | -- | cache an xpub that was asked about+ JobCache !XPubSpec+ | -- | try again to claim changes of an xpub that another store was busy+ -- with: the transactions that changed it, and the claims+ JobClaim !XPubSpec !(HashSet TxHash) ![ByteString]+ | -- | reload an xpub for changes whose claims this store holds+ JobHeld !XPubSpec !(HashSet TxHash) ![ByteString]++-- | Connect to Redis and run the process that keeps the cache up to date with+-- the events of the store, while an action runs.+withCache ::+ CacheConfig ->+ DatabaseReader ->+ Chain ->+ Publisher StoreEvent ->+ Logger ->+ (Cache -> IO a) ->+ IO a+withCache cfg db chain pub logger action = do+ info <-+ if null cfg.url+ then return R.defaultConnectInfo+ else either fail return (R.parseConnectInfo cfg.url)+ conn <- R.checkedConnect info+ jobs <- newTVarIO []+ counters <- newIORef (CacheCounters 0 0 0)+ touched <- newIORef HashMap.empty+ lost <- newIORef HashSet.empty+ metrics <- mapM newCacheMetrics cfg.stats+ let cache =+ Cache+ { redis = conn,+ prefix = cachePrefix db.net,+ db = db,+ chain = chain,+ minAddrs = cfg.minAddrs,+ minTxs = cfg.minTxs,+ maxXPubs = cfg.maxXPubs,+ logger = logger,+ metrics = metrics,+ counters = counters,+ jobs = jobs,+ touched = touched,+ lost = lost+ }+ withSubscription pub $ \events ->+ withAsync (listener cache events) $ \a1 ->+ withAsync (worker cache) $ \a2 -> do+ link a1+ link a2+ action cache++-- | Prefix of every key of the cache for a network.+cachePrefix :: Network -> ByteString+cachePrefix net = "hs2:" <> C8.pack net.name <> ":"++-- | Identifier of an xpub in the keys.+xpubId :: XPubSpec -> ByteString+xpubId = cs . encodeHex . BS.take 16 . encode . sha256 . encode++xkey :: Cache -> XPubSpec -> ByteString -> ByteString+xkey c x suffix = c.prefix <> "x:" <> xpubId x <> ":" <> suffix++txKey :: Cache -> TxHash -> ByteString+txKey c h = c.prefix <> "tx:" <> encode h++spKey :: Cache -> TxHash -> ByteString+spKey c h = c.prefix <> "sp:" <> encode h++addrKey :: Cache -> Address -> ByteString+addrKey c a = c.prefix <> "a:" <> encode a++lruKey :: Cache -> ByteString+lruKey c = c.prefix <> "lru"++-- | Count something in the metrics, if they are enabled.+count :: Cache -> (CacheMetrics -> StatCounter) -> Int -> IO ()+count c f n = mapM_ (\m -> incrementCounter (f m) n) c.metrics++log' :: Cache -> LogLevel -> Text -> IO ()+log' c l = logAt l c.logger++-- | Whether Redis answers a ping within two seconds. The health check of+-- the web server asks, so that a store whose Redis is gone says that it is+-- answering from its own database alone.+cacheAlive :: Cache -> IO Bool+cacheAlive c =+ handleAny (\_ -> return False) $+ timeout 2000000 (R.runRedis c.redis R.ping) <&> \case+ Just (Right _) -> True+ _ -> False++-- | Run Redis commands, throwing if Redis answers with an error.+run :: Cache -> Redis (Either Reply a) -> IO a+run c f =+ R.runRedis c.redis f >>= \case+ Right x -> return x+ Left e -> fail ("Redis error: " <> show e)++-------------------------------------------------------------------------------+-- Scores+-------------------------------------------------------------------------------++-- | Score of a position in a Redis sorted set, such that sorting by score+-- ascending sorts from newest to oldest. Mempool transactions get negative+-- scores from their time, so they come first; confirmed ones get positive+-- scores that decrease with height and position. Height and position are+-- packed into the 53 bits that a double holds exactly.+--+-- Between equal scores Redis orders members by their bytes, which for+-- transaction hashes and unspent outputs is the order that the database uses+-- ('Haskoin.Store.Common.txRefKey', 'Haskoin.Store.Common.unspentKey'), so a+-- cached page is the page the database gives.+blockRefScore :: BlockRef -> Double+blockRefScore BlockRef {height = h, position = p} =+ fromIntegral (0x001fffffffffffff - (h' .|. p'))+ where+ h' = (fromIntegral h .&. 0x07ffffff) `shift` 26 :: Word64+ p' = (fromIntegral p .&. 0x03ffffff) :: Word64+blockRefScore MemRef {timestamp = t} = negate t'+ where+ t' = fromIntegral (t .&. 0x001fffffffffffff)++-- | Inverse of 'blockRefScore'.+scoreBlockRef :: Double -> BlockRef+scoreBlockRef s+ | s < 0 = MemRef {timestamp = n}+ | otherwise = BlockRef {height = h, position = p}+ where+ n = truncate (abs s) :: Word64+ m = 0x001fffffffffffff - n+ h = fromIntegral (m `shift` (-26))+ p = fromIntegral (m .&. 0x03ffffff)++-------------------------------------------------------------------------------+-- Reading+-------------------------------------------------------------------------------++-- | Balances of a cached xpub, or 'Nothing' if it is not cached. Marks the+-- xpub as used. An entry that cannot be decoded is deleted and counts as not+-- cached.+cacheXPubBals :: Cache -> XPubSpec -> IO (Maybe [XPubBal])+cacheXPubBals c x = do+ touch <- touchArgs c x+ r <- run c $ do+ b <- R.get (xkey c x "bal")+ -- marking an xpub that is not cached is harmless: eviction removes it+ forM_ touch $ \now -> R.zadd (lruKey c) [(fromIntegral now, xpubId x)]+ return b+ case r of+ Nothing -> count c (.misses) 1 >> return Nothing+ Just bs -> case decode bs of+ Right bals -> count c (.hits) 1 >> return (Just bals)+ Left _ -> do+ count c (.misses) 1+ log' c LevelWarn "Cached xpub balances cannot be decoded; deleting them"+ void (cacheDelete c x)+ return Nothing++-- | Whether an xpub is cached.+cacheIsCached :: Cache -> XPubSpec -> IO Bool+cacheIsCached c x = run c (R.exists (xkey c x "meta"))++-- | Number of transactions of a cached xpub.+cacheXPubTxCount :: Cache -> XPubSpec -> IO (Maybe Word32)+cacheXPubTxCount c x = do+ touch <- touchArgs c x+ -- sent together, in one round trip+ (cached, n) <- run c $ do+ e <- R.exists (xkey c x "meta")+ z <- R.zcard (xkey c x "txs")+ forM_ touch $ \now -> R.zadd (lruKey c) [(fromIntegral now, xpubId x)]+ return ((,) <$> e <*> z)+ return (if cached then Just (fromIntegral n) else Nothing)++-- | Page of the transactions of a cached xpub, newest first, as the database+-- would give it.+cacheXPubTxs :: Cache -> XPubSpec -> Limits -> IO (Maybe [TxRef])+cacheXPubTxs c x limits = do+ xs <- page c x "txs" limits (\th -> (0, encode th))+ return $ xs <&> \ys -> [TxRef {txid = t, block = scoreBlockRef sc} | (bs, sc) <- ys, Right t <- [decode bs]]++-- | Page of the unspent outputs of a cached xpub, newest first, given its+-- balances, which give the paths of the addresses.+cacheXPubUnspents :: Cache -> XPubSpec -> [XPubBal] -> Limits -> IO (Maybe [XPubUnspent])+cacheXPubUnspents c x bals limits = do+ -- members are serialized unspent outputs: their block position, which is+ -- nine bytes long, then their output point+ xs <- page c x "utxo" limits (\th -> (9, encode (OutPoint th 0)))+ let paths = HashMap.fromList [(b.balance.address, b.path) | b <- bals]+ return $+ xs <&> \ys ->+ [ XPubUnspent {unspent = u, path = p}+ | (bs, _) <- ys,+ Right u <- [decode bs],+ Just a <- [u.address],+ Just p <- [HashMap.lookup a paths]+ ]++-- | How often a store marks an xpub as used, in milliseconds. Marking it on+-- every read would make every read a write; eviction only needs to know+-- which xpubs were used recently.+touchEvery :: Integer+touchEvery = 10000++-- | The time to mark an xpub as used with, if this store has not marked it+-- in the last 'touchEvery' milliseconds.+touchArgs :: Cache -> XPubSpec -> IO (Maybe Integer)+touchArgs c x = do+ now <- nowMs+ atomicModifyIORef' c.touched $ \m ->+ case HashMap.lookup (xpubId x) m of+ Just t | now - t < touchEvery -> (m, Nothing)+ _ ->+ -- forget everything now and then, so that the map stays small+ let m' = if HashMap.size m > 100000 then HashMap.empty else m+ in (HashMap.insert (xpubId x) now m', Just now)++-- | A page of a sorted set of a cached xpub, with the scores of its members,+-- or 'Nothing' if the xpub is not cached. One round trip to Redis: the+-- script finds where the page starts, as the database would, and marks the+-- xpub as used if it is time to.+--+-- A page that starts at a transaction starts at its position, which is the+-- score of the transaction in the sorted set of transactions of the xpub.+-- Members of the same score whose bytes, after a number of bytes, sort+-- before the bytes that the function gives for the transaction are skipped,+-- as the database skips them. A start transaction that is not in the xpub is+-- looked up in Redis or in the database, in a second round trip; a page+-- with a start that is found nowhere is empty.+page ::+ Cache ->+ XPubSpec ->+ ByteString ->+ Limits ->+ (TxHash -> (Int, ByteString)) ->+ IO (Maybe [(ByteString, Double)])+page c x set limits tieBytes = do+ touch <- touchArgs c x+ let common =+ [ xkey c x "meta",+ xkey c x set,+ xkey c x "txs",+ lruKey c,+ maybe "" num touch,+ xpubId x,+ num limits.offset,+ num limits.limit+ ]+ call extra = run c (R.eval pageScript [] (common <> extra))+ r <- case limits.start of+ Nothing -> call ["top"]+ Just (AtBlock h) -> call ["score", num' (blockRefScore (BlockRef h maxBound))]+ Just (AtTx th) -> do+ let (skip, bytes) = tieBytes th+ call ["tx", encode th, num skip, bytes] >>= \case+ ["nostart"] ->+ startOf th >>= \case+ Nothing -> return ["ok"]+ Just ref -> call ["at", num' (blockRefScore ref), num skip, bytes]+ y -> return y+ case r of+ "ok" : rest -> return (Just (pairs rest))+ _ -> return Nothing+ where+ pairs (m : sc : rest) | Just d <- readMaybe (C8.unpack sc) = (m, d) : pairs rest+ pairs _ = []+ startOf th =+ cacheTxData c [th] >>= \case+ [Just td] -> return (Just td.block)+ _ -> fmap (.block) <$> viewed c (\v -> L.getTxData v th)++-- | Bodies of transactions that Redis has, in the order of the hashes, with+-- the spenders of their outputs filled in. 'Nothing' for those it does not+-- have.+cacheTxData :: Cache -> [TxHash] -> IO [Maybe TxData]+cacheTxData _ [] = return []+cacheTxData c hs = do+ bodies <- run c (R.mget (NonEmpty.fromList (map (txKey c) hs)))+ spenders <- run c $ do+ rs <- forM (zip hs bodies) $ \(h, b) ->+ if isJust b then fmap Just <$> R.hgetall (spKey c h) else return (pure Nothing)+ return (sequence rs)+ return (zipWith fill bodies spenders)+ where+ fill (Just b) sp+ | Right td <- decode b =+ let ss =+ IntMap.fromList+ [ (fromIntegral i, s)+ | (k, v) <- fromMaybe [] sp,+ Right (i :: Word32) <- [decode k],+ Right s <- [decode v]+ ]+ in Just (td :: TxData) {spenders = ss}+ fill _ _ = Nothing++-------------------------------------------------------------------------------+-- Changing+-------------------------------------------------------------------------------++-- | Cache an xpub that was just read from the database, if it has enough+-- used addresses, or enough transactions: a wallet that reuses a few+-- addresses for many payments is as slow to read from the database as one+-- with many addresses. Transactions are counted per address, so one that+-- touches two addresses counts twice, which is close enough to decide. The+-- xpub is loaded in the background; the caller keeps the answer it has.+cacheRequest :: Cache -> XPubSpec -> [XPubBal] -> IO ()+cacheRequest c x bals =+ when (length used >= c.minAddrs || sum (map (.balance.txs) used) >= fromIntegral c.minTxs) $+ push c (JobCache x)+ where+ used = filter (not . nullBalance . (.balance)) bals++-- | Give the worker something to do.+push :: Cache -> Job -> IO ()+push c j = atomically (modifyTVar' c.jobs (j :))++-- | Give the worker something to do in two seconds.+later :: Cache -> Job -> IO ()+later c j = void . forkIO $ threadDelay 2000000 >> push c j++-- | Remove an xpub from the cache. Returns whether it was cached.+cacheDelete :: Cache -> XPubSpec -> IO Bool+cacheDelete c x = do+ r <- run c (R.eval deleteScript [] [c.prefix, xpubId x])+ return (r == (1 :: Integer))++-- | Evict the least recently used xpubs until at most the given number are+-- cached. Returns how many were evicted. The cache does this by itself after+-- caching an xpub, down to its configured maximum.+cacheEvict :: Cache -> Int -> IO Int+cacheEvict c n = do+ evicted <- fromInteger <$> run c (R.eval evictScript [] [c.prefix, num n])+ count c (.evictions) evicted+ return evicted++-- | Bring the entry of an xpub in Redis up to date with the database, unless+-- another store holds its lock or Redis knows more than this store (see+-- 'Reload'). The transactions given are the ones that changed it: their+-- bodies are written again even if Redis has them.+--+-- A store never writes data that knows less than what is in Redis, so stores+-- that see the chain and the mempool at slightly different times do not undo+-- each other's work. Every body that the entry refers to is in Redis before+-- the entry is: the changes and the missing bodies are written in one Lua+-- script, which writes nothing if a body is missing, so a store that is+-- behind never finds a cached transaction that it cannot serve. Only the+-- differences are written, and the script writes nothing if the entry+-- changed since it was read (its generation), so Redis is held for the size+-- of the change, not of the xpub. Bodies that Redis already has, such as+-- those of the transactions that a block confirms, are rewritten first, in+-- small scripts ('rewriteBodies'), so that the confirmation of a large block+-- does not hold Redis in one long script.+--+-- = Reading only what changed+--+-- Working out the differences can take one of two paths.+--+-- * __Incremental__ ('incrementalDiff'): only the transactions that changed+-- are read, with the outputs that they create and spend at addresses of+-- the xpub. The stress test (@stress\/results@) showed that reading the+-- whole xpub from both the database and Redis was almost all the time of a+-- reload, growing with the size of the xpub to seconds at 100,000+-- transactions; the changes of one block or transaction are a few reads,+-- whatever the size of the xpub.+--+-- * __Full__: the whole xpub is read from the database and from Redis, and+-- compared. This is what a first caching does, and the fallback whenever+-- the incremental path cannot be sure of its answer.+--+-- The incremental path relies on every change of the xpub having been+-- applied to the entry, which is what the claims of events are for, but a+-- store can miss events: while it catches up, after a restart, or after an+-- error. So it is only taken when the entry shows no sign of having missed+-- one, and it checks its own result:+--+-- * the entry is in the current format, stamped with the best block of this+-- store or the one before it (a larger gap means blocks it may have+-- missed), and was fully reloaded less than 'fullReloadAfter' ago;+--+-- * the balances of every address of the xpub are read from the database,+-- which is cheap, a read per address; an address that no changed+-- transaction touches must have the balance that Redis has. Under load it+-- often does not, because the database is already ahead of the events+-- being applied, so the newer transactions of those addresses are added to+-- the changes first ('catchUp'), and only a balance that they do not+-- explain means a change was missed;+--+-- * no address with transactions enters or leaves the gap, as its history+-- would have to be read whole.+--+-- Otherwise the full path runs. A missed change that leaves every balance+-- as it was, such as the confirmation of a transaction that only spends from+-- the xpub, is the one thing the checks cannot see, and the regular full+-- reload bounds how long it can last.+cacheRefresh :: Cache -> XPubSpec -> HashSet TxHash -> IO Reload+cacheRefresh c x changed =+ fromMaybe Locked <$> withLock c x (timed (go (3 :: Int) []))+ where+ go tries extra = do+ m <- readMeta c x+ height <- bestHeight c+ now <- nowMs+ if not (mayReplace m.stamp height False)+ then behind+ else do+ bals <- viewed c (\v -> L.xPubBals v x)+ fast <-+ if incremental m height now+ then incrementalDiff c x changed m bals+ else return Fallback+ diff <- case fast of+ Apply d -> return (Just d)+ KnowsMore -> return Nothing+ Fallback -> fullDiff c x changed m height bals now+ case diff of+ Nothing -> behind+ Just d -> commit tries extra m height now bals d+ commit tries extra m height now bals d = do+ bodies <- catMaybes <$> viewed c (\v -> L.getManyTxData v (nub (extra <> d.wanted))) >>= rewriteBodies c+ let args =+ [c.prefix, xpubId x, num height, num now, encode x, encode bals, m.gen]+ <> [if d.full then num now else ""]+ <> counted d.addrAdd+ <> counted d.addrDel+ <> counted2 [(t, num' sc) | (t, sc) <- d.txAdd]+ <> counted d.txDel+ <> counted3 [(op, u, num' sc) | (op, u, sc) <- d.utxoAdd]+ <> counted2 d.utxoDel+ <> bodyArgs bodies+ run c (R.eval commitScript [] args) >>= \case+ ["ok"] -> do+ void (cacheEvict c c.maxXPubs)+ count c (.reloads) 1+ when d.full $ count c (.fullReloads) 1+ atomicModifyIORef' c.counters $ \k ->+ ( k {reloads = k.reloads + 1, fullReloads = k.fullReloads + fromEnum d.full} :: CacheCounters,+ ()+ )+ return Reloaded+ ("missing" : missing)+ | tries > 0 -> go (tries - 1) (rights (map decode missing))+ ["changed"]+ | tries > 0 -> go (tries - 1) extra+ _ -> behind+ -- whether the entry may be brought up to date incrementally+ incremental m height now =+ m.ver == "3"+ && not (HashSet.null changed)+ && maybe False (\s -> toInteger height - s <= 1) m.stamp+ && maybe False (\f -> now - f < fullReloadAfter x) m.full+ behind = do+ count c (.stale) 1+ atomicModifyIORef' c.counters (\k -> (k {behind = k.behind + 1} :: CacheCounters, ()))+ return Behind+ timed f = do+ start <- nowMs+ r <- f+ end <- nowMs+ forM_ c.metrics $ \m -> addTiming m.reloadTime (fromInteger (end - start))+ return r++-- | Longest time, in milliseconds, between full reloads of an entry: ten+-- minutes; each xpub has its own interval up to this ('fullReloadAfter'). It+-- bounds how long a change that the checks of the incremental path cannot+-- see stays out of the cache. A full reload of the largest xpubs of the+-- stress test takes a few seconds and holds the xpub in memory while it+-- runs, so it is kept rare.+fullReloadEvery :: Integer+fullReloadEvery = 10 * 60 * 1000++-- | Time, in milliseconds, after its last full reload when an entry is read+-- whole again: between half of 'fullReloadEvery' and all of it, the same for+-- an xpub on every store, and different from one xpub to the next.+--+-- With one interval for every entry, entries cached together are reloaded+-- whole together, every time. In the full stress test the four xpubs did so+-- every ten minutes, and the heap of the server went from under 100 MiB to+-- 785 MiB for a few seconds, holding all four at once. Each xpub gets its own+-- interval instead, so such entries drift apart from their first scheduled+-- full reload. It comes from the hash of the xpub, not from a random number,+-- so that it needs no state and every store sharing Redis agrees on it.+fullReloadAfter :: XPubSpec -> Integer+fullReloadAfter x = half + spread `mod` half+ where+ half = fullReloadEvery `div` 2+ spread = BS.foldl' (\a w -> a * 256 + toInteger w) 0 (BS.take 4 (encode (sha256 (encode x))))++-- | What to write to bring an entry up to date. Members of the sets are+-- serialized: addresses, transaction hashes, and unspent outputs, which go+-- with their outpoints.+data Diff = Diff+ { addrAdd :: ![ByteString],+ addrDel :: ![ByteString],+ -- | transactions to add or move, with their scores+ txAdd :: ![(ByteString, Double)],+ txDel :: ![ByteString],+ -- | unspent outputs to add, with their outpoints and scores+ utxoAdd :: ![(ByteString, ByteString, Double)],+ -- | outpoints of unspent outputs to remove, with the member if known; the+ -- script finds it by its outpoint otherwise+ utxoDel :: ![(ByteString, ByteString)],+ -- | transactions whose bodies to write+ wanted :: ![TxHash],+ -- | whether the whole xpub was read, which the entry records+ full :: !Bool+ }++-- | The outcome of trying the incremental path.+data Incremental+ = -- | these are the differences+ Apply !Diff+ | -- | Redis has a changed transaction that this store has never seen+ KnowsMore+ | -- | the incremental path cannot be sure; read the whole xpub+ Fallback++-- | The differences of an entry from the transactions that changed and the+-- balances of the xpub, without reading its history; see 'cacheRefresh'.+--+-- When the balances show that the database knows of changes beyond the ones+-- given, the newest transactions of the addresses whose balances moved are+-- added to them ('catchUp'), and the differences are worked out again; only+-- if that does not account for every balance does it fall back.+incrementalDiff :: Cache -> XPubSpec -> HashSet TxHash -> Meta -> [XPubBal] -> IO Incremental+incrementalDiff c x changed m bals =+ case (decode m.bal :: Either String [XPubBal], m.stamp) of+ (Right old, Just stamp) ->+ incrementalFrom c x old bals changed >>= \case+ Missed missed ->+ catchUp c x stamp missed >>= \case+ Just extra+ | not (extra `HashSet.isSubsetOf` changed) ->+ incrementalFrom c x old bals (changed <> extra) <&> \case+ Missed _ -> Fallback+ Settled r -> r+ _ -> return Fallback+ Settled r -> return r+ _ -> return Fallback++-- | The outcome of working out the differences from a set of changes.+data Attempt+ = -- | the balances of these addresses moved, but no change touches them+ Missed ![Address]+ | Settled !Incremental++-- | The differences of an entry from its old balances, its new ones, and the+-- transactions that changed it.+incrementalFrom :: Cache -> XPubSpec -> [XPubBal] -> [XPubBal] -> HashSet TxHash -> IO Attempt+incrementalFrom c x old bals changed = do+ let hashes = HashSet.toList changed+ stored <- viewed c (\v -> L.getManyTxData v hashes)+ -- those that the database does not have because this store deleted+ -- them count as known, and as deleted+ gone <- readIORef c.lost+ dropped <- lostTxs c [h | (h, Nothing) <- zip hashes stored, HashSet.member h gone]+ let found = HashMap.fromList [(txHash td.tx, td) | td <- dropped]+ tds = [maybe (HashMap.lookup h found) Just td | (h, td) <- zip hashes stored]+ let newMap = HashMap.fromList [(b.balance.address, b.balance) | b <- bals]+ oldMap = HashMap.fromList [(b.balance.address, b.balance) | b <- old]+ entering = [b | (a, b) <- HashMap.toList newMap, not (HashMap.member a oldMap)]+ leaving = [b | (a, b) <- HashMap.toList oldMap, not (HashMap.member a newMap)]+ known = catMaybes tds+ unknown = [h | (h, Nothing) <- zip hashes tds]+ ours a = HashMap.member a newMap+ touched = HashSet.fromList [a | td <- known, a <- txAddresses c td, ours a]+ missed =+ [ a+ | (a, b) <- HashMap.toList newMap,+ Just o <- [HashMap.lookup a oldMap],+ o /= b,+ not (HashSet.member a touched)+ ]+ if+ | any (\b -> b.txs > 0) (entering <> leaving) -> return (Settled Fallback)+ | not (null missed) -> return (Missed missed)+ | otherwise -> do+ inRedis <- run c (sequence <$> mapM (R.zscore (xkey c x "txs") . encode) unknown)+ if any isJust inRedis+ then return (Settled KnowsMore)+ else do+ let active td = not td.deleted && any ours (txAddresses c td)+ ops = nub (concatMap (outpointsAt c ours) known)+ us <- viewed c (\v -> mapM (L.getUnspent v) ops)+ return . Settled . Apply $+ Diff+ { addrAdd = [encode b.address | b <- entering],+ addrDel = [encode b.address | b <- leaving],+ txAdd = [(encode (txHash td.tx), blockRefScore td.block) | td <- known, active td],+ txDel = [encode (txHash td.tx) | td <- known, not (active td)],+ utxoAdd = [(encode op, encode u, blockRefScore u.block) | (op, Just u) <- zip ops us],+ utxoDel = [(encode op, "") | (op, Nothing) <- zip ops us],+ wanted = [txHash td.tx | td <- known, active td],+ full = False+ }++-- | Changes of an entry that the database has and that it was not told of:+-- what a store finds when its database runs ahead of the events it is+-- applying, as it does under load, when the balances it reads after a batch+-- already include the transactions of the next. 'Nothing' if there are more+-- than 'catchUpMost', when reading the whole xpub is as cheap.+--+-- The entry is stamped with a block that this store had whole, so what can+-- have changed since is:+--+-- * transactions in the mempool or in a later block, which are first in the+-- index of an address, newest first, and are read from it for each address+-- whose balance moved, down to the stamp;+--+-- * transactions in the mempool when the entry was written that have been+-- confirmed or dropped since, found from the mempool transactions of the+-- entry, which are few.+--+-- A reorganisation that replaces the block of the stamp is not covered, and+-- the balances catch it: if the transactions found do not account for every+-- balance, the full path runs.+catchUp :: Cache -> XPubSpec -> Integer -> [Address] -> IO (Maybe (HashSet TxHash))+catchUp c x stamp addrs = do+ newer <- sequence <$> mapM addressNewer addrs+ pool <- run c (R.zrangebyscoreLimit (xkey c x "txs") (-1 / 0) (-0.5) 0 (toInteger catchUpMost + 1))+ let poolHashes = rights (map decode pool)+ tds <- viewed c (\v -> L.getManyTxData v poolHashes)+ let settled = [h | (h, td) <- zip poolHashes tds, maybe True left td]+ found = HashSet.fromList (settled <> concat (fromMaybe [] newer))+ return $+ if isNothing newer || length pool > catchUpMost || HashSet.size found > catchUpMost+ then Nothing+ else Just found+ where+ -- a mempool transaction of the entry that is no longer in the mempool+ left td = td.deleted || not (isMem td.block)+ isMem MemRef {} = True+ isMem BlockRef {} = False+ after t = case t.block of+ MemRef {} -> True+ BlockRef {height = h} -> toInteger h > stamp+ chunk = 100 :: Word32+ addressNewer a = loop 0 []+ where+ loop off acc = do+ ts <- viewed c (\v -> L.getAddressTxs v a Limits {limit = chunk, offset = off, start = Nothing})+ let (new, rest) = span after ts+ acc' = acc <> map (.txid) new+ if+ | length acc' > catchUpMost -> return Nothing+ | not (null rest) || length ts < fromIntegral chunk -> return (Just acc')+ | otherwise -> loop (off + chunk) acc'++-- | Most transactions that 'catchUp' adds to the changes of a reload. Each+-- costs a few reads; a full reload of the xpubs of the stress test costs+-- tens of thousands.+catchUpMost :: Int+catchUpMost = 1000++-- | The differences of an entry from the whole xpub, read from the database+-- and from Redis. 'Nothing' if Redis has a transaction of the xpub that this+-- store has never seen.+fullDiff :: Cache -> XPubSpec -> HashSet TxHash -> Meta -> Word32 -> [XPubBal] -> Integer -> IO (Maybe Diff)+fullDiff c x changed m height bals _ = do+ st <- readStored c x+ txs <- viewed c (\v -> L.xPubTxs v bals def')+ let newTxs = [(encode t.txid, blockRefScore t.block) | t <- txs]+ newTxSet = HashSet.fromList (map fst newTxs)+ txDel = [t | t <- HashMap.keys st.txs, not (HashSet.member t newTxSet)]+ -- a transaction that Redis has and this store has never seen. The store+ -- keeps nothing of a transaction that it deleted, so one that it does+ -- not have is taken for deleted, and not for unseen, if the cache heard+ -- of its deletion, or if the entry has it as unconfirmed for more than+ -- ten minutes: a store that is in step with the others has seen a+ -- transaction of the mempool by then, and one that is not does not get+ -- here.+ let asked = [(h, sc) | t <- txDel, Right h <- [decode t], Just sc <- [HashMap.lookup t st.txs]]+ known <- viewed c (\v -> L.getManyTxData v (map fst asked))+ gone <- readIORef c.lost+ now <- (`div` 1000) <$> nowMs+ let stale sc = sc < 0 && fromIntegral now - negate sc > 600+ unseen = [h | ((h, sc), Nothing) <- zip asked known, not (HashSet.member h gone), not (stale sc)]+ if not (mayReplace m.stamp height (not (null unseen)))+ then return Nothing+ else do+ utxos <- viewed c (\v -> L.xPubUnspents v bals def')+ let txAdd = [(t, sc) | (t, sc) <- newTxs, HashMap.lookup t st.txs /= Just sc]+ newUs = [(encode u.unspent.outpoint, encode u.unspent, blockRefScore u.unspent.block) | u <- utxos]+ newUSet = HashSet.fromList [u | (_, u, _) <- newUs]+ -- an entry of an older format has no index of outpoints yet, so+ -- every output is written, to build it+ utxoAdd+ | m.ver == "3" = [n | n@(_, u, _) <- newUs, not (HashSet.member u st.utxos)]+ | otherwise = newUs+ utxoDel =+ [ (either (const "") (encode . (.outpoint)) (decode u :: Either String Unspent), u)+ | u <- HashSet.toList st.utxos,+ not (HashSet.member u newUSet)+ ]+ newAs = HashSet.fromList [encode b.balance.address | b <- bals]+ return . Just $+ Diff+ { addrAdd = [a | a <- HashSet.toList newAs, not (HashSet.member a st.addrs)],+ addrDel = [a | a <- HashSet.toList st.addrs, not (HashSet.member a newAs)],+ txAdd,+ txDel,+ utxoAdd,+ utxoDel,+ -- bodies of the transactions that are new or moved, and of those+ -- that changed; the others are in Redis, kept there by this entry+ wanted =+ nub $+ [h | (t, _) <- txAdd, Right h <- [decode t]]+ <> [t.txid | t <- txs, t.txid `HashSet.member` changed],+ full = True+ }+ where+ def' = Limits {limit = 0, offset = 0, start = Nothing}++-- | Addresses that a transaction spends from or pays to.+txAddresses :: Cache -> TxData -> [Address]+txAddresses c td =+ nub . rights $+ map (scriptToAddressBS c.db.net c.db.ctx . (.script)) (IntMap.elems td.prevs)+ <> map (scriptToAddressBS c.db.net c.db.ctx . (.script)) td.tx.outputs++-- | Outpoints whose unspent output a transaction may change at addresses that+-- pass a test: those it creates, and those it spends.+outpointsAt :: Cache -> (Address -> Bool) -> TxData -> [OutPoint]+outpointsAt c ours td =+ [OutPoint (txHash td.tx) i | (i, o) <- zip [0 ..] td.tx.outputs, at o.script]+ <> [ inp.outpoint+ | (i, inp) <- zip [0 :: Int ..] td.tx.inputs,+ Just prev <- [IntMap.lookup i td.prevs],+ at prev.script+ ]+ where+ at s = either (const False) ours (scriptToAddressBS c.db.net c.db.ctx s)++-- | What a reload needs to know of an entry first: the height it is stamped+-- with, its generation, which every commit increments, the version of its+-- format, the time of its last full reload, and its balances, serialized.+data Meta = Meta+ { stamp :: !(Maybe Integer),+ gen :: !ByteString,+ ver :: !ByteString,+ full :: !(Maybe Integer),+ bal :: !ByteString+ }++-- | Read what a reload needs to know of an entry, in one round trip.+readMeta :: Cache -> XPubSpec -> IO Meta+readMeta c x = do+ (fs, b) <- run c $ do+ fs <- R.hmget (xkey c x "meta") ("height" NonEmpty.:| ["gen", "ver", "full"])+ b <- R.get (xkey c x "bal")+ return ((,) <$> fs <*> b)+ let field k = join (lookup k (zip [0 :: Int ..] fs))+ number = (readMaybe . C8.unpack =<<)+ return+ Meta+ { stamp = number (field 0),+ gen = fromMaybe "" (field 1),+ ver = fromMaybe "" (field 2),+ full = number (field 3),+ bal = fromMaybe "" b+ }++-- | The sets of an entry, as a full reload compares them with the database.+data Stored = Stored+ { txs :: !(HashMap ByteString Double),+ utxos :: !(HashSet ByteString),+ addrs :: !(HashSet ByteString)+ }++-- | Read the sets of an entry. The sorted sets are read in chunks, so that+-- no command holds Redis for long however large the xpub. The chunks are not+-- read atomically, but nothing changes the entry while this store holds its+-- lock except deleting or evicting it, which changes its generation, and the+-- commit checks the generation.+readStored :: Cache -> XPubSpec -> IO Stored+readStored c x = do+ as <- run c (R.smembers (xkey c x "addrs"))+ ts <- chunks (\i j -> R.zrangeWithscores (xkey c x "txs") i j)+ us <- chunks (\i j -> R.zrange (xkey c x "utxo") i j)+ return+ Stored+ { txs = HashMap.fromList ts,+ utxos = HashSet.fromList us,+ addrs = HashSet.fromList as+ }+ where+ size = 5000 :: Integer+ chunks :: (Integer -> Integer -> Redis (Either Reply [a])) -> IO [a]+ chunks f = loop 0+ where+ loop i = do+ xs <- run c (f i (i + size - 1))+ if toInteger (length xs) < size then return xs else (xs <>) <$> loop (i + size)++-- | Rewrite the bodies that Redis already has, in scripts of 'bodyChunk'+-- bodies each, and give back the others, for the commit to write.+--+-- A block confirms many transactions of a busy xpub at once, and each needs+-- its body written again, with its block and spenders. In one script, with+-- the scores and outputs of the entry, that held Redis for 10 to 30 ms at+-- the confirmation of each block in the stress test, while every other+-- client waited. Bodies are shared by every entry that has the transaction,+-- and are written whole from the database, so writing them before the entry+-- is safe: a body is never older than the entry that refers to it, which is+-- what matters (see 'cacheRefresh'), and other entries already see bodies+-- written by commits of other xpubs before their own. Only bodies that are+-- there are rewritten, so that a commit that then fails leaves nothing+-- behind; and if one goes between these scripts and the commit, the commit+-- reports it missing and is retried with it.+rewriteBodies :: Cache -> [TxData] -> IO [TxData]+rewriteBodies c tds =+ fmap concat . forM (chunks tds) $ \ch -> do+ absent <- HashSet.fromList <$> run c (R.eval bodiesScript [] (c.prefix : bodyArgs ch))+ return [td | td <- ch, encode (txHash td.tx) `HashSet.member` absent]+ where+ chunks [] = []+ chunks xs = let (a, b) = splitAt bodyChunk xs in a : chunks b++-- | Bodies rewritten by one script of 'rewriteBodies': each is a few+-- commands, so a chunk holds Redis for a millisecond or two.+bodyChunk :: Int+bodyChunk = 200++-- | Arguments for bodies and their spenders: the number of bodies, the hash+-- and body of each, the number of spenders, and the transaction, output index+-- and spender of each.+bodyArgs :: [TxData] -> [ByteString]+bodyArgs tds =+ counted2 [(encode (txHash td.tx), encode (td :: TxData) {spenders = IntMap.empty}) | td <- tds]+ <> counted3+ [ (encode (txHash td.tx), encode (fromIntegral i :: Word32), encode s)+ | td <- tds,+ (i, s) <- IntMap.toList td.spenders+ ]++counted :: [ByteString] -> [ByteString]+counted xs = num (length xs) : xs++counted2 :: [(ByteString, ByteString)] -> [ByteString]+counted2 xs = num (length xs) : concat [[a, b] | (a, b) <- xs]++counted3 :: [(ByteString, ByteString, ByteString)] -> [ByteString]+counted3 xs = num (length xs) : concat [[a, b, d] | (a, b, d) <- xs]++num :: (Integral a) => a -> ByteString+num = C8.pack . show . toInteger++num' :: Double -> ByteString+num' = C8.pack . show++nowMs :: IO Integer+nowMs = round . (* 1000) <$> getPOSIXTime++-- | Height of the best block of the database, 0 if none.+bestHeight :: Cache -> IO Word32+bestHeight c =+ Q.getBestBlock c.db >>= \case+ Nothing -> return 0+ Just h -> maybe 0 (.height) <$> Q.getBlock c.db h++-- | Whether the database has the best header of the node.+inSync :: Cache -> IO Bool+inSync c =+ Q.getBestBlock c.db >>= \case+ Nothing -> return False+ Just bb -> do+ cb <- chainGetBest c.chain+ return (cb.height > 0 && headerHash cb.header == bb)++-- | Run an action holding the lock of an xpub. 'Nothing' if another store+-- holds it. The lock expires after two minutes in case its holder dies.+withLock :: Cache -> XPubSpec -> IO a -> IO (Maybe a)+withLock c x f = do+ token <- C8.pack . show <$> (randomIO :: IO Word64)+ let key = c.prefix <> "lock:" <> xpubId x+ opts =+ R.SetOpts+ { R.setSeconds = Nothing,+ R.setMilliseconds = Just 120000,+ R.setUnixSeconds = Nothing,+ R.setUnixMilliseconds = Nothing,+ R.setCondition = Just R.Nx,+ R.setKeepTTL = False+ }+ R.runRedis c.redis (R.setOpts key token opts) >>= \case+ Right R.Ok -> do+ r <- f+ void (run c (R.eval unlockScript [key] [token] :: Redis (Either Reply Integer)))+ return (Just r)+ _ -> return Nothing++-------------------------------------------------------------------------------+-- Keeping up with the store+-------------------------------------------------------------------------------++-- | Do the work of the cache, forever.+--+-- A store that is not in sync ignores events and requests: the stores that+-- are ahead have already done or will do the work, and this one would only+-- find that Redis knows more. Once in sync, each event is claimed per cached+-- xpub that it changes ('claim'): the first store to claim a change reloads+-- the xpub and marks the claim done, and the others skip it, so a change is+-- applied once however many stores share the Redis, and stores that catch up+-- find their events done. A change that a store could not apply because+-- Redis knew more is released for another store to take; one that another+-- store is busy with is looked at again later.+worker :: Cache -> IO ()+worker c = do+ lastBest <- newIORef Nothing+ loopForever $ do+ js <- atomically $ do+ xs <- readTVar c.jobs+ checkSTM (not (null xs))+ writeTVar c.jobs []+ return (reverse xs)+ synced <- inSync c+ if synced+ then handleAny (\e -> log' c LevelError ("Cache: " <> cs (show e))) (work c lastBest js)+ else releaseClaims c (concat [ks | JobHeld _ _ ks <- js])++-- | Do a batch of jobs.+work :: Cache -> IORef (Maybe BlockHash) -> [Job] -> IO ()+work c lastBest js = do+ fromEvents <- concat <$> mapM (eventChanges c lastBest) [e | JobEvent e <- js]+ let toClaim = fromEvents <> [(k, x, ts) | JobClaim x ts ks <- js, k <- ks]+ claimed <- fmap catMaybes . forM toClaim $ \(k, x, ts) ->+ afterClaim <$> claim c k >>= \case+ ReloadNow -> return (Just (x, (ts, [k])))+ Skip -> return Nothing+ ClaimLater -> later c (JobClaim x ts [k]) >> return Nothing+ let held =+ HashMap.fromListWith+ (\(a, b) (a', b') -> (a <> a', b <> b'))+ (claimed <> [(x, (ts, ks)) | JobHeld x ts ks <- js])+ pooledForConcurrentlyN_ parallelReloads (HashMap.toList held) $ \(x, (ts, ks)) ->+ afterReload <$> reload x ts >>= \case+ Finish -> finishClaims c ks+ Release -> releaseClaims c ks+ Hold -> extendClaims c ks >> later c (JobHeld x ts ks)+ pooledForConcurrentlyN_ parallelReloads (nub [x | JobCache x <- js, not (HashMap.member x held)]) $ \x ->+ cacheIsCached c x >>= \case+ True -> return ()+ False ->+ reload x HashSet.empty >>= \case+ Locked -> later c (JobCache x)+ _ -> return ()+ where+ reload x ts =+ tryAny (cacheRefresh c x ts) >>= \case+ Right r -> return r+ Left e -> do+ log' c LevelError ("Could not cache xpub: " <> cs (show e))+ return Locked++-- | How many xpubs a store reloads at once. Each holds the lock of its xpub,+-- so two stores never reload the same one; reloading several at once keeps+-- one large xpub from holding up the others.+parallelReloads :: Int+parallelReloads = 4++-- | Changes that an event makes to cached xpubs: for each xpub, the key of+-- the claim for the change, and the transactions that make it. Also keeps the+-- spenders of cached transactions up to date, which is harmless to repeat.+eventChanges :: Cache -> IORef (Maybe BlockHash) -> StoreEvent -> IO [(ByteString, XPubSpec, HashSet TxHash)]+eventChanges c lastBest e = do+ (name, tds) <- case e of+ StoreBestBlock h -> do+ prev <- readIORef lastBest+ writeIORef lastBest (Just h)+ ("b:" <> encode h,) <$> blockTxs c prev h+ StoreMempoolNew t -> ("t+:" <> encode t,) . maybeToList <$> viewed c (\v -> L.getTxData v t)+ -- the store keeps nothing of a transaction that it deleted. Redis has+ -- the body of every transaction of a cached xpub, so if it does not+ -- have this one, no entry does either+ StoreMempoolDelete t -> do+ noteLost c [t]+ ("t-:" <> encode t,) <$> lostTxs c [t]+ _ -> return ("", [])+ if null tds+ then return []+ else do+ updateSpenders c tds+ affected <- affectedXPubs c tds+ return [(c.prefix <> "ev:" <> name <> ":" <> xpubId x, x, ts) | (x, ts) <- affected]++-- | Claim a change. A claim is held for two minutes, so that a store that+-- dies while holding it does not keep others from the change for long.+claim :: Cache -> ByteString -> IO Claim+claim c k = do+ let opts =+ R.SetOpts+ { R.setSeconds = Nothing,+ R.setMilliseconds = Just 120000,+ R.setUnixSeconds = Nothing,+ R.setUnixMilliseconds = Nothing,+ R.setCondition = Just R.Nx,+ R.setKeepTTL = False+ }+ R.runRedis c.redis (R.setOpts k "busy" opts) >>= \case+ Right R.Ok -> return Claimed+ _ ->+ run c (R.get k) <&> \case+ Just "done" -> Done+ _ -> Busy++-- | Mark changes as applied, for an hour: long enough for every store in+-- sync to see the same events.+finishClaims :: Cache -> [ByteString] -> IO ()+finishClaims _ [] = return ()+finishClaims c ks =+ void . run c $ sequence <$> mapM (\k -> R.setex k 3600 "done") ks++-- | Give up changes, for another store to apply.+releaseClaims :: Cache -> [ByteString] -> IO ()+releaseClaims _ [] = return ()+releaseClaims c ks = void (run c (R.del (NonEmpty.fromList ks)))++-- | Hold changes for another two minutes.+extendClaims :: Cache -> [ByteString] -> IO ()+extendClaims _ [] = return ()+extendClaims c ks = void . run c $ sequence <$> mapM (`R.pexpire` 120000) ks++-- | Pass the events of the store that may change cached xpubs to the worker.+listener :: Cache -> Inbox StoreEvent -> IO ()+listener c events =+ loopForever $+ receive events >>= \case+ e@StoreBestBlock {} -> push c (JobEvent e)+ e@StoreMempoolNew {} -> push c (JobEvent e)+ e@StoreMempoolDelete {} -> push c (JobEvent e)+ _ -> return ()++-- | Transactions of a new best block, and of the blocks that a+-- reorganisation took out of the chain since the previous best block.+blockTxs :: Cache -> Maybe BlockHash -> BlockHash -> IO [TxData]+blockTxs c prev h =+ Q.getBlock c.db h >>= \case+ Nothing -> return []+ Just bd -> do+ reverted <- case prev of+ Just p | p /= bd.header.prev && p /= h -> walk p+ _ -> return []+ let hashes = bd.txs <> reverted+ found <- viewed c (\v -> L.getManyTxData v hashes)+ -- a transaction of a reverted block that did not go back to the+ -- mempool, as one that spends its coinbase does not, is gone from+ -- the database. Redis has its body if a cached xpub has it.+ let missing = [h' | (h', Nothing) <- zip hashes found]+ noteLost c missing+ (catMaybes found <>) <$> lostTxs c missing+ where+ -- blocks that are no longer in the main chain, from the old best block+ walk p =+ Q.getBlock c.db p >>= \case+ Just b | not b.main -> (b.txs <>) <$> walk b.header.prev+ _ -> return []++-- | Note that the store deleted transactions.+noteLost :: Cache -> [TxHash] -> IO ()+noteLost _ [] = return ()+noteLost c hs = atomicModifyIORef' c.lost $ \seen ->+ let more = foldr HashSet.insert seen hs+ in (if HashSet.size more > 100000 then HashSet.fromList hs else more, ())++-- | The bodies that Redis has of transactions that the store deleted,+-- marked as deleted. Redis has the body of every transaction of a cached+-- xpub, so one that it does not have is in no entry.+lostTxs :: Cache -> [TxHash] -> IO [TxData]+lostTxs c hs = map (\td -> (td :: TxData) {deleted = True}) . catMaybes <$> cacheTxData c hs++-- | Cached xpubs with an address that these transactions spend from or pay+-- to, with the transactions that touch each.+affectedXPubs :: Cache -> [TxData] -> IO [(XPubSpec, HashSet TxHash)]+affectedXPubs c tds = do+ let net = c.db.net+ ctx = c.db.ctx+ addrsOf :: TxData -> [Address]+ addrsOf td =+ nub . rights $+ map (scriptToAddressBS net ctx . (.script)) (IntMap.elems td.prevs)+ <> map (scriptToAddressBS net ctx . (.script)) td.tx.outputs+ byAddr =+ HashMap.fromListWith (<>) [(a, HashSet.singleton (txHash td.tx)) | td <- tds, a <- addrsOf td]+ addrs = HashMap.keys byAddr+ ids <- run c (sequence <$> mapM (R.smembers . addrKey c) addrs)+ let byId =+ HashMap.fromListWith+ (<>)+ [(i, ts) | (a, is) <- zip addrs ids, i <- is, Just ts <- [HashMap.lookup a byAddr]]+ spec i = R.hget (c.prefix <> "x:" <> i <> ":meta") "spec"+ specs <- run c (sequence <$> mapM spec (HashMap.keys byId))+ return+ [ (x, ts)+ | (Just bs, ts) <- zip specs (HashMap.elems byId),+ Right x <- [decode bs]+ ]++-- | Record who spends the outputs of cached transactions: these+-- transactions spend them, or no longer do if they were deleted.+updateSpenders :: Cache -> [TxData] -> IO ()+updateSpenders c tds =+ unless (null quads) $+ void (run c (R.eval spendsScript [] args :: Redis (Either Reply Integer)))+ where+ args = c.prefix : num (length quads) : concat quads+ quads =+ [ [ encode i.outpoint.hash,+ encode i.outpoint.index,+ if td.deleted then "" else encode (Spender (txHash td.tx) n),+ encode (txHash td.tx)+ ]+ | td <- tds,+ (n, i) <- zip [0 ..] td.tx.inputs+ ]++-------------------------------------------------------------------------------+-- Lua scripts+-------------------------------------------------------------------------------++-- The scripts that Redis runs are in files of their own under+-- @src\/Haskoin\/Store\/Cache\/lua@, where they can be read, linted and run+-- with @redis-cli --eval@ on their own, and are embedded here when the module+-- is compiled. Embedding them, rather than reading them when the server+-- starts, keeps the library free of data files that a program using it would+-- have to install and find. Their comments are sent with every call, so keep+-- them short; the full description of each is the Haddock of its binding.++-- | Delete an xpub. Arguments: prefix, id. Returns 1 if it was cached.+deleteScript :: ByteString+deleteScript = $(makeRelativeToProject "src/Haskoin/Store/Cache/lua/delete.lua" >>= embedFile)++-- | Evict the least recently used xpubs past a number. Arguments: prefix,+-- maximum. Returns how many were evicted.+evictScript :: ByteString+evictScript = $(makeRelativeToProject "src/Haskoin/Store/Cache/lua/evict.lua" >>= embedFile)++-- | A page of a sorted set of a cached xpub; see 'page'. Arguments: the+-- keys of the meta hash, of the sorted set and of the sorted set of+-- transactions, the key of the set of recent use, the time to mark the xpub+-- used with (empty for not now), the id of the xpub, the offset and the limit+-- (0 for no limit), and how the page starts: @top@; @score@ and a score;+-- @at@, a score, a number of bytes and the bytes of the start; or @tx@, a+-- transaction hash, a number of bytes and the bytes of the start. Returns+-- @{\"none\"}@ if the xpub is not cached, @{\"nostart\"}@ if the+-- transaction of @tx@ is not one of the xpub, or @{\"ok\"}@ followed by+-- each member and its score.+--+-- Bytes are compared one by one: Lua compares strings by the collation of+-- the locale of the server, which need not be the order of their bytes that+-- Redis and the database sort members by.+pageScript :: ByteString+pageScript = $(makeRelativeToProject "src/Haskoin/Store/Cache/lua/page.lua" >>= embedFile)++-- | Apply the differences of an entry of an xpub, unless the stored one is+-- stamped with a higher block, or is not the generation they were computed+-- from. Arguments: prefix, id, height, time of use, serialized xpub,+-- balances, generation (empty for none); then, each preceded by its number,+-- the addresses to add, the addresses to remove, the transactions to add or+-- move with their scores, the transactions to remove, the unspent outputs to+-- add with their scores, the unspent outputs to remove, the bodies to write+-- with their hashes, and the spenders with their transaction and output+-- index. Returns @{\"stale\"}@ if the stored entry has a higher block,+-- @{\"changed\"}@ if its generation is not the one given,+-- @{\"missing\", ...}@ with the hashes of added transactions that have no+-- body in Redis nor in the arguments, or @{\"ok\"}@. Nothing is written+-- unless it returns @{\"ok\"}@.+--+-- A transaction removed from the entry loses this xpub from its set of+-- xpubs, and its body goes when no cached xpub has it any more.+commitScript :: ByteString+commitScript = $(makeRelativeToProject "src/Haskoin/Store/Cache/lua/commit.lua" >>= embedFile)++-- | Rewrite bodies that Redis has, with their spenders; see 'rewriteBodies'.+-- Arguments: prefix, then as at the end of 'commitScript': the number of+-- bodies, the hash and body of each, the number of spenders, and the+-- transaction, output index and spender of each. Returns the hashes of the+-- bodies that Redis does not have, which it does not write.+bodiesScript :: ByteString+bodiesScript = $(makeRelativeToProject "src/Haskoin/Store/Cache/lua/bodies.lua" >>= embedFile)++-- | Record or forget spenders of outputs of cached transactions. Arguments:+-- prefix, number of entries, then for each: hash of the transaction that+-- created the output, output index, spender (empty to forget) and hash of+-- the spending transaction. Only transactions whose body is cached are+-- touched, and a spender is only forgotten if it is still the one given.+spendsScript :: ByteString+spendsScript = $(makeRelativeToProject "src/Haskoin/Store/Cache/lua/spends.lua" >>= embedFile)++-- | Release a lock if it still holds the given token. Keys: lock. Arguments:+-- token.+unlockScript :: ByteString+unlockScript = $(makeRelativeToProject "src/Haskoin/Store/Cache/lua/unlock.lua" >>= embedFile)
@@ -0,0 +1,122 @@+-- |+-- Module : Haskoin.Store.Cache.Protocol+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- The rules that stores sharing one Redis follow to apply each change of a+-- cached xpub once, as pure decisions. "Haskoin.Store.Cache" carries them+-- out against Redis.+--+-- Each change (a block, or a transaction entering or leaving the mempool,+-- for one xpub) has a claim in Redis that goes through these states:+--+-- @+-- claim reload succeeds+-- absent ─────────► busy ─────────────────► done (for an hour)+-- ▲ │+-- └─────────────────┘ Redis knew more: release+-- @+--+-- A store that finds the claim busy tries again later; one that finds it+-- done skips the change. While it holds the claim, it reloads the xpub from+-- its database, unless the entry in Redis knows more than the database+-- ('mayReplace'), in which case it releases the claim and writes nothing.+--+-- = Why these rules+--+-- * __A change is applied once.__ The first store to see a change claims it;+-- stores that see it later, because they are behind, find it done. A set of+-- stores catching up does no work for changes already applied.+-- * __Stores never undo each other.__ An entry is stamped with the best block+-- of the store that wrote it, and no store replaces an entry stamped with a+-- higher block, or one with a transaction it has never seen. Without this+-- rule, a store that is behind would replace a newer entry with an older+-- one, and the next store would put the newer one back.+-- * __An entry is complete.__ The entry is written with every transaction+-- body that it needs, in one script, so a store that is behind can serve+-- it without its database.+module Haskoin.Store.Cache.Protocol+ ( -- * Claims+ Claim (..),+ OnClaim (..),+ afterClaim,++ -- * Reloads+ Reload (..),+ OnReload (..),+ afterReload,+ mayReplace,+ )+where++import Haskoin (BlockHeight)++-- | The state of the claim of a change, as a store that tries to claim it+-- finds it.+data Claim+ = -- | it was absent: this store holds it now+ Claimed+ | -- | another store applied the change+ Done+ | -- | another store holds the claim+ Busy+ deriving stock (Eq, Show, Enum, Bounded)++-- | What to do after trying to claim a change.+data OnClaim+ = -- | reload the xpub, holding the claim+ ReloadNow+ | -- | nothing: the change was applied+ Skip+ | -- | try to claim it again later, in case its holder gives it up+ ClaimLater+ deriving stock (Eq, Show)++-- | What to do after trying to claim a change.+afterClaim :: Claim -> OnClaim+afterClaim = \case+ Claimed -> ReloadNow+ Done -> Skip+ Busy -> ClaimLater++-- | What became of a reload.+data Reload+ = -- | the entry was replaced+ Reloaded+ | -- | Redis knows more about the xpub than this store: its entry is stamped+ -- with a higher block, or has a transaction this store has never seen. It+ -- is left for a store that knows more.+ Behind+ | -- | another store holds the lock of the xpub+ Locked+ deriving stock (Eq, Show, Enum, Bounded)++-- | What to do with the claims of the changes that a reload was for.+data OnReload+ = -- | mark them done+ Finish+ | -- | give them up, for a store that knows more+ Release+ | -- | keep holding them, and reload again later+ Hold+ deriving stock (Eq, Show)++-- | What to do with the claims of the changes that a reload was for.+afterReload :: Reload -> OnReload+afterReload = \case+ Reloaded -> Finish+ Behind -> Release+ Locked -> Hold++-- | Whether a store may replace the entry of an xpub: given the block height+-- that the entry is stamped with, if there is an entry, the height of the+-- best block of the store, and whether the entry has a transaction that the+-- store has never seen. An entry of the same height may be replaced, as it+-- can differ only in the mempool. The commit script checks the height again,+-- atomically, as another store may write between the check and the commit.+mayReplace :: Maybe Integer -> BlockHeight -> Bool -> Bool+mayReplace stamp height unknownTxs =+ maybe True (<= toInteger height) stamp && not unknownTxs
@@ -0,0 +1,25 @@+-- Rewrite bodies that Redis has, ahead of a commit: bodiesScript in Haskoin.Store.Cache.+local p = ARGV[1]+local nb = tonumber(ARGV[2])+local bi = 3+local si = bi + 2 * nb + 1+local ns = tonumber(ARGV[si - 1])+local written = {}+local absent = {}+for j = 0, nb - 1 do+ local t = ARGV[bi + 2 * j]+ if redis.call('EXISTS', p .. 'tx:' .. t) == 1 then+ redis.call('SET', p .. 'tx:' .. t, ARGV[bi + 2 * j + 1])+ redis.call('DEL', p .. 'sp:' .. t)+ written[t] = true+ else+ table.insert(absent, t)+ end+end+for j = 0, ns - 1 do+ local k = si + 3 * j+ if written[ARGV[k]] then+ redis.call('HSET', p .. 'sp:' .. ARGV[k], ARGV[k + 1], ARGV[k + 2])+ end+end+return absent
@@ -0,0 +1,92 @@+-- Apply the differences of an entry of an xpub: commitScript in Haskoin.Store.Cache.+local p = ARGV[1]+local id = ARGV[2]+local h = tonumber(ARGV[3])+local xk = p .. 'x:' .. id .. ':'+local meta = xk .. 'meta'+local oh = redis.call('HGET', meta, 'height')+if oh and h < tonumber(oh) then return {'stale'} end+if (redis.call('HGET', meta, 'gen') or '') ~= ARGV[7] then return {'changed'} end+local full = ARGV[8]+local i = 9+local function list(width)+ local n = tonumber(ARGV[i])+ local s = i + 1+ i = s + width * n+ return s, n+end+local aa, naa = list(1)+local ad, nad = list(1)+local ta, nta = list(2)+local td, ntd = list(1)+local ua, nua = list(3)+local ud, nud = list(2)+local bi, nb = list(2)+local si, ns = list(3)+local given = {}+for j = 0, nb - 1 do given[ARGV[bi + 2 * j]] = true end+local missing = {'missing'}+for j = 0, nta - 1 do+ local t = ARGV[ta + 2 * j]+ if not given[t] and redis.call('EXISTS', p .. 'tx:' .. t) == 0 then+ table.insert(missing, t)+ end+end+if #missing > 1 then return missing end+for j = 0, nad - 1 do+ local a = ARGV[ad + j]+ redis.call('SREM', p .. 'a:' .. a, id)+ redis.call('SREM', xk .. 'addrs', a)+end+for j = 0, naa - 1 do+ local a = ARGV[aa + j]+ redis.call('SADD', p .. 'a:' .. a, id)+ redis.call('SADD', xk .. 'addrs', a)+end+for j = 0, ntd - 1 do+ local t = ARGV[td + j]+ local rk = p .. 'txref:' .. t+ redis.call('ZREM', xk .. 'txs', t)+ redis.call('SREM', rk, id)+ if redis.call('SCARD', rk) == 0 then+ redis.call('DEL', rk, p .. 'tx:' .. t, p .. 'sp:' .. t)+ end+end+for j = 0, nta - 1 do+ local t = ARGV[ta + 2 * j]+ redis.call('ZADD', xk .. 'txs', ARGV[ta + 2 * j + 1], t)+ redis.call('SADD', p .. 'txref:' .. t, id)+end+-- unspent outputs by outpoint: uop maps each to its member, which changes+-- with the block of the output, so that one can go by its outpoint alone+local uop = xk .. 'uop'+for j = 0, nud - 1 do+ local op, m = ARGV[ud + 2 * j], ARGV[ud + 2 * j + 1]+ if m == '' then m = redis.call('HGET', uop, op) end+ if m then redis.call('ZREM', xk .. 'utxo', m) end+ redis.call('HDEL', uop, op)+end+for j = 0, nua - 1 do+ local op, m = ARGV[ua + 3 * j], ARGV[ua + 3 * j + 1]+ local old = redis.call('HGET', uop, op)+ if old and old ~= m then redis.call('ZREM', xk .. 'utxo', old) end+ redis.call('ZADD', xk .. 'utxo', ARGV[ua + 3 * j + 2], m)+ redis.call('HSET', uop, op, m)+end+for j = 0, nb - 1 do+ local t = ARGV[bi + 2 * j]+ redis.call('SET', p .. 'tx:' .. t, ARGV[bi + 2 * j + 1])+ redis.call('DEL', p .. 'sp:' .. t)+end+for j = 0, ns - 1 do+ local k = si + 3 * j+ if given[ARGV[k]] then+ redis.call('HSET', p .. 'sp:' .. ARGV[k], ARGV[k + 1], ARGV[k + 2])+ end+end+redis.call('SET', xk .. 'bal', ARGV[6])+redis.call('HSET', meta, 'spec', ARGV[5], 'height', ARGV[3], 'ver', '3')+if full ~= '' then redis.call('HSET', meta, 'full', full) end+redis.call('HINCRBY', meta, 'gen', 1)+redis.call('ZADD', p .. 'lru', ARGV[4], id)+return {'ok'}
@@ -0,0 +1,18 @@+-- Delete an xpub: deleteScript in Haskoin.Store.Cache. evict.lua removes+-- xpubs the same way.+local p = ARGV[1]+local id = ARGV[2]+local xk = p .. 'x:' .. id .. ':'+for _, a in ipairs(redis.call('SMEMBERS', xk .. 'addrs')) do+ redis.call('SREM', p .. 'a:' .. a, id)+end+for _, t in ipairs(redis.call('ZRANGE', xk .. 'txs', 0, -1)) do+ local rk = p .. 'txref:' .. t+ redis.call('SREM', rk, id)+ if redis.call('SCARD', rk) == 0 then+ redis.call('DEL', rk, p .. 'tx:' .. t, p .. 'sp:' .. t)+ end+end+local existed = redis.call('DEL', xk .. 'meta', xk .. 'bal', xk .. 'txs', xk .. 'utxo', xk .. 'addrs', xk .. 'uop')+redis.call('ZREM', p .. 'lru', id)+if existed > 0 then return 1 else return 0 end
@@ -0,0 +1,23 @@+-- Evict the least recently used xpubs past a number: evictScript in+-- Haskoin.Store.Cache. Each xpub goes as delete.lua removes one.+local p = ARGV[1]+local max = tonumber(ARGV[2])+local n = 0+while redis.call('ZCARD', p .. 'lru') > max do+ local id = redis.call('ZRANGE', p .. 'lru', 0, 0)[1]+ local xk = p .. 'x:' .. id .. ':'+ for _, a in ipairs(redis.call('SMEMBERS', xk .. 'addrs')) do+ redis.call('SREM', p .. 'a:' .. a, id)+ end+ for _, t in ipairs(redis.call('ZRANGE', xk .. 'txs', 0, -1)) do+ local rk = p .. 'txref:' .. t+ redis.call('SREM', rk, id)+ if redis.call('SCARD', rk) == 0 then+ redis.call('DEL', rk, p .. 'tx:' .. t, p .. 'sp:' .. t)+ end+ end+ redis.call('DEL', xk .. 'meta', xk .. 'bal', xk .. 'txs', xk .. 'utxo', xk .. 'addrs', xk .. 'uop')+ redis.call('ZREM', p .. 'lru', id)+ n = n + 1+end+return n
@@ -0,0 +1,39 @@+-- A page of a sorted set of a cached xpub: pageScript in Haskoin.Store.Cache.+local set, txs = ARGV[2], ARGV[3]+if redis.call('EXISTS', ARGV[1]) == 0 then return {'none'} end+if ARGV[5] ~= '' then redis.call('ZADD', ARGV[4], ARGV[5], ARGV[6]) end+local off = tonumber(ARGV[7])+local lim = tonumber(ARGV[8])+local mode = ARGV[9]+local function lt(a, b)+ for k = 1, math.min(#a, #b) do+ local x, y = string.byte(a, k), string.byte(b, k)+ if x ~= y then return x < y end+ end+ return #a < #b+end+local function out(r)+ local o = {'ok'}+ for _, v in ipairs(r) do table.insert(o, v) end+ return o+end+if mode == 'top' then+ local stop = -1+ if lim > 0 then stop = off + lim - 1 end+ return out(redis.call('ZRANGE', set, off, stop, 'WITHSCORES'))+end+local score = ARGV[10]+local skip = 0+if mode == 'tx' then+ score = redis.call('ZSCORE', txs, ARGV[10])+ if not score then return {'nostart'} end+end+if mode ~= 'score' then+ local cut, bytes = tonumber(ARGV[11]), ARGV[12]+ for _, m in ipairs(redis.call('ZRANGEBYSCORE', set, score, score)) do+ if lt(string.sub(m, cut + 1), bytes) then skip = skip + 1 end+ end+end+local count = -1+if lim > 0 then count = lim end+return out(redis.call('ZRANGEBYSCORE', set, score, '+inf', 'WITHSCORES', 'LIMIT', off + skip, count))
@@ -0,0 +1,20 @@+-- Record or forget spenders of outputs of cached transactions: spendsScript in Haskoin.Store.Cache.+local p = ARGV[1]+local n = tonumber(ARGV[2])+local i = 3+for j = 1, n do+ local t = ARGV[i]+ if redis.call('EXISTS', p .. 'tx:' .. t) == 1 then+ local sk = p .. 'sp:' .. t+ if ARGV[i + 2] ~= '' then+ redis.call('HSET', sk, ARGV[i + 1], ARGV[i + 2])+ else+ local cur = redis.call('HGET', sk, ARGV[i + 1])+ if cur and string.sub(cur, 1, 32) == ARGV[i + 3] then+ redis.call('HDEL', sk, ARGV[i + 1])+ end+ end+ end+ i = i + 4+end+return 1
@@ -0,0 +1,5 @@+-- Release a lock if it still holds the given token: unlockScript in Haskoin.Store.Cache.+if redis.call('GET', KEYS[1]) == ARGV[1] then+ return redis.call('DEL', KEYS[1])+end+return 0
@@ -1,12 +1,4 @@-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE ImportQualifiedPost #-}-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE RecordWildCards #-}-{-# LANGUAGE TupleSections #-}-{-# LANGUAGE NoFieldSelectors #-} -- | -- Module : Haskoin.Store.Common@@ -18,22 +10,15 @@ -- -- Interfaces shared by every part of the store. ----- 'StoreReadBase' and 'StoreReadExtra' abstract over anything that can answer--- queries about blocks, transactions, addresses and extended public keys: the--- RocksDB database ("Haskoin.Store.Database.Reader"), the in-memory write--- buffer used while importing ("Haskoin.Store.Database.Writer"), and the Redis--- cache ("Haskoin.Store.Cache"). 'StoreWrite' is implemented by the writer.+-- 'StoreRead' is the queries that the web server asks about blocks,+-- transactions, addresses and extended public keys, as a record of functions+-- in 'IO': "Haskoin.Store.Database.Query" gives them for the RocksDB+-- database, and the web server puts the Redis cache in front of some. -- -- This module also has helpers for pagination ('Limits'), for deriving--- addresses from extended public keys, and for merging sorted streams.--- = Why the store is a set of type classes------ The same questions are asked in three places: by the web server against the--- database, by the import logic against a buffer of uncommitted changes, and by--- the cache, which answers some questions itself and passes others on. Writing--- the queries once against a class, and providing instances for each place, keeps--- those three in agreement and lets the cache sit transparently in front of the--- database.+-- addresses from extended public keys ('scanXPubBals'), for the order of+-- transactions and outputs ('txRefKey', 'unspentKey'), and for merging sorted+-- streams. -- -- = Why pages start at a transaction --@@ -44,12 +29,11 @@ module Haskoin.Store.Common ( Limits (..), Start (..),- StoreReadBase (..),- StoreReadExtra (..),- StoreWrite (..),+ StoreRead (..), StoreEvent (..), getActiveBlock, getActiveTxData,+ notDeleted, getDefaultBalance, getTransaction, getTransactions,@@ -60,6 +44,9 @@ xPubSummary, deriveAddresses, deriveFunction,+ scanXPubBals,+ txRefKey,+ unspentKey, deOffset, applyLimits, applyLimitsC,@@ -86,18 +73,18 @@ ($$++), ) import Control.Monad.Trans (lift)-import Control.Monad.Trans.Maybe (MaybeT (..), runMaybeT)-import Control.Monad.Trans.Reader (runReaderT) import Data.ByteString (ByteString) import Data.Default (Default (..))+import Data.Functor ((<&>)) import Data.Graph qualified as G import Data.HashMap.Strict qualified as M import Data.HashSet qualified as H import Data.Hashable (Hashable)-import Data.Map.Strict qualified as Map import Data.List (sortOn)-import Data.Maybe (catMaybes, mapMaybe)+import Data.Map.Strict qualified as Map+import Data.Maybe (catMaybes, fromMaybe, mapMaybe) import Data.Ord (Down (..))+import Data.Serialize (encode) import Data.Time.Clock.System ( getSystemTime, systemNanoseconds,@@ -105,10 +92,11 @@ ) import Data.Word (Word32, Word64) import Haskoin-import Haskoin.Node (Chain, Peer)+import Haskoin.Node (Chain, Peer, chainRunHeaders) import Haskoin.Store.Data ( Balance (..), BlockData (..),+ BlockRef (..), DeriveType (..), Spender, Transaction (..),@@ -124,7 +112,6 @@ toTransaction, zeroBalance, )-import UnliftIO (MonadIO, liftIO) -- | Function to derive the address at an index of an extended public key. type DeriveAddr = XPubKey -> KeyIndex -> Address@@ -141,8 +128,9 @@ -- entered the mempool, and for a confirmed one, at its position in the -- chain AtTx !TxHash- | -- | at this block height, including every transaction in the mempool if it- -- is above the best block+ | -- | at the last transaction confirmed at or below this block height.+ -- Nothing of the mempool is in the page, not even when the height is+ -- above the best block AtBlock !BlockHeight deriving (Eq, Show) @@ -165,131 +153,117 @@ instance Default Limits where def = defaultLimits --- | Basic queries that every store answers.-class (Monad m) => StoreReadBase m where- -- | Network the store indexes.- getNetwork :: m Network- -- | Context for secp256k1 operations, needed to parse and derive keys.- getCtx :: m Ctx- -- | Hash of the best block imported, or 'Nothing' if none was imported yet.- getBestBlock :: m (Maybe BlockHash)- -- | Hashes of all blocks imported at a height, including those on side- -- chains.- getBlocksAtHeight :: BlockHeight -> m [BlockHash]- -- | Data of a block imported in the past, whether still in the main chain- -- or not. Check 'Haskoin.Store.Data.main' to know.- getBlock :: BlockHash -> m (Maybe BlockData)- -- | Data of a transaction, including deleted ones. Check- -- 'Haskoin.Store.Data.deleted' to know.- getTxData :: TxHash -> m (Maybe TxData)-- -- | Data of many transactions, in the order of their hashes. Stores that- -- can read many keys at once read them together.- getManyTxData :: [TxHash] -> m [Maybe TxData]- getManyTxData = mapM getTxData-- -- | Transaction input that spends an output, if it is spent.- getSpender :: OutPoint -> m (Maybe Spender)- -- | Balance of an address, or 'Nothing' if it was never used.- getBalance :: Address -> m (Maybe Balance)- -- | Unspent output, or 'Nothing' if it does not exist or is spent.- getUnspent :: OutPoint -> m (Maybe Unspent)- -- | Whole mempool, newest first.- getMempool :: m [(UnixTime, TxHash)]-- -- | Page of the mempool, newest first, from the offset and limit of the- -- 'Limits'; its start is ignored. Stores that index the mempool by time- -- read only the entries of the page instead of the whole mempool.- getMempoolPage :: Limits -> m [(UnixTime, TxHash)]- getMempoolPage l = applyLimits l <$> getMempool---- | Queries about addresses and extended public keys, which need indices or--- configuration beyond the basic store.-class (StoreReadBase m) => StoreReadExtra m where- -- | Transactions involving any of the addresses, newest first, without- -- duplicates.- getAddressesTxs :: [Address] -> Limits -> m [TxRef]- -- | Unspent outputs of any of the addresses, newest first.- getAddressesUnspents :: [Address] -> Limits -> m [Unspent]- -- | Number of unused addresses to look ahead when deriving the addresses of- -- an extended public key that has no transactions yet.- getInitialGap :: m Word32- -- | Number of consecutive unused addresses after which derivation of an- -- extended public key stops.- getMaxGap :: m Word32- -- | Transactions whose hash, read as a number, matches the short identifier- -- used by the Blockchain.info compatibility API. There may be more than one.- getNumTxData :: Word64 -> m [TxData]- -- | Balances of the addresses, in the same order. Addresses never used get- -- a zero balance.- getBalances :: [Address] -> m [Balance]- -- | Transactions involving an address, newest first.- getAddressTxs :: Address -> Limits -> m [TxRef]- -- | Unspent outputs of an address, newest first.- getAddressUnspents :: Address -> Limits -> m [Unspent]- -- | Balances of the used addresses of an extended public key, with their- -- derivation paths.- xPubBals :: XPubSpec -> m [XPubBal]- -- | Unspent outputs of an extended public key, newest first. The second- -- argument is the result of 'xPubBals' for the same key.- xPubUnspents :: XPubSpec -> [XPubBal] -> Limits -> m [XPubUnspent]- -- | Transactions of an extended public key, newest first. The second- -- argument is the result of 'xPubBals' for the same key.- xPubTxs :: XPubSpec -> [XPubBal] -> Limits -> m [TxRef]- -- | Number of transactions of an extended public key. The second argument- -- is the result of 'xPubBals' for the same key.- xPubTxCount :: XPubSpec -> [XPubBal] -> m Word32---- | Changes to the index. Implemented by the writer, which buffers them in--- memory and commits them to the database in one batch.-class StoreWrite m where- -- | Set the best block.- setBest :: BlockHash -> m ()- -- | Add or replace the data of a block.- insertBlock :: BlockData -> m ()- -- | Set the hashes of the blocks at a height.- setBlocksAtHeight :: [BlockHash] -> BlockHeight -> m ()- -- | Add or replace the data of a transaction.- insertTx :: TxData -> m ()- -- | Add a transaction to the index of an address.- insertAddrTx :: Address -> TxRef -> m ()- -- | Remove a transaction from the index of an address.- deleteAddrTx :: Address -> TxRef -> m ()- -- | Add an unspent output to the index of an address.- insertAddrUnspent :: Address -> Unspent -> m ()- -- | Remove an unspent output from the index of an address.- deleteAddrUnspent :: Address -> Unspent -> m ()- -- | Add a transaction to the mempool, with the time it arrived.- addToMempool :: TxHash -> UnixTime -> m ()- -- | Remove a transaction from the mempool.- deleteFromMempool :: TxHash -> m ()- -- | Set the balance of an address.- setBalance :: Balance -> m ()- -- | Add an unspent output.- insertUnspent :: Unspent -> m ()- -- | Remove an unspent output.- deleteUnspent :: OutPoint -> m ()+-- | The queries that the web server answers with, as plain functions in+-- 'IO'. "Haskoin.Store.Database.Query" gives them for the database+-- ('Haskoin.Store.Database.Query.databaseRead'); the web server puts the+-- Redis cache in front of some of them.+--+-- A record of functions rather than a class: a class needs a monad to pick+-- its instance, and the store has no monads of its own to pick it with, only+-- 'IO'. The record makes the source of every answer an ordinary value that+-- can be passed around and replaced, as @nocache=true@ does.+data StoreRead = StoreRead+ { -- | network the store indexes+ net :: !Network,+ -- | context for secp256k1 operations, needed to parse and derive keys+ ctx :: !Ctx,+ -- | hash of the best block imported, or 'Nothing' if none yet+ getBestBlock :: IO (Maybe BlockHash),+ -- | hashes of all blocks imported at a height, side chains included+ getBlocksAtHeight :: BlockHeight -> IO [BlockHash],+ -- | data of a block imported in the past, whether still in the main+ -- chain or not; check 'Haskoin.Store.Data.main' to know+ getBlock :: BlockHash -> IO (Maybe BlockData),+ -- | data of a transaction that is confirmed or in the mempool. One+ -- that was deleted is not returned, here or by the two queries below;+ -- see 'notDeleted'+ getTxData :: TxHash -> IO (Maybe TxData),+ -- | data of many transactions, in the order of their hashes, read+ -- together+ getManyTxData :: [TxHash] -> IO [Maybe TxData],+ -- | transaction input that spends an output, if it is spent+ getSpender :: OutPoint -> IO (Maybe Spender),+ -- | balance of an address, or 'Nothing' if it was never used+ getBalance :: Address -> IO (Maybe Balance),+ -- | unspent output, or 'Nothing' if it does not exist or is spent+ getUnspent :: OutPoint -> IO (Maybe Unspent),+ -- | whole mempool, newest first+ getMempool :: IO [(UnixTime, TxHash)],+ -- | page of the mempool, newest first, from the offset and limit of the+ -- 'Limits'; its start is ignored+ getMempoolPage :: Limits -> IO [(UnixTime, TxHash)],+ -- | transactions involving any of the addresses, newest first, without+ -- duplicates+ getAddressesTxs :: [Address] -> Limits -> IO [TxRef],+ -- | unspent outputs of any of the addresses, newest first+ getAddressesUnspents :: [Address] -> Limits -> IO [Unspent],+ -- | unused addresses to look at for an extended public key with no+ -- transactions yet+ initialGap :: !Word32,+ -- | consecutive unused addresses after which derivation of an extended+ -- public key stops+ maxGap :: !Word32,+ -- | transactions whose hash, read as a number, matches the short+ -- identifier of the Blockchain.info compatible API; there may be more+ -- than one+ getNumTxData :: Word64 -> IO [TxData],+ -- | balances of the addresses, in the same order; addresses never used+ -- get a zero balance+ getBalances :: [Address] -> IO [Balance],+ -- | transactions involving an address, newest first+ getAddressTxs :: Address -> Limits -> IO [TxRef],+ -- | unspent outputs of an address, newest first+ getAddressUnspents :: Address -> Limits -> IO [Unspent],+ -- | balances of the used addresses of an extended public key, with+ -- their derivation paths+ xPubBals :: XPubSpec -> IO [XPubBal],+ -- | unspent outputs of an extended public key, newest first, given its+ -- balances from 'xPubBals'+ xPubUnspents :: XPubSpec -> [XPubBal] -> Limits -> IO [XPubUnspent],+ -- | transactions of an extended public key, newest first, given its+ -- balances from 'xPubBals'+ xPubTxs :: XPubSpec -> [XPubBal] -> Limits -> IO [TxRef],+ -- | number of transactions of an extended public key, given its+ -- balances from 'xPubBals'+ xPubTxCount :: XPubSpec -> [XPubBal] -> IO Word32,+ -- | number of distinct transactions of a set of addresses, counted+ -- without reading them+ getAddressesTxCount :: [Address] -> IO Word32+ } -- | Data of a block, or 'Nothing' if it is unknown or not in the main chain.-getActiveBlock :: (StoreReadExtra m) => BlockHash -> m (Maybe BlockData)-getActiveBlock bh =- getBlock bh >>= \case- Just b | b.main -> return (Just b)- _ -> return Nothing+getActiveBlock :: StoreRead -> BlockHash -> IO (Maybe BlockData)+getActiveBlock r bh =+ r.getBlock bh <&> \case+ Just b | b.main -> Just b+ _ -> Nothing +-- | Leave out a transaction that was deleted: replaced, dropped from the+-- mempool, or the coinbase of a block that left the main chain.+--+-- The store used to answer with such transactions, marked as deleted, so+-- that a wallet could say what became of a payment. Some clients do not+-- look at the mark, and took a payment that will never happen for one that+-- is pending. So every 'StoreRead' leaves them out, and to the API a+-- deleted transaction is one that the store does not know. Since database+-- version 22 nothing is kept of them at all; this is for the bodies that+-- version 20 left in a database that was migrated.+notDeleted :: Maybe TxData -> Maybe TxData+notDeleted (Just td) | td.deleted = Nothing+notDeleted found = found+ -- | Data of a transaction, or 'Nothing' if it is unknown or was deleted.-getActiveTxData :: (StoreReadBase m) => TxHash -> m (Maybe TxData)-getActiveTxData th =- getTxData th >>= \case- Just td | not td.deleted -> return (Just td)- _ -> return Nothing+-- The same as the query of a 'StoreRead' now that it leaves deleted+-- transactions out.+getActiveTxData :: StoreRead -> TxHash -> IO (Maybe TxData)+getActiveTxData r th =+ r.getTxData th <&> \case+ Just td | not td.deleted -> Just td+ _ -> Nothing -- | Balance of an address, or a zero balance if the address was never used.-getDefaultBalance :: (StoreReadBase m) => Address -> m Balance-getDefaultBalance a =- getBalance a >>= \case- Nothing -> return $ zeroBalance a- Just b -> return b+getDefaultBalance :: StoreRead -> Address -> IO Balance+getDefaultBalance r a = fromMaybe (zeroBalance a) <$> r.getBalance a -- | Infinite list of the addresses of an extended public key, with their -- indices, from the given index on.@@ -304,6 +278,76 @@ deriveFunction ctx DeriveP2SH i = fst . deriveCompatWitnessAddr ctx i deriveFunction ctx DeriveP2WPKH i = fst . deriveWitnessAddr ctx i +-- | Balances of the addresses of an extended public key, derived up to the+-- gap: every address of each chain up to the last used one, followed by+-- exactly @gap@ unused ones. Arguments, in order: a function to read the+-- balances of addresses, the initial gap, the gap, the external chain and+-- the change chain (both infinite lists of indices and addresses).+--+-- The first @initial gap@ external addresses are read first. If none is used,+-- the key is taken as unused, those balances are returned and nothing more is+-- read. Otherwise each chain is read until the last @gap@ addresses are+-- unused, counting the unused addresses at the end of the initial block too.+-- The initial block is always returned whole, even when it reaches past the+-- gap after its last used address (an initial gap larger than the gap): it+-- was read, so a payment to any of its addresses is seen, and the cache must+-- keep those addresses to see it too.+--+-- = Why the gap is exact+--+-- The gap is the number of unused addresses a wallet may hand out in a row.+-- Wallets follow BIP 44 and stop looking for payments after that many, so a+-- store that looked further would show payments the wallet cannot see, and a+-- store that sometimes looked further and sometimes not would make the answer+-- depend on how the reads happened to be batched. Earlier versions read 32+-- addresses at a time and checked the gap only between batches, so they+-- looked between 33 and 64 addresses past the last used one, and started+-- counting again at the end of the initial block. The Redis cache keeps+-- exactly @gap@ unused addresses, so cached and uncached answers differed+-- whenever a payment fell in the extra range.+--+-- Reads are still batched: each read asks for as many addresses as are still+-- needed to complete the gap if all of them turn out unused, so no address+-- past the gap is ever read, and a run of unused addresses costs one read.+scanXPubBals ::+ (Monad m) =>+ ([Address] -> m [Balance]) ->+ Word32 ->+ Word32 ->+ [(Word32, Address)] ->+ [(Word32, Address)] ->+ m [XPubBal]+scanXPubBals getBals igap gap ext chg = do+ let (first, rest) = splitAt (fromIntegral igap) ext+ ext1 <- readChain 0 first+ if all nullxb ext1+ then return ext1+ else do+ ext2 <- go (length ext1) 0 (reverse ext1) rest+ chg' <- go 0 1 [] chg+ return (ext2 <> chg')+ where+ g = fromIntegral gap :: Int+ nullxb b = nullBalance b.balance+ readChain m as = do+ bs <- getBals (map snd as)+ return $ zipWith (\(n, _) b -> XPubBal {path = [m, n], balance = b}) as bs+ -- acc holds the balances read so far, newest first; keep is the number+ -- of addresses at the start of the chain to return in any case+ trailing = length . takeWhile nullxb+ go keep m acc as+ | trailing acc >= g = return (trim keep acc)+ | otherwise =+ case splitAt (g - trailing acc) as of+ ([], _) -> return (trim keep acc)+ (now, later) -> do+ bs <- readChain m now+ go keep m (reverse bs <> acc) later+ trim keep acc =+ let (ns, ys) = span nullxb acc+ gapped = reverse ys <> take g (reverse ns)+ in if length gapped >= keep then gapped else take keep (reverse acc)+ -- | Summary of the balances of an extended public key, from the balances of its -- addresses. The received total only counts external addresses, and the -- indices are those of the last used external and change addresses.@@ -326,73 +370,52 @@ rx = sum [b.balance.received | b <- xt] -- | Transaction with its metadata, or 'Nothing' if it is unknown.-getTransaction ::- (StoreReadBase m) => TxHash -> m (Maybe Transaction)-getTransaction h = do- net <- getNetwork- ctx <- getCtx- fmap (toTransaction net ctx) <$> getTxData h+getTransaction :: StoreRead -> TxHash -> IO (Maybe Transaction)+getTransaction r h = fmap (toTransaction r.net r.ctx) <$> r.getTxData h -- | Many transactions, in the order of their hashes, read together.-getTransactions ::- (StoreReadBase m) => [TxHash] -> m [Maybe Transaction]-getTransactions hs = do- net <- getNetwork- ctx <- getCtx- map (fmap (toTransaction net ctx)) <$> getManyTxData hs+getTransactions :: StoreRead -> [TxHash] -> IO [Maybe Transaction]+getTransactions r hs = map (fmap (toTransaction r.net r.ctx)) <$> r.getManyTxData hs -- | Transactions that match a short numeric identifier; see 'getNumTxData'.-getNumTransaction ::- (StoreReadExtra m) => Word64 -> m [Transaction]-getNumTransaction i = do- net <- getNetwork- ctx <- getCtx- map (toTransaction net ctx) <$> getNumTxData i+getNumTransaction :: StoreRead -> Word64 -> IO [Transaction]+getNumTransaction r i = map (toTransaction r.net r.ctx) <$> r.getNumTxData i -- | First block in the main chain whose timestamp is at or after the given time, -- or 'Nothing' if there is none.-blockAtOrAfter ::- (MonadIO m, StoreReadExtra m) =>- Chain ->- UnixTime ->- m (Maybe BlockData)-blockAtOrAfter ch q = runMaybeT $ do- net <- lift getNetwork- x <- MaybeT $ liftIO $ runReaderT (firstGreaterOrEqual net f) ch- MaybeT $ getBlock $ headerHash x.header+blockAtOrAfter :: StoreRead -> Chain -> UnixTime -> IO (Maybe BlockData)+blockAtOrAfter r ch q =+ chainRunHeaders ch (firstGreaterOrEqual r.net f) >>= \case+ Nothing -> return Nothing+ Just x -> r.getBlock (headerHash x.header) where+ f :: (BlockHeaders n) => BlockNode -> n Ordering f x = let t = fromIntegral x.header.timestamp in return $ t `compare` q -- | Last block in the main chain whose timestamp is at or before the given time, -- or 'Nothing' if there is none.-blockAtOrBefore ::- (MonadIO m, StoreReadExtra m) =>- Chain ->- UnixTime ->- m (Maybe BlockData)-blockAtOrBefore ch q = runMaybeT $ do- net <- lift getNetwork- x <- MaybeT $ liftIO $ runReaderT (lastSmallerOrEqual net f) ch- MaybeT $ getBlock $ headerHash x.header+blockAtOrBefore :: StoreRead -> Chain -> UnixTime -> IO (Maybe BlockData)+blockAtOrBefore r ch q =+ chainRunHeaders ch (lastSmallerOrEqual r.net f) >>= \case+ Nothing -> return Nothing+ Just x -> r.getBlock (headerHash x.header) where+ f :: (BlockHeaders n) => BlockNode -> n Ordering f x = let t = fromIntegral x.header.timestamp in return $ t `compare` q -- | First block in the main chain whose median time past is at or after the given -- time, or 'Nothing' if there is none.-blockAtOrAfterMTP ::- (MonadIO m, StoreReadExtra m) =>- Chain ->- UnixTime ->- m (Maybe BlockData)-blockAtOrAfterMTP ch q = runMaybeT $ do- net <- lift getNetwork- x <- MaybeT $ liftIO $ runReaderT (firstGreaterOrEqual net f) ch- MaybeT $ getBlock $ headerHash x.header+blockAtOrAfterMTP :: StoreRead -> Chain -> UnixTime -> IO (Maybe BlockData)+blockAtOrAfterMTP r ch q =+ chainRunHeaders ch (firstGreaterOrEqual r.net f) >>= \case+ Nothing -> return Nothing+ Just x -> r.getBlock (headerHash x.header) where+ f :: (BlockHeaders n) => BlockNode -> n Ordering f x = do t <- fromIntegral <$> mtp x return $ t `compare` q@@ -478,8 +501,8 @@ nub' :: (Hashable a) => [a] -> [a] nub' = H.toList . H.fromList --- | A 'forever' that stays a flat loop in any monad. 'forever' sequences--- with @*>@; monad-logger's @LoggingT@ leaves @*>@ at its default+-- | A 'Control.Monad.forever' that stays a flat loop in any monad.+-- 'Control.Monad.forever' sequences with @*>@; monad-logger's @LoggingT@ leaves @*>@ at its default -- @(id <$ a) <*> b@ and @ReaderT@ defines @>>@ as @*>@, so a loop that is -- polymorphic in its monad and not specialised by GHC keeps an @<*>@ -- continuation frame and two thunks per iteration for its whole lifetime.@@ -491,12 +514,12 @@ {-# INLINE loopForever #-} -- | Current time in microseconds since the Unix epoch.-microseconds :: (MonadIO m) => m Integer+microseconds :: IO Integer microseconds = let f t = toInteger (systemSeconds t) * 1000000 + toInteger (systemNanoseconds t) `div` 1000- in liftIO $ f <$> getSystemTime+ in f <$> getSystemTime -- | Stream every result of a paged query, fetching one page at a time. --@@ -536,48 +559,74 @@ [] -> return () ls -> mapM_ yield ls >> go l' (last ls) --- | Merge streams sorted in decreasing order into one stream sorted in decreasing--- order, dropping duplicates.+-- | Merge streams sorted in decreasing order of a key into one stream sorted+-- in decreasing order of that key, dropping items whose key is the same as+-- the one before. The key must be the order the streams are really sorted+-- in: an item that comes out of order in its stream can be emitted twice.+--+-- Use 'txRefKey' for transactions and 'unspentKey' for unspent outputs: they+-- are the order the database and the cache both keep, including the order of+-- ties between mempool transactions that arrived in the same second. joinDescStreams ::- (Monad m, Ord a) =>+ (Monad m, Ord k) =>+ (a -> k) -> [ConduitT () a m ()] -> ConduitT () a m ()-joinDescStreams xs = do+joinDescStreams key xs = do let ss = map sealConduitT xs go Nothing =<< g ss where- j (x, y) = (,[x]) <$> y+ j (x, y) = (\v -> (key v, [(v, x)])) <$> y g ss = let l = mapMaybe j <$> lift (traverse ($$++ await) ss) in Map.fromListWith (++) <$> l go m mp = case Map.lookupMax mp of Nothing -> return ()- Just (x, ss) -> do+ Just (k, vss) -> do case m of- Nothing -> yield x- Just x'- | x == x' -> return ()- | otherwise -> yield x- mp1 <- g ss+ Just k' | k == k' -> return ()+ _ -> yield (fst (last vss))+ mp1 <- g (map snd vss) let mp2 = Map.deleteMax mp mp' = Map.unionWith (++) mp1 mp2- go (Just x) mp'+ go (Just k) mp' +-- | Order of transaction references, newest first when sorted in decreasing+-- order: by block position, and between mempool transactions that arrived in+-- the same second, by the bytes of their hashes in increasing order.+--+-- The tie order is not the derived 'Ord' of 'TxRef', which would sort the+-- hashes in decreasing order. It is the order of the database keys, which+-- serialize the hash after the block position, and of the Redis sorted sets+-- of the cache, which break ties between equal scores by the bytes of the+-- member. Using it everywhere makes every source agree, so that merged pages+-- have no duplicates and the cache answers exactly like the database.+txRefKey :: TxRef -> (BlockRef, Down TxHash)+txRefKey t = (t.block, Down t.txid)++-- | Order of unspent outputs, newest first when sorted in decreasing order:+-- by block position, then by the serialized output point in increasing+-- order. See 'txRefKey' for why the ties are broken by serialized bytes.+unspentKey :: Unspent -> (BlockRef, Down ByteString)+unspentKey u = (u.block, Down (encode u.outpoint))+ -- | Merge streams whose keys never increase, taking at most the given number -- of items from each stream ('Nothing': all of them). The items come out in -- groups of equal key, in decreasing order of key, and each group is sorted--- in decreasing order of the items themselves. So the output is the same as+-- in decreasing order of the second function, which must agree with the+-- first. So the output is the same as -- sorting the first items of every stream in decreasing order, but a stream -- is read only as far as the consumer needs: a consumer that stops after -- the first n items makes each stream be read only a little past its first -- n items, instead of to the end of its quota. mergeDescGroups ::- (Monad m, Ord a, Ord k) =>+ (Monad m, Ord o, Ord k) => (a -> k) ->+ (a -> o) -> Maybe Word32 -> [ConduitT () a m ()] -> ConduitT () a m ()-mergeDescGroups key quota streams = do+mergeDescGroups key order quota streams = do heads <- lift $ mapM (next quota . sealConduitT) streams go (insertAll (catMaybes heads) Map.empty) where@@ -601,5 +650,5 @@ Nothing -> return () Just ((Down k, hs), mp') -> do gs <- lift $ mapM (group k) hs- mapM_ yield (sortOn Down (concatMap fst gs))+ mapM_ yield (sortOn (Down . order) (concatMap fst gs)) go (insertAll (mapMaybe snd gs) mp')
@@ -0,0 +1,505 @@+-- |+-- Module : Haskoin.Store.Database.Mempool+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- The mempool area of the layered layout of the database, and how it is+-- applied on top of the chain. The design, and the reasons for it, are in+-- @docs\/layered-database.md@. This is the layout that the store runs on,+-- since version 21. Its rules are checked by the tests of+-- "Haskoin.Store.Database.Migrate" against the answers of a database of+-- version 20, in which the mempool was mixed into the chain. The queries+-- that apply them are in "Haskoin.Store.Database.Query.Layered".+--+-- = Two layers+--+-- In the layered layout the column families of the chain hold only what+-- follows from the blocks of the main chain, and change only when a block is+-- imported or reverted. Everything about unconfirmed transactions is in two+-- column families of its own, 'mempoolCF' and 'mempoolAddrCF'. A query reads+-- the chain, reads what the mempool has for the same transaction, output or+-- address, and puts the two together. Emptying the mempool area leaves the+-- chain as it is, with an empty mempool on top.+--+-- = Every key belongs to one transaction+--+-- 'mempoolRecords' gives the keys that a mempool transaction contributes.+-- They depend on that transaction alone: on its inputs, with the outputs+-- they spend, which it carries with it, and on its outputs. The mempool area+-- is the union of the keys of its transactions. So a transaction enters the+-- mempool by writing its keys and leaves it by deleting them, in any order,+-- and when a block confirms some transactions and not their descendants,+-- nothing has to be worked out again for those that stay.+--+-- The alternative is to keep, for the mempool, the same values that the+-- chain has: a balance, a list of unspent outputs. Such values depend on the+-- chain under them. A balance with the mempool applied is wrong as soon as a+-- block pays the address, and every block would have to find and correct+-- the values it made stale. The keys here say what the mempool does, not+-- what the result is, so they stay right whatever happens to the chain.+--+-- What a query needs is worked out when it is answered, from the few entries+-- that the mempool has for an address, an t'AddressMempool':+--+-- * an output that the mempool creates and does not spend is an unspent+-- output of the mempool ('mempoolUnspents');+--+-- * an output that the mempool spends and did not create belongs to the+-- chain, which still has it as unspent, and is left out of what the chain+-- says ('hiddenOutputs');+--+-- * an output that the mempool both creates and spends shows up in neither,+-- and only counts towards what the address received.+--+-- = The summary of an address+--+-- The entries of an address are few for nearly every address, and many+-- thousands for some. So each address that the mempool touches also has a+-- t'MemSummary', one key with what its entries add up to ('summarise'). A+-- balance is the balance of the chain and the summary ('applySummary'), and+-- the queries read the entries themselves only for lists, as far as a page+-- goes.+--+-- The summary is not the key of one transaction, but it is a function of+-- the entries of its address and of nothing else, so it shares what makes+-- them safe: nothing that happens to the chain makes it wrong.+-- 'wrongSummaries' checks that, and 'rebuildSummaries' writes them all+-- again from the entries.+module Haskoin.Store.Database.Mempool+ ( -- * What a transaction contributes+ MemRecords (..),+ mempoolRecords,+ recordEntries,+ insertRecordOps,+ deleteRecordOps,++ -- * Reading the mempool area+ AddressMempool (..),+ getAddressMempool,+ getAddressMempools,+ getMempoolTx,+ getMempoolSpenders,+ wipeMempoolArea,++ -- * Applying the mempool on top of the chain+ mempoolUnspents,+ hiddenOutputs,+ applyMempool,+ mergeBalance,++ -- * The summary of an address+ summarise,+ summaryEntries,+ applySummary,+ summaryBalance,+ getSummaries,+ rebuildSummaries,+ wrongSummaries,+ addressArea,+ )+where++import Conduit (runConduit, sinkList, (.|))+import Data.ByteString (ByteString)+import Data.HashMap.Strict (HashMap)+import Data.HashMap.Strict qualified as HashMap+import Data.HashSet qualified as HashSet+import Data.IntMap.Strict (IntMap)+import Data.IntMap.Strict qualified as IntMap+import Data.Maybe (fromMaybe, isNothing, mapMaybe)+import Data.Serialize (Serialize, decode, encode)+import Data.Word (Word32, Word64)+import Database.RocksDB+ ( BatchOp (..),+ ColumnFamily,+ DB,+ Iterator,+ getCF,+ iterFirst,+ iterKey,+ iterNext,+ multiGetCF,+ withIterCF,+ )+import Database.RocksDB.Query+ ( matching,+ matchingAsListCF,+ retrieveCF,+ writeBatch,+ )+import Haskoin+ ( Address,+ Ctx,+ Network,+ OutPoint (..),+ Tx (..),+ TxHash,+ TxIn (..),+ TxOut (..),+ eitherToMaybe,+ nullOutPoint,+ scriptToAddressBS,+ txHash,+ )+import Haskoin.Store.Common (nub')+import Haskoin.Store.Data+ ( Balance (..),+ BlockRef (..),+ Prev (..),+ Spender (..),+ TxData (..),+ TxRef (..),+ UnixTime,+ Unspent (..),+ zeroBalance,+ )+import Haskoin.Store.Database.Reader+ ( DatabaseReader (..),+ mempoolAddrCF,+ mempoolCF,+ )+import Haskoin.Store.Database.Types+ ( MemAddrKey (..),+ MemAddrVal (..),+ MemSpenderKey (..),+ MemSummary (..),+ MemTimeKey (..),+ MemTxKey (..),+ TxKey (..),+ )++-------------------------------------------------------------------------------+-- What a transaction contributes+-------------------------------------------------------------------------------++-- | What one mempool transaction contributes to the mempool area.+data MemRecords = MemRecords+ { -- | hash of the transaction+ txid :: !TxHash,+ -- | time it entered the mempool: its place in the list of the mempool+ -- and in the index by time+ time :: !UnixTime,+ -- | its body, without spenders and at a mempool position+ body :: !TxData,+ -- | the outputs it spends, each with the input that spends it+ spends :: ![(OutPoint, Spender)],+ -- | for each address it touches: the transaction itself, the outputs of+ -- the address that it spends and the outputs that it pays to the+ -- address+ addresses :: ![(MemAddrKey, MemAddrVal)]+ }+ deriving (Eq, Show)++-- | What a mempool transaction that entered the mempool at a time+-- contributes to the mempool area.+--+-- It is a function of the transaction and that time and of nothing else:+-- not of the chain, and not of the other transactions in the mempool. The+-- body is stored without spenders, which have keys of their own, and as not+-- deleted. The flag for replace-by-fee is kept as given; it is the one+-- thing here that can depend on other transactions, because a transaction+-- inherits it from unconfirmed ancestors, and it is corrected when one of+-- them is confirmed.+mempoolRecords :: Network -> Ctx -> UnixTime -> TxData -> MemRecords+mempoolRecords net ctx time td =+ MemRecords+ { txid = h,+ time,+ body = (td :: TxData) {block = MemRef time, deleted = False, spenders = IntMap.empty},+ spends = [(op, Spender h k) | (k, op) <- spent],+ addresses =+ [(MemAddrTxKey a time h, MemAddrTxVal) | a <- touched]+ <> [ (MemAddrSpentKey a op, MemAddrSpentVal p.value)+ | (k, op) <- spent,+ Just p <- [IntMap.lookup (fromIntegral k) td.prevs],+ Just a <- [address p.script]+ ]+ <> [ (MemAddrOutKey a time (OutPoint h i), MemAddrOutVal o.value o.script)+ | (i, o) <- zip [0 ..] td.tx.outputs,+ Just a <- [address o.script]+ ]+ }+ where+ h = txHash td.tx+ spent = [(k, i.outpoint) | (k, i) <- zip [0 :: Word32 ..] td.tx.inputs, i.outpoint /= nullOutPoint]+ address = eitherToMaybe . scriptToAddressBS net ctx+ touched =+ nub' $+ mapMaybe (address . (.script)) (IntMap.elems td.prevs)+ <> mapMaybe (address . (.script)) td.tx.outputs++-- | The keys and values of a transaction, serialized, for each of the two+-- column families of the mempool area: the mempool column family first,+-- then the one of address entries.+recordEntries :: MemRecords -> ([(ByteString, ByteString)], [(ByteString, ByteString)])+recordEntries r =+ ( [ kv (MemTxKey r.txid) r.time,+ kv (MemTimeKey r.time r.txid) (),+ kv (TxKey r.txid) r.body+ ]+ <> [kv (MemSpenderKey op) s | (op, s) <- r.spends],+ [kv k v | (k, v) <- r.addresses]+ )+ where+ kv :: (Serialize k, Serialize v) => k -> v -> (ByteString, ByteString)+ kv k v = (encode k, encode v)++-- | Operations that write the keys of a transaction to the mempool area.+insertRecordOps :: DB -> MemRecords -> [BatchOp]+insertRecordOps db r =+ [PutCF (mempoolCF db) k v | (k, v) <- general]+ <> [PutCF (mempoolAddrCF db) k v | (k, v) <- byAddress]+ where+ (general, byAddress) = recordEntries r++-- | Operations that delete the keys of a transaction from the mempool area.+deleteRecordOps :: DB -> MemRecords -> [BatchOp]+deleteRecordOps db r =+ [DelCF (mempoolCF db) k | (k, _) <- general]+ <> [DelCF (mempoolAddrCF db) k | (k, _) <- byAddress]+ where+ (general, byAddress) = recordEntries r++-------------------------------------------------------------------------------+-- Reading the mempool area+-------------------------------------------------------------------------------++-- | What the mempool does at an address. It is small, usually empty, and+-- read with one scan of the entries of the address.+data AddressMempool = AddressMempool+ { -- | mempool transactions that spend from or pay to the address, newest+ -- first, in the order of 'Haskoin.Store.Common.txRefKey'+ txs :: ![TxRef],+ -- | outputs of the address that mempool transactions spend, with their+ -- amounts, whether a block or the mempool created them+ spent :: !(HashMap OutPoint Word64),+ -- | outputs that mempool transactions pay to the address, whether the+ -- mempool spends them or not, newest first, in the order of+ -- 'Haskoin.Store.Common.unspentKey'+ created :: ![Unspent]+ }+ deriving (Eq, Show)++-- | What the mempool does at an address.+getAddressMempool :: DatabaseReader -> Address -> IO AddressMempool+getAddressMempool r a = withIterCF r.db (mempoolAddrCF r.db) (`addressMempool` a)++-- | What the mempool does at each of some addresses, in their order.+--+-- All of them are read through one iterator, which is moved to each address+-- in turn. A query about an extended public key asks about hundreds of+-- addresses, nearly all with nothing in the mempool; opening an iterator for+-- each would cost more than the reads, each of which stops at the filter.+getAddressMempools :: DatabaseReader -> [Address] -> IO [AddressMempool]+getAddressMempools _ [] = return []+getAddressMempools r as = withIterCF r.db (mempoolAddrCF r.db) $ \it -> mapM (addressMempool it) as++-- | What the mempool does at an address, read with an iterator on the+-- column family of the entries by address, which is left wherever the scan+-- ended.+addressMempool :: Iterator -> Address -> IO AddressMempool+addressMempool it a = do+ rows <- runConduit $ matching it (MemAddrKeyA a) .| sinkList+ return+ AddressMempool+ { txs = [TxRef {block = MemRef t, txid = h} | (MemAddrTxKey _ t h, _) <- rows],+ spent = HashMap.fromList [(p, v) | (MemAddrSpentKey _ p, MemAddrSpentVal v) <- rows],+ created =+ [ Unspent {block = MemRef t, outpoint = p, value = v, script = s, address = Just a}+ | (MemAddrOutKey _ t p, MemAddrOutVal v s) <- rows+ ]+ }++-- | Body of a transaction that is in the mempool, without its spenders.+getMempoolTx :: DatabaseReader -> TxHash -> IO (Maybe TxData)+getMempoolTx r h = retrieveCF r.db (mempoolCF r.db) (TxKey h)++-- | Mempool inputs that spend the outputs of a transaction, by output index.+-- The transaction may be confirmed or in the mempool.+getMempoolSpenders :: DatabaseReader -> TxHash -> IO (IntMap Spender)+getMempoolSpenders r h = do+ rows <- matchingAsListCF r.db (mempoolCF r.db) (MemSpenderKeyS h)+ return $ IntMap.fromList [(fromIntegral p.index, s) | (MemSpenderKey p, s) <- rows]++-- | Delete every key of the mempool area, and say how many there were. The+-- chain is not touched: afterwards the database is that of a store whose+-- mempool is empty, as if every unconfirmed transaction had been deleted one+-- by one, except that it does not remember having seen them.+--+-- This is all that wiping the mempool takes in the layered layout, and what+-- can be done if the mempool area is ever found to be wrong.+wipeMempoolArea :: DatabaseReader -> IO Int+wipeMempoolArea r = sum <$> mapM wipe [mempoolCF r.db, mempoolAddrCF r.db]+ where+ wipe cf = go 0+ where+ go n =+ firstKeys cf >>= \case+ [] -> return n+ ks -> writeBatch r.db (map (DelCF cf) ks) >> go (n + length ks)+ firstKeys :: ColumnFamily -> IO [ByteString]+ firstKeys cf = withIterCF r.db cf $ \it -> iterFirst it >> collect it (10000 :: Int)+ collect _ 0 = return []+ collect it n =+ iterKey it >>= \case+ Nothing -> return []+ Just k -> iterNext it >> (k :) <$> collect it (n - 1)++-------------------------------------------------------------------------------+-- Applying the mempool on top of the chain+-------------------------------------------------------------------------------++-- | Outputs of an address that the mempool created and has not spent,+-- newest first.+mempoolUnspents :: AddressMempool -> [Unspent]+mempoolUnspents m = [u | u <- m.created, not (HashMap.member u.outpoint m.spent)]++-- | Outputs of an address that the chain has as unspent and that the mempool+-- spends, with their amounts: those that the mempool spends and did not+-- create itself.+hiddenOutputs :: AddressMempool -> HashMap OutPoint Word64+hiddenOutputs m = HashMap.filterWithKey (\p _ -> not (HashSet.member p made)) m.spent+ where+ made = HashSet.fromList (map (.outpoint) m.created)++-- | The balance that the chain has for an address, with what the mempool+-- does at the address applied: the balance as the API gives it.+--+-- As in version 20, an output of a block that a mempool transaction spends no+-- longer counts as confirmed balance, and the unconfirmed balance is what+-- the mempool pays to the address and has not spent.+--+-- The amounts taken from the chain can never be more than it has, as long+-- as every output that the mempool spends exists. If they ever are, the+-- result stops at zero rather than wrapping around.+applyMempool :: Balance -> AddressMempool -> Balance+applyMempool c m = applySummary c (summarise m)++-- | Balance of an address as the API gives it, from its balance in the+-- chain, if it has one, and what the mempool does at it ('applyMempool').+-- 'Nothing' if the address was never used in either.+--+-- Version 20 answers with a balance of zero, rather than with 'Nothing',+-- for an address whose only transactions were deleted from the mempool: it+-- leaves the balance that it wrote for them behind. The API shows an unknown+-- address as a balance of zero, so the two are the same to it.+mergeBalance :: Address -> Maybe Balance -> AddressMempool -> Maybe Balance+mergeBalance a chain m+ | isNothing chain && null m.txs && HashMap.null m.spent && null m.created = Nothing+ | otherwise = Just (applyMempool (fromMaybe (zeroBalance a) chain) m)++-------------------------------------------------------------------------------+-- The summary of an address+-------------------------------------------------------------------------------++-- | What the entries of an address add up to.+summarise :: AddressMempool -> MemSummary+summarise m =+ MemSummary+ { txs = fromIntegral (length m.txs),+ paid = sum (map (.value) m.created),+ paidCount = fromIntegral (length m.created),+ spent = sum (HashMap.elems m.spent),+ spentCount = fromIntegral (HashMap.size m.spent),+ both = sum (map (.value) inside),+ bothCount = fromIntegral (length inside)+ }+ where+ inside = [u | u <- m.created, HashMap.member u.outpoint m.spent]++-- | The summaries that go with some entries by address, which may be of+-- many addresses: what the column family must have next to them, for the+-- migration, for repairs and for the tests.+summaryEntries :: [(MemAddrKey, MemAddrVal)] -> [(MemAddrKey, MemAddrVal)]+summaryEntries rows =+ [ (MemAddrSumKey a, MemAddrSumVal (summarise m))+ | (a, m) <- HashMap.toList (HashMap.fromListWith (flip merge) (mapMaybe single rows))+ ]+ where+ entries ts sp cr = AddressMempool {txs = ts, spent = sp, created = cr}+ merge x y = entries (x.txs <> y.txs) (x.spent <> y.spent) (x.created <> y.created)+ single = \case+ (MemAddrTxKey a t h, _) -> Just (a, entries [TxRef {block = MemRef t, txid = h}] HashMap.empty [])+ (MemAddrSpentKey a p, MemAddrSpentVal v) -> Just (a, entries [] (HashMap.singleton p v) [])+ (MemAddrOutKey a t p, MemAddrOutVal v s) ->+ let u = Unspent {block = MemRef t, outpoint = p, value = v, script = s, address = Just a}+ in Just (a, entries [] HashMap.empty [u])+ _ -> Nothing++-- | The balance that the chain has for an address with the summary of what+-- the mempool does at it applied: what 'applyMempool' gives, from the+-- summary instead of the entries.+applySummary :: Balance -> MemSummary -> Balance+applySummary c s =+ Balance+ { address = c.address,+ confirmed = c.confirmed `minus` (s.spent `minus` s.both),+ unconfirmed = c.unconfirmed + (s.paid `minus` s.both),+ utxo = (c.utxo `minus` (s.spentCount `minus` s.bothCount)) + (s.paidCount `minus` s.bothCount),+ txs = c.txs + s.txs,+ received = c.received + s.paid+ }+ where+ minus x y = if y > x then 0 else x - y++-- | Balance of an address as the API gives it, from its balance in the+-- chain and its summary in the mempool, either of which it may not have:+-- what 'mergeBalance' gives, without reading the entries.+summaryBalance :: Address -> Maybe Balance -> Maybe MemSummary -> Maybe Balance+summaryBalance _ chain Nothing = chain+summaryBalance a chain (Just s) = Just (applySummary (fromMaybe (zeroBalance a) chain) s)++-- | The summaries of addresses, in their order, read by key with one+-- multi-get: 'Nothing' for an address that the mempool does not touch,+-- which is nearly all of them, and which the filter of the column family+-- answers.+getSummaries :: DatabaseReader -> [Address] -> IO [Maybe MemSummary]+getSummaries r as =+ map (>>= decoded) <$> case map (encode . MemAddrSumKey) as of+ [] -> return []+ -- one key is cheaper to read by itself than in a multi-get+ [key] -> pure <$> getCF r.db (mempoolAddrCF r.db) key+ keys -> multiGetCF r.db (mempoolAddrCF r.db) keys+ where+ decoded bytes = case decode bytes of+ Right (MemAddrSumVal s) -> Just s+ _ -> Nothing++-- | The addresses whose summary is not what their entries add up to, each+-- with the summary that it should have and the one that it has. There are+-- none unless something is broken: this is what the tests hold the rules+-- to after every operation.+wrongSummaries :: DatabaseReader -> IO [(MemAddrKey, Maybe MemAddrVal, Maybe MemAddrVal)]+wrongSummaries r = do+ rows <- matchingAsListCF r.db (mempoolAddrCF r.db) MemAddrKeyS+ let found = HashMap.fromList [(k, v) | (k@MemAddrSumKey {}, v) <- rows]+ expected = HashMap.fromList (summaryEntries rows)+ return+ [ (k, HashMap.lookup k expected, HashMap.lookup k found)+ | k <- HashMap.keys (HashMap.union expected found),+ HashMap.lookup k expected /= HashMap.lookup k found+ ]++-- | Everything that the column family of entries by address holds for a+-- mempool of some transactions, serialized, in no order: the entries of+-- each, and the summaries of the addresses that they touch.+addressArea :: [MemRecords] -> [(ByteString, ByteString)]+addressArea records = [(encode k, encode v) | (k, v) <- entries <> summaryEntries entries]+ where+ entries = concatMap (.addresses) records++-- | Work every summary out again from the entries, and say how many+-- addresses have one. The migration does this once everything is moved, and+-- it is what puts the summaries right if they are ever in doubt.+rebuildSummaries :: DatabaseReader -> IO Int+rebuildSummaries r = do+ rows <- matchingAsListCF r.db cf MemAddrKeyS+ let old = [DelCF cf (encode k) | (k@MemAddrSumKey {}, _ :: MemAddrVal) <- rows]+ new = summaryEntries rows+ writeBatch r.db (old <> [PutCF cf (encode k) (encode v) | (k, v) <- new])+ return (length new)+ where+ cf = mempoolAddrCF r.db
@@ -0,0 +1,678 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE MultiWayIf #-}++-- |+-- Module : Haskoin.Store.Database.Migrate+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- Migration of a database of version 20 to the layered layout, version 21,+-- of @docs\/layered-database.md@: the column families of the chain are left+-- with nothing from the mempool, and the mempool gets an area of its own+-- ("Haskoin.Store.Database.Mempool").+--+-- The store runs it when it opens a database ('upgradeDatabase'). It is+-- what makes the change of layout possible without syncing the chain again.+--+-- = What it does+--+-- In version 20 a mempool transaction is written into the indices of the+-- chain: its body, the entries of its addresses, its outputs as unspent, the+-- outputs it spends as spent, with itself as their spender in the bodies of+-- the transactions that created them, and all of it in the balances. The+-- migration takes each mempool transaction out of those indices, exactly as+-- deleting it does in version 20, and writes it to the mempool area instead.+-- When it is done, the chain's column families hold what they would hold if+-- the mempool had been wiped, and the mempool area holds the mempool.+--+-- It reads and writes only what the mempool touches, so it takes a time in+-- proportion to the mempool, not to the chain: about what wiping the+-- mempool takes in version 20.+--+-- = Why its own rules+--+-- Taking a transaction out of the indices of version 20 is what+-- "Haskoin.Store.Logic" does today when it deletes one. The migration does+-- not call that code: the rules of the store will change with the layout,+-- and a migration has to go on reading version 20 as it was. It carries the+-- few rules it needs, and the tests check them against "Haskoin.Store.Logic"+-- while both exist.+--+-- = Safe to interrupt+--+-- A mempool can hold hundreds of thousands of transactions, too many for one+-- write, so the migration works in batches. Each batch is one atomic write+-- that moves whole transactions, descendants before the transactions they+-- spend. After any batch the database is a valid mix: the transactions+-- already moved are in the mempool area, and the others are still in the+-- chain's indices, as a database of version 20 with a smaller mempool. The+-- migration can stop there and carry on later.+--+-- The first write sets the new version and a marker, t'MigrationKey', so+-- that a store of version 20 refuses the database from then on and a store+-- that knows the new layout carries the migration on. The last write+-- removes the marker. Before the first write everything is read once, so+-- that a database that the migration cannot handle is refused untouched.+--+-- = Version 22: records of transactions that are written once+--+-- Version 21 still had two things in the record of a confirmed+-- transaction that were not settled by the block that confirmed it:+--+-- * __its time__, which was the time at which the store first saw it in+-- its mempool, if it had, and so another on each store;+--+-- * __the spenders of its outputs__, which were written into the record+-- as later blocks spent them, and taken out when such a block was+-- reverted: the same on every store, but a record that was read and+-- written whole again for each of its outputs, for as long as it had+-- one unspent.+--+-- From version 22 a confirmed transaction has the time of its block, the+-- spenders of its outputs are under keys of their own+-- as t'Haskoin.Store.Database.Types.SpenderKey' says, and its record is written+-- when a block confirms it and deleted if that block is reverted, and+-- nothing else ('Haskoin.Store.Logic.Layered.importBlock').+-- 'migrateRecords' brings the records that are there already to that: it+-- gives each the time of its block, and moves its spenders to their keys.+-- It reads every transaction, and writes nearly every one, since most+-- have an output that was spent, so it takes a time in proportion to the+-- chain: 'upgradeDatabase' says how far it is as it goes. Each write does+-- whole transactions, the keys with the record that they come out of, and+-- running it again skips what is done, so it can be interrupted and+-- started over.+module Haskoin.Store.Database.Migrate+ ( upgradeDatabase,+ currentVersion,+ migrateRecords,+ layeredVersion,+ Migration (..),+ MigrationError (..),+ MigrationOptions (..),+ defaultMigrationOptions,+ migrateLayered,+ migrateLayeredWith,+ )+where++import Control.Monad (forM_, unless, void, when)+import Data.Default (def)+import Data.HashMap.Strict (HashMap)+import Data.HashMap.Strict qualified as HashMap+import Data.HashSet (HashSet)+import Data.HashSet qualified as HashSet+import Data.IntMap.Strict qualified as IntMap+import Data.Maybe (isJust, mapMaybe)+import Data.Serialize (decode, encode)+import Data.String.Conversions (cs)+import Data.Text (Text)+import Data.Word (Word32, Word64)+import Database.RocksDB+ ( BatchOp,+ IterConfig (..),+ getCF,+ iterEntry,+ iterFirst,+ iterGetError,+ iterNext,+ withIterConfig,+ )+import Database.RocksDB.Query+ ( deleteOp,+ deleteOpCF,+ insertOp,+ insertOpCF,+ matchingAsListCF,+ retrieve,+ retrieveCF,+ writeBatch,+ )+import Haskoin+ ( Address,+ BlockHeader (..),+ OutPoint (..),+ Tx (..),+ TxHash,+ TxIn (..),+ TxOut (..),+ eitherToMaybe,+ nullOutPoint,+ scriptToAddressBS,+ txHash,+ txHashToHex,+ )+import Haskoin.Store.Common (nub')+import Haskoin.Store.Data+ ( BlockData (..),+ BlockRef (..),+ Prev (..),+ Spender (..),+ TxData (..),+ TxRef (..),+ UnixTime,+ confirmed,+ )+import Haskoin.Store.Database.Mempool (insertRecordOps, mempoolRecords, rebuildSummaries)+import Haskoin.Store.Database.Query qualified as Q+import Haskoin.Store.Database.Reader+ ( DatabaseReader (..),+ addrOutCF,+ addrTxCF,+ balanceCF,+ initRocksDB,+ mempoolCF,+ spenderCF,+ txCF,+ unspentCF,+ )+import Haskoin.Store.Database.Types+ ( AddrOutKey (..),+ AddrTxKey (..),+ BalKey (..),+ BalVal (..),+ MemTxKey (..),+ MigrationKey (..),+ OutVal (..),+ SpenderKey (..),+ SpenderVal (..),+ TxKey (..),+ UnspentKey (..),+ UnspentVal (..),+ VersionKey (..),+ spenderGroup,+ )+import UnliftIO+ ( Exception,+ IORef,+ modifyIORef',+ newIORef,+ readIORef,+ throwIO,+ writeIORef,+ )++-- | Version of the layered layout as it was first written, with the time+-- at which a store first saw a transaction kept in the chain.+layeredVersion :: Word32+layeredVersion = 21++-- | Version that the store runs on: the layered layout, with the time of+-- its block for every confirmed transaction.+currentVersion :: Word32+currentVersion = 22++-- | Version that the migration starts from.+mixedVersion :: Word32+mixedVersion = 20++-- | What a migration did.+data Migration = Migration+ { -- | mempool transactions moved to the mempool area by this run+ moved :: !Int,+ -- | writes to the database that moved them+ batches :: !Int,+ -- | mempool transactions checked at the end, moved by this run or by+ -- an earlier one that was interrupted+ checked :: !Int,+ -- | records of confirmed transactions that this run wrote again, with+ -- the time of their block and without the spenders of their outputs+ rewritten :: !Int+ }+ deriving (Eq, Show)++-- | Why a migration refused a database, or stopped.+data MigrationError+ = -- | the database is of a version that this migration does not start+ -- from, or has none+ UnknownVersion !(Maybe Word32)+ | -- | the database is not as its version says it should be; nothing was+ -- written for the transaction that showed it+ Inconsistent !Text+ deriving (Eq, Show, Exception)++-- | How to run a migration.+data MigrationOptions = MigrationOptions+ { -- | mempool transactions to move in each write+ chunk :: !Int,+ -- | called after each write with the number of transactions moved so+ -- far; the tests interrupt the migration from here+ afterBatch :: Int -> IO (),+ -- | where to say what is going on+ report :: Text -> IO ()+ }++-- | A thousand transactions in each write, the size of the batches that+-- wipe the mempool in version 20, and nothing said.+defaultMigrationOptions :: MigrationOptions+defaultMigrationOptions =+ MigrationOptions+ { chunk = 1000,+ afterBatch = \_ -> return (),+ report = \_ -> return ()+ }++-- | Bring a database opened with+-- 'Haskoin.Store.Database.Reader.withLayeredReader' to the layout that the+-- store runs on, whatever it was, telling a function what is being done:+-- a new database gets the current version, one of version 18 or 19 is+-- first brought to version 20 as it always was, one of version 20 is+-- migrated to the layered layout ('migrateLayered'), and one of version 21+-- gets the time of its block for every confirmed transaction+-- ('migrateRecords'), each from the start or after an earlier run that was+-- interrupted. Throws a 'MigrationError' for any other version.+--+-- This is the point of no return for a database of an older version: a+-- store older than the layout refuses it afterwards.+upgradeDatabase :: (Text -> IO ()) -> DatabaseReader -> IO Migration+upgradeDatabase report r = do+ version <- retrieve r.db VersionKey+ marker <- retrieve r.db MigrationKey :: IO (Maybe Word32)+ case version of+ Nothing -> do+ writeBatch r.db [insertOp VersionKey currentVersion]+ return nothing+ Just v+ | v == 18 || v == 19 -> do+ report $ "Upgrading the database from version " <> cs (show v) <> " to " <> cs (show mixedVersion)+ initRocksDB r+ layered >>= times+ | v == currentVersion && marker == Nothing -> return nothing+ -- the times were being set when the store last stopped+ | v == currentVersion && marker == Just layeredVersion -> times nothing+ | v == currentVersion -> throwIO (UnknownVersion version)+ Just (_ :: Word32) -> layered >>= times+ where+ nothing = Migration {moved = 0, batches = 0, checked = 0, rewritten = 0}+ layered = migrateLayeredWith defaultMigrationOptions {report = report} r+ times done = do+ rewritten <- migrateRecords report r+ return (done :: Migration) {rewritten}++-- | Bring the records of the confirmed transactions of a database of+-- version 21 to what version 22 writes, and make the database one of that+-- version; or carry that on in a database where it was interrupted. Each+-- record gets the time of its block, and the spenders of its outputs go+-- from the record to keys of their own. Says how many records it wrote.+-- Throws a 'MigrationError' for a database of another version, and for+-- one with a confirmed transaction at a height where the main chain has+-- no block.+--+-- Every record is read, and nearly every one is written: the whole of the+-- largest column family, which on a database of Bitcoin is many hours,+-- and room on the disk for a second copy of it until RocksDB has+-- compacted the first away. A function is told how far it is, every+-- million transactions.+--+-- The first write sets the new version and the marker of a migration under+-- way; the last removes the marker. In between each write does some+-- thousand transactions, each with its keys, and a database that is half+-- done is a valid mix of both kinds of record. Starting again reads from+-- the beginning and skips what is done.+migrateRecords :: (Text -> IO ()) -> DatabaseReader -> IO Int+migrateRecords report r = do+ version <- retrieve db VersionKey+ marker <- retrieve db MigrationKey :: IO (Maybe Word32)+ if+ | version == Just layeredVersion && marker == Nothing -> do+ report "Making the record of every confirmed transaction one that is written once: this reads them all"+ writeBatch db [insertOp VersionKey currentVersion, insertOp MigrationKey layeredVersion]+ run+ | version == Just currentVersion && marker == Just layeredVersion -> do+ report "Carrying on with the records of confirmed transactions, from the first"+ run+ | version == Just currentVersion && marker == Nothing -> return 0+ | otherwise -> throwIO (UnknownVersion version)+ where+ db = r.db+ run = do+ times <- newIORef IntMap.empty+ pending <- newIORef []+ scanned <- newIORef (0 :: Int)+ changed <- newIORef (0 :: Int)+ let write = do+ ops <- readIORef pending+ unless (null ops) (writeBatch db ops)+ writeIORef pending []+ -- the time of the block of the main chain at a height+ timeAt :: Word32 -> IO Word64+ timeAt height =+ IntMap.lookup (fromIntegral height) <$> readIORef times >>= \case+ Just t -> return t+ Nothing -> do+ blocks <- Q.getBlocksAtHeight r height >>= mapM (Q.getBlock r)+ case [b | Just b <- blocks, b.main] of+ [b] -> do+ let t = fromIntegral b.header.timestamp+ t <$ modifyIORef' times (IntMap.insert (fromIntegral height) t)+ _ -> throwIO (Inconsistent ("No block of the main chain at height " <> cs (show height)))+ one (key, value) = case (decode key, decode value) of+ (Right (TxKey h), Right (td :: TxData)) -> do+ case td.block of+ BlockRef {height} | not td.deleted -> do+ t <- timeAt height+ -- the spenders of its outputs get keys of their own,+ -- in the write that takes them out of the record+ let grouped =+ IntMap.fromListWith+ IntMap.union+ [ (i `div` fromIntegral spenderGroup, IntMap.singleton i s)+ | (i, s) <- IntMap.toList td.spenders+ ]+ keys =+ [ insertOpCF (spenderCF db) (SpenderKey h (fromIntegral g)) (SpenderVal m)+ | (g, m) <- IntMap.toList grouped+ ]+ -- a confirmed transaction cannot be replaced, and the+ -- rules write it so: one that says otherwise is put right+ record = (td :: TxData) {timestamp = t, spenders = IntMap.empty, rbf = False}+ when (td.timestamp /= t || td.rbf || not (null keys)) $ do+ modifyIORef' pending ((insertOpCF (txCF db) (TxKey h) record : keys) <>)+ modifyIORef' changed (+ 1)+ n <- length <$> readIORef pending+ when (n >= 4000) write+ _ -> return ()+ n <- readIORef scanned+ writeIORef scanned (n + 1)+ when ((n + 1) `mod` 1000000 == 0) $ do+ c <- readIORef changed+ report $ cs (show (n + 1)) <> " transactions read, " <> cs (show c) <> " written again"+ -- the short keys of the column family are not transactions+ _ -> return ()+ go it =+ iterEntry it >>= \case+ Nothing -> iterGetError it >>= mapM_ (throwIO . Inconsistent . cs . show)+ Just row -> one row >> iterNext it >> go it+ -- every transaction, whatever its prefix: the column family has a+ -- prefix for its filters, and an iterator there promises the keys of+ -- one prefix only unless it is asked for the order of them all+ withIterConfig db (Just (txCF db)) def {totalOrderSeek = True} (\it -> iterFirst it >> go it)+ write+ writeBatch db [deleteOp MigrationKey]+ c <- readIORef changed+ n <- readIORef scanned+ report $ cs (show c) <> " of " <> cs (show n) <> " records of transactions written again"+ return c++-- | Migrate a database of version 20, opened with+-- 'Haskoin.Store.Database.Reader.withLayeredReader', to the layered layout,+-- or carry on a migration that was interrupted. Does nothing to a database+-- that is already there. Throws a 'MigrationError' otherwise.+migrateLayered :: DatabaseReader -> IO Migration+migrateLayered = migrateLayeredWith defaultMigrationOptions++-- | 'migrateLayered' with options.+migrateLayeredWith :: MigrationOptions -> DatabaseReader -> IO Migration+migrateLayeredWith opts r = do+ version <- retrieve db VersionKey+ marker <- retrieve db MigrationKey :: IO (Maybe Word32)+ if+ -- a database that is past this migration, whatever came after it+ | version == Just layeredVersion && marker == Nothing ->+ return Migration {moved = 0, batches = 0, checked = 0, rewritten = 0}+ | version == Just currentVersion ->+ return Migration {moved = 0, batches = 0, checked = 0, rewritten = 0}+ | version == Just layeredVersion && marker == Just mixedVersion -> do+ opts.report "Carrying on with the migration to the layered layout"+ run+ | version == Just mixedVersion -> do+ listed <- mempoolList+ mapM_ (preflight r) listed+ opts.report $ "Moving " <> cs (show (length listed)) <> " mempool transactions to their own area"+ -- from here on a store of version 20 refuses the database+ writeBatch db [insertOp VersionKey layeredVersion, insertOp MigrationKey mixedVersion]+ run+ | otherwise -> throwIO (UnknownVersion version)+ where+ db = r.db+ mempoolList = do+ rows <- matchingAsListCF db (mempoolCF db) MemTxKeyS+ return [h | (MemTxKey h, _ :: UnixTime) <- rows]+ run = do+ env <- newEnv opts r+ listed <- mempoolList+ mapM_ (moveTx env) listed+ flush env+ mapM_ (verify r) listed+ -- the summaries are worked out from the entries, all at once: doing it+ -- again after an interruption gives the same+ void (rebuildSummaries r)+ writeBatch db [deleteOp MigrationKey]+ moved <- readIORef env.total+ batches <- readIORef env.written+ opts.report $ "Moved " <> cs (show moved) <> " mempool transactions"+ return Migration {moved, batches, checked = length listed, rewritten = 0}++-------------------------------------------------------------------------------+-- A batch+-------------------------------------------------------------------------------++-- | A migration under way, and the batch that it is building: what it will+-- write, with the last value of everything that is read again before the+-- write.+data Env = Env+ { reader :: !DatabaseReader,+ options :: !MigrationOptions,+ -- | bodies in the chain's column family that the batch changes;+ -- 'Nothing' for one that it removes+ bodies :: !(IORef (HashMap TxHash (Maybe TxData))),+ -- | balances in the chain's column family that the batch changes+ balances :: !(IORef (HashMap Address BalVal)),+ -- | everything else that the batch writes, newest first: each of these+ -- keys is written without being read, and in this order+ ops :: !(IORef [BatchOp]),+ -- | transactions that the batch moves+ moving :: !(IORef (HashSet TxHash)),+ -- | transactions moved by this run+ total :: !(IORef Int),+ -- | batches written by this run+ written :: !(IORef Int)+ }++-- | A migration that has moved nothing yet, with an empty batch.+newEnv :: MigrationOptions -> DatabaseReader -> IO Env+newEnv options reader = do+ bodies <- newIORef HashMap.empty+ balances <- newIORef HashMap.empty+ ops <- newIORef []+ moving <- newIORef HashSet.empty+ total <- newIORef 0+ written <- newIORef 0+ return Env {reader, options, bodies, balances, ops, moving, total, written}++-- | Body of a transaction in the chain's column family, as the batch leaves+-- it.+chainBody :: Env -> TxHash -> IO (Maybe TxData)+chainBody env h =+ HashMap.lookup h <$> readIORef env.bodies >>= \case+ Just changed -> return changed+ Nothing -> retrieveCF db (txCF db) (TxKey h)+ where+ db = env.reader.db++-- | Change the balance of an address in the chain's column family, or stop+-- if the change cannot be made.+change :: Env -> Address -> (BalVal -> Either Text BalVal) -> IO ()+change env a f = do+ current <-+ HashMap.lookup a <$> readIORef env.balances >>= \case+ Just b -> return b+ Nothing -> maybe def id <$> retrieveCF db (balanceCF db) (BalKey a)+ new <- either (throwIO . Inconsistent) return (f current)+ modifyIORef' env.balances (HashMap.insert a new)+ where+ db = env.reader.db++-- | Add an operation to the batch.+op :: Env -> BatchOp -> IO ()+op env o = modifyIORef' env.ops (o :)++-- | Write the batch, and start another.+flush :: Env -> IO ()+flush env = do+ count <- HashSet.size <$> readIORef env.moving+ unless (count == 0) $ do+ others <- reverse <$> readIORef env.ops+ bodies <- HashMap.toList <$> readIORef env.bodies+ balances <- HashMap.toList <$> readIORef env.balances+ writeBatch db $+ others+ <> [maybe (deleteOpCF (txCF db) (TxKey h)) (insertOpCF (txCF db) (TxKey h)) b | (h, b) <- bodies]+ <> [balanceOp a b | (a, b) <- balances]+ writeIORef env.ops []+ writeIORef env.bodies HashMap.empty+ writeIORef env.balances HashMap.empty+ writeIORef env.moving HashSet.empty+ modifyIORef' env.written (+ 1)+ readIORef env.total >>= env.options.afterBatch+ where+ db = env.reader.db+ -- an address that only the mempool used has no balance in the chain+ balanceOp a b+ | b == def = deleteOpCF (balanceCF db) (BalKey a)+ | otherwise = insertOpCF (balanceCF db) (BalKey a) b++-------------------------------------------------------------------------------+-- Moving a transaction+-------------------------------------------------------------------------------++-- | Whether a transaction is already in the mempool area.+isMoved :: Env -> TxHash -> IO Bool+isMoved env h = do+ now <- HashSet.member h <$> readIORef env.moving+ if now then return True else isJust <$> getCF db (mempoolCF db) (encode (TxKey h))+ where+ db = env.reader.db++-- | Move a mempool transaction, and first the mempool transactions that+-- spend its outputs: taking those out is what makes its outputs unspent+-- again, as they must be for its own turn.+moveTx :: Env -> TxHash -> IO ()+moveTx env h = do+ done <- isMoved env h+ unless done $ do+ td <- mempoolBody+ forM_ (nub' (map (.txid) (IntMap.elems td.spenders))) (moveTx env)+ -- read again: moving the spenders removed them from the body+ td' <- mempoolBody+ unless (IntMap.null td'.spenders) . stop $ "still has spenders after they were moved"+ time <- case td'.block of+ MemRef t -> return t+ BlockRef {} -> stop "is confirmed"+ takeOut env td'+ mapM_ (op env) (insertRecordOps db (mempoolRecords env.reader.net env.reader.ctx time td'))+ modifyIORef' env.moving (HashSet.insert h)+ modifyIORef' env.total (+ 1)+ count <- HashSet.size <$> readIORef env.moving+ when (count >= env.options.chunk) (flush env)+ where+ db = env.reader.db+ stop :: Text -> IO a+ stop why = throwIO . Inconsistent $ "Mempool transaction " <> txHashToHex h <> " " <> why+ mempoolBody =+ chainBody env h >>= \case+ Just td | not td.deleted, not (confirmed td.block) -> return td+ Just td | td.deleted -> stop "is marked as deleted"+ Just _ -> stop "is confirmed"+ Nothing -> stop "has no body"++-- | Take a mempool transaction that nothing spends out of the indices of+-- the chain, as deleting it does in version 20, except that its body is+-- removed and not kept as deleted: it is not deleted, it moves.+takeOut :: Env -> TxData -> IO ()+takeOut env td = do+ -- the entries of its addresses, and their counts of transactions+ forM_ touched $ \a -> do+ op env (deleteOpCF (addrTxCF db) (AddrTxKey a TxRef {block = td.block, txid = h}))+ change env a $ \b -> do+ n <- less "transactions" b.txs 1+ return (b :: BalVal) {txs = n}+ -- its outputs, all of them unspent by now+ forM_ (zip [0 ..] td.tx.outputs) $ \(i, o) -> do+ let p = OutPoint h i+ op env (deleteOpCF (unspentCF db) (UnspentKey p))+ forM_ (address o.script) $ \a -> do+ op env (deleteOpCF (addrOutCF db) (AddrOutKey a td.block p))+ change env a $ \b -> do+ u <- less "unconfirmed balance" b.unconfirmed o.value+ n <- less "unspent outputs" b.utxo 1+ rx <- less "amount received" b.received o.value+ return (b :: BalVal) {unconfirmed = u, utxo = n, received = rx}+ -- the outputs it spends are unspent again, as before it came+ forM_ [i.outpoint | i <- td.tx.inputs, i.outpoint /= nullOutPoint] $ \p -> do+ parent <-+ chainBody env p.hash >>= \case+ Just t | not t.deleted -> return t+ _ -> stop $ "spends an output of " <> txHashToHex p.hash <> ", which is not there"+ o <- case drop (fromIntegral p.index) parent.tx.outputs of+ o : _ -> return o+ [] -> stop $ "spends an output that " <> txHashToHex p.hash <> " does not have"+ modifyIORef' env.bodies . HashMap.insert p.hash . Just $+ (parent :: TxData) {spenders = IntMap.delete (fromIntegral p.index) parent.spenders}+ op env . insertOpCF (unspentCF db) (UnspentKey p) $+ UnspentVal {block = parent.block, value = o.value, script = o.script}+ forM_ (address o.script) $ \a -> do+ op env (insertOpCF (addrOutCF db) (AddrOutKey a parent.block p) (OutVal o.value o.script))+ change env a $ \b ->+ return $+ if confirmed parent.block+ then (b :: BalVal) {confirmed = b.confirmed + o.value, utxo = b.utxo + 1}+ else (b :: BalVal) {unconfirmed = b.unconfirmed + o.value, utxo = b.utxo + 1}+ -- its body leaves the chain's column family+ modifyIORef' env.bodies (HashMap.insert h Nothing)+ where+ db = env.reader.db+ h = txHash td.tx+ address = eitherToMaybe . scriptToAddressBS env.reader.net env.reader.ctx+ touched =+ nub' $+ mapMaybe (address . (.script)) (IntMap.elems td.prevs)+ <> mapMaybe (address . (.script)) td.tx.outputs+ stop :: Text -> IO a+ stop why = throwIO . Inconsistent $ "Mempool transaction " <> txHashToHex h <> " " <> why+ -- a balance never goes below zero in a database that is in order+ less :: Text -> Word64 -> Word64 -> Either Text Word64+ less what x y+ | y > x = Left $ "Taking out " <> txHashToHex h <> " would make the " <> what <> " of an address negative"+ | otherwise = Right (x - y)++-------------------------------------------------------------------------------+-- Checks+-------------------------------------------------------------------------------++-- | Before anything is written: a transaction that the mempool lists must+-- have the body of a mempool transaction.+preflight :: DatabaseReader -> TxHash -> IO ()+preflight r h =+ (retrieveCF r.db (txCF r.db) (TxKey h) :: IO (Maybe TxData)) >>= \case+ Just td | not td.deleted, not (confirmed td.block) -> return ()+ _ ->+ throwIO . Inconsistent $+ "The mempool lists " <> txHashToHex h <> ", which is not an unconfirmed transaction"++-- | After everything is moved: a mempool transaction is in the mempool+-- area and has left nothing in the chain's column families, and every+-- output it spends is there to be spent, in the mempool or unspent in the+-- chain.+verify :: DatabaseReader -> TxHash -> IO ()+verify r h = do+ td <-+ retrieveCF db (mempoolCF db) (TxKey h) >>= \case+ Just td -> return (td :: TxData)+ Nothing -> stop "is not in the mempool area"+ inChain <- isJust <$> getCF db (txCF db) (encode (TxKey h))+ when inChain $ stop "still has a body in the chain"+ forM_ (zip [0 ..] td.tx.outputs) $ \(i, _) -> do+ left <- isJust <$> getCF db (unspentCF db) (encode (UnspentKey (OutPoint h i)))+ when left $ stop "still has an unspent output in the chain"+ forM_ [i.outpoint | i <- td.tx.inputs, i.outpoint /= nullOutPoint] $ \p -> do+ inMempool <- isJust <$> getCF db (mempoolCF db) (encode (TxKey p.hash))+ unspent <- isJust <$> getCF db (unspentCF db) (encode (UnspentKey p))+ unless (inMempool || unspent) . void . stop $+ "spends an output of " <> txHashToHex p.hash <> " that is neither in the mempool nor unspent"+ where+ db = r.db+ stop :: Text -> IO a+ stop why = throwIO . Inconsistent $ "After the migration, mempool transaction " <> txHashToHex h <> " " <> why
@@ -0,0 +1,696 @@+-- |+-- Module : Haskoin.Store.Database.Query+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- Queries against the RocksDB database, as plain functions in 'IO' that take+-- the 'DatabaseReader' as their first argument.+--+-- These read one layer. On a database of version 20 that is everything,+-- the mempool included, and the tests use them so. On the layout that the+-- store runs on, they give what the chain has and nothing of the mempool,+-- but for its list: "Haskoin.Store.Database.Query.Layered" calls them for+-- the chain's part of its answers, and is what the store answers with.+--+-- = Why plain functions+--+-- The queries used to be the methods of two classes, run in+-- @ReaderT DatabaseReader@, and reached the web server through more layers of+-- @ReaderT@ for the cache. Every caller then+-- had to live in a stack of monad transformers to ask the database anything,+-- and every layer had to lift or re-implement every method. A function that+-- takes the reader and runs in 'IO' can be called from anywhere, needs no+-- instance, and says in its type exactly what it reads from.+--+-- Streams are still conduits, run in 'IO': they are a good fit for merging the+-- sorted indices of many addresses and reading only as far as a page needs.+module Haskoin.Store.Database.Query+ ( -- * All of them+ databaseRead,++ -- * Blocks+ getBestBlock,+ getBlocksAtHeight,+ getBlock,++ -- * Transactions+ getTxData,+ getTxRecord,+ getManyTxData,+ getChainSpender,+ getChainSpenders,+ withChainSpenders,+ getNumTxData,+ getSpender,++ -- * Addresses+ getBalance,+ getBalances,+ getUnspent,+ getAddressTxs,+ getAddressesTxs,+ getAddressUnspents,+ getAddressesUnspents,++ -- * Mempool+ getMempool,+ getMempoolPage,+ getMempoolBefore,++ -- * Extended public keys+ xPubBals,+ xPubUnspents,+ xPubTxs,+ xPubTxCount,+ getAddressesTxCount,++ -- * Building blocks++ -- | What the queries above are made of, for the queries of the layered+ -- layout in "Haskoin.Store.Database.Query.Layered", which read the same+ -- column families for the part of their answers that is in the chain.+ count,+ withManyIters,+ retrieveManyCF,+ joinConduits,+ usedAddresses,+ )+where++import Conduit+ ( ConduitT,+ concatC,+ concatMapC,+ dropWhileC,+ lift,+ mapC,+ runConduit,+ sinkList,+ takeC,+ takeWhileC,+ yield,+ (.|),+ )+import Control.Monad (forM, (>=>))+import Data.Bits ((.&.))+import Data.ByteString qualified as BS+import Data.Default (def)+import Data.Functor ((<&>))+import Data.IntMap.Strict qualified as IntMap+import Data.Map.Strict qualified as Map+import Data.Maybe (catMaybes, listToMaybe)+import Data.Ord (Down (..))+import Data.Serialize (Serialize, decode, encode)+import Data.Word (Word32, Word64)+import Database.RocksDB+ ( ColumnFamily,+ DB,+ IterConfig (..),+ Iterator,+ createIteratorConfig,+ getCF,+ iterKey,+ iterLast,+ iterNext,+ iterPrev,+ iterSeek,+ iterValid,+ multiGetCF,+ prefixUpperBound,+ withIterCF,+ withIterConfig,+ )+import Database.RocksDB.Query+ ( matching,+ matchingAsListCF,+ matchingSkip,+ retrieve,+ retrieveCF,+ )+import Haskoin+ ( Address,+ BlockHash,+ BlockHeight,+ OutPoint (..),+ Tx (..),+ TxHash,+ )+import Haskoin.Store.Common+ ( Limits (..),+ Start (..),+ StoreRead (..),+ applyLimitsC,+ deOffset,+ joinDescStreams,+ mergeDescGroups,+ notDeleted,+ scanXPubBals,+ txRefKey,+ unspentKey,+ )+import Haskoin.Store.Data+import Haskoin.Store.Database.Reader+import Haskoin.Store.Database.Types+import System.Metrics.StatsD (decrementGauge, incrementCounter, incrementGauge)+import UnliftIO+ ( bracket,+ bracket_,+ mask_,+ modifyIORef',+ newIORef,+ readIORef,+ )++-- | The queries of a database as a 'StoreRead', for code that takes its+-- queries as a value, such as the web server, which puts the Redis cache in+-- front of some of them. Transactions that were deleted are left out+-- ('notDeleted'); the functions of this module still return them, for the+-- code that imports transactions.+databaseRead :: DatabaseReader -> StoreRead+databaseRead r =+ StoreRead+ { net = r.net,+ ctx = r.ctx,+ getBestBlock = getBestBlock r,+ getBlocksAtHeight = getBlocksAtHeight r,+ getBlock = getBlock r,+ getTxData = fmap notDeleted . getTxData r,+ getManyTxData = fmap (map notDeleted) . getManyTxData r,+ getSpender = getSpender r,+ getBalance = getBalance r,+ getUnspent = getUnspent r,+ getMempool = getMempool r,+ getMempoolPage = getMempoolPage r,+ getAddressesTxs = getAddressesTxs r,+ getAddressesUnspents = getAddressesUnspents r,+ initialGap = r.initGap,+ maxGap = r.maxGap,+ getNumTxData = fmap (filter (not . (.deleted))) . getNumTxData r,+ getBalances = getBalances r,+ getAddressTxs = getAddressTxs r,+ getAddressUnspents = getAddressUnspents r,+ xPubBals = xPubBals r,+ xPubUnspents = \_ -> xPubUnspents r,+ xPubTxs = \_ -> xPubTxs r,+ xPubTxCount = \_ -> xPubTxCount r,+ getAddressesTxCount = getAddressesTxCount r+ }++-- | Count something in the metrics, if they are enabled.+count :: DatabaseReader -> (DataMetrics -> IO ()) -> IO ()+count r f = mapM_ f r.metrics++-------------------------------------------------------------------------------+-- Blocks+-------------------------------------------------------------------------------++-- | Hash of the best block, if the database has any.+getBestBlock :: DatabaseReader -> IO (Maybe BlockHash)+getBestBlock r = do+ count r $ \s -> incrementCounter s.dataBestCount 1+ retrieve r.db BestKey++-- | Hashes of the blocks at a height, of the main chain and of side chains.+getBlocksAtHeight :: DatabaseReader -> BlockHeight -> IO [BlockHash]+getBlocksAtHeight r h =+ retrieveCF r.db (heightCF r.db) (HeightKey h) >>= \case+ Nothing -> return []+ Just ls -> do+ count r $ \s -> incrementCounter s.dataBlockCount (length ls)+ return ls++-- | Data of a block.+getBlock :: DatabaseReader -> BlockHash -> IO (Maybe BlockData)+getBlock r h =+ retrieveCF r.db (blockCF r.db) (BlockKey h) >>= \case+ Nothing -> return Nothing+ Just b -> do+ count r $ \s -> incrementCounter s.dataBlockCount 1+ return (Just b)++-------------------------------------------------------------------------------+-- Transactions+-------------------------------------------------------------------------------++-- | Data of a transaction, including deleted ones, with the inputs of+-- blocks that spend its outputs.+getTxData :: DatabaseReader -> TxHash -> IO (Maybe TxData)+getTxData r th =+ getTxRecord r th >>= \case+ Nothing -> return Nothing+ Just t -> do+ count r $ \s -> incrementCounter s.dataTxCount 1+ listToMaybe <$> withChainSpenders r [(th, t)]++-- | The record of a transaction as the database has it, including deleted+-- ones. Since version 22 the spenders of its outputs are not in it+-- ('getChainSpenders'); a database of version 20 or 21 has them there.+-- This is what the rules that write the database read: they must not+-- write back what was read from somewhere else.+getTxRecord :: DatabaseReader -> TxHash -> IO (Maybe TxData)+getTxRecord r th = retrieveCF r.db (txCF r.db) (TxKey th)++-- | Data of many transactions, in the order of the hashes, read with one+-- multi-get, with the inputs of blocks that spend their outputs.+getManyTxData :: DatabaseReader -> [TxHash] -> IO [Maybe TxData]+getManyTxData r ths = do+ found <- retrieveManyCF r.db (txCF r.db) (map TxKey ths)+ count r $ \s -> incrementCounter s.dataTxCount (length (catMaybes found))+ spent <- withChainSpenders r [(h, td) | (h, Just td) <- zip ths found]+ return (fill found spent)+ where+ fill (Just _ : more) (td : tds) = Just td : fill more tds+ fill (Nothing : more) tds = Nothing : fill more tds+ fill _ _ = []++-- | The input of a block that spends an output, if one does: one read by+-- key, of the group of outputs that it is in, in a database of version 22.+getChainSpender :: DatabaseReader -> OutPoint -> IO (Maybe Spender)+getChainSpender r p =+ retrieveCF r.db (spenderCF r.db) (spenderKey p) <&> \case+ Just (SpenderVal m) -> IntMap.lookup (fromIntegral p.index) m+ Nothing -> Nothing++-- | The inputs of blocks that spend the outputs of a transaction, by+-- output, read with one scan of the keys of the transaction.+getChainSpenders :: DatabaseReader -> TxHash -> IO (IntMap.IntMap Spender)+getChainSpenders r h = do+ rows <- matchingAsListCF r.db (spenderCF r.db) (SpenderKeyS h)+ return (IntMap.unions [m | (_ :: SpenderKey, SpenderVal m) <- rows])++-- | Records of transactions, with the inputs of blocks that spend their+-- outputs added to whatever spenders the records have themselves, which+-- those of a database older than version 22 do.+--+-- The spenders are read by the keys of the groups of outputs, all the+-- groups of all the transactions in one multi-get. A group is sixteen+-- outputs, so nearly every transaction is one key, and a page of a+-- hundred transactions is one more read of a hundred keys. A scan would+-- need an iterator for each transaction, at ten microseconds apiece. Only+-- a transaction with more groups than 'wideTx' is scanned, so that a+-- payout to thousands is not hundreds of keys to ask for.+--+-- Each record comes with the hash of its transaction, which whoever read+-- it by its key has: working it out again from the record is two rounds+-- of SHA-256 over the whole transaction, and cost more than everything+-- else here together when this was first written without it.+withChainSpenders :: DatabaseReader -> [(TxHash, TxData)] -> IO [TxData]+withChainSpenders _ [] = return []+withChainSpenders r tds = do+ let groups td = (fromIntegral (length td.tx.outputs) + spenderGroup - 1) `div` spenderGroup+ keys (h, td) = [SpenderKey h g | g <- take (fromIntegral (groups td)) [0 ..]]+ narrow (_, td) = groups td <= wideTx+ asked = concatMap keys (filter narrow tds)+ found <- case asked of+ [] -> return []+ _ -> retrieveManyCF r.db (spenderCF r.db) asked+ let byKey = Map.fromList [(k, m) | (k, Just (SpenderVal m)) <- zip asked found]+ keyed x = IntMap.unions [m | k <- keys x, Just m <- [Map.lookup k byKey]]+ forM tds $ \x@(h, td) -> do+ chain <- if narrow x then return (keyed x) else getChainSpenders r h+ return (td :: TxData) {spenders = IntMap.union td.spenders chain}++-- | The number of groups of outputs above which the spenders of a+-- transaction are read with a scan instead of by key.+wideTx :: Word32+wideTx = 16++-- | Transactions whose hash, read as a number, matches a short identifier of+-- the Blockchain.info API. There may be more than one.+getNumTxData :: DatabaseReader -> Word64 -> IO [TxData]+getNumTxData r i = do+ let (sk, w) = decodeTxKey i+ ls <- matchingAsListCF r.db (txCF r.db) (TxKeyS sk)+ let f h =+ let bs = encode h+ b = BS.head (BS.drop 6 bs)+ w' = b .&. 0xf8+ in w == w'+ txs = [(h, td) | (TxKey h, td) <- ls, f h]+ count r $ \s -> incrementCounter s.dataTxCount (length txs)+ withChainSpenders r txs++-- | Transaction that spends an output, if any: by its key, or, in a+-- database older than version 22, from the record of the transaction that+-- made the output.+getSpender :: DatabaseReader -> OutPoint -> IO (Maybe Spender)+getSpender r op =+ getChainSpender r op >>= \case+ Just s -> return (Just s)+ Nothing ->+ getTxRecord r op.hash <&> \case+ Nothing -> Nothing+ Just td -> IntMap.lookup (fromIntegral op.index) td.spenders++-------------------------------------------------------------------------------+-- Addresses+-------------------------------------------------------------------------------++-- | Balance of an address, if it was ever used.+getBalance :: DatabaseReader -> Address -> IO (Maybe Balance)+getBalance r a = do+ count r $ \s -> incrementCounter s.dataBalanceCount 1+ fmap (valToBalance a) <$> retrieveCF r.db (balanceCF r.db) (BalKey a)++-- | Balances of addresses, in the same order, read with one multi-get.+-- Addresses never used get a zero balance.+getBalances :: DatabaseReader -> [Address] -> IO [Balance]+getBalances _ [] = return []+getBalances r as = do+ count r $ \s -> incrementCounter s.dataBalanceCount (length as)+ vals <- retrieveManyCF r.db (balanceCF r.db) (map BalKey as)+ return (zipWith f as vals)+ where+ f a Nothing = zeroBalance a+ f a (Just v) = valToBalance a v++-- | Unspent output, if it exists and is unspent.+getUnspent :: DatabaseReader -> OutPoint -> IO (Maybe Unspent)+getUnspent r p =+ retrieveCF r.db (unspentCF r.db) (UnspentKey p) >>= \case+ Nothing -> return Nothing+ Just val -> do+ count r $ \s -> incrementCounter s.dataUnspentCount 1+ return (Just (valToUnspent r.net r.ctx p val))++-- | Transactions of an address, newest first.+getAddressTxs :: DatabaseReader -> Address -> Limits -> IO [TxRef]+getAddressTxs r a limits = do+ txs <- withIterCFStat r (addrTxCF r.db) (AddrTxKeyA a) $ \it ->+ runConduit $+ addressConduit r a limits.start it+ .| applyLimitsC limits+ .| sinkList+ count r $ \s -> incrementCounter s.dataAddrTxCount (length txs)+ return txs++-- | Transactions of any of the addresses, newest first, each once.+getAddressesTxs :: DatabaseReader -> [Address] -> Limits -> IO [TxRef]+getAddressesTxs r addrs limits =+ withManyIters r (addrTxCF r.db) (map AddrTxKeyA addrs) $ \its -> do+ txs <- joinConduits txRefKey (zipWith (\a -> addressConduit r a limits.start) addrs its) limits+ count r $ \s -> incrementCounter s.dataAddrTxCount (length txs)+ return txs++-- | Unspent outputs of an address, newest first.+getAddressUnspents :: DatabaseReader -> Address -> Limits -> IO [Unspent]+getAddressUnspents r a limits = do+ us <- withIterCFStat r (addrOutCF r.db) (AddrOutKeyA a) $ \it ->+ runConduit $+ unspentConduit r a limits.start it+ .| applyLimitsC limits+ .| sinkList+ count r $ \s -> incrementCounter s.dataUnspentCount (length us)+ return us++-- | Unspent outputs of any of the addresses, newest first.+getAddressesUnspents :: DatabaseReader -> [Address] -> Limits -> IO [Unspent]+getAddressesUnspents r addrs limits =+ withManyIters r (addrOutCF r.db) (map AddrOutKeyA addrs) $ \its -> do+ uns <- joinConduits unspentKey (zipWith (\a -> unspentConduit r a limits.start) addrs its) limits+ count r $ \s -> incrementCounter s.dataUnspentCount (length uns)+ return uns++-- | Stream the transactions of an address, newest first, from a starting point.+-- If the start is a transaction that is not found, nothing is streamed.+addressConduit ::+ DatabaseReader ->+ Address ->+ Maybe Start ->+ Iterator ->+ ConduitT i TxRef IO ()+addressConduit r a s it =+ pipe .| mapC (uncurry f)+ where+ f (AddrTxKey _ t) () = t+ f _ _ = undefined+ pipe = case s of+ Nothing ->+ matching it (AddrTxKeyA a)+ Just (AtBlock bh) ->+ matchingSkip it (AddrTxKeyA a) (AddrTxKeyB a (BlockRef bh maxBound))+ Just (AtTx txh) ->+ lift (getTxRecord r txh) >>= \case+ Just TxData {block = b@BlockRef {}} ->+ matchingSkip it (AddrTxKeyA a) (AddrTxKeyB a b)+ Just TxData {block = b@MemRef {}} ->+ -- Mempool transactions that arrived in the same second are+ -- keyed by hash, so seek to the time and drop those before it.+ let start = txRefKey (TxRef b txh)+ before (AddrTxKey _ t, ()) = txRefKey t > start+ before _ = undefined+ in matchingSkip it (AddrTxKeyA a) (AddrTxKeyB a b)+ .| (dropWhileC before >> mapC id)+ Nothing -> return ()++-- | Stream the unspent outputs of an address, newest first, from a starting+-- point. If the start is a transaction that is not found, nothing is streamed.+unspentConduit ::+ DatabaseReader ->+ Address ->+ Maybe Start ->+ Iterator ->+ ConduitT i Unspent IO ()+unspentConduit r a s it =+ x .| mapC (uncurry (toUnspent r.net r.ctx)) .| concatC+ where+ x = case s of+ Nothing ->+ matching it (AddrOutKeyA a)+ Just (AtBlock h) ->+ matchingSkip it (AddrOutKeyA a) (AddrOutKeyB a (BlockRef h maxBound))+ Just (AtTx txh) ->+ lift (getTxRecord r txh) >>= \case+ Just TxData {block = b@BlockRef {}} ->+ matchingSkip it (AddrOutKeyA a) (AddrOutKeyB a b)+ Just TxData {block = b@MemRef {}} ->+ -- The outputs of the start transaction come first among those+ -- of the same second whose hashes are not smaller.+ let start = (b, Down (encode (OutPoint txh 0)))+ before (AddrOutKey _ ref p, _) = (ref, Down (encode p)) > start+ before _ = undefined+ in matchingSkip it (AddrOutKeyA a) (AddrOutKeyB a b)+ .| (dropWhileC before >> mapC id)+ Nothing -> return ()++-------------------------------------------------------------------------------+-- Mempool+-------------------------------------------------------------------------------++-- | The whole mempool, newest first.+getMempool :: DatabaseReader -> IO [(UnixTime, TxHash)]+getMempool r = getMempoolPage r def++-- | Page of the mempool, newest first, from the offset and limit of the+-- 'Limits'; its start is ignored. Reads only the entries of the page from+-- the index by time.+getMempoolPage :: DatabaseReader -> Limits -> IO [(UnixTime, TxHash)]+getMempoolPage r limits = do+ count r $ \s -> incrementCounter s.dataMempoolCount 1+ withIterCF r.db (mempoolCF r.db) $ \it ->+ runConduit $+ mempoolNewestC it+ .| applyLimitsC limits+ .| sinkList++-- | Mempool transactions that entered it before the given time, oldest+-- first, at most as many as given. Reads only those entries of the index.+getMempoolBefore :: DatabaseReader -> UnixTime -> Int -> IO [(UnixTime, TxHash)]+getMempoolBefore r time n =+ withIterCF r.db (mempoolCF r.db) $ \it ->+ runConduit $+ matching it MemTimeKeyS+ .| concatMapC entry+ .| takeWhileC ((< time) . fst)+ .| takeC n+ .| sinkList+ where+ entry (MemTimeKey t h, ()) = Just (t, h)+ entry (MemTimeKeyS, ()) = Nothing++-- | Mempool from the index by time, newest first. The index sorts last in+-- the mempool column family, so seek past its prefix and walk backwards+-- until a key is no longer in it.+mempoolNewestC :: Iterator -> ConduitT i (UnixTime, TxHash) IO ()+mempoolNewestC it = do+ lift $ do+ -- 0x0c is the first prefix after the 0x0b of 'MemTimeKey'+ iterSeek it (BS.singleton 0x0c)+ iterValid it >>= \case+ True -> iterPrev it+ False -> iterLast it+ go+ where+ go =+ lift (iterKey it) >>= \case+ Just k | Right (MemTimeKey t h) <- decode k -> do+ yield (t, h)+ lift (iterPrev it)+ go+ _ -> return ()++-------------------------------------------------------------------------------+-- Extended public keys+-------------------------------------------------------------------------------++-- | Balances of the addresses of an extended public key, derived up to the+-- exact gap (see 'scanXPubBals').+xPubBals :: DatabaseReader -> XPubSpec -> IO [XPubBal]+xPubBals r xpub = do+ Derived ext chg <- derivedAddrs r xpub+ bals <- scanXPubBals (getBalances r) r.initGap r.maxGap ext chg+ count r $ \s -> incrementCounter s.dataXPubBals (length bals)+ return bals++-- | Unspent outputs of an extended public key, newest first, given its+-- balances from 'xPubBals'.+--+-- The first unspent outputs of each address, at most offset plus limit of+-- them, sorted together. The addresses are read in step, so that each is+-- read only as far as the page needs, rather than decoding the whole quota+-- of every address to keep one page of it.+xPubUnspents :: DatabaseReader -> [XPubBal] -> Limits -> IO [XPubUnspent]+xPubUnspents r xbals limits = do+ let quota = case (deOffset limits).limit of+ 0 -> Nothing+ l -> Just l+ stream b it =+ unspentConduit r b.balance.address limits.start it+ .| mapC (\u -> XPubUnspent {path = b.path, unspent = u})+ us <- withManyIters r (addrOutCF r.db) (map (AddrOutKeyA . (.balance.address)) cs) $ \its ->+ runConduit $+ mergeDescGroups (.unspent.block) (unspentKey . (.unspent)) quota (zipWith stream cs its)+ .| applyLimitsC limits+ .| sinkList+ count r $ \s -> incrementCounter s.dataXPubUnspents (length us)+ return us+ where+ cs = filter ((> 0) . (.balance.utxo)) xbals++-- | Transactions of an extended public key, newest first, given its balances+-- from 'xPubBals'.+xPubTxs :: DatabaseReader -> [XPubBal] -> Limits -> IO [TxRef]+xPubTxs r xbals limits = do+ txs <- getAddressesTxs r (usedAddresses xbals) limits+ count r $ \s -> incrementCounter s.dataXPubTxs (length txs)+ return txs++-- | Number of transactions of an extended public key, given its balances+-- from 'xPubBals'.+--+-- Each transaction is counted once even when it touches several addresses.+-- The entries of an address are sorted, and the same transaction has the same+-- key after the address prefix in every address, so the addresses are merged+-- by those raw key bytes and the distinct ones counted, without decoding any+-- key or reading any value.+xPubTxCount :: DatabaseReader -> [XPubBal] -> IO Word32+xPubTxCount r xbals = do+ count r $ \s -> incrementCounter s.dataXPubTxCount 1+ getAddressesTxCount r (usedAddresses xbals)++-- | Number of distinct transactions of a set of addresses: each transaction+-- is counted once even when it touches several of them. It reads only the+-- keys of the index, once each, as 'xPubTxCount' does, so it costs about as+-- much as reading the references of the transactions once, and much less+-- than paging through them.+getAddressesTxCount :: DatabaseReader -> [Address] -> IO Word32+getAddressesTxCount _ [] = return 0+getAddressesTxCount r as =+ withManyIters r (addrTxCF r.db) (map AddrTxKeyA as) $ \its ->+ fromIntegral <$> countDistinctSuffixes (zip (map (encode . AddrTxKeyA) as) its)++-- | Addresses of the balances that were ever used.+usedAddresses :: [XPubBal] -> [Address]+usedAddresses = map (.address) . filter (not . nullBalance) . map (.balance)++-- | Number of distinct keys, after their prefix, in a set of iterators that+-- each walk the keys with one prefix, in order.+countDistinctSuffixes :: [(BS.ByteString, Iterator)] -> IO Int+countDistinctSuffixes streams = do+ heads <- mapM start streams+ go 0 (Map.fromListWith (<>) [(k, [x]) | (k, x) <- catMaybes heads])+ where+ start x@(pfx, it) = iterSeek it pfx >> current x+ current x@(pfx, it) =+ iterKey it <&> \case+ Just k | pfx `BS.isPrefixOf` k -> Just (BS.drop (BS.length pfx) k, x)+ _ -> Nothing+ advance x@(_, it) = iterNext it >> current x+ go !n m = case Map.minViewWithKey m of+ Nothing -> return n+ Just ((_, xs), m') -> do+ nexts <- catMaybes <$> mapM advance xs+ go (n + 1) (foldr (\(k, x) -> Map.insertWith (<>) k [x]) m' nexts)++-------------------------------------------------------------------------------+-- Iterators and multi-gets+-------------------------------------------------------------------------------++-- | Run an action with an iterator on a column family, counting it in the+-- metrics.+--+-- The iterator is for the keys that start with a prefix, and RocksDB+-- stops it where they end ('scanOf').+withIterCFStat :: (Serialize k) => DatabaseReader -> ColumnFamily -> k -> (Iterator -> IO a) -> IO a+withIterCFStat r cf prefix f =+ bracket_ (gauge r 1) (gauge r (-1)) $ withIterConfig r.db (Just cf) (scanOf prefix) f++-- | An iterator for the keys that start with a prefix, such as those of one+-- address: one that RocksDB itself stops at the end of them.+--+-- A scan that stops by looking at the key that it has reached makes+-- RocksDB find that key first, and if the keys after the prefix were+-- deleted, it steps over the marker of every one of them to get there.+-- With an upper bound it stops at the bound. The summary of the mempool+-- area and the layout of the chain keep such runs of deleted keys short as+-- things are, so this is for the day that they do not: after a+-- reorganisation that reverts many blocks, or a change of layout.+scanOf :: (Serialize k) => k -> IterConfig+scanOf prefix = def {upperBound = prefixUpperBound (encode prefix)}++-- | Run an action with this many iterators on a column family. They are+-- opened and closed in a single bracket rather than one bracket each: each+-- bracket leaves a frame on the stack, and every call into RocksDB through+-- an iterator walks the stack, so with one bracket per address an xpub of a+-- hundred addresses made each step of each iterator a hundred times slower.+--+-- There is one iterator for each of the prefixes given, in their order,+-- and each is bounded to the keys of its prefix ('scanOf').+withManyIters :: (Serialize k) => DatabaseReader -> ColumnFamily -> [k] -> ([Iterator] -> IO a) -> IO a+withManyIters r cf prefixes f =+ bracket_ (gauge r i) (gauge r (negate i)) $+ bracket (newIORef []) (readIORef >=> sequence_) $ \ref -> do+ its <- forM prefixes $ \prefix -> mask_ $ do+ (it, destroy) <- createIteratorConfig r.db (Just cf) (scanOf prefix)+ modifyIORef' ref (destroy :)+ return it+ f its+ where+ i = length prefixes++-- | Change the gauge of open iterators.+gauge :: DatabaseReader -> Int -> IO ()+gauge r n = count r $ \s ->+ if n >= 0 then incrementGauge s.dataIters n else decrementGauge s.dataIters (negate n)++-- | Values of many keys of a column family, read with one multi-get, in+-- the order of the keys.+--+-- One key is read by itself. A multi-get costs about as much as a read to+-- set up and a third of one for each key, as measured, so it is the slower+-- way to read one key, and most requests about one transaction or one+-- address ask for one.+retrieveManyCF :: (Serialize key, Serialize value) => DB -> ColumnFamily -> [key] -> IO [Maybe value]+retrieveManyCF _ _ [] = return []+retrieveManyCF db cf [key] = pure . fmap (either error id . decode) <$> getCF db cf (encode key)+retrieveManyCF db cf keys =+ map (fmap (either error id . decode)) <$> multiGetCF db cf (map encode keys)++-- | Merge streams sorted newest first, drop duplicates, and apply the offset+-- and limit.+joinConduits :: (Ord k) => (o -> k) -> [ConduitT () o IO ()] -> Limits -> IO [o]+joinConduits key cs l =+ runConduit $ joinDescStreams key cs .| applyLimitsC l .| sinkList
@@ -0,0 +1,807 @@+-- |+-- Module : Haskoin.Store.Database.Query.Layered+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- The queries of "Haskoin.Store.Database.Query" for the layered layout of+-- the database, in which the chain and the mempool are kept apart; see+-- @docs\/layered-database.md@. These are the queries that the store+-- answers with. Each answers as the query of the same name answers on a+-- database of version 20, pages included. The tests check that, on+-- databases migrated from that version and on databases built by the rules+-- of "Haskoin.Store.Logic.Layered".+--+-- = Two sources+--+-- A query reads the column families of the chain, with the functions of+-- "Haskoin.Store.Database.Query", reads what the mempool area has for the+-- same transaction, output or address, with those of+-- "Haskoin.Store.Database.Mempool", and puts the two together.+--+-- = Why a view+--+-- The two reads must see the database at the same moment. A block could+-- confirm a transaction between them. Read from the mempool before the block+-- and from the chain after it, the transaction would be listed twice, and+-- its payment counted twice in a balance; read the other way around, it+-- would be missing. The queries of version 20 need no such care, because each of their+-- answers is one key or one scan.+--+-- So every query takes a t'View', a snapshot of the database, and 'withView'+-- is the only way to get one: a query cannot be given the database itself by+-- mistake. The web server is meant to take one view for each request. That+-- also makes the several queries of a request agree with each other, which+-- they did not have to in version 20.+--+-- = Pages+--+-- Lists are ordered newest first, and unconfirmed transactions are newer+-- than any block. The list of an address is therefore what the mempool has+-- for it followed by what the chain has, and that is the sequence that the+-- single index of version 20 holds. A page is cut from it by the same+-- rules: where it starts, then the offset, then the limit. Both parts are+-- streamed, each from its own index, only as far as the page needs.+--+-- = Reading no more than the answer+--+-- Nearly every address has nothing in the mempool, and a few have thousands+-- of entries there. Each address that the mempool touches has a summary of+-- its entries, a t'MemSummary', which every query about an address reads+-- first, by key. A balance is the chain's balance with the summary applied.+-- A list reads the entries only of the addresses that have a summary, and+-- the summary says how many outputs the mempool spends, so that no more are+-- looked for. The first version of these queries scanned the entries of+-- every address for every question, which measured ten thousand times+-- slower for a busy address, and a hundred times slower for any address+-- after a block, when the scan steps over all that the block deleted.+module Haskoin.Store.Database.Query.Layered+ ( -- * A view of the database+ View,+ withView,+ layeredRead,+ standaloneRead,+ viewReader,++ -- * Parts shared with the queries of a fleet+ From (..),+ chainTxsC,+ chainUnspentsC,+ withSpenders,++ -- * Blocks+ getBestBlock,+ getBlocksAtHeight,+ getBlock,++ -- * Transactions+ getTxData,+ getManyTxData,+ getNumTxData,+ getSpender,++ -- * Addresses+ getBalance,+ getBalances,+ getUnspent,+ getAddressTxs,+ getAddressesTxs,+ getAddressUnspents,+ getAddressesUnspents,++ -- * Mempool+ getMempool,+ getMempoolPage,++ -- * Extended public keys+ xPubBals,+ xPubUnspents,+ xPubTxs,+ xPubTxCount,+ getAddressesTxCount,+ )+where++import Conduit+ ( ConduitT,+ await,+ awaitForever,+ concatMapC,+ lift,+ mapC,+ runConduit,+ sinkList,+ yield,+ yieldMany,+ (.|),+ )+import Control.Monad (forM)+import Data.Bits ((.&.))+import Data.ByteString qualified as BS+import Data.Functor ((<&>))+import Data.HashMap.Strict (HashMap)+import Data.HashMap.Strict qualified as HashMap+import Data.HashSet qualified as HashSet+import Data.IntMap.Strict (IntMap)+import Data.IntMap.Strict qualified as IntMap+import Data.List (partition)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Maybe (catMaybes, isJust)+import Data.Serialize (Serialize, encode)+import Data.Word (Word32, Word64)+import Database.RocksDB (ColumnFamily, Iterator, multiGetCF, withSnapshot)+import Database.RocksDB.Query+ ( matching,+ matchingAsListCF,+ matchingSkip,+ retrieveCF,+ )+import Haskoin+ ( Address,+ BlockHash,+ BlockHeight,+ OutPoint (..),+ Tx (..),+ TxHash,+ TxOut (..),+ eitherToMaybe,+ scriptToAddressBS,+ )+import Haskoin.Store.Common+ ( Limits (..),+ Start (..),+ StoreRead (..),+ applyLimitsC,+ deOffset,+ mergeDescGroups,+ notDeleted,+ scanXPubBals,+ txRefKey,+ unspentKey,+ )+import Haskoin.Store.Data+ ( Balance (..),+ BlockData,+ BlockRef (..),+ Spender,+ TxData (..),+ TxRef (..),+ UnixTime,+ Unspent (..),+ XPubBal (..),+ XPubSpec,+ XPubUnspent (..),+ )+import Haskoin.Store.Database.Mempool qualified as M+import Haskoin.Store.Database.Query qualified as Q+import Haskoin.Store.Database.Reader+ ( DataMetrics (..),+ DatabaseReader (..),+ Derived (..),+ addrOutCF,+ addrTxCF,+ derivedAddrs,+ mempoolAddrCF,+ mempoolCF,+ txCF,+ )+import Haskoin.Store.Database.Types+ ( AddrOutKey (..),+ AddrTxKey (..),+ MemAddrKey (..),+ MemAddrVal (..),+ MemSpenderKey (..),+ MemSummary (..),+ OutVal,+ TxKey (..),+ decodeTxKey,+ toUnspent,+ )+import System.Metrics.StatsD (incrementCounter)++-------------------------------------------------------------------------------+-- A view of the database+-------------------------------------------------------------------------------++-- | A database of the layered layout as it was at one moment: both of its+-- areas, read through one RocksDB snapshot. Whatever is written after the+-- view was taken, the queries on it go on answering from that moment.+newtype View = View DatabaseReader++-- | The reader of a view: the database as it was at the moment of the+-- view. The queries of "Haskoin.Store.Database.Query" on it give what the+-- chain had then.+viewReader :: View -> DatabaseReader+viewReader (View r) = r++-- | Run an action with a view of a database as it is now.+--+-- Taking a view costs about a microsecond, so there is no reason to share+-- one between requests, and a reason not to: RocksDB keeps the data that a+-- view can see for as long as the view is open.+--+-- The view, and anything made from it, such as the queries of 'layeredRead',+-- must not be used after the action returns: the snapshot is released then,+-- and RocksDB would be handed a pointer to freed memory. An action that+-- gives back something lazy or something that reads later, such as the body+-- of a streamed response, has to run inside.+withView :: DatabaseReader -> (View -> IO a) -> IO a+withView r action = withSnapshot r.db $ \snapshot -> action (View r {db = snapshot})++-- | The queries of a view as a t'StoreRead', for the web server, as+-- 'Q.databaseRead' gives those of a database of version 20.+layeredRead :: View -> StoreRead+layeredRead v@(View r) =+ StoreRead+ { net = r.net,+ ctx = r.ctx,+ getBestBlock = getBestBlock v,+ getBlocksAtHeight = getBlocksAtHeight v,+ getBlock = getBlock v,+ getTxData = fmap notDeleted . getTxData v,+ getManyTxData = fmap (map notDeleted) . getManyTxData v,+ getSpender = getSpender v,+ getBalance = getBalance v,+ getUnspent = getUnspent v,+ getMempool = getMempool v,+ getMempoolPage = getMempoolPage v,+ getAddressesTxs = getAddressesTxs v,+ getAddressesUnspents = getAddressesUnspents v,+ initialGap = r.initGap,+ maxGap = r.maxGap,+ getNumTxData = fmap (filter (not . (.deleted))) . getNumTxData v,+ getBalances = getBalances v,+ getAddressTxs = getAddressTxs v,+ getAddressUnspents = getAddressUnspents v,+ xPubBals = xPubBals v,+ xPubUnspents = \_ -> xPubUnspents v,+ xPubTxs = \_ -> xPubTxs v,+ xPubTxCount = \_ -> xPubTxCount v,+ getAddressesTxCount = getAddressesTxCount v+ }++-- | The queries of a database as a t'StoreRead', each on a view of its+-- own, taken when it is asked. For code that asks one thing at a time and+-- has no request to hold a view for. Queries asked one after the other may+-- see different moments, so what needs several of them to agree takes a+-- view with 'withView'.+standaloneRead :: DatabaseReader -> StoreRead+standaloneRead r =+ StoreRead+ { net = r.net,+ ctx = r.ctx,+ getBestBlock = one (.getBestBlock),+ getBlocksAtHeight = \h -> one (\q -> q.getBlocksAtHeight h),+ getBlock = \h -> one (\q -> q.getBlock h),+ getTxData = \h -> one (\q -> q.getTxData h),+ getManyTxData = \hs -> one (\q -> q.getManyTxData hs),+ getSpender = \p -> one (\q -> q.getSpender p),+ getBalance = \a -> one (\q -> q.getBalance a),+ getUnspent = \p -> one (\q -> q.getUnspent p),+ getMempool = one (.getMempool),+ getMempoolPage = \l -> one (\q -> q.getMempoolPage l),+ getAddressesTxs = \as l -> one (\q -> q.getAddressesTxs as l),+ getAddressesUnspents = \as l -> one (\q -> q.getAddressesUnspents as l),+ initialGap = r.initGap,+ maxGap = r.maxGap,+ getNumTxData = \n -> one (\q -> q.getNumTxData n),+ getBalances = \as -> one (\q -> q.getBalances as),+ getAddressTxs = \a l -> one (\q -> q.getAddressTxs a l),+ getAddressUnspents = \a l -> one (\q -> q.getAddressUnspents a l),+ xPubBals = \x -> one (\q -> q.xPubBals x),+ xPubUnspents = \x bs l -> one (\q -> q.xPubUnspents x bs l),+ xPubTxs = \x bs l -> one (\q -> q.xPubTxs x bs l),+ xPubTxCount = \x bs -> one (\q -> q.xPubTxCount x bs),+ getAddressesTxCount = \as -> one (\q -> q.getAddressesTxCount as)+ }+ where+ one :: (StoreRead -> IO a) -> IO a+ one f = withView r (f . layeredRead)++-------------------------------------------------------------------------------+-- Blocks+-------------------------------------------------------------------------------++-- | Hash of the best block, if the database has any.+getBestBlock :: View -> IO (Maybe BlockHash)+getBestBlock (View r) = Q.getBestBlock r++-- | Hashes of the blocks at a height, of the main chain and of side chains.+getBlocksAtHeight :: View -> BlockHeight -> IO [BlockHash]+getBlocksAtHeight (View r) = Q.getBlocksAtHeight r++-- | Data of a block.+getBlock :: View -> BlockHash -> IO (Maybe BlockData)+getBlock (View r) = Q.getBlock r++-------------------------------------------------------------------------------+-- Transactions+-------------------------------------------------------------------------------++-- | Data of a transaction, with the spenders of its outputs, those in blocks+-- and those in the mempool. A transaction in the mempool comes from the+-- mempool area, and a confirmed one from the chain. One that left the+-- mempool unconfirmed is not known: nothing is kept of it, and the bodies+-- that version 20 left in the chain for such transactions are passed over.+getTxData :: View -> TxHash -> IO (Maybe TxData)+getTxData v h =+ getManyTxData v [h] <&> \case+ [found] -> found+ _ -> Nothing++-- | Data of many transactions, in the order of the hashes. Each column+-- family that has to be read is read with one multi-get.+getManyTxData :: View -> [TxHash] -> IO [Maybe TxData]+getManyTxData (View r) hs = do+ found <- bodies r hs+ spent <- mempoolSpenders r (Map.toList found)+ let tds = [withSpenders spent h <$> Map.lookup h found | h <- hs]+ Q.count r $ \s -> incrementCounter s.dataTxCount (length (catMaybes tds))+ return tds++-- | Transactions whose hash, read as a number, matches a short identifier of+-- the Blockchain.info API, in the order of their hashes. There may be more+-- than one.+getNumTxData :: View -> Word64 -> IO [TxData]+getNumTxData (View r) i = do+ unconfirmed <- matchingAsListCF r.db (mempoolCF r.db) (TxKeyS prefix)+ stored <- matchingAsListCF r.db (txCF r.db) (TxKeyS prefix)+ let rows = [(h, td) | (TxKey h, td) <- stored]+ chain <- zip (map fst rows) <$> Q.withChainSpenders r rows+ let found =+ preferred+ (Map.fromList [(h, td) | (TxKey h, td) <- unconfirmed])+ (Map.fromList chain)+ matches = filter (seventh . fst) (Map.toAscList found)+ spent <- mempoolSpenders r matches+ Q.count r $ \s -> incrementCounter s.dataTxCount (length matches)+ return [withSpenders spent h td | (h, td) <- matches]+ where+ (prefix, bits) = decodeTxKey i+ -- the keys match on the first six bytes of the hash; the identifier+ -- also has the five high bits of the seventh+ seventh h = BS.index (encode h) 6 .&. 0xf8 == bits++-- | Input that spends an output, in the mempool or in a block.+getSpender :: View -> OutPoint -> IO (Maybe Spender)+getSpender (View r) p =+ retrieveCF r.db (mempoolCF r.db) (MemSpenderKey p) >>= \case+ Just s -> return (Just s)+ Nothing -> Q.getChainSpender r p++-- | The bodies of transactions by hash, each from where it is kept, with+-- the spenders that blocks give to its outputs and without those of the+-- mempool.+--+-- The mempool area is asked first, although most transactions asked for are+-- confirmed: with its filter, finding that a transaction is not there costs+-- a fraction of reading a body, and the chain is then asked only for the+-- others.+bodies :: DatabaseReader -> [TxHash] -> IO (Map TxHash TxData)+bodies r hs = do+ unconfirmed <- look (mempoolCF r.db) TxKey hs+ chain <- look (txCF r.db) TxKey (filter (`Map.notMember` unconfirmed) hs) >>= spent+ return (preferred unconfirmed chain)+ where+ -- the spenders that blocks give to the outputs of confirmed+ -- transactions are under keys of their own+ spent found = do+ let rows = Map.toAscList found+ Map.fromDistinctAscList . zip (map fst rows) <$> Q.withChainSpenders r rows+ look :: (Serialize key) => ColumnFamily -> (TxHash -> key) -> [TxHash] -> IO (Map TxHash TxData)+ look _ _ [] = return Map.empty+ look cf key ks =+ Map.fromList . catMaybes . zipWith (fmap . (,)) ks+ <$> Q.retrieveManyCF r.db cf (map key ks)++-- | The body to answer with for each transaction, given the bodies that the+-- mempool area has for transactions in the mempool and those that the+-- chain has. A transaction is in one place only. A body that version 20+-- left in the chain for a transaction that it deleted is no body at all:+-- finding them all to delete them would mean reading every transaction of+-- the database, so they stay where they are and are passed over.+preferred :: Map TxHash TxData -> Map TxHash TxData -> Map TxHash TxData+preferred unconfirmed chain = unconfirmed <> Map.filter (not . (.deleted)) chain++-- | The spenders that the mempool has for the outputs of transactions,+-- given their bodies as 'bodies' gives them.+--+-- Only the outputs that no block spends are looked up, since the mempool+-- cannot spend the others: all of them with one multi-get, which the filter+-- of the column family answers for the many outputs that the mempool does+-- not spend. A transaction with more than 'scanFrom' such outputs has its+-- spenders read with a scan of their common prefix instead.+mempoolSpenders :: DatabaseReader -> [(TxHash, TxData)] -> IO (HashMap TxHash (IntMap Spender))+mempoolSpenders r txs = do+ found <- case points of+ [] -> return []+ _ -> Q.retrieveManyCF r.db (mempoolCF r.db) (map MemSpenderKey points)+ scanned <- mapM (\(h, _) -> (h,) <$> M.getMempoolSpenders r h) wide+ return . HashMap.fromListWith IntMap.union $+ [(p.hash, IntMap.singleton (fromIntegral p.index) s) | (p, Just s) <- zip points found]+ <> scanned+ where+ open :: [(TxHash, [Word32])]+ open =+ [ (h, [i | (i, _) <- zip [0 ..] td.tx.outputs, IntMap.notMember (fromIntegral i) td.spenders])+ | (h, td) <- txs,+ not td.deleted+ ]+ (narrow, wide) = partition (null . drop scanFrom . snd) open+ points = [OutPoint h i | (h, is) <- narrow, i <- is]++-- | Number of outputs of a transaction above which the mempool spenders of+-- the transaction are read with a scan. A scan costs about as much as eight+-- lookups in a multi-get whatever it finds, by the measurements in+-- @docs\/layered-database.md@.+scanFrom :: Int+scanFrom = 8++-- | Add the spenders that the mempool has for the outputs of a transaction+-- to those in its body.+withSpenders :: HashMap TxHash (IntMap Spender) -> TxHash -> TxData -> TxData+withSpenders spent h td = case HashMap.lookup h spent of+ Nothing -> td+ Just unconfirmed -> (td :: TxData) {spenders = IntMap.union td.spenders unconfirmed}++-------------------------------------------------------------------------------+-- Addresses+-------------------------------------------------------------------------------++-- | Balance of an address, with the mempool applied. 'Nothing' if neither+-- the chain nor the mempool has anything for the address; see+-- 'M.mergeBalance' for where version 20 says zero instead.+--+-- It is two reads by key, the balance of the chain and the summary of the+-- mempool, however much the mempool does at the address.+getBalance :: View -> Address -> IO (Maybe Balance)+getBalance (View r) a = M.summaryBalance a <$> Q.getBalance r a <*> (only <$> M.getSummaries r [a])+ where+ only = \case+ [s] -> s+ _ -> Nothing++-- | Balances of addresses, in the same order, with the mempool applied.+-- Addresses never used get a zero balance. Two multi-gets, one in each area.+getBalances :: View -> [Address] -> IO [Balance]+getBalances (View r) as = zipWith apply <$> Q.getBalances r as <*> M.getSummaries r as+ where+ apply c = maybe c (M.applySummary c)++-- | Unspent output, if it exists and neither a block nor the mempool spends+-- it: one that the chain has, or one of a mempool transaction.+getUnspent :: View -> OutPoint -> IO (Maybe Unspent)+getUnspent (View r) p =+ retrieveCF r.db (mempoolCF r.db) (MemSpenderKey p) >>= \case+ Just (_ :: Spender) -> return Nothing+ Nothing ->+ Q.getUnspent r p >>= \case+ Just u -> return (Just u)+ Nothing -> (>>= output) <$> M.getMempoolTx r p.hash+ where+ output td = case drop (fromIntegral p.index) td.tx.outputs of+ o : _ ->+ Just+ Unspent+ { block = td.block,+ outpoint = p,+ value = o.value,+ script = o.script,+ address = eitherToMaybe (scriptToAddressBS r.net r.ctx o.script)+ }+ [] -> Nothing++-- | Transactions of an address, newest first.+getAddressTxs :: View -> Address -> Limits -> IO [TxRef]+getAddressTxs v a = getAddressesTxs v [a]++-- | Transactions of any of the addresses, newest first, each once.+getAddressesTxs :: View -> [Address] -> Limits -> IO [TxRef]+getAddressesTxs (View r) as limits = paged r limits $ \from -> do+ txs <- withMempoolIters r from as (const True) $ \mempools ->+ Q.withManyIters r (addrTxCF r.db) (map AddrTxKeyA as) $ \its ->+ Q.joinConduits txRefKey (zipWith3 (addressTxsC from) as (map (fmap snd) mempools) its) limits+ Q.count r $ \s -> incrementCounter s.dataAddrTxCount (length txs)+ return txs++-- | Unspent outputs of an address, newest first.+getAddressUnspents :: View -> Address -> Limits -> IO [Unspent]+getAddressUnspents v a = getAddressesUnspents v [a]++-- | Unspent outputs of any of the addresses, newest first.+getAddressesUnspents :: View -> [Address] -> Limits -> IO [Unspent]+getAddressesUnspents (View r) as limits = paged r limits $ \from -> do+ us <- withUnspentStreams r from as $ \streams ->+ Q.joinConduits unspentKey streams limits+ Q.count r $ \s -> incrementCounter s.dataUnspentCount (length us)+ return us++-------------------------------------------------------------------------------+-- Pages+-------------------------------------------------------------------------------++-- | Where a page starts in a list that has the mempool's part first and the+-- chain's part after it.+data From+ = -- | at the newest item+ Top+ | -- | in the mempool's part, at the transaction that entered the mempool+ -- at this time and has this hash, or where it would be+ InMempool !UnixTime !TxHash+ | -- | in the chain's part, at this position: nothing of the mempool is in+ -- the page+ InChain !BlockRef++-- | Where the page of a query starts, or 'Nothing' if it starts at a+-- transaction that is not known, which makes the page empty.+--+-- A transaction that left the mempool unconfirmed is not known, so a page+-- that starts at one is empty.+pageStart :: DatabaseReader -> Maybe Start -> IO (Maybe From)+pageStart _ Nothing = return (Just Top)+pageStart _ (Just (AtBlock height)) = return (Just (InChain (BlockRef height maxBound)))+pageStart r (Just (AtTx h)) =+ bodies r [h] <&> \found -> case (.block) <$> Map.lookup h found of+ Nothing -> Nothing+ Just (MemRef time) -> Just (InMempool time h)+ Just position@BlockRef {} -> Just (InChain position)++-- | Answer a query from where its page starts.+paged :: DatabaseReader -> Limits -> (From -> IO [a]) -> IO [a]+paged r limits answer = pageStart r limits.start >>= maybe (return []) answer++-- | Run an action with, for each address, its summary and an iterator on+-- its entries in the mempool area, or 'Nothing' for an address whose+-- entries the list does not need: one that the mempool does not touch, which+-- is nearly all of them, one whose summary says that there is nothing of+-- the kind wanted, and any address when the page starts in the chain, as+-- every page after the first of a long history does.+--+-- The summaries are read by key in one multi-get, which the filter of the+-- column family answers for the addresses that have none. No iterator is+-- moved to an address with nothing there: such a seek costs ten times a+-- read by key, and far more when it has to step over what a block just+-- deleted.+withMempoolIters ::+ DatabaseReader ->+ From ->+ [Address] ->+ (MemSummary -> Bool) ->+ ([Maybe (MemSummary, Iterator)] -> IO a) ->+ IO a+withMempoolIters r from as wanted action = do+ summaries <- case from of+ InChain _ -> return (map (const Nothing) as)+ _ -> map (>>= \s -> if wanted s then Just s else Nothing) <$> M.getSummaries r as+ Q.withManyIters r (mempoolAddrCF r.db) [MemAddrKeyA a | (a, Just _) <- zip as summaries] (action . pair summaries)+ where+ pair (Just s : rest) (it : its) = Just (s, it) : pair rest its+ pair (_ : rest) its = Nothing : pair rest its+ pair [] _ = []++-- | Transactions of an address, newest first, from where a page starts:+-- those of the mempool that belong in it, streamed from the entries of the+-- address if it has any, then those of the chain, streamed from its index.+-- Neither is read further than the page goes.+addressTxsC :: From -> Address -> Maybe Iterator -> Iterator -> ConduitT i TxRef IO ()+addressTxsC from a mempool it = mapM_ (mempoolTxsC from a) mempool >> chainTxsC from a it++-- | Transactions of an address in the chain, newest first, from where a+-- page starts, streamed from the index of the address.+chainTxsC :: From -> Address -> Iterator -> ConduitT i TxRef IO ()+chainTxsC from a it = chain .| concatMapC ref+ where+ chain = case from of+ InChain position -> matchingSkip it (AddrTxKeyA a) (AddrTxKeyB a position)+ _ -> matching it (AddrTxKeyA a)+ ref :: (AddrTxKey, ()) -> Maybe TxRef+ ref (AddrTxKey _ t, ()) = Just t+ ref _ = Nothing++-- | Mempool transactions of an address, newest first, from where a page+-- starts. Those that entered the mempool in the same second are in the+-- order of their hashes, which is the order of their keys.+mempoolTxsC :: From -> Address -> Iterator -> ConduitT i TxRef IO ()+mempoolTxsC from a it = rows .| concatMapC ref+ where+ rows = case from of+ Top -> matching it (MemAddrTxKeyA a)+ InMempool time h -> matchingSkip it (MemAddrTxKeyA a) (MemAddrTxKey a time h)+ InChain _ -> return ()+ ref :: (MemAddrKey, MemAddrVal) -> Maybe TxRef+ ref (MemAddrTxKey _ t h, _) = Just TxRef {block = MemRef t, txid = h}+ ref _ = Nothing++-- | Run an action with the stream of unspent outputs of each address, from+-- where a page starts.+--+-- The summaries are read whatever the start of the page: the chain still+-- has, as unspent, the outputs that the mempool spends, on every page.+withUnspentStreams ::+ DatabaseReader ->+ From ->+ [Address] ->+ ([ConduitT i Unspent IO ()] -> IO a) ->+ IO a+withUnspentStreams r from as action = do+ summaries <- M.getSummaries r as+ let fresh = case from of+ InChain _ -> map (const Nothing) as+ _ -> map (>>= \s -> if s.paidCount > s.bothCount then Just s else Nothing) summaries+ Q.withManyIters r (mempoolAddrCF r.db) [MemAddrKeyA a | (a, Just _) <- zip as fresh] $ \mits ->+ Q.withManyIters r (addrOutCF r.db) (map AddrOutKeyA as) $ \its ->+ action (zipWith3 stream (zip as summaries) (pair fresh mits) its)+ where+ stream (a, s) = addressUnspentsC r from a s+ pair (Just _ : rest) (it : its) = Just it : pair rest its+ pair (_ : rest) its = Nothing : pair rest its+ pair [] _ = []++-- | Unspent outputs of an address, newest first, from where a page starts,+-- given the summary of what the mempool does at the address, if anything,+-- and an iterator on its entries if the page has some: the outputs that the+-- mempool created and has not spent, then those of the chain that the+-- mempool does not spend, streamed from the index of the address.+--+-- Whether the mempool spends an output is a read by key, and the summary+-- says how many of them there are to find: none for nearly every address,+-- so that nothing is read, and when the last one is found the reads stop.+addressUnspentsC ::+ DatabaseReader ->+ From ->+ Address ->+ Maybe MemSummary ->+ Maybe Iterator ->+ Iterator ->+ ConduitT i Unspent IO ()+addressUnspentsC r from a summary mempool it = do+ mapM_ (\mit -> fresh mit .| concatMapC created .| hide (maybe 0 (.bothCount) summary) 1) mempool+ chainUnspentsC r from a it .| hide (maybe 0 (\s -> s.spentCount - s.bothCount) summary) 1+ where+ -- the outputs of the transaction that the page starts at come first+ -- among those of the same second whose hashes are not smaller+ fresh mit = case from of+ Top -> matching mit (MemAddrOutKeyA a)+ InMempool time h -> matchingSkip mit (MemAddrOutKeyA a) (MemAddrOutKey a time (OutPoint h 0))+ InChain _ -> return ()+ created :: (MemAddrKey, MemAddrVal) -> Maybe Unspent+ created (MemAddrOutKey _ t p, MemAddrOutVal v s) =+ Just Unspent {block = MemRef t, outpoint = p, value = v, script = s, address = Just a}+ created _ = Nothing+ -- let outputs through, but for those that the mempool spends, of which+ -- there are no more than a number. They are asked about several at a+ -- time, in one multi-get, which costs a fifth of asking about each: one+ -- at first, then twice as many each time. A page of one address is then+ -- a few reads, and the first output of each of the hundreds of+ -- addresses of a wallet, which is all that a merge of them may take,+ -- does not cost a batch of rows read ahead for each.+ hide :: Word64 -> Int -> ConduitT Unspent Unspent IO ()+ hide 0 _ = awaitForever yield+ hide n size =+ gather size >>= \case+ [] -> return ()+ batch -> do+ found <- lift (spentAmong batch)+ yieldMany [u | (u, False) <- zip batch found]+ hide (n - min n (fromIntegral (length (filter id found)))) (min hideBatch (size * 2))+ -- whether the mempool spends each of some outputs of the address: a+ -- read by key for each of a few, and one multi-get for more, which+ -- only then costs less+ spentAmong :: [Unspent] -> IO [Bool]+ spentAmong batch+ | length batch < 8 = forM batch $ \u -> do+ found <- retrieveCF r.db (mempoolAddrCF r.db) (MemAddrSpentKey a u.outpoint)+ return (isJust (found :: Maybe MemAddrVal))+ | otherwise =+ map isJust <$> multiGetCF r.db (mempoolAddrCF r.db) [encode (MemAddrSpentKey a u.outpoint) | u <- batch]+ gather :: Int -> ConduitT Unspent o IO [Unspent]+ gather 0 = return []+ gather k =+ await >>= \case+ Nothing -> return []+ Just u -> (u :) <$> gather (k - 1)++-- | The most outputs that are asked about at once when looking for those+-- that the mempool spends. More would save little, and each one is a row+-- read ahead of what a page may need.+hideBatch :: Int+hideBatch = 32++-- | Unspent outputs of an address in the chain, newest first, from where a+-- page starts, streamed from the index of the address. Those that the+-- mempool spends are among them.+chainUnspentsC :: DatabaseReader -> From -> Address -> Iterator -> ConduitT i Unspent IO ()+chainUnspentsC r from a it = chain .| concatMapC output+ where+ chain = case from of+ InChain position -> matchingSkip it (AddrOutKeyA a) (AddrOutKeyB a position)+ _ -> matching it (AddrOutKeyA a)+ output :: (AddrOutKey, OutVal) -> Maybe Unspent+ output = uncurry (toUnspent r.net r.ctx)++-------------------------------------------------------------------------------+-- Mempool+-------------------------------------------------------------------------------++-- | The whole mempool, newest first.+getMempool :: View -> IO [(UnixTime, TxHash)]+getMempool (View r) = Q.getMempool r++-- | Page of the mempool, newest first, from the offset and limit of the+-- t'Limits'; its start is ignored. The list of the mempool and its index by+-- time are the same in both layouts.+getMempoolPage :: View -> Limits -> IO [(UnixTime, TxHash)]+getMempoolPage (View r) = Q.getMempoolPage r++-------------------------------------------------------------------------------+-- Extended public keys+-------------------------------------------------------------------------------++-- | Balances of the addresses of an extended public key, derived up to the+-- exact gap (see 'scanXPubBals'), with the mempool applied. An address that+-- only the mempool has used counts as used, as it did in version 20.+xPubBals :: View -> XPubSpec -> IO [XPubBal]+xPubBals v@(View r) xpub = do+ Derived ext chg <- derivedAddrs r xpub+ bals <- scanXPubBals (getBalances v) r.initGap r.maxGap ext chg+ Q.count r $ \s -> incrementCounter s.dataXPubBals (length bals)+ return bals++-- | Unspent outputs of an extended public key, newest first, given its+-- balances from @xPubBals@. The addresses are read in step, each only as far+-- as the page needs, as 'Q.xPubUnspents' reads them.+xPubUnspents :: View -> [XPubBal] -> Limits -> IO [XPubUnspent]+xPubUnspents (View r) xbals limits = paged r limits $ \from -> do+ let quota = case (deOffset limits).limit of+ 0 -> Nothing+ l -> Just l+ tag b = mapC (\u -> XPubUnspent {path = b.path, unspent = u})+ us <- withUnspentStreams r from (map (.balance.address) funded) $ \streams ->+ runConduit $+ mergeDescGroups (.unspent.block) (unspentKey . (.unspent)) quota (zipWith (\b s -> s .| tag b) funded streams)+ .| applyLimitsC limits+ .| sinkList+ Q.count r $ \s -> incrementCounter s.dataXPubUnspents (length us)+ return us+ where+ funded = filter ((> 0) . (.balance.utxo)) xbals++-- | Transactions of an extended public key, newest first, given its balances+-- from @xPubBals@.+xPubTxs :: View -> [XPubBal] -> Limits -> IO [TxRef]+xPubTxs v@(View r) xbals limits = do+ txs <- getAddressesTxs v (Q.usedAddresses xbals) limits+ Q.count r $ \s -> incrementCounter s.dataXPubTxs (length txs)+ return txs++-- | Number of transactions of an extended public key, given its balances+-- from @xPubBals@.+xPubTxCount :: View -> [XPubBal] -> IO Word32+xPubTxCount v@(View r) xbals = do+ Q.count r $ \s -> incrementCounter s.dataXPubTxCount 1+ getAddressesTxCount v (Q.usedAddresses xbals)++-- | Number of distinct transactions of a set of addresses: each transaction+-- is counted once even when it touches several of them.+--+-- Those of the chain are counted from the keys of its index, as+-- 'Q.getAddressesTxCount' counts them, and those of the mempool from the+-- summaries of the addresses: nothing more is read when none of them, or+-- only one, has transactions in the mempool, and otherwise the transactions+-- of those that have are read, to count each once. No transaction is in+-- both areas.+getAddressesTxCount :: View -> [Address] -> IO Word32+getAddressesTxCount _ [] = return 0+getAddressesTxCount (View r) as = do+ inBlocks <- Q.getAddressesTxCount r as+ inMempool <- withMempoolIters r Top as ((> 0) . (.txs)) $ \mempools ->+ case [(a, m) | (a, Just m) <- zip as mempools] of+ [] -> return 0+ [(_, (s, _))] -> return (fromIntegral s.txs)+ several -> do+ txs <- forM several $ \(a, (_, it)) -> runConduit (mempoolTxsC Top a it .| mapC (.txid) .| sinkList)+ return (HashSet.size (HashSet.fromList (concat txs)))+ return (inBlocks + fromIntegral inMempool)
@@ -0,0 +1,518 @@+-- |+-- Module : Haskoin.Store.Database.Query.Pooled+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- The queries of the store with a mempool that is not the store's own laid+-- over the chain: the shared pool of a fleet of stores, which+-- "Haskoin.Store.Fleet.Redis" keeps in Redis. See "A fleet that answers as+-- one" in @docs\/layered-database.md@.+--+-- = Why another set of queries+--+-- "Haskoin.Store.Database.Query.Layered" answers from the two areas of one+-- database, read through one snapshot. Stores that share a Redis are to+-- give the same answer to the same request, and their own mempools differ.+-- So each answers from the chain as it was at the block that the fleet+-- agreed on, which every store has the same, and from the one pool that+-- they share. The chain's part of an answer is read as the layered queries+-- read it, with the same code. The mempool's part comes through a+-- t'PoolReads', a handful of functions that say what a pool has: its+-- summaries, the entries of an address by the page, bodies, spenders and+-- its list.+--+-- The alternative was to give the layered queries the same handful of+-- functions for the store's own mempool area. They read that area through+-- iterators that are made once for a request and moved from address to+-- address, which is where their speed comes from, and which a function+-- that returns a page would hide. The two sets share what is the same, and+-- the tests hold this one to the answers of the other: 'localPool' is the+-- store's own mempool area as a t'PoolReads', and with it every query here+-- must answer as the layered one does.+--+-- = What a pool cannot say+--+-- A transaction that left a mempool unconfirmed is kept by the store that+-- deleted it, and by no other. A fleet cannot answer the same about it from+-- every store, so these queries do not know deleted transactions at all: a+-- page that starts at one is empty.+module Haskoin.Store.Database.Query.Pooled+ ( PoolReads (..),+ pooledRead,+ localPool,+ pageStart,+ )+where++import Conduit+ ( ConduitT,+ await,+ awaitForever,+ lift,+ mapC,+ runConduit,+ sinkList,+ yield,+ yieldMany,+ (.|),+ )+import Control.Monad (forM)+import Data.Bits ((.&.))+import Data.ByteString qualified as BS+import Data.Functor ((<&>))+import Data.HashMap.Strict qualified as HashMap+import Data.HashSet qualified as HashSet+import Data.IntMap.Strict qualified as IntMap+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Maybe (catMaybes, isJust)+import Data.Serialize (encode)+import Data.Word (Word16, Word32, Word64)+import Database.RocksDB (Iterator)+import Database.RocksDB.Query (matchingAsListCF, retrieveCF)+import Haskoin+ ( Address,+ OutPoint (..),+ Tx (..),+ TxHash,+ TxOut (..),+ eitherToMaybe,+ scriptToAddressBS,+ )+import Haskoin.Store.Common+ ( Limits (..),+ Start (..),+ StoreRead (..),+ applyLimitsC,+ deOffset,+ mergeDescGroups,+ notDeleted,+ scanXPubBals,+ txRefKey,+ unspentKey,+ )+import Haskoin.Store.Data+ ( Balance (..),+ BlockRef (..),+ Spender,+ TxData (..),+ TxRef (..),+ UnixTime,+ Unspent (..),+ XPubBal (..),+ XPubUnspent (..),+ )+import Haskoin.Store.Database.Mempool qualified as M+import Haskoin.Store.Database.Query qualified as Q+import Haskoin.Store.Database.Query.Layered (From (..), chainTxsC, chainUnspentsC, withSpenders)+import Haskoin.Store.Database.Reader+ ( DatabaseReader (..),+ Derived (..),+ addrOutCF,+ addrTxCF,+ derivedAddrs,+ mempoolAddrCF,+ mempoolCF,+ txCF,+ )+import Haskoin.Store.Database.Types+ ( AddrOutKey (..),+ AddrTxKey (..),+ MemAddrKey (..),+ MemAddrVal (..),+ MemSpenderKey (..),+ MemSummary (..),+ TxKey (..),+ decodeTxKey,+ )++-- | What a mempool has, as much as the queries need of it. Each function+-- answers about the pool as it is when asked: one that goes with a block+-- must fail, by throwing, when the pool has moved to another.+data PoolReads = PoolReads+ { -- | the summary of each address, in their order, or 'Nothing' for an+ -- address that the pool does not touch+ summaries :: [Address] -> IO [Maybe MemSummary],+ -- | transactions of an address, newest first and in the order of their+ -- hashes within a second, from the one that entered at a time with a+ -- hash, or where it would be, or from the newest; at most so many+ addressTxs :: Address -> Maybe (UnixTime, TxHash) -> Int -> IO [(UnixTime, TxHash)],+ -- | outputs that the pool pays to an address, in the same order, from+ -- one of a time or from the newest, at most so many, each with whether+ -- the pool also spends it+ addressOuts :: Address -> Maybe (UnixTime, OutPoint) -> Int -> IO [(Unspent, Bool)],+ -- | whether the pool spends each of some outputs of an address+ spentOutputs :: Address -> [OutPoint] -> IO [Bool],+ -- | for each of some transactions, in their order: its body if it is+ -- in the pool, without spenders, and the inputs of the pool that spend+ -- its outputs, by output, whether it is in the pool or in the chain.+ -- The two come together because nearly every question about a+ -- transaction needs both, and a pool that is far away answers them in+ -- one go.+ bodies :: [TxHash] -> IO [(Maybe TxData, IntMap.IntMap Spender)],+ -- | the input of the pool that spends each output, if one does+ spenders :: [OutPoint] -> IO [Maybe Spender],+ -- | the list of the pool, newest first and by hash downwards within a+ -- second, from an offset, at most so many, or all of it for 0+ page :: Word32 -> Word32 -> IO [(UnixTime, TxHash)],+ -- | the transactions of the pool whose hash starts with six bytes, as+ -- 'decodeTxKey' gives them+ startingWith :: (Word32, Word16) -> IO [TxHash]+ }++-- | The queries of a store that answers from the chain as a reader has it,+-- which is to be the view of the database at a block, with a pool on top.+pooledRead :: DatabaseReader -> PoolReads -> StoreRead+pooledRead r pool =+ StoreRead+ { net = r.net,+ ctx = r.ctx,+ getBestBlock = Q.getBestBlock r,+ getBlocksAtHeight = Q.getBlocksAtHeight r,+ getBlock = Q.getBlock r,+ getTxData = \h -> only <$> getManyTxData r pool [h],+ getManyTxData = getManyTxData r pool,+ getSpender = getSpender r pool,+ getBalance = getBalance r pool,+ getUnspent = getUnspent r pool,+ getMempool = pool.page 0 0,+ getMempoolPage = \l -> pool.page l.offset l.limit,+ getAddressesTxs = getAddressesTxs r pool,+ getAddressesUnspents = getAddressesUnspents r pool,+ initialGap = r.initGap,+ maxGap = r.maxGap,+ getNumTxData = getNumTxData r pool,+ getBalances = getBalances r pool,+ getAddressTxs = \a -> getAddressesTxs r pool [a],+ getAddressUnspents = \a -> getAddressesUnspents r pool [a],+ xPubBals = \xpub -> do+ Derived ext chg <- derivedAddrs r xpub+ scanXPubBals (getBalances r pool) r.initGap r.maxGap ext chg,+ xPubUnspents = \_ -> xPubUnspents r pool,+ xPubTxs = \_ xbals -> getAddressesTxs r pool (Q.usedAddresses xbals),+ xPubTxCount = \_ xbals -> getAddressesTxCount r pool (Q.usedAddresses xbals),+ getAddressesTxCount = getAddressesTxCount r pool+ }+ where+ only = \case+ [found] -> found+ _ -> Nothing++-------------------------------------------------------------------------------+-- Transactions+-------------------------------------------------------------------------------++-- | Data of many transactions, in the order of the hashes: those of the+-- pool from it, the others from the chain, each with the spenders that+-- blocks and the pool give to its outputs. Deleted ones are not known.+getManyTxData :: DatabaseReader -> PoolReads -> [TxHash] -> IO [Maybe TxData]+getManyTxData _ _ [] = return []+getManyTxData r pool hs = do+ found <- known r pool hs+ return [Map.lookup h found | h <- hs]++-- | The data of transactions by hash: those that the pool has, and those+-- that the chain has for the others, each with the spenders that blocks+-- and the pool give to its outputs. One read of the pool, and one of the+-- chain for what the pool does not have.+known :: DatabaseReader -> PoolReads -> [TxHash] -> IO (Map TxHash TxData)+known _ _ [] = return Map.empty+known r pool hs = do+ inPool <- zip hs <$> pool.bodies hs+ let unconfirmed = Map.fromList [(h, td) | (h, (Just td, _)) <- inPool]+ spent = HashMap.fromList [(h, spenders) | (h, (_, spenders)) <- inPool, not (IntMap.null spenders)]+ rest = filter (`Map.notMember` unconfirmed) hs+ chain <- case rest of+ [] -> return Map.empty+ _ -> do+ found <- catMaybes . zipWith (fmap . (,)) rest <$> Q.retrieveManyCF r.db (txCF r.db) (map TxKey rest)+ -- with the spenders that blocks give to their outputs+ Map.fromList . zip (map fst found) <$> Q.withChainSpenders r found+ return (Map.mapWithKey (withSpenders spent) (unconfirmed <> Map.mapMaybe notDeleted' chain))+ where+ notDeleted' = notDeleted . Just++-- | Transactions whose hash, read as a number, matches a short identifier+-- of the Blockchain.info API, in the order of their hashes.+getNumTxData :: DatabaseReader -> PoolReads -> Word64 -> IO [TxData]+getNumTxData r pool i = do+ inPool <- pool.startingWith prefix+ chain <- matchingAsListCF r.db (txCF r.db) (TxKeyS prefix)+ let confirmed = [h | (TxKey h, td :: TxData) <- chain, not td.deleted]+ found <- known r pool (filter seventh (inPool <> confirmed))+ return (Map.elems found)+ where+ (prefix, bits) = decodeTxKey i+ seventh h = BS.index (encode h) 6 .&. 0xf8 == bits++-- | Input that spends an output, in the pool or in a block.+getSpender :: DatabaseReader -> PoolReads -> OutPoint -> IO (Maybe Spender)+getSpender r pool p =+ pool.spenders [p] >>= \case+ [Just s] -> return (Just s)+ _ -> Q.getChainSpender r p++-------------------------------------------------------------------------------+-- Addresses+-------------------------------------------------------------------------------++-- | Balance of an address, with the pool applied.+getBalance :: DatabaseReader -> PoolReads -> Address -> IO (Maybe Balance)+getBalance r pool a = M.summaryBalance a <$> Q.getBalance r a <*> (only <$> pool.summaries [a])+ where+ only = \case+ [s] -> s+ _ -> Nothing++-- | Balances of addresses, in the same order, with the pool applied.+getBalances :: DatabaseReader -> PoolReads -> [Address] -> IO [Balance]+getBalances _ _ [] = return []+getBalances r pool as = zipWith apply <$> Q.getBalances r as <*> pool.summaries as+ where+ apply c = maybe c (M.applySummary c)++-- | Unspent output, if it exists and neither a block nor the pool spends+-- it.+getUnspent :: DatabaseReader -> PoolReads -> OutPoint -> IO (Maybe Unspent)+getUnspent r pool p =+ pool.spenders [p] >>= \case+ [Just _] -> return Nothing+ _ ->+ Q.getUnspent r p >>= \case+ Just u -> return (Just u)+ Nothing -> (>>= output) . only <$> pool.bodies [p.hash]+ where+ only = \case+ [(found, _)] -> found+ _ -> Nothing+ output td = case drop (fromIntegral p.index) td.tx.outputs of+ o : _ ->+ Just+ Unspent+ { block = td.block,+ outpoint = p,+ value = o.value,+ script = o.script,+ address = eitherToMaybe (scriptToAddressBS r.net r.ctx o.script)+ }+ [] -> Nothing++-- | Where the page of a query starts, or 'Nothing' if it starts at a+-- transaction that neither the pool nor the chain has.+pageStart :: DatabaseReader -> PoolReads -> Maybe Start -> IO (Maybe From)+pageStart _ _ Nothing = return (Just Top)+pageStart _ _ (Just (AtBlock height)) = return (Just (InChain (BlockRef height maxBound)))+pageStart r pool (Just (AtTx h)) =+ known r pool [h] <&> \found -> case (.block) <$> Map.lookup h found of+ Nothing -> Nothing+ Just (MemRef time) -> Just (InMempool time h)+ Just position@BlockRef {} -> Just (InChain position)++-- | Answer a query from where its page starts.+paged :: DatabaseReader -> PoolReads -> Limits -> (From -> IO [a]) -> IO [a]+paged r pool limits answer = pageStart r pool limits.start >>= maybe (return []) answer++-- | A stream of what a function gives by the page: it is asked for a first+-- page of a size from a start, and then for more from the last item that+-- it gave, which it gives again if it still has it, for as long as it gives+-- full pages. The pages grow, so that a long list costs few reads. The+-- first is given the size of what the query is asked for, so that one read+-- is enough for a page of the API: each read of a pool that is far away+-- costs more than the rows it brings.+pages :: (Eq k) => (x -> k) -> (Maybe k -> Int -> IO [x]) -> Int -> Maybe k -> ConduitT i x IO ()+pages key fetch first = go first Nothing+ where+ go size after start = do+ rows <- lift (fetch start size)+ let fresh = maybe rows (\k -> dropWhile ((== k) . key) rows) after+ yieldMany fresh+ case reverse rows of+ final : _ | length rows == size -> go (min 4096 (size * 4)) (Just (key final)) (Just (key final))+ _ -> return ()++-- | The transactions of an address in the pool that belong in a list that+-- starts somewhere, newest first, read so many at first: none when the+-- list starts in the chain, or when the pool is known to have nothing for+-- the address.+poolTxsC :: PoolReads -> From -> Int -> Address -> Bool -> ConduitT i TxRef IO ()+poolTxsC pool from first a touched = case (from, touched) of+ (InChain _, _) -> return ()+ (_, False) -> return ()+ (Top, True) -> stream Nothing+ (InMempool time h, True) -> stream (Just (time, h))+ where+ stream start = pages id (pool.addressTxs a) first start .| mapC (\(t, h) -> TxRef {block = MemRef t, txid = h})++-- | How many items to ask a pool for at first, for a query with limits: as+-- many as the page and what is skipped before it, and one more, so that a+-- page that is not full is known to be the end; within bounds, for a query+-- without a limit.+firstPage :: Limits -> Int+firstPage limits = case limits.limit of+ 0 -> 256+ n -> max 16 (min 1024 (fromIntegral (n + limits.offset) + 1))++-- | Whether a pool may have transactions of each of some addresses, for a+-- list that starts somewhere. For one address it is not asked: its+-- transactions are, which says as much in the same read. For several, the+-- summaries are read, in one read for all of them, so that only those that+-- have something are asked for it.+touchedBy :: PoolReads -> From -> [Address] -> IO [Bool]+touchedBy pool from as = case (from, as) of+ (InChain _, _) -> return (map (const False) as)+ (_, [_]) -> return [True]+ _ -> map isJust <$> pool.summaries as++-- | Transactions of any of the addresses, newest first, each once.+getAddressesTxs :: DatabaseReader -> PoolReads -> [Address] -> Limits -> IO [TxRef]+getAddressesTxs _ _ [] _ = return []+getAddressesTxs r pool as limits = paged r pool limits $ \from -> do+ touched <- touchedBy pool from as+ Q.withManyIters r (addrTxCF r.db) (map AddrTxKeyA as) $ \its ->+ Q.joinConduits txRefKey (zipWith3 (stream from) as touched its) limits+ where+ stream from a t it = poolTxsC pool from (firstPage limits) a t >> chainTxsC from a it++-- | The stream of unspent outputs of each address, from where a page+-- starts, given the iterators on the chain's index: the outputs that the+-- pool pays and does not spend, then those of the chain that the pool does+-- not spend. The pool is asked which outputs of the chain it spends+-- several at a time, four at first and then four times as many each time,+-- so that the first output of each of many addresses does not cost a batch+-- of rows read ahead, and no more once as many were found as its summary+-- counts.+unspentStreams ::+ DatabaseReader ->+ PoolReads ->+ From ->+ Limits ->+ [Address] ->+ IO ([Iterator] -> [ConduitT () Unspent IO ()])+unspentStreams r pool from limits as = do+ sums <- pool.summaries as+ return $ \its -> zipWith3 stream as sums its+ where+ stream a summary it = fresh a summary >> (chainUnspentsC r from a it .| hide a (maybe 0 hidden summary) 4)+ hidden s = s.spentCount - s.bothCount+ fresh a = \case+ Just s | s.paidCount > s.bothCount -> case from of+ Top -> outs a Nothing+ InMempool time h -> outs a (Just (time, OutPoint h 0))+ InChain _ -> return ()+ _ -> return ()+ -- some of what the pool pays it also spends, so more is asked for+ outs a start =+ pages (\(u, _) -> position u) (pool.addressOuts a) (2 * firstPage limits) start+ .| awaitForever (\(u, spent) -> if spent then return () else yield u)+ position u = case u.block of+ MemRef t -> (t, u.outpoint)+ BlockRef {} -> (0, u.outpoint)+ hide :: Address -> Word64 -> Int -> ConduitT Unspent Unspent IO ()+ hide _ 0 _ = awaitForever yield+ hide a n size = do+ batch <- gather size+ case batch of+ [] -> return ()+ _ -> do+ flags <- lift (pool.spentOutputs a (map (.outpoint) batch))+ yieldMany [u | (u, False) <- zip batch flags]+ hide a (n - min n (fromIntegral (length (filter id flags)))) (min 256 (size * 4))+ gather :: Int -> ConduitT Unspent o IO [Unspent]+ gather 0 = return []+ gather k =+ await >>= \case+ Nothing -> return []+ Just u -> (u :) <$> gather (k - 1)++-- | Unspent outputs of any of the addresses, newest first.+getAddressesUnspents :: DatabaseReader -> PoolReads -> [Address] -> Limits -> IO [Unspent]+getAddressesUnspents _ _ [] _ = return []+getAddressesUnspents r pool as limits = paged r pool limits $ \from -> do+ streams <- unspentStreams r pool from limits as+ Q.withManyIters r (addrOutCF r.db) (map AddrOutKeyA as) $ \its ->+ Q.joinConduits unspentKey (streams its) limits++-- | Unspent outputs of an extended public key, newest first, given its+-- balances.+xPubUnspents :: DatabaseReader -> PoolReads -> [XPubBal] -> Limits -> IO [XPubUnspent]+xPubUnspents r pool xbals limits = paged r pool limits $ \from -> do+ let quota = case (deOffset limits).limit of+ 0 -> Nothing+ l -> Just l+ tag b = mapC (\u -> XPubUnspent {path = b.path, unspent = u})+ streams <- unspentStreams r pool from limits (map (.balance.address) funded)+ Q.withManyIters r (addrOutCF r.db) (map (AddrOutKeyA . (.balance.address)) funded) $ \its ->+ runConduit $+ mergeDescGroups+ (.unspent.block)+ (unspentKey . (.unspent))+ quota+ (zipWith (\b s -> s .| tag b) funded (streams its))+ .| applyLimitsC limits+ .| sinkList+ where+ funded = filter ((> 0) . (.balance.utxo)) xbals++-- | Number of distinct transactions of a set of addresses: those of the+-- chain, and those of the pool, read only when more than one of the+-- addresses has some there.+getAddressesTxCount :: DatabaseReader -> PoolReads -> [Address] -> IO Word32+getAddressesTxCount _ _ [] = return 0+getAddressesTxCount r pool as = do+ inBlocks <- Q.getAddressesTxCount r as+ sums <- pool.summaries as+ inPool <- case [(a, s) | (a, Just s) <- zip as sums, s.txs > 0] of+ [] -> return 0+ [(_, s)] -> return (fromIntegral s.txs)+ several -> do+ txs <- forM several $ \(a, _) -> runConduit (poolTxsC pool Top 256 a True .| mapC (.txid) .| sinkList)+ return (HashSet.size (HashSet.fromList (concat txs)))+ return (inBlocks + fromIntegral inPool)++-------------------------------------------------------------------------------+-- The store's own mempool area as a pool+-------------------------------------------------------------------------------++-- | The mempool area of a database, as a reader has it, as a pool. The+-- queries of this module with it must answer as those of+-- "Haskoin.Store.Database.Query.Layered" do, but for deleted transactions:+-- that is what the tests check, and what this is for.+localPool :: DatabaseReader -> PoolReads+localPool r =+ PoolReads+ { summaries = M.getSummaries r,+ addressTxs = \a start n -> do+ rows <- entries a+ let listed = [(t, h) | (MemAddrTxKey _ t h, _) <- rows]+ from (t, h) = dropWhile (\(t', h') -> t' > t || (t' == t && h' < h)) listed+ return (take n (maybe listed from start)),+ addressOuts = \a start n -> do+ rows <- entries a+ let spent = HashSet.fromList [p | (MemAddrSpentKey _ p, _) <- rows]+ made t p v s = Unspent {block = MemRef t, outpoint = p, value = v, script = s, address = Just a}+ listed = [(made t p v s, HashSet.member p spent) | (MemAddrOutKey _ t p, MemAddrOutVal v s) <- rows]+ place (u, _) = case u.block of+ MemRef t -> (t, encode u.outpoint)+ BlockRef {} -> (0, encode u.outpoint)+ from (t, p) = dropWhile (\x -> let (t', p') = place x in t' > t || (t' == t && p' < encode p)) listed+ return (take n (maybe listed from start)),+ spentOutputs = \a ps ->+ forM ps $ \p ->+ isJust <$> (retrieveCF r.db (mempoolAddrCF r.db) (MemAddrSpentKey a p) :: IO (Maybe MemAddrVal)),+ bodies = \hs -> case hs of+ [] -> return []+ _ -> zip <$> Q.retrieveManyCF r.db (mempoolCF r.db) (map TxKey hs) <*> mapM (M.getMempoolSpenders r) hs,+ spenders = \ps -> case ps of+ [] -> return []+ _ -> Q.retrieveManyCF r.db (mempoolCF r.db) (map MemSpenderKey ps),+ page = \offset limit -> Q.getMempoolPage r Limits {limit = limit, offset = offset, start = Nothing},+ startingWith = \prefix -> do+ rows <- matchingAsListCF r.db (mempoolCF r.db) (TxKeyS prefix)+ return [h | (TxKey h, _ :: TxData) <- rows]+ }+ where+ entries :: Address -> IO [(MemAddrKey, MemAddrVal)]+ entries a = matchingAsListCF r.db (mempoolAddrCF r.db) (MemAddrKeyA a)
@@ -1,13 +1,4 @@-{-# LANGUAGE BangPatterns #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE ImportQualifiedPost #-}-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-}-{-# LANGUAGE NoFieldSelectors #-} -- | -- Module : Haskoin.Store.Database.Reader@@ -17,15 +8,15 @@ -- Stability : experimental -- Portability : POSIX ----- Queries against the RocksDB database of the store.------ 'DatabaseReaderT' implements 'StoreReadBase' and 'StoreReadExtra' by reading--- the indices directly from RocksDB. The database is opened with--- 'withDatabaseReader', which also creates or migrates it.+-- The RocksDB database of the store: opening it ('withDatabaseReader'), which+-- also creates or migrates it, its column families, its metrics, and the+-- memo of the addresses of extended public keys. The queries are in+-- "Haskoin.Store.Database.Query". -- -- Data is split across column families by kind; see 'addrTxCF' and the other -- column family accessors. The keys stored in each are described in -- "Haskoin.Store.Database.Types".+-- -- = Why column families -- -- Each kind of record lives in its own column family. Their keys and access@@ -43,40 +34,34 @@ module Haskoin.Store.Database.Reader ( -- * RocksDB Database Access DatabaseReader (..),- DatabaseReaderT,- DataMetrics,- withDB,+ DataMetrics (..),+ Derived (..),+ derivedAddrs, createDataMetrics, withDatabaseReader,+ withLayeredReader,+ initRocksDB, memoryReport, addrTxCF, addrOutCF, txCF,+ spenderCF, unspentCF, blockCF, heightCF, balanceCF, mempoolCF,- getMempoolBefore,+ mempoolAddrCF, mempoolInsertOps, ) where -import Conduit-import Control.Monad (forM, forM_, replicateM, (>=>))-import Control.Monad.Reader-import Control.Monad.Trans.Maybe (MaybeT (..), runMaybeT)-import Data.Bits ((.&.))-import Data.ByteString qualified as BS+import Control.Monad (forM) import Data.ByteString.Char8 qualified as BS8 import Data.Default (def) import Data.HashMap.Strict (HashMap) import Data.HashMap.Strict qualified as HashMap-import Data.IntMap.Strict qualified as IntMap-import Data.Functor ((<&>))-import Data.Map.Strict qualified as Map-import Data.Maybe (catMaybes, fromMaybe, isJust, mapMaybe)-import Data.Serialize (Serialize, decode, encode)+import Data.Maybe (fromMaybe, isJust, mapMaybe) import Data.Text (Text) import Data.Text qualified as T import Data.Word (Word32)@@ -90,9 +75,6 @@ import Text.Read (readMaybe) import UnliftIO --- | Monad transformer to query the database.-type DatabaseReaderT = ReaderT DatabaseReader- -- | Open database and its settings. data DatabaseReader = DatabaseReader { -- | RocksDB handle, with the column families of the store@@ -131,18 +113,18 @@ derivedCacheSize = 512 -- | Addresses of an xpub, from the cache or a new lazy derivation.-derivedAddrs :: (MonadIO m) => XPubSpec -> DatabaseReaderT m Derived-derivedAddrs xpub = do- ref <- asks (.derived)- ctx <- asks (.ctx)- let fresh =+derivedAddrs :: DatabaseReader -> XPubSpec -> IO Derived+derivedAddrs r xpub = do+ let ref = r.derived+ ctx = r.ctx+ fresh = let addrs m = deriveAddresses (deriveFunction ctx xpub.deriv) (pubSubKey ctx xpub.key m) 0 in Derived (addrs 0) (addrs 1)- liftIO . atomicModifyIORef' ref $ \c ->+ atomicModifyIORef' ref $ \c -> case HashMap.lookup xpub c.newer of Just d -> (c, d) Nothing ->@@ -200,23 +182,23 @@ where n = 10 --- | Run an action with the metrics, if they are enabled.-withMetrics :: (MonadReader DatabaseReader m) => (DataMetrics -> m a) -> m ()-withMetrics go = asks (.metrics) >>= mapM_ go---- | Version of the database layout. Databases of versions 18 and 19 are--- migrated when opened; other versions are refused.+-- | Version of the layout that 'withDatabaseReader' opens, in which the+-- mempool is written into the indices of the chain. Databases of versions+-- 18 and 19 are migrated to it when opened that way; other versions are+-- refused. --+-- The store runs on a later one,+-- 'Haskoin.Store.Database.Migrate.currentVersion', and opens its database+-- with 'withLayeredReader' and 'Haskoin.Store.Database.Migrate.upgradeDatabase'.+-- This version is kept for the tests, which hold the new layout to the+-- answers of this one, and as the way through for databases of 18 and 19.+-- -- Bumping the version is how a change to the database layout is made safe: an -- old database is either migrated in place when it is opened or refused, never -- read with the wrong layout. dataVersion :: Word32 dataVersion = 20 --- | Run a query against a database.-withDB :: DatabaseReader -> DatabaseReaderT m a -> m a-withDB = flip runReaderT- -- | Open the database and run a query against it. Creates the database and its -- column families if missing, and migrates it from older versions. --@@ -226,7 +208,6 @@ -- families are flushed, block cache size in bytes, and metrics. 'Nothing' leaves -- a setting to RocksDB. withDatabaseReader ::- (MonadUnliftIO m) => Network -> Ctx -> Word32 ->@@ -238,24 +219,76 @@ Maybe Int -> Maybe Int -> Maybe DataMetrics ->- DatabaseReaderT m a ->- m a-withDatabaseReader net ctx igap gap dir bloom maxOpenFiles writeBuffers maxWal blockCache stats m =- withRunInIO $ \run ->- withDBCF dir cfg (columnFamilyConfig bloom (isJust blockCache)) $ \db -> do- derived <- newIORef (DerivedCache HashMap.empty HashMap.empty)- let bdb =- DatabaseReader- { db = db,- maxGap = gap,- net = net,- initGap = igap,- metrics = stats,- ctx = ctx,- derived = derived- }- initRocksDB bdb- run $ runReaderT m bdb+ (DatabaseReader -> IO a) ->+ IO a+withDatabaseReader = withReader columnFamilyConfig initRocksDB++-- | Open a database with the column families of the layered layout of+-- @docs\/layered-database.md@: those of 'withDatabaseReader' and one more,+-- for what the mempool does at each address. The arguments are those of+-- 'withDatabaseReader'.+--+-- It neither checks nor sets the version of the database:+-- 'Haskoin.Store.Database.Migrate.upgradeDatabase' does, and has to be+-- called before anything reads the database. The store opens its database+-- with the two.+--+-- Opening a database this way adds the column family to it. RocksDB only+-- opens a database when given all its column families, so a store that does+-- not know this one, such as any through 'withDatabaseReader', can no+-- longer open the database afterwards. That is the point of no return of+-- the migration.+withLayeredReader ::+ Network ->+ Ctx ->+ Word32 ->+ Word32 ->+ FilePath ->+ Bool ->+ Maybe Int ->+ Maybe Int ->+ Maybe Int ->+ Maybe Int ->+ Maybe DataMetrics ->+ (DatabaseReader -> IO a) ->+ IO a+withLayeredReader = withReader layeredFamilyConfig (\_ -> return ())++-- | Open a database with these column families, given whether to use Bloom+-- filters and whether there is a shared block cache, and run a check on it+-- before anything else. The other arguments are those of+-- 'withDatabaseReader'.+withReader ::+ (Bool -> Bool -> [(String, Config)]) ->+ (DatabaseReader -> IO ()) ->+ Network ->+ Ctx ->+ Word32 ->+ Word32 ->+ FilePath ->+ Bool ->+ Maybe Int ->+ Maybe Int ->+ Maybe Int ->+ Maybe Int ->+ Maybe DataMetrics ->+ (DatabaseReader -> IO a) ->+ IO a+withReader families check net ctx igap gap dir bloom maxOpenFiles writeBuffers maxWal blockCache stats m =+ withDBCF dir cfg (families bloom (isJust blockCache)) $ \db -> do+ derived <- newIORef (DerivedCache HashMap.empty HashMap.empty)+ let bdb =+ DatabaseReader+ { db = db,+ maxGap = gap,+ net = net,+ initGap = igap,+ metrics = stats,+ ctx = ctx,+ derived = derived+ }+ check bdb+ m bdb where cfg = def@@ -284,7 +317,11 @@ <> T.justifyRight 16 ' ' (num mem) <> T.justifyRight 16 ' ' (num readers) return . T.unlines $- ["** RocksDB memory (bytes) **", T.justifyLeft 10 ' ' "family" <> T.justifyRight 16 ' ' "memtables" <> T.justifyRight 16 ' ' "table readers"]+ [ "** RocksDB memory (bytes) **",+ T.justifyLeft 10 ' ' "family"+ <> T.justifyRight 16 ' ' "memtables"+ <> T.justifyRight 16 ' ' "table readers"+ ] <> map line rows <> [ line ("total", Just (total (\(_, m, _) -> m)), Just (total (\(_, _, r) -> r))), "block cache: " <> num cache,@@ -302,12 +339,31 @@ -- | Column families, with Bloom filters or not, and with their index and -- filter blocks in the block cache or not.+--+-- = Why the indices by address never have filters+--+-- A filter lets RocksDB skip a table file that cannot have a key, or, for a+-- scan, a key prefix. That is safe for column families that are read by+-- key. It is not for the two that are scanned by address, as long as the+-- iterators of the binding have no bound: a scan asks RocksDB for the first+-- key at or after an address and stops when the key is of another address.+-- With filters, the files that hold the keys of the addresses that follow+-- are skipped, as they should be, but the keys that were deleted lately are+-- not in a file yet, and RocksDB steps over every one of them looking for a+-- key to give back, since it can no longer see that most still have+-- neighbours that are alive.+--+-- Measured with 5,000 mempool transactions just deleted: a scan of an+-- address that has nothing took 810 microseconds with filters on these+-- indices, and 13 without, in the layout of version 20. No filter was ever+-- built before rocksdb-haskell-jprupp 2.6.2, so it never happened to a+-- store, and filters are on by default now for everything else. columnFamilyConfig :: Bool -> Bool -> [(String, Config)] columnFamilyConfig bloom cached =- [ ("addr-tx", c (Just 22)),- ("addr-out", c (Just 22)),+ [ ("addr-tx", scanned (Just 22)),+ ("addr-out", scanned (Just 22)), ("tx", c (Just 33)),- ("spender", c (Just 33)), -- unused+ ("spender", c (Just 33)), ("unspent", c (Just 37)), ("block", c (Just 33)), ("height", c Nothing),@@ -316,7 +372,41 @@ ] where c p = def {prefixLength = p, bloomFilter = bloom, cacheIndexAndFilterBlocks = cached}+ scanned p = (c p) {bloomFilter = False} +-- | Column families of the layered layout: those of 'columnFamilyConfig',+-- and one for the entries of the mempool by address ('MemAddrKey').+--+-- The two column families of the mempool always have Bloom filters, whatever+-- the setting for the others. Almost every lookup in them is for something+-- that is not there: a confirmed transaction, an output that no mempool+-- transaction spends, an address with nothing in the mempool. A filter+-- answers those without reading a block, and for column families this small+-- it takes a few megabytes; on the column families of the chain the same+-- filters would take gigabytes, which is why they are optional there.+--+-- The entries by address are in a column family of their own because their+-- filter is on the first 22 bytes of the key, the kind of key and the+-- address: a scan of an address with nothing in the mempool then stops at+-- the filter. The mempool column family is read by whole keys and scanned+-- across prefixes, for the index by time, so it cannot have a filter by+-- prefix.+--+-- That scan through a filter is what 'columnFamilyConfig' keeps out of the+-- indices of the chain, and it is as slow here after a block, which deletes+-- the keys of the transactions it confirms: 1.6 milliseconds for an address+-- with nothing, measured. It is one of the things that have to change before+-- this layout is used; @docs\/layered-database.md@ says how.+layeredFamilyConfig :: Bool -> Bool -> [(String, Config)]+layeredFamilyConfig bloom cached =+ map mempool (columnFamilyConfig bloom cached)+ <> [("mempool-addr", c (Just 22))]+ where+ c p = def {prefixLength = p, bloomFilter = True, cacheIndexAndFilterBlocks = cached}+ mempool (name, cfg)+ | name == "mempool" = (name, c Nothing)+ | otherwise = (name, cfg)+ -- | Column family for transactions by address ('AddrTxKey'). addrTxCF :: DB -> ColumnFamily addrTxCF db = columnFamilies db !! 0@@ -333,6 +423,11 @@ unspentCF :: DB -> ColumnFamily unspentCF db = columnFamilies db !! 4 +-- | Column family for the inputs of blocks that spend outputs+-- as t'Haskoin.Store.Database.Types.SpenderKey' says.+spenderCF :: DB -> ColumnFamily+spenderCF db = columnFamilies db !! 3+ -- | Column family for blocks ('BlockKey'). blockCF :: DB -> ColumnFamily blockCF db = columnFamilies db !! 5@@ -346,9 +441,16 @@ balanceCF db = columnFamilies db !! 7 -- | Column family for mempool transactions ('MemTxKey' and 'MemTimeKey').+-- In the layered layout it also has their bodies, under t'TxKey', and+-- their spenders, under t'MemSpenderKey'. mempoolCF :: DB -> ColumnFamily mempoolCF db = columnFamilies db !! 8 +-- | Column family for what the mempool does at each address ('MemAddrKey').+-- Only a database opened with 'withLayeredReader' has it.+mempoolAddrCF :: DB -> ColumnFamily+mempoolAddrCF db = columnFamilies db !! 9+ -- | Set the version of a new database, migrate an old one, or call 'error' if the version is not supported. initRocksDB :: DatabaseReader -> IO () initRocksDB DatabaseReader {db = db} =@@ -388,439 +490,6 @@ insertOpCF (mempoolCF db) (MemTimeKey t h) () ] --- | Mempool from the index by time, newest first. The index sorts last in--- the mempool column family, so seek past its prefix and walk backwards--- until a key is no longer in it.-mempoolNewestC :: Iterator -> ConduitT i (UnixTime, TxHash) IO ()-mempoolNewestC it = do- liftIO $ do- -- 0x0c is the first prefix after the 0x0b of 'MemTimeKey'- iterSeek it (BS.singleton 0x0c)- iterValid it >>= \case- True -> iterPrev it- False -> iterLast it- go- where- go =- liftIO (iterKey it) >>= \case- Just k | Right (MemTimeKey t h) <- decode k -> do- yield (t, h)- liftIO (iterPrev it)- go- _ -> return ()---- | Mempool transactions that entered it before the given time, oldest--- first, at most as many as given. Reads only those entries of the index.-getMempoolBefore ::- (MonadIO m) =>- UnixTime ->- Int ->- DatabaseReaderT m [(UnixTime, TxHash)]-getMempoolBefore time n = do- db <- asks (.db)- liftIO . withIterCF db (mempoolCF db) $ \it ->- runConduit $- matching it MemTimeKeyS- .| concatMapC entry- .| takeWhileC ((< time) . fst)- .| takeC n- .| sinkList- where- entry (MemTimeKey t h, ()) = Just (t, h)- entry (MemTimeKeyS, ()) = Nothing- -- | Set the version of a new database. setInitRocksDB :: DB -> IO () setInitRocksDB db = insert db VersionKey dataVersion---- | Stream the transactions of an address, newest first, from a starting point.--- If the start is a transaction that is not found, nothing is streamed.-addressConduit ::- (MonadIO m) =>- Address ->- Maybe Start ->- Iterator ->- ConduitT i TxRef (DatabaseReaderT m) ()-addressConduit a s it = do- pipe .| mapC (uncurry f)- where- f (AddrTxKey _ t) () = t- f _ _ = undefined- pipe = case s of- Nothing ->- readerC $ \_ ->- transPipe liftIO $- matching it (AddrTxKeyA a)- Just (AtBlock bh) ->- readerC $ \_ ->- transPipe liftIO $- matchingSkip- it- (AddrTxKeyA a)- (AddrTxKeyB a (BlockRef bh maxBound))- Just (AtTx txh) ->- lift (getTxData txh) >>= \case- Just TxData {block = b@BlockRef {}} ->- readerC $ \_ ->- transPipe liftIO $- matchingSkip- it- (AddrTxKeyA a)- (AddrTxKeyB a b)- Just TxData {block = MemRef {}} ->- let cond (AddrTxKey _ (TxRef MemRef {} th)) = th /= txh- cond (AddrTxKey _ (TxRef BlockRef {} _)) = False- cond _ = undefined- in readerC $ \_ ->- transPipe liftIO $- matching it (AddrTxKeyA a)- .| (dropWhileC (cond . fst) >> mapC id)- Nothing -> return ()---- | Stream the unspent outputs of an address, newest first, from a starting--- point. If the start is a transaction that is not found, nothing is streamed.-unspentConduit ::- (MonadIO m) =>- Network ->- Ctx ->- Address ->- Maybe Start ->- Iterator ->- ConduitT i Unspent (DatabaseReaderT m) ()-unspentConduit net ctx a s it =- x .| mapC (uncurry (toUnspent net ctx)) .| concatC- where- x = case s of- Nothing ->- readerC $ \_ -> transPipe liftIO $ matching it (AddrOutKeyA a)- Just (AtBlock h) ->- readerC $ \_ ->- transPipe liftIO $- matchingSkip- it- (AddrOutKeyA a)- (AddrOutKeyB a (BlockRef h maxBound))- Just (AtTx txh) ->- lift (getTxData txh) >>= \case- Just TxData {block = b@BlockRef {}} ->- readerC $ \_ ->- transPipe liftIO $- matchingSkip it (AddrOutKeyA a) (AddrOutKeyB a b)- Just TxData {block = MemRef {}} ->- let cond (AddrOutKey _ MemRef {} p) = p.hash /= txh- cond (AddrOutKey _ BlockRef {} _) = False- cond _ = undefined- in readerC $ \_ ->- transPipe liftIO $- matching it (AddrOutKeyA a)- .| (dropWhileC (cond . fst) >> mapC id)- Nothing -> return ()---- | Run an action with an iterator on a column family, counting it in the metrics.-withIterCFStat ::- (MonadUnliftIO m) =>- DB ->- ColumnFamily ->- (Iterator -> DatabaseReaderT m a) ->- DatabaseReaderT m a-withIterCFStat db cf f = do- m <- asks (.metrics)- bracket_ (open m) (close m) go- where- open (Just m) = incrementGauge m.dataIters 1- open Nothing = return ()- close (Just m) = decrementGauge m.dataIters 1- close Nothing = return ()- go = withRunInIO $ \run ->- withIterCF db cf $ \it -> run (f it)---- | Run an action with this many iterators on a column family. They are--- opened and closed in a single bracket rather than one bracket each: each--- bracket leaves a frame on the stack, and every call into RocksDB through--- an iterator walks the stack, so with one bracket per address an xpub of a--- hundred addresses made each step of each iterator a hundred times slower.-withManyIters ::- (MonadUnliftIO m) =>- DB ->- ColumnFamily ->- Int ->- ([Iterator] -> DatabaseReaderT m a) ->- DatabaseReaderT m a-withManyIters db cf i f = do- m <- asks (.metrics)- bracket_ (gauge m i) (gauge m (negate i)) $- withRunInIO $ \run ->- bracket (newIORef []) (readIORef >=> mapM_ destroyIterator) $ \ref -> do- its <- replicateM i . mask_ $ do- it <- createIterator db (Just cf)- modifyIORef' ref (it :)- return it- run (f its)- where- gauge (Just m) n = incrementGauge m.dataIters n- gauge Nothing _ = return ()---- | Values of many keys of a column family, read with one multi-get, in--- the order of the keys.-retrieveManyCF ::- (KeyValue key value, Serialize key, Serialize value) =>- DB ->- ColumnFamily ->- [key] ->- IO [Maybe value]-retrieveManyCF db cf keys =- map (fmap (either error id . decode)) <$> multiGetCF db cf (map encode keys)---- | Merge streams sorted newest first, drop duplicates, and apply the offset and--- limit.-joinConduits ::- (Monad m, Ord o) =>- [ConduitT () o m ()] ->- Limits ->- m [o]-joinConduits cs l =- runConduit $ joinDescStreams cs .| applyLimitsC l .| sinkList--instance (MonadIO m) => StoreReadBase (DatabaseReaderT m) where- getCtx = asks (.ctx)- getNetwork = asks (.net)-- getTxData th = do- db <- asks (.db)- liftIO (retrieveCF db (txCF db) (TxKey th)) >>= \case- Nothing -> return Nothing- Just t -> do- withMetrics $ \s -> incrementCounter s.dataTxCount 1- return (Just t)-- getManyTxData ths = do- db <- asks (.db)- tds <- liftIO (retrieveManyCF db (txCF db) (map TxKey ths))- withMetrics $ \s -> incrementCounter s.dataTxCount (length (catMaybes tds))- return tds-- getSpender op = runMaybeT $ do- td <- MaybeT $ getTxData op.hash- let i = fromIntegral op.index- MaybeT . return $ i `IntMap.lookup` td.spenders-- getUnspent p = do- db <- asks (.db)- net <- asks (.net)- ctx <- asks (.ctx)- val <- liftIO (retrieveCF db (unspentCF db) (UnspentKey p))- case fmap (valToUnspent net ctx p) val of- Nothing -> return Nothing- Just u -> do- withMetrics $ \s -> incrementCounter s.dataUnspentCount 1- return (Just u)-- getBalance a = do- db <- asks (.db)- withMetrics $ \s -> incrementCounter s.dataBalanceCount 1- fmap (valToBalance a) <$> liftIO (retrieveCF db (balanceCF db) (BalKey a))-- getMempool = getMempoolPage def-- getMempoolPage limits = do- db <- asks (.db)- withMetrics $ \s -> incrementCounter s.dataMempoolCount 1- liftIO . withIterCF db (mempoolCF db) $ \it ->- runConduit $- mempoolNewestC it- .| applyLimitsC limits- .| sinkList-- getBestBlock = do- withMetrics $ \s -> incrementCounter s.dataBestCount 1- asks (.db) >>= liftIO . (`retrieve` BestKey)-- getBlocksAtHeight h = do- db <- asks (.db)- liftIO (retrieveCF db (heightCF db) (HeightKey h)) >>= \case- Nothing -> return []- Just ls -> do- withMetrics $ \s -> incrementCounter s.dataBlockCount (length ls)- return ls-- getBlock h = do- db <- asks (.db)- liftIO (retrieveCF db (blockCF db) (BlockKey h)) >>= \case- Nothing -> return Nothing- Just b -> do- withMetrics $ \s -> incrementCounter s.dataBlockCount 1- return (Just b)--instance (MonadUnliftIO m) => StoreReadExtra (DatabaseReaderT m) where- getAddressesTxs addrs limits = do- db <- asks (.db)- withManyIters db (addrTxCF db) (length addrs) $ \its -> do- txs <- joinConduits (cs its) limits- withMetrics $ \s -> incrementCounter s.dataAddrTxCount (length txs)- return txs- where- cs = zipWith c addrs- c a = addressConduit a limits.start-- getAddressesUnspents addrs limits = do- db <- asks (.db)- net <- asks (.net)- ctx <- asks (.ctx)- withManyIters db (addrOutCF db) (length addrs) $ \its -> do- uns <- joinConduits (cs net ctx its) limits- withMetrics $ \s -> incrementCounter s.dataUnspentCount (length uns)- return uns- where- cs net ctx = zipWith (c net ctx) addrs- c net ctx a = unspentConduit net ctx a limits.start-- getAddressUnspents a limits = do- db <- asks (.db)- net <- asks (.net)- ctx <- asks (.ctx)- us <- withIterCFStat db (addrOutCF db) $ \it ->- runConduit $- unspentConduit net ctx a limits.start it- .| applyLimitsC limits- .| sinkList- withMetrics $ \s -> incrementCounter s.dataUnspentCount (length us)- return us-- getAddressTxs a limits = do- db <- asks (.db)- txs <- withIterCFStat db (addrTxCF db) $ \it ->- runConduit $- addressConduit a limits.start it- .| applyLimitsC limits- .| sinkList- withMetrics $ \s -> incrementCounter s.dataAddrTxCount (length txs)- return txs-- getMaxGap = asks (.maxGap)-- getInitialGap = asks (.initGap)-- getNumTxData i = ReaderT $ \r -> do- let (sk, w) = decodeTxKey i- ls <- liftIO $ matchingAsListCF r.db (txCF r.db) (TxKeyS sk)- let f t =- let bs = encode $ txHash t.tx- b = BS.head (BS.drop 6 bs)- w' = b .&. 0xf8- in w == w'- txs = filter f $ map snd ls- forM_ r.metrics $ \s -> incrementCounter s.dataTxCount (length txs)- return txs-- getBalances [] = return []- getBalances as = do- db <- asks (.db)- withMetrics $ \s -> incrementCounter s.dataBalanceCount (length as)- vals <- liftIO (retrieveManyCF db (balanceCF db) (map BalKey as))- return (zipWith f as vals)- where- f a Nothing = zeroBalance a- f a (Just v) = valToBalance a v-- xPubBals xpub = do- igap <- asks (.initGap)- gap <- asks (.maxGap)- Derived ext chg <- derivedAddrs xpub- ext1 <- derive_until_gap gap 0 (take (fromIntegral igap) ext)- if all nullxb ext1- then do- withMetrics $ \s -> incrementCounter s.dataXPubBals (length ext1)- return ext1- else do- ext2 <- derive_until_gap gap 0 (drop (fromIntegral igap) ext)- chg' <- derive_until_gap gap 1 chg- let bals = ext1 <> ext2 <> chg'- withMetrics $ \s -> incrementCounter s.dataXPubBals (length bals)- return bals- where- nullxb b = nullBalance b.balance- derive_until_gap gap m adrs =- let xb b n = XPubBal {path = [m, n], balance = b}- ig = fromIntegral gap- test acc =- let ns = takeWhile nullxb acc- in length ns > ig- go acc [] = return $ reverse acc- go acc as- | test acc =- let (ns, ys) = span nullxb acc- xs = take ig $ reverse ns- in return $ reverse ys <> xs- | otherwise = do- let (as', as'') = splitAt ig as- bs <- getBalances (map snd as')- let xs = reverse $ zipWith xb bs (map fst as')- go (xs <> acc) as''- in go [] adrs-- -- The first unspent outputs of each address, at most offset plus limit- -- of them, sorted together. Read the addresses in step, so that each is- -- read only as far as the page needs, rather than decoding the whole- -- quota of every address to keep one page of it.- xPubUnspents _xspec xbals limits = do- db <- asks (.db)- net <- asks (.net)- ctx <- asks (.ctx)- let quota = case (deOffset limits).limit of- 0 -> Nothing- l -> Just l- stream b it =- unspentConduit net ctx b.balance.address limits.start it- .| mapC (\u -> XPubUnspent {path = b.path, unspent = u})- us <- withManyIters db (addrOutCF db) (length cs) $ \its ->- runConduit $- mergeDescGroups (.unspent.block) quota (zipWith stream cs its)- .| applyLimitsC limits- .| sinkList- withMetrics $ \s -> incrementCounter s.dataXPubUnspents (length us)- return us- where- cs = filter ((> 0) . (.balance.utxo)) xbals-- xPubTxs _xspec xbals limits = do- let as =- map (.address) $- filter (not . nullBalance) $- map (.balance) xbals- txs <- getAddressesTxs as limits- withMetrics $ \s -> incrementCounter s.dataXPubTxs (length txs)- return txs-- -- Count the transactions of all the addresses, each once even when it- -- touches several of them. The entries of an address are sorted, and the- -- same transaction has the same key after the address prefix in every- -- address, so merge the addresses by those raw key bytes and count the- -- distinct ones, without decoding any key or reading any value.- xPubTxCount _xspec xbals = do- withMetrics $ \s -> incrementCounter s.dataXPubTxCount 1- db <- asks (.db)- let as =- map (.address) $- filter (not . nullBalance) $- map (.balance) xbals- withManyIters db (addrTxCF db) (length as) $ \its ->- liftIO $ fromIntegral <$> countDistinctSuffixes (zip (map (encode . AddrTxKeyA) as) its)---- | Number of distinct keys, after their prefix, in a set of iterators that--- each walk the keys with one prefix, in order.-countDistinctSuffixes :: [(BS.ByteString, Iterator)] -> IO Int-countDistinctSuffixes streams = do- heads <- mapM start streams- go 0 (Map.fromListWith (<>) [(k, [x]) | (k, x) <- catMaybes heads])- where- start x@(pfx, it) = iterSeek it pfx >> current x- current x@(pfx, it) =- iterKey it <&> \case- Just k | pfx `BS.isPrefixOf` k -> Just (BS.drop (BS.length pfx) k, x)- _ -> Nothing- advance x@(_, it) = iterNext it >> current x- go !n m = case Map.minViewWithKey m of- Nothing -> return n- Just ((_, xs), m') -> do- nexts <- catMaybes <$> mapM advance xs- go (n + 1) (foldr (\(k, x) -> Map.insertWith (<>) k [x]) m' nexts)
@@ -1,56 +1,77 @@ {-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE ImportQualifiedPost #-}-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE RecordWildCards #-}-{-# LANGUAGE NoFieldSelectors #-} -{- |-Module : Haskoin.Store.Database.Types-Copyright : (c) Haskoin Developers-License : MIT-Maintainer : jprupp@protonmail.ch-Stability : experimental-Portability : POSIX--Keys and values stored in RocksDB.--Every key type serializes to a distinct first byte, so that keys of different-types never collide even when they share a column family. Many key types-have short variants (suffixed @A@, @B@ or @S@) that serialize to a prefix of-the full key, for use with the prefix queries of "Database.RocksDB.Query".--+-----------+--------------+----------------------------------------+-| Prefix | Key | Value |-+===========+==============+========================================+-| 32 x 0x00 | 'BestKey' | hash of the best block |-+-----------+--------------+----------------------------------------+-| 0x01 | 'BlockKey' | 'BlockData' |-+-----------+--------------+----------------------------------------+-| 0x02 | 'TxKey' | 'TxData' |-+-----------+--------------+----------------------------------------+-| 0x03 | 'HeightKey' | hashes of the blocks at a height |-+-----------+--------------+----------------------------------------+-| 0x04 | 'BalKey' | 'BalVal' |-+-----------+--------------+----------------------------------------+-| 0x05 | 'AddrTxKey' | nothing |-+-----------+--------------+----------------------------------------+-| 0x06 | 'AddrOutKey' | 'OutVal' |-+-----------+--------------+----------------------------------------+-| 0x07 | 'MemKey' | whole mempool (legacy) |-+-----------+--------------+----------------------------------------+-| 0x08 | 'MemTxKey' | time a transaction entered the mempool |-+-----------+--------------+----------------------------------------+-| 0x09 | 'UnspentKey' | 'UnspentVal' |-+-----------+--------------+----------------------------------------+-| 0x0a | 'VersionKey' | database version |-+-----------+--------------+----------------------------------------+-| 0x0b | 'MemTimeKey' | nothing |-+-----------+--------------+----------------------------------------+--}+-- |+-- Module : Haskoin.Store.Database.Types+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- Keys and values stored in RocksDB.+--+-- Every key type serializes to a distinct first byte, so that keys of different+-- types never collide even when they share a column family. Many key types+-- have short variants (suffixed @A@, @B@ or @S@) that serialize to a prefix of+-- the full key, for use with the prefix queries of "Database.RocksDB.Query".+--+-- +-----------+--------------+----------------------------------------++-- | Prefix | Key | Value |+-- +===========+==============+========================================++-- | 32 x 0x00 | 'BestKey' | hash of the best block |+-- +-----------+--------------+----------------------------------------++-- | 0x01 | 'BlockKey' | 'BlockData' |+-- +-----------+--------------+----------------------------------------++-- | 0x02 | 'TxKey' | 'TxData' |+-- +-----------+--------------+----------------------------------------++-- | 0x03 | 'HeightKey' | hashes of the blocks at a height |+-- +-----------+--------------+----------------------------------------++-- | 0x04 | 'BalKey' | 'BalVal' |+-- +-----------+--------------+----------------------------------------++-- | 0x05 | 'AddrTxKey' | nothing |+-- +-----------+--------------+----------------------------------------++-- | 0x06 | 'AddrOutKey' | 'OutVal' |+-- +-----------+--------------+----------------------------------------++-- | 0x07 | 'MemKey' | whole mempool (legacy) |+-- +-----------+--------------+----------------------------------------++-- | 0x08 | 'MemTxKey' | time a transaction entered the mempool |+-- +-----------+--------------+----------------------------------------++-- | 0x09 | 'UnspentKey' | 'UnspentVal' |+-- +-----------+--------------+----------------------------------------++-- | 0x0a | 'VersionKey' | database version |+-- +-----------+--------------+----------------------------------------++-- | 0x0b | 'MemTimeKey' | nothing |+-- +-----------+--------------+----------------------------------------++--+-- = Keys of the layered layout+--+-- They belong to the layout that the store runs on, that of+-- @docs\/layered-database.md@, version 22, in which the column families of+-- the chain hold nothing from the mempool and everything about the mempool+-- is in two column families of its own. t'TxKey' is used there too, for the+-- bodies of mempool transactions. "Haskoin.Store.Database.Mempool" reads+-- and writes them, and "Haskoin.Store.Database.Migrate" moves a database of+-- version 20 to that layout.+--+-- +-----------+------------------+-------------------------------------------++-- | Prefix | Key | Value |+-- +===========+==================+===========================================++-- | 0x0c | t'MemSpenderKey' | 'Spender': mempool input that spends an |+-- | | | output |+-- +-----------+------------------+-------------------------------------------++-- | 0x0d | 'MemAddrKey' | 'MemAddrVal': what the mempool does at an |+-- | | | address |+-- +-----------+------------------+-------------------------------------------++-- | 0x0e | t'MemDeletedKey' | 'TxData' of a transaction that left the |+-- | | | mempool unconfirmed: not written any more |+-- +-----------+------------------+-------------------------------------------++-- | 0x0f | t'MigrationKey' | version a migration in progress started |+-- | | | from |+-- +-----------+------------------+-------------------------------------------++-- | 0x10 | t'SpenderKey' | t'SpenderVal': inputs of blocks that |+-- | | | spend outputs of a transaction |+-- +-----------+------------------+-------------------------------------------+ module Haskoin.Store.Database.Types ( AddrTxKey (..), AddrOutKey (..),@@ -61,6 +82,16 @@ MemKey (..), MemTxKey (..), MemTimeKey (..),+ MemSpenderKey (..),+ MemAddrKey (..),+ MemAddrVal (..),+ MemSummary (..),+ MemDeletedKey (..),+ MigrationKey (..),+ SpenderKey (..),+ SpenderVal (..),+ spenderGroup,+ spenderKey, TxKey (..), decodeTxKey, UnspentKey (..),@@ -77,7 +108,7 @@ where import Control.DeepSeq (NFData)-import Control.Monad (guard)+import Control.Monad (forM_, guard, replicateM) import Data.Bits ( shift, (.&.),@@ -86,8 +117,11 @@ import Data.ByteString qualified as BS import Data.Default (Default (..)) import Data.Hashable (Hashable)+import Data.IntMap.Strict (IntMap)+import Data.IntMap.Strict qualified as IntMap import Data.Serialize- ( Serialize (..),+ ( Get,+ Serialize (..), getBytes, getWord16be, getWord32be,@@ -116,6 +150,7 @@ ( Balance (..), BlockData, BlockRef,+ Spender, TxData, TxRef (..), UnixTime,@@ -337,7 +372,7 @@ -- | Index of the mempool by the time each transaction entered it, in the -- mempool column family next to 'MemTxKey'. Keys sort by time, then by--- hash, as the pairs in 'getMempool' do, so a page of the mempool is read+-- hash, as the pairs in @getMempool@ do, so a page of the mempool is read -- with a short scan instead of loading and sorting every entry. data MemTimeKey = -- | entry of a mempool transaction, by time and hash@@ -362,9 +397,330 @@ instance KeyValue MemTimeKey () +-- | Key of the input of a mempool transaction that spends an output, in the+-- mempool column family of the layered layout. The output may be one of a+-- confirmed transaction or of another mempool transaction: the key is the+-- same, so nothing has to change here when the transaction that created the+-- output is confirmed.+--+-- Spenders in the chain's column families are part of the body of the+-- transaction whose output they spend. A mempool spender cannot be: writing+-- it there would let the mempool change the data of the chain.+data MemSpenderKey+ = -- | key of the mempool spender of an output+ MemSpenderKey !OutPoint+ | -- | short key that matches the mempool spenders of the outputs of a+ -- transaction+ MemSpenderKeyS !TxHash+ | -- | short key that matches every mempool spender+ MemSpenderKeyB+ deriving (Show, Read, Eq, Ord, Generic, Hashable)++instance Serialize MemSpenderKey where+ -- 0x0c · TxHash · Index+ put (MemSpenderKey OutPoint {hash = h, index = i}) = do+ put (MemSpenderKeyS h)+ put i+ -- 0x0c · TxHash+ put (MemSpenderKeyS h) = do+ put MemSpenderKeyB+ put h+ -- 0x0c+ put MemSpenderKeyB = putWord8 0x0c+ get = do+ guard . (== 0x0c) =<< getWord8+ h <- get+ i <- get+ return $ MemSpenderKey OutPoint {hash = h, index = i}++instance Key MemSpenderKey++instance KeyValue MemSpenderKey Spender++-- | Key of something that the mempool does at an address, in the column+-- family of mempool address records of the layered layout. There are three+-- kinds, and all those of an address are read with one scan of its prefix.+--+-- Every entry depends on one mempool transaction alone: on its inputs, with+-- the outputs they spend, and on its outputs. None says whether an output+-- that is spent was created by a confirmed transaction or by another+-- mempool transaction, nor whether an output that is created is spent. So+-- a transaction is added by writing its entries and removed by deleting+-- them, whatever else is in the mempool, and nothing here changes when a+-- block confirms some transactions and not others. What a query needs is+-- worked out from the entries of the address when it is answered; see+-- "Haskoin.Store.Database.Mempool".+--+-- Times are stored complemented, as in a mempool 'BlockRef', so that entries+-- come out newest first, which is the order of the API.+data MemAddrKey+ = -- | a mempool transaction that spends from or pays to the address, by+ -- the time it entered the mempool+ MemAddrTxKey !Address !UnixTime !TxHash+ | -- | an output of the address that a mempool transaction spends+ MemAddrSpentKey !Address !OutPoint+ | -- | an output that a mempool transaction pays to the address, spent or+ -- not, by the time the transaction entered the mempool+ MemAddrOutKey !Address !UnixTime !OutPoint+ | -- | what the entries of an address add up to; see t'MemSummary'+ MemAddrSumKey !Address+ | -- | short key that matches the transactions of an address+ MemAddrTxKeyA !Address+ | -- | short key that matches the outputs paid to an address+ MemAddrOutKeyA !Address+ | -- | short key that matches every entry of an address+ MemAddrKeyA !Address+ | -- | short key that matches the entries of every address+ MemAddrKeyS+ deriving (Show, Eq, Ord, Generic, Hashable)++instance Serialize MemAddrKey where+ -- 0x0d · Address · 0x00 · time · TxHash+ put (MemAddrTxKey a t h) = do+ put (MemAddrKeyA a)+ putWord8 0x00+ putWord64be (maxBound - t)+ put h+ -- 0x0d · Address · 0x01 · OutPoint+ put (MemAddrSpentKey a p) = do+ put (MemAddrKeyA a)+ putWord8 0x01+ put p+ -- 0x0d · Address · 0x02 · time · OutPoint+ put (MemAddrOutKey a t p) = do+ put (MemAddrKeyA a)+ putWord8 0x02+ putWord64be (maxBound - t)+ put p+ -- 0x0d · Address · 0x03+ put (MemAddrSumKey a) = do+ put (MemAddrKeyA a)+ putWord8 0x03+ -- 0x0d · Address · 0x00+ put (MemAddrTxKeyA a) = do+ put (MemAddrKeyA a)+ putWord8 0x00+ -- 0x0d · Address · 0x02+ put (MemAddrOutKeyA a) = do+ put (MemAddrKeyA a)+ putWord8 0x02+ -- 0x0d · Address+ put (MemAddrKeyA a) = do+ put MemAddrKeyS+ put a+ -- 0x0d+ put MemAddrKeyS = putWord8 0x0d+ get = do+ guard . (== 0x0d) =<< getWord8+ a <- get+ getWord8 >>= \case+ 0x00 -> MemAddrTxKey a . (maxBound -) <$> getWord64be <*> get+ 0x01 -> MemAddrSpentKey a <$> get+ 0x02 -> MemAddrOutKey a . (maxBound -) <$> getWord64be <*> get+ 0x03 -> return (MemAddrSumKey a)+ _ -> fail "Cannot decode MemAddrKey"++instance Key MemAddrKey++-- | Value of a 'MemAddrKey': nothing for a transaction, the amount in+-- satoshi of an output that is spent, and the amount and script of an output+-- that is created. The first byte says which, so that a value can be decoded+-- without its key.+data MemAddrVal+ = -- | value of a 'MemAddrTxKey'+ MemAddrTxVal+ | -- | value of a 'MemAddrSpentKey': amount of the output+ MemAddrSpentVal !Word64+ | -- | value of a 'MemAddrOutKey': amount and script of the output+ MemAddrOutVal !Word64 !ByteString+ | -- | value of a 'MemAddrSumKey'+ MemAddrSumVal !MemSummary+ deriving (Show, Read, Eq, Ord, Generic, Hashable, NFData)++-- | What the entries of an address in the mempool area add up to, kept so+-- that its balance is one read by key however many entries there are, and+-- so that an address with nothing in the mempool is found by a key that is+-- not there instead of a scan.+--+-- It is a function of the entries of the address, and of nothing else: not+-- of the chain. Whether an output that the mempool spends is one of the+-- chain or one that the mempool made is the difference between 'spent' and+-- 'both', and 'both' only changes when one of the two transactions enters+-- or leaves the mempool. So the summary stays right when a block confirms+-- some transactions and not others, like the entries themselves.+data MemSummary = MemSummary+ { -- | mempool transactions that touch the address+ txs :: !Word64,+ -- | amount of the outputs that the mempool pays to the address+ paid :: !Word64,+ -- | number of those outputs+ paidCount :: !Word64,+ -- | amount of the outputs of the address that the mempool spends+ spent :: !Word64,+ -- | number of those outputs+ spentCount :: !Word64,+ -- | amount of the outputs that the mempool both pays and spends+ both :: !Word64,+ -- | number of those outputs+ bothCount :: !Word64+ }+ deriving (Show, Read, Eq, Ord, Generic, Hashable, Serialize, NFData)++instance Serialize MemAddrVal where+ put MemAddrTxVal = putWord8 0x00+ put (MemAddrSpentVal v) = putWord8 0x01 >> putWord64be v+ put (MemAddrOutVal v s) = putWord8 0x02 >> putWord64be v >> put s+ put (MemAddrSumVal s) = putWord8 0x03 >> put s+ get =+ getWord8 >>= \case+ 0x00 -> return MemAddrTxVal+ 0x01 -> MemAddrSpentVal <$> getWord64be+ 0x02 -> MemAddrOutVal <$> getWord64be <*> get+ 0x03 -> MemAddrSumVal <$> get+ _ -> fail "Cannot decode MemAddrVal"++instance KeyValue MemAddrKey MemAddrVal++-- | Key of the body of a transaction that left the mempool without being+-- confirmed, in the mempool column family of the layered layout: it was+-- replaced, it conflicted with a block, or it was too old. The store keeps+-- such bodies, marked as deleted, so that the API can go on saying what+-- became of a transaction that it once showed.+--+-- Version 20 keeps them in the column family of transactions, under+-- t'TxKey'. There, deleting a mempool transaction writes to the data of the+-- chain, which the layered layout does not allow.+--+-- Nothing writes or reads this key any more: since version 22 the store+-- keeps nothing of a transaction that leaves the mempool unconfirmed. A+-- database that has some has them until its mempool is wiped.+data MemDeletedKey+ = -- | key of a deleted transaction+ MemDeletedKey !TxHash+ | -- | short key that matches the deleted transactions whose hash starts+ -- with these six bytes, as 'TxKeyS' does for the others; see+ -- 'decodeTxKey'+ MemDeletedKeyP !(Word32, Word16)+ | -- | short key that matches every deleted transaction+ MemDeletedKeyS+ deriving (Show, Read, Eq, Ord, Generic, Hashable)++instance Serialize MemDeletedKey where+ -- 0x0e · TxHash+ put (MemDeletedKey h) = do+ put MemDeletedKeyS+ put h+ -- 0x0e · first six bytes of a TxHash+ put (MemDeletedKeyP h) = do+ put MemDeletedKeyS+ put h+ -- 0x0e+ put MemDeletedKeyS = putWord8 0x0e+ get = do+ guard . (== 0x0e) =<< getWord8+ MemDeletedKey <$> get++instance Key MemDeletedKey++instance KeyValue MemDeletedKey TxData++-- | Key that is present while a migration of the database is under way,+-- with the version that it started from. A migration that works in several+-- batches sets it with the first and removes it with the last, so that a+-- store that finds it knows to carry on, whatever the version says.+data MigrationKey = MigrationKey+ deriving (Eq, Show, Read, Ord, Generic, Hashable)++instance Serialize MigrationKey where+ -- 0x0f+ put MigrationKey = putWord8 0x0f+ get = do+ guard . (== 0x0f) =<< getWord8+ return MigrationKey++instance Key MigrationKey++instance KeyValue MigrationKey Word32++-- | Key of the inputs of blocks that spend some outputs of a transaction,+-- in the @spender@ column family, since database version 22: the+-- transaction, and the number of a group of 'spenderGroup' of its outputs+-- that follow each other. The value has the spender of each output of the+-- group that is spent in a t'SpenderVal'.+--+-- Until then the spenders of the outputs of a confirmed transaction were in+-- its own record, 'TxData', which was read and written whole again every+-- time a block spent one of them or was reverted: once for each of its+-- outputs, over years, for a record of any size. Now the record of a+-- transaction is written when a block confirms it and never again, and+-- spending an output reads and writes a value of a few dozen bytes.+--+-- = Why groups+--+-- A key for each spent output would make a spend one write that reads+-- nothing. But a transaction is read far more often than it is spent+-- from, and has to be read with its spenders: that would take a key for+-- each of its outputs, with each read of it. One value+-- for all the outputs of a transaction would cost one key to read, and+-- bring back the trouble with large transactions: a payout to ten+-- thousand would rewrite half a megabyte ten thousand times. With groups+-- of sixteen nearly every transaction has one key, and no value is larger+-- than sixteen spenders.+--+-- The groups of a transaction are next to each other, so all its spenders+-- can also be read with one scan of their common prefix, which is what the+-- prefix of the column family is.+data SpenderKey+ = -- | spenders of a group of outputs of a transaction+ SpenderKey !TxHash !Word32+ | -- | short key that matches the groups of a transaction+ SpenderKeyS !TxHash+ deriving (Show, Read, Eq, Ord, Generic, Hashable)++instance Serialize SpenderKey where+ -- 0x10 · TxHash · group+ put (SpenderKey h g) = do+ put (SpenderKeyS h)+ put g+ -- 0x10 · TxHash+ put (SpenderKeyS h) = do+ putWord8 0x10+ put h+ get = do+ guard . (== 0x10) =<< getWord8+ h <- get+ SpenderKey h <$> get++instance Key SpenderKey++instance KeyValue SpenderKey SpenderVal++-- | The number of outputs whose spenders are under one t'SpenderKey'.+spenderGroup :: Word32+spenderGroup = 16++-- | The key that has the spender of an output.+spenderKey :: OutPoint -> SpenderKey+spenderKey p = SpenderKey p.hash (p.index `div` spenderGroup)++-- | The inputs of blocks that spend outputs of a group, by the number of+-- the output in its transaction. A group none of whose outputs is spent+-- has no key.+newtype SpenderVal = SpenderVal (IntMap Spender)+ deriving (Show, Eq)++instance Serialize SpenderVal where+ put (SpenderVal m) = do+ putWord8 (fromIntegral (IntMap.size m))+ forM_ (IntMap.toAscList m) $ \(i, spender) -> put (fromIntegral i :: Word32) >> put spender+ get = do+ n <- getWord8+ SpenderVal . IntMap.fromList <$> replicateM (fromIntegral n) ((,) . fromIntegral <$> (get :: Get Word32) <*> get)+ -- | Block entry database key. newtype BlockKey = BlockKey BlockHash- deriving (Show, Read, Eq, Ord, Generic, Hashable)+ deriving stock (Show, Read, Eq, Ord, Generic)+ deriving anyclass (Hashable) instance Serialize BlockKey where -- 0x01 · BlockHash@@ -381,7 +737,8 @@ -- | Block height database key. newtype HeightKey = HeightKey BlockHeight- deriving (Show, Read, Eq, Ord, Generic, Hashable)+ deriving stock (Show, Read, Eq, Ord, Generic)+ deriving anyclass (Hashable) instance Serialize HeightKey where -- 0x03 · BlockHeight
@@ -0,0 +1,136 @@+-- |+-- Module : Haskoin.Store.Database.Views+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- The database as it was at each of its recent blocks: a RocksDB snapshot+-- for each, kept until it is no longer needed. Nothing in the store uses+-- this yet. It is the first part of the store's side of "A fleet that+-- answers as one" in @docs\/layered-database.md@.+--+-- = Why+--+-- Stores that share a Redis all answer from one block, the fleet tip+-- ("Haskoin.Store.Fleet"), which is usually not the newest block that a+-- store has: the tip waits for the slowest store. So a store has to be able+-- to answer from a block that it has already built on, and a snapshot is how+-- RocksDB gives the database as it was. A snapshot cannot be taken of the+-- past, only of the present, so the store takes one as each block is+-- imported and keeps it.+--+-- = Letting go+--+-- RocksDB holds on to every version of a key that a snapshot can see, so+-- snapshots are let go as soon as the rules of the fleet allow ('keepViews').+-- A request may be reading through one at that moment. Releasing it then+-- would hand RocksDB freed memory, so each view counts its readers, and one+-- that is let go while it is read is released when the last reader is done.+--+-- The blocks can be of any type that can be compared, as in+-- "Haskoin.Store.Fleet".+module Haskoin.Store.Database.Views+ ( Views,+ newViews,+ closeViews,+ takeView,+ heldViews,+ keepViews,+ withViewAt,+ )+where++import Control.Monad (forM_, when)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Database.RocksDB (DB, Snapshot, createSnapshot, releaseSnapshot)+import Haskoin.Store.Database.Reader (DatabaseReader (..))+import UnliftIO+ ( TVar,+ atomically,+ bracket,+ mask_,+ newTVarIO,+ readTVar,+ readTVarIO,+ stateTVar,+ writeTVar,+ )++-- | The views of a database, by block.+data Views b = Views+ { reader :: !DatabaseReader,+ held :: !(TVar (Map b Held))+ }++-- | A snapshot, the number of readers that are using it, and whether it is+-- to be released when the last of them is done.+data Held = Held+ { snapshot :: !(DB, Snapshot),+ readers :: !Int,+ doomed :: !Bool+ }++-- | No views yet, of a database.+newViews :: DatabaseReader -> IO (Views b)+newViews reader = Views reader <$> newTVarIO Map.empty++-- | Take a view of the database as it is now, for a block. The caller must+-- be the one that writes to the database, between the import of that block+-- and its next write, so that the view is of that block and nothing else.+-- A view that the block already has is kept, and is no longer to be+-- released: a block that comes back after a reorganisation is the same+-- block.+takeView :: (Ord b) => Views b -> b -> IO ()+takeView views block = mask_ $ do+ fresh <- createSnapshot views.reader.db+ spare <- atomically . stateTVar views.held $ \m -> case Map.lookup block m of+ Just old -> (True, Map.insert block old {doomed = False} m)+ Nothing -> (False, Map.insert block Held {snapshot = fresh, readers = 0, doomed = False} m)+ when spare (releaseSnapshot fresh)++-- | The blocks that there is a view of and that is not on its way out.+heldViews :: Views b -> IO [b]+heldViews views = Map.keys . Map.filter (not . (.doomed)) <$> readTVarIO views.held++-- | Let go of every view but those of the given blocks. A view that is+-- being read is released when its last reader is done, and counts as gone+-- from now on.+keepViews :: (Ord b) => Views b -> [b] -> IO ()+keepViews views blocks = mask_ $ do+ free <- atomically . stateTVar views.held $ \m ->+ let (stay, go) = Map.partitionWithKey (\b _ -> b `elem` blocks) m+ (idle, busy) = Map.partition ((== 0) . (.readers)) go+ in (Map.elems idle, stay <> Map.map (\h -> h {doomed = True}) busy)+ forM_ free (releaseSnapshot . (.snapshot))++-- | Run an action with the database as it was at a block, if there is a+-- view of it: a reader whose every read goes through the snapshot. The+-- reader must not be used after the action returns.+withViewAt :: (Ord b) => Views b -> b -> (DatabaseReader -> IO a) -> IO (Maybe a)+withViewAt views block action = bracket enter leave (mapM run)+ where+ enter = atomically $ do+ m <- readTVar views.held+ case Map.lookup block m of+ Just h | not h.doomed -> do+ writeTVar views.held (Map.insert block h {readers = h.readers + 1} m)+ return (Just h)+ _ -> return Nothing+ run h = action views.reader {db = fst h.snapshot}+ leave Nothing = return ()+ leave (Just _) = do+ free <- atomically . stateTVar views.held $ \m -> case Map.lookup block m of+ Just h+ | h.readers <= 1 && h.doomed -> (Just h, Map.delete block m)+ | otherwise -> (Nothing, Map.insert block h {readers = h.readers - 1} m)+ Nothing -> (Nothing, m)+ forM_ free (releaseSnapshot . (.snapshot))++-- | Release every view. None may be in use.+closeViews :: Views b -> IO ()+closeViews views = mask_ $ do+ m <- atomically (stateTVar views.held (,Map.empty))+ forM_ m (releaseSnapshot . (.snapshot))
@@ -1,12 +1,4 @@-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE ImportQualifiedPost #-}-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE NamedFieldPuns #-}-{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE RecordWildCards #-}-{-# LANGUAGE NoFieldSelectors #-} -- | -- Module : Haskoin.Store.Database.Writer@@ -18,11 +10,15 @@ -- -- Buffered writes to the database. ----- 'WriterT' implements 'StoreWrite' by recording every change in in-memory hash--- tables, and 'StoreReadBase' by reading those tables first and falling back to--- the database. When the action given to 'runWriter' finishes, all the changes--- are written to RocksDB in a single atomic batch. A block or a set of--- transactions is thus either fully imported or not at all.+-- A 'Writer' records every change in in-memory hash tables, and its reads look+-- in those tables first and fall back to the database. When the function given+-- to 'runWriter' returns, all the changes are written to RocksDB in a single+-- atomic batch. A block or a set of transactions is thus either fully imported+-- or not at all.+--+-- The writer is a plain handle passed to plain functions in 'IO' ('getTxData',+-- 'insertTx', ...), rather than a monad transformer with class instances: the+-- import logic says which writer it uses, and needs nothing but 'IO' to run. -- = Why buffer writes -- -- Importing a block touches thousands of keys, and many of them several times:@@ -30,23 +26,84 @@ -- transaction of its address. Buffering collapses those into one final value per -- key, and a single atomic batch makes the whole import all-or-nothing. A crash -- or an invalid block leaves the database exactly as it was.+--+-- = Two layouts+--+-- The writes of the first groups below are to the column families that the+-- store uses today, where the chain and the mempool share their indices.+-- The last group is for the layered layout of @docs\/layered-database.md@,+-- in which the mempool has an area of its own: bodies, spenders, address+-- entries and deleted transactions of the mempool. A batch can write to+-- both areas, and is still one atomic write. Only+-- "Haskoin.Store.Logic.Layered" uses that group, on a database opened with+-- 'Haskoin.Store.Database.Reader.withLayeredReader'; a batch that does not+-- use it writes nothing there. module Haskoin.Store.Database.Writer- ( WriterT,+ ( -- * Batches+ Writer, MempoolChanges (..), runWriter, runWriterMempool,+ writerNet,+ writerCtx, writerLogger,++ -- * Reads, with the changes of the batch applied+ getBestBlock,+ getBlocksAtHeight,+ getBlock,+ getTxData,+ getSpender,+ getUnspent,+ getBalance,+ getMempool,++ -- * Writes+ setBest,+ insertBlock,+ setBlocksAtHeight,+ insertTx,+ insertAddrTx,+ deleteAddrTx,+ insertAddrUnspent,+ deleteAddrUnspent,+ addToMempool,+ deleteFromMempool,+ inMempool,+ setBalance,+ insertUnspent,+ deleteUnspent,+ deleteTxData,+ deleteBalance,++ -- * The mempool area of the layered layout+ getMempoolTx,+ getMempoolSpender,+ getMempoolSpenders,+ getChainSpender,+ getChainSpenders,+ insertSpender,+ deleteSpender,+ insertMempoolTx,+ deleteMempoolTx,+ insertMempoolSpender,+ deleteMempoolSpender,+ insertMempoolAddr,+ deleteMempoolAddr,+ getMempoolAddr, ) where import Control.Monad (forM)-import Control.Monad.Reader (ReaderT (..))-import Control.Monad.Reader qualified as R+import Data.Functor ((<&>)) import Data.HashMap.Strict qualified as HashMap import Data.HashTable.IO qualified as H+import Data.IntMap.Strict (IntMap) import Data.IntMap.Strict qualified as IntMap import Data.List (sortOn)+import Data.Maybe (catMaybes, isJust) import Data.Ord (Down (..))+import Data.Word (Word32) import Database.RocksDB (BatchOp, DB) import Database.RocksDB.Query ( deleteOp,@@ -68,20 +125,21 @@ txHash, ) import Haskoin.Node (Logger)-import Haskoin.Store.Common import Haskoin.Store.Data+import Haskoin.Store.Database.Mempool qualified as M+import Haskoin.Store.Database.Query qualified as Q import Haskoin.Store.Database.Reader import Haskoin.Store.Database.Types import UnliftIO ( IORef,- MonadIO, liftIO, newIORef, readIORef, writeIORef, ) --- | Environment of a batch of writes.+-- | A batch of writes: the database it reads from and writes to, the+-- changes made so far, and where to log. Make one with 'runWriter'. data Writer = Writer { -- | database to read from and write to reader :: !DatabaseReader,@@ -91,24 +149,17 @@ logger :: !Logger } --- | Monad transformer for a batch of writes. Run it with 'runWriter'.-type WriterT = ReaderT Writer+-- | Network of the database of a batch.+writerNet :: Writer -> Network+writerNet w = w.reader.net --- | Logger passed to 'runWriter'.-writerLogger :: (Monad m) => WriterT m Logger-writerLogger = R.asks (.logger)+-- | Secp256k1 context of the database of a batch.+writerCtx :: Writer -> Ctx+writerCtx w = w.reader.ctx -instance (MonadIO m) => StoreReadBase (WriterT m) where- getNetwork = R.asks (.reader.net)- getCtx = R.asks (.reader.ctx)- getBestBlock = getBestBlockI- getBlocksAtHeight = getBlocksAtHeightI- getBlock = getBlockI- getTxData = getTxDataI- getSpender = getSpenderI- getUnspent = getUnspentI- getBalance = getBalanceI- getMempool = getMempoolI+-- | Logger passed to 'runWriter'.+writerLogger :: Writer -> Logger+writerLogger w = w.logger -- | Change to the best block: 'Nothing' if unchanged, @Just Nothing@ if deleted. type BestRef = IORef (Maybe (Maybe BlockHash))@@ -137,6 +188,15 @@ -- | Mempool changes in this batch: 'Nothing' for a deletion. type MempoolTable = H.BasicHashTable TxHash (Maybe UnixTime) +-- | Changed groups of spenders of outputs in the chain.+type SpenderTable = H.BasicHashTable (TxHash, Word32) (IntMap Spender)++-- | Changed mempool spenders of outputs: 'Nothing' for a deletion.+type MemSpenderTable = H.BasicHashTable OutPoint (Maybe Spender)++-- | Changed entries of the mempool by address: 'Nothing' for a deletion.+type MemAddrTable = H.BasicHashTable MemAddrKey (Maybe MemAddrVal)+ -- | Changes made in a batch, not yet written to the database. data Memory = Memory { -- | network constants@@ -160,77 +220,128 @@ -- | address unspent outputs outputTable :: !AddrOutTable, -- | mempool- mempoolTable :: !MempoolTable+ mempoolTable :: !MempoolTable,+ -- | bodies of mempool transactions, in the mempool area+ memTxTable :: !TxTable,+ -- | mempool spenders of outputs, in the mempool area+ memSpenderTable :: !MemSpenderTable,+ -- | entries of the mempool by address, in the mempool area+ memAddrTable :: !MemAddrTable,+ -- | inputs of blocks that spend outputs, by group of outputs+ -- as t'SpenderKey' says, each group as the batch leaves it: empty for one+ -- that has no spent output left+ spenderTable :: !SpenderTable } -instance (MonadIO m) => StoreWrite (WriterT m) where- setBest h =- ReaderT $ \Writer {memory = Memory {..}} ->- liftIO $ writeIORef best (Just (Just h))- insertBlock b =- ReaderT $ \Writer {memory = Memory {..}} ->- liftIO $ H.insert blockTable (headerHash b.header) (Just b)- setBlocksAtHeight h g =- ReaderT $ \Writer {memory = Memory {..}} ->- liftIO $ H.insert heightTable g h- insertTx t =- ReaderT $ \Writer {memory = Memory {..}} ->- liftIO $ H.insert txTable (txHash t.tx) (Just t)- insertAddrTx a t =- ReaderT $ \Writer {memory = Memory {..}} ->- liftIO $ H.insert addressTable (a, t) (Just ())- deleteAddrTx a t =- ReaderT $ \Writer {memory = Memory {..}} ->- liftIO $ H.insert addressTable (a, t) Nothing- insertAddrUnspent a u =- ReaderT $ \Writer {memory = Memory {..}} ->- liftIO $ H.insert outputTable k (Just v)- where- k = (a, u.block, u.outpoint)- v = OutVal u.value u.script- deleteAddrUnspent a u =- ReaderT $ \Writer {memory = Memory {..}} ->- liftIO $ H.insert outputTable k Nothing- where- k = (a, u.block, u.outpoint)- addToMempool x t =- ReaderT $ \Writer {memory = Memory {..}} ->- liftIO $ H.insert mempoolTable x (Just t)- deleteFromMempool x =- ReaderT $ \Writer {memory = Memory {..}} ->- liftIO $ H.insert mempoolTable x Nothing- setBalance b =- ReaderT $ \Writer {memory = Memory {..}} ->- liftIO $ H.insert balanceTable b.address (Just b)- insertUnspent u =- ReaderT $ \Writer {memory = Memory {..}} ->- liftIO $ H.insert unspentTable (fst (unspentToVal u)) (Just u)- deleteUnspent p =- ReaderT $ \Writer {memory = Memory {..}} ->- liftIO $ H.insert unspentTable p Nothing+-- | Set the best block.+setBest :: Writer -> BlockHash -> IO ()+setBest w h = writeIORef w.memory.best (Just (Just h)) --- | Read a value from the changes of the batch, or from the database if the batch did not change it.-getLayered ::- (MonadIO m) =>- (Memory -> IO (Maybe a)) ->- DatabaseReaderT m a ->- WriterT m a-getLayered f g =- ReaderT $ \Writer {reader = db, memory = s} ->- liftIO (f s) >>= \case- Just x -> return x- Nothing -> runReaderT g db+-- | Add or replace the data of a block.+insertBlock :: Writer -> BlockData -> IO ()+insertBlock w b = H.insert w.memory.blockTable (headerHash b.header) (Just b) +-- | Set the hashes of the blocks at a height.+setBlocksAtHeight :: Writer -> [BlockHash] -> BlockHeight -> IO ()+setBlocksAtHeight w h g = H.insert w.memory.heightTable g h++-- | Add or replace the data of a transaction.+insertTx :: Writer -> TxData -> IO ()+insertTx w t = H.insert w.memory.txTable (txHash t.tx) (Just t)++-- | Add a transaction to the index of an address.+insertAddrTx :: Writer -> Address -> TxRef -> IO ()+insertAddrTx w a t = H.insert w.memory.addressTable (a, t) (Just ())++-- | Remove a transaction from the index of an address.+deleteAddrTx :: Writer -> Address -> TxRef -> IO ()+deleteAddrTx w a t = H.insert w.memory.addressTable (a, t) Nothing++-- | Add an unspent output to the index of an address.+insertAddrUnspent :: Writer -> Address -> Unspent -> IO ()+insertAddrUnspent w a u =+ H.insert w.memory.outputTable (a, u.block, u.outpoint) (Just (OutVal u.value u.script))++-- | Remove an unspent output from the index of an address.+deleteAddrUnspent :: Writer -> Address -> Unspent -> IO ()+deleteAddrUnspent w a u =+ H.insert w.memory.outputTable (a, u.block, u.outpoint) Nothing++-- | Add a transaction to the mempool with the time it arrived.+addToMempool :: Writer -> TxHash -> UnixTime -> IO ()+addToMempool w x t = H.insert w.memory.mempoolTable x (Just t)++-- | Remove a transaction from the mempool.+deleteFromMempool :: Writer -> TxHash -> IO ()+deleteFromMempool w x = H.insert w.memory.mempoolTable x Nothing++-- | Set the balance of an address.+setBalance :: Writer -> Balance -> IO ()+setBalance w b = H.insert w.memory.balanceTable b.address (Just b)++-- | Add an unspent output.+insertUnspent :: Writer -> Unspent -> IO ()+insertUnspent w u = H.insert w.memory.unspentTable (fst (unspentToVal u)) (Just u)++-- | Remove an unspent output.+deleteUnspent :: Writer -> OutPoint -> IO ()+deleteUnspent w p = H.insert w.memory.unspentTable p Nothing++-- | Remove the data of a transaction.+deleteTxData :: Writer -> TxHash -> IO ()+deleteTxData w h = H.insert w.memory.txTable h Nothing++-- | Remove the balance of an address, as that of one never used.+deleteBalance :: Writer -> Address -> IO ()+deleteBalance w a = H.insert w.memory.balanceTable a Nothing++-- | Add or replace the body of a mempool transaction in the mempool area.+insertMempoolTx :: Writer -> TxData -> IO ()+insertMempoolTx w t = H.insert w.memory.memTxTable (txHash t.tx) (Just t)++-- | Remove the body of a mempool transaction from the mempool area.+deleteMempoolTx :: Writer -> TxHash -> IO ()+deleteMempoolTx w h = H.insert w.memory.memTxTable h Nothing++-- | Record the mempool input that spends an output.+insertMempoolSpender :: Writer -> OutPoint -> Spender -> IO ()+insertMempoolSpender w p s = H.insert w.memory.memSpenderTable p (Just s)++-- | Forget the mempool input that spends an output.+deleteMempoolSpender :: Writer -> OutPoint -> IO ()+deleteMempoolSpender w p = H.insert w.memory.memSpenderTable p Nothing++-- | Add an entry of the mempool by address.+insertMempoolAddr :: Writer -> MemAddrKey -> MemAddrVal -> IO ()+insertMempoolAddr w k v = H.insert w.memory.memAddrTable k (Just v)++-- | Remove an entry of the mempool by address.+deleteMempoolAddr :: Writer -> MemAddrKey -> IO ()+deleteMempoolAddr w k = H.insert w.memory.memAddrTable k Nothing++-- | An entry of the mempool by address, with the changes of the batch+-- applied.+getMempoolAddr :: Writer -> MemAddrKey -> IO (Maybe MemAddrVal)+getMempoolAddr w k = getLayered w (\s -> H.lookup s.memAddrTable k) (\r -> retrieveCF r.db (mempoolAddrCF r.db) k)++-- | Read a value from the changes of the batch, or from the database if the+-- batch did not change it.+getLayered :: Writer -> (Memory -> IO (Maybe a)) -> (DatabaseReader -> IO a) -> IO a+getLayered w f g =+ f w.memory >>= \case+ Just x -> return x+ Nothing -> g w.reader+ -- | Run a batch of writes and commit all its changes to the database in one--- atomic write. Reads within the batch see its own changes.+-- atomic write. Reads within the batch see its own changes. If the function+-- throws, nothing is written. runWriter ::- (MonadIO m) => Network -> Ctx -> DatabaseReader -> Logger ->- WriterT m a ->- m a+ (Writer -> IO a) ->+ IO a runWriter net ctx bdb logger f = fst <$> runWriterMempool net ctx bdb logger f @@ -247,23 +358,22 @@ -- | Like 'runWriter', but also say how the batch changed the mempool, so -- that callers do not have to read the whole mempool before and after. runWriterMempool ::- (MonadIO m) => Network -> Ctx -> DatabaseReader -> Logger ->- WriterT m a ->- m (a, MempoolChanges)+ (Writer -> IO a) ->+ IO (a, MempoolChanges) runWriterMempool net ctx bdb@DatabaseReader {db} logger f = do hm <- newMemory net ctx- x <- R.runReaderT f Writer {reader = bdb, memory = hm, logger}+ x <- f Writer {reader = bdb, memory = hm, logger} ops <- hashMapOps db hm (mops, changes) <- mempoolOps db hm.mempoolTable- liftIO $ writeBatch db (ops <> mops)+ writeBatch db (ops <> mops) return (x, changes) -- | Database operations for all changes of a batch except the mempool.-hashMapOps :: (MonadIO m) => DB -> Memory -> m [BatchOp]+hashMapOps :: DB -> Memory -> IO [BatchOp] hashMapOps db Memory {..} = mconcat <$> sequence@@ -274,11 +384,15 @@ balOps db balanceTable, addrTxOps db addressTable, addrOutOps db outputTable,- unspentOps db unspentTable+ unspentOps db unspentTable,+ memTxOps db memTxTable,+ memSpenderOps db memSpenderTable,+ memAddrOps db memAddrTable,+ spenderOps db spenderTable ] -- | Operation for the change of the best block, if any.-bestBlockOp :: (MonadIO m) => BestRef -> m [BatchOp]+bestBlockOp :: BestRef -> IO [BatchOp] bestBlockOp r = readIORef r >>= \case Nothing -> return []@@ -286,42 +400,42 @@ Just (Just b) -> return [insertOp BestKey b] -- | Operations for changed blocks.-blockHashOps :: (MonadIO m) => DB -> BlockTable -> m [BatchOp]-blockHashOps db t = map (uncurry f) <$> liftIO (H.toList t)+blockHashOps :: DB -> BlockTable -> IO [BatchOp]+blockHashOps db t = map (uncurry f) <$> (H.toList t) where f k (Just d) = insertOpCF (blockCF db) (BlockKey k) d f k Nothing = deleteOpCF (blockCF db) (BlockKey k) -- | Operations for changed block heights.-blockHeightOps :: (MonadIO m) => DB -> HeightTable -> m [BatchOp]-blockHeightOps db t = map (uncurry f) <$> liftIO (H.toList t)+blockHeightOps :: DB -> HeightTable -> IO [BatchOp]+blockHeightOps db t = map (uncurry f) <$> (H.toList t) where f = insertOpCF (heightCF db) . HeightKey -- | Operations for changed transactions.-txOps :: (MonadIO m) => DB -> TxTable -> m [BatchOp]-txOps db t = map (uncurry f) <$> liftIO (H.toList t)+txOps :: DB -> TxTable -> IO [BatchOp]+txOps db t = map (uncurry f) <$> (H.toList t) where f k (Just t') = insertOpCF (txCF db) (TxKey k) t' f k Nothing = deleteOpCF (txCF db) (TxKey k) -- | Operations for changed balances.-balOps :: (MonadIO m) => DB -> BalanceTable -> m [BatchOp]-balOps db t = map (uncurry f) <$> liftIO (H.toList t)+balOps :: DB -> BalanceTable -> IO [BatchOp]+balOps db t = map (uncurry f) <$> (H.toList t) where f a (Just b) = insertOpCF (balanceCF db) (BalKey a) (balanceToVal b) f a Nothing = deleteOpCF (balanceCF db) (BalKey a) -- | Operations for changed address transactions.-addrTxOps :: (MonadIO m) => DB -> AddrTxTable -> m [BatchOp]-addrTxOps db t = map (uncurry f) <$> liftIO (H.toList t)+addrTxOps :: DB -> AddrTxTable -> IO [BatchOp]+addrTxOps db t = map (uncurry f) <$> (H.toList t) where f (a, t') (Just ()) = insertOpCF (addrTxCF db) (AddrTxKey a t') () f (a, t') Nothing = deleteOpCF (addrTxCF db) (AddrTxKey a t') -- | Operations for changed address unspent outputs.-addrOutOps :: (MonadIO m) => DB -> AddrOutTable -> m [BatchOp]-addrOutOps db t = map (uncurry f) <$> liftIO (H.toList t)+addrOutOps :: DB -> AddrOutTable -> IO [BatchOp]+addrOutOps db t = map (uncurry f) <$> (H.toList t) where f (a, b, p) (Just l) = insertOpCF@@ -337,8 +451,8 @@ -- compared to the database: its time is needed to update the index by -- time, and the comparison says which transactions really entered or left -- the mempool.-mempoolOps :: (MonadIO m) => DB -> MempoolTable -> m ([BatchOp], MempoolChanges)-mempoolOps db t = liftIO $ do+mempoolOps :: DB -> MempoolTable -> IO ([BatchOp], MempoolChanges)+mempoolOps db t = do changes <- H.toList t rows <- forM changes $ \(h, new) -> do old <- retrieveCF db cf (MemTxKey h)@@ -360,62 +474,175 @@ | o == n = [] | otherwise = deleteOpCF cf (MemTimeKey o h) : mempoolInsertOps db h n +-- | Operations for changed bodies of mempool transactions.+memTxOps :: DB -> TxTable -> IO [BatchOp]+memTxOps db t = map (uncurry f) <$> H.toList t+ where+ f h (Just td) = insertOpCF (mempoolCF db) (TxKey h) td+ f h Nothing = deleteOpCF (mempoolCF db) (TxKey h)++-- | Operations for the changed groups of spenders of the chain.+spenderOps :: DB -> SpenderTable -> IO [BatchOp]+spenderOps db t = map (uncurry f) <$> H.toList t+ where+ f (h, g) m+ | IntMap.null m = deleteOpCF (spenderCF db) (SpenderKey h g)+ | otherwise = insertOpCF (spenderCF db) (SpenderKey h g) (SpenderVal m)++-- | Operations for changed mempool spenders.+memSpenderOps :: DB -> MemSpenderTable -> IO [BatchOp]+memSpenderOps db t = map (uncurry f) <$> H.toList t+ where+ f p (Just s) = insertOpCF (mempoolCF db) (MemSpenderKey p) s+ f p Nothing = deleteOpCF (mempoolCF db) (MemSpenderKey p)++-- | Operations for changed entries of the mempool by address. Their column+-- family is only looked up if there are any, as a database of the layout in+-- use today does not have it.+memAddrOps :: DB -> MemAddrTable -> IO [BatchOp]+memAddrOps db t = map (uncurry f) <$> H.toList t+ where+ f k (Just v) = insertOpCF (mempoolAddrCF db) k v+ f k Nothing = deleteOpCF (mempoolAddrCF db) k+ -- | Operations for changed unspent outputs.-unspentOps :: (MonadIO m) => DB -> UnspentTable -> m [BatchOp]-unspentOps db t = map (uncurry f) <$> liftIO (H.toList t)+unspentOps :: DB -> UnspentTable -> IO [BatchOp]+unspentOps db t = map (uncurry f) <$> (H.toList t) where f p (Just u) = insertOpCF (unspentCF db) (UnspentKey p) (snd (unspentToVal u)) f p Nothing = deleteOpCF (unspentCF db) (UnspentKey p) --- | Read the best block, with the changes of the batch applied.-getBestBlockI :: (MonadIO m) => WriterT m (Maybe BlockHash)-getBestBlockI = getLayered getBestBlockH getBestBlock+-- | Best block, with the changes of the batch applied.+getBestBlock :: Writer -> IO (Maybe BlockHash)+getBestBlock w = getLayered w getBestBlockH Q.getBestBlock --- | Read the blocks at a height, with the changes of the batch applied.-getBlocksAtHeightI :: (MonadIO m) => BlockHeight -> WriterT m [BlockHash]-getBlocksAtHeightI bh =- getLayered (getBlocksAtHeightH bh) (getBlocksAtHeight bh)+-- | Blocks at a height, with the changes of the batch applied.+getBlocksAtHeight :: Writer -> BlockHeight -> IO [BlockHash]+getBlocksAtHeight w bh = getLayered w (getBlocksAtHeightH bh) (`Q.getBlocksAtHeight` bh) --- | Read a block, with the changes of the batch applied.-getBlockI :: (MonadIO m) => BlockHash -> WriterT m (Maybe BlockData)-getBlockI bh = getLayered (getBlockH bh) (getBlock bh)+-- | Data of a block, with the changes of the batch applied.+getBlock :: Writer -> BlockHash -> IO (Maybe BlockData)+getBlock w bh = getLayered w (getBlockH bh) (`Q.getBlock` bh) --- | Read a transaction, with the changes of the batch applied.-getTxDataI :: (MonadIO m) => TxHash -> WriterT m (Maybe TxData)-getTxDataI th = getLayered (getTxDataH th) (getTxData th)+-- | Data of a transaction, deleted ones included, with the changes of the+-- batch applied.+getTxData :: Writer -> TxHash -> IO (Maybe TxData)+getTxData w th = getLayered w (getTxDataH th) (`Q.getTxRecord` th) --- | Read the spender of an output, with the changes of the batch applied.-getSpenderI :: (MonadIO m) => OutPoint -> WriterT m (Maybe Spender)-getSpenderI op = getLayered (getSpenderH op) (getSpender op)+-- | Spender of an output, with the changes of the batch applied.+getSpender :: Writer -> OutPoint -> IO (Maybe Spender)+getSpender w op = getLayered w (getSpenderH op) fromRecord+ where+ -- the rules of version 20 keep spenders in the record of the+ -- transaction that made the output, and this is theirs+ fromRecord r = (>>= IntMap.lookup (fromIntegral op.index) . (.spenders)) <$> Q.getTxRecord r op.hash --- | Read the balance of an address, with the changes of the batch applied.-getBalanceI :: (MonadIO m) => Address -> WriterT m (Maybe Balance)-getBalanceI a = getLayered (getBalanceH a) (getBalance a)+-- | Balance of an address, with the changes of the batch applied.+getBalance :: Writer -> Address -> IO (Maybe Balance)+getBalance w a = getLayered w (getBalanceH a) (`Q.getBalance` a) --- | Read an unspent output, with the changes of the batch applied.-getUnspentI :: (MonadIO m) => OutPoint -> WriterT m (Maybe Unspent)-getUnspentI op = getLayered (getUnspentH op) (getUnspent op)+-- | Unspent output, with the changes of the batch applied.+getUnspent :: Writer -> OutPoint -> IO (Maybe Unspent)+getUnspent w op = getLayered w (getUnspentH op) (`Q.getUnspent` op) +-- | Whether a transaction is in the mempool, with the changes of the batch+-- applied. It reads the small key that lists the transaction, where+-- 'getMempoolTx' reads and decodes its whole body: a rule that only needs+-- to know asks this.+inMempool :: Writer -> TxHash -> IO Bool+inMempool w h =+ H.lookup w.memory.mempoolTable h >>= \case+ Just change -> return (isJust change)+ Nothing -> isJust <$> (retrieveCF w.reader.db (mempoolCF w.reader.db) (MemTxKey h) :: IO (Maybe UnixTime))++-- | Body of a transaction in the mempool area, without its spenders, with+-- the changes of the batch applied.+getMempoolTx :: Writer -> TxHash -> IO (Maybe TxData)+getMempoolTx w h = getLayered w (\s -> H.lookup s.memTxTable h) (`M.getMempoolTx` h)++-- | Mempool input that spends an output, with the changes of the batch+-- applied.+getMempoolSpender :: Writer -> OutPoint -> IO (Maybe Spender)+getMempoolSpender w p = getLayered w (\s -> H.lookup s.memSpenderTable p) stored+ where+ stored r = retrieveCF r.db (mempoolCF r.db) (MemSpenderKey p)++-- | Mempool inputs that spend the outputs of a transaction, by output+-- index, with the changes of the batch applied. The second argument is the+-- number of outputs of the transaction: what the database has for it is+-- read with one scan, and the changes of the batch are looked up for each+-- output.+getMempoolSpenders :: Writer -> TxHash -> Int -> IO (IntMap Spender)+getMempoolSpenders w h outputs = do+ stored <- M.getMempoolSpenders w.reader h+ changed <- catMaybes <$> mapM change [0 .. outputs - 1]+ return $ IntMap.mapMaybe id (IntMap.union (IntMap.fromList changed) (IntMap.map Just stored))+ where+ change i = fmap (i,) <$> H.lookup w.memory.memSpenderTable (OutPoint h (fromIntegral i))++-- | The spenders of a group of outputs of a transaction, with the changes+-- of the batch applied.+spenderGroupOf :: Writer -> TxHash -> Word32 -> IO (IntMap Spender)+spenderGroupOf w h g =+ H.lookup w.memory.spenderTable (h, g) >>= \case+ Just m -> return m+ Nothing ->+ retrieveCF w.reader.db (spenderCF w.reader.db) (SpenderKey h g) <&> \case+ Just (SpenderVal m) -> m+ Nothing -> IntMap.empty++-- | Note that an input of a block spends an output, in the group of+-- outputs that it belongs to. The record of the transaction that made the+-- output is not read and not written.+insertSpender :: Writer -> OutPoint -> Spender -> IO ()+insertSpender w p s = do+ let g = p.index `div` spenderGroup+ m <- spenderGroupOf w p.hash g+ H.insert w.memory.spenderTable (p.hash, g) (IntMap.insert (fromIntegral p.index) s m)++-- | Note that no block spends an output any more.+deleteSpender :: Writer -> OutPoint -> IO ()+deleteSpender w p = do+ let g = p.index `div` spenderGroup+ m <- spenderGroupOf w p.hash g+ H.insert w.memory.spenderTable (p.hash, g) (IntMap.delete (fromIntegral p.index) m)++-- | The input of a block that spends an output, with the changes of the+-- batch applied.+getChainSpender :: Writer -> OutPoint -> IO (Maybe Spender)+getChainSpender w p = IntMap.lookup (fromIntegral p.index) <$> spenderGroupOf w p.hash (p.index `div` spenderGroup)++-- | The inputs of blocks that spend the outputs of a transaction, by+-- output, with the changes of the batch applied, given the number of its+-- outputs: what the database has, read with one scan, with the groups+-- that the batch changed in place of the stored ones.+getChainSpenders :: Writer -> TxHash -> Int -> IO (IntMap Spender)+getChainSpenders w h outputs = do+ stored <- Q.getChainSpenders w.reader h+ let groups = [0 .. (fromIntegral (max 1 outputs) - 1) `div` spenderGroup]+ changed <- catMaybes <$> mapM (\g -> fmap (g,) <$> H.lookup w.memory.spenderTable (h, g)) groups+ let replaced = [g | (g, _) <- changed]+ kept = IntMap.filterWithKey (\i _ -> (fromIntegral i `div` spenderGroup) `notElem` replaced) stored+ return (IntMap.unions (kept : map snd changed))+ -- | The mempool in the database with the changes of this batch applied, -- newest first.-getMempoolI :: (MonadIO m) => WriterT m [(UnixTime, TxHash)]-getMempoolI =- ReaderT $ \Writer {reader = db, memory = Memory {..}} -> do- stored <- runReaderT getMempool db- changes <- liftIO $ H.toList mempoolTable- let changed = HashMap.fromList changes- kept = filter (not . (`HashMap.member` changed) . snd) stored- added = [(t, h) | (h, Just t) <- changes]- return $ sortOn Down (kept <> added)+getMempool :: Writer -> IO [(UnixTime, TxHash)]+getMempool w = do+ stored <- Q.getMempool w.reader+ changes <- H.toList w.memory.mempoolTable+ let changed = HashMap.fromList changes+ kept = filter (not . (`HashMap.member` changed) . snd) stored+ added = [(t, h) | (h, Just t) <- changes]+ return $ sortOn Down (kept <> added) -- | Empty set of changes. newMemory ::- (MonadIO m) => Network -> Ctx ->- m Memory+ IO Memory newMemory net ctx = do best <- newIORef Nothing blockTable <- liftIO H.new@@ -426,6 +653,10 @@ addressTable <- liftIO H.new outputTable <- liftIO H.new mempoolTable <- liftIO H.new+ memTxTable <- liftIO H.new+ memSpenderTable <- liftIO H.new+ memAddrTable <- liftIO H.new+ spenderTable <- liftIO H.new return Memory {..} -- | Read the best block from the changes of the batch: 'Nothing' if unchanged, @Just Nothing@ if deleted.
@@ -0,0 +1,201 @@+-- |+-- Module : Haskoin.Store.Fleet+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- How stores that share one Redis agree on the block that they all answer+-- from, as pure decisions. "Haskoin.Store.Fleet.Redis" carries them out.+-- This is the first part of the design in @docs\/layered-database.md@, "A+-- fleet that answers as one", written down so that it can be tested before+-- a store depends on it.+--+-- = The problem+--+-- Stores import the same blocks at slightly different times. Between the+-- moment the first of them has a block and the moment the last has it, the+-- same request gets different answers depending on which store a load+-- balancer picks. Making the stores commit a block together would need+-- every one of them to be there and on time, and one that stalls would hold+-- up all the others.+--+-- = The idea: agree on the state, not on the moment+--+-- Redis holds the /fleet tip/: the newest block that every live store has.+-- Each store imports blocks whenever it gets them, and keeps a view of its+-- database at each recent block. A request reads the fleet tip and is+-- answered from the view at that block. So every store gives the same+-- answer, and the fleet moves to a new block in one step, when the tip+-- moves, although no two stores imported it at the same time.+--+-- = The rules+--+-- * __A store reports its recent blocks__, newest first, under a lease that+-- it has to renew ('report'). A store that dies or hangs stops counting+-- when its lease runs out, and holds nobody up. It reports only the blocks+-- that it still has a view of: a view that was let go cannot be had again,+-- and the model test found at once what happens when the tip moves to such+-- a block. A store that is alive but stuck on old blocks would hold the+-- tip back for as long as it renews its lease, so a store stops reporting+-- when its own health checks fail.+--+-- * __A store reports only if it has a view of the fleet tip__ ('mayReport').+-- One that is catching up, or that just started, would otherwise pull the+-- tip back to an older block, or to one that it alone has.+--+-- * __The tip moves to the newest block that every live store reported__+-- ('agreed', 'move'). Any store may move it. The move is a compare and+-- set, so two stores that try at once do the same thing once.+--+-- * __A store keeps the views of the blocks of its last report, and of the+-- tip that it read after making it__ ('keep'). The tip can only move to a+-- block that every live store reported, so a live store always has the+-- view that it is asked for.+--+-- * __A store that does not have a view of the tip does not answer from+-- another block__ ('answer'). It was away for longer than its lease, and+-- says that it is behind until it has caught up.+--+-- A reorganisation needs no rule of its own. The tip is a block, not a+-- height: when every live store has moved to the other branch, the newest+-- block that they all report is on it, and the tip goes there.+--+-- The stores and the blocks can be of any type that can be compared, so+-- that the tests can run fleets of small numbers.+module Haskoin.Store.Fleet+ ( -- * The state in Redis+ Fleet (..),+ Report (..),+ Time,+ empty,++ -- * Reporting+ mayReport,+ report,+ live,++ -- * Moving the tip+ agreed,+ Move (..),+ move,++ -- * In a store+ keep,+ Answer (..),+ answer,+ )+where++import Data.List (find)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Word (Word64)++-- | A moment, in any unit that the stores share. In Redis it is the clock+-- of Redis, so that the stores do not have to agree on theirs.+type Time = Word64++-- | What a store last said about itself.+data Report b = Report+ { -- | the blocks of its main chain that it has views of, newest first+ recent :: ![b],+ -- | the moment its lease runs out+ expires :: !Time+ }+ deriving (Eq, Show)++-- | What Redis holds for a fleet of stores of type @s@ with blocks of type+-- @b@.+data Fleet s b = Fleet+ { -- | the block that every store answers from, once there is one+ tip :: !(Maybe b),+ -- | the last report of each store, live or not+ reports :: !(Map s (Report b))+ }+ deriving (Eq, Show)++-- | A fleet that no store has joined.+empty :: Fleet s b+empty = Fleet {tip = Nothing, reports = Map.empty}++-- | The reports whose lease has not run out.+live :: Time -> Fleet s b -> Map s (Report b)+live now fleet = Map.filter ((> now) . (.expires)) fleet.reports++-- | Whether a store may report, given a test for the blocks that it has a+-- view of. It may when it has a view of the tip. It also may when there is+-- no tip yet, or when no other store is live: then there is nobody to+-- disagree with, and a fleet whose stores were all away has to be able to+-- start again.+mayReport :: (Ord s) => Time -> s -> (b -> Bool) -> Fleet s b -> Bool+mayReport now store has fleet =+ maybe True has fleet.tip || Map.null (Map.delete store (live now fleet))++-- | Note the recent blocks of a store, newest first, under a lease of some+-- length. The store must have checked 'mayReport' against the same state:+-- in Redis the two are one script.+report :: (Ord s) => Time -> Time -> s -> [b] -> Fleet s b -> Fleet s b+report now lease store blocks fleet =+ fleet {reports = Map.insert store Report {recent = blocks, expires = now + lease} fleet.reports}++-- | The newest block that every live store reported, if there is one, and+-- if any store is live. Stores report their blocks newest first, so it is+-- the first block of any one report that all the others have.+agreed :: (Eq b) => Time -> Fleet s b -> Maybe b+agreed now fleet = case Map.elems (live now fleet) of+ [] -> Nothing+ first : others -> find (\b -> all (elem b . (.recent)) others) first.recent++-- | What became of an attempt to move the tip.+data Move s b+ = -- | the tip is now this block+ Moved !(Fleet s b)+ | -- | the tip is already where the live stores agree, or they agree on+ -- nothing+ Stays+ | -- | the tip was not the one that the store expected: another store moved+ -- it first+ Raced+ deriving (Eq, Show)++-- | Move the tip to the block that the live stores agree on, given the tip+-- that the caller saw. The caller says what it saw because it also changes+-- the shared mempool for the blocks between the two tips, and that change+-- is right for one starting point only.+move :: (Eq b) => Time -> Maybe b -> Fleet s b -> Move s b+move now seen fleet+ | seen /= fleet.tip = Raced+ | otherwise = case agreed now fleet of+ Just b | Just b /= fleet.tip -> Moved fleet {tip = Just b}+ _ -> Stays++-- | The blocks whose views a store must keep: those of its last report,+-- and the tip that it read after that report was made. It may let go of+-- every other view.+--+-- The order matters. A tip read before the report could have moved, in+-- between, to a block that only the earlier report had.+keep :: [b] -> Maybe b -> [b]+keep reported seen = maybe reported (: reported) seen++-- | What a store answers a request from.+data Answer b+ = -- | the view at this block, as every other store does+ At !b+ | -- | its own best block: there is no fleet tip yet+ Alone+ | -- | nothing: it has no view of the fleet tip, so any answer would differ+ -- from those of the other stores+ Behind+ deriving (Eq, Show)++-- | What a store answers a request from, given a test for the blocks that+-- it has a view of and the fleet as the request found it.+answer :: (b -> Bool) -> Fleet s b -> Answer b+answer has fleet = case fleet.tip of+ Nothing -> Alone+ Just b+ | has b -> At b+ | otherwise -> Behind
@@ -0,0 +1,1078 @@+{-# LANGUAGE RecordWildCards #-}++-- |+-- Module : Haskoin.Store.Fleet.Member+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- A store as a member of a fleet: what it does, with its own database and+-- the Redis that the fleet shares, so that every store of the fleet gives+-- the same answer to the same request. The rules are in+-- "Haskoin.Store.Fleet" and "Haskoin.Store.Fleet.Pool", Redis carries them+-- out through "Haskoin.Store.Fleet.Redis", and the reasons are in "A fleet+-- that answers as one" in @docs\/layered-database.md@.+--+-- = What a member does+--+-- * __It keeps a view of its database at each recent block__ ('noteBest'),+-- taken by the block store right after it imports or reverts a block,+-- and lets go of those that the rules no longer ask for.+--+-- * __It reports those blocks under a lease, and moves the fleet tip__+-- ('memberTick') to the newest block that every live store reported,+-- telling the pool what the blocks in between did ('changeBetween'):+-- which transactions they confirmed and which outputs they spent, and+-- across a reorganisation which transactions come back.+--+-- * __It offers its mempool to the shared pool__ ('offerMempool'), each+-- transaction with whether its inputs are unspent in the store's view at+-- the fleet tip, so that the pool only takes what is valid there.+--+-- * __It answers from the view at the fleet tip with the pool on top__+-- ('answering', 'redisPool'), or says that it is behind when it has no+-- view of the tip.+--+-- * __It keeps the fleet's cache of extended public keys__+-- ("Haskoin.Store.Fleet.XPub"): it answers the queries about a key from+-- it when the cache has the key ('answeringCached'), builds the entries+-- that requests missed ('memberCacheLoop'), brings the cache to the new+-- block when it moves the tip, and empties a cache that was left behind+-- ('syncCache').+--+-- = Why the member does not withdraw what it deletes+--+-- A store deletes a transaction from its own mempool when it replaces it,+-- when a block confirms a conflicting one, and when it is too old. The+-- first reaches the pool with the offer of the replacement, which names+-- what it replaces. The second reaches it when the tip moves to that block.+-- The third is done on the pool itself, by its own times ('expirePool'), so+-- that the stores do not have to agree on when each of them first saw a+-- transaction. A store that simply does not have a transaction says+-- nothing about it: another store has it.+module Haskoin.Store.Fleet.Member+ ( -- * A member+ Member (..),+ MemberConfig (..),+ newMember,+ noteBest,+ memberForget,+ memberTick,+ memberLoop,+ memberAlive,+ memberEvents,++ -- * Answering+ Standing (..),+ answering,+ memberTip,+ answeringAt,+ answeringCached,+ TipMoved (..),+ redisPool,++ -- * The cache of extended public keys+ CacheMetrics (..),+ memberCacheLoop,+ wantCached,+ syncCache,++ -- * The parts of a tick+ report,+ moveTip,+ Between (..),+ blocksBetween,+ changeBetween,+ offerMempool,+ expirePool,+ poolTx,+ )+where++import Control.Exception (Exception, throwIO)+import Control.Monad (filterM, forM, forM_, unless, void, when)+import Data.ByteString (ByteString)+import Data.ByteString qualified as B+import Data.ByteString.Char8 qualified as C+import Data.Either (rights)+import Data.Functor ((<&>))+import Data.HashMap.Strict (HashMap)+import Data.HashMap.Strict qualified as HashMap+import Data.HashSet (HashSet)+import Data.HashSet qualified as HashSet+import Data.IntMap.Strict qualified as IntMap+import Data.List (nub, sortOn)+import Data.Map.Strict qualified as Map+import Data.Maybe (catMaybes, fromMaybe, isJust, mapMaybe)+import Data.Serialize (Serialize, decode, encode)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding (decodeUtf8)+import Data.Time.Clock.POSIX (getPOSIXTime)+import Data.Word (Word64)+import Database.Redis qualified as R+import Haskoin+ ( BlockHash,+ BlockHeader (..),+ Ctx,+ Network (..),+ OutPoint (..),+ Tx (..),+ TxHash,+ TxIn (..),+ blockHashToHex,+ headerHash,+ nullOutPoint,+ txHash,+ )+import Haskoin.Node (LogLevel (..))+import Haskoin.Store.Common (Limits (..), StoreRead (..))+import Haskoin.Store.Data+ ( Balance (..),+ BlockData (..),+ BlockRef (..),+ Spender (..),+ TxData (..),+ UnixTime,+ Unspent (..),+ XPubBal (..),+ XPubSpec,+ nullBalance,+ )+import Haskoin.Store.Database.Mempool qualified as M+import Haskoin.Store.Database.Query qualified as Q+import Haskoin.Store.Database.Query.Layered qualified as L+import Haskoin.Store.Database.Query.Pooled (PoolReads (..), pageStart, pooledRead)+import Haskoin.Store.Database.Reader (DatabaseReader (..), txCF)+import Haskoin.Store.Database.Types+ ( MemAddrKey (..),+ MemAddrVal (..),+ MemSummary (..),+ TxKey (..),+ )+import Haskoin.Store.Database.Views (Views, heldViews, keepViews, newViews, takeView, withViewAt)+import Haskoin.Store.Fleet qualified as F+import Haskoin.Store.Fleet.Redis+import Haskoin.Store.Fleet.XPub+import System.Metrics.StatsD+ ( StatCounter,+ StatGauge,+ StatTiming,+ Stats,+ addTiming,+ incrementCounter,+ newStatCounter,+ newStatGauge,+ newStatTiming,+ setGauge,+ )+import UnliftIO+ ( TVar,+ atomically,+ handle,+ handleAny,+ modifyIORef',+ modifyTVar',+ newIORef,+ newTVarIO,+ readIORef,+ readTVar,+ readTVarIO,+ retrySTM,+ stateTVar,+ timeout,+ writeIORef,+ writeTVar,+ )++-- | How a store takes part in a fleet.+data MemberConfig = MemberConfig+ { -- | what every key of the fleet in Redis starts with+ prefix :: !ByteString,+ -- | the name of this store, which no other store of the fleet has+ name :: !ByteString,+ -- | how long a report counts for, in milliseconds. A store that stops+ -- reporting holds the tip back for this long at most.+ lease :: !Word64,+ -- | how many recent blocks to keep views of and report. With more, the+ -- stores find a block in common sooner when they import several at+ -- once; each view keeps the files that it reads from being deleted.+ depth :: !Int,+ -- | seconds after which a transaction leaves the pool unconfirmed, or 0+ -- for never+ expiry :: !Word64,+ -- | how many extended public keys the fleet's cache of them may hold+ -- ("Haskoin.Store.Fleet.XPub"), the least recently used going first, or+ -- 0 not to use the cache+ cacheMax :: !Int,+ -- | cache a key that has at least this many used addresses+ cacheMinAddrs :: !Int,+ -- | or at least this many transactions in its addresses+ cacheMinTxs :: !Int,+ -- | StatsD client, to report what the cache of extended public keys+ -- does, as t'CacheMetrics' lists+ stats :: !(Maybe Stats),+ -- | where to say what a log should have: that the cache was emptied+ -- and why, as information, and each entry built and each move of the+ -- cache, for debugging+ note :: !(LogLevel -> Text -> IO ())+ }++-- | StatsD metrics of the fleet's cache of extended public keys, as this+-- store sees it. They have the names of those of "Haskoin.Store.Cache"+-- under @fleet@, so that a dashboard reads both alike.+data CacheMetrics = CacheMetrics+ { -- | requests about a key whose balances the cache gave+ -- (@fleet.cache.hits@)+ hits :: !StatCounter,+ -- | requests about a key that the database answered: the cache did+ -- not have the key, had it with other addresses, or was not at the+ -- fleet tip (@fleet.cache.misses@)+ misses :: !StatCounter,+ -- | entries that this store built or added addresses to+ -- (@fleet.cache.builds@)+ builds :: !StatCounter,+ -- | builds that this store gave up, because the tip moved or another+ -- store wrote the entry (@fleet.cache.abandoned@)+ abandoned :: !StatCounter,+ -- | entries that this store deleted for being the least recently used+ -- (@fleet.cache.evictions@)+ evictions :: !StatCounter,+ -- | times that this store emptied the cache (@fleet.cache.emptied@)+ emptied :: !StatCounter,+ -- | milliseconds to build an entry (@fleet.cache.build@)+ buildTime :: !StatTiming,+ -- | entries in the cache, one for each extended public key, whichever+ -- store built them (@fleet.cache.xpubs@)+ xpubs :: !StatGauge,+ -- | addresses that those entries have between them+ -- (@fleet.cache.addresses@)+ addresses :: !StatGauge+ }++-- | Register the metrics of the cache.+newCacheMetrics :: Stats -> IO CacheMetrics+newCacheMetrics s = do+ hits <- c "hits"+ misses <- c "misses"+ builds <- c "builds"+ abandoned <- c "abandoned"+ evictions <- c "evictions"+ emptied <- c "emptied"+ buildTime <- newStatTiming s "fleet.cache.build" 10+ xpubs <- newStatGauge s "fleet.cache.xpubs" 0+ addresses <- newStatGauge s "fleet.cache.addresses" 0+ return CacheMetrics {..}+ where+ c x = newStatCounter s ("fleet.cache." <> x) 10++-- | Add to a counter of the cache, if metrics are reported.+tally :: Member -> (CacheMetrics -> StatCounter) -> Int -> IO ()+tally m which n = forM_ m.metrics (\x -> incrementCounter (which x) n)++-- | A store that is a member of a fleet.+data Member = Member+ { config :: !MemberConfig,+ redis :: !R.Connection,+ keys :: !FleetKeys,+ db :: !DatabaseReader,+ -- | the database as it was at each recent block+ views :: !(Views BlockHash),+ -- | the blocks that views were taken of, newest first+ recent :: !(TVar [BlockHash]),+ -- | the blocks of the last report that was taken+ reported :: !(TVar [BlockHash]),+ -- | the fleet tip, read after that report+ tip :: !(TVar (Maybe BlockHash)),+ -- | the transactions of the store's mempool that the pool has+ offered :: !(TVar (HashSet TxHash)),+ -- | those that it refused, or that there was no time to offer, with+ -- the times at which they entered the mempool: they are offered again+ waiting :: !(TVar (HashMap TxHash UnixTime)),+ -- | how many times the mempool was offered, and at which tip last+ rounds :: !(TVar Int),+ offeredAt :: !(TVar (Maybe BlockHash)),+ -- | whether the best block changed since the mempool was last offered+ changed :: !(TVar Bool),+ -- | whether the store's own health checks pass. While they do not, the+ -- member does nothing, so that its lease runs out and a store that is+ -- stuck does not hold the fleet back.+ healthy :: !(TVar Bool),+ -- | the extended public keys that requests asked about and the cache+ -- did not have, by their names in Redis: 'memberCacheLoop' builds+ -- their entries+ wanted :: !(TVar (HashMap ByteString XPubSpec)),+ -- | the fleet tip at which the cache was found at another block, at+ -- the last turn+ lagging :: !(TVar (Maybe ByteString)),+ -- | the metrics of the cache, if the configuration has a StatsD client+ metrics :: !(Maybe CacheMetrics),+ -- | transactions that the store deleted from its mempool since it+ -- started, up to a hundred thousand. The database keeps nothing of+ -- them, and the rule for conflicts in the pool asks about them+ -- ('offerMempool').+ gone :: !(TVar (HashSet TxHash))+ }++-- | A member with no views yet, on a connection to the Redis of the fleet.+newMember :: MemberConfig -> R.Connection -> DatabaseReader -> IO Member+newMember config redis db = do+ views <- newViews db+ recent <- newTVarIO []+ reported <- newTVarIO []+ tip <- newTVarIO Nothing+ offered <- newTVarIO HashSet.empty+ waiting <- newTVarIO HashMap.empty+ rounds <- newTVarIO 0+ offeredAt <- newTVarIO Nothing+ changed <- newTVarIO True+ healthy <- newTVarIO True+ wanted <- newTVarIO HashMap.empty+ lagging <- newTVarIO Nothing+ metrics <- mapM newCacheMetrics config.stats+ gone <- newTVarIO HashSet.empty+ return Member {keys = fleetKeys config.prefix 32, ..}++-- | Take a view of the database for its best block. The block store calls+-- this right after it imports or reverts a block, and when it starts,+-- before it writes anything else: the view is then of that block and of+-- nothing more.+noteBest :: Member -> BlockHash -> IO ()+noteBest m block = do+ takeView m.views block+ atomically $ do+ modifyTVar' m.recent $ \blocks -> take m.config.depth (block : filter (/= block) blocks)+ writeTVar m.changed True+ prune m++-- | Note that transactions left the store's mempool unconfirmed. The store+-- calls this when it deletes them. The member forgets that it offered them:+-- one that comes back, as a transaction does that is sent again after it+-- was replaced, is then offered again, and takes the place of what replaced+-- it if this store's rules say so.+memberForget :: Member -> [TxHash] -> IO ()+memberForget m hs = atomically $ do+ modifyTVar' m.offered (\have -> foldr HashSet.delete have hs)+ modifyTVar' m.waiting (\held -> foldr HashMap.delete held hs)+ modifyTVar' m.gone $ \seen ->+ let more = foldr HashSet.insert seen hs+ in if HashSet.size more > 100000 then HashSet.fromList hs else more++-- | Let go of the views that the rules do not ask for: all but those of+-- the recent blocks, of the last report, and of the tip read after it.+prune :: Member -> IO ()+prune m = do+ recent <- readTVarIO m.recent+ reported <- readTVarIO m.reported+ tip <- readTVarIO m.tip+ keepViews m.views (nub (F.keep (recent <> reported) tip))++-- | Whether Redis answers a ping within two seconds.+memberAlive :: Member -> IO Bool+memberAlive m =+ handleAny (\_ -> return False) $+ timeout 2000000 (R.runRedis m.redis R.ping) <&> \case+ Just (Right _) -> True+ _ -> False++-- | One round of what a member does: report, move the tip if the stores+-- agree on another block, offer what the pool does not have of the store's+-- mempool, and let go of old views. Call it every second or two, and when+-- the best block changes. A store whose own health checks fail must not+-- call it: its lease then runs out, and the fleet goes on without it.+memberTick :: Member -> IO ()+memberTick m = do+ report m+ moved <- moveTip m+ -- the tip is read after the report, as the rule of 'F.keep' asks+ tip <- fmap decoded <$> fleetTip m.redis m.keys+ atomically (writeTVar m.tip (fromMaybe Nothing tip))+ prune m+ syncCache m+ unless moved (expirePool m)+ offerMempool m+ where+ decoded = either (const Nothing) Just . decode++-- | Take a turn as a member every two seconds, and at once when an action+-- that waits for the next best block returns, for as long as the store's+-- health checks pass. A turn that fails, as when Redis is away, is told to+-- a function and tried again at the next.+memberLoop :: Member -> IO () -> (String -> IO ()) -> IO a+memberLoop m nextBlock complain = go+ where+ go = do+ void (timeout 2000000 nextBlock)+ well <- readTVarIO m.healthy+ when well (handleAny (complain . show) (memberTick m))+ go++-- | Hear what happens to the fleet, for as long as the store runs: each+-- move of the tip and each transaction that enters or leaves the pool is+-- told to a function, in the order in which Redis has them. Every store of+-- the fleet hears the same, which is what its clients are told+-- ('FleetEvent').+--+-- The telling starts from now, or from the oldest event that Redis keeps:+-- the second is for a listener that wants to hold the same history as the+-- other stores whenever it started, as the log of events of the web server+-- does. When Redis cannot be read a function is told why, and listening+-- goes on from the last event heard, so nothing is left out unless Redis+-- dropped it meanwhile.+memberEvents :: Member -> Bool -> (FleetEvent -> IO ()) -> (String -> IO ()) -> IO a+memberEvents m whole tell complain = newIORef (if whole then Just "0-0" else Nothing) >>= start+ where+ start at = do+ handleAny (complain . show) $ do+ from <- readIORef at >>= maybe (fleetEventsEnd m.redis m.keys) return+ writeIORef at (Just from)+ listen at from+ void (timeout 1000000 (atomically retrySTM) :: IO (Maybe ()))+ start at+ listen at from = do+ events <- fleetEventsAfter m.redis m.keys from 1000+ mapM_ (tell . snd) events+ let next = foldl (\_ (place, _) -> place) from events+ writeIORef at (Just next)+ listen at next++-- | Report the blocks of the main chain that the store has views of, newest+-- first.+report :: Member -> IO ()+report m = do+ held <- heldViews m.views+ recent <- readTVarIO m.recent+ blocks <- filterM main (filter (`elem` held) recent)+ taken <- fleetReport m.redis m.keys Nothing m.config.lease m.config.name (map encode blocks) (map encode held)+ atomically (writeTVar m.reported (if taken then blocks else []))+ where+ main h = maybe False (.main) <$> Q.getBlock m.db h++-- | Move the fleet tip to the block that the live stores agree on, if it is+-- another one, with what the blocks in between did to the pool. Says+-- whether it moved. When this store cannot say what they did, the pool is+-- emptied, which Redis allows only to a store that is alone.+moveTip :: Member -> IO Bool+moveTip m = do+ seen <- fleetTip m.redis m.keys+ leases <- fleetLive m.redis m.keys+ recents <- redis' (R.hgetall m.keys.recent)+ now <- redis' R.time <&> \(s, us) -> fromIntegral (s * 1000 + us `div` 1000)+ let reports =+ Map.fromList+ [ (store, F.Report {recent = chunks blocks, expires})+ | (store, expires) <- leases,+ Just blocks <- [lookup store recents]+ ]+ fleet = F.Fleet {tip = seen, reports}+ case F.agreed now fleet of+ Just target | Just target /= seen -> do+ between <- case (traverse decode seen, decode target) of+ (Right from, Right to) -> blocksBetween m.db from to+ _ -> return Nothing+ let path = maybe [] (map encode . (.path)) between+ fleetAdvance m.redis m.keys 32 Nothing seen target ((.pool) <$> between) path >>= \case+ MovedTo _ -> advanceCache m seen target between >> return True+ _ -> return False+ _ -> return False+ where+ redis' :: R.Redis (Either R.Reply a) -> IO a+ redis' f = R.runRedis m.redis f >>= either (fail . ("Redis error: " <>) . show) return+ chunks bytes+ | B.null bytes = []+ | otherwise = B.take 32 bytes : chunks (B.drop 32 bytes)++-- | The most blocks that 'changeBetween' goes through on either side of a+-- fork. Beyond that, a store that is alone starts the pool again, which+-- costs the stores an offer of their mempools; going through a thousand+-- blocks would cost a read of every transaction in them.+farthest :: Int+farthest = 50++-- | What the blocks between two tips did, as far as the pool is concerned,+-- read from the database as it is now: the transactions of the blocks that+-- lead from the first tip, or from the genesis block, to where the main+-- chain forked from it, which come back or are gone, and those of the+-- blocks of the main chain from there to the second tip, which are+-- confirmed, with the outputs that they spend.+--+-- Without a first tip there was no pool, and the blocks did nothing to it.+-- 'Nothing' if the database does not have one of the tips, if the second+-- is not on its main chain, or if the way is longer than 'farthest'+-- blocks on either side.+changeBetween :: DatabaseReader -> Maybe BlockHash -> BlockHash -> IO (Maybe PoolChange)+changeBetween db from to = fmap (.pool) <$> blocksBetween db from to++-- | What the blocks between two tips did.+data Between = Between+ { -- | what they did to the pool+ pool :: !PoolChange,+ -- | the transactions of the blocks of the main chain from where it+ -- left the way to the first tip, up to the second, in their order+ added :: ![TxData],+ -- | whether blocks of the way to the first tip were abandoned+ reorganised :: !Bool,+ -- | those blocks of the main chain, oldest first, the second tip last:+ -- what the fleet tells its clients that the tip moved through+ path :: ![BlockHash]+ }++-- | 'changeBetween', with the transactions that the new blocks confirm,+-- which is what the cache of extended public keys is brought to the new+-- tip with ('advanceCache').+blocksBetween :: DatabaseReader -> Maybe BlockHash -> BlockHash -> IO (Maybe Between)+blocksBetween db from to = L.withView db $ \view -> do+ let r = L.viewReader view+ -- the blocks from one that is not on the main chain back to it+ back :: Int -> BlockHash -> IO (Maybe ([BlockData], BlockData))+ back n h+ | n > farthest = return Nothing+ | otherwise =+ Q.getBlock r h >>= \case+ Nothing -> return Nothing+ Just bd+ | bd.main -> return (Just ([], bd))+ | otherwise -> fmap (\(bs, fork) -> (bd : bs, fork)) <$> back (n + 1) bd.header.prev+ -- the blocks of the main chain from a block down to above a height+ down :: Int -> BlockData -> BlockData -> IO (Maybe [BlockData])+ down n fork bd+ | n > farthest = return Nothing+ | bd.height <= fork.height = return (if bd.header == fork.header then Just [] else Nothing)+ | otherwise =+ Q.getBlock r bd.header.prev >>= \case+ Nothing -> return Nothing+ Just parent -> fmap (bd :) <$> down (n + 1) fork parent+ target <- Q.getBlock r to+ left <- case from of+ Just h -> back 0 h+ -- without a tip there was no pool, and nothing for the blocks to do+ Nothing -> return (fmap ([],) target)+ case (target, left) of+ (Just bd, Just (abandoned, fork))+ | bd.main ->+ down 0 fork bd >>= \case+ Nothing -> return Nothing+ Just added -> do+ old <- concat <$> mapM (bodies view) (reverse abandoned)+ new <- concat <$> mapM (confirmedBy r) (reverse added)+ let pool =+ PoolChange+ { returned = [poolTx r.net r.ctx td | (_, Just td) <- old, not (coinbase td)],+ gone = [encode h | (h, found) <- old, maybe True coinbase found],+ confirmed = map (encode . txHash . (.tx)) new,+ spent = [encode i.outpoint | td <- new, i <- td.tx.inputs, i.outpoint /= nullOutPoint]+ }+ let path = map (headerHash . (.header)) (reverse added)+ return (Just Between {pool, added = new, reorganised = not (null abandoned), path})+ _ -> return Nothing+ where+ -- the transactions of an abandoned block, each with what the store has+ -- of it now, if it still has it and did not delete it+ bodies view bd = do+ found <- L.getManyTxData view bd.txs+ return [(h, found' >>= live) | (h, found') <- zip bd.txs found]+ live td = if td.deleted then Nothing else Just td+ confirmedBy :: DatabaseReader -> BlockData -> IO [TxData]+ confirmedBy r bd = catMaybes <$> Q.retrieveManyCF r.db (txCF r.db) (map TxKey bd.txs)+ coinbase td = any ((== nullOutPoint) . (.outpoint)) td.tx.inputs++-- | A transaction as the pool keeps it: its body without spenders, and+-- what it does at each address, as "Haskoin.Store.Database.Mempool" works+-- them out for the store's own mempool area.+poolTx :: Network -> Ctx -> TxData -> PoolTx+poolTx net ctx td =+ PoolTx+ { txid = encode records.txid,+ time,+ inputs = [encode op | (op, _) <- records.spends],+ body = encode records.body,+ signals = net.replaceByFee && any ((< 0xffffffff - 1) . (.sequence)) td.tx.inputs,+ entries = mapMaybe entry records.addresses+ }+ where+ time = case td.block of+ MemRef t -> t+ BlockRef {} -> 0+ records = M.mempoolRecords net ctx time td+ entry = \case+ (MemAddrTxKey a _ _, _) -> Just (PoolTouches (encode a))+ (MemAddrSpentKey a p, MemAddrSpentVal v) -> Just (PoolSpends (encode a) (encode p) v)+ (MemAddrOutKey a _ p, MemAddrOutVal v s) -> Just (PoolPays (encode a) (encode p) v s)+ _ -> Nothing++-- | Offer to the pool the transactions of the store's mempool that it does+-- not have, oldest first, so that each comes after those that it spends+-- from. A store without a view of the fleet tip offers nothing: it cannot+-- say what is unspent there.+--+-- A transaction that the pool refuses is offered again at the next round:+-- its parents may not be there yet, or the tip may have moved. One that+-- conflicts with transactions of the pool takes their place when this+-- store deleted every one of them by its own rules, as it does when it+-- replaces a transaction, and otherwise stays out: the first of two that+-- spend the same output stays.+--+-- = Reading no more of the mempool than is new+--+-- A mempool has tens of thousands of transactions and a round comes every+-- two seconds, so a round does not read the whole list. It reads the list+-- from the newest, a page at a time, until a page has nothing that it does+-- not know, and offers what it found with what is waiting from earlier+-- rounds. The whole list is read when the tip has moved or the store's best+-- block has changed, since a block or a reorganisation changes the mempool+-- anywhere, and every 'wholeEvery' rounds, for whatever the short way+-- missed. What the store deletes in between it tells the member+-- ('memberForget'). A round offers 'mostOffers'+-- transactions at most and leaves the rest waiting, so that a store with a+-- large mempool to offer still reports in time to keep its lease.+offerMempool :: Member -> IO ()+offerMempool m =+ readTVarIO m.tip >>= \case+ Nothing -> return ()+ Just tip -> void . withViewAt m.views tip $ \at -> do+ before <- readTVarIO m.offeredAt+ (n, dirty) <- atomically $ do+ writeTVar m.offeredAt (Just tip)+ (,) <$> stateTVar m.rounds (\k -> (k, k + 1)) <*> stateTVar m.changed (,False)+ todo <- if dirty || before /= Just tip || n `mod` wholeEvery == 0 then everything else newest+ let (now, later) = splitAt mostOffers (sortOn fst todo)+ atomically (modifyTVar' m.waiting (HashMap.union (HashMap.fromList [(h, time) | (time, h) <- later])))+ go at tip now+ where+ -- all of the mempool that the pool does not have, forgetting what has+ -- left the mempool+ everything = do+ mempool <- Q.getMempool m.db+ have <- readTVarIO m.offered+ let kept = HashSet.intersection have (HashSet.fromList (map snd mempool))+ atomically (writeTVar m.offered kept >> writeTVar m.waiting HashMap.empty)+ return [(time, h) | (time, h) <- mempool, not (HashSet.member h kept)]+ -- the newest of the mempool that this member has not seen, and what is+ -- waiting+ newest = do+ have <- readTVarIO m.offered+ held <- readTVarIO m.waiting+ let known h = HashSet.member h have || HashMap.member h held+ scan offset = do+ page <- Q.getMempoolPage m.db Limits {limit = pageSize, offset, start = Nothing}+ let fresh = filter (not . known . snd) page+ if null fresh || length page < fromIntegral pageSize+ then return fresh+ else (fresh <>) <$> scan (offset + pageSize)+ found <- scan 0+ return (found <> [(time, h) | (h, time) <- HashMap.toList held])+ pageSize = 256+ go _ _ [] = return ()+ go at tip ((time, h) : rest) =+ M.getMempoolTx m.db h >>= \case+ Nothing -> forget h >> go at tip rest+ Just td -> do+ let tx = poolTx m.db.net m.db.ctx td+ unspent <- forM [i.outpoint | i <- td.tx.inputs, i.outpoint /= nullOutPoint] $ \p ->+ isJust <$> Q.getUnspent at p+ let offer = poolOffer m.redis m.keys 32 (Just (encode tip)) tx unspent+ done = atomically $ do+ modifyTVar' m.offered (HashSet.insert h)+ modifyTVar' m.waiting (HashMap.delete h)+ wait = atomically (modifyTVar' m.waiting (HashMap.insert h time))+ offer [] >>= \case+ Taken -> done >> go at tip rest+ Known -> done >> go at tip rest+ Orphan _ -> wait >> go at tip rest+ -- the tip moved: the next round reads the whole mempool+ Late -> return ()+ Conflict rivals -> do+ mine <- mapM deletedHere rivals+ if and mine+ then+ offer rivals >>= \case+ Taken -> done+ _ -> wait+ else wait+ go at tip rest+ forget h = atomically (modifyTVar' m.waiting (HashMap.delete h))+ deletedHere rival = case decode rival of+ Left _ -> return False+ -- it deleted it, and has not taken it back since. A store that was+ -- started again does not remember, and leaves the conflict as it+ -- is: the first of the two stays in the pool.+ Right h -> do+ deleted <- HashSet.member h <$> readTVarIO m.gone+ back <- isJust <$> M.getMempoolTx m.db h+ return (deleted && not back)++-- | How many rounds of offers go by between two that read the whole+-- mempool: five minutes of them.+wholeEvery :: Int+wholeEvery = 150++-- | The most transactions that one round offers. An offer takes a tenth of+-- a millisecond or two, so this is well under the two seconds of a round.+mostOffers :: Int+mostOffers = 2000++-- | Take out of the pool the transactions that entered a mempool longer+-- ago than the configuration allows, with what spends them.+expirePool :: Member -> IO ()+expirePool m = when (m.config.expiry > 0) $ do+ (now, _) <- R.runRedis m.redis R.time >>= either (fail . show) return+ let before = fromIntegral now - fromIntegral m.config.expiry :: Double+ old <-+ R.runRedis m.redis (R.zrangebyscoreLimit (m.keys.prefix <> "pool:time") 0 before 0 1000)+ >>= either (fail . show) return+ unless (null old) (void (poolWithdraw m.redis m.keys 32 old))++-- | Where a store stands when a request comes.+data Standing a+ = -- | the request was answered from the view at the fleet tip+ Answered !a+ | -- | there is no fleet tip yet: the store answers by itself+ Alone+ | -- | the store has no view of the fleet tip, so any answer of its own+ -- would differ from those of the other stores+ Behind++-- | What a read of the pool throws when the fleet tip is no longer the+-- block that the request answers from. The request is then answered again,+-- from the new tip.+data TipMoved = TipMoved+ deriving (Show)++instance Exception TipMoved++-- | Answer a request as a member of a fleet: read the fleet tip, and run+-- an action with the database as it was at that block and the pool as it is+-- at that tip. The action must do all its reading before it returns.+answering :: Member -> (DatabaseReader -> PoolReads -> IO a) -> IO (Standing a)+answering m action =+ memberTip m >>= \case+ Nothing -> return Alone+ Just tip -> maybe Behind Answered <$> answeringAt m tip (\r pool _ -> action r pool)++-- | The fleet tip as Redis has it now, if there is one.+memberTip :: Member -> IO (Maybe ByteString)+memberTip m = fleetTip m.redis m.keys++-- | Run an action with the database as it was at a block that is taken to+-- be the fleet tip, and the pool as it is at that tip, or give 'Nothing' if+-- the store has no view of that block. The action must do all its reading+-- before it returns.+--+-- The block need not be the tip any more: it can be the one that the+-- member read at its last turn, which saves a request the round trip of+-- asking. Every read of the pool checks it and throws v'TipMoved' if the tip+-- is another one. The action is also given a check to run when it has what+-- it needs: it asks Redis for the tip if no read of the pool has done so,+-- and throws the same. An answer that passed either is one that every+-- store of the fleet gave at that moment.+answeringAt :: Member -> ByteString -> (DatabaseReader -> PoolReads -> IO () -> IO a) -> IO (Maybe a)+answeringAt m tip action = answeringWith m tip (\r pool _ confirm -> action r pool confirm)++-- | 'answeringAt', for an action that reads Redis by other ways than the+-- pool too: it is also given what to run after each such read that found+-- the tip where it should be.+answeringWith :: Member -> ByteString -> (DatabaseReader -> PoolReads -> IO () -> IO () -> IO a) -> IO (Maybe a)+answeringWith m tip action = case decode tip of+ Left _ -> return Nothing+ Right block -> withViewAt m.views block $ \r -> do+ checked <- newIORef False+ let confirm =+ readIORef checked >>= \case+ True -> return ()+ False -> do+ now <- memberTip m+ if now == Just tip then writeIORef checked True else throwIO TipMoved+ found = writeIORef checked True+ action r (redisPool m.redis m.keys tip found) found confirm++-- | 'answeringAt' with the queries ready: those of+-- "Haskoin.Store.Database.Query.Pooled" for the view and the pool, and+-- the same with the cache of extended public keys in front+-- ('cachedRead'), which is what requests are answered with unless they+-- ask not to be.+answeringCached :: Member -> ByteString -> (StoreRead -> StoreRead -> IO () -> IO a) -> IO (Maybe a)+answeringCached m tip action = answeringWith m tip $ \r pool found confirm -> do+ cached <- cachedRead m tip r pool found+ action (pooledRead r pool) cached confirm++-------------------------------------------------------------------------------+-- The cache of extended public keys+-------------------------------------------------------------------------------++-- | The queries of a request with the fleet's cache of extended public+-- keys in front of those about them: given the tip that the request+-- answers from, the view at that block, the pool, and what to run after a+-- read that found the tip where it should be.+--+-- The balances of a key come from the cache if it has the key with the+-- addresses that it has now. The other three queries about a key are given+-- its balances by the caller, and are answered from the cache when this+-- same request got them there: the cache is then asked for an entry with+-- as many addresses, so that an entry that changed in between is not+-- used. Whatever the cache does not answer the database does, and a key+-- that it did not have is put down to be built ('wantCached'). A read that+-- finds the tip moved throws v'TipMoved', like those of the pool.+cachedRead :: Member -> ByteString -> DatabaseReader -> PoolReads -> IO () -> IO StoreRead+cachedRead m tip r pool found+ | m.config.cacheMax <= 0 = return base+ | otherwise = do+ seen <- newIORef HashMap.empty+ let hit :: Cached a -> IO (Maybe a)+ hit = \case+ Hit x -> Just x <$ found+ Miss -> return Nothing+ Moved -> throwIO TipMoved+ entryOf x = HashMap.lookup (xpubId x) <$> readIORef seen+ -- from the cache if this request has the entry of the key, and+ -- from where the page starts if it has a start+ paged :: XPubSpec -> Limits -> IO [a] -> (Entry -> L.From -> IO (Cached [a])) -> IO [a]+ paged x limits direct cached =+ entryOf x >>= \case+ Nothing -> direct+ Just e ->+ pageStart r pool limits.start >>= \case+ Nothing -> return []+ Just from -> cached e from >>= hit >>= maybe direct return+ return+ base+ { xPubBals = \x -> do+ now <- milliseconds+ cachedEntry m.redis m.keys tip r.initGap r.maxGap now x >>= hit >>= \case+ Just e -> do+ tally m (.hits) 1+ e.balances <$ modifyIORef' seen (HashMap.insert (xpubId x) e)+ Nothing -> do+ tally m (.misses) 1+ bals <- base.xPubBals x+ wantCached m x bals+ return bals,+ xPubTxs = \x bals limits ->+ paged x limits (base.xPubTxs x bals limits) $ \e from ->+ cachedTxs m.redis m.keys tip x e.sizes from limits,+ xPubUnspents = \x bals limits ->+ paged x limits (base.xPubUnspents x bals limits) $ \e from ->+ cachedUnspents m.redis m.keys tip x e from limits,+ xPubTxCount = \x bals ->+ entryOf x >>= \case+ Nothing -> base.xPubTxCount x bals+ Just e ->+ cachedCount m.redis m.keys tip x e.sizes >>= hit >>= maybe (base.xPubTxCount x bals) return+ }+ where+ base = pooledRead r pool++-- | The time in milliseconds, by which the cache orders the keys by use.+milliseconds :: IO Word64+milliseconds = round . (* 1000) <$> getPOSIXTime++-- | Put an extended public key down to be cached, given its balances, if+-- it is worth it: a key with few addresses and few transactions is read+-- from the database as fast as from Redis. The thresholds are those of the+-- configuration, and count as "Haskoin.Store.Cache" counts them.+wantCached :: Member -> XPubSpec -> [XPubBal] -> IO ()+wantCached m x bals =+ when (m.config.cacheMax > 0 && (length used >= m.config.cacheMinAddrs || txs >= m.config.cacheMinTxs)) $+ atomically . modifyTVar' m.wanted $ \w ->+ if HashMap.size w < 1000 then HashMap.insert (xpubId x) x w else w+ where+ used = filter (not . nullBalance . (.balance)) bals+ txs = fromIntegral (sum (map (.balance.txs) used)) :: Int++-- | Build the entries of the keys that requests asked about and the cache+-- did not have, one at a time, for as long as the store runs. A build that+-- fails is told to a function. The least recently used entries are deleted+-- when there are more than the configuration allows.+--+-- This is apart from the turns of the member because an entry of a wallet+-- with a long history takes seconds to read from the database, and a+-- member that does not report for that long loses its lease.+memberCacheLoop :: Member -> (String -> IO ()) -> IO a+memberCacheLoop m complain = go+ where+ go = do+ spec <- atomically $ do+ w <- readTVar m.wanted+ case HashMap.toList w of+ [] -> retrySTM+ (k, x) : _ -> x <$ writeTVar m.wanted (HashMap.delete k w)+ handleAny (complain . show) (build spec)+ go+ build spec =+ readTVarIO m.tip >>= \case+ Nothing -> return ()+ Just block -> do+ now <- milliseconds+ let tip = encode block+ name = decodeUtf8 (xpubId spec)+ built <-+ handle (\TipMoved -> return (Just Abandoned)) $+ answeringAt m tip (\r pool _ -> cacheBuild m.redis m.keys tip r pool now spec)+ took <- subtract now <$> milliseconds+ case built of+ Just (Built n) | n > 0 -> do+ tally m (.builds) 1+ forM_ m.metrics (\x -> addTiming x.buildTime (fromIntegral took))+ m.config.note LevelDebug $+ "Cached xpub " <> name <> ": " <> shown n <> " addresses added in " <> shown took <> " ms"+ gone <- cacheEvict m.redis m.keys m.config.cacheMax+ when (gone > 0) $ do+ tally m (.evictions) gone+ m.config.note LevelDebug ("Evicted " <> shown gone <> " xpubs from the cache")+ Just Abandoned -> do+ tally m (.abandoned) 1+ m.config.note LevelDebug ("Gave up caching xpub " <> name <> ": the fleet or the entry moved on")+ _ -> return ()++-- | Bring the cache of extended public keys to the block that this member+-- just moved the fleet tip to, with what the blocks in between did at the+-- addresses that its entries have. The cache is emptied instead when the+-- member cannot say what they did, when the chain was reorganised, which+-- is rare and would need what the abandoned blocks did undone, or when+-- the member has no view of the block.+advanceCache :: Member -> Maybe ByteString -> ByteString -> Maybe Between -> IO ()+advanceCache m seen target between = when (m.config.cacheMax > 0) $ do+ changes <- case (between, decode target) of+ (Just b, Right to) | not b.reorganised -> withViewAt m.views to $ \r -> do+ let touched = HashMap.toList (blockChanges r.net r.ctx b.added)+ flags <- cacheTracked m.redis m.keys (map fst touched)+ forM [x | (x, True) <- zip touched flags] $ \(a, change) -> do+ bal <- Q.getBalance r a+ return (a, bal, change)+ _ -> return Nothing+ let why = case (between, changes) of+ (Nothing, _) -> "this store cannot say what the blocks since the last tip did"+ (Just b, _) | b.reorganised -> "the chain was reorganised"+ (_, Nothing) -> "this store has no view of the block"+ _ -> "it was at another block than the last tip"+ cacheAdvance m.redis m.keys seen target changes >>= \case+ CacheEmptied -> wasEmptied m target why+ CacheMoved ->+ m.config.note LevelDebug $+ "The xpub cache is at block "+ <> blockName target+ <> ": "+ <> shown (maybe 0 length changes)+ <> " addresses changed"+ _ -> return ()++-- | Count and say that this store emptied the cache, at a block and for a+-- reason. It is information and not a warning: nothing is wrong with what+-- the fleet answers, which comes from the database until entries are+-- built again. Emptied at every block, though, the cache is of no use,+-- and the log is where that shows.+wasEmptied :: Member -> ByteString -> Text -> IO ()+wasEmptied m block why = do+ tally m (.emptied) 1+ m.config.note LevelInfo ("Emptied the xpub cache at block " <> blockName block <> ": " <> why)++-- | A block as a log names it.+blockName :: ByteString -> Text+blockName bytes = either (const "?") blockHashToHex (decode bytes :: Either String BlockHash)++shown :: (Show a) => a -> Text+shown = T.pack . show++-- | See that the cache of extended public keys does not stay at another+-- block than the fleet tip. It is there for a moment after every move of+-- the tip, until the store that moved it has brought the cache along, and+-- for good if that store stopped before it did. So a member that finds+-- the cache behind the same tip at two turns in a row empties it, and it+-- starts again at the tip. Every turn of a member runs this.+syncCache :: Member -> IO ()+syncCache m = when (m.config.cacheMax > 0) $ do+ -- how large the cache is, at every turn: it is one small read, and the+ -- number is the fleet's, the same from every store+ forM_ m.metrics $ \x -> do+ (entries, held) <- cacheSize m.redis m.keys+ setGauge x.xpubs entries+ setGauge x.addresses held+ tip <- fmap encode <$> readTVarIO m.tip+ at <- cacheTip m.redis m.keys+ before <- readTVarIO m.lagging+ case tip of+ Just t | at /= Just t -> do+ when (before == Just t) $+ cacheAdvance m.redis m.keys Nothing t Nothing >>= \case+ CacheEmptied -> wasEmptied m t "no store brought it to the fleet tip"+ _ -> return ()+ atomically (writeTVar m.lagging (Just t))+ _ -> atomically (writeTVar m.lagging Nothing)++-- | The shared pool of a fleet as it is at a tip, for the queries of+-- "Haskoin.Store.Database.Query.Pooled". Every read throws v'TipMoved' if+-- the tip is another one by then, and runs an action if it is not, by which+-- a caller knows that the tip was checked.+redisPool :: R.Connection -> FleetKeys -> ByteString -> IO () -> PoolReads+redisPool conn keys tip checked =+ PoolReads+ { summaries = \as -> case as of+ [] -> return []+ _ -> map summary <$> ask "sums" (map encode as),+ addressTxs = \a start n -> do+ rows <- ask "txs" (encode a : from start <> [num n])+ return [(number time, h) | [member, time] <- groups 2 rows, Right h <- [decode member]],+ addressOuts = \a start n -> do+ rows <- ask "outs" (encode a : from start <> [num n])+ let paid time p v s = Unspent {block = MemRef time, outpoint = p, value = v, script = s, address = Just a}+ return+ [ (paid (number time) p (number v) s, f == "1")+ | [member, time, kept, f] <- groups 4 rows,+ Right p <- [decode member],+ let (v, s) = B.drop 1 <$> C.break (== ' ') kept+ ],+ spentOutputs = \a ps -> case ps of+ [] -> return []+ _ -> map (== '1') . C.unpack . B.concat <$> ask "spent" [encode a, B.concat (map encode ps)],+ bodies = \hs -> case hs of+ [] -> return []+ _ -> transactions <$> ask "bodies" [B.concat (map encode hs)],+ spenders = \ps -> case ps of+ [] -> return []+ _ -> do+ rows <- ask "spenders" [B.concat (map encode ps)]+ return [spender t i | [t, i] <- groups 2 rows],+ page = \offset limit -> do+ rows <- ask "page" [num offset, num limit]+ return [(number time, h) | [member, time] <- groups 2 rows, Right h <- [decode member]],+ startingWith = \start -> rights . map decode <$> ask "ids" [B.drop 1 (encode (TxKeyS start))]+ }+ where+ ask what args = poolRead conn keys 32 (Just tip) what args >>= maybe (throwIO TipMoved) (<$ checked)+ num :: (Show a) => a -> ByteString+ num = C.pack . show+ number :: (Num a) => ByteString -> a+ number = maybe 0 (fromInteger . fst) . C.readInteger+ from :: (Serialize k) => Maybe (UnixTime, k) -> [ByteString]+ from = maybe ["", ""] (\(time, k) -> [num time, encode k])+ groups n rows+ | null rows = []+ | otherwise = take n rows : groups n (drop n rows)+ -- for each transaction: its body, whether it can be replaced by fee,+ -- how many of its outputs the pool spends, and those with what spends+ -- them+ transactions (bytes : flag : count : rest) =+ let (spent, more) = splitAt (3 * number count) rest+ inputs =+ IntMap.fromList+ [ (fromIntegral p.index, s)+ | [o, t, i] <- groups 3 spent,+ Right p <- [outpoint o],+ Just s <- [spender t i]+ ]+ in (body bytes (flag == "1"), inputs) : transactions more+ transactions _ = []+ outpoint :: ByteString -> Either String OutPoint+ outpoint = decode+ summary row = case map number (C.words row) of+ [txs, paid, paidCount, spent, spentCount, both, bothCount] -> Just MemSummary {..}+ _ -> Nothing+ -- whether a transaction can be replaced by fee is what the pool says+ -- now, not what the store that offered it found then+ body bytes can+ | B.null bytes = Nothing+ | otherwise = either (const Nothing) (\td -> Just (td :: TxData) {rbf = can}) (decode bytes)+ spender t i+ | B.null t = Nothing+ | otherwise = either (const Nothing) (\h -> Just Spender {txid = h, index = number i}) (decode t)
@@ -0,0 +1,320 @@+-- |+-- Module : Haskoin.Store.Fleet.Pool+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- The mempool that stores sharing one Redis answer from, as pure decisions:+-- the second part of "A fleet that answers as one" in+-- @docs\/layered-database.md@, after the fleet tip of "Haskoin.Store.Fleet".+-- "Haskoin.Store.Fleet.Redis" carries it out.+--+-- = The problem+--+-- Every store has a mempool of its own, filled in the order in which its+-- peers sent it transactions, and no two are the same. Stores that answer+-- from the same block still answer differently if each lays its own mempool+-- on top.+--+-- = One pool, the mempool as of the fleet tip+--+-- The stores keep one pool in Redis and all answer from it. It goes with+-- the fleet tip: it holds transactions that are valid on top of the chain+-- up to that block, and it changes with the tip in the same step, so that+-- no request sees a block without what it did to the pool.+--+-- A store's own mempool does not go away. It is what the store offers to+-- the pool and relays to its peers, and what it answers from when Redis is+-- down.+--+-- = The rules+--+-- * __A store offers what it has__ ('offer'). Each input of the transaction+-- must spend an output of a transaction of the pool, or one that the store+-- finds unspent in its view at the fleet tip. A store that is ahead of the+-- tip holds back what only makes sense after its newer blocks.+--+-- * __The first of two that spend the same output stays__, unless the store+-- that offers the second names the first as replaced, having replaced it+-- by its own rules. This is a rule, not a truth, as it is for a node that+-- keeps the first of two conflicting transactions that it hears of.+--+-- * __A transaction leaves only for a reason__ ('withdraw', 'advance'): it+-- was replaced, it is too old, or the tip moved to a block that confirms+-- it or spends what it spends. A store that simply does not have a+-- transaction leaves it alone, because another store has it.+--+-- * __Whatever goes takes along what spends it.__ A transaction whose+-- parent left unconfirmed spends an output that does not exist.+--+-- * __The pool follows the tip__ ('advance'). The store that moves the tip+-- says what the blocks in between did: which transactions they confirmed+-- and which outputs they spent, and, across a reorganisation, which+-- transactions of the abandoned blocks come back and which are gone for+-- good, as their coinbases are.+--+-- Transactions and outputs can be of any type that can be compared; the+-- pool only needs to know which transaction made an output.+module Haskoin.Store.Fleet.Pool+ ( -- * The pool+ Pool (..),+ empty,+ member,+ descendants,++ -- * Offering and withdrawing+ Offer (..),+ Refusal (..),+ offer,+ withdraw,++ -- * Following the tip+ Change (..),+ advance,++ -- * What the pool holds for each address+ Entry (..),+ AddressPool (..),+ Totals (..),+ addresses,+ totals,++ -- * Replacing by fee+ replaceable,+ )+where++import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Maybe (mapMaybe)+import Data.Ord (Down (..))+import Data.Set (Set)+import Data.Set qualified as Set+import Data.Word (Word64)++-- | The shared mempool, for transactions of type @t@ and outputs of type+-- @o@. The second and third fields follow from the first, and are kept so+-- that nothing has to be searched for.+data Pool t o = Pool+ { -- | the outputs that each transaction spends+ spends :: !(Map t [o]),+ -- | the transaction that spends each output that is spent+ spender :: !(Map o t),+ -- | the transactions of the pool that spend outputs of each transaction,+ -- whether that one is in the pool or in the chain: a confirmed+ -- transaction can come back in a reorganisation, and what spends it must+ -- then be found again+ children :: !(Map t (Set t))+ }+ deriving (Eq, Show)++-- | A pool with nothing in it.+empty :: Pool t o+empty = Pool {spends = Map.empty, spender = Map.empty, children = Map.empty}++-- | Whether a transaction is in the pool.+member :: (Ord t) => t -> Pool t o -> Bool+member t pool = Map.member t pool.spends++-- | The transactions of the pool that spend outputs of the given ones, or+-- of those in turn, with the given ones that are in the pool.+descendants :: (Ord t) => [t] -> Pool t o -> Set t+descendants from pool = go Set.empty (filter (`member` pool) from)+ where+ go seen [] = seen+ go seen (t : rest)+ | Set.member t seen = go seen rest+ | otherwise = go (Set.insert t seen) (maybe [] Set.toList (Map.lookup t pool.children) <> rest)++-- | Why a transaction was not taken.+data Refusal t o+ = -- | it is in the pool already, which is no harm+ Known+ | -- | these transactions of the pool spend what it spends, and were there+ -- first+ Conflict ![t]+ | -- | these inputs spend outputs that are neither made by the pool nor+ -- unspent at the tip+ Orphan ![o]+ deriving (Eq, Show)++-- | What became of an offer.+data Offer t o+ = Taken !(Pool t o)+ | Refused !(Refusal t o)+ deriving (Eq, Show)++-- | Offer a transaction to the pool, given the transaction that made each+-- output, a test for the outputs that the offering store finds unspent in+-- its view at the fleet tip, the outputs that the transaction spends, and+-- the transactions of the pool that the store replaced with it.+--+-- Those leave first, with what spends them. The transaction is then taken+-- if nothing left in the pool spends what it spends, and if each of its+-- inputs is made by the pool or unspent at the tip.+offer :: (Ord t, Ord o) => (o -> t) -> (o -> Bool) -> t -> [o] -> [t] -> Pool t o -> Offer t o+offer maker unspent t inputs replaced pool0+ | member t pool0 = Refused Known+ | not (null rivals) = Refused (Conflict rivals)+ | not (null orphans) = Refused (Orphan orphans)+ | otherwise = Taken (insert maker t inputs pool)+ where+ pool = withdraw replaced pool0+ rivals = Set.toList (Set.fromList (mapMaybe (`Map.lookup` pool.spender) inputs))+ orphans = [o | o <- inputs, not (member (maker o) pool), not (unspent o)]++-- | Put a transaction in, without asking.+insert :: (Ord t, Ord o) => (o -> t) -> t -> [o] -> Pool t o -> Pool t o+insert maker t inputs pool =+ Pool+ { spends = Map.insert t inputs pool.spends,+ spender = foldl' (\m o -> Map.insert o t m) pool.spender inputs,+ children = foldl' (\m o -> Map.insertWith Set.union (maker o) (Set.singleton t) m) pool.children inputs+ }++-- | Take transactions out of the pool, with everything that spends them.+withdraw :: (Ord t, Ord o) => [t] -> Pool t o -> Pool t o+withdraw ts pool = foldl' (flip remove) pool (Set.toList (descendants ts pool))++-- | Take one transaction out, leaving what spends it.+remove :: (Ord t, Ord o) => t -> Pool t o -> Pool t o+remove t pool =+ Pool+ { spends = Map.delete t pool.spends,+ spender = foldl' (flip Map.delete) pool.spender inputs,+ children = Map.filter (not . Set.null) (Map.map (Set.delete t) pool.children)+ }+ where+ inputs = Map.findWithDefault [] t pool.spends++-- | What the blocks between two tips did, as far as the pool is concerned.+data Change t o = Change+ { -- | transactions of abandoned blocks that can be unconfirmed again, with+ -- what they spend, in an order in which each comes after those it spends+ returned :: ![(t, [o])],+ -- | transactions of abandoned blocks that cannot, such as coinbases:+ -- their outputs no longer exist+ gone :: ![t],+ -- | transactions that the new blocks confirm+ confirmed :: ![t],+ -- | outputs that the transactions of the new blocks spend+ spent :: ![o]+ }+ deriving (Eq, Show)++-- | Bring the pool from one tip to another.+--+-- The abandoned blocks come first: their transactions that can come back+-- do, putting out whatever in the pool spends what they spend, and what+-- spends the others leaves. Then the new blocks: what they+-- confirm leaves the pool, alone, since what spends it now spends an output+-- of the chain; and what spends an output that they spend leaves with+-- everything that spends it.+advance :: (Ord t, Ord o) => (o -> t) -> Change t o -> Pool t o -> Pool t o+advance maker change pool0 = clashed+ where+ back = foldl' comeBack pool0 change.returned+ -- a transaction that was confirmed has the better claim to what it+ -- spends: a transaction of the pool that spends the same cannot have+ -- been valid, and leaves+ comeBack p (t, inputs)+ | member t p = p+ | otherwise = insert maker t inputs (withdraw (mapMaybe (`Map.lookup` p.spender) inputs) p)+ orphaned = concatMap (\t -> maybe [] Set.toList (Map.lookup t back.children)) change.gone+ alive = withdraw orphaned back+ settled = foldl' (flip remove) alive change.confirmed+ clashed = withdraw (mapMaybe (`Map.lookup` settled.spender) change.spent) settled++-- | What a transaction does at an address, for addresses of type @a@: the+-- entries by address of "Haskoin.Store.Database.Mempool", which a store+-- works out from the transaction alone and gives to the pool with it.+data Entry a o+ = -- | it spends from the address or pays to it+ Touches !a+ | -- | it spends this output of the address, of this amount+ Spends !a !o !Word64+ | -- | it pays this output to the address, with this amount+ Pays !a !o !Word64+ deriving (Eq, Show)++-- | What the pool holds for an address: the union of the entries of its+-- transactions at that address, in the order in which queries list them.+data AddressPool t o = AddressPool+ { -- | the transactions that touch it with the time each entered a+ -- mempool, newest first, and in the order of their ids within a time+ txs :: ![(Word64, t)],+ -- | the outputs paid to it, in the same order, with their amounts+ paid :: ![(Word64, o, Word64)],+ -- | the outputs of it that are spent, with their amounts+ spent :: !(Map o Word64)+ }+ deriving (Eq, Show)++-- | The sums that the pool keeps for an address, so that a balance does+-- not take reading its entries: the summary of+-- 'Haskoin.Store.Database.Types.MemSummary'.+data Totals = Totals+ { txs :: !Word64,+ paid :: !Word64,+ paidCount :: !Word64,+ spent :: !Word64,+ spentCount :: !Word64,+ -- | amount of the outputs that the pool both pays and spends+ both :: !Word64,+ bothCount :: !Word64+ }+ deriving (Eq, Show)++-- | What a pool holds for each address, given its transactions, each with+-- the time it entered a mempool and its entries. This is the specification+-- of what "Haskoin.Store.Fleet.Redis" keeps up to date as transactions come+-- and go: it depends on which transactions are in the pool and on nothing+-- else, so the tests work it out again after every step and compare.+addresses :: (Ord a, Ord t, Ord o) => [(t, Word64, [Entry a o])] -> Map a (AddressPool t o)+addresses members = Map.map ordered (Map.fromListWith merge (concatMap single members))+ where+ single (t, time, entries) = map (one t time) entries+ one t time = \case+ Touches a -> (a, AddressPool [(time, t)] [] Map.empty)+ Spends a o v -> (a, AddressPool [] [] (Map.singleton o v))+ Pays a o v -> (a, AddressPool [] [(time, o, v)] Map.empty)+ merge x y = AddressPool (x.txs <> y.txs) (x.paid <> y.paid) (Map.union x.spent y.spent)+ ordered p =+ AddressPool+ (Map.elems (Map.fromList [((Down time, t), (time, t)) | (time, t) <- p.txs]))+ (Map.elems (Map.fromList [((Down time, o), (time, o, v)) | (time, o, v) <- p.paid]))+ p.spent++-- | The sums of what a pool holds for an address.+totals :: (Ord o) => AddressPool t o -> Totals+totals p =+ Totals+ { txs = fromIntegral (length p.txs),+ paid = sum [v | (_, _, v) <- p.paid],+ paidCount = fromIntegral (length p.paid),+ spent = sum (Map.elems p.spent),+ spentCount = fromIntegral (Map.size p.spent),+ both = sum inside,+ bothCount = fromIntegral (length inside)+ }+ where+ inside = [v | (_, o, v) <- p.paid, Map.member o p.spent]++-- | The transactions of a pool that can be replaced by fee, given those+-- that say so themselves: those, and whatever spends from one that can be.+-- A transaction inherits this from its unconfirmed parents only, so it+-- changes when a parent is confirmed and when one comes back from an+-- abandoned block. This is the specification of what Redis keeps right as+-- transactions come and go.+replaceable :: (Ord t) => (t -> Bool) -> (o -> t) -> Pool t o -> Set t+replaceable signals maker pool = go (Set.filter signals members)+ where+ members = Map.keysSet pool.spends+ inherits found t = any ((`Set.member` found) . maker) (Map.findWithDefault [] t pool.spends)+ go found+ | grown == found = found+ | otherwise = go grown+ where+ grown = Set.union found (Set.filter (inherits found) members)
@@ -0,0 +1,400 @@+{-# LANGUAGE TemplateHaskell #-}++-- |+-- Module : Haskoin.Store.Fleet.Redis+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- The rules of "Haskoin.Store.Fleet" carried out against Redis: the state+-- of a fleet in three keys, and a Lua script for each step that has to be+-- one step.+--+-- = Why scripts+--+-- Whether a store may report depends on the tip and on the leases of the+-- others, and the block that the tip moves to depends on every report. Read+-- and written in separate commands, each could change in between: two+-- stores would both find themselves alone, or the tip would move to a block+-- that a store had just stopped reporting. Redis runs a script without+-- letting anything else in, so each rule sees one state and changes it at+-- once.+--+-- = Whose clock+--+-- Leases are measured on the clock of Redis, read inside the scripts. The+-- stores then need not agree on the time, only on which Redis they share. A+-- time can be given instead, which is how the tests make leases run out+-- without waiting.+--+-- = Blocks and stores+--+-- A block is its hash, or any bytes of one fixed size, and a store is a+-- name that no other store of the fleet has.+--+-- = The shared mempool+--+-- The rules of "Haskoin.Store.Fleet.Pool" are one more script, with the+-- pool in keys next to those of the tip. A transaction is its id, of one+-- fixed size, and an output is the id of the transaction that made it and+-- four more bytes. An offer says which tip the store checked the+-- transaction against, and is turned away if the tip has moved since: what+-- was unspent at one block may be spent at the next. Moving the tip and+-- changing the pool for the blocks in between is one step ('fleetAdvance'),+-- so that no request finds a block without what it did to the pool.+--+-- A transaction comes with what it does at each address (t'PoolEntry'),+-- and the script keeps what the pool holds for each address in step: its+-- transactions and the outputs paid to it, each in a sorted set ordered as+-- queries list them, the outputs of it that are spent, and the sums that a+-- balance needs. 'Haskoin.Store.Fleet.Pool.addresses' says what those+-- should be. Kept as the transactions come and go, they cost a few commands+-- for each entry; worked out when asked, a balance would cost a read of+-- every entry of the address, which is what the store's own mempool area+-- did at first and was measured to be thousands of times too slow for a+-- busy address.+module Haskoin.Store.Fleet.Redis+ ( FleetKeys (..),+ fleetKeys,+ Moved (..),+ fleetReport,+ fleetMove,+ fleetTip,+ fleetLive,++ -- * The shared mempool+ Offered (..),+ PoolChange (..),+ PoolTx (..),+ PoolEntry (..),+ poolOffer,+ poolWithdraw,+ fleetAdvance,+ FleetEvent (..),+ fleetEventsEnd,+ fleetEventsAfter,+ poolSpends,+ poolBody,+ poolRead,+ )+where++import Data.ByteString (ByteString)+import Data.ByteString qualified as B+import Data.ByteString.Char8 qualified as C+import Data.FileEmbed (embedFile, makeRelativeToProject)+import Data.Word (Word64)+import Database.Redis (Connection, Redis, Reply)+import Database.Redis qualified as R+import Haskoin.Store.Fleet (Time)++-- | The keys of a fleet in Redis, and the size of its blocks in bytes.+data FleetKeys = FleetKeys+ { -- | what every key of the fleet starts with+ prefix :: !ByteString,+ -- | the fleet tip: a block+ tip :: !ByteString,+ -- | a hash from each store to the time its lease runs out, in+ -- milliseconds+ lease :: !ByteString,+ -- | a hash from each store to its recent blocks, newest first, one+ -- after the other+ recent :: !ByteString,+ -- | bytes in a block+ size :: !Int+ }++-- | The keys of the fleet under a prefix, such as that of the cache of a+-- network, for blocks of a size.+fleetKeys :: ByteString -> Int -> FleetKeys+fleetKeys prefix size =+ FleetKeys+ { prefix,+ tip = prefix <> "fleet:tip",+ lease = prefix <> "fleet:lease",+ recent = prefix <> "fleet:recent",+ size+ }++-- | What became of an attempt to move the tip; see 'Haskoin.Store.Fleet.Move'.+data Moved+ = -- | the tip is now this block+ MovedTo !ByteString+ | -- | the tip is where the live stores agree, or they agree on nothing+ Stays+ | -- | another store moved the tip first+ Raced+ deriving (Eq, Show)++-- | Report the recent blocks of a store, newest first, under a lease of so+-- many milliseconds, given the blocks that the store has views of. Says+-- whether the report was taken: it is not when the store has no view of the+-- tip and another store is live ('Haskoin.Store.Fleet.mayReport').+fleetReport :: Connection -> FleetKeys -> Maybe Time -> Time -> ByteString -> [ByteString] -> [ByteString] -> IO Bool+fleetReport conn keys clock term store blocks views =+ (== 1) <$> run conn (R.eval reportScript (scriptKeys keys) args :: Redis (Either Reply Integer))+ where+ args = [store, num term, maybe "" num clock, num keys.size, B.concat blocks, B.concat views]++-- | Move the tip to the newest block that every live store reported, given+-- the tip that the caller saw.+fleetMove :: Connection -> FleetKeys -> Maybe Time -> Maybe ByteString -> IO Moved+fleetMove conn keys clock seen =+ run conn (R.eval moveScript (scriptKeys keys) args) >>= \case+ ["moved", block] -> return (MovedTo block)+ ["stays"] -> return Stays+ ["raced"] -> return Raced+ other -> fail ("Unexpected answer of the fleet script: " <> show (other :: [ByteString]))+ where+ args = [maybe "" id seen, maybe "" num clock, num keys.size]++-- | The fleet tip, if there is one.+fleetTip :: Connection -> FleetKeys -> IO (Maybe ByteString)+fleetTip conn keys = run conn (R.get keys.tip)++-- | The stores that have a lease, with the time each runs out, in+-- milliseconds. Leases that ran out are in it until the next report.+fleetLive :: Connection -> FleetKeys -> IO [(ByteString, Time)]+fleetLive conn keys = map (fmap (maybe 0 (fromIntegral . fst) . C.readInteger)) <$> run conn (R.hgetall keys.lease)++-- | What became of an offer; see 'Haskoin.Store.Fleet.Pool.Offer'.+data Offered+ = -- | it is in the pool now+ Taken+ | -- | it was there already+ Known+ | -- | these transactions of the pool spend what it spends+ Conflict ![ByteString]+ | -- | these outputs are neither made by the pool nor unspent at the tip+ Orphan ![ByteString]+ | -- | the tip is not the one that the store checked the transaction+ -- against: check again and offer again+ Late+ deriving (Eq, Show)++-- | What a transaction does at an address; see+-- 'Haskoin.Store.Fleet.Pool.Entry'. Addresses, outputs and scripts are+-- whatever bytes the stores of a fleet agree on.+data PoolEntry+ = -- | it spends from the address or pays to it+ PoolTouches !ByteString+ | -- | it spends this output of the address, of this amount+ PoolSpends !ByteString !ByteString !Word64+ | -- | it pays this output to the address, with this amount and script+ PoolPays !ByteString !ByteString !Word64 !ByteString+ deriving (Eq, Show)++-- | A transaction as the pool keeps it.+data PoolTx = PoolTx+ { txid :: !ByteString,+ -- | when it entered the mempool of the store that offers it. The pool+ -- keeps the time of the first offer, so that every store lists the+ -- transaction in the same place.+ time :: !Word64,+ -- | the outputs that it spends, in the order of its inputs+ inputs :: ![ByteString],+ -- | what a store needs to answer about it+ body :: !ByteString,+ -- | whether it says itself that it can be replaced by fee. Whether it+ -- can also depends on the transactions of the pool that it spends+ -- from, which the pool works out and keeps right as those come and go.+ signals :: !Bool,+ -- | what it does at each address+ entries :: ![PoolEntry]+ }+ deriving (Eq, Show)++-- | What the blocks between two tips did; see+-- 'Haskoin.Store.Fleet.Pool.Change'.+data PoolChange = PoolChange+ { -- | transactions that come back+ returned :: ![PoolTx],+ -- | transactions of abandoned blocks that cannot come back+ gone :: ![ByteString],+ -- | transactions that the new blocks confirm+ confirmed :: ![ByteString],+ -- | outputs that the new blocks spend+ spent :: ![ByteString]+ }+ deriving (Eq, Show)++-- | Offer a transaction to the pool: given the size of a transaction id,+-- the tip that the store checked against, the transaction, whether the+-- store finds each output that it spends unspent at that tip, in the order+-- of its inputs, and the transactions that it replaces.+poolOffer ::+ Connection ->+ FleetKeys ->+ Int ->+ Maybe ByteString ->+ PoolTx ->+ [Bool] ->+ [ByteString] ->+ IO Offered+poolOffer conn keys tsize seen tx unspents replaced =+ run conn (R.eval poolScript [] args) >>= \case+ ["taken"] -> return Taken+ ["known"] -> return Known+ "conflict" : rivals -> return (Conflict rivals)+ "orphan" : outputs -> return (Orphan outputs)+ ["raced"] -> return Late+ other -> fail ("Unexpected answer of the pool script: " <> show (other :: [ByteString]))+ where+ flags = C.pack [if unspent then '1' else '0' | unspent <- unspents]+ args = ["offer", keys.prefix, num tsize, maybe "" id seen, flags, B.concat replaced] <> txArgs tx++-- | Take transactions out of the pool, with all that spends them. Gives+-- those that left.+poolWithdraw :: Connection -> FleetKeys -> Int -> [ByteString] -> IO [ByteString]+poolWithdraw conn keys tsize ts =+ run conn (R.eval poolScript [] ["withdraw", keys.prefix, num tsize, B.concat ts])++-- | Move the tip to a block and bring the pool there, in one step: given+-- the tip that the caller saw, the block, which must be the one that the+-- live stores agree on, and what the blocks in between did.+--+-- A store that cannot say what they did, because it does not have the old+-- tip or because the way from it is too long, gives 'Nothing', and the pool+-- is emptied: but only if no other store is live, as when a fleet comes+-- back after all its stores were away. The stores then offer their mempools+-- to the empty pool. With another store live the answer is 'Raced', and+-- the tip waits for a store that knows the way.+--+-- The last argument is the blocks that the tip moves through, oldest+-- first and the new tip last, for the events of the fleet ('FleetEvent'):+-- with none given, the new tip is the one block told.+fleetAdvance ::+ Connection ->+ FleetKeys ->+ Int ->+ Maybe Time ->+ Maybe ByteString ->+ ByteString ->+ Maybe PoolChange ->+ [ByteString] ->+ IO Moved+fleetAdvance conn keys tsize clock seen target known path =+ run conn (R.eval poolScript [] args) >>= \case+ ["moved"] -> return (MovedTo target)+ ["stays"] -> return Stays+ ["raced"] -> return Raced+ other -> fail ("Unexpected answer of the pool script: " <> show (other :: [ByteString]))+ where+ args =+ ["advance", keys.prefix, num tsize, maybe "" id seen, target, maybe "" num clock, num keys.size]+ <> [maybe "1" (const "0") known]+ <> [B.concat change.gone, B.concat change.confirmed, B.concat change.spent]+ <> [num (length change.returned)]+ <> concatMap txArgs change.returned+ <> [B.concat path]++ change = maybe (PoolChange [] [] [] []) id known++-- | Read from the pool as it is at a tip: given the size of a transaction+-- id, the tip that the caller answers from, what to read and its arguments,+-- as @read.lua@ takes them. 'Nothing' if the tip is another one by now.+poolRead :: Connection -> FleetKeys -> Int -> Maybe ByteString -> ByteString -> [ByteString] -> IO (Maybe [ByteString])+poolRead conn keys tsize tip what args =+ run conn (R.eval readScript [] (what : keys.prefix : num tsize : maybe "" id tip : args)) >>= \case+ "ok" : rows -> return (Just rows)+ ["raced"] -> return Nothing+ other -> fail ("Unexpected answer of the read script: " <> show (take 1 other))++-- | Something that happened to the fleet, as every store of it tells its+-- clients.+--+-- A store alone tells what its own node saw. The stores of a fleet answer+-- from one tip and one pool, and their events have to be those of that tip+-- and that pool: otherwise a client would hear of a transaction from one+-- server and find that the next does not have it. So the script that+-- changes the pool or moves the tip appends what it did to a stream in+-- Redis, in the same step, and every store reads the stream.+data FleetEvent+ = -- | the fleet tip moved to this block, or through it+ FleetBlock !ByteString+ | -- | a transaction entered the pool+ FleetEntered !ByteString+ | -- | a transaction left the pool unconfirmed+ FleetLeft !ByteString+ deriving (Eq, Ord, Show)++-- | The place in the stream of events after the last event that there is:+-- where a store that starts listening now begins.+fleetEventsEnd :: Connection -> FleetKeys -> IO ByteString+fleetEventsEnd conn keys =+ run conn (R.sendRequest ["XREVRANGE", keys.prefix <> "fleet:events", "+", "-", "COUNT", "1"]) >>= \case+ R.MultiBulk (Just [R.MultiBulk (Just (R.Bulk (Just at) : _))]) -> return at+ _ -> return "0-0"++-- | The events after a place in the stream, each with its place, waiting+-- up to so many milliseconds for one if there is none yet.+fleetEventsAfter :: Connection -> FleetKeys -> ByteString -> Integer -> IO [(ByteString, FleetEvent)]+fleetEventsAfter conn keys from wait =+ run conn (R.sendRequest ["XREAD", "BLOCK", num wait, "STREAMS", keys.prefix <> "fleet:events", from]) >>= \case+ R.MultiBulk (Just [R.MultiBulk (Just [_, R.MultiBulk (Just records)])]) -> return (concatMap record records)+ _ -> return []+ where+ record = \case+ R.MultiBulk (Just [R.Bulk (Just at), R.MultiBulk (Just fields)]) ->+ case (lookup "k" (pairs fields), lookup "h" (pairs fields)) of+ (Just "b", Just h) -> [(at, FleetBlock h)]+ (Just "t", Just h) -> [(at, FleetEntered h)]+ (Just "d", Just h) -> [(at, FleetLeft h)]+ _ -> []+ _ -> []+ pairs (R.Bulk (Just k) : R.Bulk (Just v) : more) = (k, v) : pairs more+ pairs _ = []++-- | A transaction as the arguments of the pool script.+txArgs :: PoolTx -> [ByteString]+txArgs tx =+ [tx.txid, num tx.time, B.concat tx.inputs, tx.body, if tx.signals then "1" else "0", num (length tx.entries)]+ <> concatMap entry tx.entries+ where+ entry = \case+ PoolTouches a -> ["t", a, "", "", ""]+ PoolSpends a o v -> ["s", a, o, num v, ""]+ PoolPays a o v s -> ["o", a, o, num v, s]++-- | The transactions of the pool, each with the outputs that it spends one+-- after the other.+poolSpends :: Connection -> FleetKeys -> IO [(ByteString, ByteString)]+poolSpends conn keys = run conn (R.hgetall (keys.prefix <> "pool:spends"))++-- | The body of a transaction of the pool.+poolBody :: Connection -> FleetKeys -> ByteString -> IO (Maybe ByteString)+poolBody conn keys t = run conn (R.hget (keys.prefix <> "pool:body") t)++scriptKeys :: FleetKeys -> [ByteString]+scriptKeys keys = [keys.tip, keys.lease, keys.recent]++num :: (Show a) => a -> ByteString+num = C.pack . show++-- | Run Redis commands, throwing if Redis answers with an error.+run :: Connection -> Redis (Either Reply a) -> IO a+run conn f =+ R.runRedis conn f >>= \case+ Right x -> return x+ Left e -> fail ("Redis error: " <> show e)++-- | 'Haskoin.Store.Fleet.mayReport' and 'Haskoin.Store.Fleet.report' in one+-- step.+reportScript :: ByteString+reportScript = $(makeRelativeToProject "src/Haskoin/Store/Fleet/lua/report.lua" >>= embedFile)++-- | 'Haskoin.Store.Fleet.move'.+moveScript :: ByteString+moveScript = $(makeRelativeToProject "src/Haskoin/Store/Fleet/lua/move.lua" >>= embedFile)++-- | What a store reads from the pool, each read with a check that the tip+-- is the one that the store answers from.+readScript :: ByteString+readScript = $(makeRelativeToProject "src/Haskoin/Store/Fleet/lua/read.lua" >>= embedFile)++-- | 'Haskoin.Store.Fleet.Pool.offer', 'Haskoin.Store.Fleet.Pool.withdraw',+-- and 'Haskoin.Store.Fleet.Pool.advance' with the move of the tip.+poolScript :: ByteString+poolScript = $(makeRelativeToProject "src/Haskoin/Store/Fleet/lua/pool.lua" >>= embedFile)
@@ -0,0 +1,642 @@+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TemplateHaskell #-}++-- |+-- Module : Haskoin.Store.Fleet.XPub+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- The cache of extended public keys of a fleet of stores: what a store that+-- answers as a member of a fleet ("Haskoin.Store.Fleet.Member") keeps in+-- the Redis that the fleet shares, so that a request about a wallet is one+-- or two reads of Redis instead of a read of the database for each of its+-- addresses. It is built on the shared pool, and described in "The cache+-- of extended public keys of a fleet" in @docs\/layered-database.md@.+--+-- = What an entry is+--+-- An entry belongs to one extended public key and has, for the addresses+-- that a scan of the key reads ('Haskoin.Store.Common.scanXPubBals'):+--+-- * __the chain's layer__: the balance of each address in the chain, the+-- transactions of all of them, and their unspent outputs, as they are at+-- one block, which is the same for every entry and is written next to+-- them;+--+-- * __the pool's layer__: the transactions of the shared pool that touch+-- those addresses and the outputs that it pays to them. The script of the+-- pool puts them in and takes them out in the step in which it changes+-- the pool, so this layer is the pool, always.+--+-- Transactions and outputs of both layers are in one list each, ordered as+-- the API orders them, so a page is a range of a sorted set. A balance is+-- the chain's with the summary that the pool keeps for the address.+--+-- = Why it cannot give another answer than the database+--+-- A store of a fleet answers from the chain as it was at the fleet tip+-- with the pool on top ("Haskoin.Store.Database.Query.Pooled"). An entry is+-- that same answer worked out beforehand, and it is used only while that+-- holds:+--+-- * __The chain's layer follows from the chain__ at a block and the+-- addresses. Any store computes the same, so it does not matter which one+-- writes, and there is nothing for stores to disagree on.+--+-- * __A read answers only where the cache is__, which is a move of the+-- fleet tip and the block that it went to, and only while the tip is+-- there. A block alone would not do: the tip can leave a block and come+-- back to it, as when a block is reverted and mined again, and on the+-- way the pool takes that block's transactions out of the entries and+-- puts them back as unconfirmed. When the tip moves, the store that moved it+-- brings the cache there, with what the blocks in between did to the+-- addresses that entries have ('cacheAdvance'). Until it has, every read+-- is a miss and the store answers from its database. If it never does, or+-- the chain was reorganised, or the pool was emptied, the cache is+-- emptied: the next move finds it at a block that it does not start from.+--+-- * __The addresses of a key depend on which are used__, and the pool+-- counts. So every read of balances runs the scan again over what the+-- entry has ('window'), and is a miss unless the scan reads exactly the+-- addresses of the entry. The other reads say how many addresses the+-- caller found, and miss if the entry has another number.+--+-- The alternative that this replaces, "Haskoin.Store.Cache", keeps one+-- store's view of each key and repairs it from that store's events. It is+-- still what a store that is not in a fleet uses.+--+-- = What it costs+--+-- An entry is built from the database by the store that missed, in the+-- background, a few addresses at a time ('cacheBuild'); when a key gains+-- addresses only those are read. Each block costs one question to Redis+-- about the addresses that it touches, and a write for those that entries+-- have.+module Haskoin.Store.Fleet.XPub+ ( -- * Reading+ Cached (..),+ Entry (..),+ cachedEntry,+ cachedTxs,+ cachedUnspents,+ cachedCount,+ window,++ -- * Writing+ Built (..),+ cacheBuild,+ cacheDrop,+ cacheHave,+ CacheMoved (..),+ AddressChange (..),+ blockChanges,+ cacheTracked,+ cacheAdvance,+ cacheEvict,+ cacheTip,+ cacheSize,++ -- * Encoding+ xpubId,+ score,+ position,+ nothingPool,+ )+where++import Control.Monad (forM)+import Data.Bits (shift, (.&.), (.|.))+import Data.ByteString (ByteString)+import Data.ByteString qualified as B+import Data.ByteString.Char8 qualified as C+import Data.Default (def)+import Data.FileEmbed (embedFile, makeRelativeToProject)+import Data.Functor ((<&>))+import Data.HashMap.Strict (HashMap)+import Data.HashMap.Strict qualified as HashMap+import Data.Int (Int64)+import Data.IntMap.Strict qualified as IntMap+import Data.List (sortOn)+import Data.Maybe (fromMaybe, mapMaybe)+import Data.Serialize (decode, encode, runPut)+import Data.Serialize.Put (putWord32be, putWord8)+import Data.Text.Encoding (encodeUtf8)+import Data.Word (Word32, Word64)+import Database.Redis (Connection, Redis, Reply)+import Database.Redis qualified as R+import Haskoin+ ( Address (..),+ Ctx,+ Network,+ OutPoint (..),+ Tx (..),+ TxHash,+ TxIn (..),+ TxOut (..),+ addressHash,+ encodeHex,+ nullOutPoint,+ scriptToAddressBS,+ sha256,+ txHash,+ )+import Haskoin.Store.Common (Limits (..), StoreRead (..), scanXPubBals)+import Haskoin.Store.Data+ ( Balance (..),+ BlockRef (..),+ Prev (..),+ TxData (..),+ TxRef (..),+ Unspent (..),+ XPubBal (..),+ XPubSpec (..),+ XPubUnspent (..),+ nullBalance,+ zeroBalance,+ )+import Haskoin.Store.Database.Mempool qualified as M+import Haskoin.Store.Database.Query qualified as Q+import Haskoin.Store.Database.Query.Layered (From (..))+import Haskoin.Store.Database.Query.Pooled (PoolReads (..), pooledRead)+import Haskoin.Store.Database.Reader (DatabaseReader (..))+import Haskoin.Store.Database.Types (MemSummary (..))+import Haskoin.Store.Fleet.Redis (FleetKeys (..))++-------------------------------------------------------------------------------+-- Encoding+-------------------------------------------------------------------------------++-- | What an extended public key is called in Redis: the start of a hash of+-- the key and of the kind of its addresses.+xpubId :: XPubSpec -> ByteString+xpubId = encodeUtf8 . encodeHex . B.take 16 . encode . sha256 . encode++-- | The score of a position in the sorted sets of an entry, such that+-- sorting by score puts the newest first, as the API lists transactions:+-- those of the pool have the negative of the time at which they entered+-- it, and those of the chain a positive number that goes down with height+-- and position in the block. Between equal scores Redis orders members by+-- their bytes, which for hashes and outputs is the order of the database+-- ('Haskoin.Store.Common.txRefKey', 'Haskoin.Store.Common.unspentKey').+--+-- It is the score of "Haskoin.Store.Cache", as a whole number: height and+-- position fit in the 53 bits that Redis keeps exactly.+score :: BlockRef -> Int64+score BlockRef {height = h, position = p} = 0x001fffffffffffff - (h' .|. p')+ where+ h' = (fromIntegral h .&. 0x07ffffff) `shift` 26+ p' = fromIntegral p .&. 0x03ffffff+score MemRef {timestamp = t} = negate (fromIntegral (t .&. 0x001fffffffffffff))++-- | The position that has a score: the inverse of 'score'.+position :: Int64 -> BlockRef+position s+ | s < 0 = MemRef {timestamp = fromIntegral (negate s)}+ | otherwise = BlockRef {height = fromIntegral (m `shift` (-26)), position = fromIntegral (m .&. 0x03ffffff)}+ where+ m = 0x001fffffffffffff - s++-- | A pool that has nothing. The queries of+-- "Haskoin.Store.Database.Query.Pooled" with it answer from the chain+-- alone, which is how the chain's layer of an entry is read.+nothingPool :: PoolReads+nothingPool =+ PoolReads+ { summaries = return . map (const Nothing),+ addressTxs = \_ _ _ -> return [],+ addressOuts = \_ _ _ -> return [],+ spentOutputs = \_ ps -> return (map (const False) ps),+ bodies = return . map (const (Nothing, IntMap.empty)),+ spenders = return . map (const Nothing),+ page = \_ _ -> return [],+ startingWith = \_ -> return []+ }++-------------------------------------------------------------------------------+-- Reading+-------------------------------------------------------------------------------++-- | What a read of the cache gave.+data Cached a+ = -- | the answer, which is the one that the database gives at the fleet+ -- tip with the pool on top+ Hit !a+ | -- | the cache cannot answer: ask the database+ Miss+ | -- | the fleet tip is another block than the one that the request+ -- answers from: answer again from the new tip+ Moved+ deriving (Eq, Show, Functor)++-- | The balances of a cached key, and what the other reads need of them.+data Entry = Entry+ { -- | the balances, as 'Haskoin.Store.Common.StoreRead' gives them+ balances :: ![XPubBal],+ -- | the number of addresses in each of the two chains of the key+ sizes :: !(Int, Int),+ -- | where each address is in the key. It is worked out when it is+ -- asked for, which only a list of unspent outputs does.+ paths :: HashMap Address [Word32]+ }+ deriving (Eq, Show)++-- | The balances of a key at the fleet tip, with the pool applied, if the+-- cache has the key with exactly the addresses that a scan of it reads+-- now. Arguments: the tip that the request answers from, the initial gap+-- and the gap of the store, and the time in milliseconds, by which the+-- cache knows which keys are in use.+cachedEntry :: Connection -> FleetKeys -> ByteString -> Word32 -> Word32 -> Word64 -> XPubSpec -> IO (Cached Entry)+cachedEntry conn keys tip igap gap now spec =+ ask conn keys "bals" [tip, xpubId spec, num now] <&> \case+ Hit rows -> maybe Miss Hit (entry (mapMaybe row (groups 3 rows)))+ Miss -> Miss+ Moved -> Moved+ where+ row [a, kept, sums] = do+ address <- either (const Nothing) Just (decode a)+ let (path, bal) = B.splitAt 5 kept+ [m, _, _, _, _] <- Just (B.unpack path)+ n <- either (const Nothing) Just (decode (B.drop 1 path)) :: Maybe Word32+ let stored = if B.null bal then Nothing else either (const Nothing) Just (decode bal)+ chain = fromMaybe (zeroBalance address) stored+ return (fromIntegral m, n, maybe chain (M.applySummary chain) (summary sums))+ row _ = Nothing+ summary sums = case map number (C.words sums) of+ [txs, paid, paidCount, spent, spentCount, both, bothCount] -> Just MemSummary {..}+ _ -> Nothing+ entry rows = do+ balances <- window igap gap rows+ let count m = length [() | XPubBal {path = m' : _} <- balances, m' == m]+ return+ Entry+ { balances,+ sizes = (count 0, count 1),+ paths = HashMap.fromList [(b.balance.address, b.path) | b <- balances]+ }++-- | The balances of a key as a scan of it gives them, from the balances of+-- the addresses that an entry has, each with the chain that it is in and+-- its number there: if the scan, run over them, reads exactly those+-- addresses. 'Nothing' if it would read one that the entry does not have,+-- or would stop before one that it has.+--+-- This is what makes an entry safe against the gap. The addresses of a key+-- are those up to the last one that is used and so many more, and a+-- payment in the pool counts as use. An entry is built for the addresses+-- of one moment; after a payment to one of its unused addresses the key+-- has more, and after such a payment is dropped it has fewer. Either way+-- this gives 'Nothing', and the store answers from the database and+-- builds the entry again.+window :: Word32 -> Word32 -> [(Word32, Word32, Balance)] -> Maybe [XPubBal]+window igap gap rows+ | not (contiguous ext && contiguous chg) = Nothing+ | otherwise = do+ found <- scanXPubBals (traverse (`HashMap.lookup` known)) igap gap (ext <> [beyond]) (chg <> [beyond])+ if length found == HashMap.size known then Just found else Nothing+ where+ chain m = sortOn fst [(n, b.address) | (m', n, b) <- rows, m' == m]+ ext = chain 0+ chg = chain 1+ contiguous as = map fst as == take (length as) [0 ..]+ known = HashMap.fromList [(b.address, b) | (_, _, b) <- rows]+ -- an address that the entry does not have, after the last of each+ -- chain: a scan that wants more than the entry has asks for it+ beyond = (maxBound, other 0)+ other :: Word32 -> Address+ other i =+ let a = PubKeyAddress (addressHash (encode i))+ in if HashMap.member a known then other (i + 1) else a++-- | A page of the transactions of a cached key, newest first, from where+-- the page starts, given the numbers of addresses that the caller has for+-- the key ('sizes').+cachedTxs :: Connection -> FleetKeys -> ByteString -> XPubSpec -> (Int, Int) -> From -> Limits -> IO (Cached [TxRef])+cachedTxs conn keys tip spec (n0, n1) from limits =+ ask conn keys "txs" (who <> place from encode <> [num limits.offset, num limits.limit]) <&> fmap refs+ where+ who = [tip, xpubId spec, num n0, num n1]+ refs rows =+ [ TxRef {block = position s, txid = h}+ | [m, sc] <- groups 2 rows,+ Right h <- [decode m],+ Just s <- [integer sc]+ ]++-- | A page of the unspent outputs of a cached key, newest first, each with+-- where its address is in the key ('paths').+cachedUnspents ::+ Connection ->+ FleetKeys ->+ ByteString ->+ XPubSpec ->+ Entry ->+ From ->+ Limits ->+ IO (Cached [XPubUnspent])+cachedUnspents conn keys tip spec e from limits =+ ask conn keys "outs" (who <> place from first <> [num limits.offset, num limits.limit]) <&> \case+ Hit rows -> maybe Miss Hit (mapM output (groups 3 rows))+ Miss -> Miss+ Moved -> Moved+ where+ (n0, n1) = e.sizes+ who = [tip, xpubId spec, num n0, num n1]+ -- the outputs of the transaction that a page starts at come first+ -- among those of its second+ first h = encode (OutPoint h 0)+ output [o, sc, kept] = do+ outpoint <- either (const Nothing) Just (decode o)+ s <- integer sc+ let (value, more) = C.break (== ' ') kept+ (size, rest) = C.break (== ' ') (B.drop 1 more)+ (a, script) = B.splitAt (number size) (B.drop 1 rest)+ address <- either (const Nothing) Just (decode a)+ path <- HashMap.lookup address e.paths+ return+ XPubUnspent+ { path,+ unspent = Unspent {block = position s, outpoint, value = number value, script, address = Just address}+ }+ output _ = Nothing++-- | The number of transactions of a cached key.+cachedCount :: Connection -> FleetKeys -> ByteString -> XPubSpec -> (Int, Int) -> IO (Cached Word32)+cachedCount conn keys tip spec (n0, n1) =+ ask conn keys "count" [tip, xpubId spec, num n0, num n1] <&> \case+ Hit [n] -> Hit (number n)+ Hit _ -> Miss+ Miss -> Miss+ Moved -> Moved++-- | Where a page starts, as the arguments of the script, given the bytes+-- that the members of the score of a transaction are to be no smaller+-- than.+place :: From -> (TxHash -> ByteString) -> [ByteString]+place from bytes = case from of+ Top -> ["top", "", ""]+ InMempool time h -> ["at", num (score (MemRef time)), bytes h]+ InChain ref -> ["score", num (score ref), ""]++-- | Ask the script something that it answers with rows.+ask :: Connection -> FleetKeys -> ByteString -> [ByteString] -> IO (Cached [ByteString])+ask conn keys what args =+ run conn (R.eval xpubScript [] (what : keys.prefix : "32" : args)) >>= \case+ "ok" : rows -> return (Hit rows)+ ["miss"] -> return Miss+ ["raced"] -> return Moved+ other -> fail ("Unexpected answer of the xpub script: " <> show (take 1 other))++-------------------------------------------------------------------------------+-- Writing+-------------------------------------------------------------------------------++-- | What became of an attempt to build an entry.+data Built+ = -- | the entry has the addresses of the key now, of which so many were+ -- added+ Built !Int+ | -- | the fleet tip moved, the cache is not at the tip, or another store+ -- wrote the entry meanwhile: nothing more was written+ Abandoned+ deriving (Eq, Show)++-- | The numbers of addresses that the cache has for a key in each of its+-- two chains, both zero if it does not have the key.+cacheHave :: Connection -> FleetKeys -> XPubSpec -> IO (Int, Int)+cacheHave conn keys spec =+ run conn (R.eval xpubScript [] ["have", keys.prefix, "32", xpubId spec]) >>= \case+ [n0, n1] -> return (fromInteger n0, fromInteger n1)+ other -> fail ("Unexpected answer of the xpub script: " <> show (other :: [Integer]))++-- | Delete the entry of a key.+cacheDrop :: Connection -> FleetKeys -> XPubSpec -> IO ()+cacheDrop conn keys spec = do+ _ <- run conn (R.eval xpubScript [] ["drop", keys.prefix, "32", xpubId spec]) :: IO [ByteString]+ return ()++-- | Build the entry of a key, or add to it the addresses that the key has+-- gained: given the tip that the fleet is at, the database as it was at+-- that block, the pool, and the time in milliseconds.+--+-- The addresses of the key are read as a request reads them, from the+-- chain with the pool on top. An entry that has some of them keeps what it+-- has and gets the others; one that has an address that the key no longer+-- has is deleted and built again. The chain's part of each new address is+-- read from the database, and sent a few addresses at a time, so that no+-- one write holds Redis for long: until the last is in, the entry has+-- fewer addresses than the key, and every read of it misses.+--+-- Each write is taken only while the fleet tip, the block of the cache+-- and the addresses of the entry are what this store found.+cacheBuild :: Connection -> FleetKeys -> ByteString -> DatabaseReader -> PoolReads -> Word64 -> XPubSpec -> IO Built+cacheBuild conn keys tip r pool now spec = do+ bals <- (pooledRead r pool).xPubBals spec+ let wanted m = [b | b@XPubBal {path = m' : _} <- bals, m' == m]+ (w0, w1) = (length (wanted 0), length (wanted 1))+ (h0, h1) <- cacheHave conn keys spec+ (n0, n1) <-+ if h0 > w0 || h1 > w1+ then cacheDrop conn keys spec >> return (0, 0)+ else return (h0, h1)+ let new = drop n0 (wanted 0) <> drop n1 (wanted 1)+ go (n0, n1) 0 =<< mapM chainOf new+ where+ chain = pooledRead r nothingPool+ chainOf b = do+ let a = b.balance.address+ bal <- Q.getBalance r a+ txs <- chain.getAddressTxs a def+ outs <- chain.getAddressUnspents a def+ return (b, bal, txs, outs)+ weight (_, _, txs, outs) = 1 + length txs + length outs+ -- as many addresses as hold about 'chunk' rows between them+ batch _ [] = ([], [])+ batch room (x : xs)+ | room <= 0 = ([], x : xs)+ | otherwise = let (now', later) = batch (room - weight x) xs in (x : now', later)+ go _ added [] = return (Built added)+ go (n0, n1) added todo = do+ let (now', later) = batch chunk todo+ args =+ [tip, xpubId spec, num n0, num n1, num now, num (length now')]+ <> concatMap address now'+ count m = length [() | (XPubBal {path = m' : _}, _, _, _) <- now', m' == m]+ run conn (R.eval xpubScript [] ("build" : keys.prefix : "32" : args)) >>= \case+ ["ok"] -> go (n0 + count 0, n1 + count 1) (added + length now') later+ [why] | why `elem` ["raced", "behind", "changed"] -> return Abandoned+ other -> fail ("Unexpected answer of the xpub script: " <> show (take 1 (other :: [ByteString])))+ address (b, bal, txs, outs) =+ [encode b.balance.address, path b.path, maybe "" encode (bal >>= real)]+ <> [num (length txs)]+ <> concat [[encode t.txid, num (score t.block)] | t <- txs]+ <> [num (length outs)]+ <> concat [[encode u.outpoint, num (score u.block), num u.value, u.script] | u <- outs]+ real b = if nullBalance b then Nothing else Just b+ path = \case+ [m, n] -> runPut (putWord8 (fromIntegral m) >> putWord32be n)+ _ -> ""++-- | About how many transactions and outputs one write of 'cacheBuild'+-- carries.+chunk :: Int+chunk = 5000++-- | What became of an attempt to bring the cache to a block.+data CacheMoved+ = -- | the cache is at the block now, with what the blocks did+ CacheMoved+ | -- | it was there already+ CacheStays+ | -- | the fleet tip is not that block any more: whoever moves it next+ -- sees to the cache+ CacheRaced+ | -- | the cache was emptied and starts again at the block+ CacheEmptied+ deriving (Eq, Show)++-- | What blocks did at an address: the transactions that they added to+-- it, the outputs that they paid to it, and its outputs that they spent.+data AddressChange = AddressChange+ { txs :: ![(TxHash, BlockRef)],+ paid :: ![(OutPoint, BlockRef, Word64, ByteString)],+ spent :: ![OutPoint]+ }+ deriving (Eq, Show)++instance Semigroup AddressChange where+ a <> b = AddressChange {txs = a.txs <> b.txs, paid = a.paid <> b.paid, spent = a.spent <> b.spent}++-- | What the transactions of some blocks, in their order, did at each+-- address that they touch. An output that one of them pays and a later one+-- spends is in both lists of its address: 'cacheAdvance' takes out what+-- was spent after it puts in what was paid.+blockChanges :: Network -> Ctx -> [TxData] -> HashMap Address AddressChange+blockChanges net ctx tds = HashMap.fromListWith (flip (<>)) (concatMap one tds)+ where+ one td =+ let h = txHash td.tx+ address = either (const Nothing) Just . scriptToAddressBS net ctx+ ins =+ [ (a, AddressChange {txs = [], paid = [], spent = [i.outpoint]})+ | (n, i) <- zip [0 ..] td.tx.inputs,+ i.outpoint /= nullOutPoint,+ Just prev <- [IntMap.lookup n td.prevs],+ Just a <- [address prev.script]+ ]+ outs =+ [ (a, AddressChange {txs = [], paid = [(OutPoint h n, td.block, o.value, o.script)], spent = []})+ | (n, o) <- zip [0 ..] td.tx.outputs,+ Just a <- [address o.script]+ ]+ touched = HashMap.keys (HashMap.fromList [(a, ()) | (a, _) <- ins <> outs])+ in [(a, AddressChange {txs = [(h, td.block)], paid = [], spent = []}) | a <- touched] <> ins <> outs++-- | Which of some addresses an entry of the cache has, in their order.+cacheTracked :: Connection -> FleetKeys -> [Address] -> IO [Bool]+cacheTracked conn keys as = concat <$> forM (pieces as) asked+ where+ asked some =+ map (== '1') . C.unpack+ <$> run conn (R.eval xpubScript [] ("tracked" : keys.prefix : "32" : map encode some))+ pieces [] = []+ pieces xs = let (now, later) = splitAt 4000 xs in now : pieces later++-- | Bring the cache from the block that it is taken to be at to the block+-- that the fleet tip has moved to: given what the blocks in between did at+-- the addresses that entries have, each with its balance in the chain at+-- the new block, or 'Nothing' to empty the cache instead, as a store does+-- that cannot say what they did.+--+-- The cache is also emptied when it is found at another block than the+-- one given: a store moved the tip and did not get to bring the cache+-- along, and what the blocks since did to it is not known here.+cacheAdvance ::+ Connection ->+ FleetKeys ->+ Maybe ByteString ->+ ByteString ->+ Maybe [(Address, Maybe Balance, AddressChange)] ->+ IO CacheMoved+cacheAdvance conn keys from to changes =+ run conn (R.eval xpubScript [] ("advance" : keys.prefix : "32" : args)) >>= \case+ ["moved"] -> return CacheMoved+ ["stays"] -> return CacheStays+ ["raced"] -> return CacheRaced+ ["emptied"] -> return CacheEmptied+ other -> fail ("Unexpected answer of the xpub script: " <> show (take 1 (other :: [ByteString])))+ where+ args =+ [fromMaybe "" from, to, maybe "1" (const "0") changes, num (maybe 0 length changes)]+ <> concatMap one (fromMaybe [] changes)+ one (a, bal, c) =+ [encode a, maybe "" encode (bal >>= real), num (length c.txs)]+ <> concat [[encode h, num (score ref)] | (h, ref) <- c.txs]+ <> [num (length c.paid)]+ <> concat [[encode p, num (score ref), num v, s] | (p, ref, v, s) <- c.paid]+ <> [num (length c.spent)]+ <> map encode c.spent+ real b = if nullBalance b then Nothing else Just b++-- | Delete the least recently used entries past so many. Says how many it+-- deleted.+cacheEvict :: Connection -> FleetKeys -> Int -> IO Int+cacheEvict conn keys most =+ fromInteger <$> run conn (R.eval xpubScript [] ["evict", keys.prefix, "32", num most])++-- | How large the cache is: the number of its entries, one for each+-- extended public key, and the number of addresses that they have between+-- them, which is what its memory in Redis goes with, together with the+-- transactions of those addresses.+cacheSize :: Connection -> FleetKeys -> IO (Int, Int)+cacheSize conn keys =+ run conn (R.eval xpubScript [] ["size", keys.prefix, "32"]) >>= \case+ [entries, addresses] -> return (fromInteger entries, fromInteger addresses)+ other -> fail ("Unexpected answer of the xpub script: " <> show (other :: [Integer]))++-- | The block that the cache is at, if it is where the fleet tip is: at+-- the same block, by the same move of the tip.+cacheTip :: Connection -> FleetKeys -> IO (Maybe ByteString)+cacheTip conn keys =+ run conn (R.eval xpubScript [] ["where", keys.prefix, "32"]) <&> \block ->+ if B.null block then Nothing else Just block++-------------------------------------------------------------------------------+-- Helpers+-------------------------------------------------------------------------------++num :: (Show a) => a -> ByteString+num = C.pack . show++number :: (Num a) => ByteString -> a+number = maybe 0 (fromInteger . fst) . C.readInteger++-- | A score as Redis prints it: a whole number, which every score of an+-- entry is, or, should Redis ever print a large one that way, a number in+-- the notation of floating point numbers. The first is read as a whole+-- number, since reading a floating point number from text takes several+-- microseconds, and a page has a score for each row.+integer :: ByteString -> Maybe Int64+integer bytes = case C.readInteger bytes of+ Just (n, rest) | B.null rest -> Just (fromInteger n)+ _ -> case reads (C.unpack bytes) :: [(Double, String)] of+ [(d, "")] -> Just (round d)+ _ -> Nothing++groups :: Int -> [a] -> [[a]]+groups n rows+ | null rows = []+ | otherwise = take n rows : groups n (drop n rows)++-- | Run Redis commands, throwing if Redis answers with an error.+run :: Connection -> Redis (Either Reply a) -> IO a+run conn f =+ R.runRedis conn f >>= \case+ Right x -> return x+ Left e -> fail ("Redis error: " <> show e)++-- | The script of the cache; see its header for what it takes and gives.+xpubScript :: ByteString+xpubScript = $(makeRelativeToProject "src/Haskoin/Store/Fleet/lua/xpub.lua" >>= embedFile)
@@ -0,0 +1,74 @@+-- Move the tip to the newest block that every live store reported, if the+-- tip is still the one that the caller saw: moveScript in+-- Haskoin.Store.Fleet.Redis, and agreed and move in Haskoin.Store.Fleet.+--+-- KEYS: the tip, the leases, the recent blocks.+-- ARGV: the tip that the caller saw, or '' for none, the time in+-- milliseconds or '' for the clock of Redis, and the size of a block in+-- bytes.+--+-- Returns {'moved', block}, {'stays'} or {'raced'}.+local seen, clock, size = ARGV[1], ARGV[2], tonumber(ARGV[3])++local tip = redis.call('GET', KEYS[1]) or ''+if tip ~= seen then+ return {'raced'}+end++local now+if clock == '' then+ local t = redis.call('TIME')+ now = t[1] * 1000 + math.floor(t[2] / 1000)+else+ now = tonumber(clock)+end++-- the stores whose lease has not run out, in the order of their names+local live = {}+local leases = redis.call('HGETALL', KEYS[2])+for i = 1, #leases, 2 do+ if tonumber(leases[i + 1]) > now then+ live[#live + 1] = leases[i]+ end+end+if #live == 0 then+ return {'stays'}+end+table.sort(live)++local reports = {}+for i, store in ipairs(live) do+ reports[i] = redis.call('HGET', KEYS[3], store) or ''+end++-- the first block of one report that every other report has+local first = reports[1]+for i = 1, #first, size do+ local block = string.sub(first, i, i + size - 1)+ local everywhere = true+ for j = 2, #reports do+ local found = false+ for k = 1, #reports[j], size do+ if string.sub(reports[j], k, k + size - 1) == block then+ found = true+ break+ end+ end+ if not found then+ everywhere = false+ break+ end+ end+ if everywhere then+ if block == tip then+ return {'stays'}+ end+ redis.call('SET', KEYS[1], block)+ -- the moves of the tip are counted, next to it: see pool.lua+ redis.call('INCR', string.sub(KEYS[1], 1, -4) .. 'moves')+ -- and told to the stores, as pool.lua tells it+ redis.call('XADD', string.sub(KEYS[1], 1, -4) .. 'events', 'MAXLEN', '~', '10000', '*', 'k', 'b', 'h', block)+ return {'moved', block}+ end+end+return {'stays'}
@@ -0,0 +1,477 @@+-- The shared mempool of a fleet: poolScript in Haskoin.Store.Fleet.Redis,+-- and offer, withdraw and advance in Haskoin.Store.Fleet.Pool. The three are+-- one script because they share how a transaction is put in and taken out.+--+-- ARGV[1] is what to do, ARGV[2] the prefix of the keys, and ARGV[3] the+-- size of a transaction id in bytes. An output is the id of the transaction+-- that made it and four more bytes. Lists are their items one after the+-- other.+--+-- A transaction is given as its id, the time it entered a mempool, the+-- outputs that it spends, its body, '1' if it says itself that it can be+-- replaced by fee or '0', the number of its entries by address, and then+-- five arguments for each entry: its kind, the address, an output+-- or '', an amount or '', and a script or ''. The kinds are 't', the+-- transaction touches the address; 's', it spends that output of the+-- address, of that amount; and 'o', it pays that output to the address,+-- with that amount and script.+--+-- 'offer': the tip that the store saw or '', a string with '1' for each+-- output that the transaction spends and that the store finds unspent at+-- the tip and '0' for the others, the transactions that it replaces, and+-- the transaction. Returns {'taken'}, {'known'}, {'conflict', ...},+-- {'orphan', ...} or {'raced'}.+--+-- 'withdraw': the transactions. Returns those that left.+--+-- 'advance': the tip that the store saw or '', the block to move to, the+-- time in milliseconds or '' for the clock of Redis, the size of a block in+-- bytes, '1' to empty the pool instead of changing it or '0', the+-- transactions that are gone, those that are confirmed, the outputs that+-- are spent, the number of transactions that come back, those+-- transactions, and the blocks that the tip moves through, oldest first,+-- or ''. Returns {'moved'}, {'stays'} or {'raced'}. The pool is+-- emptied for a store that cannot say what the blocks between the two tips+-- did, and only when no other store is live: {'raced'} otherwise.+--+-- What the pool holds for each address, kept in step with its transactions:+-- its transactions and the outputs paid to it, both ordered newest first,+-- the outputs of it that are spent, and the sums of all that, which are the+-- summary of Haskoin.Store.Database.Types.MemSummary. It also holds every+-- transaction by the time it entered a mempool, which is the list of the+-- mempool, and by its id alone, to find those whose id starts with some+-- bytes.+--+-- A transaction can be replaced by fee if it says so itself or if it spends+-- from a transaction of the pool that can be. The second changes when a+-- parent is confirmed or comes back from an abandoned block, so the pool+-- keeps the answer apart from the body, and corrects it for what spends a+-- transaction whenever that one comes or goes. An output counts in+-- 'both' while the pool has both the entry that pays it and the entry that+-- spends it, so it is added by the second to come and taken off by the+-- first to go.+--+-- Every move of the tip is counted, in 'fleet:moves'. The cache of extended+-- public keys answers at one move of the tip and not at one block, since+-- the tip can leave a block and come back to it, and what the pool did to+-- the entries on the way is not undone by coming back.+--+-- The cache of extended public keys (xpub.lua) has, for each key that it+-- holds, the transactions of its addresses and the outputs paid to them,+-- those of the pool among them. So what the pool puts in or takes out at an+-- address goes in or out of the entry of every key that has the address,+-- here, in the same step.+local what, prefix, tsize = ARGV[1], ARGV[2], tonumber(ARGV[3])+local osize = tsize + 4+local key = {+ tip = prefix .. 'fleet:tip',+ moves = prefix .. 'fleet:moves',+ events = prefix .. 'fleet:events',+ lease = prefix .. 'fleet:lease',+ recent = prefix .. 'fleet:recent',+ spends = prefix .. 'pool:spends',+ spender = prefix .. 'pool:spender',+ body = prefix .. 'pool:body',+ kids = prefix .. 'pool:kids:',+ entries = prefix .. 'pool:entries',+ time = prefix .. 'pool:time',+ ids = prefix .. 'pool:ids',+ own = prefix .. 'pool:own',+ rbf = prefix .. 'pool:rbf',+ atx = prefix .. 'pool:atx:',+ aout = prefix .. 'pool:aout:',+ aval = prefix .. 'pool:aval:',+ aspent = prefix .. 'pool:aspent:',+ asum = prefix .. 'pool:asum:',+ of = prefix .. 'fx:of:',+ entry = prefix .. 'fx:e:'+}++local function split(s, n)+ local items = {}+ for i = 1, #s, n do+ items[#items + 1] = string.sub(s, i, i + n - 1)+ end+ return items+end++-- Tell every store of the fleet that something happened: 'b' and a block+-- that the tip moved to or through, 't' and a transaction that entered the+-- pool, 'd' and one that left it unconfirmed. The events go in a stream, in+-- the step that makes the change, so that every store serves the same+-- events in the same order, whatever its own node saw and when. The stream+-- keeps about the last ten thousand.+local function emit(kind, what)+ redis.call('XADD', key.events, 'MAXLEN', '~', '10000', '*', 'k', kind, 'h', what)+end++local function member(t)+ return redis.call('HEXISTS', key.spends, t) == 1+end++-- the transactions of the pool among these, and all that spend them+local function descendants(from)+ local seen, order, todo = {}, {}, {}+ for _, t in ipairs(from) do+ if member(t) then+ todo[#todo + 1] = t+ end+ end+ while #todo > 0 do+ local t = table.remove(todo)+ if not seen[t] then+ seen[t] = true+ order[#order + 1] = t+ for _, kid in ipairs(redis.call('SMEMBERS', key.kids .. t)) do+ todo[#todo + 1] = kid+ end+ end+ end+ return order, seen+end++-- a transaction of the arguments, from a position, and the position after+-- it+local function read(at)+ local tx = {id = ARGV[at], time = tonumber(ARGV[at + 1]), inputs = ARGV[at + 2], body = ARGV[at + 3]}+ tx.own = ARGV[at + 4] == '1'+ local entries = {}+ local n = tonumber(ARGV[at + 5])+ at = at + 6+ for i = 1, n do+ entries[i] = {ARGV[at], ARGV[at + 1], ARGV[at + 2], ARGV[at + 3], ARGV[at + 4]}+ at = at + 5+ end+ tx.entries = entries+ return tx, at+end++local function add(a, field, by)+ redis.call('HINCRBY', key.asum .. a, field, string.format('%.0f', by))+end++-- put the entries of a transaction in, with sign 1, or take them out, with+-- sign -1+local function account(t, time, entries, sign)+ local touched = {}+ for _, e in ipairs(entries) do+ local kind, a, o, v = e[1], e[2], e[3], tonumber(e[4])+ touched[a] = true+ -- the cached keys that have the address+ local watching = {}+ if kind ~= 's' then+ watching = redis.call('SMEMBERS', key.of .. a)+ end+ if kind == 't' then+ if sign > 0 then+ redis.call('ZADD', key.atx .. a, -time, t)+ else+ redis.call('ZREM', key.atx .. a, t)+ end+ for _, id in ipairs(watching) do+ if sign > 0 then+ redis.call('ZADD', key.entry .. id .. ':txs', -time, t)+ else+ redis.call('ZREM', key.entry .. id .. ':txs', t)+ end+ end+ add(a, 'txs', sign)+ elseif kind == 's' then+ if sign > 0 then+ redis.call('HSET', key.aspent .. a, o, e[4])+ else+ redis.call('HDEL', key.aspent .. a, o)+ end+ add(a, 'spent', sign * v)+ add(a, 'spentCount', sign)+ if redis.call('HEXISTS', key.aval .. a, o) == 1 then+ add(a, 'both', sign * v)+ add(a, 'bothCount', sign)+ end+ else+ if sign > 0 then+ redis.call('ZADD', key.aout .. a, -time, o)+ redis.call('HSET', key.aval .. a, o, e[4] .. ' ' .. e[5])+ else+ redis.call('ZREM', key.aout .. a, o)+ redis.call('HDEL', key.aval .. a, o)+ end+ for _, id in ipairs(watching) do+ if sign > 0 then+ redis.call('ZADD', key.entry .. id .. ':outs', -time, o)+ redis.call('HSET', key.entry .. id .. ':oval', o, e[4] .. ' ' .. #a .. ' ' .. a .. e[5])+ else+ redis.call('ZREM', key.entry .. id .. ':outs', o)+ redis.call('HDEL', key.entry .. id .. ':oval', o)+ end+ end+ add(a, 'paid', sign * v)+ add(a, 'paidCount', sign)+ if redis.call('HEXISTS', key.aspent .. a, o) == 1 then+ add(a, 'both', sign * v)+ add(a, 'bothCount', sign)+ end+ end+ end+ -- an address that no transaction touches has no summary+ if sign < 0 then+ for a, _ in pairs(touched) do+ if tonumber(redis.call('HGET', key.asum .. a, 'txs')) == 0 then+ redis.call('DEL', key.asum .. a)+ end+ end+ end+end++-- work out again whether a transaction of the pool can be replaced by+-- fee, and if that changed, the same for what spends it+local function replaceable(t)+ local inputs = redis.call('HGET', key.spends, t)+ if not inputs then+ return+ end+ local can = redis.call('SISMEMBER', key.own, t) == 1+ if not can then+ for _, o in ipairs(split(inputs, osize)) do+ if redis.call('SISMEMBER', key.rbf, string.sub(o, 1, tsize)) == 1 then+ can = true+ break+ end+ end+ end+ if can ~= (redis.call('SISMEMBER', key.rbf, t) == 1) then+ if can then+ redis.call('SADD', key.rbf, t)+ else+ redis.call('SREM', key.rbf, t)+ end+ for _, kid in ipairs(redis.call('SMEMBERS', key.kids .. t)) do+ replaceable(kid)+ end+ end+end++-- take one transaction out, leaving what spends it: quietly if a block+-- confirmed it, which is no event of the mempool+local function remove(t, quiet)+ local inputs = redis.call('HGET', key.spends, t)+ if not inputs then+ return+ end+ if not quiet then+ emit('d', t)+ end+ local kept = cmsgpack.unpack(redis.call('HGET', key.entries, t))+ account(t, kept[1], kept[2], -1)+ redis.call('HDEL', key.entries, t)+ redis.call('ZREM', key.time, t)+ redis.call('ZREM', key.ids, t)+ for _, o in ipairs(split(inputs, osize)) do+ redis.call('HDEL', key.spender, o)+ redis.call('SREM', key.kids .. string.sub(o, 1, tsize), t)+ end+ redis.call('HDEL', key.spends, t)+ redis.call('HDEL', key.body, t)+ redis.call('SREM', key.own, t)+ if redis.call('SREM', key.rbf, t) == 1 then+ for _, kid in ipairs(redis.call('SMEMBERS', key.kids .. t)) do+ replaceable(kid)+ end+ end+end++local function insert(tx)+ local t = tx.id+ redis.call('HSET', key.spends, t, tx.inputs)+ redis.call('HSET', key.body, t, tx.body)+ for _, o in ipairs(split(tx.inputs, osize)) do+ redis.call('HSET', key.spender, o, t)+ redis.call('SADD', key.kids .. string.sub(o, 1, tsize), t)+ end+ redis.call('HSET', key.entries, t, cmsgpack.pack({tx.time, tx.entries}))+ redis.call('ZADD', key.time, tx.time, t)+ redis.call('ZADD', key.ids, 0, t)+ account(t, tx.time, tx.entries, 1)+ if tx.own then+ redis.call('SADD', key.own, t)+ end+ replaceable(t)+ emit('t', t)+end++local function withdraw(from)+ local order = descendants(from)+ for _, t in ipairs(order) do+ remove(t)+ end+ return order+end++if what == 'withdraw' then+ return withdraw(split(ARGV[4], tsize))+end++local tip = redis.call('GET', key.tip) or ''+if tip ~= ARGV[4] then+ return {'raced'}+end++if what == 'offer' then+ local flags = ARGV[5]+ local tx = read(7)+ local t, inputs = tx.id, split(tx.inputs, osize)+ if member(t) then+ return {'known'}+ end+ -- what it replaces leaves first, but only if it is taken+ local leaving, left = descendants(split(ARGV[6], tsize))+ local rivals, counted = {}, {}+ for _, o in ipairs(inputs) do+ local s = redis.call('HGET', key.spender, o)+ if s and not left[s] and not counted[s] then+ counted[s] = true+ rivals[#rivals + 1] = s+ end+ end+ if #rivals > 0 then+ table.insert(rivals, 1, 'conflict')+ return rivals+ end+ local orphans = {'orphan'}+ for i, o in ipairs(inputs) do+ local maker = string.sub(o, 1, tsize)+ local made = member(maker) and not left[maker]+ if not made and string.sub(flags, i, i) ~= '1' then+ orphans[#orphans + 1] = o+ end+ end+ if #orphans > 1 then+ return orphans+ end+ for _, gone in ipairs(leaving) do+ remove(gone)+ end+ insert(tx)+ return {'taken'}+end++-- advance: the block must be the one that the live stores agree on+local target, clock, bsize = ARGV[5], ARGV[6], tonumber(ARGV[7])+if target == tip then+ return {'stays'}+end+local now+if clock == '' then+ local time = redis.call('TIME')+ now = time[1] * 1000 + math.floor(time[2] / 1000)+else+ now = tonumber(clock)+end+local live = {}+local leases = redis.call('HGETALL', key.lease)+for i = 1, #leases, 2 do+ if tonumber(leases[i + 1]) > now then+ live[#live + 1] = leases[i]+ end+end+if #live == 0 then+ return {'raced'}+end+table.sort(live)+local reports = {}+for i, store in ipairs(live) do+ reports[i] = redis.call('HGET', key.recent, store) or ''+end+local agreed = ''+for _, block in ipairs(split(reports[1], bsize)) do+ local everywhere = true+ for j = 2, #reports do+ local found = false+ for _, other in ipairs(split(reports[j], bsize)) do+ if other == block then+ found = true+ break+ end+ end+ if not found then+ everywhere = false+ break+ end+ end+ if everywhere then+ agreed = block+ break+ end+end+if agreed ~= target then+ return {'raced'}+end++-- a store that is alone and does not know the way from the old tip starts+-- the pool again+if ARGV[8] == '1' then+ if #live ~= 1 then+ return {'raced'}+ end+ for _, k in ipairs(redis.call('KEYS', prefix .. 'pool:*')) do+ redis.call('DEL', k)+ end+ redis.call('SET', key.tip, target)+ redis.call('INCR', key.moves)+ emit('b', target)+ return {'moved'}+end++-- the abandoned blocks: what can come back does, and what spends the rest+-- leaves+local at = 13+for _ = 1, tonumber(ARGV[12]) do+ local tx+ tx, at = read(at)+ if not member(tx.id) then+ -- it was confirmed: what spends the same in the pool gives way+ local rivals = {}+ for _, o in ipairs(split(tx.inputs, osize)) do+ local s = redis.call('HGET', key.spender, o)+ if s then+ rivals[#rivals + 1] = s+ end+ end+ withdraw(rivals)+ insert(tx)+ end+end+local orphaned = {}+for _, t in ipairs(split(ARGV[9], tsize)) do+ for _, kid in ipairs(redis.call('SMEMBERS', key.kids .. t)) do+ orphaned[#orphaned + 1] = kid+ end+end+withdraw(orphaned)++-- the new blocks: what they confirm leaves alone, and what spends what they+-- spend leaves with all that spends it+for _, t in ipairs(split(ARGV[10], tsize)) do+ remove(t, true)+end+local clashing = {}+for _, o in ipairs(split(ARGV[11], osize)) do+ local s = redis.call('HGET', key.spender, o)+ if s then+ clashing[#clashing + 1] = s+ end+end+withdraw(clashing)++redis.call('SET', key.tip, target)+redis.call('INCR', key.moves)+-- the blocks that the tip moved through, oldest first, if the store that+-- moves it gave them, and the block that it is at now otherwise+local path = split(ARGV[at] or '', bsize)+if #path == 0 then+ path = {target}+end+for _, block in ipairs(path) do+ emit('b', block)+end+return {'moved'}
@@ -0,0 +1,172 @@+-- What a store reads from the shared mempool of its fleet: readScript in+-- Haskoin.Store.Fleet.Redis. pool.lua writes what this reads.+--+-- ARGV[1] is what to read, ARGV[2] the prefix of the keys, ARGV[3] the size+-- of a transaction id in bytes, and ARGV[4] the fleet tip that the store+-- answers from, or ''. Every answer is {'raced'} if the tip is another one:+-- the pool goes with the tip, and laid over the chain of another block it+-- would give a transaction twice or not at all. Otherwise it is {'ok', ...}.+--+-- 'sums': addresses. For each, its seven sums separated by spaces, in the+-- order of the summary, or '' if the pool has nothing for it.+--+-- 'txs': an address, the time and id of the transaction to start at or two+-- '', and how many. Transactions of the address from there, newest first:+-- an id and a time for each.+--+-- 'outs': an address, the time and output to start at or two '', and how+-- many. Outputs paid to the address from there, newest first: for each the+-- output, its time, its amount and script separated by a space, and '1' if+-- the pool spends it or '0'.+--+-- 'spent': an address and outputs. A string with '1' for each output of the+-- address that the pool spends and '0' for the others.+--+-- 'bodies': transactions, of the pool or not. For each: its body or '',+-- '1' if it can be replaced by fee or '0', the number of its outputs that+-- the pool spends, and for each of those the output, the transaction that+-- spends it and the number of its input that does.+--+-- 'spenders': outputs. For each, the transaction that spends it and the+-- number of its input that does, or two ''.+--+-- 'page': an offset and how many, 0 for all. Transactions of the pool from+-- the newest, an id and a time for each.+--+-- 'ids': some bytes. The transactions whose id starts with them.+local what, prefix, tsize = ARGV[1], ARGV[2], tonumber(ARGV[3])+local osize = tsize + 4+if (redis.call('GET', prefix .. 'fleet:tip') or '') ~= ARGV[4] then+ return {'raced'}+end+local answer = {'ok'}++local function split(s, n)+ local items = {}+ for i = 1, #s, n do+ items[#items + 1] = string.sub(s, i, i + n - 1)+ end+ return items+end++-- whether one string of bytes sorts before another, as Redis sorts the+-- members of a sorted set; the comparison of Lua goes by the locale+local function before(a, b)+ local n = math.min(#a, #b)+ for i = 1, n do+ local x, y = string.byte(a, i), string.byte(b, i)+ if x ~= y then+ return x < y+ end+ end+ return #a < #b+end++-- the position in a sorted set, ordered newest first, of the member with a+-- time and a name, or where it would be+local function position(zkey, time, member)+ if time == '' then+ return 0+ end+ local score = -tonumber(time)+ local at = redis.call('ZCOUNT', zkey, '-inf', '(' .. string.format('%.0f', score))+ for _, m in ipairs(redis.call('ZRANGEBYSCORE', zkey, score, score)) do+ if before(m, member) then+ at = at + 1+ end+ end+ return at+end++if what == 'sums' then+ for i = 5, #ARGV do+ local s = redis.call('HMGET', prefix .. 'pool:asum:' .. ARGV[i],+ 'txs', 'paid', 'paidCount', 'spent', 'spentCount', 'both', 'bothCount')+ if s[1] then+ for j = 1, 7 do+ s[j] = s[j] or '0'+ end+ answer[#answer + 1] = table.concat(s, ' ')+ else+ answer[#answer + 1] = ''+ end+ end+elseif what == 'txs' then+ local zkey = prefix .. 'pool:atx:' .. ARGV[5]+ local at = position(zkey, ARGV[6], ARGV[7])+ local rows = redis.call('ZRANGE', zkey, at, at + tonumber(ARGV[8]) - 1, 'WITHSCORES')+ for i = 1, #rows, 2 do+ answer[#answer + 1] = rows[i]+ answer[#answer + 1] = string.format('%.0f', -tonumber(rows[i + 1]))+ end+elseif what == 'outs' then+ local a = ARGV[5]+ local zkey = prefix .. 'pool:aout:' .. a+ local at = position(zkey, ARGV[6], ARGV[7])+ local rows = redis.call('ZRANGE', zkey, at, at + tonumber(ARGV[8]) - 1, 'WITHSCORES')+ for i = 1, #rows, 2 do+ answer[#answer + 1] = rows[i]+ answer[#answer + 1] = string.format('%.0f', -tonumber(rows[i + 1]))+ answer[#answer + 1] = redis.call('HGET', prefix .. 'pool:aval:' .. a, rows[i])+ answer[#answer + 1] = tostring(redis.call('HEXISTS', prefix .. 'pool:aspent:' .. a, rows[i]))+ end+elseif what == 'spent' then+ local flags = {}+ for i, o in ipairs(split(ARGV[6], osize)) do+ flags[i] = tostring(redis.call('HEXISTS', prefix .. 'pool:aspent:' .. ARGV[5], o))+ end+ answer[2] = table.concat(flags)+elseif what == 'bodies' then+ for _, t in ipairs(split(ARGV[5], tsize)) do+ answer[#answer + 1] = redis.call('HGET', prefix .. 'pool:body', t) or ''+ answer[#answer + 1] = tostring(redis.call('SISMEMBER', prefix .. 'pool:rbf', t))+ -- what spends its outputs: the inputs of the transactions of the pool+ -- that spend from it+ local spent = {}+ for _, kid in ipairs(redis.call('SMEMBERS', prefix .. 'pool:kids:' .. t)) do+ for i, input in ipairs(split(redis.call('HGET', prefix .. 'pool:spends', kid), osize)) do+ if string.sub(input, 1, tsize) == t then+ spent[#spent + 1] = input+ spent[#spent + 1] = kid+ spent[#spent + 1] = tostring(i - 1)+ end+ end+ end+ answer[#answer + 1] = tostring(#spent / 3)+ for _, item in ipairs(spent) do+ answer[#answer + 1] = item+ end+ end+elseif what == 'spenders' then+ for _, o in ipairs(split(ARGV[5], osize)) do+ local s = redis.call('HGET', prefix .. 'pool:spender', o)+ local index = ''+ if s then+ for i, input in ipairs(split(redis.call('HGET', prefix .. 'pool:spends', s), osize)) do+ if input == o then+ index = tostring(i - 1)+ end+ end+ end+ answer[#answer + 1] = s or ''+ answer[#answer + 1] = index+ end+elseif what == 'page' then+ local offset, many = tonumber(ARGV[5]), tonumber(ARGV[6])+ local last = -1+ if many > 0 then+ last = offset + many - 1+ end+ local rows = redis.call('ZREVRANGE', prefix .. 'pool:time', offset, last, 'WITHSCORES')+ for i = 1, #rows, 2 do+ answer[#answer + 1] = rows[i]+ answer[#answer + 1] = string.format('%.0f', tonumber(rows[i + 1]))+ end+elseif what == 'ids' then+ local found = redis.call('ZRANGEBYLEX', prefix .. 'pool:ids',+ '[' .. ARGV[5], '(' .. ARGV[5] .. string.rep(string.char(255), tsize))+ for _, t in ipairs(found) do+ answer[#answer + 1] = t+ end+end+return answer
@@ -0,0 +1,54 @@+-- Note the recent blocks of a store under a lease, if the store may report:+-- reportScript in Haskoin.Store.Fleet.Redis, and mayReport and report in+-- Haskoin.Store.Fleet.+--+-- KEYS: the tip, the leases, the recent blocks.+-- ARGV: the store, the length of the lease in milliseconds, the time in+-- milliseconds or '' for the clock of Redis, the size of a block in bytes,+-- the recent blocks of the store, newest first, and the blocks that it has+-- views of, each as blocks one after the other.+--+-- Returns 1 if the report was taken and 0 if the store may not report.+local store, lease, clock, size = ARGV[1], tonumber(ARGV[2]), ARGV[3], tonumber(ARGV[4])+local recent, views = ARGV[5], ARGV[6]++local now+if clock == '' then+ local t = redis.call('TIME')+ now = t[1] * 1000 + math.floor(t[2] / 1000)+else+ now = tonumber(clock)+end++-- whether the store has a view of the tip+local tip = redis.call('GET', KEYS[1])+local has = not tip+if tip then+ for i = 1, #views, size do+ if string.sub(views, i, i + size - 1) == tip then+ has = true+ break+ end+ end+end++-- whether another store is live; leases that ran out are forgotten+local alone = true+local leases = redis.call('HGETALL', KEYS[2])+for i = 1, #leases, 2 do+ if tonumber(leases[i + 1]) > now then+ if leases[i] ~= store then+ alone = false+ end+ else+ redis.call('HDEL', KEYS[2], leases[i])+ redis.call('HDEL', KEYS[3], leases[i])+ end+end++if not (has or alone) then+ return 0+end+redis.call('HSET', KEYS[2], store, now + lease)+redis.call('HSET', KEYS[3], store, recent)+return 1
@@ -0,0 +1,401 @@+-- The cache of extended public keys of a fleet: xpubScript in+-- Haskoin.Store.Fleet.XPub, which says what an entry is and why. pool.lua+-- keeps the part of each entry that comes from the shared mempool.+--+-- ARGV[1] is what to do, ARGV[2] the prefix of the keys, and ARGV[3] the+-- size of a transaction id in bytes. An output is the id of the transaction+-- that made it and four more bytes. A position in a list is a score, as+-- Haskoin.Store.Fleet.XPub.score gives it: lists are ordered by score and,+-- within one score, by the bytes of the member. What an entry keeps for an+-- output is its amount, a space, the length of its address, a space, the+-- address, and the script.+--+-- The reads take the fleet tip that the store answers from, the id of a+-- key, and the number of its addresses in each of its two chains as the+-- caller has them. They answer {'raced'} if the fleet tip is another block,+-- {'miss'} if the cache is not where the tip is, if it does not have the key,+-- or if it has it with other addresses, and {'ok', ...} otherwise.+--+-- 'bals': the tip, the id, and the time in milliseconds, or '' not to note+-- that the key was used. The numbers of addresses are not given, and not+-- checked. For each address of the entry: the address, its path followed+-- by its balance in the chain if it has one, and its seven sums in the+-- pool separated by spaces or ''. A path is five bytes: the chain of the+-- key that the address is in, and its number there.+--+-- 'txs', 'outs': the tip, the id, the two numbers, where to start, an+-- offset and how many, 0 for all. Where to start is 'top' and two '';+-- 'score' with a score and ''; or 'at' with a score and the bytes that the+-- members of that score are to be no smaller than. 'txs' gives a+-- transaction and a score for each; 'outs' an output, a score and what is+-- kept for it, leaving out the outputs that the pool spends.+--+-- 'count': the tip, the id and the two numbers. The number of transactions.+--+-- 'have': the id. The two numbers of the entry, whatever block the cache is+-- at: {n0, n1}.+--+-- 'build': the tip, the id, the two numbers that the entry is to have+-- before, the time in milliseconds, the number of addresses to add, and for+-- each: the address, its path, its balance in the chain or '', the number+-- of its transactions in the chain, a transaction and a score for each, the+-- number of its unspent outputs in the chain, and an output, a score, an+-- amount and a script for each. What the pool has for the address is added+-- from the pool. Returns {'ok'}; {'raced'} if the fleet tip is another+-- block; {'behind'} if the cache is at another block; {'changed'} if the+-- entry does not have those two numbers of addresses.+--+-- 'drop': the id. Deletes the entry.+--+-- 'tracked': addresses. A string with '1' for each address that an entry+-- has and '0' for the others.+--+-- 'where': nothing. The block that the cache is at, if it is at the fleet+-- tip as it stands, and '' otherwise.+--+-- 'advance': the block that the cache is taken to be at or '', the block to+-- bring it to, '1' to empty it or '0', the number of addresses that the+-- blocks in between changed, and for each: the address, its balance in+-- the chain at the new block or '', the number of transactions that the+-- blocks added to it, a transaction and a score for each, the number of+-- outputs that they paid to it, an output, a score, an amount and a script+-- for each, the number of its outputs that they spent, and those outputs.+-- Returns {'moved'}; {'stays'} if the cache is at the new block already;+-- {'raced'} if the fleet tip is not the new block; and {'emptied'} if it+-- had entries and was asked to empty them, or was not where the tip was+-- one move ago, in which case what happened since cannot be told.+--+-- 'size': nothing. The number of entries, and the number of addresses that+-- they have between them: {entries, addresses}.+--+-- 'evict': how many entries to keep. Deletes the least recently used ones+-- past that many, and says how many it deleted.+local what, prefix, tsize = ARGV[1], ARGV[2], tonumber(ARGV[3])+local key = {+ fleet = prefix .. 'fleet:tip',+ tip = prefix .. 'fx:tip',+ lru = prefix .. 'fx:lru',+ addrs = prefix .. 'fx:addrs',+ of = prefix .. 'fx:of:',+ entry = prefix .. 'fx:e:',+ spender = prefix .. 'pool:spender',+ atx = prefix .. 'pool:atx:',+ aout = prefix .. 'pool:aout:',+ aval = prefix .. 'pool:aval:',+ asum = prefix .. 'pool:asum:'+}++-- whether one string of bytes sorts before another, as Redis sorts the+-- members of a sorted set; the comparison of Lua goes by the locale+local function before(a, b)+ local n = math.min(#a, #b)+ for i = 1, n do+ local x, y = string.byte(a, i), string.byte(b, i)+ if x ~= y then+ return x < y+ end+ end+ return #a < #b+end++local function kept(a, amount, script)+ return amount .. ' ' .. #a .. ' ' .. a .. script+end++local function drop(id)+ local e = key.entry .. id .. ':'+ local had = redis.call('HKEYS', e .. 'addrs')+ for _, a in ipairs(had) do+ redis.call('SREM', key.of .. a, id)+ end+ if #had > 0 then+ redis.call('DECRBY', key.addrs, #had)+ end+ redis.call('DEL', e .. 'addrs', e .. 'meta', e .. 'txs', e .. 'outs', e .. 'oval')+ redis.call('ZREM', key.lru, id)+end++local function empty()+ for _, id in ipairs(redis.call('ZRANGE', key.lru, 0, -1)) do+ drop(id)+ end+ -- whatever a script that failed half way left behind+ for _, k in ipairs(redis.call('KEYS', prefix .. 'fx:*')) do+ redis.call('DEL', k)+ end+end++local function numbers(e)+ local n = redis.call('HMGET', e .. 'meta', 'n0', 'n1')+ return tonumber(n[1] or '0'), tonumber(n[2] or '0')+end++if what == 'have' then+ local n0, n1 = numbers(key.entry .. ARGV[4] .. ':')+ return {n0, n1}+elseif what == 'drop' then+ drop(ARGV[4])+ return {'ok'}+elseif what == 'tracked' then+ local flags = {}+ for i = 4, #ARGV do+ flags[#flags + 1] = tostring(redis.call('EXISTS', key.of .. ARGV[i]))+ end+ return table.concat(flags)+elseif what == 'size' then+ return {redis.call('ZCARD', key.lru), tonumber(redis.call('GET', key.addrs) or '0')}+elseif what == 'evict' then+ local over = redis.call('ZCARD', key.lru) - tonumber(ARGV[4])+ if over <= 0 then+ return 0+ end+ for _, id in ipairs(redis.call('ZRANGE', key.lru, 0, over - 1)) do+ drop(id)+ end+ return over+end++local fleet = redis.call('GET', key.fleet) or ''++-- Where the cache is, is a move of the fleet tip and the block that it went+-- to. A block alone would not do: the tip can leave a block and come back+-- to it, as when a block is reverted and mined again, and a cache that was+-- not brought along would find itself at the tip, with entries that the+-- pool has taken the transactions of that block out of on the way.+local moves = tonumber(redis.call('GET', prefix .. 'fleet:moves') or '0')+local function place(n, block)+ return n .. '@' .. block+end+local here = place(moves, fleet)++if what == 'where' then+ if (redis.call('GET', key.tip) or '') == here then+ return fleet+ end+ return ''+end++if what == 'advance' then+ local from, to = ARGV[4], ARGV[5]+ if fleet ~= to then+ return {'raced'}+ end+ local at = redis.call('GET', key.tip) or ''+ if at == here then+ return {'stays'}+ end+ -- it can be brought along only from the move before this one+ local held = redis.call('ZCARD', key.lru) > 0+ if held and (ARGV[6] == '1' or at ~= place(moves - 1, from)) then+ empty()+ redis.call('SET', key.tip, here)+ return {'emptied'}+ end+ local changed = tonumber(ARGV[7])+ local i = 8+ for _ = 1, changed do+ local a, bal = ARGV[i], ARGV[i + 1]+ local ids = redis.call('SMEMBERS', key.of .. a)+ local txs, outs, spent = {}, {}, {}+ local n = tonumber(ARGV[i + 2])+ i = i + 3+ for _ = 1, n do+ txs[#txs + 1] = {ARGV[i], ARGV[i + 1]}+ i = i + 2+ end+ n = tonumber(ARGV[i])+ i = i + 1+ for _ = 1, n do+ outs[#outs + 1] = {ARGV[i], ARGV[i + 1], kept(a, ARGV[i + 2], ARGV[i + 3])}+ i = i + 4+ end+ n = tonumber(ARGV[i])+ i = i + 1+ for _ = 1, n do+ spent[#spent + 1] = ARGV[i]+ i = i + 1+ end+ for _, id in ipairs(ids) do+ local e = key.entry .. id .. ':'+ local row = redis.call('HGET', e .. 'addrs', a)+ if row then+ redis.call('HSET', e .. 'addrs', a, string.sub(row, 1, 5) .. bal)+ end+ for _, t in ipairs(txs) do+ redis.call('ZADD', e .. 'txs', t[2], t[1])+ end+ for _, o in ipairs(outs) do+ redis.call('ZADD', e .. 'outs', o[2], o[1])+ redis.call('HSET', e .. 'oval', o[1], o[3])+ end+ for _, o in ipairs(spent) do+ redis.call('ZREM', e .. 'outs', o)+ redis.call('HDEL', e .. 'oval', o)+ end+ end+ end+ redis.call('SET', key.tip, here)+ return {'moved'}+end++-- everything else is for a store that answers from the fleet tip+local tip, id = ARGV[4], ARGV[5]+local e = key.entry .. id .. ':'+if fleet ~= tip then+ return {'raced'}+end++if what == 'build' then+ local at = redis.call('GET', key.tip)+ if not at then+ -- nothing is cached yet: the cache starts here+ redis.call('SET', key.tip, here)+ elseif at ~= here then+ return {'behind'}+ end+ local n0, n1 = numbers(e)+ if n0 ~= tonumber(ARGV[6]) or n1 ~= tonumber(ARGV[7]) then+ return {'changed'}+ end+ redis.call('ZADD', key.lru, ARGV[8], id)+ local i = 10+ for _ = 1, tonumber(ARGV[9]) do+ local a, path, bal = ARGV[i], ARGV[i + 1], ARGV[i + 2]+ redis.call('HSET', e .. 'addrs', a, path .. bal)+ redis.call('SADD', key.of .. a, id)+ if string.byte(path, 1) == 0 then+ n0 = n0 + 1+ else+ n1 = n1 + 1+ end+ local n = tonumber(ARGV[i + 3])+ i = i + 4+ for _ = 1, n do+ redis.call('ZADD', e .. 'txs', ARGV[i + 1], ARGV[i])+ i = i + 2+ end+ n = tonumber(ARGV[i])+ i = i + 1+ for _ = 1, n do+ redis.call('ZADD', e .. 'outs', ARGV[i + 1], ARGV[i])+ redis.call('HSET', e .. 'oval', ARGV[i], kept(a, ARGV[i + 2], ARGV[i + 3]))+ i = i + 4+ end+ -- what the pool has for the address, which pool.lua keeps up from now+ local rows = redis.call('ZRANGE', key.atx .. a, 0, -1, 'WITHSCORES')+ for j = 1, #rows, 2 do+ redis.call('ZADD', e .. 'txs', rows[j + 1], rows[j])+ end+ rows = redis.call('ZRANGE', key.aout .. a, 0, -1, 'WITHSCORES')+ for j = 1, #rows, 2 do+ local o = rows[j]+ local value = redis.call('HGET', key.aval .. a, o)+ local space = string.find(value, ' ', 1, true)+ redis.call('ZADD', e .. 'outs', rows[j + 1], o)+ redis.call('HSET', e .. 'oval', o, kept(a, string.sub(value, 1, space - 1), string.sub(value, space + 1)))+ end+ end+ redis.call('HSET', e .. 'meta', 'n0', n0, 'n1', n1)+ redis.call('INCRBY', key.addrs, tonumber(ARGV[9]))+ return {'ok'}+end++if (redis.call('GET', key.tip) or '') ~= here then+ return {'miss'}+end+local answer = {'ok'}++if what == 'bals' then+ local addrs = redis.call('HGETALL', e .. 'addrs')+ if #addrs == 0 then+ return {'miss'}+ end+ if ARGV[6] ~= '' then+ redis.call('ZADD', key.lru, ARGV[6], id)+ end+ -- the pool has a summary for an address when one of its transactions+ -- touches it, and those transactions are in the entry, with scores+ -- below zero: most keys have none, and are spared a read for each+ -- address+ local touched = redis.call('ZCOUNT', e .. 'txs', '-inf', '(0') > 0+ for i = 1, #addrs, 2 do+ local a = addrs[i]+ answer[#answer + 1] = a+ answer[#answer + 1] = addrs[i + 1]+ local sums = ''+ if touched then+ local s = redis.call('HMGET', key.asum .. a,+ 'txs', 'paid', 'paidCount', 'spent', 'spentCount', 'both', 'bothCount')+ if s[1] then+ for j = 1, 7 do+ s[j] = s[j] or '0'+ end+ sums = table.concat(s, ' ')+ end+ end+ answer[#answer + 1] = sums+ end+ return answer+end++local n0, n1 = numbers(e)+if n0 + n1 == 0 or n0 ~= tonumber(ARGV[6]) or n1 ~= tonumber(ARGV[7]) then+ return {'miss'}+end++if what == 'count' then+ return {'ok', tostring(redis.call('ZCARD', e .. 'txs'))}+end++-- 'txs' and 'outs': the place in the list where the page starts+local zkey = e .. what+local mode, score, bytes = ARGV[8], ARGV[9], ARGV[10]+local offset, many = tonumber(ARGV[11]), tonumber(ARGV[12])+local first = 0+if mode ~= 'top' then+ first = redis.call('ZCOUNT', zkey, '-inf', '(' .. score)+ if mode == 'at' then+ for _, m in ipairs(redis.call('ZRANGEBYSCORE', zkey, score, score)) do+ if before(m, bytes) then+ first = first + 1+ end+ end+ end+end++if what == 'txs' then+ local last = -1+ if many > 0 then+ last = first + offset + many - 1+ end+ local rows = redis.call('ZRANGE', zkey, first + offset, last, 'WITHSCORES')+ for _, row in ipairs(rows) do+ answer[#answer + 1] = row+ end+ return answer+end++-- 'outs': those that the pool spends are left out, before the offset+local found, skipped, size = 0, 0, 64+while many == 0 or found < many do+ local rows = redis.call('ZRANGE', zkey, first, first + size - 1, 'WITHSCORES')+ if #rows == 0 then+ break+ end+ for j = 1, #rows, 2 do+ local o = rows[j]+ if redis.call('HEXISTS', key.spender, o) == 0 then+ if skipped < offset then+ skipped = skipped + 1+ elseif many == 0 or found < many then+ found = found + 1+ answer[#answer + 1] = o+ answer[#answer + 1] = rows[j + 1]+ answer[#answer + 1] = redis.call('HGET', e .. 'oval', o)+ end+ end+ end+ first = first + size+ size = math.min(4096, size * 4)+end+return answer
@@ -1,13 +1,5 @@-{-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-}-{-# LANGUAGE TupleSections #-}-{-# LANGUAGE NoFieldSelectors #-} {-# OPTIONS_GHC -Wno-ambiguous-fields #-} -- |@@ -18,12 +10,19 @@ -- Stability : experimental -- Portability : POSIX ----- Rules to import and revert blocks and transactions.+-- Rules to import and revert blocks and transactions in the layout of+-- version 20 of the database, in which the mempool is written into the+-- indices of the chain. The store no longer imports with them: it runs on+-- the layered layout, with the rules of "Haskoin.Store.Logic.Layered".+-- These are kept as the reference: the tests give the same random histories+-- to both and compare every result and every answer, and that comparison is+-- the evidence that the new layout does what the old one did. -- -- These functions keep every index consistent: transactions, unspent outputs, -- spenders, address transactions, address unspent outputs, balances and the--- mempool. They run in 'ImportM', which buffers all changes and commits them in--- one atomic batch, so a failure leaves the database untouched.+-- mempool. Each takes the 'Writer' of a batch as its first argument and runs+-- in 'IO'; the writer buffers all changes and 'runWriter' commits them in one+-- atomic batch, so a failure leaves the database untouched. -- -- When a block is imported, transactions already in the mempool are confirmed, -- new ones are imported, and mempool transactions that conflict with the block@@ -51,9 +50,13 @@ -- transactions that conflict with it are deleted together with everything that -- spends them. Between mempool transactions, replace-by-fee decides on networks -- that support it; elsewhere the first one seen stays.+--+-- These rules write unconfirmed transactions into the same indices as the+-- chain. "Haskoin.Store.Logic.Layered" has the rules for a layout that keeps+-- them apart, with the same functions; the store will move to it when that+-- layout replaces this one. module Haskoin.Store.Logic ( ImportException (..),- ImportM, initBest, revertBlock, importBlock,@@ -71,11 +74,11 @@ when, zipWithM_, )-import qualified Data.ByteString as B+import Data.ByteString qualified as B import Data.Either (rights)-import qualified Data.HashMap.Strict as M-import qualified Data.HashSet as H-import qualified Data.IntMap.Strict as I+import Data.HashMap.Strict qualified as M+import Data.HashSet qualified as H+import Data.IntMap.Strict qualified as I import Data.List (nub) import Data.Maybe ( catMaybes,@@ -113,21 +116,35 @@ txHashToHex, ) import Haskoin.Node (LogLevel (..), logAt)-import Haskoin.Store.Common+import Haskoin.Store.Common (nub', sortTxs) import Haskoin.Store.Data-import Haskoin.Store.Database.Writer (WriterT, writerLogger)+import Haskoin.Store.Database.Writer hiding (deleteSpender, insertSpender) import UnliftIO (Exception, throwIO) --- | Block and transaction import. Failures are thrown as 'ImportException'.--- Writes are buffered and committed together when 'runWriter' finishes, so a--- failed batch commits nothing.-type ImportM = WriterT IO- -- | Log a message with the logger of the writer.-logDebug, logError :: Text -> ImportM ()-logDebug msg = writerLogger >>= \l -> logAt LevelDebug l msg-logError msg = writerLogger >>= \l -> logAt LevelError l msg+logDebug, logError :: Writer -> Text -> IO ()+logDebug wr = logAt LevelDebug (writerLogger wr)+logError wr = logAt LevelError (writerLogger wr) +-- | Network of the database being written.+getNetwork :: Writer -> IO Network+getNetwork wr = return (writerNet wr)++-- | Secp256k1 context of the database being written.+getCtx :: Writer -> IO Ctx+getCtx wr = return (writerCtx wr)++-- | Data of a transaction that is in the database and not deleted.+getActiveTxData :: Writer -> TxHash -> IO (Maybe TxData)+getActiveTxData wr th =+ getTxData wr th >>= \case+ Just td | not td.deleted -> return (Just td)+ _ -> return Nothing++-- | Balance of an address, or a zero balance if it was never used.+getDefaultBalance :: Writer -> Address -> IO Balance+getDefaultBalance wr a = fromMaybe (zeroBalance a) <$> getBalance wr a+ -- | Reasons why an import can fail. data ImportException = -- | the block does not build on the best block@@ -169,14 +186,14 @@ show TxSpent = "Transaction is spent" -- | Import the genesis block if the database has no best block yet.-initBest :: ImportM ()-initBest = do- logDebug "Initializing best block"- net <- getNetwork- m <- getBestBlock+initBest :: Writer -> IO ()+initBest wr = do+ logDebug wr "Initializing best block"+ net <- getNetwork wr+ m <- getBestBlock wr when (isNothing m) . void $ do- logDebug "Importing Genesis block"- importBlock (genesisBlock net) (genesisNode net)+ logDebug wr "Importing Genesis block"+ importBlock wr (genesisBlock net) (genesisNode net) -- | Import a transaction into the mempool with the time it arrived. Returns -- 'False' if it is already known.@@ -185,84 +202,84 @@ -- descendants if the network has replace-by-fee; otherwise the transaction is -- rejected with 'DoubleSpend'. Throws an 'ImportException' if the transaction is -- invalid, for example if it spends unknown outputs.-newMempoolTx :: Tx -> UnixTime -> ImportM Bool-newMempoolTx tx w =- getActiveTxData (txHash tx) >>= \case+newMempoolTx :: Writer -> Tx -> UnixTime -> IO Bool+newMempoolTx wr tx w =+ getActiveTxData wr (txHash tx) >>= \case Just _ -> return False Nothing -> do -- Check everything before writing, so that a rejected transaction -- leaves the mempool as it was.- replaced <- getConflicts True True tx- checkNewTx (map txHash replaced) tx- mapM_ (deleteSingleTx . txHash) replaced- rbf <- isRBF (MemRef w) tx- _ <- importTx (MemRef w) w rbf tx+ replaced <- getConflicts wr True True tx+ checkNewTx wr (map txHash replaced) tx+ mapM_ (deleteSingleTx wr . txHash) replaced+ rbf <- isRBF wr (MemRef w) tx+ _ <- importTx wr (MemRef w) w rbf tx return True -- | Data of the best block. Throws 'BestBlockNotFound' if there is none.-bestBlockData :: ImportM BlockData-bestBlockData = do+bestBlockData :: Writer -> IO BlockData+bestBlockData wr = do h <-- getBestBlock >>= \case+ getBestBlock wr >>= \case Nothing -> do- logError "Best block unknown"+ logError wr "Best block unknown" throwIO BestBlockNotFound Just h -> return h- getBlock h >>= \case+ getBlock wr h >>= \case Nothing -> do- logError "Best block not found"+ logError wr "Best block not found" throwIO BestBlockNotFound Just b -> return b -- | Revert the best block: make its parent the best block, mark it as not in the -- main chain, move its transactions back to the mempool, and delete its -- coinbase. Throws 'BlockNotBest' if the block is not the best block.-revertBlock :: BlockHash -> ImportM ()-revertBlock bh = do+revertBlock :: Writer -> BlockHash -> IO ()+revertBlock wr bh = do bd <-- bestBlockData >>= \b ->+ bestBlockData wr >>= \b -> if headerHash b.header == bh then return b else do- logError $+ logError wr $ "Cannot revert non-head block: " <> blockHashToHex bh throwIO BlockNotBest- logDebug $+ logDebug wr $ "Obtained block data for " <> blockHashToHex bh- tds <- mapM getImportTxData bd.txs- logDebug $+ tds <- mapM (getImportTxData wr) bd.txs+ logDebug wr $ "Obtained import tx data for block " <> blockHashToHex bh- setBest bd.header.prev- logDebug $+ setBest wr bd.header.prev+ logDebug wr $ "Set parent as best block " <> blockHashToHex bd.header.prev- insertBlock bd {main = False}- logDebug $+ insertBlock wr bd {main = False}+ logDebug wr $ "Updated as not in main chain: " <> blockHashToHex bh- forM_ (drop 1 tds) unConfirmTx- logDebug $+ forM_ (drop 1 tds) (unConfirmTx wr)+ logDebug wr $ "Unconfirmed " <> cs (show (length tds)) <> " transactions"- deleteConfirmedTx (txHash (tds !! 0).tx)- logDebug $+ deleteConfirmedTx wr (txHash (tds !! 0).tx)+ logDebug wr $ "Deleted coinbase: " <> txHashToHex (txHash (tds !! 0).tx) -- | Check that a block builds on the best block, or that it is the genesis block -- of an empty database.-checkNewBlock :: Block -> BlockNode -> ImportM ()-checkNewBlock b n =- getBestBlock >>= \case+checkNewBlock :: Writer -> Block -> BlockNode -> IO ()+checkNewBlock wr b n =+ getBestBlock wr >>= \case Nothing | isGenesis n -> return () | otherwise -> do- logError $+ logError wr $ "Cannot import non-genesis block: " <> blockHashToHex (headerHash b.header) throwIO BestBlockNotFound Just h | b.header.prev == h -> return () | otherwise -> do- logError $+ logError wr $ "Block does not build on head: " <> blockHashToHex (headerHash b.header) throwIO PrevBlockNotBest@@ -270,57 +287,66 @@ -- | Import the transactions of a block, in dependency order. Mempool transactions -- that conflict with the block are deleted first. Transactions already in the -- mempool are confirmed; others are imported.-importOrConfirm :: BlockNode -> [Tx] -> ImportM [TxData]-importOrConfirm bn txns = do- logDebug "Freeing outputs..."- mapM_ (freeOutputs True False . snd) (reverse txs)- logDebug "Outputs freed"+importOrConfirm :: Writer -> BlockNode -> [Tx] -> IO [TxData]+importOrConfirm wr bn txns = do+ logDebug wr "Freeing outputs..."+ mapM_ (freeOutputs wr True False . snd) (reverse txs)+ logDebug wr "Outputs freed" mapM (uncurry action) txs where txs = sortTxs txns br i = BlockRef {height = bn.height, position = i} bn_time = fromIntegral $ bn.header.timestamp action i tx =- testPresent tx >>= \case+ testPresent wr tx >>= \case False -> import_it i tx True -> confirm_it i tx confirm_it i tx =- getActiveTxData (txHash tx) >>= \case+ getActiveTxData wr (txHash tx) >>= \case Just t -> do- logDebug $+ logDebug wr $ "Confirming tx: " <> txHashToHex (txHash tx)- confirmTx t (br i)+ -- with the time of the block, as the layered rules give it: see+ -- 'Haskoin.Store.Logic.Layered.importChainTx'. Version 20 kept+ -- the time at which the store first saw the transaction; these+ -- rules are what the tests hold the layered ones to, and have+ -- to agree with them on it.+ td <- confirmTx wr t (br i)+ let timed = (td :: TxData) {timestamp = bn_time}+ insertTx wr timed+ return timed Nothing -> do- logError $+ logError wr $ "Cannot find tx to confirm: " <> txHashToHex (txHash tx) throwIO TxNotFound import_it i tx = do- logDebug $+ logDebug wr $ "Importing tx: " <> txHashToHex (txHash tx)- importTx (br i) bn_time False tx+ importTx wr (br i) bn_time False tx -- | Import a block that builds on the best block, and make it the best block. -- Returns its data and the data of its transactions.-importBlock :: Block -> BlockNode -> ImportM (BlockData, [TxData])-importBlock b n = do- logDebug $+importBlock :: Writer -> Block -> BlockNode -> IO (BlockData, [TxData])+importBlock wr b n = do+ logDebug wr $ "Checking new block: " <> blockHashToHex (headerHash n.header)- checkNewBlock b n- logDebug "Passed check"- net <- getNetwork+ checkNewBlock wr b n+ logDebug wr "Passed check"+ net <- getNetwork wr let subsidy = computeSubsidy net n.height- bs <- getBlocksAtHeight n.height- logDebug $+ bs <- getBlocksAtHeight wr n.height+ logDebug wr $ "Inserting block entries for: " <> blockHashToHex (headerHash n.header) setBlocksAtHeight+ wr (nub (headerHash n.header : bs)) n.height- setBest (headerHash n.header)- tds <- importOrConfirm n b.txs+ setBest wr (headerHash n.header)+ tds <- importOrConfirm wr n b.txs let bd = BlockData { height = n.height,@@ -334,8 +360,8 @@ fee = sum $ map txDataFee tds, outputs = ts_out_val }- insertBlock bd- logDebug $+ insertBlock wr bd+ logDebug wr $ "Finished importing block: " <> blockHashToHex (headerHash n.header) return (bd, tds)@@ -351,39 +377,39 @@ -- | Check a transaction that will replace these ones. Outputs that they spend -- count as unspent, and outputs that they create as missing.-checkNewTx :: [TxHash] -> Tx -> ImportM ()-checkNewTx replaced tx = do+checkNewTx :: Writer -> [TxHash] -> Tx -> IO ()+checkNewTx wr replaced tx = do when (unique_inputs < length tx.inputs) $ do- logError $+ logError wr $ "Transaction spends same output twice: " <> txHashToHex (txHash tx) throwIO DuplicatePrevOutput prevs <- mapM prev_output (prevOuts tx) when (any isNothing prevs) $ do- logError $+ logError wr $ "Orphan: " <> txHashToHex (txHash tx) throwIO Orphan when (isCoinbaseTx tx) $ do- logError $+ logError wr $ "Coinbase cannot be imported into mempool: " <> txHashToHex (txHash tx) throwIO UnexpectedCoinbase when (outputs > sum (map (.value) (catMaybes prevs))) $ do- logError $+ logError wr $ "Insufficient funds for tx: " <> txHashToHex (txHash tx) throwIO InsufficientFunds where prev_output op | op.hash `elem` replaced = return Nothing | otherwise = do- parent <- getActiveTxData op.hash+ parent <- getActiveTxData wr op.hash return (parent >>= getTxOut op.index . (.tx)) outputs = sum (map (.value) tx.outputs) unique_inputs = length (nub' (map (.outpoint) tx.inputs)) -- | Unspent outputs spent by a transaction, or 'Nothing' for those that are missing.-getUnspentOutputs :: (StoreReadBase m) => Tx -> m [Maybe Unspent]-getUnspentOutputs tx = mapM getUnspent (prevOuts tx)+getUnspentOutputs :: Writer -> Tx -> IO [Maybe Unspent]+getUnspentOutputs wr tx = mapM (getUnspent wr) (prevOuts tx) -- | Data of a new transaction, from its replace-by-fee flag, position, time and -- the outputs it spends.@@ -404,47 +430,50 @@ -- | Import a transaction at a position in the chain or the mempool. Throws 'Orphan' if it spends a missing output. importTx ::+ Writer -> BlockRef -> -- | unix time Word64 -> -- | RBF Bool -> Tx ->- ImportM TxData-importTx br tt rbf tx = do- mus <- getUnspentOutputs tx+ IO TxData+importTx wr br tt rbf tx = do+ mus <- getUnspentOutputs wr tx us <- forM mus $ \case Nothing -> do- logError $+ logError wr $ "Attempted to import a tx missing UTXO: " <> txHashToHex (txHash tx) throwIO Orphan Just u -> return u let td = prepareTxData rbf br tt tx us- commitAddTx td+ commitAddTx wr td return td -- | Move a confirmed transaction back to the mempool.-unConfirmTx :: TxData -> ImportM TxData-unConfirmTx t = confTx t Nothing+unConfirmTx :: Writer -> TxData -> IO TxData+unConfirmTx wr t = confTx wr t Nothing -- | Confirm a mempool transaction at a position in a block.-confirmTx :: TxData -> BlockRef -> ImportM TxData-confirmTx t br = confTx t (Just br)+confirmTx :: Writer -> TxData -> BlockRef -> IO TxData+confirmTx wr t br = confTx wr t (Just br) -- | Move a transaction to a new position in the indices of all its addresses.-replaceAddressTx :: TxData -> BlockRef -> ImportM ()-replaceAddressTx t new = do- net <- getNetwork- ctx <- getCtx+replaceAddressTx :: Writer -> TxData -> BlockRef -> IO ()+replaceAddressTx wr t new = do+ net <- getNetwork wr+ ctx <- getCtx wr forM_ (txDataAddresses net ctx t) $ \a -> do deleteAddrTx+ wr a TxRef { block = t.block, txid = txHash t.tx } insertAddrTx+ wr a TxRef { block = new,@@ -454,14 +483,15 @@ -- | Move an unspent output to a new position in the chain, updating the indices -- and balance of its address. adjustAddressOutput ::+ Writer -> OutPoint -> TxOut -> BlockRef -> BlockRef ->- ImportM ()-adjustAddressOutput op o old new = do+ IO ()+adjustAddressOutput wr op o old new = do let pk = o.script- getUnspent op >>= \case+ getUnspent wr op >>= \case Nothing -> return () Just u -> do unless (u.block == old) $@@ -470,11 +500,12 @@ replace_unspent pk where replace_unspent pk = do- net <- getNetwork- ctx <- getCtx+ net <- getNetwork wr+ ctx <- getCtx wr let ma = eitherToMaybe (scriptToAddressBS net ctx pk)- deleteUnspent op+ deleteUnspent wr op insertUnspent+ wr Unspent { block = new, outpoint = op,@@ -485,6 +516,7 @@ forM_ ma $ replace_addr_unspent pk replace_addr_unspent pk a = do deleteAddrUnspent+ wr a Unspent { block = old,@@ -494,6 +526,7 @@ address = Just a } insertAddrUnspent+ wr a Unspent { block = new,@@ -502,22 +535,22 @@ script = pk, address = Just a }- decreaseBalance (confirmed old) a o.value- increaseBalance (confirmed new) a o.value+ decreaseBalance wr (confirmed old) a o.value+ increaseBalance wr (confirmed new) a o.value -- | Confirm a transaction at a position in a block, or move it back to the mempool -- if no position is given.-confTx :: TxData -> Maybe BlockRef -> ImportM TxData-confTx t mbr = do- replaceAddressTx t new+confTx :: Writer -> TxData -> Maybe BlockRef -> IO TxData+confTx wr t mbr = do+ replaceAddressTx wr t new forM_ (zip [0 ..] t.tx.outputs) $ \(n, o) -> do let op = OutPoint (txHash t.tx) n- adjustAddressOutput op o old new- rbf <- isRBF new t.tx+ adjustAddressOutput wr op o old new+ rbf <- isRBF wr new t.tx let td = (t :: TxData) {block = new, rbf = rbf}- insertTx td- updateMempool td- updateSpendersRBF td+ insertTx wr td+ updateMempool wr td+ updateSpendersRBF wr td return td where new = fromMaybe (MemRef t.timestamp) mbr@@ -525,111 +558,114 @@ -- | Spenders inherit BIP125 signalling from unconfirmed parents only, so their -- flag can change when a parent is confirmed or unconfirmed.-updateSpendersRBF :: TxData -> ImportM ()-updateSpendersRBF td =+updateSpendersRBF :: Writer -> TxData -> IO ()+updateSpendersRBF wr td = forM_ (nub' (map (.txid) (I.elems td.spenders))) $ \h ->- getActiveTxData h >>= mapM_ update+ getActiveTxData wr h >>= mapM_ update where update s = do- rbf <- isRBF s.block s.tx+ rbf <- isRBF wr s.block s.tx when (rbf /= s.rbf) $ do let s' = (s :: TxData) {rbf = rbf}- insertTx s'- updateSpendersRBF s'+ insertTx wr s'+ updateSpendersRBF wr s' -- | Delete the transactions that spend the same outputs as this one, with their -- descendants. The first flag refuses to delete confirmed transactions, and the -- second refuses any deletion on networks without replace-by-fee.-freeOutputs :: Bool -> Bool -> Tx -> ImportM ()-freeOutputs memonly rbfcheck tx =- getConflicts memonly rbfcheck tx >>= mapM_ (deleteSingleTx . txHash)+freeOutputs :: Writer -> Bool -> Bool -> Tx -> IO ()+freeOutputs wr memonly rbfcheck tx =+ getConflicts wr memonly rbfcheck tx >>= mapM_ (deleteSingleTx wr . txHash) -- | Transactions that spend the same outputs as this one, with their -- descendants, in an order in which they can be deleted. getConflicts ::+ Writer -> -- | only delete transaction if unconfirmed Bool -> -- | only delete if network has RBF Bool -> Tx ->- ImportM [Tx]-getConflicts memonly rbfcheck tx = do- logDebug $+ IO [Tx]+getConflicts wr memonly rbfcheck tx = do+ logDebug wr $ "Getting conflicts for tx " <> txHashToHex (txHash tx) <> "..." let prevs = prevOuts tx- unspents <- mapM getUnspent prevs+ unspents <- mapM (getUnspent wr) prevs let spents = [p | (p, Nothing) <- zip prevs unspents]- spndrs <- catMaybes <$> mapM getSpender spents+ spndrs <- catMaybes <$> mapM (getSpender wr) spents let txids = H.fromList $ filter (/= txHash tx) $ map (.txid) spndrs- chains <- mapM (getChain memonly rbfcheck) (H.toList txids)+ chains <- mapM (getChain wr memonly rbfcheck) (H.toList txids) return . reverse . map snd . sortTxs . M.elems $ M.fromList [(txHash t, t) | t <- concat chains] -- | Delete a transaction and its descendants, confirmed or not.-deleteConfirmedTx :: TxHash -> ImportM ()-deleteConfirmedTx = deleteTx False False+deleteConfirmedTx :: Writer -> TxHash -> IO ()+deleteConfirmedTx wr = deleteTx wr False False -- | Delete an unconfirmed transaction and its descendants. If the flag is set and -- the network has no replace-by-fee, throw 'DoubleSpend' instead. Does nothing if -- the transaction is not found.-deleteUnconfirmedTx :: Bool -> TxHash -> ImportM ()-deleteUnconfirmedTx rbfcheck th =- getActiveTxData th >>= \case- Just _ -> deleteTx True rbfcheck th+deleteUnconfirmedTx :: Writer -> Bool -> TxHash -> IO ()+deleteUnconfirmedTx wr rbfcheck th =+ getActiveTxData wr th >>= \case+ Just _ -> deleteTx wr True rbfcheck th Nothing ->- logDebug $+ logDebug wr $ "Not found or already deleted: " <> txHashToHex th -- | Delete a transaction and its descendants. deleteTx ::+ Writer -> -- | only delete transaction if unconfirmed Bool -> -- | only delete if network has RBF Bool -> TxHash ->- ImportM ()-deleteTx memonly rbfcheck th = do- chain <- getChain memonly rbfcheck th- logDebug $+ IO ()+deleteTx wr memonly rbfcheck th = do+ chain <- getChain wr memonly rbfcheck th+ logDebug wr $ "Deleting " <> cs (show (length chain)) <> " txs from chain leading to " <> txHashToHex th- mapM_ (\t -> let h = txHash t in deleteSingleTx h >> return h) chain+ mapM_ (\t -> let h = txHash t in deleteSingleTx wr h >> return h) chain -- | A transaction and all its descendants, in an order in which they can be -- deleted: spenders first. getChain ::+ Writer -> -- | only delete transaction if unconfirmed Bool -> -- | only delete if network has RBF Bool -> TxHash ->- ImportM [Tx]-getChain memonly rbfcheck th' = do- logDebug $+ IO [Tx]+getChain wr memonly rbfcheck th' = do+ logDebug wr $ "Getting chain for tx " <> txHashToHex th' sort_clean <$> go M.empty (H.singleton th') where sort_clean = reverse . map snd . sortTxs get_tx th =- getActiveTxData th >>= \case+ getActiveTxData wr th >>= \case Nothing -> do- logDebug $+ logDebug wr $ "Transaction not found: " <> txHashToHex th return Nothing Just td | memonly && confirmed td.block -> do- logError $+ logError wr $ "Transaction already confirmed: " <> txHashToHex th throwIO TxConfirmed | rbfcheck ->- getNetwork >>= \net ->+ getNetwork wr >>= \net -> if net.replaceByFee then return $ Just td else do- logError $+ logError wr $ "Double-spending transaction: " <> txHashToHex th throwIO DoubleSpend@@ -643,7 +679,7 @@ txm' = txmn <> txm if H.null pdg' then do- logDebug $+ logDebug wr $ "Chain for tx " <> txHashToHex th' <> " contains "@@ -653,43 +689,43 @@ else go txm' pdg' -- | Delete a transaction that nothing spends. Throws 'TxSpent' otherwise.-deleteSingleTx :: TxHash -> ImportM ()-deleteSingleTx th =- getActiveTxData th >>= \case+deleteSingleTx :: Writer -> TxHash -> IO ()+deleteSingleTx wr th =+ getActiveTxData wr th >>= \case Nothing -> do- logError $+ logError wr $ "Already deleted: " <> txHashToHex th throwIO TxNotFound Just td -> if I.null td.spenders then do- logDebug $+ logDebug wr $ "Deleting tx: " <> txHashToHex th- commitDelTx td+ commitDelTx wr td else do- logError $+ logError wr $ "Tried to delete spent tx: " <> txHashToHex th throwIO TxSpent -- | Remove a transaction from every index and mark it as deleted.-commitDelTx :: TxData -> ImportM ()-commitDelTx = commitModTx False+commitDelTx :: Writer -> TxData -> IO ()+commitDelTx wr = commitModTx wr False -- | Add a transaction to every index.-commitAddTx :: TxData -> ImportM ()-commitAddTx = commitModTx True+commitAddTx :: Writer -> TxData -> IO ()+commitAddTx wr = commitModTx wr True -- | Add a transaction to every index, or remove it if the flag is 'False'.-commitModTx :: Bool -> TxData -> ImportM ()-commitModTx add tx_data = do- net <- getNetwork- ctx <- getCtx+commitModTx :: Writer -> Bool -> TxData -> IO ()+commitModTx wr add tx_data = do+ net <- getNetwork wr+ ctx <- getCtx wr mapM_ mod_addr_tx (txDataAddresses net ctx td) mod_outputs mod_unspent- insertTx td- updateMempool td+ insertTx wr td+ updateMempool wr td where tx = td.tx br = td.block@@ -697,50 +733,50 @@ tx_ref = TxRef br (txHash tx) mod_addr_tx a | add = do- insertAddrTx a tx_ref- modAddressCount add a+ insertAddrTx wr a tx_ref+ modAddressCount wr add a | otherwise = do- deleteAddrTx a tx_ref- modAddressCount add a+ deleteAddrTx wr a tx_ref+ modAddressCount wr add a mod_unspent- | add = spendOutputs tx- | otherwise = unspendOutputs tx+ | add = spendOutputs wr tx+ | otherwise = unspendOutputs wr tx mod_outputs- | add = addOutputs br tx- | otherwise = delOutputs br tx+ | add = addOutputs wr br tx+ | otherwise = delOutputs wr br tx -- | Add a transaction to the mempool if it is unconfirmed, or remove it otherwise.-updateMempool :: TxData -> ImportM ()-updateMempool td@TxData {deleted = True} =- deleteFromMempool (txHash td.tx)-updateMempool td@TxData {block = MemRef t} =- addToMempool (txHash td.tx) t-updateMempool td@TxData {block = BlockRef {}} =- deleteFromMempool (txHash td.tx)+updateMempool :: Writer -> TxData -> IO ()+updateMempool wr td@TxData {deleted = True} =+ deleteFromMempool wr (txHash td.tx)+updateMempool wr td@TxData {block = MemRef t} =+ addToMempool wr (txHash td.tx) t+updateMempool wr td@TxData {block = BlockRef {}} =+ deleteFromMempool wr (txHash td.tx) -- | Mark the outputs spent by a transaction as spent.-spendOutputs :: Tx -> ImportM ()-spendOutputs tx =- zipWithM_ (spendOutput (txHash tx)) [0 ..] (prevOuts tx)+spendOutputs :: Writer -> Tx -> IO ()+spendOutputs wr tx =+ zipWithM_ (spendOutput wr (txHash tx)) [0 ..] (prevOuts tx) -- | Add the outputs of a transaction as unspent.-addOutputs :: BlockRef -> Tx -> ImportM ()-addOutputs br tx =- zipWithM_ (addOutput br . OutPoint (txHash tx)) [0 ..] tx.outputs+addOutputs :: Writer -> BlockRef -> Tx -> IO ()+addOutputs wr br tx =+ zipWithM_ (addOutput wr br . OutPoint (txHash tx)) [0 ..] tx.outputs -- | Whether an unconfirmed transaction can be replaced by fee: it signals BIP125 -- in one of its inputs, or it spends an unconfirmed transaction that can be -- replaced. Always 'False' for confirmed transactions and on networks without -- replace-by-fee. isRBF ::- (StoreReadBase m) =>+ Writer -> BlockRef -> Tx ->- m Bool-isRBF br tx+ IO Bool+isRBF wr br tx | confirmed br = return False | otherwise =- getNetwork >>= \net ->+ getNetwork wr >>= \net -> if net.replaceByFee then go else return False@@ -752,7 +788,7 @@ let hs = nub' $ map (.outpoint.hash) tx.inputs ck [] = return False ck (h : hs') =- getActiveTxData h >>= \case+ getActiveTxData wr h >>= \case Nothing -> return False Just t | confirmed t.block -> ck hs'@@ -761,29 +797,30 @@ in ck hs -- | Add an unspent output.-addOutput :: BlockRef -> OutPoint -> TxOut -> ImportM ()-addOutput = modOutput True+addOutput :: Writer -> BlockRef -> OutPoint -> TxOut -> IO ()+addOutput wr = modOutput wr True -- | Delete an output.-delOutput :: BlockRef -> OutPoint -> TxOut -> ImportM ()-delOutput = modOutput False+delOutput :: Writer -> BlockRef -> OutPoint -> TxOut -> IO ()+delOutput wr = modOutput wr False -- | Add an unspent output, or delete it if the flag is 'False', updating the indices and balance of its address. modOutput ::+ Writer -> Bool -> BlockRef -> OutPoint -> TxOut ->- ImportM ()-modOutput add br op o = do- net <- getNetwork- ctx <- getCtx+ IO ()+modOutput wr add br op o = do+ net <- getNetwork wr+ ctx <- getCtx wr let ma = eitherToMaybe (scriptToAddressBS net ctx o.script) mod_unspent ma forM_ ma $ \a -> do mod_addr_unspent a (u ma)- modBalance (confirmed br) add a o.value- modifyReceived a v+ modBalance wr (confirmed br) add a o.value+ modifyReceived wr a v where v | add = (+ o.value)@@ -797,25 +834,25 @@ address = ma } mod_unspent ma- | add = insertUnspent (u ma)- | otherwise = deleteUnspent op+ | add = insertUnspent wr (u ma)+ | otherwise = deleteUnspent wr op mod_addr_unspent- | add = insertAddrUnspent- | otherwise = deleteAddrUnspent+ | add = insertAddrUnspent wr+ | otherwise = deleteAddrUnspent wr -- | Delete the outputs of a transaction.-delOutputs :: BlockRef -> Tx -> ImportM ()-delOutputs br tx =+delOutputs :: Writer -> BlockRef -> Tx -> IO ()+delOutputs wr br tx = forM_ (zip [0 ..] tx.outputs) $ \(i, o) -> do let op = OutPoint (txHash tx) i- delOutput br op o+ delOutput wr br op o -- | Data of a transaction. Throws 'TxNotFound' if it is missing or deleted.-getImportTxData :: TxHash -> ImportM TxData-getImportTxData th =- getActiveTxData th >>= \case+getImportTxData :: Writer -> TxHash -> IO TxData+getImportTxData wr th =+ getActiveTxData wr th >>= \case Nothing -> do- logDebug $ "Tx not found: " <> txHashToHex th+ logDebug wr $ "Tx not found: " <> txHashToHex th throwIO TxNotFound Just d -> return d @@ -826,50 +863,50 @@ return $ tx.outputs !! fromIntegral i -- | Record which input spends an output.-insertSpender :: OutPoint -> Spender -> ImportM ()-insertSpender op s = do- td <- getImportTxData op.hash+insertSpender :: Writer -> OutPoint -> Spender -> IO ()+insertSpender wr op s = do+ td <- getImportTxData wr op.hash let p = td.spenders p' = I.insert (fromIntegral op.index) s p td' = (td :: TxData) {spenders = p'}- insertTx td'+ insertTx wr td' -- | Forget which input spends an output.-deleteSpender :: OutPoint -> ImportM ()-deleteSpender op = do- td <- getImportTxData op.hash+deleteSpender :: Writer -> OutPoint -> IO ()+deleteSpender wr op = do+ td <- getImportTxData wr op.hash let p = td.spenders p' = I.delete (fromIntegral op.index) p td' = (td :: TxData) {spenders = p'}- insertTx td'+ insertTx wr td' -- | Spend an unspent output with an input of a transaction.-spendOutput :: TxHash -> Word32 -> OutPoint -> ImportM ()-spendOutput th ix op = do+spendOutput :: Writer -> TxHash -> Word32 -> OutPoint -> IO ()+spendOutput wr th ix op = do u <-- getUnspent op >>= \case+ getUnspent wr op >>= \case Just u -> return u Nothing -> error $ "Could not find UTXO to spend: " <> show op- deleteUnspent op- insertSpender op (Spender th ix)+ deleteUnspent wr op+ insertSpender wr op (Spender th ix) let pk = u.script- net <- getNetwork- ctx <- getCtx+ net <- getNetwork wr+ ctx <- getCtx wr forM_ (scriptToAddressBS net ctx pk) $ \a -> do- decreaseBalance (confirmed u.block) a u.value- deleteAddrUnspent a u+ decreaseBalance wr (confirmed u.block) a u.value+ deleteAddrUnspent wr a u -- | Make the outputs spent by a transaction unspent again.-unspendOutputs :: Tx -> ImportM ()-unspendOutputs = mapM_ unspendOutput . prevOuts+unspendOutputs :: Writer -> Tx -> IO ()+unspendOutputs wr = mapM_ (unspendOutput wr) . prevOuts -- | Make an output unspent again.-unspendOutput :: OutPoint -> ImportM ()-unspendOutput op = do- net <- getNetwork- ctx <- getCtx+unspendOutput :: Writer -> OutPoint -> IO ()+unspendOutput wr op = do+ net <- getNetwork wr+ ctx <- getCtx wr t <-- getActiveTxData op.hash >>= \case+ getActiveTxData wr op.hash >>= \case Nothing -> error $ "Could not find tx data: " <> show op.hash Just t -> return t let o =@@ -885,38 +922,39 @@ outpoint = op, address = m }- deleteSpender op- insertUnspent u+ deleteSpender wr op+ insertUnspent wr u forM_ m $ \a -> do- insertAddrUnspent a u- increaseBalance (confirmed u.block) a o.value+ insertAddrUnspent wr a u+ increaseBalance wr (confirmed u.block) a o.value -- | Change the total received by an address.-modifyReceived :: Address -> (Word64 -> Word64) -> ImportM ()-modifyReceived a f = do- b <- getDefaultBalance a- setBalance b {received = f b.received}+modifyReceived :: Writer -> Address -> (Word64 -> Word64) -> IO ()+modifyReceived wr a f = do+ b <- getDefaultBalance wr a+ setBalance wr b {received = f b.received} -- | Remove an output from the confirmed or unconfirmed balance of an address.-decreaseBalance :: Bool -> Address -> Word64 -> ImportM ()-decreaseBalance conf = modBalance conf False+decreaseBalance :: Writer -> Bool -> Address -> Word64 -> IO ()+decreaseBalance wr conf = modBalance wr conf False -- | Add an output to the confirmed or unconfirmed balance of an address.-increaseBalance :: Bool -> Address -> Word64 -> ImportM ()-increaseBalance conf = modBalance conf True+increaseBalance :: Writer -> Bool -> Address -> Word64 -> IO ()+increaseBalance wr conf = modBalance wr conf True -- | Add or remove an output from the confirmed or unconfirmed balance of an address, and its count of unspent outputs. modBalance ::+ Writer -> -- | confirmed Bool -> -- | add Bool -> Address -> Word64 ->- ImportM ()-modBalance conf add a val = do- b <- getDefaultBalance a- setBalance $ (g . f) b+ IO ()+modBalance wr conf add a val = do+ b <- getDefaultBalance wr a+ setBalance wr $ (g . f) b where g b = (b :: Balance) {utxo = m 1 b.utxo} f b@@ -927,10 +965,10 @@ | otherwise = subtract -- | Increase or decrease the number of transactions of an address.-modAddressCount :: Bool -> Address -> ImportM ()-modAddressCount add a = do- b <- getDefaultBalance a- setBalance b {txs = f b.txs}+modAddressCount :: Writer -> Bool -> Address -> IO ()+modAddressCount wr add a = do+ b <- getDefaultBalance wr a+ setBalance wr b {txs = f b.txs} where f | add = (+ 1)@@ -960,5 +998,5 @@ prevOuts tx = filter (/= nullOutPoint) (map (.outpoint) tx.inputs) -- | Whether a transaction is in the database and not deleted.-testPresent :: (StoreReadBase m) => Tx -> m Bool-testPresent tx = isJust <$> getActiveTxData (txHash tx)+testPresent :: Writer -> Tx -> IO Bool+testPresent wr tx = isJust <$> getActiveTxData wr (txHash tx)
@@ -0,0 +1,792 @@+{-# LANGUAGE RecordWildCards #-}+{-# OPTIONS_GHC -Wno-ambiguous-fields #-}++-- |+-- Module : Haskoin.Store.Logic.Layered+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- Rules to import and revert blocks and transactions in the layered layout+-- of the database (@docs\/layered-database.md@), in which the mempool is+-- kept apart from the chain. These are the rules that the block store+-- imports with. They do what "Haskoin.Store.Logic" did for the layout of+-- version 20, with the same functions, the same results and the same+-- errors, which is how the block store moved from one to the other, and+-- how the tests compare the two.+--+-- = Two sets of rules+--+-- __The chain__ has one door. 'importBlock' and 'revertBlock' are the only+-- functions here that write to the column families of the chain, and what+-- they write depends on the block and on those column families alone. A+-- block is imported the same way whether or not the store had seen its+-- transactions before, as during the initial sync: every output it spends+-- must be unspent in the chain, or the block is refused.+--+-- __The mempool__ is a set of transactions, each with its keys in the+-- mempool area ('mempoolRecords'). A transaction enters by having its keys+-- written and leaves by having them deleted; nothing else is read or+-- changed, and nothing is written to the chain. What is left of the rules+-- is the checks: that the outputs a transaction spends exist and are+-- unspent, in the chain or in the mempool, and what it replaces.+--+-- The two meet in three places. A block takes the transactions it confirms+-- out of the mempool, and deletes those that conflict with it. A reverted+-- block gives its transactions back to the mempool. And a mempool+-- transaction may spend outputs of the chain, which the chain goes on+-- listing as unspent: only the mempool area says otherwise.+--+-- = Same answers as version 20+--+-- For any sequence of blocks and transactions, these rules leave the+-- database that "Haskoin.Store.Logic" followed by+-- "Haskoin.Store.Database.Migrate" leaves, key for key, and each step gives+-- the same result. The tests check that for random sequences. There is one+-- difference by design. A block that spends an output of an unconfirmed+-- transaction is refused here, because that output is not in the chain;+-- version 20 imports it, since its indices do not tell the two apart.+module Haskoin.Store.Logic.Layered+ ( initBest,+ revertBlock,+ importBlock,+ newMempoolTx,+ deleteUnconfirmedTx,+ )+where++import Control.Monad+ ( forM,+ forM_,+ guard,+ unless,+ void,+ when,+ zipWithM_,+ )+import Data.ByteString qualified as B+import Data.Either (rights)+import Data.Functor ((<&>))+import Data.HashMap.Strict qualified as M+import Data.HashSet qualified as H+import Data.IntMap.Strict qualified as I+import Data.List (nub)+import Data.Maybe+ ( catMaybes,+ fromMaybe,+ isJust,+ isNothing,+ mapMaybe,+ )+import Data.Serialize (encode)+import Data.Text (Text)+import Data.Word (Word32, Word64)+import Haskoin+ ( Address,+ Block (..),+ BlockHash,+ BlockHeader (..),+ BlockNode (..),+ Ctx,+ Network (..),+ OutPoint (..),+ Tx (..),+ TxHash,+ TxIn (..),+ TxOut (..),+ blockHashToHex,+ computeSubsidy,+ eitherToMaybe,+ genesisBlock,+ genesisNode,+ headerHash,+ isGenesis,+ nullOutPoint,+ scriptToAddressBS,+ txHash,+ txHashToHex,+ )+import Haskoin.Node (LogLevel (..), logAt)+import Haskoin.Store.Common (nub', sortTxs)+import Haskoin.Store.Data+import Haskoin.Store.Database.Mempool (MemRecords (..), mempoolRecords)+import Haskoin.Store.Database.Types (MemAddrKey (..), MemAddrVal (..), MemSummary (..))+import Haskoin.Store.Database.Writer+import Haskoin.Store.Logic (ImportException (..))+import UnliftIO (throwIO)++-------------------------------------------------------------------------------+-- Reading+-------------------------------------------------------------------------------++-- | Log a message with the logger of the writer.+logDebug, logError :: Writer -> Text -> IO ()+logDebug wr = logAt LevelDebug (writerLogger wr)+logError wr = logAt LevelError (writerLogger wr)++-- | Address of an output script, if it has one on the network.+scriptAddress :: Writer -> B.ByteString -> Maybe Address+scriptAddress wr = eitherToMaybe . scriptToAddressBS (writerNet wr) (writerCtx wr)++-- | Data of a confirmed transaction, with the spenders of its outputs that+-- are in blocks. Version 20 kept the bodies of deleted transactions in the+-- same place; they do not count.+chainTx :: Writer -> TxHash -> IO (Maybe TxData)+chainTx wr h =+ getTxData wr h <&> \case+ Just td | not td.deleted -> Just td+ _ -> Nothing++-- | Data of a transaction that is in the mempool or in the chain, without+-- the mempool spenders of its outputs.+activeBody :: Writer -> TxHash -> IO (Maybe TxData)+activeBody wr h = getMempoolTx wr h >>= maybe (chainTx wr h) (return . Just)++-- | Data of a transaction that is in the mempool or in the chain, with every+-- spender of its outputs, in blocks and in the mempool.+activeTx :: Writer -> TxHash -> IO (Maybe TxData)+activeTx wr h = activeBody wr h >>= mapM spent+ where+ spent td = do+ ms <- getMempoolSpenders wr h (length td.tx.outputs)+ -- those of blocks are under keys of their own, and only a confirmed+ -- transaction can have any+ cs <- if confirmed td.block then getChainSpenders wr h (length td.tx.outputs) else return I.empty+ return (td :: TxData) {spenders = I.union cs ms}++-- | An output that a new mempool transaction may spend: one that the chain+-- has as unspent, or one of a mempool transaction, that no mempool+-- transaction spends.+spendable :: Writer -> OutPoint -> IO (Maybe Unspent)+spendable wr p =+ getMempoolSpender wr p >>= \case+ Just _ -> return Nothing+ Nothing ->+ getUnspent wr p >>= \case+ Just u -> return (Just u)+ Nothing -> (>>= output) <$> getMempoolTx wr p.hash+ where+ output td = unspentOutput wr td.block p <$> getTxOut p.index td.tx++-- | An output as an unspent output, given the position of its transaction.+unspentOutput :: Writer -> BlockRef -> OutPoint -> TxOut -> Unspent+unspentOutput wr br p o =+ Unspent+ { block = br,+ outpoint = p,+ value = o.value,+ script = o.script,+ address = scriptAddress wr o.script+ }++-- | Input that spends an output, in the mempool or in a block.+spenderOf :: Writer -> OutPoint -> IO (Maybe Spender)+spenderOf wr p = getMempoolSpender wr p >>= maybe (getChainSpender wr p) (return . Just)++-- | Data of the best block. Throws 'BestBlockNotFound' if there is none.+bestBlockData :: Writer -> IO BlockData+bestBlockData wr = do+ h <-+ getBestBlock wr >>= \case+ Nothing -> do+ logError wr "Best block unknown"+ throwIO BestBlockNotFound+ Just h -> return h+ getBlock wr h >>= \case+ Nothing -> do+ logError wr "Best block not found"+ throwIO BestBlockNotFound+ Just b -> return b++-------------------------------------------------------------------------------+-- Blocks+-------------------------------------------------------------------------------++-- | Import the genesis block if the database has no best block yet.+initBest :: Writer -> IO ()+initBest wr = do+ logDebug wr "Initializing best block"+ let net = writerNet wr+ m <- getBestBlock wr+ when (isNothing m) . void $ do+ logDebug wr "Importing Genesis block"+ importBlock wr (genesisBlock net) (genesisNode net)++-- | Check that a block builds on the best block, or that it is the genesis block+-- of an empty database.+checkNewBlock :: Writer -> Block -> BlockNode -> IO ()+checkNewBlock wr b n =+ getBestBlock wr >>= \case+ Nothing+ | isGenesis n -> return ()+ | otherwise -> do+ logError wr $ "Cannot import non-genesis block: " <> blockHashToHex (headerHash b.header)+ throwIO BestBlockNotFound+ Just h+ | b.header.prev == h -> return ()+ | otherwise -> do+ logError wr $ "Block does not build on head: " <> blockHashToHex (headerHash b.header)+ throwIO PrevBlockNotBest++-- | Import a block that builds on the best block, and make it the best block.+-- Returns its data and the data of its transactions.+--+-- The block goes into the chain's column families as it would into those of+-- a store with an empty mempool. Before that, the mempool gives way: the+-- transactions of the block that were in it leave it, and the mempool+-- transactions that spend an output that the block spends are deleted, with+-- their descendants.+importBlock :: Writer -> Block -> BlockNode -> IO (BlockData, [TxData])+importBlock wr b n = do+ checkNewBlock wr b n+ let net = writerNet wr+ subsidy = computeSubsidy net n.height+ bs <- getBlocksAtHeight wr n.height+ setBlocksAtHeight wr (nub (headerHash n.header : bs)) n.height+ setBest wr (headerHash n.header)+ tds <- importTxs wr n b.txs+ let bd =+ BlockData+ { height = n.height,+ main = True,+ work = n.work,+ header = n.header,+ size = fromIntegral (B.length (encode b)),+ txs = map txHash b.txs,+ weight = if net.segWit then w else 0,+ subsidy = subsidy,+ fee = sum $ map txDataFee tds,+ outputs = ts_out_val+ }+ insertBlock wr bd+ logDebug wr $ "Finished importing block: " <> blockHashToHex (headerHash n.header)+ return (bd, tds)+ where+ ts_out_val =+ sum $ map (sum . map (.value) . (.outputs)) (drop 1 b.txs)+ w =+ let f t = (t :: Tx) {witness = []}+ b' = (b :: Block) {txs = map f b.txs}+ x = B.length (encode b)+ s = B.length (encode b')+ in fromIntegral $ s * 3 + x++-- | Import the transactions of a block, in dependency order, after deleting+-- the mempool transactions that conflict with them.+importTxs :: Writer -> BlockNode -> [Tx] -> IO [TxData]+importTxs wr bn txns = do+ mapM_ (freeOutputs . snd) (reverse txs)+ mapM (uncurry action) txs+ where+ txs = sortTxs txns+ br i = BlockRef {height = bn.height, position = i}+ blockTime = fromIntegral bn.header.timestamp+ freeOutputs tx = getConflicts wr True False tx >>= mapM_ (deleteSingleTx wr . txHash)+ action i tx =+ getMempoolTx wr (txHash tx) >>= \case+ Just td -> do+ -- it leaves the mempool, and with it the time at which this+ -- store first saw it: in the chain its time is that of its+ -- block, like that of a transaction that was never in the+ -- mempool, so that the record is the same on every store+ removeMempoolTx wr td+ td' <- importChainTx wr (br i) blockTime tx+ updateSpendersRBF wr tx+ return td'+ Nothing ->+ chainTx wr (txHash tx) >>= \case+ Just td -> moveChainTx wr td (br i) blockTime+ Nothing -> importChainTx wr (br i) blockTime tx++-- | Import a transaction of a block into the chain, with the time of the+-- block. Throws 'Orphan' if an output that it spends is not unspent in the+-- chain.+--+-- = Why the time of the block+--+-- What the chain's column families hold is to follow from the chain and+-- from nothing else: the same bytes on every store that has the same+-- blocks, whatever each of them saw before. The time of a transaction was+-- the one thing in them that did not. A store kept the time at which it+-- first saw a transaction in its mempool when a block confirmed it, so two+-- stores had two times for it, a store that synced later had the time of+-- the block, and the stores of a fleet gave different answers about the+-- same confirmed transaction. The time of first sight is still what a+-- transaction has while it is in the mempool, where it is the store's own,+-- or the pool's in a fleet.+importChainTx :: Writer -> BlockRef -> Word64 -> Tx -> IO TxData+importChainTx wr br time tx = do+ us <- forM (prevOuts tx) $ \p ->+ getUnspent wr p >>= \case+ Just u -> return u+ Nothing -> do+ logError wr $ "Attempted to import a tx missing UTXO: " <> txHashToHex (txHash tx)+ throwIO Orphan+ let td = prepareTxData False br time tx us+ addChainTx wr td+ return td++-- | Give a confirmed transaction a new position in the chain: a block has a+-- transaction that is already confirmed. Only two coinbases of 2010 are, on+-- Bitcoin; the later one takes the place of the earlier, with the outputs+-- that are still unspent, as in the layout of version 20, and the time of+-- the later block.+moveChainTx :: Writer -> TxData -> BlockRef -> Word64 -> IO TxData+moveChainTx wr t new time = do+ forM_ (txDataAddresses wr t) $ \a -> do+ deleteAddrTx wr a TxRef {block = t.block, txid = h}+ insertAddrTx wr a TxRef {block = new, txid = h}+ forM_ (zip [0 ..] t.tx.outputs) $ \(i, _) ->+ getUnspent wr (OutPoint h i) >>= mapM_ move+ let td = (t :: TxData) {block = new, timestamp = time}+ insertTx wr td+ return td+ where+ h = txHash t.tx+ move u = do+ let u' = (u :: Unspent) {block = new}+ insertUnspent wr u'+ forM_ u.address $ \a -> deleteAddrUnspent wr a u >> insertAddrUnspent wr a u'++-- | Revert the best block: make its parent the best block, mark it as not in+-- the main chain, take its transactions out of the chain and give them to+-- the mempool, and delete its coinbase with whatever spends it. Throws+-- 'BlockNotBest' if the block is not the best block.+revertBlock :: Writer -> BlockHash -> IO ()+revertBlock wr bh = do+ bd <-+ bestBlockData wr >>= \b ->+ if headerHash b.header == bh+ then return b+ else do+ logError wr $ "Cannot revert non-head block: " <> blockHashToHex bh+ throwIO BlockNotBest+ tds <- mapM inChain bd.txs+ setBest wr bd.header.prev+ insertBlock wr bd {main = False}+ case tds of+ [] -> return ()+ coinbase : rest -> do+ let returned = M.fromList [(txHash td.tx, td) | td <- rest]+ -- the transactions that go back to the mempool, each after the+ -- transactions that it spends+ ordered = filter ((`M.member` returned) . txHash) (map snd (sortTxs (map (.tx) tds)))+ -- out of the chain, each after the transactions that spend it+ forM_ (reverse ordered) (leave . txHash)+ -- into the mempool, at the time that each had in the chain, which is+ -- that of the block: the time at which a store first saw it is not+ -- kept past its confirmation, and every store gives it the same+ -- time this way. The coinbase is still in the chain: a transaction that spends it has all its+ -- parents while its flag is worked out, as in the layout of version 20+ forM_ (mapMaybe ((`M.lookup` returned) . txHash) ordered) $ \td -> do+ rbf <- isRBF wr td.tx+ addMempoolTx wr td.timestamp (td :: TxData) {rbf = rbf}+ updateSpendersRBF wr td.tx+ -- a coinbase cannot be in the mempool: it is deleted, and so is+ -- everything that spends it+ spenders <- getMempoolSpenders wr (txHash coinbase.tx) (length coinbase.tx.outputs)+ forM_ (nub' (map (.txid) (I.elems spenders))) $ \h ->+ getChain wr True False h >>= mapM_ (deleteSingleTx wr . txHash)+ -- and forgotten: nothing is kept of a transaction that leaves the+ -- store unconfirmed+ leave (txHash coinbase.tx)+ where+ inChain h =+ chainTx wr h >>= \case+ Just td -> return td+ Nothing -> do+ logDebug wr $ "Tx not found: " <> txHashToHex h+ throwIO TxNotFound+ -- take a transaction that nothing in the chain spends out of the chain+ leave h = do+ td <- inChain h+ spent <- getChainSpenders wr h (length td.tx.outputs)+ unless (I.null spent) $ do+ logError wr $ "Tried to revert spent tx: " <> txHashToHex h+ throwIO TxSpent+ removeChainTx wr td++-------------------------------------------------------------------------------+-- The chain's column families+-------------------------------------------------------------------------------++-- | Data of a new transaction, from its replace-by-fee flag, position, time and+-- the outputs it spends.+prepareTxData :: Bool -> BlockRef -> Word64 -> Tx -> [Unspent] -> TxData+prepareTxData rbf br tt tx us =+ TxData+ { block = br,+ tx = tx,+ prevs = ps,+ deleted = False,+ rbf = rbf,+ timestamp = tt,+ spenders = I.empty+ }+ where+ mkprv Unspent {..} = Prev script value+ ps = I.fromList $ zip [0 ..] $ map mkprv us++-- | Change the balance of an address in the chain. An address whose balance+-- goes back to nothing, as when the only block that paid it is reverted, has+-- none.+modifyBalance :: Writer -> Address -> (Balance -> Balance) -> IO ()+modifyBalance wr a f = do+ b <- f . fromMaybe (zeroBalance a) <$> getBalance wr a+ if nullBalance b then deleteBalance wr a else setBalance wr b++-- | Subtract, or stop: no count or amount of the chain goes below zero.+less :: String -> Word64 -> Word64 -> Word64+less what x y+ | y > x = error $ "The " <> what <> " of an address would go below zero"+ | otherwise = x - y++-- | Add a confirmed transaction to every index of the chain. Every output+-- that it spends must be unspent in the chain.+addChainTx :: Writer -> TxData -> IO ()+addChainTx wr td = do+ forM_ (txDataAddresses wr td) $ \a -> do+ insertAddrTx wr a TxRef {block = td.block, txid = h}+ modifyBalance wr a $ \b -> (b :: Balance) {txs = b.txs + 1}+ forM_ (zip [0 ..] td.tx.outputs) $ \(i, o) -> do+ let u = unspentOutput wr td.block (OutPoint h i) o+ insertUnspent wr u+ forM_ u.address $ \a -> do+ insertAddrUnspent wr a u+ modifyBalance wr a $ \b ->+ (b :: Balance)+ { confirmed = b.confirmed + o.value,+ utxo = b.utxo + 1,+ received = b.received + o.value+ }+ zipWithM_ spend [0 ..] (prevOuts td.tx)+ insertTx wr td+ where+ h = txHash td.tx+ spend ix p = do+ u <-+ getUnspent wr p >>= \case+ Just u -> return u+ Nothing -> error $ "Could not find UTXO to spend: " <> show p+ deleteUnspent wr p+ -- under the key of the output: the record of the transaction that+ -- made it is written once, when it is confirmed, and is not read+ -- here either+ insertSpender wr p (Spender h ix)+ forM_ u.address $ \a -> do+ deleteAddrUnspent wr a u+ modifyBalance wr a $ \b ->+ (b :: Balance)+ { confirmed = less "confirmed balance" b.confirmed u.value,+ utxo = less "count of unspent outputs" b.utxo 1+ }++-- | Take a confirmed transaction that nothing in the chain spends out of+-- every index of the chain, and make the outputs that it spent unspent+-- again.+removeChainTx :: Writer -> TxData -> IO ()+removeChainTx wr td = do+ forM_ (txDataAddresses wr td) $ \a -> do+ deleteAddrTx wr a TxRef {block = td.block, txid = h}+ modifyBalance wr a $ \b -> (b :: Balance) {txs = less "count of transactions" b.txs 1}+ forM_ (zip [0 ..] td.tx.outputs) $ \(i, o) -> do+ let p = OutPoint h i+ u = unspentOutput wr td.block p o+ deleteUnspent wr p+ forM_ u.address $ \a -> do+ deleteAddrUnspent wr a u+ modifyBalance wr a $ \b ->+ (b :: Balance)+ { confirmed = less "confirmed balance" b.confirmed o.value,+ utxo = less "count of unspent outputs" b.utxo 1,+ received = less "amount received" b.received o.value+ }+ forM_ (prevOuts td.tx) $ \p -> do+ parent <-+ chainTx wr p.hash >>= \case+ Just t -> return t+ Nothing -> error $ "Could not find tx data: " <> show p.hash+ let o = fromMaybe (error ("Could not find output: " <> show p)) (getTxOut p.index parent.tx)+ u = unspentOutput wr parent.block p o+ deleteSpender wr p+ insertUnspent wr u+ forM_ u.address $ \a -> do+ insertAddrUnspent wr a u+ modifyBalance wr a $ \b -> (b :: Balance) {confirmed = b.confirmed + o.value, utxo = b.utxo + 1}+ deleteTxData wr h+ where+ h = txHash td.tx++-------------------------------------------------------------------------------+-- The mempool area+-------------------------------------------------------------------------------++-- | Import a transaction into the mempool with the time it arrived. Returns+-- 'False' if it is already known.+--+-- Conflicting unconfirmed transactions are deleted together with their+-- descendants if the network has replace-by-fee; otherwise the transaction is+-- rejected with 'DoubleSpend'. Throws an 'ImportException' if the transaction is+-- invalid, for example if it spends unknown outputs.+newMempoolTx :: Writer -> Tx -> UnixTime -> IO Bool+newMempoolTx wr tx w =+ activeBody wr (txHash tx) >>= \case+ Just _ -> return False+ Nothing -> do+ -- Check everything before writing, so that a rejected transaction+ -- leaves the mempool as it was.+ replaced <- getConflicts wr True True tx+ checkNewTx wr (map txHash replaced) tx+ mapM_ (deleteSingleTx wr . txHash) replaced+ rbf <- isRBF wr tx+ us <- forM (prevOuts tx) $ \p ->+ spendable wr p >>= \case+ Just u -> return u+ Nothing -> do+ logError wr $ "Attempted to import a tx missing UTXO: " <> txHashToHex (txHash tx)+ throwIO Orphan+ addMempoolTx wr w (prepareTxData rbf (MemRef w) w tx us)+ return True++-- | Delete an unconfirmed transaction and its descendants. If the flag is set and+-- the network has no replace-by-fee, throw 'DoubleSpend' instead. Does nothing if+-- the transaction is not found.+deleteUnconfirmedTx :: Writer -> Bool -> TxHash -> IO ()+deleteUnconfirmedTx wr rbfcheck th =+ activeBody wr th >>= \case+ Just _ -> getChain wr True rbfcheck th >>= mapM_ (deleteSingleTx wr . txHash)+ Nothing -> logDebug wr $ "Not found or already deleted: " <> txHashToHex th++-- | Write the keys of a transaction to the mempool area, with the time it+-- entered the mempool. The body goes in at that mempool position and without+-- spenders, whatever it had.+addMempoolTx :: Writer -> UnixTime -> TxData -> IO ()+addMempoolTx wr time td = do+ insertMempoolTx wr r.body+ forM_ r.spends (uncurry (insertMempoolSpender wr))+ forM_ r.addresses (uncurry (insertMempoolAddr wr))+ adjustSummaries wr (+) r+ addToMempool wr r.txid r.time+ where+ r = mempoolRecords (writerNet wr) (writerCtx wr) time td++-- | Delete the keys of a mempool transaction, given its body, from the+-- mempool area.+removeMempoolTx :: Writer -> TxData -> IO ()+removeMempoolTx wr td = do+ deleteMempoolTx wr r.txid+ forM_ r.spends (deleteMempoolSpender wr . fst)+ forM_ r.addresses (deleteMempoolAddr wr . fst)+ adjustSummaries wr (less "mempool summary") r+ deleteFromMempool wr r.txid+ where+ time = case td.block of+ MemRef t -> t+ BlockRef {} -> td.timestamp+ r = mempoolRecords (writerNet wr) (writerCtx wr) time td++-- | Add what a transaction does at each address it touches to the summary+-- of the address, or take it off again, given how to combine a number of+-- the summary with one of the transaction.+--+-- Everything is in the entries of the transaction but one thing: whether an+-- output that it spends was made by the mempool, and whether an output that+-- it makes is spent by the mempool, which is what 'both' counts. Each is a+-- read by key of what another transaction wrote. An output is counted in+-- 'both' while both of its transactions are in the mempool, so it is added+-- by the second of them to arrive and taken off by the first to leave,+-- whichever that is.+adjustSummaries :: Writer -> (Word64 -> Word64 -> Word64) -> MemRecords -> IO ()+adjustSummaries wr op r = do+ deltas <- forM r.addresses $ \case+ (MemAddrTxKey a _ _, _) -> return (a, MemSummary 1 0 0 0 0 0 0)+ (MemAddrSpentKey a p, MemAddrSpentVal v) -> do+ made <- inMempool wr p.hash+ return (a, if made then MemSummary 0 0 0 v 1 v 1 else MemSummary 0 0 0 v 1 0 0)+ (MemAddrOutKey a _ p, MemAddrOutVal v _) -> do+ taken <- isJust <$> getMempoolSpender wr p+ return (a, if taken then MemSummary 0 v 1 0 0 v 1 else MemSummary 0 v 1 0 0 0 0)+ (k, _) -> error ("Not an entry of a transaction: " <> show k)+ forM_ (M.toList (M.fromListWith plus deltas)) $ \(a, d) -> do+ old <-+ getMempoolAddr wr (MemAddrSumKey a) <&> \case+ Just (MemAddrSumVal s) -> s+ _ -> zero+ let new = combine op old d+ if new == zero+ then deleteMempoolAddr wr (MemAddrSumKey a)+ else insertMempoolAddr wr (MemAddrSumKey a) (MemAddrSumVal new)+ where+ zero = MemSummary 0 0 0 0 0 0 0+ plus = combine (+)+ combine f x y =+ MemSummary+ { txs = f x.txs y.txs,+ paid = f x.paid y.paid,+ paidCount = f x.paidCount y.paidCount,+ spent = f x.spent y.spent,+ spentCount = f x.spentCount y.spentCount,+ both = f x.both y.both,+ bothCount = f x.bothCount y.bothCount+ }++-- | Delete a mempool transaction that nothing spends. Nothing is kept of+-- it. Throws 'TxSpent' if something spends it.+--+-- Until version 22 its body was kept, marked as deleted, and the API+-- returned it with that mark. Clients that did not read the mark took a+-- replaced transaction for a payment, each store had its own set of them,+-- which a fleet cannot answer from, and nothing ever removed them. What+-- the store needs of them, not to fetch a transaction again that it just+-- deleted, it remembers for a while in memory+-- ("Haskoin.Store.BlockStore").+deleteSingleTx :: Writer -> TxHash -> IO ()+deleteSingleTx wr th =+ getMempoolTx wr th >>= \case+ Nothing -> do+ logError wr $ "Already deleted: " <> txHashToHex th+ throwIO TxNotFound+ Just td -> do+ spenders <- getMempoolSpenders wr th (length td.tx.outputs)+ if I.null spenders+ then do+ logDebug wr $ "Deleting tx: " <> txHashToHex th+ removeMempoolTx wr td+ else do+ logError wr $ "Tried to delete spent tx: " <> txHashToHex th+ throwIO TxSpent++-- | Check a transaction that will replace these ones. Outputs that they spend+-- count as unspent, and outputs that they create as missing.+checkNewTx :: Writer -> [TxHash] -> Tx -> IO ()+checkNewTx wr replaced tx = do+ when (unique_inputs < length tx.inputs) $ do+ logError wr $ "Transaction spends same output twice: " <> txHashToHex (txHash tx)+ throwIO DuplicatePrevOutput+ prevs <- mapM prev_output (prevOuts tx)+ when (any isNothing prevs) $ do+ logError wr $ "Orphan: " <> txHashToHex (txHash tx)+ throwIO Orphan+ when (isCoinbaseTx tx) $ do+ logError wr $ "Coinbase cannot be imported into mempool: " <> txHashToHex (txHash tx)+ throwIO UnexpectedCoinbase+ when (outputs > sum (map (.value) (catMaybes prevs))) $ do+ logError wr $ "Insufficient funds for tx: " <> txHashToHex (txHash tx)+ throwIO InsufficientFunds+ where+ prev_output op+ | op.hash `elem` replaced = return Nothing+ | otherwise = (>>= getTxOut op.index . (.tx)) <$> activeBody wr op.hash+ outputs = sum (map (.value) tx.outputs)+ unique_inputs = length (nub' (map (.outpoint) tx.inputs))++-- | Transactions that spend the same outputs as this one, with their+-- descendants, in an order in which they can be deleted. The first flag+-- refuses confirmed transactions, and the second refuses any on networks+-- without replace-by-fee.+getConflicts :: Writer -> Bool -> Bool -> Tx -> IO [Tx]+getConflicts wr memonly rbfcheck tx = do+ let prevs = prevOuts tx+ unspents <- mapM (spendable wr) prevs+ let spents = [p | (p, Nothing) <- zip prevs unspents]+ spndrs <- catMaybes <$> mapM (spenderOf wr) spents+ let txids = H.fromList $ filter (/= txHash tx) $ map (.txid) spndrs+ chains <- mapM (getChain wr memonly rbfcheck) (H.toList txids)+ return . reverse . map snd . sortTxs . M.elems $+ M.fromList [(txHash t, t) | t <- concat chains]++-- | A transaction and all its descendants, in an order in which they can be+-- deleted: spenders first.+getChain :: Writer -> Bool -> Bool -> TxHash -> IO [Tx]+getChain wr memonly rbfcheck th' =+ sort_clean <$> go M.empty (H.singleton th')+ where+ sort_clean = reverse . map snd . sortTxs+ get_tx th =+ activeTx wr th >>= \case+ Nothing -> do+ logDebug wr $ "Transaction not found: " <> txHashToHex th+ return Nothing+ Just td+ | memonly && confirmed td.block -> do+ logError wr $ "Transaction already confirmed: " <> txHashToHex th+ throwIO TxConfirmed+ | rbfcheck ->+ if (writerNet wr).replaceByFee+ then return $ Just td+ else do+ logError wr $ "Double-spending transaction: " <> txHashToHex th+ throwIO DoubleSpend+ | otherwise -> return $ Just td+ go txm pdg = do+ let ths = filter (not . (`M.member` txm)) (H.toList pdg)+ tds <- catMaybes <$> mapM get_tx ths+ let txmn = M.fromList $ fmap (\d -> (txHash d.tx, d.tx)) tds+ spds = concatMap (map (.txid) . I.elems . (.spenders)) tds+ pdg' = H.fromList spds+ txm' = txmn <> txm+ if H.null pdg'+ then return $ M.elems txm'+ else go txm' pdg'++-- | Whether a mempool transaction can be replaced by fee: it signals BIP125+-- in one of its inputs, or it spends an unconfirmed transaction that can be+-- replaced. Always 'False' on networks without replace-by-fee.+isRBF :: Writer -> Tx -> IO Bool+isRBF wr tx+ | not (writerNet wr).replaceByFee = return False+ | any ((< 0xffffffff - 1) . (.sequence)) tx.inputs = return True+ | otherwise = ck (nub' $ map (.outpoint.hash) tx.inputs)+ where+ ck [] = return False+ ck (h : hs') =+ activeBody wr h >>= \case+ Nothing -> return False+ Just t+ | confirmed t.block -> ck hs'+ | t.rbf -> return True+ | otherwise -> ck hs'++-- | Mempool transactions inherit BIP125 signalling from unconfirmed parents+-- only, so their flag can change when a parent is confirmed or goes back to+-- the mempool. Correct the flags of the mempool transactions that spend the+-- outputs of a transaction, and of theirs in turn.+updateSpendersRBF :: Writer -> Tx -> IO ()+updateSpendersRBF wr tx = do+ spenders <- getMempoolSpenders wr (txHash tx) (length tx.outputs)+ forM_ (nub' (map (.txid) (I.elems spenders))) $ \h ->+ getMempoolTx wr h >>= mapM_ update+ where+ update s = do+ rbf <- isRBF wr s.tx+ when (rbf /= s.rbf) $ do+ insertMempoolTx wr (s :: TxData) {rbf = rbf}+ updateSpendersRBF wr s.tx++-------------------------------------------------------------------------------+-- Transactions+-------------------------------------------------------------------------------++-- | Output of a transaction at an index, or 'Nothing' if out of range.+getTxOut :: Word32 -> Tx -> Maybe TxOut+getTxOut i tx = do+ guard (fromIntegral i < length tx.outputs)+ return $ tx.outputs !! fromIntegral i++-- | Addresses that a transaction is indexed under: those of its outputs and+-- of the outputs that it spends. An output script counts only when it has+-- an address on the network.+txDataAddresses :: Writer -> TxData -> [Address]+txDataAddresses wr t =+ nub' . rights $+ map (scriptToAddressBS net ctx . (.script)) (I.elems t.prevs)+ <> map (scriptToAddressBS net ctx . (.script)) t.tx.outputs+ where+ net = writerNet wr :: Network+ ctx = writerCtx wr :: Ctx++-- | Outputs spent by a transaction, without the null outpoint of a coinbase.+prevOuts :: Tx -> [OutPoint]+prevOuts tx = filter (/= nullOutPoint) (map (.outpoint) tx.inputs)
@@ -1,10 +1,3 @@-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE NamedFieldPuns #-}-{-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE NoFieldSelectors #-}- -- | -- Module : Haskoin.Store.Manager -- Copyright : (c) Haskoin Developers@@ -15,11 +8,12 @@ -- -- Wires the components of a store together: the RocksDB database, the -- peer-to-peer node, the block store that imports data, and the optional Redis--- cache with its writer process. Node events are forwarded to the block store--- and turned into 'StoreEvent's.+-- cache. Node events are forwarded to the block store and turned into+-- 'StoreEvent's, which the cache follows.+-- -- Keeping the wiring in one place means each component only knows about the--- interfaces it uses: the block store does not know about the cache, and the--- node does not know about either. 'withStore' starts them in dependency order+-- interfaces it uses: the node does not know about the block store, and the+-- block store does not know about the cache. 'withStore' starts them in dependency order -- and stops them all together. module Haskoin.Store.Manager ( StoreConfig (..),@@ -28,15 +22,14 @@ ) where -import Control.Monad (unless)-import Control.Monad.Cont- ( ContT (..),- runContT,- )-import Control.Monad.Reader (ReaderT (ReaderT), runReaderT)-import Control.Monad.Trans (lift)+import Control.Monad (forM_, unless, when)+import Data.ByteString (ByteString)+import Data.ByteString.Char8 qualified as C+import Data.Serialize (Serialize, decode)+import Data.String.Conversions (cs) import Data.Time.Clock (NominalDiffTime)-import Data.Word (Word32)+import Data.Word (Word32, Word64)+import Database.Redis qualified as Redis import Haskoin ( BlockHash (..), Ctx,@@ -57,6 +50,7 @@ import Haskoin.Node ( Chain, ChainEvent (..),+ LogLevel (..), Logger, Node (..), NodeConfig (..),@@ -64,6 +58,7 @@ PeerEvent (..), PeerMgr, WithConnection,+ logAt, withNode, withSource, )@@ -79,48 +74,46 @@ blockStoreTxSTM, withBlockStore, )-import Haskoin.Store.Cache- ( CacheConfig (..),- CacheWriter,- cacheNewBlock,- cacheNewTx,- cacheSyncMempool,- cacheWriter,- connectRedis,- newCacheMetrics,- )+import Haskoin.Store.Cache (Cache, CacheConfig (..), cachePrefix, withCache) import Haskoin.Store.Common ( StoreEvent (..), loopForever, )+import Haskoin.Store.Database.Migrate (Migration (..), upgradeDatabase) import Haskoin.Store.Database.Reader ( DatabaseReader (..),- DatabaseReaderT, createDataMetrics,- withDatabaseReader,+ withLayeredReader, )+import Haskoin.Store.Fleet.Member+ ( Member,+ MemberConfig (..),+ memberCacheLoop,+ memberEvents,+ memberForget,+ memberLoop,+ newMember,+ noteBest,+ )+import Haskoin.Store.Fleet.Redis (FleetEvent (..)) import NQE ( Inbox,- Process (..), Publisher,+ publish, publishSTM, receive,- withProcess, withPublisher, withSubscription, ) import Network.Socket (SockAddr (..)) import System.Metrics.StatsD+import System.Random (randomIO) import UnliftIO- ( MonadIO,- MonadUnliftIO,- STM,+ ( STM, atomically,- liftIO, link, withAsync, )-import UnliftIO.Concurrent (threadDelay) -- | Store mailboxes. Handles to the running components of a store, given by -- 'withStore'.@@ -133,8 +126,17 @@ block :: !BlockStore, -- | database reader db :: !DatabaseReader,- -- | cache configuration, if Redis is configured- cache :: !(Maybe CacheConfig),+ -- | cache of extended public keys, if Redis is configured and the store+ -- is not a member of a fleet+ cache :: !(Maybe Cache),+ -- | the store as a member of a fleet, if it is one+ fleet :: !(Maybe Member),+ -- | what the store tells its clients: new best blocks and changes of+ -- the mempool. For a store alone they are its own, those that the+ -- block store publishes.+ -- For a member of a fleet they are those of the fleet, read from the+ -- Redis that it shares, so that every server tells the same.+ events :: !(Publisher StoreEvent), -- | store events are published here pub :: !(Publisher StoreEvent), -- | network constants@@ -155,7 +157,8 @@ db :: !FilePath, -- | network constants net :: !Network,- -- | Redis cache configuration+ -- | Redis URL for the cache of extended public keys ("Haskoin.Store.Cache");+ -- 'Nothing' for no cache, an empty URL for the local Redis redis :: !(Maybe String), -- | Secp256k1 context ctx :: !Ctx,@@ -163,14 +166,25 @@ initGap :: !Word32, -- | gap for extended public keys gap :: !Word32,- -- | cache xpubs with more than this many used addresses+ -- | cache xpubs with at least this many used addresses redisMinAddrs :: !Int,- -- | maximum number of keys in Redis cache- redisMaxKeys :: !Integer,+ -- | or with at least this many transactions in their addresses+ redisMinTxs :: !Int,+ -- | evict the least recently used xpubs from the cache past this many+ redisMaxXPubs :: !Int, -- | do not index new mempool transactions noMempool :: !Bool, -- | wipe mempool when starting wipeMempool :: !Bool,+ -- | answer as one of a fleet of stores that share the Redis of 'redis':+ -- every store of the fleet then gives the same answer to the same+ -- request, from the block that they agree on and the mempool that they+ -- share ("Haskoin.Store.Fleet.Member"). The cache of extended public+ -- keys of a store alone ("Haskoin.Store.Cache") is not used then: its+ -- entries follow each store's own database, which is what a fleet does+ -- not answer from. A fleet has one of its own+ -- ("Haskoin.Store.Fleet.XPub"), under the same three settings.+ fleet :: !Bool, -- | sync mempool from peers syncMempool :: !Bool, -- | blocks to request from the syncing peer in one batch@@ -181,13 +195,11 @@ peerTimeout :: !NominalDiffTime, -- | disconnect peer if it has been connected this long maxPeerLife :: !NominalDiffTime,- -- | connect to peers using the function 'withConnection'+ -- | connect to peers using the function 'Haskoin.Node.withConnection' connect :: !(SockAddr -> WithConnection), -- | StatsD stats :: !(Maybe Stats),- -- | sync mempool against cache every this many seconds- redisSyncInterval :: !Int,- -- | database Bloom filters+ -- | Bloom filters on the column families that are read by key bloom :: !Bool, -- | RocksDB max_open_files ('Nothing' = unlimited: every SST file's index -- and filter blocks stay resident for the life of the process)@@ -210,46 +222,151 @@ -- when the action returns, and an exception in any component is re-thrown in -- the calling thread. ----- The database is opened first, then the node, the cache (if 'redis' is set)--- and the block store.+-- The database is opened first, then the node, the block store and the cache+-- (if 'redis' is set). withStore ::- (MonadUnliftIO m) => StoreConfig ->- (Store -> m a) ->- m a+ (Store -> IO a) ->+ IO a withStore cfg action =- connectDB cfg $- ReaderT $ \db -> flip runContT return $ do- pub <- ContT withPublisher- node_pub <- ContT withPublisher- node_sub <- ContT $ withSubscription node_pub- node <- ContT $ withNode $ nodeCfg cfg db node_pub- cache_cfg <- ContT $ withCache cfg node.chain db pub- block_store <- ContT $ withBlockStore $ blockStoreCfg cfg node pub db- fwd <- ContT $ withAsync $ nodeForwarder block_store pub node_sub- link fwd- lift $- action- Store- { peerMgr = node.peerMgr,- chain = node.chain,- block = block_store,- db = db,- cache = cache_cfg,- pub = pub,- net = cfg.net,- ctx = cfg.ctx- }+ connectDB cfg $ \db ->+ withPublisher $ \pub ->+ withPublisher $ \node_pub ->+ withSubscription node_pub $ \node_sub ->+ withNode (nodeCfg cfg db node_pub) $ \node -> do+ member <- storeMember cfg db+ withBlockStore (blockStoreCfg cfg node pub db member) $ \block_store ->+ withAsync (nodeForwarder block_store pub node_sub) $ \fwd -> do+ link fwd+ withStoreFleet cfg pub member . withStoreCache cfg db node.chain pub $ \cache ->+ withFleetEvents cfg pub member $ \events ->+ action $+ Store+ { peerMgr = node.peerMgr,+ chain = node.chain,+ block = block_store,+ db = db,+ cache = cache,+ fleet = member,+ events = events,+ pub = pub,+ net = cfg.net,+ ctx = cfg.ctx+ } --- | Open the database described by the configuration and run an action with its reader.+-- | The store as a member of a fleet, if the configuration says that it is+-- one and has a Redis to share: under a name of its own, with a lease of+-- ten seconds, views of its last six blocks, and the store's limit on the+-- age of unconfirmed transactions.+storeMember :: StoreConfig -> DatabaseReader -> IO (Maybe Member)+storeMember cfg db = case (cfg.fleet, cfg.redis) of+ (True, Just url) -> do+ info <-+ if null url+ then return Redis.defaultConnectInfo+ else either fail return (Redis.parseConnectInfo url)+ conn <- Redis.checkedConnect info+ name <- C.pack . show <$> (randomIO :: IO Word64)+ Just+ <$> newMember+ MemberConfig+ { prefix = cachePrefix cfg.net,+ name,+ lease = 10000,+ depth = 6,+ expiry = fromIntegral cfg.mempoolTimeout * 24 * 3600,+ cacheMax = cfg.redisMaxXPubs,+ cacheMinAddrs = cfg.redisMinAddrs,+ cacheMinTxs = cfg.redisMinTxs,+ stats = cfg.stats,+ note = \level -> logAt level (withSource "Fleet" cfg.logger)+ }+ conn+ db+ _ -> return Nothing++-- | Take the store's turns as a member of its fleet while an action runs,+-- if it is one: every two seconds, and whenever the best block changes.+-- Beside them, in a thread of its own, the member builds the entries of+-- the fleet's cache of extended public keys that requests asked for.+withStoreFleet :: StoreConfig -> Publisher StoreEvent -> Maybe Member -> IO a -> IO a+withStoreFleet _ _ Nothing action = action+withStoreFleet cfg pub (Just member) action =+ withSubscription pub $ \sub ->+ withAsync (memberLoop member (nextBlock sub) complain) $ \a ->+ withAsync (memberCacheLoop member cacheComplain) $ \b -> link a >> link b >> action+ where+ -- until the next block, telling the member what the store deletes+ nextBlock sub =+ receive sub >>= \case+ StoreBestBlock _ -> return ()+ StoreMempoolDelete h -> memberForget member [h] >> nextBlock sub+ _ -> nextBlock sub+ complain why = logAt LevelError (withSource "Fleet" cfg.logger) ("Could not take a turn: " <> cs why)+ cacheComplain why = logAt LevelError (withSource "Fleet" cfg.logger) ("Could not cache an xpub: " <> cs why)++-- | Run an action with the events that the store tells its clients. A+-- store alone tells its own. A member of a fleet tells those of the+-- fleet, which a thread reads from Redis for as long as the action runs.+withFleetEvents ::+ StoreConfig ->+ Publisher StoreEvent ->+ Maybe Member ->+ (Publisher StoreEvent -> IO a) ->+ IO a+withFleetEvents _ pub Nothing action = action pub+withFleetEvents cfg _ (Just member) action =+ withPublisher $ \shared ->+ withAsync (memberEvents member False (tell shared) complain) $ \a -> link a >> action shared+ where+ tell shared event = forM_ (told event) (`publish` shared)+ told = \case+ FleetBlock h -> StoreBestBlock <$> hash h+ FleetEntered h -> StoreMempoolNew <$> hash h+ FleetLeft h -> StoreMempoolDelete <$> hash h+ hash :: (Serialize x) => ByteString -> Maybe x+ hash = either (const Nothing) Just . decode+ complain why = logAt LevelError (withSource "Fleet" cfg.logger) ("Could not read the events: " <> cs why)++-- | Run the cache while an action runs, if Redis is configured.+withStoreCache ::+ StoreConfig ->+ DatabaseReader ->+ Chain ->+ Publisher StoreEvent ->+ (Maybe Cache -> IO a) ->+ IO a+withStoreCache cfg db chain pub action =+ case cfg.redis of+ Nothing -> action Nothing+ Just _ | cfg.fleet -> action Nothing+ Just url ->+ withCache+ CacheConfig+ { url = url,+ minAddrs = cfg.redisMinAddrs,+ minTxs = cfg.redisMinTxs,+ maxXPubs = cfg.redisMaxXPubs,+ stats = cfg.stats+ }+ db+ chain+ pub+ (withSource "Cache" cfg.logger)+ (action . Just)++-- | Open the database described by the configuration, bring it to the+-- layout that the store runs on ('upgradeDatabase'), and run an action with+-- its reader. A database of version 20 has its mempool moved to an area of+-- its own here, once, before anything reads it; older stores refuse the+-- database from then on. connectDB ::- (MonadUnliftIO m) => StoreConfig ->- DatabaseReaderT m a ->- m a+ (DatabaseReader -> IO a) ->+ IO a connectDB cfg f = do- stats <- liftIO $ mapM createDataMetrics cfg.stats- withDatabaseReader+ stats <- mapM createDataMetrics cfg.stats+ withLayeredReader cfg.net cfg.ctx cfg.initGap@@ -261,7 +378,15 @@ cfg.maxWalSize cfg.blockCache stats- f+ $ \db -> do+ done <- upgradeDatabase (logAt LevelInfo (withSource "Database" cfg.logger)) db+ when (done.moved > 0) $+ logAt LevelInfo (withSource "Database" cfg.logger) $+ "Moved " <> cs (show done.moved) <> " mempool transactions to their own area"+ when (done.rewritten > 0) $+ logAt LevelInfo (withSource "Database" cfg.logger) $+ "Wrote the records of " <> cs (show done.rewritten) <> " confirmed transactions again, to be written once"+ f db -- | Configuration of the block store, from the store configuration. blockStoreCfg ::@@ -269,8 +394,9 @@ Node -> Publisher StoreEvent -> DatabaseReader ->+ Maybe Member -> BlockStoreConfig-blockStoreCfg cfg node pub db =+blockStoreCfg cfg node pub db member = BlockStoreConfig { chain = node.chain, peerMgr = node.peerMgr,@@ -280,6 +406,7 @@ ctx = cfg.ctx, noMempool = cfg.noMempool, wipeMempool = cfg.wipeMempool,+ onBest = maybe (\_ -> return ()) noteBest member, syncMempool = cfg.syncMempool, blockWindow = cfg.blockWindow, mempoolTimeout = cfg.mempoolTimeout,@@ -314,82 +441,12 @@ logger = cfg.logger } --- | If Redis is configured, connect to it, start the cache writer, and pass the--- cache configuration to the action. Otherwise pass 'Nothing'.-withCache ::- (MonadUnliftIO m) =>- StoreConfig ->- Chain ->- DatabaseReader ->- Publisher StoreEvent ->- (Maybe CacheConfig -> m a) ->- m a-withCache cfg chain db pub action =- case cfg.redis of- Nothing ->- action Nothing- Just redisurl -> do- conn <- connectRedis redisurl- metrics <- mapM (\s -> newCacheMetrics logger s conn db) cfg.stats- withSubscription pub $ \evts ->- let conf = c conn metrics- in withProcess (f conf) $ \p ->- cacheWriterProcesses- cfg.redisSyncInterval- evts- (getProcessMailbox p)- (action (Just conf))- where- f conf cwinbox = runReaderT (cacheWriter conf cwinbox) db- logger = withSource "Cache" cfg.logger- c conn metrics =- CacheConfig- { redis = conn,- minAddrs = cfg.redisMinAddrs,- chain = chain,- maxKeys = cfg.redisMaxKeys,- metrics = metrics,- logger = logger- }---- | Run the loop that feeds store events to the cache writer while the action runs.-cacheWriterProcesses ::- (MonadUnliftIO m) =>- Int ->- Inbox StoreEvent ->- CacheWriter ->- m a ->- m a-cacheWriterProcesses interval evts cwm action =- withAsync (cacheWriterEvents interval evts cwm) $ \a1 -> link a1 >> action---- | Forward store events to the cache writer, and ask it to sync the mempool--- every given number of seconds.-cacheWriterEvents :: (MonadUnliftIO m) => Int -> Inbox StoreEvent -> CacheWriter -> m ()-cacheWriterEvents interval evts cwm =- withAsync mempool . const $- loopForever $- receive evts >>= \e ->- e `cacheWriterDispatch` cwm- where- mempool = loopForever $ do- threadDelay (interval * 1000 * 1000)- cacheSyncMempool cwm---- | Tell the cache writer about new blocks and mempool changes.-cacheWriterDispatch :: (MonadIO m) => StoreEvent -> CacheWriter -> m ()-cacheWriterDispatch (StoreBestBlock _) = cacheNewBlock-cacheWriterDispatch (StoreMempoolNew t) = cacheNewTx t-cacheWriterDispatch (StoreMempoolDelete t) = cacheNewTx t-cacheWriterDispatch _ = const (return ())- -- | Forward every node event to the block store and the store publisher, forever. nodeForwarder ::- (MonadIO m) => BlockStore -> Publisher StoreEvent -> Inbox NodeEvent ->- m ()+ IO () nodeForwarder b pub sub = loopForever $ receive sub >>= atomically . storeDispatch b pub
@@ -1,3077 +1,3567 @@ {-# LANGUAGE BlockArguments #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE ImportQualifiedPost #-}-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE MultiWayIf #-}-{-# LANGUAGE NamedFieldPuns #-}-{-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RecordWildCards #-}-{-# LANGUAGE ScopedTypeVariables #-}-{-# LANGUAGE TupleSections #-}-{-# LANGUAGE NoFieldSelectors #-}-{-# OPTIONS_GHC -Wno-deprecations #-}---- |--- Module : Haskoin.Store.Web--- Copyright : (c) Haskoin Developers--- License : MIT--- Maintainer : jprupp@protonmail.ch--- Stability : experimental--- Portability : POSIX------ REST API server of Haskoin Store, built with Scotty.------ 'runWeb' serves the endpoints described in "Haskoin.Store.WebCommon" and a--- set of endpoints compatible with the Blockchain.info API under--- @\/blockchain\/@. Responses are JSON by default, pretty-printed with--- @?pretty=true@, or binary if the request has the header--- @Accept: application\/octet-stream@. New blocks and mempool transactions are--- streamed at @\/events@, as lines of JSON or over a WebSocket.------ Limits on the size of responses are set with 'WebLimits'. Errors are--- returned as JSON with an HTTP status that depends on the 'Except'.--- = Design of the API------ * __Two encodings of the same data.__ JSON is easy to use from anywhere. The--- binary encoding is several times smaller and faster to produce and parse, and--- is what the Haskell client uses.------ * __Limits protect the server.__ A single request for every transaction of a--- busy address could take minutes and gigabytes. 'WebLimits' caps list sizes--- and offsets, and lower caps apply to full transactions, which are much larger--- than references.------ * __Health is measured, not assumed.__ The store reports itself unhealthy when--- it lags behind the network, stops receiving blocks or transactions, has too--- few peers or a backlog of transactions. Load balancers can route around an--- instance that is still syncing or has lost its peers.------ * __Slow event clients are dropped.__ Each @\/events@ client gets a bounded--- queue. A client that falls too far behind is disconnected rather than allowed--- to make the server buffer events without limit.------ * __Compatibility endpoints ease migration.__ The @\/blockchain\/@ endpoints--- follow the Blockchain.info API so that existing wallets can switch to a--- self-hosted server with few changes.-module Haskoin.Store.Web- ( -- * Web- WebConfig (..),- Except (..),- WebLimits (..),- runWeb,-- -- * Request parsing- decodeTxBody,- )-where--import Conduit- ( ConduitT,- await,- concatMapC,- concatMapMC,- dropC,- dropWhileC,- headC,- mapC,- runConduit,- sinkList,- takeC,- takeWhileC,- yield,- (.|),- )-import Control.Applicative ((<|>))-import Control.Lens ((.~), (^.))-import Control.Monad- ( forM_,- unless,- void,- when,- (<=<),- )-import Control.Monad.Reader- ( MonadReader,- ReaderT,- asks,- local,- runReaderT,- )-import Control.Monad.Trans (lift)-import Control.Monad.Trans.Class (MonadTrans)-import Control.Monad.Trans.Control- ( liftWith,- restoreT,- )-import Control.Monad.Trans.Maybe- ( MaybeT (..),- runMaybeT,- )-import Data.Aeson- ( Encoding,- ToJSON (..),- Value,- )-import Data.Aeson qualified as A-import Data.Aeson.Encode.Pretty- ( Config (..),- defConfig,- encodePretty',- )-import Data.Aeson.Encoding- ( encodingToLazyByteString,- list,- )-import Data.ByteString.Base16 (decodeBase16, parseBase16)-import Data.ByteString.Builder (lazyByteString)-import Data.ByteString.Char8 qualified as C-import Data.ByteString.Lazy qualified as L-import Data.Bytes.Get-import Data.Bytes.Put-import Data.Bytes.Serial-import Data.Char (isSpace)-import Data.Default (Default (..))-import Data.Function ((&))-import Data.HashMap.Strict (HashMap)-import Data.HashMap.Strict qualified as HashMap-import Data.HashSet (HashSet)-import Data.HashSet qualified as HashSet-import Data.Int (Int64)-import Data.List (nub)-import Data.Maybe- ( catMaybes,- fromJust,- fromMaybe,- isJust,- mapMaybe,- maybeToList,- )-import Data.Proxy (Proxy (..))-import Data.Serialize (decode)-import Data.String (fromString)-import Data.String.Conversions (cs)-import Data.Text (Text)-import Data.Text qualified as T-import Data.Text.Encoding qualified as T-import Data.Text.Lazy qualified as TL-import Data.Time.Clock.POSIX (getPOSIXTime)-import Data.Word (Word32, Word64)-import Database.RocksDB- ( Property (..),- getProperty,- )-import Haskoin.Address-import Haskoin.Block qualified as H-import Haskoin.Crypto.Keys-import Haskoin.Network-import Haskoin.Node- ( Chain,- Logger,- OnlinePeer (..),- PeerMgr,- chainGetAncestor,- chainGetBest,- getPeers,- logDebug,- logError,- sendMessage,- withSource,- )-import Haskoin.Script-import Haskoin.Store.BlockStore-import Haskoin.Store.Cache-import Haskoin.Store.Common-import Haskoin.Store.Data-import Haskoin.Store.Database.Reader-import Haskoin.Store.Manager-import Haskoin.Store.WebCommon-import Haskoin.Transaction-import Haskoin.Util-import NQE- ( Publisher,- mailboxFullSTM,- newBoundedInbox,- receiveSTM,- sendSTM,- subscribe,- unsubscribe,- )-import Network.HTTP.Types- ( Status (..),- status400,- status404,- status409,- status413,- status500,- status503,- statusIsClientError,- statusIsServerError,- statusIsSuccessful,- )-import Network.Wai- ( Middleware,- Request (..),- responseStatus,- )-import Network.Wai.Handler.Warp- ( defaultSettings,- setHost,- setPort,- )-import Network.Wai.Handler.WebSockets (websocketsOr)-import Network.WebSockets- ( acceptRequest,- defaultConnectionOptions,- pendingRequest,- rejectRequestWith,- requestPath,- sendTextData,- )-import Network.WebSockets qualified as WebSockets-import Network.WebSockets.Connection.PingPong- ( PingPongOptions (..),- defaultPingPongOptions,- withPingPong,- )-import Network.Wreq qualified as Wreq-import Network.Wreq.Session as Wreq (Session)-import Network.Wreq.Session qualified as Wreq.Session-import Numeric.Natural (Natural)-import System.Metrics.StatsD-import UnliftIO- ( MonadIO,- MonadUnliftIO,- TVar,- askRunInIO,- async,- atomically,- bracket,- bracket_,- checkSTM,- handleAny,- liftIO,- newTVarIO,- orElse,- race_,- readTVar,- readTVarIO,- withAsync,- writeTVar,- )-import UnliftIO.Concurrent (threadDelay)-import Web.Scotty.Trans qualified as S---- | Scotty application with access to the web state.-type ScottyT m = S.ScottyT (ReaderT WebState m)---- | Scotty request handler with access to the web state.-type ActionT m = S.ActionT (ReaderT WebState m)---- | Limits of the API. A value of @0@ means no limit.-data WebLimits = WebLimits- { -- | maximum number of items in a list response- maxItemCount :: !Word32,- -- | maximum number of full transactions in a list response- maxFullItemCount :: !Word32,- -- | maximum offset of a paged query- maxOffset :: !Word32,- -- | number of items in a list response when no limit is given- defItemCount :: !Word32,- -- | consecutive unused addresses after which xpub derivation stops- xpubGap :: !Word32,- -- | unused addresses to look at for an xpub with no transactions- xpubGapInit :: !Word32,- -- | seconds without a new transaction before the store is reported- -- unhealthy- txTimeout :: !Word64,- -- | seconds without a new block before the store is reported unhealthy- blockTimeout :: !Word64- }- deriving (Eq, Show)---- | 200000 items, 5000 full transactions, offset up to 50000, 100 items by--- default, xpub gaps of 32 and 20, transaction timeout of 30 minutes and--- block timeout of four hours.-instance Default WebLimits where- def =- WebLimits- { maxItemCount = 200000,- maxFullItemCount = 5000,- maxOffset = 50000,- defItemCount = 100,- xpubGap = 32,- xpubGapInit = 20,- txTimeout = 3600 `div` 2,- blockTimeout = 4 * 3600- }---- | Configuration of the web server.-data WebConfig = WebConfig- { -- | address to listen on, such as @*@ for all- host :: !String,- -- | port to listen on- port :: !Int,- -- | running store to serve- store :: !Store,- -- | blocks that the database may lag behind the headers before the store- -- is reported unhealthy- maxLaggingBlocks :: !Int,- -- | transactions that may be waiting to be imported before the store is- -- reported unhealthy- maxPendingTxs :: !Int,- -- | fewest peers before the store is reported unhealthy- minPeers :: !Int,- -- | limits of the API- limits :: !WebLimits,- -- | version reported by the health check- version :: !String,- -- | the mempool is not indexed, so do not report it in health checks- noMempool :: !Bool,- -- | StatsD client, if metrics are enabled- stats :: !(Maybe Stats),- -- | microseconds between updates of the price ticker- tickerRefresh :: !Int,- -- | URL of the price ticker service- tickerURL :: !String,- -- | URL of the historical price service- priceHistoryURL :: !String,- -- | disable the xpub endpoints- noXPub :: !Bool,- -- | disable the Blockchain.info compatible endpoints- noBlockchainInfo :: !Bool,- -- | seconds between health checks- healthCheckInterval :: !Int,- -- | log messages here- logger :: !Logger- }---- | State shared by all request handlers.-data WebState = WebState- { -- | configuration- config :: !WebConfig,- -- | latest prices by currency code- ticker :: !(TVar (HashMap Text BinfoTicker)),- -- | StatsD metrics, if enabled- metrics :: !(Maybe WebMetrics),- -- | HTTP session for price requests- session :: !Wreq.Session,- -- | result of the latest health check- health :: !(TVar HealthCheck)- }---- | StatsD metrics of the web server: a timing for each kind of request,--- under @web.\<name\>@, error counters and a gauge of connected event--- clients.-data WebMetrics = WebMetrics- { -- Addresses- addressTx :: !StatTiming,- addressTxFull :: !StatTiming,- addressBalance :: !StatTiming,- addressUnspent :: !StatTiming,- xpub :: !StatTiming,- xpubDelete :: !StatTiming,- xpubTxFull :: !StatTiming,- xpubTx :: !StatTiming,- xpubBalance :: !StatTiming,- xpubUnspent :: !StatTiming,- -- Transactions- tx :: !StatTiming,- txRaw :: !StatTiming,- txAfter :: !StatTiming,- txBlock :: !StatTiming,- txBlockRaw :: !StatTiming,- txPost :: !StatTiming,- mempool :: !StatTiming,- -- Blocks- block :: !StatTiming,- blockRaw :: !StatTiming,- -- Blockchain- binfoMultiaddr :: !StatTiming,- binfoBalance :: !StatTiming,- binfoAddressRaw :: !StatTiming,- binfoUnspent :: !StatTiming,- binfoTxRaw :: !StatTiming,- binfoBlock :: !StatTiming,- binfoBlockHeight :: !StatTiming,- binfoBlockLatest :: !StatTiming,- binfoBlockRaw :: !StatTiming,- binfoMempool :: !StatTiming,- binfoExportHistory :: !StatTiming,- -- Blockchain /q endpoints- binfoQaddresstohash :: !StatTiming,- binfoQhashtoaddress :: !StatTiming,- binfoQaddrpubkey :: !StatTiming,- binfoQpubkeyaddr :: !StatTiming,- binfoQhashpubkey :: !StatTiming,- binfoQgetblockcount :: !StatTiming,- binfoQlatesthash :: !StatTiming,- binfoQbcperblock :: !StatTiming,- binfoQtxtotalbtcoutput :: !StatTiming,- binfoQtxtotalbtcinput :: !StatTiming,- binfoQtxfee :: !StatTiming,- binfoQtxresult :: !StatTiming,- binfoQgetreceivedbyaddress :: !StatTiming,- binfoQgetsentbyaddress :: !StatTiming,- binfoQaddressbalance :: !StatTiming,- binfoQaddressfirstseen :: !StatTiming,- -- Errors- serverErrors :: StatCounter,- clientErrors :: StatCounter,- -- Others- health :: !StatTiming,- peers :: !StatTiming,- db :: !StatTiming,- events :: !StatGauge- }---- | Register the web server metrics with a StatsD client.-createMetrics :: (MonadIO m) => Stats -> m WebMetrics-createMetrics s = liftIO $ do- -- Addresses- addressTx <- d "address_transactions"- addressTxFull <- d "address_transactions_full"- addressBalance <- d "address_balance"- addressUnspent <- d "address_unspent"- xpub <- d "xpub"- xpubDelete <- d "xpub_delete"- xpubTxFull <- d "xpub_transactions_full"- xpubTx <- d "xpub_transactions"- xpubBalance <- d "xpub_balances"- xpubUnspent <- d "xpub_unspent"-- -- Transactions- tx <- d "transaction"- txRaw <- d "transaction_raw"- txAfter <- d "transaction_after"- txPost <- d "transaction_post"- txBlock <- d "transactions_block"- txBlockRaw <- d "transactions_block_raw"- mempool <- d "mempool"-- -- Blocks- block <- d "block"- blockRaw <- d "block_raw"-- -- Blockchain- binfoMultiaddr <- d "blockchain_multiaddr"- binfoBalance <- d "blockchain_balance"- binfoAddressRaw <- d "blockchain_rawaddr"- binfoUnspent <- d "blockchain_unspent"- binfoTxRaw <- d "blockchain_rawtx"- binfoBlock <- d "blockchain_blocks"- binfoBlockHeight <- d "blockchain_block_height"- binfoBlockLatest <- d "blockchain_latestblock"- binfoBlockRaw <- d "blockchain_rawblock"- binfoMempool <- d "blockchain_mempool"- binfoExportHistory <- d "blockchain_export_history"-- -- Blockchain /q endpoints- binfoQaddresstohash <- d "blockchain_q_addresstohash"- binfoQhashtoaddress <- d "blockchain_q_hashtoaddress"- binfoQaddrpubkey <- d "blockckhain_q_addrpubkey"- binfoQpubkeyaddr <- d "blockchain_q_pubkeyaddr"- binfoQhashpubkey <- d "blockchain_q_hashpubkey"- binfoQgetblockcount <- d "blockchain_q_getblockcount"- binfoQlatesthash <- d "blockchain_q_latesthash"- binfoQbcperblock <- d "blockchain_q_bcperblock"- binfoQtxtotalbtcoutput <- d "blockchain_q_txtotalbtcoutput"- binfoQtxtotalbtcinput <- d "blockchain_q_txtotalbtcinput"- binfoQtxfee <- d "blockchain_q_txfee"- binfoQtxresult <- d "blockchain_q_txresult"- binfoQgetreceivedbyaddress <- d "blockchain_q_getreceivedbyaddress"- binfoQgetsentbyaddress <- d "blockchain_q_getsentbyaddress"- binfoQaddressbalance <- d "blockchain_q_addressbalance"- binfoQaddressfirstseen <- d "blockchain_q_addressfirstseen"-- -- Errors- serverErrors <- c "server_errors"- clientErrors <- c "client_errors"-- -- Others- health <- d "health"- peers <- d "peers"- db <- d "dbstats"-- events <- g "events_connected"- return WebMetrics {..}- where- d x = newStatTiming s ("web." <> x) 10- g x = newStatGauge s ("web." <> x) 0- c x = newStatCounter s ("web." <> x) 10---- | Increase a gauge while an action runs.-withGaugeIO :: (MonadUnliftIO m) => StatGauge -> m a -> m a-withGaugeIO g = bracket_ (incrementGauge g 1) (decrementGauge g 1)---- | Increase a gauge of the web metrics while a handler runs.-withGaugeIncrease ::- (MonadUnliftIO m) =>- (WebMetrics -> StatGauge) ->- ActionT m a ->- ActionT m a-withGaugeIncrease gf go =- askl (.metrics) >>= \case- Nothing -> go- Just m -> do- s <- liftWith $ \run -> withGaugeIO (gf m) (run go)- restoreT $ return s---- | Record how long a handler takes in a timing of the web metrics.-withMetrics ::- (MonadUnliftIO m) => (WebMetrics -> StatTiming) -> ActionT m a -> ActionT m a-withMetrics df go =- askl (.metrics) >>= \case- Nothing -> go- Just m ->- restoreT . return- =<< liftWith (\run -> bracket time (stop m) (const (run go)))- where- time = round . (* 1000) <$> liftIO getPOSIXTime- stop m t1 = do- t2 <- time- let ms = t2 - t1- liftIO $ addTiming (df m) ms---- | Encoding of a response.-data SerialAs- = -- | binary, for @Accept: application\/octet-stream@- SerialAsBinary- | -- | compact JSON- SerialAsJSON- | -- | indented JSON, for @?pretty=true@- SerialAsPrettyJSON- deriving (Eq, Show)--instance- (MonadUnliftIO m) =>- StoreReadBase (ReaderT WebState m)- where- getCtx = runInWebReader getCtx- getNetwork = runInWebReader getNetwork- getBestBlock = runInWebReader getBestBlock- getBlocksAtHeight height = runInWebReader (getBlocksAtHeight height)- getBlock bh = runInWebReader (getBlock bh)- getTxData th = runInWebReader (getTxData th)- getManyTxData ths = runInWebReader (getManyTxData ths)- getSpender op = runInWebReader (getSpender op)- getUnspent op = runInWebReader (getUnspent op)- getBalance a = runInWebReader (getBalance a)- getMempool = runInWebReader getMempool- getMempoolPage = runInWebReader . getMempoolPage--instance- (MonadUnliftIO m) =>- StoreReadExtra (ReaderT WebState m)- where- getMaxGap = runInWebReader getMaxGap- getInitialGap = runInWebReader getInitialGap- getBalances as = runInWebReader (getBalances as)- getAddressesTxs as = runInWebReader . getAddressesTxs as- getAddressTxs a = runInWebReader . getAddressTxs a- getAddressUnspents a = runInWebReader . getAddressUnspents a- getAddressesUnspents as = runInWebReader . getAddressesUnspents as- xPubBals = runInWebReader . xPubBals- xPubUnspents xpub xbals = runInWebReader . xPubUnspents xpub xbals- xPubTxs xpub xbals = runInWebReader . xPubTxs xpub xbals- xPubTxCount xpub = runInWebReader . xPubTxCount xpub- getNumTxData = runInWebReader . getNumTxData--instance (MonadUnliftIO m) => StoreReadBase (ActionT m) where- getCtx = lift getCtx- getNetwork = lift getNetwork- getBestBlock = lift getBestBlock- getBlocksAtHeight = lift . getBlocksAtHeight- getBlock = lift . getBlock- getTxData = lift . getTxData- getManyTxData = lift . getManyTxData- getSpender = lift . getSpender- getUnspent = lift . getUnspent- getBalance = lift . getBalance- getMempool = lift getMempool- getMempoolPage = lift . getMempoolPage--instance (MonadUnliftIO m) => StoreReadExtra (ActionT m) where- getBalances = lift . getBalances- getAddressesTxs as = lift . getAddressesTxs as- getAddressTxs a = lift . getAddressTxs a- getAddressUnspents a = lift . getAddressUnspents a- getAddressesUnspents as = lift . getAddressesUnspents as- xPubBals = lift . xPubBals- xPubUnspents xpub xbals = lift . xPubUnspents xpub xbals- xPubTxs xpub xbals = lift . xPubTxs xpub xbals- xPubTxCount xpub = lift . xPubTxCount xpub- getMaxGap = lift getMaxGap- getInitialGap = lift getInitialGap- getNumTxData = lift . getNumTxData------------------------ Path Handlers -------------------------- | Run the web server forever. Also starts a thread that updates prices, unless--- the Blockchain.info endpoints are disabled, and a thread that runs health--- checks.-runWeb :: (MonadUnliftIO m) => WebConfig -> m ()-runWeb config = do- ticker <- newTVarIO HashMap.empty- metrics <- mapM createMetrics config.stats- session <- liftIO Wreq.Session.newAPISession- health <- runHealthCheck >>= newTVarIO- let state = WebState {..}- withAsync (priceUpdater session ticker) $ \_a1 ->- withAsync (healthCheckLoop health) $ \_a2 -> do- runner <- askRunInIO- S.scottyOptsT opts (runner . flip runReaderT state) $ do- S.middleware $ webSocketEvents state- S.middleware (logIt config.logger)- handlePaths config- S.notFound $ raise ThingNotFound- where- priceUpdater session =- unless config.noBlockchainInfo- . price- (withSource "Price" config.logger)- config.store.net- session- config.tickerURL- config.tickerRefresh- runHealthCheck = runReaderT (healthCheck config) config.store.db- healthCheckLoop v = loopForever $ do- threadDelay (config.healthCheckInterval * 1000 * 1000)- runHealthCheck >>= atomically . writeTVar v- opts = S.defaultOptions {S.settings = settings defaultSettings}- settings = setPort config.port . setHost (fromString config.host)---- | Historical prices of the coin of the network in a currency at several times. Returns an empty list on failure.-getRates ::- (MonadUnliftIO m) =>- Logger ->- Network ->- Wreq.Session ->- String ->- Text ->- [Word64] ->- m [BinfoRate]-getRates logger net session url currency times = do- handleAny err $ do- r <-- liftIO $- Wreq.asJSON- =<< Wreq.Session.postWith opts session url body- return $ r ^. Wreq.responseBody- where- err _ = do- logError logger "Could not get historic prices"- return []- body = toJSON times- base =- Wreq.defaults- & Wreq.param "base" .~ [T.toUpper $ T.pack net.name]- opts = base & Wreq.param "quote" .~ [currency]---- | Update the prices of the coin of the network periodically, for BTC and BCH only.-price ::- (MonadUnliftIO m) =>- Logger ->- Network ->- Wreq.Session ->- String ->- Int ->- TVar (HashMap Text BinfoTicker) ->- m ()-price logger net session url pget v = forM_ purl $ \u -> loopForever $ do- let err e = logError logger $ cs (show e)- handleAny err $ do- r <- liftIO $ Wreq.asJSON =<< Wreq.Session.get session u- atomically . writeTVar v $ r ^. Wreq.responseBody- threadDelay pget- where- purl = case code of- Nothing -> Nothing- Just x -> Just (url <> "?base=" <> x)- where- code- | net == btc = Just "btc"- | net == bch = Just "bch"- | otherwise = Nothing---- | Respond with an error and stop handling the request.-raise :: (MonadIO m) => Except -> ActionT m a-raise e = do- askl (.metrics) >>= mapM_ \m -> do- liftIO $- mapM_ (`incrementCounter` 1) $- if- | statusIsClientError (errStatus e) -> Just m.clientErrors- | statusIsServerError (errStatus e) -> Just m.serverErrors- | otherwise -> Nothing- setHeaders- S.status $ errStatus e- S.json e- S.finish---- | HTTP status of an error.-errStatus :: Except -> Status-errStatus ThingNotFound = status404-errStatus BadRequest = status400-errStatus UserError {} = status400-errStatus StringError {} = status400-errStatus ServerError = status500-errStatus TxIndexConflict {} = status409-errStatus ServerTimeout = status500-errStatus RequestTooLarge = status413---- | Register the handlers of every endpoint, except those disabled in the configuration.-handlePaths ::- (MonadUnliftIO m) =>- WebConfig ->- S.ScottyT (ReaderT WebState m) ()-handlePaths cfg = do- -- Block Paths- pathCompact- (GetBlockBest <$> paramDef)- scottyBlockBest- (marshalEncoding net)- (marshalValue net)- pathCompact- (GetBlockHeight <$> paramCapture <*> paramDef)- (fmap SerialList . scottyBlockHeight)- (list (marshalEncoding net) . (.get))- (json_list (marshalValue net) . (.get))- pathCompact- (GetBlockTime <$> paramCapture <*> paramDef)- scottyBlockTime- (marshalEncoding net)- (marshalValue net)- pathCompact- (GetBlockMTP <$> paramCapture <*> paramDef)- scottyBlockMTP- (marshalEncoding net)- (marshalValue net)- pathCompact- (GetBlock <$> paramCapture <*> paramDef)- scottyBlock- (marshalEncoding net)- (marshalValue net)- pathCompact- (GetTx <$> paramCapture)- scottyTx- (marshalEncoding net)- (marshalValue net)- pathCompact- (GetTxRaw <$> paramCapture)- scottyTxRaw- toEncoding- toJSON- pathCompact- (PostTx <$> parseTxBody)- scottyPostTx- toEncoding- toJSON- pathCompact- (GetAddrTxs <$> paramCapture <*> parseLimits)- (fmap SerialList . scottyAddrTxs)- toEncoding- toJSON- pathCompact- (GetAddrBalance <$> paramCapture)- scottyAddrBalance- (marshalEncoding net)- (marshalValue net)- pathCompact- (GetAddrUnspent <$> paramCapture <*> parseLimits)- (fmap SerialList . scottyAddrUnspent)- (list (marshalEncoding net) . (.get))- (json_list (marshalValue net) . (.get))- pathCompact- (GetPeers & return)- (fmap SerialList . scottyPeers)- toEncoding- toJSON- pathCompact- (GetHealth & return)- scottyHealth- toEncoding- toJSON- pathCompact- (GetMempool <$> paramOptional <*> parseOffset)- (fmap SerialList . scottyMempool)- toEncoding- toJSON- pathCompact- (GetBlocks <$> paramRequired <*> paramDef)- (fmap SerialList . scottyBlocks)- (list (marshalEncoding net) . (.get))- (json_list (marshalValue net) . (.get))- pathCompact- (GetBlockRaw <$> paramCapture)- scottyBlockRaw- toEncoding- toJSON- pathCompact- (GetBlockBestRaw & return)- scottyBlockBestRaw- toEncoding- toJSON- pathCompact- (GetBlockLatest <$> paramDef)- (fmap SerialList . scottyBlockLatest)- (list (marshalEncoding net) . (.get))- (json_list (marshalValue net) . (.get))- pathCompact- (GetBlockHeights <$> paramRequired <*> paramDef)- (fmap SerialList . scottyBlockHeights)- (list (marshalEncoding net) . (.get))- (json_list (marshalValue net) . (.get))- pathCompact- (GetBlockHeightRaw <$> paramCapture)- scottyBlockHeightRaw- toEncoding- toJSON- pathCompact- (GetBlockTimeRaw <$> paramCapture)- scottyBlockTimeRaw- toEncoding- toJSON- pathCompact- (GetBlockMTPRaw <$> paramCapture)- scottyBlockMTPRaw- toEncoding- toJSON- pathCompact- (GetTxs <$> paramRequired)- (fmap SerialList . scottyTxs)- (list (marshalEncoding net) . (.get))- (json_list (marshalValue net) . (.get))- pathCompact- (GetTxsRaw <$> paramRequired)- scottyTxsRaw- toEncoding- toJSON- pathCompact- (GetTxsBlock <$> paramCapture)- (fmap SerialList . scottyTxsBlock)- (list (marshalEncoding net) . (.get))- (json_list (marshalValue net) . (.get))- pathCompact- (GetTxsBlockRaw <$> paramCapture)- scottyTxsBlockRaw- toEncoding- toJSON- pathCompact- (GetTxAfter <$> paramCapture <*> paramCapture)- scottyTxAfter- toEncoding- toJSON- pathCompact- (GetAddrsTxs <$> paramRequired <*> parseLimits)- (fmap SerialList . scottyAddrsTxs)- toEncoding- toJSON- pathCompact- (GetAddrTxsFull <$> paramCapture <*> parseLimits)- (fmap SerialList . scottyAddrTxsFull)- (list (marshalEncoding net) . (.get))- (json_list (marshalValue net) . (.get))- pathCompact- (GetAddrsTxsFull <$> paramRequired <*> parseLimits)- (fmap SerialList . scottyAddrsTxsFull)- (list (marshalEncoding net) . (.get))- (json_list (marshalValue net) . (.get))- pathCompact- (GetAddrsBalance <$> paramRequired)- (fmap SerialList . scottyAddrsBalance)- (list (marshalEncoding net) . (.get))- (json_list (marshalValue net) . (.get))- pathCompact- (GetAddrsUnspent <$> paramRequired <*> parseLimits)- (fmap SerialList . scottyAddrsUnspent)- (list (marshalEncoding net) . (.get))- (json_list (marshalValue net) . (.get))- S.get "/events" scottyEvents- S.get "/dbstats" scottyDbStats- unless cfg.noXPub $ do- pathCompact- (GetXPub <$> paramCapture <*> paramDef <*> paramDef)- scottyXPub- toEncoding- toJSON- pathCompact- (GetXPubTxs <$> paramCapture <*> paramDef <*> parseLimits <*> paramDef)- (fmap SerialList . scottyXPubTxs)- toEncoding- toJSON- pathCompact- (GetXPubTxsFull <$> paramCapture <*> paramDef <*> parseLimits <*> paramDef)- (fmap SerialList . scottyXPubTxsFull)- (list (marshalEncoding net) . (.get))- (json_list (marshalValue net) . (.get))- pathCompact- (GetXPubBalances <$> paramCapture <*> paramDef <*> paramDef)- (fmap SerialList . scottyXPubBalances)- (list (marshalEncoding net) . (.get))- (json_list (marshalValue net) . (.get))- pathCompact- (GetXPubUnspent <$> paramCapture <*> paramDef <*> parseLimits <*> paramDef)- (fmap SerialList . scottyXPubUnspent)- (list (marshalEncoding net) . (.get))- (json_list (marshalValue net) . (.get))- pathCompact- (DelCachedXPub <$> paramCapture <*> paramDef)- scottyDelXPub- toEncoding- toJSON- unless cfg.noBlockchainInfo $ do- S.post "/blockchain/multiaddr" scottyMultiAddr- S.get "/blockchain/multiaddr" scottyMultiAddr- S.get "/blockchain/balance" scottyShortBal- S.post "/blockchain/balance" scottyShortBal- S.get "/blockchain/rawaddr/:addr" scottyRawAddr- S.get "/blockchain/address/:addr" scottyRawAddr- S.get "/blockchain/xpub/:addr" scottyRawAddr- S.post "/blockchain/unspent" scottyBinfoUnspent- S.get "/blockchain/unspent" scottyBinfoUnspent- S.get "/blockchain/rawtx/:txid" scottyBinfoTx- S.get "/blockchain/rawblock/:block" scottyBinfoBlock- S.get "/blockchain/latestblock" scottyBinfoLatest- S.get "/blockchain/unconfirmed-transactions" scottyBinfoMempool- S.get "/blockchain/block-height/:height" scottyBinfoBlockHeight- S.get "/blockchain/blocks/:milliseconds" scottyBinfoBlocksDay- S.get "/blockchain/export-history" scottyBinfoHistory- S.post "/blockchain/export-history" scottyBinfoHistory- S.get "/blockchain/q/addresstohash/:addr" scottyBinfoAddrToHash- S.get "/blockchain/q/hashtoaddress/:hash" scottyBinfoHashToAddr- S.get "/blockchain/q/addrpubkey/:pubkey" scottyBinfoAddrPubkey- S.get "/blockchain/q/pubkeyaddr/:addr" scottyBinfoPubKeyAddr- S.get "/blockchain/q/hashpubkey/:pubkey" scottyBinfoHashPubkey- S.get "/blockchain/q/getblockcount" scottyBinfoGetBlockCount- S.get "/blockchain/q/latesthash" scottyBinfoLatestHash- S.get "/blockchain/q/bcperblock" scottyBinfoSubsidy- S.get "/blockchain/q/txtotalbtcoutput/:txid" scottyBinfoTotalOut- S.get "/blockchain/q/txtotalbtcinput/:txid" scottyBinfoTotalInput- S.get "/blockchain/q/txfee/:txid" scottyBinfoTxFees- S.get "/blockchain/q/txresult/:txid/:addr" scottyBinfoTxResult- S.get "/blockchain/q/getreceivedbyaddress/:addr" scottyBinfoReceived- S.get "/blockchain/q/getsentbyaddress/:addr" scottyBinfoSent- S.get "/blockchain/q/addressbalance/:addr" scottyBinfoAddrBalance- S.get "/blockchain/q/addressfirstseen/:addr" scottyFirstSeen- where- json_list f = toJSONList . map f- net = cfg.store.net---- | Register a handler for an API resource, with compact JSON by default.-pathCompact ::- (ApiResource a b, MonadUnliftIO m) =>- ActionT m a ->- (a -> ActionT m b) ->- (b -> Encoding) ->- (b -> Value) ->- S.ScottyT (ReaderT WebState m) ()-pathCompact parser action encJson encValue =- pathCommon parser action encJson encValue False---- | Register a handler for an API resource: parse the request, run the handler,--- and encode the response as JSON or binary, as the client asked.-pathCommon ::- (ApiResource a b, MonadUnliftIO m) =>- ActionT m a ->- (a -> ActionT m b) ->- (b -> Encoding) ->- (b -> Value) ->- Bool ->- ScottyT m ()-pathCommon parser action encJson encValue pretty =- S.addroute (resourceMethod proxy) (capturePath proxy) $ do- setHeaders- proto <- setupContentType pretty- apiRes <- parser- res <- action apiRes- S.raw $ protoSerial proto encJson encValue res- where- toProxy :: ActionT m a -> Proxy a- toProxy = const Proxy- proxy = toProxy parser---- | Respond with JSON.-streamEncoding :: (MonadIO m) => Encoding -> ActionT m ()-streamEncoding e = do- S.setHeader "Content-Type" "application/json; charset=utf-8"- S.raw (encodingToLazyByteString e)---- | Encode a response in binary, JSON or pretty JSON.-protoSerial ::- (Serial a) =>- SerialAs ->- (a -> Encoding) ->- (a -> Value) ->- a ->- L.ByteString-protoSerial SerialAsBinary _ _ = runPutL . serialize-protoSerial SerialAsJSON f _ = encodingToLazyByteString . f-protoSerial SerialAsPrettyJSON _ g =- encodePretty' defConfig {confTrailingNewline = True} . g---- | Allow requests from any origin.-setHeaders :: (MonadIO m) => S.ActionT m ()-setHeaders = S.setHeader "Access-Control-Allow-Origin" "*"---- | Respond with JSON, pretty if the @pretty@ parameter or the default says so.-setupJSON :: (MonadUnliftIO m) => Bool -> S.ActionT m SerialAs-setupJSON pretty = do- S.setHeader "Content-Type" "application/json"- p <- param "pretty" `rescue` return pretty- return $ if p then SerialAsPrettyJSON else SerialAsJSON---- | Respond with binary.-setupBinary :: (MonadIO m) => S.ActionT m SerialAs-setupBinary = do- S.setHeader "Content-Type" "application/octet-stream"- return SerialAsBinary---- | Choose the encoding of the response from the @Accept@ header of the request.-setupContentType :: (MonadUnliftIO m) => Bool -> S.ActionT m SerialAs-setupContentType pretty = do- accept <- S.header "accept"- maybe (setupJSON pretty) setType accept- where- setType "application/octet-stream" = setupBinary- setType _ = setupJSON pretty---- GET Block / GET Blocks ------ | Handler for @GET \/block\/:block@: a block by hash.-scottyBlock ::- (MonadUnliftIO m) => GetBlock -> ActionT m BlockData-scottyBlock (GetBlock h (NoTx noTx)) = withMetrics (.block) $ do- getBlock h >>= \case- Nothing ->- raise ThingNotFound- Just b -> do- return $ pruneTx noTx b---- | Blocks by hash, without duplicates, leaving out unknown ones.-getBlocks ::- (MonadUnliftIO m) =>- [H.BlockHash] ->- Bool ->- ActionT m [BlockData]-getBlocks hs notx =- (pruneTx notx <$>) . catMaybes <$> mapM getBlock (nub hs)---- | Handler for @GET \/blocks?blocks=…@: several blocks by hash.-scottyBlocks ::- (MonadUnliftIO m) => GetBlocks -> ActionT m [BlockData]-scottyBlocks (GetBlocks hs (NoTx notx)) =- withMetrics (.block) $- getBlocks hs notx---- | Keep only the coinbase in the list of transactions of a block, if the flag is 'True'.-pruneTx :: Bool -> BlockData -> BlockData-pruneTx False b = b-pruneTx True BlockData {..} = BlockData {txs = take 1 txs, ..}---- GET BlockRaw ------ | Handler for @GET \/block\/:block\/raw@: a block by hash, serialized as in the peer-to-peer protocol.-scottyBlockRaw ::- (MonadUnliftIO m) =>- GetBlockRaw ->- ActionT m (RawResult H.Block)-scottyBlockRaw (GetBlockRaw h) =- withMetrics (.blockRaw) $- RawResult <$> getRawBlock h---- | Block by hash with all its transactions. Responds with an error if not found.-getRawBlock ::- (MonadUnliftIO m) =>- H.BlockHash ->- ActionT m H.Block-getRawBlock h =- getBlock h >>= maybe (raise ThingNotFound) return >>= lift . toRawBlock---- | Rebuild a block from its data and its transactions.-toRawBlock :: (StoreReadBase m) => BlockData -> m H.Block-toRawBlock b = do- txs <- map transactionData . catMaybes <$> getTransactions b.txs- return H.Block {header = b.header, txs}---- GET BlockBest / BlockBestRaw ------ | Handler for @GET \/block\/best@: the best block.-scottyBlockBest ::- (MonadUnliftIO m) => GetBlockBest -> ActionT m BlockData-scottyBlockBest (GetBlockBest (NoTx notx)) =- withMetrics (.block) $- maybe (raise ThingNotFound) (return . pruneTx notx) <=< runMaybeT $- MaybeT getBestBlock >>= MaybeT . getBlock---- | Handler for @GET \/block\/best\/raw@: the best block, serialized.-scottyBlockBestRaw ::- (MonadUnliftIO m) =>- GetBlockBestRaw ->- ActionT m (RawResult H.Block)-scottyBlockBestRaw _ =- withMetrics (.blockRaw) $- maybe (raise ThingNotFound) (return . RawResult) <=< runMaybeT $- MaybeT getBestBlock >>= lift . getRawBlock---- GET BlockLatest ------ | Handler for @GET \/block\/latest@: the best block and its ancestors, up to 100 blocks.-scottyBlockLatest ::- (MonadUnliftIO m) =>- GetBlockLatest ->- ActionT m [BlockData]-scottyBlockLatest (GetBlockLatest (NoTx noTx)) =- withMetrics (.block) $- getBestBlock >>= maybe (raise ThingNotFound) (go [] <=< getBlock)- where- go acc Nothing = return $ reverse acc- go acc (Just b)- | b.height <= 0 = return $ reverse acc- | length acc == 99 = return . reverse $ pruneTx noTx b : acc- | otherwise = go (pruneTx noTx b : acc) =<< getBlock b.header.prev---- GET BlockHeight / BlockHeights / BlockHeightRaw ------ | Handler for @GET \/block\/height\/:height@: the blocks at a height.-scottyBlockHeight ::- (MonadUnliftIO m) => GetBlockHeight -> ActionT m [BlockData]-scottyBlockHeight (GetBlockHeight h (NoTx notx)) =- withMetrics (.block) $- getBlocksAtHeight (fromIntegral h)- >>= (`getBlocks` notx)---- | Handler for @GET \/block\/heights?heights=…@: the blocks at several heights.-scottyBlockHeights ::- (MonadUnliftIO m) =>- GetBlockHeights ->- ActionT m [BlockData]-scottyBlockHeights (GetBlockHeights (HeightsParam heights) (NoTx notx)) =- withMetrics (.block) $- mapM (getBlocksAtHeight . fromIntegral) heights- >>= (`getBlocks` notx) . concat---- | Handler for @GET \/block\/height\/:height\/raw@: the blocks at a height, serialized.-scottyBlockHeightRaw ::- (MonadUnliftIO m) =>- GetBlockHeightRaw ->- ActionT m (RawResultList H.Block)-scottyBlockHeightRaw (GetBlockHeightRaw h) =- withMetrics (.blockRaw) $- fmap RawResultList $- mapM getRawBlock =<< getBlocksAtHeight (fromIntegral h)---- GET BlockTime / BlockTimeRaw ------ | Handler for @GET \/block\/time\/:time@: the last block with a timestamp at or before a time.-scottyBlockTime ::- (MonadUnliftIO m) =>- GetBlockTime ->- ActionT m BlockData-scottyBlockTime (GetBlockTime (TimeParam t) (NoTx notx)) =- withMetrics (.block) $ do- ch <- askl (.config.store.chain)- blockAtOrBefore ch t >>= \case- Nothing -> raise ThingNotFound- Just b -> return $ pruneTx notx b---- | Handler for @GET \/block\/mtp\/:time@: the first block with a median time past at or after a time.-scottyBlockMTP ::- (MonadUnliftIO m) =>- GetBlockMTP ->- ActionT m BlockData-scottyBlockMTP (GetBlockMTP (TimeParam t) (NoTx notx)) =- withMetrics (.block) $ do- ch <- askl (.config.store.chain)- blockAtOrAfterMTP ch t >>= \case- Nothing -> raise ThingNotFound- Just b -> return $ pruneTx notx b---- | Handler for @GET \/block\/time\/:time\/raw@: the last block with a timestamp at or before a time, serialized.-scottyBlockTimeRaw ::- (MonadUnliftIO m) =>- GetBlockTimeRaw ->- ActionT m (RawResult H.Block)-scottyBlockTimeRaw (GetBlockTimeRaw (TimeParam t)) =- withMetrics (.blockRaw) $ do- ch <- askl (.config.store.chain)- blockAtOrBefore ch t >>= \case- Nothing -> raise ThingNotFound- Just b -> RawResult <$> lift (toRawBlock b)---- | Handler for @GET \/block\/mtp\/:time\/raw@: the first block with a median time past at or after a time, serialized.-scottyBlockMTPRaw ::- (MonadUnliftIO m) =>- GetBlockMTPRaw ->- ActionT m (RawResult H.Block)-scottyBlockMTPRaw (GetBlockMTPRaw (TimeParam t)) =- withMetrics (.blockRaw) $ do- ch <- askl (.config.store.chain)- blockAtOrAfterMTP ch t >>= \case- Nothing -> raise ThingNotFound- Just b -> RawResult <$> lift (toRawBlock b)---- GET Transactions ------ | Handler for @GET \/transaction\/:txid@: a transaction.-scottyTx :: (MonadUnliftIO m) => GetTx -> ActionT m Transaction-scottyTx (GetTx txid) =- withMetrics (.tx) $- getTransaction txid >>= maybe (raise ThingNotFound) return---- | Handler for @GET \/transactions?txids=…@: several transactions.-scottyTxs ::- (MonadUnliftIO m) => GetTxs -> ActionT m [Transaction]-scottyTxs (GetTxs txids) =- withMetrics (.tx) $- catMaybes <$> getTransactions (nub txids)---- | Handler for @GET \/transaction\/:txid\/raw@: a transaction, serialized.-scottyTxRaw ::- (MonadUnliftIO m) => GetTxRaw -> ActionT m (RawResult Tx)-scottyTxRaw (GetTxRaw txid) =- withMetrics (.txRaw) $- getTransaction txid- >>= \case- Nothing -> raise ThingNotFound- Just tx -> return $ RawResult (transactionData tx)---- | Handler for @GET \/transactions\/raw?txids=…@: several transactions, serialized.-scottyTxsRaw ::- (MonadUnliftIO m) =>- GetTxsRaw ->- ActionT m (RawResultList Tx)-scottyTxsRaw (GetTxsRaw txids) =- withMetrics (.txRaw) $- RawResultList . map transactionData . catMaybes- <$> getTransactions (nub txids)---- | Transactions of a block. Responds with an error if not found.-getTxsBlock ::- (MonadUnliftIO m) =>- H.BlockHash ->- ActionT m [Transaction]-getTxsBlock h =- getBlock h >>= \case- Nothing -> raise ThingNotFound- Just b -> catMaybes <$> getTransactions b.txs---- | Handler for @GET \/transactions\/block\/:block@: the transactions of a block.-scottyTxsBlock ::- (MonadUnliftIO m) =>- GetTxsBlock ->- ActionT m [Transaction]-scottyTxsBlock (GetTxsBlock h) =- withMetrics (.txBlock) $ getTxsBlock h---- | Handler for @GET \/transactions\/block\/:block\/raw@: the transactions of a block, serialized.-scottyTxsBlockRaw ::- (MonadUnliftIO m) =>- GetTxsBlockRaw ->- ActionT m (RawResultList Tx)-scottyTxsBlockRaw (GetTxsBlockRaw h) =- withMetrics (.txBlockRaw) $- RawResultList . map transactionData <$> getTxsBlock h---- GET TransactionAfterHeight ------ | Handler for @GET \/transaction\/:txid\/after\/:height@: whether any ancestor of a transaction is a coinbase confirmed after a height.-scottyTxAfter ::- (MonadUnliftIO m) =>- GetTxAfter ->- ActionT m (GenericResult (Maybe Bool))-scottyTxAfter (GetTxAfter txid height) =- withMetrics (.txAfter) $- GenericResult . fst <$> cbAfterHeight (fromIntegral height) txid---- | Check if any of the ancestors of this transaction is a coinbase after the--- specified height. Returns 'Nothing' if answer cannot be computed before--- hitting limits.-cbAfterHeight ::- (StoreReadBase m) =>- H.BlockHeight ->- TxHash ->- m (Maybe Bool, Int)-cbAfterHeight height txid =- inputs n HashSet.empty HashSet.empty [txid]- where- n = 10000- inputs 0 _ _ [] = return (Nothing, 10000)- inputs i is ns [] =- let is' = HashSet.union is ns- ns' = HashSet.empty- ts = HashSet.toList (HashSet.difference ns is)- in case ts of- [] -> return (Just False, n - i)- _ -> inputs i is' ns' ts- inputs i is ns (t : ts) =- getTransaction t >>= \case- Nothing -> return (Nothing, n - i)- Just tx- | height_check tx ->- if cb_check tx- then return (Just True, n - i + 1)- else- let ns' = HashSet.union (ins tx) ns- in inputs (i - 1) is ns' ts- | otherwise -> inputs (i - 1) is ns ts- cb_check = any isCoinbase . (.inputs)- ins = HashSet.fromList . map (.outpoint.hash) . (.inputs)- height_check tx =- case tx.block of- BlockRef h _ -> h > height- MemRef _ -> True---- POST Transaction ------ | Handler for @POST \/transactions@: broadcast a transaction to half of the peers and return its hash.-scottyPostTx :: (MonadUnliftIO m) => PostTx -> ActionT m TxId-scottyPostTx (PostTx tx) =- withMetrics (.txPost) $ do- cfg <- askl (.config)- lift (publishTx cfg tx)- return (TxId (txHash tx))---- | Send transaction to all connected peers.-publishTx ::- (MonadUnliftIO m, StoreReadBase m) =>- WebConfig ->- Tx ->- m ()-publishTx cfg tx = do- ps <- liftIO $ getPeers cfg.store.peerMgr- let c = max 1 (length ps `div` 2)- forM_ (take c ps) $ \p -> do- liftIO $ sendMessage (MTx tx) p.mailbox- void . async $ do- threadDelay (5 * 1000 * 1000)- let v = if cfg.store.net.segWit then InvWitnessTx else InvTx- g = MGetData (GetData [InvVector v (txHash tx).get])- liftIO $ sendMessage g p.mailbox---- GET Mempool / Events ------ | Handler for @GET \/mempool@: hashes of mempool transactions, newest first.-scottyMempool ::- (MonadUnliftIO m) => GetMempool -> ActionT m [TxHash]-scottyMempool (GetMempool limitM (OffsetParam o)) =- withMetrics (.mempool) $ do- WebLimits {..} <- askl (.config.limits)- let wl' = WebLimits {maxItemCount = 0, ..}- l = Limits (validateLimit wl' False limitM) (fromIntegral o) Nothing- map snd <$> getMempoolPage l---- | Middleware that serves @\/events@ over a WebSocket: each new block or mempool--- transaction is sent as a JSON message. Clients that do not answer pings, or--- that fall more than 10000 events behind, are disconnected.-webSocketEvents :: WebState -> Middleware-webSocketEvents s =- websocketsOr defaultConnectionOptions (wrap . events)- where- pub = s.config.store.pub- pingPong = defaultPingPongOptions {pingInterval = 30, pongTimeout = 60}- gauge = (.events) <$> s.metrics- wrap f = case gauge of- Nothing -> f- Just g -> withGaugeIO g f- events pending = do- let path = requestPath $ pendingRequest pending- if path == "/events"- then withEventSubscription pub $ \next -> do- conn <- acceptRequest pending- -- Drop clients that stop answering pings, and clients that fall- -- so far behind that their event queue fills up.- -- websockets only sees pongs while reading, hence the reader.- withPingPong pingPong conn $ \c ->- let send_events =- next >>= \case- Nothing ->- WebSockets.sendClose c ("Event queue full" :: Text)- Just event -> do- sendTextData c (A.encode event)- send_events- in race_- (loopForever (WebSockets.receiveDataMessage c))- send_events- else- rejectRequestWith- pending- WebSockets.defaultRejectRequest- { WebSockets.rejectBody =- L.toStrict $ A.encode ThingNotFound,- WebSockets.rejectCode =- 404,- WebSockets.rejectMessage =- "Not Found",- WebSockets.rejectHeaders =- [("Content-Type", "application/json")]- }---- | Handler for @GET \/events@: stream of new blocks and mempool transactions, one per line in JSON, until the client falls more than 10000 events behind.-scottyEvents :: (MonadUnliftIO m) => ActionT m ()-scottyEvents =- withGaugeIncrease (.events) $ do- setHeaders- proto <- setupContentType False- pub <- askl (.config.store.pub)- S.stream $ \io flush' ->- withEventSubscription pub $ \next ->- -- ends the response when the client falls too far behind- let send_events = do- flush'- next >>= \case- Nothing -> return ()- Just msg -> io (serial proto msg) >> send_events- in send_events- where- serial proto e =- lazyByteString $- protoSerial proto toEncoding toJSON e <> newLine proto- newLine SerialAsBinary = mempty- newLine SerialAsJSON = "\n"- newLine SerialAsPrettyJSON = mempty---- | Most events queued for one /events client. A client that does not keep--- up is disconnected once this many are waiting, instead of the queue--- growing without bound.-eventQueueSize :: Natural-eventQueueSize = 10000---- | Subscribe to the store events that /events clients see. The action gets--- a blocking read that returns the next event, or 'Nothing' once the queue--- has overflowed, after which the client must be disconnected. Other store--- events are dropped by the publisher instead of being queued.-withEventSubscription ::- Publisher StoreEvent -> (IO (Maybe Event) -> IO a) -> IO a-withEventSubscription pub f = do- inbox <- newBoundedInbox eventQueueSize- overflow <- newTVarIO False- let listen e = forM_ (toEvent e) $ \ev ->- mailboxFullSTM inbox >>= \case- True -> writeTVar overflow True- False -> sendSTM ev inbox- next =- atomically $- (readTVar overflow >>= checkSTM >> return Nothing)- `orElse` (Just <$> receiveSTM inbox)- bracket (subscribe pub listen) (unsubscribe pub) $ \_ -> f next- where- toEvent = \case- StoreBestBlock b -> Just (EventBlock b)- StoreMempoolNew t -> Just (EventTx t)- StoreMempoolDelete t -> Just (EventTx t)- _ -> Nothing---- GET Address Transactions ------ | Handler for @GET \/address\/:address\/transactions@: transactions of an address.-scottyAddrTxs ::- (MonadUnliftIO m) => GetAddrTxs -> ActionT m [TxRef]-scottyAddrTxs (GetAddrTxs addr pLimits) =- withMetrics (.addressTx) $- getAddressTxs addr =<< paramToLimits False pLimits---- | Handler for @GET \/address\/transactions?addresses=…@: transactions of several addresses.-scottyAddrsTxs ::- (MonadUnliftIO m) => GetAddrsTxs -> ActionT m [TxRef]-scottyAddrsTxs (GetAddrsTxs addrs pLimits) =- withMetrics (.addressTx) $- getAddressesTxs addrs =<< paramToLimits False pLimits---- | Handler for @GET \/address\/:address\/transactions\/full@: full transactions of an address.-scottyAddrTxsFull ::- (MonadUnliftIO m) =>- GetAddrTxsFull ->- ActionT m [Transaction]-scottyAddrTxsFull (GetAddrTxsFull addr pLimits) =- withMetrics (.addressTxFull) $ do- txs <- getAddressTxs addr =<< paramToLimits True pLimits- catMaybes <$> getTransactions (map (.txid) txs)---- | Handler for @GET \/address\/transactions\/full?addresses=…@: full transactions of several addresses.-scottyAddrsTxsFull ::- (MonadUnliftIO m) =>- GetAddrsTxsFull ->- ActionT m [Transaction]-scottyAddrsTxsFull (GetAddrsTxsFull addrs pLimits) =- withMetrics (.addressTxFull) $ do- txs <- getAddressesTxs addrs =<< paramToLimits True pLimits- catMaybes <$> getTransactions (map (.txid) txs)---- | Handler for @GET \/address\/:address\/balance@: balance of an address.-scottyAddrBalance :: (MonadUnliftIO m) => GetAddrBalance -> ActionT m Balance-scottyAddrBalance (GetAddrBalance addr) =- withMetrics (.addressBalance) $ getDefaultBalance addr---- | Handler for @GET \/address\/balances?addresses=…@: balances of several addresses.-scottyAddrsBalance ::- (MonadUnliftIO m) => GetAddrsBalance -> ActionT m [Balance]-scottyAddrsBalance (GetAddrsBalance addrs) =- withMetrics (.addressBalance) $ getBalances addrs---- | Handler for @GET \/address\/:address\/unspent@: unspent outputs of an address.-scottyAddrUnspent ::- (MonadUnliftIO m) => GetAddrUnspent -> ActionT m [Unspent]-scottyAddrUnspent (GetAddrUnspent addr pLimits) =- withMetrics (.addressUnspent) $- getAddressUnspents addr =<< paramToLimits False pLimits---- | Handler for @GET \/address\/unspent?addresses=…@: unspent outputs of several addresses.-scottyAddrsUnspent ::- (MonadUnliftIO m) => GetAddrsUnspent -> ActionT m [Unspent]-scottyAddrsUnspent (GetAddrsUnspent addrs pLimits) =- withMetrics (.addressUnspent) $- getAddressesUnspents addrs =<< paramToLimits False pLimits---- GET XPubs ------ | Handler for @GET \/xpub\/:xpub@: summary of an extended public key.-scottyXPub ::- (MonadUnliftIO m) => GetXPub -> ActionT m XPubSummary-scottyXPub (GetXPub xpub deriv (NoCache noCache)) =- withMetrics (.xpub) $- let xs = XPubSpec xpub deriv- in xPubSummary xs <$> lift (runNoCache noCache $ xPubBals xs)---- | Handler for @DELETE \/xpub\/:xpub@: remove an extended public key from the cache. Returns 'True' if it was cached.-scottyDelXPub ::- (MonadUnliftIO m) =>- DelCachedXPub ->- ActionT m (GenericResult Bool)-scottyDelXPub (DelCachedXPub xpub deriv) =- withMetrics (.xpubDelete) $ do- c <- askl (.config.store.cache)- n <- lift (withCache c $ cacheDelXPubs [XPubSpec xpub deriv])- return $ GenericResult (n > 0)---- | Transactions of an extended public key, optionally without the cache.-getXPubTxs ::- (MonadUnliftIO m) =>- XPubKey ->- DeriveType ->- LimitsParam ->- Bool ->- ActionT m [TxRef]-getXPubTxs xpub deriv plimits nocache = do- limits <- paramToLimits False plimits- let xs = XPubSpec xpub deriv- xbals <- xPubBals xs- lift $ runNoCache nocache $ xPubTxs xs xbals limits---- | Handler for @GET \/xpub\/:xpub\/transactions@: transactions of an extended public key.-scottyXPubTxs ::- (MonadUnliftIO m) => GetXPubTxs -> ActionT m [TxRef]-scottyXPubTxs (GetXPubTxs xpub deriv plimits (NoCache nocache)) =- withMetrics (.xpubTx) $- getXPubTxs xpub deriv plimits nocache---- | Handler for @GET \/xpub\/:xpub\/transactions\/full@: full transactions of an extended public key.-scottyXPubTxsFull ::- (MonadUnliftIO m) =>- GetXPubTxsFull ->- ActionT m [Transaction]-scottyXPubTxsFull (GetXPubTxsFull xpub deriv plimits (NoCache nocache)) =- withMetrics (.xpubTxFull) $- fmap catMaybes $- lift . runNoCache nocache . getTransactions . map (.txid)- =<< getXPubTxs xpub deriv plimits nocache---- | Handler for @GET \/xpub\/:xpub\/balances@: balances of the used addresses of an extended public key.-scottyXPubBalances ::- (MonadUnliftIO m) => GetXPubBalances -> ActionT m [XPubBal]-scottyXPubBalances (GetXPubBalances xpub deriv (NoCache noCache)) =- withMetrics (.xpubBalance) $- lift $- runNoCache noCache $- xPubBals (XPubSpec xpub deriv)---- | Handler for @GET \/xpub\/:xpub\/unspent@: unspent outputs of an extended public key.-scottyXPubUnspent ::- (MonadUnliftIO m) =>- GetXPubUnspent ->- ActionT m [XPubUnspent]-scottyXPubUnspent (GetXPubUnspent xpub deriv pLimits (NoCache noCache)) =- withMetrics (.xpubUnspent) $ do- limits <- paramToLimits False pLimits- let xspec = XPubSpec xpub deriv- xbals <- xPubBals xspec- lift $ runNoCache noCache $ xPubUnspents xspec xbals limits-------------------------------------------- Blockchain.info API Compatibility ---------------------------------------------- | Currency symbol of the coin of a network, in the Blockchain.info format.-netBinfoSymbol :: Network -> BinfoSymbol-netBinfoSymbol net- | net == btc =- BinfoSymbol- { code = "BTC",- symbol = "BTC",- name = "Bitcoin",- conversion = 100 * 1000 * 1000,- after = True,- local = False- }- | net == bch =- BinfoSymbol- { code = "BCH",- symbol = "BCH",- name = "Bitcoin Cash",- conversion = 100 * 1000 * 1000,- after = True,- local = False- }- | otherwise =- BinfoSymbol- { code = "XTS",- symbol = "¤",- name = "Test",- conversion = 100 * 1000 * 1000,- after = False,- local = False- }---- | Currency symbol of a fiat currency from its price ticker.-binfoTickerToSymbol :: Text -> BinfoTicker -> BinfoSymbol-binfoTickerToSymbol code t =- BinfoSymbol- { code,- symbol = t.symbol,- name,- conversion = 100 * 1000 * 1000 / t.fifteen, -- sat/usd- after = False,- local = True- }- where- name = case code of- "EUR" -> "Euro"- "USD" -> "U.S. dollar"- "GBP" -> "British pound"- x -> x---- | Addresses and xpubs in a request parameter, separated by pipes or commas.-getBinfoAddrsParam ::- (MonadUnliftIO m) =>- Text ->- ActionT m (HashSet BinfoAddr)-getBinfoAddrsParam name = do- net <- askl (.config.store.net)- ctx <- askl (.config.store.ctx)- p <- param name `rescue` return ""- if T.null p- then return HashSet.empty- else case parseBinfoAddr net ctx p of- Nothing -> raise (UserError "invalid address")- Just xs -> return $ HashSet.fromList xs---- | Xpubs and addresses in the @active@, @activeP2SH@ and @activeBech32@--- parameters. Xpubs in each parameter derive legacy, nested SegWit and native--- SegWit addresses.-getBinfoActive ::- (MonadUnliftIO m) =>- ActionT m (HashSet XPubSpec, HashSet Address)-getBinfoActive = do- active <- getBinfoAddrsParam "active"- p2sh <- getBinfoAddrsParam "activeP2SH"- bech32 <- getBinfoAddrsParam "activeBech32"- let xspec d b = (`XPubSpec` d) <$> xpub b- xspecs =- HashSet.fromList $- concat- [ mapMaybe (xspec DeriveNormal) (HashSet.toList active),- mapMaybe (xspec DeriveP2SH) (HashSet.toList p2sh),- mapMaybe (xspec DeriveP2WPKH) (HashSet.toList bech32)- ]- addrs = HashSet.fromList $ mapMaybe addr $ HashSet.toList active- return (xspecs, addrs)- where- addr (BinfoAddr a) = Just a- addr (BinfoXpub _) = Nothing- xpub (BinfoXpub x) = Just x- xpub (BinfoAddr _) = Nothing---- | Whether to identify transactions by short number, unless @txidindex@ is false.-getNumTxId :: (MonadUnliftIO m) => ActionT m Bool-getNumTxId = fmap not $ param "txidindex" `rescue` return False---- | Height of the best header.-getChainHeight :: (MonadUnliftIO m) => ActionT m H.BlockHeight-getChainHeight =- fmap (.height) $ liftIO . chainGetBest =<< askl (.config.store.chain)---- | Handler for @GET\/POST \/blockchain\/unspent@: unspent outputs of addresses and xpubs, as Blockchain.info.-scottyBinfoUnspent :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoUnspent =- withMetrics (.binfoUnspent) $ do- (xspecs, addrs) <- getBinfoActive- numtxid <- getNumTxId- limit <- get_limit- min_conf <- get_min_conf- net <- askl (.config.store.net)- ctx <- askl (.config.store.ctx)- height <- getChainHeight- let mn u = min_conf > u.confirmations- xbals <- lift $ getXBals xspecs- bus <-- lift . runConduit $- getBinfoUnspents numtxid height xbals xspecs addrs- .| (dropWhileC mn >> takeC limit .| sinkList)- setHeaders- streamEncoding (marshalEncoding (net, ctx) (BinfoUnspents bus))- where- get_limit = fmap (min 1000) $ param "limit" `rescue` return 250- get_min_conf = param "confirmations" `rescue` return 0---- | Stream the unspent outputs of xpubs and addresses, newest first, in the--- Blockchain.info format.-getBinfoUnspents ::- (StoreReadExtra m, MonadIO m) =>- Bool ->- H.BlockHeight ->- HashMap XPubSpec [XPubBal] ->- HashSet XPubSpec ->- HashSet Address ->- ConduitT () BinfoUnspent m ()-getBinfoUnspents numtxid height xbals xspecs addrs = do- cs' <- conduits- joinDescStreams cs' .| mapC (uncurry binfo)- where- binfo u xp =- let conf = case u.block of- MemRef _ -> 0- BlockRef h _ -> height - h + 1- in BinfoUnspent- { txid = u.outpoint.hash,- index = u.outpoint.index,- script = u.script,- value = u.value,- confirmations = fromIntegral conf,- txidx = encodeBinfoTxId numtxid u.outpoint.hash,- xpub = xp- }- conduits = (<>) <$> xconduits <*> pure acounduits- xconduits = lift $ do- let f x (XPubUnspent u p) =- let path = toSoft (listToPath p)- xp = BinfoXPubPath x.key <$> path- in (u, xp)- g x = do- let h = xPubUnspents x (xBals x xbals)- l = let Limits {..} = def in Limits {limit = 16, ..}- return $ streamThings h Nothing l .| mapC (f x)- mapM g (HashSet.toList xspecs)- acounduits =- let f u = (u, Nothing)- h = getAddressUnspents- l = let Limits {..} = def in Limits {limit = 16, ..}- g a = streamThings (h a) Nothing l .| mapC f- in map g (HashSet.toList addrs)---- | Balances of the used addresses of each xpub.-getXBals ::- (StoreReadExtra m) =>- HashSet XPubSpec ->- m (HashMap XPubSpec [XPubBal])-getXBals =- fmap HashMap.fromList . mapM f . HashSet.toList- where- f x = (x,) . filter (not . nullBalance . (.balance)) <$> xPubBals x---- | Balances of an xpub, or none if missing.-xBals :: XPubSpec -> HashMap XPubSpec [XPubBal] -> [XPubBal]-xBals = HashMap.findWithDefault []---- | Stream the transactions of xpubs and addresses, newest first, in the--- Blockchain.info format, with the running balance of the given addresses after--- each, keeping only those that pass the filter.-getBinfoTxs ::- (StoreReadExtra m, MonadIO m) =>- HashMap XPubSpec [XPubBal] -> -- xpub balances- HashMap Address (Maybe BinfoXPubPath) -> -- address book- HashSet XPubSpec -> -- show xpubs- HashSet Address -> -- show addrs- HashSet Address -> -- balance addresses- BinfoFilter ->- Bool -> -- numtxid- Bool -> -- prune outputs- Int64 -> -- starting balance- ConduitT () BinfoTx m ()-getBinfoTxs- xbals- abook- sxspecs- saddrs- baddrs- bfilter- numtxid- prune- bal = do- cs' <- conduits- joinDescStreams cs' .| go bal- where- sxspecs_ls = HashSet.toList sxspecs- saddrs_ls = HashSet.toList saddrs- conduits =- (<>)- <$> mapM xpub_c sxspecs_ls- <*> pure (map addr_c saddrs_ls)- xpub_c x = do- let f = xPubTxs x (xBals x xbals)- l = Limits {limit = 16, offset = 0, start = Nothing}- lift . return $- streamThings f (Just (.txid)) l- addr_c a = do- let l = Limits {limit = 16, offset = 0, start = Nothing}- streamThings (getAddressTxs a) (Just (.txid)) l- binfo_tx = toBinfoTx numtxid abook prune- compute_bal_change t =- let ins = map (.output) t.inputs- out = t.outputs- f b BinfoTxOutput {..} =- let val = fromIntegral value- in case address of- Nothing -> 0- Just a- | a `HashSet.member` baddrs ->- if b then val else negate val- | otherwise -> 0- in sum $ map (f False) ins <> map (f True) out- go b =- await >>= \case- Nothing -> return ()- Just (TxRef _ t) ->- lift (getTransaction t) >>= \case- Nothing -> go b- Just x -> do- let a = binfo_tx b x- b' = b - compute_bal_change a- c = isJust a.blockHeight- d = fst (fromJust a.balance)- r = d + fromIntegral a.fee- case bfilter of- BinfoFilterAll ->- yield a >> go b'- BinfoFilterSent- | 0 > r -> yield a >> go b'- | otherwise -> go b'- BinfoFilterReceived- | r > 0 -> yield a >> go b'- | otherwise -> go b'- BinfoFilterMoved- | r == 0 -> yield a >> go b'- | otherwise -> go b'- BinfoFilterConfirmed- | c -> yield a >> go b'- | otherwise -> go b'- BinfoFilterMempool- | c -> return ()- | otherwise -> yield a >> go b'---- | Whether to show addresses in CashAddr format (@cashaddr@).-getCashAddr :: (MonadUnliftIO m) => ActionT m Bool-getCashAddr = param "cashaddr" `rescue` return False---- | Parse an address. Responds with not found if invalid.-getAddress :: (MonadUnliftIO m) => Text -> ActionT m Address-getAddress txt = do- net <- askl (.config.store.net)- case textToAddr net txt of- Nothing -> raise ThingNotFound- Just a -> return a---- | Address or xpub in a path parameter. Passes to the next route if invalid.-getBinfoAddr :: (MonadUnliftIO m) => Text -> ActionT m BinfoAddr-getBinfoAddr param' = do- txt <- S.captureParam param'- net <- askl (.config.store.net)- ctx <- askl (.config.store.ctx)- let addr = BinfoAddr <$> textToAddr net txt- xpub = BinfoXpub <$> xPubImport net ctx txt- maybe S.next return (addr <|> xpub)---- | Handler for @GET\/POST \/blockchain\/export-history@: daily history of the balance of addresses and xpubs with prices, as Blockchain.info.-scottyBinfoHistory :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoHistory =- withMetrics (.binfoExportHistory) $ do- (xspecs, addrs) <- getBinfoActive- (startM, endM) <- get_dates- (code, price') <- getPrice- xbals <- getXBals xspecs- let xaddrs = HashSet.fromList $ concatMap (map get_addr) (HashMap.elems xbals)- aaddrs = xaddrs <> addrs- cur = price'.fifteen- cs' = conduits (HashMap.toList xbals) addrs endM- txs <-- lift $- runConduit $- joinDescStreams cs'- .| takeWhileC (is_newer startM)- .| concatMapMC get_transaction- .| sinkList- let times = map (\Transaction {..} -> timestamp) txs- net <- askl (.config.store.net)- url <- askl (.config.priceHistoryURL)- session <- askl (.session)- logger <- askl (.config.logger)- rates <- map (.price) <$> lift (getRates logger net session url code times)- let hs = zipWith (convert cur aaddrs) txs (rates <> repeat 0.0)- setHeaders- streamEncoding $ toEncoding hs- where- is_newer- (Just BlockData {height = bh})- TxRef {block = BlockRef {height = th}} =- bh <= th- is_newer Nothing TxRef {} = True- is_newer _ _ = undefined- get_addr = (.balance.address)- get_transaction TxRef {txid = h} =- getTransaction h- convert cur addrs tx rate =- let ins = tx.inputs- outs = tx.outputs- fins = filter (input_addr addrs) ins- fouts = filter (output_addr addrs) outs- vin = fromIntegral . sum $ map (.value) fins- vout = fromIntegral . sum $ map (.value) fouts- fee = tx.fee- v = vout - vin- t = tx.timestamp- h = txHash $ transactionData tx- in toBinfoHistory v t rate cur fee h- input_addr addrs' StoreInput {address = Just a} =- a `HashSet.member` addrs'- input_addr _ _ = False- output_addr addrs' StoreOutput {address = Just a} =- a `HashSet.member` addrs'- output_addr _ _ = False- get_dates = do- BinfoDate start <- param "start"- BinfoDate end' <- param "end"- let end = end' + 24 * 60 * 60- ch <- askl (.config.store.chain)- startM <- blockAtOrAfter ch start- endM <- blockAtOrBefore ch end- return (startM, endM)- conduits xpubs addrs endM =- map (uncurry (xpub_c endM)) xpubs- <> map (addr_c endM) (HashSet.toList addrs)- lim endM =- Limits- { limit = 16,- offset = 0,- start = AtBlock . (.height) <$> endM- }- addr_c endM a =- streamThings (getAddressTxs a) (Just (.txid)) (lim endM)- xpub_c endM x bs =- streamThings (xPubTxs x bs) (Just (.txid)) (lim endM)---- | Currency code in the @currency@ parameter, USD by default, and its latest price.-getPrice :: (MonadUnliftIO m) => ActionT m (Text, BinfoTicker)-getPrice = do- code <- T.toUpper <$> param "currency" `rescue` return "USD"- ticker <- askl (.ticker)- prices <- readTVarIO ticker- case HashMap.lookup code prices of- Nothing -> return (code, def)- Just p -> return (code, p)---- | Symbol of the currency in the @currency@ parameter.-getSymbol :: (MonadUnliftIO m) => ActionT m BinfoSymbol-getSymbol = uncurry binfoTickerToSymbol <$> getPrice---- | Handler for @GET \/blockchain\/blocks\/:milliseconds@: the blocks of the day before a time in milliseconds, as Blockchain.info.-scottyBinfoBlocksDay :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoBlocksDay =- withMetrics (.binfoBlock) $ do- t <- min h . (`div` 1000) <$> S.captureParam "milliseconds"- ch <- askl (.config.store.chain)- m <- blockAtOrBefore ch t- bs <- go (d t) m- streamEncoding $ toEncoding $ map toBinfoBlockInfo bs- where- h = fromIntegral (maxBound :: H.Timestamp)- d = subtract (24 * 3600)- go _ Nothing = return []- go t (Just b)- | b.header.timestamp <= fromIntegral t =- return []- | otherwise = do- b' <- getBlock b.header.prev- (b :) <$> go t b'---- | Handler for @GET\/POST \/blockchain\/multiaddr@: balances and transactions of addresses and xpubs, as Blockchain.info.-scottyMultiAddr :: (MonadUnliftIO m) => ActionT m ()-scottyMultiAddr =- withMetrics (.binfoMultiaddr) $ do- (addrs', _, saddrs, sxpubs, xspecs) <- get_addrs- numtxid <- getNumTxId- cashaddr <- getCashAddr- local' <- getSymbol- offset <- getBinfoOffset- n <- getBinfoCount "n"- prune <- get_prune- fltr <- get_filter- xbals <- getXBals xspecs- xtxns <- get_xpub_tx_count xbals xspecs- let sxbals = only_show_xbals sxpubs xbals- xabals = compute_xabals xbals- addrs = addrs' `HashSet.difference` HashMap.keysSet xabals- abals <- get_abals addrs- let sxspecs = only_show_xspecs sxpubs xspecs- sxabals = compute_xabals sxbals- sabals = only_show_abals saddrs abals- sallbals = sabals <> sxabals- sbal = compute_bal sallbals- allbals = abals <> xabals- abook = compute_abook addrs xbals- sxaddrs = compute_xaddrs sxbals- salladdrs = saddrs <> sxaddrs- bal = compute_bal allbals- ibal = fromIntegral sbal- ftxs <-- lift . runConduit $- getBinfoTxs- xbals- abook- sxspecs- saddrs- salladdrs- fltr- numtxid- prune- ibal- .| (dropC offset >> takeC n .| sinkList)- net <- askl (.config.store.net)- ctx <- askl (.config.store.ctx)- best <- get_best_block- peers <- get_peers- let baddrs = toBinfoAddrs sabals sxbals xtxns- abaddrs = toBinfoAddrs abals xbals xtxns- recv = sum $ map (.received) abaddrs- sent' = sum $ map (.sent) abaddrs- txn = fromIntegral $ length ftxs- wallet =- BinfoWallet- { balance = bal,- txs = txn,- filtered = txn,- received = recv,- sent = sent'- }- coin = netBinfoSymbol net- let block =- BinfoBlockInfo- { hash = H.headerHash best.header,- height = best.height,- timestamp = best.header.timestamp,- index = best.height- }- let info =- BinfoInfo- { connected = peers,- conversion = 100 * 1000 * 1000,- fiat = local',- crypto = coin,- head = block- }- let multiaddr =- BinfoMultiAddr- { addresses = baddrs,- wallet = wallet,- txs = ftxs,- info = info,- recommendFee = True,- cashAddr = cashaddr- }- setHeaders- streamEncoding $ marshalEncoding (net, ctx) multiaddr- where- get_xpub_tx_count xbals =- fmap HashMap.fromList- . mapM- ( \x ->- (x,)- . fromIntegral- <$> xPubTxCount x (xBals x xbals)- )- . HashSet.toList- get_filter = param "filter" `rescue` return BinfoFilterAll- get_best_block =- getBestBlock >>= \case- Nothing -> raise ThingNotFound- Just bh ->- getBlock bh >>= \case- Nothing -> raise ThingNotFound- Just b -> return b- get_prune =- fmap not $- param "noCompact"- `rescue` param "no_compact"- `rescue` return False- only_show_xbals sxpubs =- HashMap.filterWithKey (\k _ -> k.key `HashSet.member` sxpubs)- only_show_xspecs sxpubs =- HashSet.filter (\k -> k.key `HashSet.member` sxpubs)- only_show_abals saddrs =- HashMap.filterWithKey (\k _ -> k `HashSet.member` saddrs)- addr (BinfoAddr a) = Just a- addr (BinfoXpub _) = Nothing- xpub (BinfoXpub x) = Just x- xpub (BinfoAddr _) = Nothing- get_addrs = do- (xspecs, addrs) <- getBinfoActive- sh <- getBinfoAddrsParam "onlyShow"- let xpubs = HashSet.map (.key) xspecs- actives =- HashSet.map BinfoAddr addrs- <> HashSet.map BinfoXpub xpubs- sh' = if HashSet.null sh then actives else sh- saddrs = HashSet.fromList $ mapMaybe addr $ HashSet.toList sh'- sxpubs = HashSet.fromList $ mapMaybe xpub $ HashSet.toList sh'- return (addrs, xpubs, saddrs, sxpubs, xspecs)- get_abals =- let f b = (b.address, b)- g = HashMap.fromList . map f- in fmap g . getBalances . HashSet.toList- get_peers = do- ps <- lift $ getPeersInformation =<< asks (.config.store.peerMgr)- return (fromIntegral (length ps))- compute_xabals =- let f b = (b.balance.address, b.balance)- in HashMap.fromList . concatMap (map f) . HashMap.elems- compute_bal =- let f b = b.confirmed + b.unconfirmed- in sum . map f . HashMap.elems- compute_abook addrs xbals =- let f xs xb =- let a = xb.balance.address- e = error "lions and tigers and bears"- s = toSoft (listToPath xb.path)- in (a, Just (BinfoXPubPath xs.key (fromMaybe e s)))- amap =- HashMap.map (const Nothing) $- HashSet.toMap addrs- xmap =- HashMap.fromList- . concatMap (uncurry (map . f))- $ HashMap.toList xbals- in amap <> xmap- compute_xaddrs =- let f = map (.balance.address)- in HashSet.fromList . concatMap f . HashMap.elems---- | Number of items requested in a parameter, within the limits.-getBinfoCount :: (MonadUnliftIO m) => Text -> ActionT m Int-getBinfoCount str = do- d <- askl (.config.limits.defItemCount)- x <- askl (.config.limits.maxFullItemCount)- i <- min x <$> (param str `rescue` return d)- return (fromIntegral i :: Int)---- | Offset requested in the @offset@ parameter. Responds with an error if above the maximum.-getBinfoOffset ::- (MonadUnliftIO m) =>- ActionT m Int-getBinfoOffset = do- x <- askl (.config.limits.maxOffset)- o <- param "offset" `rescue` return 0- when (o > x) $- raise $- UserError $- "offset exceeded: " <> show o <> " > " <> show x- return (fromIntegral o :: Int)---- | Handler for @GET \/blockchain\/rawaddr\/:addr@: balance and transactions of an address or xpub, as Blockchain.info; also at @\/blockchain\/address\/:addr@ and @\/blockchain\/xpub\/:addr@.-scottyRawAddr :: (MonadUnliftIO m) => ActionT m ()-scottyRawAddr =- withMetrics (.binfoAddressRaw) $- getBinfoAddr "addr" >>= \case- BinfoAddr addr -> do_addr addr- BinfoXpub xpub -> do_xpub xpub- where- do_xpub xpub = do- numtxid <- getNumTxId- derive <- param "derive" `rescue` return DeriveNormal- let xspec = XPubSpec xpub derive- n <- getBinfoCount "limit"- off <- getBinfoOffset- xbals <- getXBals $ HashSet.singleton xspec- net <- askl (.config.store.net)- let summary = xPubSummary xspec (xBals xspec xbals)- abook = compute_abook xpub (xBals xspec xbals)- xspecs = HashSet.singleton xspec- saddrs = HashSet.empty- baddrs = HashMap.keysSet abook- bfilter = BinfoFilterAll- amnt = summary.confirmed + summary.unconfirmed- txs <-- lift $- runConduit $- getBinfoTxs- xbals- abook- xspecs- saddrs- baddrs- bfilter- numtxid- False- (fromIntegral amnt)- .| (dropC off >> takeC n .| sinkList)- let ra =- BinfoRawAddr- { address = BinfoXpub xpub,- balance = amnt,- ntx = fromIntegral $ length txs,- utxo = summary.utxo,- received = summary.received,- sent = fromIntegral summary.received - fromIntegral amnt,- txs = txs- }- setHeaders- ctx <- askl (.config.store.ctx)- streamEncoding $ marshalEncoding (net, ctx) ra- compute_abook xpub xbals =- let f xb =- let a = xb.balance.address- e = error "black hole swallows all your code"- s = toSoft $ listToPath xb.path- m = fromMaybe e s- in (a, Just (BinfoXPubPath xpub m))- in HashMap.fromList $ map f xbals- do_addr addr = do- numtxid <- getNumTxId- n <- getBinfoCount "limit"- off <- getBinfoOffset- bal <- fromMaybe (zeroBalance addr) <$> getBalance addr- net <- askl (.config.store.net)- let abook = HashMap.singleton addr Nothing- xspecs = HashSet.empty- saddrs = HashSet.singleton addr- bfilter = BinfoFilterAll- balance = bal.confirmed + bal.unconfirmed- txs <-- lift . runConduit $- getBinfoTxs- HashMap.empty- abook- xspecs- saddrs- saddrs- bfilter- numtxid- False- (fromIntegral balance)- .| (dropC off >> takeC n .| sinkList)- let ra =- BinfoRawAddr- { address = BinfoAddr addr,- balance,- ntx = bal.txs,- utxo = bal.utxo,- received = bal.received,- sent = fromIntegral bal.received - fromIntegral balance,- txs- }- setHeaders- ctx <- askl (.config.store.ctx)- streamEncoding $ marshalEncoding (net, ctx) ra---- | Handler for @GET \/blockchain\/q\/getreceivedbyaddress\/:addr@: total received by an address.-scottyBinfoReceived :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoReceived =- withMetrics (.binfoQgetreceivedbyaddress) $ do- a <- getAddress =<< S.captureParam "addr"- b <- fromMaybe (zeroBalance a) <$> getBalance a- setHeaders- S.text $ cs $ show b.received---- | Handler for @GET \/blockchain\/q\/getsentbyaddress\/:addr@: total sent by an address.-scottyBinfoSent :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoSent =- withMetrics (.binfoQgetsentbyaddress) $ do- a <- getAddress =<< S.captureParam "addr"- b <- fromMaybe (zeroBalance a) <$> getBalance a- setHeaders- S.text $ cs $ show $ b.received - b.confirmed - b.unconfirmed---- | Handler for @GET \/blockchain\/q\/addressbalance\/:addr@: balance of an address.-scottyBinfoAddrBalance :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoAddrBalance =- withMetrics (.binfoQaddressbalance) $ do- a <- getAddress =<< S.captureParam "addr"- b <- fromMaybe (zeroBalance a) <$> getBalance a- setHeaders- S.text $ cs $ show $ b.confirmed + b.unconfirmed---- | Handler for @GET \/blockchain\/q\/addressfirstseen\/:addr@: time of the first block with a transaction of an address.-scottyFirstSeen :: (MonadUnliftIO m) => ActionT m ()-scottyFirstSeen =- withMetrics (.binfoQaddressfirstseen) $ do- a <- getAddress =<< S.captureParam "addr"- ch <- askl (.config.store.chain)- bb <- liftIO $ chainGetBest ch- let top = bb.height- bot = 0- i <- go ch bb a bot top- setHeaders- S.text $ cs $ show i- where- go ch bb a bot top = do- let mid = bot + (top - bot) `div` 2- n = top - bot < 2- x <- hasone a bot- y <- hasone a mid- z <- hasone a top- if- | x -> getblocktime ch bb bot- | n -> getblocktime ch bb top- | y -> go ch bb a bot mid- | z -> go ch bb a mid top- | otherwise -> return 0- getblocktime ch bb h =- liftIO (chainGetAncestor ch h bb) >>= \case- Just b -> return b.header.timestamp- Nothing -> do- logger <- askl (.config.logger)- logError logger $- "Could not get ancestor at height "- <> cs (show h)- <> " for block "- <> H.blockHashToHex (H.headerHash bb.header)- error "Block ancestor retreival error"- hasone a h = do- let l = Limits 1 0 (Just (AtBlock h))- not . null <$> getAddressTxs a l---- | Handler for @GET\/POST \/blockchain\/balance@: short balances of addresses and xpubs, as Blockchain.info.-scottyShortBal :: (MonadUnliftIO m) => ActionT m ()-scottyShortBal =- withMetrics (.binfoBalance) $ do- (xspecs, addrs) <- getBinfoActive- cashaddr <- getCashAddr- net <- askl (.config.store.net)- abals <- catMaybes <$> mapM (getabal net cashaddr) (HashSet.toList addrs)- xbals <- mapM (getxbal net) (HashSet.toList xspecs)- let res = HashMap.fromList (abals <> xbals)- setHeaders- streamEncoding $ toEncoding res- where- shorten bal =- BinfoShortBal- { final = bal.confirmed + bal.unconfirmed,- ntx = bal.txs,- received = bal.received- }- getabal net cashaddr a =- let net' =- if- | cashaddr -> net- | net == bch -> btc- | net == bchTest -> btcTest- | net == bchTest4 -> btcTest- | otherwise -> net- in case addrToText net' a of- Nothing -> return Nothing- Just a' ->- getBalance a >>= \case- Nothing -> return $ Just (a', shorten (zeroBalance a))- Just b -> return $ Just (a', shorten b)- getxbal net xpub = do- xbals <- xPubBals xpub- ntx <- fromIntegral <$> xPubTxCount xpub xbals- let val = sum $ map (.balance.confirmed) xbals- zro = sum $ map (.balance.unconfirmed) xbals- exs = [x | x@XPubBal {path = 0 : _} <- xbals]- final = val + zro- received = sum $ map (.balance.received) exs- sbl = BinfoShortBal {final, ntx, received}- ctx <- askl (.config.store.ctx)- return (xPubExport net ctx xpub.key, sbl)---- | Whether to respond with hex (@format=hex@).-getBinfoHex :: (MonadUnliftIO m) => ActionT m Bool-getBinfoHex = (== ("hex" :: Text)) <$> param "format" `rescue` return "json"---- | Handler for @GET \/blockchain\/block-height\/:height@: the blocks at a height, as Blockchain.info.-scottyBinfoBlockHeight :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoBlockHeight =- withMetrics (.binfoBlockHeight) $ do- numtxid <- getNumTxId- height <- S.captureParam "height"- bs <- fmap catMaybes $ getBlocksAtHeight height >>= mapM getBlock- ns <- fmap catMaybes $ getBlocksAtHeight (height + 1) >>= mapM getBlock- is <- mapM (get_binfo_blocks numtxid ns) bs- setHeaders- net <- askl (.config.store.net)- ctx <- askl (.config.store.ctx)- streamEncoding $ marshalEncoding (net, ctx) is- where- get_binfo_blocks numtxid ns b = do- txs <- catMaybes <$> getTransactions b.txs- let h x = H.headerHash x.header- nbs = [h n | n <- ns, n.header.prev == h b]- bts = map (toBinfoTxSimple numtxid) txs- return $ toBinfoBlock b bts nbs---- | Handler for @GET \/blockchain\/latestblock@: the best block, as Blockchain.info.-scottyBinfoLatest :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoLatest =- withMetrics (.binfoBlockLatest) $ do- numtxid <- getNumTxId- mb <- runMaybeT $ MaybeT getBestBlock >>= MaybeT . getBlock- b <- maybe (raise ThingNotFound) return mb- streamEncoding $- toEncoding- BinfoHeader- { hash = H.headerHash b.header,- timestamp = b.header.timestamp,- index = b.height,- height = b.height,- txids = map (encodeBinfoTxId numtxid) b.txs- }---- | Handler for @GET \/blockchain\/rawblock\/:block@: a block with its transactions, as Blockchain.info.-scottyBinfoBlock :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoBlock =- withMetrics (.binfoBlockRaw) $ do- numtxid <- getNumTxId- hex <- getBinfoHex- S.captureParam "block" >>= \case- BinfoBlockHash bh -> go numtxid hex bh- BinfoBlockIndex i ->- getBlocksAtHeight i >>= \case- [] -> raise ThingNotFound- bh : _ -> go numtxid hex bh- where- go numtxid hex bh = do- b <- maybe (raise ThingNotFound) return =<< getBlock bh- txs <- catMaybes <$> getTransactions b.txs- nhs <- fmap catMaybes $ getBlocksAtHeight (b.height + 1) >>= mapM getBlock- let h x = H.headerHash x.header- nxt = [h n | n <- nhs, n.header.prev == h b]- if hex- then do- let x = H.Block b.header (map transactionData txs)- setHeaders- S.text . encodeHexLazy . runPutL $ serialize x- else do- let bts = map (toBinfoTxSimple numtxid) txs- y = toBinfoBlock b bts nxt- setHeaders- net <- askl (.config.store.net)- ctx <- askl (.config.store.ctx)- streamEncoding $ marshalEncoding (net, ctx) y---- | Transaction by hash or short number. Fails with 'TxIndexConflict' if a short number matches several.-getBinfoTx ::- (MonadUnliftIO m) =>- BinfoTxId ->- ActionT m (Either Except Transaction)-getBinfoTx txid = do- tx <- case txid of- BinfoTxIdHash h -> maybeToList <$> getTransaction h- BinfoTxIdIndex i -> getNumTransaction i- case tx of- [t] -> return $ Right t- [] -> return $ Left ThingNotFound- ts ->- let tids = map (txHash . transactionData) ts- in return $ Left (TxIndexConflict tids)---- | Handler for @GET \/blockchain\/rawtx\/:txid@: a transaction, as Blockchain.info.-scottyBinfoTx :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoTx =- withMetrics (.binfoTxRaw) $ do- numtxid <- getNumTxId- hex <- getBinfoHex- txid <- S.captureParam "txid"- tx <-- getBinfoTx txid >>= \case- Right t -> return t- Left e -> raise e- if hex then hx tx else js numtxid tx- where- js numtxid t = do- net <- askl (.config.store.net)- ctx <- askl (.config.store.ctx)- setHeaders- streamEncoding $ marshalEncoding (net, ctx) $ toBinfoTxSimple numtxid t- hx t = do- setHeaders- S.text . encodeHexLazy . runPutL . serialize $ transactionData t---- | Handler for @GET \/blockchain\/q\/txtotalbtcoutput\/:txid@: total value of the outputs of a transaction.-scottyBinfoTotalOut :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoTotalOut =- withMetrics (.binfoQtxtotalbtcoutput) $ do- txid <- S.captureParam "txid"- tx <-- getBinfoTx txid >>= \case- Right t -> return t- Left e -> raise e- S.text $ cs $ show $ sum $ map (.value) tx.outputs---- | Handler for @GET \/blockchain\/q\/txfee\/:txid@: fee of a transaction.-scottyBinfoTxFees :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoTxFees =- withMetrics (.binfoQtxfee) $ do- txid <- S.captureParam "txid"- tx <-- getBinfoTx txid >>= \case- Right t -> return t- Left e -> raise e- let i = sum $ map (.value) $ filter f tx.inputs- o = sum $ map (.value) tx.outputs- S.text . cs . show $ i - o- where- f StoreInput {} = True- f StoreCoinbase {} = False---- | Handler for @GET \/blockchain\/q\/txresult\/:txid\/:addr@: net amount that a transaction sends to an address.-scottyBinfoTxResult :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoTxResult =- withMetrics (.binfoQtxresult) $ do- txid <- S.captureParam "txid"- addr <- getAddress =<< S.captureParam "addr"- tx <-- getBinfoTx txid >>= \case- Right t -> return t- Left e -> raise e- let i = toInteger $ sum $ map (.value) $ filter (f addr) tx.inputs- o = toInteger $ sum $ map (.value) $ filter (g addr) tx.outputs- S.text $ cs $ show $ o - i- where- f addr StoreInput {address = Just a} = a == addr- f _ _ = False- g addr StoreOutput {address = Just a} = a == addr- g _ _ = False---- | Handler for @GET \/blockchain\/q\/txtotalbtcinput\/:txid@: total value of the inputs of a transaction.-scottyBinfoTotalInput :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoTotalInput =- withMetrics (.binfoQtxtotalbtcinput) $ do- txid <- S.captureParam "txid"- tx <-- getBinfoTx txid >>= \case- Right t -> return t- Left e -> raise e- S.text $ cs $ show $ sum $ map (.value) $ filter f $ tx.inputs- where- f StoreInput {} = True- f StoreCoinbase {} = False---- | Handler for @GET \/blockchain\/unconfirmed-transactions@: mempool transactions, as Blockchain.info.-scottyBinfoMempool :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoMempool =- withMetrics (.binfoMempool) $ do- numtxid <- getNumTxId- offset <- getBinfoOffset- n <- getBinfoCount "limit"- -- a limit of 0 would mean no limit to getMempoolPage- txids <-- if n <= 0- then return []- else- map snd- <$> getMempoolPage (Limits (fromIntegral n) (fromIntegral offset) Nothing)- txs <- catMaybes <$> getTransactions txids- net <- askl (.config.store.net)- setHeaders- let mem = BinfoMempool $ map (toBinfoTxSimple numtxid) txs- ctx <- askl (.config.store.ctx)- streamEncoding $ marshalEncoding (net, ctx) mem---- | Handler for @GET \/blockchain\/q\/getblockcount@: height of the best header.-scottyBinfoGetBlockCount :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoGetBlockCount =- withMetrics (.binfoQgetblockcount) $ do- ch <- askl (.config.store.chain)- bn <- liftIO $ chainGetBest ch- setHeaders- S.text $ TL.pack $ show bn.height---- | Handler for @GET \/blockchain\/q\/latesthash@: hash of the best header.-scottyBinfoLatestHash :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoLatestHash =- withMetrics (.binfoQlatesthash) $ do- ch <- askl (.config.store.chain)- bn <- liftIO $ chainGetBest ch- setHeaders- S.text $ TL.fromStrict $ H.blockHashToHex $ H.headerHash bn.header---- | Handler for @GET \/blockchain\/q\/bcperblock@: block subsidy at the next height.-scottyBinfoSubsidy :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoSubsidy =- withMetrics (.binfoQbcperblock) $ do- ch <- askl (.config.store.chain)- net <- askl (.config.store.net)- bn <- liftIO $ chainGetBest ch- setHeaders- S.text $- cs $- show $- (/ (100 * 1000 * 1000 :: Double)) $- fromIntegral $- H.computeSubsidy net (bn.height + 1)---- | Handler for @GET \/blockchain\/q\/addresstohash\/:addr@: hash160 of an address in hex.-scottyBinfoAddrToHash :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoAddrToHash =- withMetrics (.binfoQaddresstohash) $ do- addr <- getAddress =<< S.captureParam "addr"- setHeaders- if- | Just h <- addressHash160 addr ->- S.text $ encodeHexLazy $ runPutL $ serialize h- | Just h <- addressHash256 addr ->- S.text $ encodeHexLazy $ runPutL $ serialize h- | otherwise ->- return ()---- | Handler for @GET \/blockchain\/q\/hashtoaddress\/:hash@: pay-to-public-key-hash address of a hash160.-scottyBinfoHashToAddr :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoHashToAddr =- withMetrics (.binfoQhashtoaddress) $ do- bs <- maybe S.next return . decodeHex =<< S.captureParam "hash"- net <- askl (.config.store.net)- hash <- either (const S.next) return $ decode bs- addr <- maybe S.next return $ addrToText net $ PubKeyAddress hash- setHeaders- S.text $ TL.fromStrict addr---- | Handler for @GET \/blockchain\/q\/addrpubkey\/:pubkey@: address of a public key.-scottyBinfoAddrPubkey :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoAddrPubkey =- withMetrics (.binfoQaddrpubkey) $ do- hex <- S.captureParam "pubkey"- ctx <- askl (.config.store.ctx)- pubkey <-- maybe S.next (return . pubKeyAddr ctx) $- eitherToMaybe . unmarshal ctx =<< decodeHex hex- net <- askl (.config.store.net)- setHeaders- case addrToText net pubkey of- Nothing -> raise ThingNotFound- Just a -> S.text $ TL.fromStrict a---- | Handler for @GET \/blockchain\/q\/pubkeyaddr\/:addr@: public key of an address, found in a transaction input that spends from it.-scottyBinfoPubKeyAddr :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoPubKeyAddr =- withMetrics (.binfoQpubkeyaddr) $ do- addr <- getAddress =<< S.captureParam "addr"- mi <- strm addr- i <- case mi of- Nothing -> raise ThingNotFound- Just i -> return i- pk <- case extr addr i of- Left e -> raise $ UserError e- Right t -> return t- setHeaders- S.text $ encodeHexLazy $ L.fromStrict pk- where- strm addr = do- runConduit $ do- let l = Limits {limit = 8, offset = 0, start = Nothing}- streamThings (getAddressTxs addr) (Just (.txid)) l- .| concatMapMC (getTransaction . (.txid))- .| concatMapC (filter (inp addr) . (.inputs))- .| headC- inp addr StoreInput {address = Just a} = a == addr- inp _ _ = False- extr addr StoreInput {script, pkscript, witness} = do- Script sig <- decode script- Script pks <- decode pkscript- case addr of- PubKeyAddress {} ->- case sig of- [OP_PUSHDATA _ _, OP_PUSHDATA pub _] ->- Right pub- [OP_PUSHDATA _ _] ->- case pks of- [OP_PUSHDATA pub _, OP_CHECKSIG] ->- Right pub- _ -> Left "Could not parse scriptPubKey"- _ -> Left "Could not parse scriptSig"- WitnessPubKeyAddress {} ->- case witness of- [_, pub] -> return pub- _ -> Left "Could not parse scriptPubKey"- _ -> Left "Address does not have public key"- extr _ _ = Left "Incorrect input type"---- | Handler for @GET \/blockchain\/q\/hashpubkey\/:pubkey@: hash160 of a public key in hex.-scottyBinfoHashPubkey :: (MonadUnliftIO m) => ActionT m ()-scottyBinfoHashPubkey =- withMetrics (.binfoQhashpubkey) $ do- ctx <- askl (.config.store.ctx)- pkm <-- fmap- (eitherToMaybe . unmarshal ctx <=< decodeHex)- (S.captureParam "pubkey")- addr <- case pkm of- Nothing -> raise $ UserError "Could not decode public key"- Just pk -> return $ pubKeyAddr ctx pk- setHeaders- if- | Just h <- addressHash160 addr ->- S.text $ encodeHexLazy $ runPutL $ serialize h- | Just h <- addressHash256 addr ->- S.text $ encodeHexLazy $ runPutL $ serialize h- | otherwise ->- return ()---- GET Network Information ------ | Handler for @GET \/peers@: connected peers.-scottyPeers ::- (MonadUnliftIO m) =>- GetPeers ->- ActionT m [PeerInfo]-scottyPeers _ =- withMetrics (.peers) $ do- lift . getPeersInformation =<< askl (.config.store.peerMgr)---- | Obtain information about connected peers from peer manager process.-getPeersInformation ::- (MonadIO m) => PeerMgr -> m [PeerInfo]-getPeersInformation mgr =- mapMaybe toInfo <$> liftIO (getPeers mgr)- where- toInfo op = do- ver <- op.version- return- PeerInfo- { userAgent = ver.userAgent.get,- address = show op.address,- -- Signed, as the peer sent it. See 'PeerInfo'.- version = ver.version,- services = ver.services,- relay = ver.relay- }---- | Handler for @GET \/health@: health of the store.-scottyHealth ::- (MonadUnliftIO m) => GetHealth -> ActionT m HealthCheck-scottyHealth _ =- withMetrics (.health) $ do- h <- askl (.health) >>= readTVarIO- unless (isOK h) $ S.status status503- return h---- | Heights of the best header and best block, and the maximum lag allowed.-blockHealthCheck ::- (MonadUnliftIO m, StoreReadBase m) =>- WebConfig ->- m BlockHealth-blockHealthCheck cfg = do- let ch = cfg.store.chain- headers <- (.height) <$> liftIO (chainGetBest ch)- blocks <-- maybe 0 (.height)- <$> runMaybeT (MaybeT getBestBlock >>= MaybeT . getBlock)- return- BlockHealth- { headers,- blocks,- max = fromIntegral cfg.maxLaggingBlocks- }---- | Age of the best header and the maximum allowed.-lastBlockHealthCheck ::- (MonadUnliftIO m, StoreReadBase m) =>- Chain ->- WebLimits ->- m TimeHealth-lastBlockHealthCheck ch WebLimits {blockTimeout} = do- n <- round <$> liftIO getPOSIXTime- t <- fromIntegral . (.header.timestamp) <$> liftIO (chainGetBest ch)- return- TimeHealth- { age = n - t,- max = fromIntegral blockTimeout- }---- | Age of the newest mempool transaction or best header, and the maximum allowed.-lastTxHealthCheck ::- (MonadUnliftIO m, StoreReadBase m) =>- WebConfig ->- m TimeHealth-lastTxHealthCheck WebConfig {noMempool, store = store', limits} = do- n <- round <$> liftIO getPOSIXTime- b <- fromIntegral . (.header.timestamp) <$> liftIO (chainGetBest ch)- t <-- getMempoolPage (Limits 1 0 Nothing) >>= \case- t : _ ->- let x = fromIntegral $ fst t- in return $ max x b- [] -> return b- return- TimeHealth- { age = n - t,- max = fromIntegral to- }- where- ch = store'.chain- to =- if noMempool- then limits.blockTimeout- else limits.txTimeout---- | Transactions waiting to be imported and the maximum allowed.-pendingTxsHealthCheck ::- (MonadUnliftIO m, StoreReadBase m) =>- WebConfig ->- m MaxHealth-pendingTxsHealthCheck cfg = do- n <- blockStorePendingTxs cfg.store.block- return- MaxHealth- { max = fromIntegral cfg.maxPendingTxs,- count = fromIntegral n- }---- | Online peers and the minimum required.-peerHealthCheck ::- (MonadUnliftIO m, StoreReadBase m) =>- WebConfig ->- m CountHealth-peerHealthCheck cfg = do- count <- fromIntegral . length <$> liftIO (getPeers cfg.store.peerMgr)- return CountHealth {min = fromIntegral cfg.minPeers, count}---- | Run every health check.-healthCheck ::- (MonadUnliftIO m, StoreReadBase m) => WebConfig -> m HealthCheck-healthCheck cfg = do- blocks <- blockHealthCheck cfg- lastBlock <- lastBlockHealthCheck cfg.store.chain cfg.limits- lastTx <- lastTxHealthCheck cfg- pendingTxs <- pendingTxsHealthCheck cfg- peers <- peerHealthCheck cfg- time <- round <$> liftIO getPOSIXTime- return- HealthCheck- { network = cfg.store.net.name,- version = cfg.version,- ..- }---- | Handler for @GET \/dbstats@: RocksDB memory use and statistics, as text.-scottyDbStats :: (MonadUnliftIO m) => ActionT m ()-scottyDbStats =- withMetrics (.db) $ do- setHeaders- db <- askl (.config.store.db.db)- memory <- liftIO (memoryReport db)- statsM <- liftIO (getProperty db Stats)- S.text . cs $ memory <> maybe "Could not get stats" cs statsM---------------------------- Parameter Parsing ------------------------------ | Returns @Nothing@ if the parameter is not supplied. Raises an exception on--- parse failure.-paramOptional :: forall a m. (Param a, MonadUnliftIO m) => ActionT m (Maybe a)-paramOptional = do- net <- askl (.config.store.net)- ctx <- askl (.config.store.ctx)- fmap Just (param label) `rescue` return Nothing >>= \case- Nothing -> return Nothing -- Parameter was not supplied- Just ts -> maybe (raise err) (return . Just) (parseParam net ctx ts)- where- label = proxyLabel (Proxy :: Proxy a)- err = UserError $ "Unable to parse param " <> T.unpack label---- | Raises an exception if the parameter is not supplied-paramRequired :: forall a m. (Param a, MonadUnliftIO m) => ActionT m a-paramRequired = maybe (raise err) return =<< paramOptional- where- label = T.unpack (proxyLabel (Proxy :: Proxy a))- err = UserError $ "Param " <> label <> " not defined"---- | Returns the default value of a parameter if it is not supplied. Raises an--- exception on parse failure.-paramDef :: (Default a, Param a, MonadUnliftIO m) => ActionT m a-paramDef = fromMaybe def <$> paramOptional---- | Does not raise exceptions. Will call @Scotty.next@ if the parameter is--- not supplied or if parsing fails.-paramCapture :: forall a m. (Param a, MonadUnliftIO m) => ActionT m a-paramCapture = do- net <- askl (.config.store.net)- ctx <- askl (.config.store.ctx)- p <- S.captureParam label `rescue` S.next- maybe S.next return (parseParam net ctx p)- where- label = proxyLabel (Proxy :: Proxy a)---- | Read the transaction that a request carries in its body.-parseTxBody :: (MonadUnliftIO m) => ActionT m Tx-parseTxBody = do- net <- askl (.config.store.net)- b <- L.toStrict <$> S.body- case decodeTxBody net b of- Left _ -> raise $ UserError "Failed to parse request body"- Right tx -> return tx---- | Read a transaction from a request body. The body is the transaction in--- hex or as raw bytes. White space in the hex is ignored.------ The body must hold one transaction and nothing else. Bytes after the--- transaction are refused, as Bitcoin Core does for a raw transaction.------ The transaction must be in the format that peers of the network read. A--- network without SegWit, such as Bitcoin Cash, has the format without--- witness only. Its nodes would not read the SegWit format, so the store--- does not take it either.-decodeTxBody :: Network -> C.ByteString -> Either String Tx-decodeTxBody net body = fromHex <> fromBytes body- where- fromBytes = runGetS $ do- tx <- getNetworkTx net- done <- isEmpty- unless done $ fail "Trailing bytes after transaction"- return tx- fromHex =- case parseBase16 (C.filter (not . isSpace) body) of- Right b16 -> fromBytes (decodeBase16 b16)- Left e -> Left (cs e)---- | Offset in the @offset@ parameter. Responds with an error if above the maximum.-parseOffset :: (MonadUnliftIO m) => ActionT m OffsetParam-parseOffset = do- res@(OffsetParam o) <- paramDef- limits <- askl (.config.limits)- when (limits.maxOffset > 0 && fromIntegral o > limits.maxOffset) $- raise . UserError $- "offset exceeded: " <> show o <> " > " <> show limits.maxOffset- return res---- | Turn a start parameter into a position: a transaction, the block of a block--- hash, a height, or the last block at or before a time.-parseStart ::- (MonadUnliftIO m) =>- Maybe StartParam ->- ActionT m (Maybe Start)-parseStart Nothing = return Nothing-parseStart (Just s) =- runMaybeT $- case s of- StartParamHash {hash = h} -> start_tx h <|> start_block h- StartParamHeight {height = h} -> start_height h- StartParamTime {time = q} -> start_time q- where- start_height h = return $ AtBlock $ fromIntegral h- start_block h = do- b <- MaybeT $ getBlock (H.BlockHash h)- return $ AtBlock b.height- start_tx h = do- _ <- MaybeT $ getTxData (TxHash h)- return $ AtTx (TxHash h)- start_time q = do- ch <- lift $ askl (.config.store.chain)- b <- MaybeT $ blockAtOrBefore ch q- return $ AtBlock b.height---- | Limit, offset and start in the parameters of a request.-parseLimits :: (MonadUnliftIO m) => ActionT m LimitsParam-parseLimits = LimitsParam <$> paramOptional <*> parseOffset <*> paramOptional---- | Pagination of a query from request parameters, within the limits for full or short items.-paramToLimits ::- (MonadUnliftIO m) =>- Bool ->- LimitsParam ->- ActionT m Limits-paramToLimits full (LimitsParam limitM o startM) = do- wl <- askl (.config.limits)- Limits (validateLimit wl full limitM) (fromIntegral o) <$> parseStart startM---- | Limit of a query: the requested one or the default, but no more than the maximum.-validateLimit :: WebLimits -> Bool -> Maybe LimitParam -> Word32-validateLimit wl full limitM =- f m $ maybe d (fromIntegral . (.get)) limitM- where- m- | full && wl.maxFullItemCount > 0 = wl.maxFullItemCount- | otherwise = wl.maxItemCount- d = wl.defItemCount- f a 0 = a- f 0 b = b- f a b = min a b-------------------- Utilities ---------------------- | Run a query against the database, through the cache if there is one.-runInWebReader ::- (MonadIO m) =>- CacheT (DatabaseReaderT m) a ->- ReaderT WebState m a-runInWebReader f = do- bdb <- asks (.config.store.db)- mc <- asks (.config.store.cache)- lift $ runReaderT (withCache mc f) bdb---- | Run queries without the cache if the flag is 'True'.-runNoCache ::- (MonadIO m) =>- Bool ->- ReaderT WebState m a ->- ReaderT WebState m a-runNoCache False = id-runNoCache True = local $ \s ->- s {config = s.config {store = s.config.store {cache = Nothing}}}---- | Middleware that logs every request with its response status: errors at error level, others at debug level.-logIt :: Logger -> Middleware-logIt logger app req respond =- app req $ \res -> do- let s = responseStatus res- msg = fmtReq req <> ": " <> fmtStatus s- if statusIsSuccessful s- then logDebug logger msg- else logError logger msg- respond res---- | Describe a request for logs.-fmtReq :: Request -> Text-fmtReq req =- let m = requestMethod req- v = httpVersion req- p = rawPathInfo req- q = rawQueryString req- in T.decodeUtf8 (m <> " " <> p <> q <> " " <> cs (show v))---- | Describe a response status for logs.-fmtStatus :: Status -> Text-fmtStatus s = cs (show (statusCode s)) <> " " <> cs (statusMessage s)---- | Run a handler, or another if the first one fails.-rescue ::- (MonadUnliftIO m) =>- S.ActionT m a ->- S.ActionT m a ->- S.ActionT m a-x `rescue` y = x `S.catch` \(_ :: S.ScottyException) -> y---- | Read a request parameter from the path, query string or form.-param ::- (MonadUnliftIO m, S.Parsable a) =>- Text ->- S.ActionT m a-param t = S.queryParam t `rescue` S.formParam t---- | Read a field of the environment from a monad transformer.-askl :: (MonadTrans t, MonadReader r m) => (r -> a) -> t m a-askl f = lift (asks f)+{-# LANGUAGE MultiWayIf #-}+{-# LANGUAGE RecordWildCards #-}+{-# OPTIONS_GHC -Wno-deprecations #-}++-- |+-- Module : Haskoin.Store.Web+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- REST API server of Haskoin Store, running directly on WAI and Warp.+--+-- 'runWeb' serves the endpoints described in "Haskoin.Store.WebCommon" and a+-- set of endpoints compatible with the Blockchain.info API under+-- @\/blockchain\/@. Responses are JSON by default, pretty-printed with+-- @?pretty=true@, or binary if the request has the header+-- @Accept: application\/octet-stream@. New blocks and mempool transactions are+-- streamed at @\/events@, as lines of JSON or over a WebSocket.+--+-- Limits on the size of responses are set with 'WebLimits'. Errors are+-- returned as JSON with an HTTP status that depends on the 'Except'.+-- = Design of the API+--+-- * __Two encodings of the same data.__ JSON is easy to use from anywhere. The+-- binary encoding is several times smaller and faster to produce and parse, and+-- is what the Haskell client uses.+--+-- * __Limits protect the server.__ A single request for every transaction of a+-- busy address could take minutes and gigabytes. 'WebLimits' caps list sizes+-- and offsets, and lower caps apply to full transactions, which are much larger+-- than references.+--+-- * __Health is measured, not assumed.__ The store reports itself unhealthy when+-- it lags behind the network, stops receiving blocks or transactions, has too+-- few peers or a backlog of transactions. Load balancers can route around an+-- instance that is still syncing or has lost its peers.+--+-- * __Slow event clients are dropped.__ Each @\/events@ client gets a bounded+-- queue. A client that falls too far behind is disconnected rather than allowed+-- to make the server buffer events without limit.+--+-- * __Compatibility endpoints ease migration.__ The @\/blockchain\/@ endpoints+-- follow the Blockchain.info API so that existing wallets can switch to a+-- self-hosted server with few changes.+--+-- * __Handlers are plain functions.__ A handler takes the 'WebState' and+-- the request ('Req'), and returns a response in 'IO'. The state holds the+-- queries to answer with in 'query' ('webQuery': the database, with the+-- cache in front of the queries about xpubs); @nocache=true@ is the same+-- handler given a state whose queries read only the database ('cacheOr').+-- The state is made for each request, around one view of the database,+-- so that everything a request reads is of one moment; a store that is a+-- member of a fleet takes the view at the block that its fleet answers+-- from, with the mempool that the fleet shares on top, and has the+-- fleet's cache of xpubs in front of the queries about them+-- ("Haskoin.Store.Fleet.Member").+-- Routes, parameters and the passing of a request to the next route are+-- in "Haskoin.Store.Web.Router", a few dozen lines over WAI. A handler+-- stops early by throwing an 'Except', which becomes the response+-- ('guarded'). There is no web framework and no monad transformer: a+-- framework runs every handler in a stack of its own, and a reader monad+-- in it would hide the state.+module Haskoin.Store.Web+ ( -- * Web+ WebConfig (..),+ Except (..),+ WebLimits (..),+ runWeb,+ runWebSocket,++ -- * Request parsing+ decodeTxBody,+ acceptsBinary,+ )+where++import Conduit+ ( ConduitT,+ await,+ concatMapC,+ concatMapMC,+ dropC,+ dropWhileC,+ headC,+ lengthC,+ mapC,+ runConduit,+ sinkList,+ takeC,+ takeWhileC,+ yield,+ (.|),+ )+import Control.Applicative ((<|>))+import Control.Lens ((.~), (^.))+import Control.Monad+ ( filterM,+ forM_,+ forever,+ guard,+ join,+ unless,+ void,+ when,+ (<=<),+ )+import Control.Monad.Trans (lift)+import Data.Aeson+ ( Encoding,+ ToJSON (..),+ Value,+ )+import Data.Aeson qualified as A+import Data.Aeson.Encode.Pretty+ ( Config (..),+ defConfig,+ encodePretty',+ )+import Data.Aeson.Encoding+ ( encodingToLazyByteString,+ list,+ )+import Data.Aeson.Types (parseMaybe)+import Data.ByteString.Base16 (decodeBase16, parseBase16)+import Data.ByteString.Builder (lazyByteString)+import Data.ByteString.Char8 qualified as C+import Data.ByteString.Lazy qualified as L+import Data.Bytes.Get+import Data.Bytes.Put+import Data.Bytes.Serial+import Data.Char (isSpace)+import Data.Default (Default (..))+import Data.Function ((&))+import Data.HashMap.Strict (HashMap)+import Data.HashMap.Strict qualified as HashMap+import Data.HashSet (HashSet)+import Data.HashSet qualified as HashSet+import Data.Int (Int64)+import Data.List (nub)+import Data.Maybe+ ( catMaybes,+ fromJust,+ fromMaybe,+ isJust,+ listToMaybe,+ mapMaybe,+ maybeToList,+ )+import Data.Proxy (Proxy (..))+import Data.Serialize (Serialize, decode, encode)+import Data.String (fromString)+import Data.String.Conversions (cs)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding qualified as T+import Data.Text.Lazy qualified as TL+import Data.Text.Lazy.Encoding qualified as TL+import Data.Time.Clock.POSIX (getPOSIXTime)+import Data.Word (Word32, Word64)+import Database.RocksDB+ ( Property (..),+ getProperty,+ )+import Haskoin.Address+import Haskoin.Block qualified as H+import Haskoin.Crypto.Keys+import Haskoin.Network+import Haskoin.Node+ ( Chain,+ Logger,+ OnlinePeer (..),+ PeerMgr,+ chainGetAncestor,+ chainGetBest,+ getPeers,+ logDebug,+ logError,+ sendMessage,+ withSource,+ )+import Haskoin.Script+import Haskoin.Store.BlockStore+import Haskoin.Store.Cache+import Haskoin.Store.Common hiding+ ( blockAtOrAfter,+ blockAtOrAfterMTP,+ blockAtOrBefore,+ getActiveBlock,+ getActiveTxData,+ getDefaultBalance,+ getNumTransaction,+ getTransaction,+ getTransactions,+ )+import Haskoin.Store.Common qualified as C+import Haskoin.Store.Data+import Haskoin.Store.Database.Query qualified as Q+import Haskoin.Store.Database.Query.Layered qualified as L+import Haskoin.Store.Database.Reader+import Haskoin.Store.Fleet.Member+ ( Member (..),+ TipMoved (..),+ answeringCached,+ memberAlive,+ memberEvents,+ memberTip,+ )+import Haskoin.Store.Fleet.Redis (FleetEvent (..))+import Haskoin.Store.Manager+import Haskoin.Store.Web.Docs (swaggerPage, swaggerYaml)+import Haskoin.Store.Web.Events+import Haskoin.Store.Web.Router hiding (header)+import Haskoin.Store.Web.Router qualified as Router+import Haskoin.Store.WebCommon+import Haskoin.Transaction+import Haskoin.Util+import NQE+ ( subscribe,+ unsubscribe,+ )+import Network.HTTP.Types+ ( Status (..),+ hContentType,+ methodGet,+ methodPost,+ parseQuery,+ renderStdMethod,+ status200,+ status203,+ status204,+ status400,+ status404,+ status409,+ status413,+ status500,+ status503,+ statusIsClientError,+ statusIsServerError,+ statusIsSuccessful,+ )+import Network.Socket (Socket)+import Network.Wai+ ( Application,+ Middleware,+ Request (..),+ Response,+ mapResponseHeaders,+ modifyResponse,+ responseLBS,+ responseStatus,+ responseStream,+ )+import Network.Wai.Handler.Warp+ ( defaultSettings,+ runSettings,+ runSettingsSocket,+ setHost,+ setPort,+ )+import Network.Wai.Handler.WarpTLS (runTLS, runTLSSocket, tlsSettings)+import Network.Wai.Handler.WebSockets (websocketsOr)+import Network.Wai.Middleware.Autohead (autohead)+import Network.Wai.Middleware.Gzip (gzip)+import Network.Wai.Middleware.Gzip qualified as Gzip+import Network.WebSockets+ ( acceptRequest,+ defaultConnectionOptions,+ pendingRequest,+ rejectRequestWith,+ requestPath,+ sendBinaryData,+ sendTextData,+ )+import Network.WebSockets qualified as WebSockets+import Network.WebSockets.Connection.PingPong+ ( PingPongOptions (..),+ defaultPingPongOptions,+ withPingPong,+ )+import Network.Wreq qualified as Wreq+import Network.Wreq.Session as Wreq (Session)+import Network.Wreq.Session qualified as Wreq.Session+import Numeric.Natural (Natural)+import System.Metrics.StatsD+import Text.Read (readMaybe)+import UnliftIO+ ( TVar,+ async,+ atomically,+ bracket,+ bracket_,+ catch,+ catchAny,+ fromException,+ handleAny,+ link,+ newIORef,+ newTQueueIO,+ newTVarIO,+ race_,+ readIORef,+ readTQueue,+ readTVarIO,+ stateTVar,+ throwIO,+ timeout,+ try,+ withAsync,+ writeIORef,+ writeTQueue,+ writeTVar,+ )+import UnliftIO.Concurrent (threadDelay)++-- | Limits of the API. A value of @0@ means no limit.+data WebLimits = WebLimits+ { -- | maximum number of items in a list response+ maxItemCount :: !Word32,+ -- | maximum number of full transactions in a list response+ maxFullItemCount :: !Word32,+ -- | maximum offset of a paged query+ maxOffset :: !Word32,+ -- | number of items in a list response when no limit is given+ defItemCount :: !Word32,+ -- | consecutive unused addresses after which xpub derivation stops+ xpubGap :: !Word32,+ -- | unused addresses to look at for an xpub with no transactions+ xpubGapInit :: !Word32,+ -- | seconds without a new transaction before the store is reported+ -- unhealthy+ txTimeout :: !Word64,+ -- | seconds without a new block before the store is reported unhealthy+ blockTimeout :: !Word64+ }+ deriving (Eq, Show)++-- | 200000 items, 5000 full transactions, offset up to 50000, 100 items by+-- default, xpub gaps of 32 and 20, transaction timeout of 30 minutes and+-- block timeout of four hours.+instance Default WebLimits where+ def =+ WebLimits+ { maxItemCount = 200000,+ maxFullItemCount = 5000,+ maxOffset = 50000,+ defItemCount = 100,+ xpubGap = 32,+ xpubGapInit = 20,+ txTimeout = 3600 `div` 2,+ blockTimeout = 4 * 3600+ }++-- | Configuration of the web server.+data WebConfig = WebConfig+ { -- | address to listen on, such as @*@ for all+ host :: !String,+ -- | port to listen on+ port :: !Int,+ -- | running store to serve+ store :: !Store,+ -- | blocks that the database may lag behind the headers before the store+ -- is reported unhealthy+ maxLaggingBlocks :: !Int,+ -- | transactions that may be waiting to be imported before the store is+ -- reported unhealthy+ maxPendingTxs :: !Int,+ -- | fewest peers before the store is reported unhealthy+ minPeers :: !Int,+ -- | limits of the API+ limits :: !WebLimits,+ -- | version reported by the health check+ version :: !String,+ -- | the mempool is not indexed, so do not report it in health checks+ noMempool :: !Bool,+ -- | StatsD client, if metrics are enabled+ stats :: !(Maybe Stats),+ -- | microseconds between updates of the price ticker+ tickerRefresh :: !Int,+ -- | URL of the price ticker service+ tickerURL :: !String,+ -- | URL of the historical price service+ priceHistoryURL :: !String,+ -- | disable the xpub endpoints+ noXPub :: !Bool,+ -- | disable the Blockchain.info compatible endpoints+ noBlockchainInfo :: !Bool,+ -- | seconds between health checks+ healthCheckInterval :: !Int,+ -- | a certificate and its private key, in files in PEM format, to+ -- serve HTTPS instead of HTTP; 'Nothing' for HTTP, which is the+ -- default+ tls :: !(Maybe (FilePath, FilePath)),+ -- | log messages here+ logger :: !Logger+ }++-- | State shared by all request handlers.+data WebState = WebState+ { -- | configuration+ config :: !WebConfig,+ -- | latest prices by currency code+ ticker :: !(TVar (HashMap Text BinfoTicker)),+ -- | StatsD metrics, if enabled+ metrics :: !(Maybe WebMetrics),+ -- | HTTP session for price requests+ session :: !Wreq.Session,+ -- | result of the latest health check+ health :: !(TVar HealthCheck),+ -- | the queries to answer with, the cache included ('webQuery'): those+ -- of one view of the database, taken for the request+ query :: !StoreRead,+ -- | the same queries without the cache, on the same view+ direct :: !StoreRead,+ -- | whether Redis answered the last time it was asked; always 'True'+ -- for a store without a cache+ cacheUp :: !(TVar Bool),+ -- | the events that the clients of @\/events@ read ('feedEvents')+ eventLog :: !EventLog+ }++-- | StatsD metrics of the web server: a timing for each kind of request,+-- under @web.\<name\>@, error counters and a gauge of connected event+-- clients.+data WebMetrics = WebMetrics+ { -- Addresses+ addressTx :: !StatTiming,+ addressTxFull :: !StatTiming,+ addressBalance :: !StatTiming,+ addressUnspent :: !StatTiming,+ xpub :: !StatTiming,+ xpubDelete :: !StatTiming,+ xpubTxFull :: !StatTiming,+ xpubTx :: !StatTiming,+ xpubBalance :: !StatTiming,+ xpubUnspent :: !StatTiming,+ -- Transactions+ tx :: !StatTiming,+ txRaw :: !StatTiming,+ txAfter :: !StatTiming,+ txBlock :: !StatTiming,+ txBlockRaw :: !StatTiming,+ txPost :: !StatTiming,+ mempool :: !StatTiming,+ -- Blocks+ block :: !StatTiming,+ blockRaw :: !StatTiming,+ -- Blockchain+ binfoMultiaddr :: !StatTiming,+ binfoBalance :: !StatTiming,+ binfoAddressRaw :: !StatTiming,+ binfoUnspent :: !StatTiming,+ binfoTxRaw :: !StatTiming,+ binfoBlock :: !StatTiming,+ binfoBlockHeight :: !StatTiming,+ binfoBlockLatest :: !StatTiming,+ binfoBlockRaw :: !StatTiming,+ binfoMempool :: !StatTiming,+ binfoExportHistory :: !StatTiming,+ -- Blockchain /q endpoints+ binfoQaddresstohash :: !StatTiming,+ binfoQhashtoaddress :: !StatTiming,+ binfoQaddrpubkey :: !StatTiming,+ binfoQpubkeyaddr :: !StatTiming,+ binfoQhashpubkey :: !StatTiming,+ binfoQgetblockcount :: !StatTiming,+ binfoQlatesthash :: !StatTiming,+ binfoQbcperblock :: !StatTiming,+ binfoQtxtotalbtcoutput :: !StatTiming,+ binfoQtxtotalbtcinput :: !StatTiming,+ binfoQtxfee :: !StatTiming,+ binfoQtxresult :: !StatTiming,+ binfoQgetreceivedbyaddress :: !StatTiming,+ binfoQgetsentbyaddress :: !StatTiming,+ binfoQaddressbalance :: !StatTiming,+ binfoQaddressfirstseen :: !StatTiming,+ -- Errors+ serverErrors :: StatCounter,+ clientErrors :: StatCounter,+ -- Others+ health :: !StatTiming,+ peers :: !StatTiming,+ db :: !StatTiming,+ events :: !StatGauge+ }++-- | Register the web server metrics with a StatsD client.+createMetrics :: Stats -> IO WebMetrics+createMetrics s = do+ -- Addresses+ addressTx <- d "address_transactions"+ addressTxFull <- d "address_transactions_full"+ addressBalance <- d "address_balance"+ addressUnspent <- d "address_unspent"+ xpub <- d "xpub"+ xpubDelete <- d "xpub_delete"+ xpubTxFull <- d "xpub_transactions_full"+ xpubTx <- d "xpub_transactions"+ xpubBalance <- d "xpub_balances"+ xpubUnspent <- d "xpub_unspent"++ -- Transactions+ tx <- d "transaction"+ txRaw <- d "transaction_raw"+ txAfter <- d "transaction_after"+ txPost <- d "transaction_post"+ txBlock <- d "transactions_block"+ txBlockRaw <- d "transactions_block_raw"+ mempool <- d "mempool"++ -- Blocks+ block <- d "block"+ blockRaw <- d "block_raw"++ -- Blockchain+ binfoMultiaddr <- d "blockchain_multiaddr"+ binfoBalance <- d "blockchain_balance"+ binfoAddressRaw <- d "blockchain_rawaddr"+ binfoUnspent <- d "blockchain_unspent"+ binfoTxRaw <- d "blockchain_rawtx"+ binfoBlock <- d "blockchain_blocks"+ binfoBlockHeight <- d "blockchain_block_height"+ binfoBlockLatest <- d "blockchain_latestblock"+ binfoBlockRaw <- d "blockchain_rawblock"+ binfoMempool <- d "blockchain_mempool"+ binfoExportHistory <- d "blockchain_export_history"++ -- Blockchain /q endpoints+ binfoQaddresstohash <- d "blockchain_q_addresstohash"+ binfoQhashtoaddress <- d "blockchain_q_hashtoaddress"+ binfoQaddrpubkey <- d "blockckhain_q_addrpubkey"+ binfoQpubkeyaddr <- d "blockchain_q_pubkeyaddr"+ binfoQhashpubkey <- d "blockchain_q_hashpubkey"+ binfoQgetblockcount <- d "blockchain_q_getblockcount"+ binfoQlatesthash <- d "blockchain_q_latesthash"+ binfoQbcperblock <- d "blockchain_q_bcperblock"+ binfoQtxtotalbtcoutput <- d "blockchain_q_txtotalbtcoutput"+ binfoQtxtotalbtcinput <- d "blockchain_q_txtotalbtcinput"+ binfoQtxfee <- d "blockchain_q_txfee"+ binfoQtxresult <- d "blockchain_q_txresult"+ binfoQgetreceivedbyaddress <- d "blockchain_q_getreceivedbyaddress"+ binfoQgetsentbyaddress <- d "blockchain_q_getsentbyaddress"+ binfoQaddressbalance <- d "blockchain_q_addressbalance"+ binfoQaddressfirstseen <- d "blockchain_q_addressfirstseen"++ -- Errors+ serverErrors <- c "server_errors"+ clientErrors <- c "client_errors"++ -- Others+ health <- d "health"+ peers <- d "peers"+ db <- d "dbstats"++ events <- g "events_connected"+ return WebMetrics {..}+ where+ d x = newStatTiming s ("web." <> x) 10+ g x = newStatGauge s ("web." <> x) 0+ c x = newStatCounter s ("web." <> x) 10++-- | Increase a gauge while an action runs.+withGaugeIO :: StatGauge -> IO a -> IO a+withGaugeIO g = bracket_ (incrementGauge g 1) (decrementGauge g 1)++-- | Increase a gauge of the web metrics while a handler runs.+withGaugeIncrease ::+ WebState ->+ (WebMetrics -> StatGauge) ->+ IO a ->+ IO a+withGaugeIncrease st gf go =+ case st.metrics of+ Nothing -> go+ Just m -> withGaugeIO (gf m) go++-- | Record how long a handler takes in a timing of the web metrics.+withMetrics ::+ WebState ->+ (WebMetrics -> StatTiming) ->+ IO a ->+ IO a+withMetrics st df go =+ case st.metrics of+ Nothing -> go+ Just m -> bracket time (stop m) (const go)+ where+ time = round . (* 1000) <$> getPOSIXTime+ stop m t1 = do+ t2 <- time+ addTiming (df m) (t2 - t1)++-- | Encoding of a response.+data SerialAs+ = -- | binary, for @Accept: application\/octet-stream@+ SerialAsBinary+ | -- | compact JSON+ SerialAsJSON+ | -- | indented JSON, for @?pretty=true@+ SerialAsPrettyJSON+ deriving (Eq, Show)++-------------------+-- Path Handlers --+-------------------++-- | Run the web server forever on the host and port of the configuration.+-- Also starts a thread that updates prices, unless the Blockchain.info+-- endpoints are disabled, and a thread that runs health checks.+--+-- = HTTPS+--+-- With a certificate and a key in 'tls', the server speaks HTTPS on that+-- port, and nothing else: TLS 1.2 and 1.3, and HTTP\/2 for clients that+-- ask for it. It is off by default. A public service usually sits behind+-- a proxy that has the certificate, limits requests and spreads them over+-- servers; this is for a server by itself, and for links between machines+-- that should not be in the clear. The files are read when the server+-- starts, so a renewed certificate takes a restart.+runWeb :: WebConfig -> IO ()+runWeb config = serveWeb config $ case config.tls of+ Nothing -> runSettings settings+ Just (certificate, key) -> runTLS (tlsSettings certificate key) settings+ where+ settings = setPort config.port . setHost (fromString config.host) $ defaultSettings++-- | Like 'runWeb', on a socket that already listens; the host and port of the+-- configuration are ignored. Binding port 0 and passing the socket gives a+-- free port with no window in which another program can take it, which+-- finding a free port first and binding it later has.+runWebSocket :: WebConfig -> Socket -> IO ()+runWebSocket config sock = serveWeb config $ case config.tls of+ Nothing -> runSettingsSocket defaultSettings sock+ Just (certificate, key) -> runTLSSocket (tlsSettings certificate key) defaultSettings sock++-- | Run the web server with a function of Warp that serves an application.+-- The application is the router ("Haskoin.Store.Web.Router") with the+-- routes of 'handlePaths', behind the middleware for WebSocket events, for+-- logs and for the header that lets any origin make requests.+serveWeb :: WebConfig -> (Application -> IO ()) -> IO ()+serveWeb config serve = do+ ticker <- newTVarIO HashMap.empty+ metrics <- mapM createMetrics config.stats+ session <- Wreq.Session.newAPISession+ health <- runHealthCheck >>= newTVarIO+ cacheUp <- cacheCheck >>= newTVarIO+ eventLog <- newEventLog+ let db = config.store.db+ stateOf q = WebState {query = webQuery config.store cacheUp q, direct = q, ..}+ routesOf s = map (guarded s) (concatMap withPost (handlePaths s config))+ -- outside of a request, each query has a view of its own; a request+ -- has one view for all that it reads, which is let go when its+ -- response is sent+ st = stateOf (L.standaloneRead db)+ local answer = L.withView db (answer . routesOf . stateOf . L.layeredRead)+ scoped answer = case config.store.fleet of+ Nothing -> local answer+ Just member ->+ readTVarIO cacheUp >>= \case+ True -> shared member (3 :: Int) False answer+ -- without Redis there is no fleet to answer with+ False -> local answer+ -- as a member of a fleet: from the view at the fleet tip, with the+ -- pool that the fleet shares on top. The tip is taken to be the one+ -- that the member read at its last turn, which saves asking Redis+ -- for it: the first read of the pool checks it, and a request that+ -- reads nothing from the pool checks it when its handler is done. If+ -- it is not the tip any more, the request is answered again, from the+ -- tip that Redis has.+ shared member tries fresh answer = do+ known <- if fresh then return Nothing else fmap encode <$> readTVarIO member.tip+ asked <- case known of+ Just tip -> return (Just (Just tip))+ Nothing -> timeout 2000000 (memberTip member) `catchAny` \_ -> return Nothing+ case asked of+ Nothing -> atomically (writeTVar cacheUp False) >> local answer+ -- no store has reported yet+ Just Nothing -> local answer+ Just (Just tip) -> do+ handled <- newIORef False+ let marked confirm r = r {handler = \req -> r.handler req <* confirm <* writeIORef handled True} :: Route+ -- extended public keys through the fleet's cache of+ -- them, unless a request asks for the database+ inFleet plain cached confirm =+ answer (map (marked confirm) (routesOf WebState {query = cached, direct = plain, ..}))+ try (answeringCached member tip inFleet) >>= \case+ Right (Just sent) -> return sent+ Right Nothing+ -- a tip from the member's last turn of which there is no+ -- view may not be the tip any more+ | not fresh && tries > 0 -> shared member (tries - 1) True answer+ | otherwise -> behind answer+ Left TipMoved ->+ -- the tip moved while the handler was reading: answer+ -- again from the new one, unless the response is on its way+ readIORef handled >>= \case+ True -> throwIO TipMoved+ False+ | tries > 0 -> shared member (tries - 1) True answer+ | otherwise -> behind answer+ behind answer = answer [r {handler = behindResponse st} :: Route | r <- routes]+ -- the stream of events never ends, and reads nothing from the+ -- database: answered like the rest it would hold a view, and the+ -- data under it, for as long as its client listens+ app request+ | pathInfo request == ["events"] = routerScoped routes ($ routes) notFound request+ | otherwise = routerScoped routes scoped notFound request+ routes = routesOf st+ notFound req NoRoute = errorResponse st req ThingNotFound+ notFound req (Unknown ks) =+ errorResponse st req . UserError $+ "Unknown parameter: " <> T.unpack (T.intercalate ", " ks)+ withAsync (priceUpdater session ticker) $ \_a1 ->+ withAsync (healthCheckLoop health cacheUp) $ \_a2 ->+ withAsync (feedEvents st) $ \a3 ->+ link a3 >> do+ serve . webSocketEvents st . logIt config.logger . anyOrigin . preflight . autohead . compressed $ app+ where+ priceUpdater session =+ unless config.noBlockchainInfo+ . price+ (withSource "Price" config.logger)+ config.store.net+ session+ config.tickerURL+ config.tickerRefresh+ runHealthCheck = healthCheck config+ cacheCheck = case (config.store.cache, config.store.fleet) of+ (Just cache, _) -> cacheAlive cache+ (_, Just member) -> memberAlive member+ _ -> return True+ healthCheckLoop v up = loopForever $ do+ threadDelay (config.healthCheckInterval * 1000 * 1000)+ h <- runHealthCheck+ atomically (writeTVar v h)+ -- a store whose checks fail stops taking its turns as a member of+ -- its fleet, so that it does not hold the others back+ forM_ config.store.fleet $ \member -> atomically (writeTVar member.healthy (isOK h))+ cacheCheck >>= atomically . writeTVar up+ -- what a browser asks before a request from another site that is not+ -- of the plainest kind: which methods and headers it may use. Without+ -- an answer it does not send the request.+ preflight inner req respond+ | requestMethod req == "OPTIONS" =+ respond $+ responseLBS+ status204+ [ ("Allow", allowed),+ ("Access-Control-Allow-Methods", allowed),+ ("Access-Control-Allow-Headers", "Accept, Content-Type"),+ ("Access-Control-Max-Age", "86400")+ ]+ ""+ | otherwise = inner req respond+ allowed = "GET, HEAD, POST, DELETE, OPTIONS"+ -- answers are compressed for a client that says it reads gzip: JSON+ -- shrinks several times over, and the binary format, which is mostly+ -- hashes, by less. The events are a stream that has to leave as it is+ -- written, and are left alone.+ compressed inner req+ | pathInfo req == ["events"] = inner req+ | otherwise = gzip gzipSettings inner req+ gzipSettings =+ Gzip.defaultGzipSettings+ { Gzip.gzipCheckMime = \m -> Gzip.defaultCheckMime m || "application/octet-stream" `C.isPrefixOf` m+ }+ -- any origin may ask, and what is answered depends on the format that+ -- was asked for, which a cache between the client and the server has+ -- to know: it must not give the JSON of one client to another that+ -- asked for binary. The same goes for whether it is compressed, and+ -- the two are said in one header.+ anyOrigin = modifyResponse (mapResponseHeaders varied)+ varied headers =+ [("Access-Control-Allow-Origin", "*"), ("Vary", "Accept, Accept-Encoding")]+ <> filter ((/= "Vary") . fst) headers++-- | Answer the errors of the handler of a route: an 'Except' with its+-- status and JSON, any other error as a server error, after logging it. A+-- request passed on to the next route goes on.+guarded :: WebState -> Route -> Route+guarded st r = r {handler = \req -> r.handler req `catchAny` answer req}+ where+ answer req e+ | Just Next <- fromException e = throwIO Next+ | Just TipMoved <- fromException e = throwIO TipMoved+ | Just x <- fromException e = errorResponse st req x+ | otherwise = do+ logError st.config.logger $ fmtReq req.wai <> ": " <> cs (show e)+ errorResponse st req ServerError++-- | What a store that is a member of a fleet answers when it has no view of+-- the block that the fleet answers from: 503, so that a load balancer sends+-- the request to another store. Any answer of its own could differ from+-- those of the other stores, which is what a fleet is there to prevent.+behindResponse :: WebState -> Req -> IO Response+behindResponse st req = errorResponse st req BehindFleet++-- | The response to an error, counted in the metrics, in the format that+-- the request asked for.+--+-- Errors were JSON whatever was asked, because 'Except' had no binary+-- encoding. A client that speaks binary had to carry a JSON decoder for+-- the one answer that it least expects. From haskoin-store-data 1.10 an+-- error has a binary encoding like everything else.+errorResponse :: WebState -> Req -> Except -> IO Response+errorResponse st req e = do+ forM_ st.metrics $ \m ->+ mapM_ (`incrementCounter` 1) $+ if+ | statusIsClientError (errStatus e) -> Just m.clientErrors+ | statusIsServerError (errStatus e) -> Just m.serverErrors+ | otherwise -> Nothing+ return $+ if wantsBinary req+ then responseLBS (errStatus e) ((hContentType, "application/octet-stream") : again) (runPutL (serialize e))+ else responseLBS (errStatus e) ((hContentType, "application/json") : again) (A.encode e)+ where+ -- a server behind its fleet catches up within a block or is taken out+ -- of rotation: a client or a load balancer that reads this header+ -- tries again in a second, and elsewhere meanwhile+ again = [("Retry-After", "1") | e == BehindFleet]++-- | Historical prices of the coin of the network in a currency at several times. Returns an empty list on failure.+getRates ::+ Logger ->+ Network ->+ Wreq.Session ->+ String ->+ Text ->+ [Word64] ->+ IO [BinfoRate]+getRates logger net session url currency times = do+ handleAny err $ do+ r <-+ Wreq.asJSON+ =<< Wreq.Session.postWith opts session url body+ return $ r ^. Wreq.responseBody+ where+ err _ = do+ logError logger "Could not get historic prices"+ return []+ body = toJSON times+ base =+ Wreq.defaults+ & Wreq.param "base" .~ [T.toUpper $ T.pack net.name]+ opts = base & Wreq.param "quote" .~ [currency]++-- | Update the prices of the coin of the network periodically, for BTC and BCH only.+price ::+ Logger ->+ Network ->+ Wreq.Session ->+ String ->+ Int ->+ TVar (HashMap Text BinfoTicker) ->+ IO ()+price logger net session url pget v = forM_ purl $ \u -> loopForever $ do+ let err e = logError logger $ cs (show e)+ handleAny err $ do+ r <- Wreq.asJSON =<< Wreq.Session.get session u+ atomically . writeTVar v $ r ^. Wreq.responseBody+ threadDelay pget+ where+ purl = case code of+ Nothing -> Nothing+ Just x -> Just (url <> "?base=" <> x)+ where+ code+ | net == btc = Just "btc"+ | net == bch = Just "bch"+ | otherwise = Nothing++-- | Stop handling the request, and answer with an error ('guarded').+raise :: WebState -> Except -> IO a+raise _ = throwIO++-- | HTTP status of an error.+errStatus :: Except -> Status+errStatus ThingNotFound = status404+errStatus BadRequest = status400+errStatus UserError {} = status400+errStatus StringError {} = status400+errStatus ServerError = status500+errStatus TxIndexConflict {} = status409+errStatus ServerTimeout = status500+errStatus RequestTooLarge = status413+errStatus BehindFleet = status503++-- | A route, and with it the same route under @POST@ if it takes a list in+-- a parameter: addresses, blocks, heights or transactions.+--+-- A list in the query string has to fit in a URL, and proxies cut those+-- off at a few thousand characters: some hundred addresses. The router+-- reads the parameters of a form in the body as it reads those of the+-- query string, so under @POST@ the same parameters go in the body, with+-- @Content-Type: application\/x-www-form-urlencoded@, and have no such+-- limit. @\/transactions@ is left out: @POST@ there publishes a+-- transaction.+withPost :: Route -> [Route]+withPost r+ | r.method == methodGet && takesList && r.pattern /= [Left "transactions"] = [r, r {method = methodPost}]+ | otherwise = [r]+ where+ takesList = any (`elem` r.accepts) ["addresses", "blocks", "heights", "txids"]++-- | The parameters of @\/events@ that say what a client hears about from+-- the start ('eventsOpen').+eventParams :: [Text]+eventParams = ["quiet", "addresses", "xpub", "derive", "filter", "functions", "tweak", "flags"]++-- | The routes of every endpoint, except those disabled in the configuration.+handlePaths :: WebState -> WebConfig -> [Route]+handlePaths st cfg =+ [ marshalled ["notx"] (\r -> GetBlockBest <$> dflt r) (handleBlockBest st),+ marshalledList ["notx"] (\r -> GetBlockHeight <$> cap r <*> dflt r) (handleBlockHeight st),+ marshalled ["notx"] (\r -> GetBlockTime <$> cap r <*> dflt r) (handleBlockTime st),+ marshalled ["notx"] (\r -> GetBlockMTP <$> cap r <*> dflt r) (handleBlockMTP st),+ marshalled ["notx"] (\r -> GetBlock <$> cap r <*> dflt r) (handleBlock st),+ marshalled [] (\r -> GetTx <$> cap r) (handleTx st),+ plain [] (\r -> GetTxRaw <$> cap r) (handleTxRaw st),+ plain [] (\r -> PostTx <$> parseTxBody st r) (handlePostTx st),+ plainList limits (\r -> GetAddrTxs <$> cap r <*> lim r) (handleAddrTxs st),+ marshalled [] (\r -> GetAddrBalance <$> cap r) (handleAddrBalance st),+ marshalledList limits (\r -> GetAddrUnspent <$> cap r <*> lim r) (handleAddrUnspent st),+ plainList [] (\_ -> return GetPeers) (handlePeers st),+ pathHealth st,+ plainList ["limit", "offset"] (\r -> GetMempool <$> opt r <*> off r) (handleMempool st),+ marshalledList ["blocks", "notx"] (\r -> GetBlocks <$> need r <*> dflt r) (handleBlocks st),+ plain [] (\r -> GetBlockRaw <$> cap r) (handleBlockRaw st),+ plain [] (\_ -> return GetBlockBestRaw) (handleBlockBestRaw st),+ marshalledList ["notx"] (\r -> GetBlockLatest <$> dflt r) (handleBlockLatest st),+ marshalledList ["heights", "notx"] (\r -> GetBlockHeights <$> need r <*> dflt r) (handleBlockHeights st),+ plain [] (\r -> GetBlockHeightRaw <$> cap r) (handleBlockHeightRaw st),+ plain [] (\r -> GetBlockTimeRaw <$> cap r) (handleBlockTimeRaw st),+ plain [] (\r -> GetBlockMTPRaw <$> cap r) (handleBlockMTPRaw st),+ marshalledList ["txids"] (\r -> GetTxs <$> need r) (handleTxs st),+ plain ["txids"] (\r -> GetTxsRaw <$> need r) (handleTxsRaw st),+ marshalledList [] (\r -> GetTxsBlock <$> cap r) (handleTxsBlock st),+ plain [] (\r -> GetTxsBlockRaw <$> cap r) (handleTxsBlockRaw st),+ plain [] (\r -> GetTxAfter <$> cap r <*> cap r) (handleTxAfter st),+ plainList ("addresses" : limits) (\r -> GetAddrsTxs <$> need r <*> lim r) (handleAddrsTxs st),+ marshalledList limits (\r -> GetAddrTxsFull <$> cap r <*> lim r) (handleAddrTxsFull st),+ marshalledList ("addresses" : limits) (\r -> GetAddrsTxsFull <$> need r <*> lim r) (handleAddrsTxsFull st),+ marshalledList ["addresses"] (\r -> GetAddrsBalance <$> need r) (handleAddrsBalance st),+ marshalledList ("addresses" : limits) (\r -> GetAddrsUnspent <$> need r <*> lim r) (handleAddrsUnspent st),+ get "/events" ("pretty" : "after" : eventParams) (handleEvents st),+ get "/dbstats" [] (handleDbStats st),+ -- the reference of the API, as a page and as the file that it shows+ get "/" [] (\_ -> file "text/html; charset=utf-8" swaggerPage),+ get "/swagger.yaml" [] (\_ -> file "application/yaml" swaggerYaml)+ ]+ <> (if cfg.noXPub then [] else xpubRoutes)+ <> (if cfg.noBlockchainInfo then [] else binfoRoutes)+ where+ file kind bytes = return (responseLBS status200 [(hContentType, kind)] (L.fromStrict bytes))+ net = cfg.store.net+ -- the parameters that routes accept, beyond those of each route+ limits = ["limit", "offset", "height"]+ active = ["active", "activeP2SH", "activeBech32"]+ -- the Blockchain.info endpoints accept format=json ('jsonOnly')+ multiaddrParams =+ ["format", "onlyShow", "n", "offset", "noCompact", "no_compact", "currency", "cashaddr", "txidindex", "filter"]+ <> active+ rawaddrParams = ["format", "derive", "limit", "offset", "txidindex"]+ balanceParams = "format" : "cashaddr" : active+ unspentParams = "format" : "limit" : "confirmations" : "txidindex" : active+ historyParams = "format" : "start" : "end" : "currency" : active+ -- the parameters of a request+ cap, need :: (Param a) => Req -> IO a+ cap = paramCapture st+ need = paramRequired st+ dflt :: (Default a, Param a) => Req -> IO a+ dflt = paramDef st+ opt :: (Param a) => Req -> IO (Maybe a)+ opt = paramOptional st+ lim = parseLimits st+ off = parseOffset st+ -- routes by how they encode their responses: in the JSON of the+ -- network, as a list of those, or in plain JSON+ marshalled ::+ (ApiResource a b, MarshalJSON Network b) => [Text] -> (Req -> IO a) -> (a -> IO b) -> Route+ marshalled ps p h = pathCompact st ps p h (marshalEncoding net) (marshalValue net)+ marshalledList ::+ (ApiResource a (SerialList b), MarshalJSON Network b) => [Text] -> (Req -> IO a) -> (a -> IO [b]) -> Route+ marshalledList ps p h =+ pathCompact+ st+ ps+ p+ (fmap SerialList . h)+ (list (marshalEncoding net) . (.get))+ (toJSONList . map (marshalValue net) . (.get))+ plain :: (ApiResource a b, ToJSON b) => [Text] -> (Req -> IO a) -> (a -> IO b) -> Route+ plain ps p h = pathCompact st ps p h toEncoding toJSON+ plainList ::+ (ApiResource a (SerialList b), ToJSON b) => [Text] -> (Req -> IO a) -> (a -> IO [b]) -> Route+ plainList ps p h = plain ps p (fmap SerialList . h)+ xpubRoutes =+ [ plain ["derive", "nocache"] (\r -> GetXPub <$> cap r <*> dflt r <*> dflt r) (handleXPub st),+ plainList+ ("derive" : "nocache" : limits)+ (\r -> GetXPubTxs <$> cap r <*> dflt r <*> lim r <*> dflt r)+ (handleXPubTxs st),+ marshalledList+ ("derive" : "nocache" : limits)+ (\r -> GetXPubTxsFull <$> cap r <*> dflt r <*> lim r <*> dflt r)+ (handleXPubTxsFull st),+ marshalledList+ ["derive", "nocache"]+ (\r -> GetXPubBalances <$> cap r <*> dflt r <*> dflt r)+ (handleXPubBalances st),+ marshalledList+ ("derive" : "nocache" : limits)+ (\r -> GetXPubUnspent <$> cap r <*> dflt r <*> lim r <*> dflt r)+ (handleXPubUnspent st),+ plain ["derive"] (\r -> DelCachedXPub <$> cap r <*> dflt r) (handleDelXPub st)+ ]+ binfoRoutes =+ [ post "/blockchain/multiaddr" multiaddrParams (jsonOnly (handleMultiAddr st)),+ get "/blockchain/multiaddr" multiaddrParams (jsonOnly (handleMultiAddr st)),+ get "/blockchain/balance" balanceParams (jsonOnly (handleShortBal st)),+ post "/blockchain/balance" balanceParams (jsonOnly (handleShortBal st)),+ get "/blockchain/rawaddr/:addr" rawaddrParams (jsonOnly (handleRawAddr st)),+ get "/blockchain/address/:addr" rawaddrParams (jsonOnly (handleRawAddr st)),+ get "/blockchain/xpub/:addr" rawaddrParams (jsonOnly (handleRawAddr st)),+ post "/blockchain/unspent" unspentParams (jsonOnly (handleBinfoUnspent st)),+ get "/blockchain/unspent" unspentParams (jsonOnly (handleBinfoUnspent st)),+ get "/blockchain/rawtx/:txid" ["format", "txidindex"] (handleBinfoTx st),+ get "/blockchain/rawblock/:block" ["format", "txidindex"] (handleBinfoBlock st),+ get "/blockchain/latestblock" ["format", "txidindex"] (jsonOnly (handleBinfoLatest st)),+ get+ "/blockchain/unconfirmed-transactions"+ ["format", "limit", "offset", "txidindex"]+ (jsonOnly (handleBinfoMempool st)),+ get "/blockchain/block-height/:height" ["format", "txidindex"] (jsonOnly (handleBinfoBlockHeight st)),+ get "/blockchain/blocks/:milliseconds" ["format"] (jsonOnly (handleBinfoBlocksDay st)),+ get "/blockchain/export-history" historyParams (jsonOnly (handleBinfoHistory st)),+ post "/blockchain/export-history" historyParams (jsonOnly (handleBinfoHistory st)),+ get "/blockchain/q/addresstohash/:addr" [] (handleBinfoAddrToHash st),+ get "/blockchain/q/hashtoaddress/:hash" [] (handleBinfoHashToAddr st),+ get "/blockchain/q/addrpubkey/:pubkey" [] (handleBinfoAddrPubkey st),+ get "/blockchain/q/pubkeyaddr/:addr" [] (handleBinfoPubKeyAddr st),+ get "/blockchain/q/hashpubkey/:pubkey" [] (handleBinfoHashPubkey st),+ get "/blockchain/q/getblockcount" [] (handleBinfoGetBlockCount st),+ get "/blockchain/q/latesthash" [] (handleBinfoLatestHash st),+ get "/blockchain/q/bcperblock" [] (handleBinfoSubsidy st),+ get "/blockchain/q/txtotalbtcoutput/:txid" [] (handleBinfoTotalOut st),+ get "/blockchain/q/txtotalbtcinput/:txid" [] (handleBinfoTotalInput st),+ get "/blockchain/q/txfee/:txid" [] (handleBinfoTxFees st),+ get "/blockchain/q/txresult/:txid/:addr" [] (handleBinfoTxResult st),+ get "/blockchain/q/getreceivedbyaddress/:addr" [] (handleBinfoReceived st),+ get "/blockchain/q/getsentbyaddress/:addr" [] (handleBinfoSent st),+ get "/blockchain/q/addressbalance/:addr" [] (handleBinfoAddrBalance st),+ get "/blockchain/q/addressfirstseen/:addr" [] (handleFirstSeen st)+ ]++-- | A handler of the Blockchain.info endpoints that answer in JSON only. They+-- accept @format=json@, which clients written for Blockchain.info often+-- send, and refuse any other format rather than answering in JSON anyway.+jsonOnly :: (Req -> IO Response) -> Req -> IO Response+jsonOnly h req = do+ paramOr req json "format" ()+ h req+ where+ json "json" = Just ()+ json _ = Nothing++-- | Register a handler for an API resource, with compact JSON by default.+pathCompact ::+ (ApiResource a b) =>+ WebState ->+ [Text] ->+ (Req -> IO a) ->+ (a -> IO b) ->+ (b -> Encoding) ->+ (b -> Value) ->+ Route+pathCompact st ps parser action encJson encValue =+ pathCommon st ps parser action (const status200) encJson encValue False++-- | The route of an API resource: parse the request, run the handler, and+-- encode the response as JSON or binary, as the client asked. It accepts the+-- parameters given, and @pretty@.+pathCommon ::+ (ApiResource a b) =>+ WebState ->+ [Text] ->+ (Req -> IO a) ->+ (a -> IO b) ->+ (b -> Status) ->+ (b -> Encoding) ->+ (b -> Value) ->+ Bool ->+ Route+pathCommon _ ps parser action status encJson encValue pretty =+ route (renderStdMethod (resourceMethod proxy)) (capturePath proxy) ("pretty" : ps) $ \req -> do+ proto <- serialAs req pretty+ res <- action =<< parser req+ return $+ responseLBS (status res) [(hContentType, serialType proto)] (protoSerial proto encJson encValue res)+ where+ toProxy :: (Req -> IO a) -> Proxy a+ toProxy = const Proxy+ proxy = toProxy parser++-- | Respond with JSON.+streamEncoding :: Encoding -> IO Response+streamEncoding e =+ return $+ responseLBS status200 [(hContentType, "application/json; charset=utf-8")] (encodingToLazyByteString e)++-- | Respond with text.+textResponse :: TL.Text -> IO Response+textResponse t =+ return $+ responseLBS status200 [(hContentType, "text/plain; charset=utf-8")] (TL.encodeUtf8 t)++-- | Encode a response in binary, JSON or pretty JSON.+protoSerial ::+ (Serial a) =>+ SerialAs ->+ (a -> Encoding) ->+ (a -> Value) ->+ a ->+ L.ByteString+protoSerial SerialAsBinary _ _ = runPutL . serialize+protoSerial SerialAsJSON f _ = encodingToLazyByteString . f+protoSerial SerialAsPrettyJSON _ g =+ encodePretty' defConfig {confTrailingNewline = True} . g++-- | The content type of an encoding.+serialType :: SerialAs -> C.ByteString+serialType SerialAsBinary = "application/octet-stream"+serialType _ = "application/json"++-- | Whether a request asks for the binary encoding: its @Accept@ header+-- names @application\/octet-stream@, and gives it no less weight than it+-- gives JSON.+--+-- The header is a list of media types, each with a weight from 0 to 1,+-- written @;q=@, that is 1 when left out. Binary is what is sent when+-- @application\/octet-stream@ is in the list with a weight above 0 that is+-- at least that of @application\/json@, if that is there too. A wildcard+-- such as @*\/*@ asks for nothing in particular and gets JSON, the+-- default, as does a request without the header. The header used to be+-- compared with the one string, so that a client that sent a list, or a+-- weight, got JSON without being told.+wantsBinary :: Req -> Bool+wantsBinary req = maybe False acceptsBinary (Router.header req "accept")++-- | 'wantsBinary', given the value of the @Accept@ header.+acceptsBinary :: Text -> Bool+acceptsBinary accept =+ case weight "application/octet-stream" of+ Just q | q > 0 -> maybe True (q >=) (weight "application/json")+ _ -> False+ where+ ranges = map range (T.splitOn "," accept)+ weight name = lookup name ranges+ range :: Text -> (Text, Double)+ range part = case map T.strip (T.splitOn ";" part) of+ [] -> ("", 0)+ name : params -> (T.toLower name, fromMaybe 1 (listToMaybe (mapMaybe quality params)))+ quality setting = case T.splitOn "=" setting of+ [k, v] | T.toLower (T.strip k) == "q" -> readMaybe (T.unpack (T.strip v)) <|> Just 0+ _ -> Nothing++-- | The encoding that a request asks for: binary if it 'wantsBinary', else+-- JSON, pretty if the @pretty@ parameter or the default says so.+serialAs :: Req -> Bool -> IO SerialAs+serialAs req pretty+ | wantsBinary req = return SerialAsBinary+ | otherwise = do+ p <- paramOr req readBool "pretty" pretty+ return $ if p then SerialAsPrettyJSON else SerialAsJSON++-- GET Block / GET Blocks --++-- | Handler for @GET \/block\/:block@: a block by hash.+handleBlock ::+ WebState ->+ GetBlock ->+ IO BlockData+handleBlock st (GetBlock h (NoTx noTx)) = withMetrics st (.block) $ do+ st.query.getBlock h >>= \case+ Nothing ->+ raise st ThingNotFound+ Just b -> do+ return $ pruneTx noTx b++-- | Blocks by hash, without duplicates, leaving out unknown ones.+getBlocks ::+ WebState ->+ [H.BlockHash] ->+ Bool ->+ IO [BlockData]+getBlocks st hs notx =+ (pruneTx notx <$>) . catMaybes <$> mapM (st.query.getBlock) (nub hs)++-- | Handler for @GET \/blocks?blocks=…@: several blocks by hash.+handleBlocks ::+ WebState ->+ GetBlocks ->+ IO [BlockData]+handleBlocks st (GetBlocks hs (NoTx notx)) =+ withMetrics st (.block) $+ getBlocks st hs notx++-- | Keep only the coinbase in the list of transactions of a block, if the flag is 'True'.+pruneTx :: Bool -> BlockData -> BlockData+pruneTx False b = b+pruneTx True BlockData {..} = BlockData {txs = take 1 txs, ..}++-- GET BlockRaw --++-- | Handler for @GET \/block\/:block\/raw@: a block by hash, serialized as in the peer-to-peer protocol.+handleBlockRaw ::+ WebState ->+ GetBlockRaw ->+ IO (RawResult H.Block)+handleBlockRaw st (GetBlockRaw h) =+ withMetrics st (.blockRaw) $+ RawResult <$> getRawBlock st h++-- | Block by hash with all its transactions. Responds with an error if not found.+getRawBlock ::+ WebState ->+ H.BlockHash ->+ IO H.Block+getRawBlock st h =+ st.query.getBlock h >>= maybe (raise st ThingNotFound) return >>= toRawBlock st.query++-- | Rebuild a block from its data and its transactions.+toRawBlock :: StoreRead -> BlockData -> IO H.Block+toRawBlock q b = do+ txs <- map transactionData . catMaybes <$> C.getTransactions q b.txs+ return H.Block {header = b.header, txs}++-- GET BlockBest / BlockBestRaw --++-- | Handler for @GET \/block\/best@: the best block.+handleBlockBest ::+ WebState ->+ GetBlockBest ->+ IO BlockData+handleBlockBest st (GetBlockBest (NoTx notx)) =+ withMetrics st (.block) $+ (bestBlock st.query) >>= maybe (raise st ThingNotFound) (return . pruneTx notx)++-- | Handler for @GET \/block\/best\/raw@: the best block, serialized.+handleBlockBestRaw ::+ WebState ->+ GetBlockBestRaw ->+ IO (RawResult H.Block)+handleBlockBestRaw st _ =+ withMetrics st (.blockRaw) $+ st.query.getBestBlock >>= \case+ Nothing -> raise st ThingNotFound+ Just h -> RawResult <$> getRawBlock st h++-- GET BlockLatest --++-- | Handler for @GET \/block\/latest@: the best block and its ancestors, up to 100 blocks.+handleBlockLatest ::+ WebState ->+ GetBlockLatest ->+ IO [BlockData]+handleBlockLatest st (GetBlockLatest (NoTx noTx)) =+ withMetrics st (.block) $+ st.query.getBestBlock >>= maybe (raise st ThingNotFound) (go [] <=< st.query.getBlock)+ where+ go acc Nothing = return $ reverse acc+ go acc (Just b)+ | b.height <= 0 = return $ reverse acc+ | length acc == 99 = return . reverse $ pruneTx noTx b : acc+ | otherwise = go (pruneTx noTx b : acc) =<< st.query.getBlock b.header.prev++-- GET BlockHeight / BlockHeights / BlockHeightRaw --++-- | Handler for @GET \/block\/height\/:height@: the blocks at a height.+handleBlockHeight ::+ WebState ->+ GetBlockHeight ->+ IO [BlockData]+handleBlockHeight st (GetBlockHeight h (NoTx notx)) =+ withMetrics st (.block) $+ st.query.getBlocksAtHeight (fromIntegral h)+ >>= (\hs -> getBlocks st hs notx)++-- | Handler for @GET \/block\/heights?heights=…@: the blocks at several heights.+handleBlockHeights ::+ WebState ->+ GetBlockHeights ->+ IO [BlockData]+handleBlockHeights st (GetBlockHeights (HeightsParam heights) (NoTx notx)) =+ withMetrics st (.block) $+ mapM (st.query.getBlocksAtHeight . fromIntegral) heights+ >>= (\hs -> getBlocks st hs notx) . concat++-- | Handler for @GET \/block\/height\/:height\/raw@: the blocks at a height, serialized.+handleBlockHeightRaw ::+ WebState ->+ GetBlockHeightRaw ->+ IO (RawResultList H.Block)+handleBlockHeightRaw st (GetBlockHeightRaw h) =+ withMetrics st (.blockRaw) $+ fmap RawResultList $+ mapM (getRawBlock st) =<< st.query.getBlocksAtHeight (fromIntegral h)++-- GET BlockTime / BlockTimeRaw --++-- | Handler for @GET \/block\/time\/:time@: the last block with a timestamp at or before a time.+handleBlockTime ::+ WebState ->+ GetBlockTime ->+ IO BlockData+handleBlockTime st (GetBlockTime (TimeParam t) (NoTx notx)) =+ withMetrics st (.block) $ do+ let ch = st.config.store.chain+ C.blockAtOrBefore st.query ch t >>= \case+ Nothing -> raise st ThingNotFound+ Just b -> return $ pruneTx notx b++-- | Handler for @GET \/block\/mtp\/:time@: the first block with a median time past at or after a time.+handleBlockMTP ::+ WebState ->+ GetBlockMTP ->+ IO BlockData+handleBlockMTP st (GetBlockMTP (TimeParam t) (NoTx notx)) =+ withMetrics st (.block) $ do+ let ch = st.config.store.chain+ C.blockAtOrAfterMTP st.query ch t >>= \case+ Nothing -> raise st ThingNotFound+ Just b -> return $ pruneTx notx b++-- | Handler for @GET \/block\/time\/:time\/raw@: the last block with a timestamp at or before a time, serialized.+handleBlockTimeRaw ::+ WebState ->+ GetBlockTimeRaw ->+ IO (RawResult H.Block)+handleBlockTimeRaw st (GetBlockTimeRaw (TimeParam t)) =+ withMetrics st (.blockRaw) $ do+ let ch = st.config.store.chain+ C.blockAtOrBefore st.query ch t >>= \case+ Nothing -> raise st ThingNotFound+ Just b -> RawResult <$> (toRawBlock st.query b)++-- | Handler for @GET \/block\/mtp\/:time\/raw@: the first block with a median time+-- past at or after a time, serialized.+handleBlockMTPRaw ::+ WebState ->+ GetBlockMTPRaw ->+ IO (RawResult H.Block)+handleBlockMTPRaw st (GetBlockMTPRaw (TimeParam t)) =+ withMetrics st (.blockRaw) $ do+ let ch = st.config.store.chain+ C.blockAtOrAfterMTP st.query ch t >>= \case+ Nothing -> raise st ThingNotFound+ Just b -> RawResult <$> (toRawBlock st.query b)++-- GET Transactions --++-- | Handler for @GET \/transaction\/:txid@: a transaction.+handleTx :: WebState -> GetTx -> IO Transaction+handleTx st (GetTx txid) =+ withMetrics st (.tx) $+ C.getTransaction st.query txid >>= maybe (raise st ThingNotFound) return++-- | Handler for @GET \/transactions?txids=…@: several transactions.+handleTxs ::+ WebState ->+ GetTxs ->+ IO [Transaction]+handleTxs st (GetTxs txids) =+ withMetrics st (.tx) $+ catMaybes <$> C.getTransactions st.query (nub txids)++-- | Handler for @GET \/transaction\/:txid\/raw@: a transaction, serialized.+handleTxRaw ::+ WebState ->+ GetTxRaw ->+ IO (RawResult Tx)+handleTxRaw st (GetTxRaw txid) =+ withMetrics st (.txRaw) $+ C.getTransaction st.query txid+ >>= \case+ Nothing -> raise st ThingNotFound+ Just tx -> return $ RawResult (transactionData tx)++-- | Handler for @GET \/transactions\/raw?txids=…@: several transactions, serialized.+handleTxsRaw ::+ WebState ->+ GetTxsRaw ->+ IO (RawResultList Tx)+handleTxsRaw st (GetTxsRaw txids) =+ withMetrics st (.txRaw) $+ RawResultList . map transactionData . catMaybes+ <$> C.getTransactions st.query (nub txids)++-- | Transactions of a block. Responds with an error if not found.+getTxsBlock ::+ WebState ->+ H.BlockHash ->+ IO [Transaction]+getTxsBlock st h =+ st.query.getBlock h >>= \case+ Nothing -> raise st ThingNotFound+ Just b -> catMaybes <$> C.getTransactions st.query b.txs++-- | Handler for @GET \/transactions\/block\/:block@: the transactions of a block.+handleTxsBlock ::+ WebState ->+ GetTxsBlock ->+ IO [Transaction]+handleTxsBlock st (GetTxsBlock h) =+ withMetrics st (.txBlock) $ getTxsBlock st h++-- | Handler for @GET \/transactions\/block\/:block\/raw@: the transactions of a block, serialized.+handleTxsBlockRaw ::+ WebState ->+ GetTxsBlockRaw ->+ IO (RawResultList Tx)+handleTxsBlockRaw st (GetTxsBlockRaw h) =+ withMetrics st (.txBlockRaw) $+ RawResultList . map transactionData <$> getTxsBlock st h++-- GET TransactionAfterHeight --++-- | Handler for @GET \/transaction\/:txid\/after\/:height@: whether any ancestor+-- of a transaction is a coinbase confirmed after a height.+handleTxAfter ::+ WebState ->+ GetTxAfter ->+ IO (GenericResult (Maybe Bool))+handleTxAfter st (GetTxAfter txid height) =+ withMetrics st (.txAfter) $+ GenericResult . fst <$> (cbAfterHeight st.query (fromIntegral height) txid)++-- | Check if any of the ancestors of this transaction is a coinbase after the+-- specified height. Returns 'Nothing' if answer cannot be computed before+-- hitting limits.+cbAfterHeight ::+ StoreRead ->+ H.BlockHeight ->+ TxHash ->+ IO (Maybe Bool, Int)+cbAfterHeight q height txid =+ inputs n HashSet.empty HashSet.empty [txid]+ where+ n = 10000+ inputs 0 _ _ [] = return (Nothing, 10000)+ inputs i is ns [] =+ let is' = HashSet.union is ns+ ns' = HashSet.empty+ ts = HashSet.toList (HashSet.difference ns is)+ in case ts of+ [] -> return (Just False, n - i)+ _ -> inputs i is' ns' ts+ inputs i is ns (t : ts) =+ C.getTransaction q t >>= \case+ Nothing -> return (Nothing, n - i)+ Just tx+ | height_check tx ->+ if cb_check tx+ then return (Just True, n - i + 1)+ else+ let ns' = HashSet.union (ins tx) ns+ in inputs (i - 1) is ns' ts+ | otherwise -> inputs (i - 1) is ns ts+ cb_check = any isCoinbase . (.inputs)+ ins = HashSet.fromList . map (.outpoint.hash) . (.inputs)+ height_check tx =+ case tx.block of+ BlockRef h _ -> h > height+ MemRef _ -> True++-- POST Transaction --++-- | Handler for @POST \/transactions@: broadcast a transaction to half of the peers and return its hash.+handlePostTx :: WebState -> PostTx -> IO TxId+handlePostTx st (PostTx tx) =+ withMetrics st (.txPost) $ do+ let cfg = st.config+ (publishTx cfg tx)+ return (TxId (txHash tx))++-- | Send transaction to all connected peers.+publishTx ::+ WebConfig ->+ Tx ->+ IO ()+publishTx cfg tx = do+ ps <- getPeers cfg.store.peerMgr+ let c = max 1 (length ps `div` 2)+ forM_ (take c ps) $ \p -> do+ sendMessage (MTx tx) p.mailbox+ void . async $ do+ threadDelay (5 * 1000 * 1000)+ let v = if cfg.store.net.segWit then InvWitnessTx else InvTx+ g = MGetData (GetData [InvVector v (txHash tx).get])+ sendMessage g p.mailbox++-- GET Mempool / Events --++-- | Handler for @GET \/mempool@: hashes of mempool transactions, newest first.+handleMempool ::+ WebState ->+ GetMempool ->+ IO [TxHash]+handleMempool st (GetMempool limitM (OffsetParam o)) =+ withMetrics st (.mempool) $ do+ WebLimits {..} <- pure st.config.limits+ let wl' = WebLimits {maxItemCount = 0, ..}+ l = Limits (validateLimit wl' False limitM) (fromIntegral o) Nothing+ map snd <$> st.query.getMempoolPage l++-- | Middleware that serves @\/events@ over a WebSocket: each new block and+-- each transaction that enters or leaves the mempool is a message, from the+-- log of events ("Haskoin.Store.Web.Events").+--+-- The client may send the commands of t'EventCommand' to choose the+-- transactions that it hears about. Each side speaks JSON in text frames+-- or binary in binary frames, and the server speaks as it was last spoken+-- to; before the client has said anything, as the @Accept@ header of the+-- handshake asks, like any request. A command that the server cannot read+-- or cannot do closes the connection with the reason, as a node does with+-- an SPV client: there are no answers to wait for.+--+-- @?after=@ with the hash of the last event heard takes up from there+-- ('eventsStart'). Clients that do not answer pings, or that fall off the+-- start of the log, are disconnected.+webSocketEvents :: WebState -> Middleware+webSocketEvents s =+ websocketsOr defaultConnectionOptions (wrap . events)+ where+ pingPong = defaultPingPongOptions {pingInterval = 30, pongTimeout = 60}+ gauge = (.events) <$> s.metrics+ net = s.config.store.net+ ctx = s.config.store.ctx+ gap = s.query.maxGap+ wrap f = case gauge of+ Nothing -> f+ Just g -> withGaugeIO g f+ events pending = do+ let request = pendingRequest pending+ (path, query) = C.break (== '?') (requestPath request)+ asked = parseQuery query+ accept = lookup "Accept" (WebSockets.requestHeaders request)+ binary = maybe False (acceptsBinary . T.decodeUtf8Lenient) accept+ if path /= "/events"+ then turnAway pending ThingNotFound+ else+ eventsOpen s (\name -> T.decodeUtf8Lenient <$> join (lookup (T.encodeUtf8 name) asked)) >>= \case+ Left e -> turnAway pending e+ Right (start, watch) -> do+ conn <- acceptRequest pending+ format <- newTVarIO binary+ -- websockets only sees pongs while reading, which the+ -- thread that hears the commands does+ withPingPong pingPong conn $ \c ->+ race_ (hear c watch format) (tell c watch format start) `catch` gone+ -- a client that leaves is not an error to report+ gone :: WebSockets.ConnectionException -> IO ()+ gone _ = return ()+ -- the commands of the client, until one cannot be done+ hear c watch format = do+ message <- WebSockets.receiveDataMessage c+ let (binary, read') = case message of+ WebSockets.Text bytes _ -> (False, A.decode bytes >>= parseMaybe (unmarshalValue (net, ctx)))+ WebSockets.Binary bytes -> (True, either (const Nothing) Just (unmarshal (net, ctx) (L.toStrict bytes)))+ atomically (writeTVar format binary)+ case read' of+ Nothing -> refuse c "Could not read the command"+ Just cmd ->+ try (command ctx gap s.query.xPubBals watch cmd) >>= \case+ Right (Right ()) -> hear c watch format+ Right (Left why) -> refuse c why+ Left (_ :: Except) -> refuse c "Could not do the command"+ -- 1008: the message is against what the server allows+ refuse c why = WebSockets.sendCloseCode c 1008 (T.pack why)+ -- the events from a place in the log on, those that the client chose+ tell c watch format from =+ atomically (readEvents s.eventLog from) >>= \case+ Nothing -> WebSockets.sendClose c ("Event queue full" :: Text)+ Just entries -> do+ forM_ entries $ \e -> do+ wanted <- atomically (stateTVar watch (relevant ctx gap e.seen))+ when wanted $+ readTVarIO format >>= \case+ True -> sendBinaryData c (runPutL (serialize e.event))+ False -> sendTextData c (A.encode e.event)+ tell c watch format (from + fromIntegral (length entries))+ turnAway pending e =+ rejectRequestWith+ pending+ WebSockets.defaultRejectRequest+ { WebSockets.rejectBody = L.toStrict (A.encode e),+ WebSockets.rejectCode = statusCode (errStatus e),+ WebSockets.rejectMessage = statusMessage (errStatus e),+ WebSockets.rejectHeaders = [("Content-Type", "application/json")]+ }++-- | Handler for @GET \/events@: stream of new blocks and of transactions+-- that enter or leave the mempool, one per line in JSON or one after the+-- other in binary, until the client falls off the start of the log.+-- @after@ takes up from an event ('eventsStart').+handleEvents :: WebState -> Req -> IO Response+handleEvents st req = do+ proto <- serialAs req False+ let asked name = lookup name req.params+ (start, watch) <- eventsOpen st asked >>= either (raise st) return+ return . responseStream status200 [(hContentType, serialType proto)] $ \write flush' ->+ let send from = do+ flush'+ -- ends the response when the client falls too far behind+ atomically (readEvents st.eventLog from) >>= \case+ Nothing -> return ()+ Just entries -> do+ forM_ entries $ \e -> do+ wanted <- atomically (stateTVar watch (relevant own.ctx st.query.maxGap e.seen))+ when wanted (write (serial proto e.event))+ send (from + fromIntegral (length entries))+ in withGaugeIncrease st (.events) (send start)+ where+ serial proto e =+ lazyByteString $+ protoSerial proto toEncoding toJSON e <> newLine proto+ newLine SerialAsBinary = mempty+ newLine SerialAsJSON = "\n"+ newLine SerialAsPrettyJSON = mempty+ own = st.config.store++-- | Where a client of the events starts reading the log, and what it hears+-- about from the start, as the parameters of its request say: 'eventsStart'+-- for @after@, and for the rest a choice made of the same commands that a+-- WebSocket client sends later.+--+-- A WebSocket client could only choose once it was connected, and until its+-- first command was read it was sent every transaction of a busy mempool.+-- With these the choice is there before the first event:+--+-- * @quiet=true@: no transactions, only blocks, until something is watched;+-- * @addresses@: a list of addresses to watch, separated by commas;+-- * @xpub@ and @derive@: an extended public key and the types of its+-- addresses, one or several separated by commas, legacy if left out;+-- * @filter@, @functions@, @tweak@, @flags@: a Bloom filter, in hex, with+-- its numbers.+--+-- Any of them but @quiet=false@ makes the client a filtered one. The HTTP+-- stream takes them too, and it is the only way that it has to choose.+-- The place in the log is taken first, so that nothing that happens while+-- a key is read from the database is left out.+eventsOpen :: WebState -> (Text -> Maybe Text) -> IO (Either Except (Word64, TVar Watch))+eventsOpen st asked =+ eventsStart st (asked "after") >>= \case+ Left e -> return (Left e)+ Right start -> case commands of+ Left why -> return (Left (UserError why))+ Right cmds -> do+ watch <- newTVarIO everything+ let run [] = return (Right (start, watch))+ run (cmd : rest) =+ command own.ctx st.query.maxGap st.query.xPubBals watch cmd >>= \case+ Left why -> return (Left (UserError why))+ Right () -> run rest+ run cmds+ where+ own = st.config.store+ commands = do+ quiet <- maybe (Right False) (need "quiet" . readBool) (asked "quiet")+ addresses <- mapM (need "addresses" . textToAddr own.net) (listed "addresses")+ derivs <- mapM (need "derive" . textToDeriveType) (listed "derive")+ keys <- mapM (need "xpub" . xPubImport own.net own.ctx) (maybe [] pure (asked "xpub"))+ filters <- case asked "filter" of+ Nothing -> Right []+ Just hex -> do+ let number name = maybe (Right 0) (need name . (readNum :: Text -> Maybe Word32)) (asked name)+ functions <- number "functions"+ tweak <- number "tweak"+ flags <- number "flags"+ let json =+ A.object+ [ "type" A..= A.String "filterload",+ "filter" A..= hex,+ "functions" A..= functions,+ "tweak" A..= tweak,+ "flags" A..= flags+ ]+ pure <$> need "filter" (parseMaybe (unmarshalValue (own.net, own.ctx)) json)+ when (null keys && not (null derivs)) (Left "Parameter derive needs xpub")+ let types = if null derivs then [DeriveNormal] else derivs+ return $+ [EventNone | quiet]+ <> [EventWatch addresses | not (null addresses)]+ <> [EventWatchXPub XPubSpec {key, deriv} | key <- keys, deriv <- types]+ <> filters+ listed name = maybe [] (filter (not . T.null) . T.splitOn ",") (asked name)+ need :: Text -> Maybe a -> Either String a+ need name = maybe (Left ("Could not read parameter " <> T.unpack name)) Right++-- | Where a client of the events starts reading the log: at the end, or+-- after the event that it says it heard last, named by the hash of its+-- block or transaction. 404 when the log has no event with that hash: the+-- client cannot know what it missed, and has to read what it follows again+-- and listen from the end.+eventsStart :: WebState -> Maybe Text -> IO (Either Except Word64)+eventsStart st = \case+ Nothing -> Right <$> atomically (eventsEnd st.eventLog)+ Just hex -> case (H.hexToBlockHash hex, hexToTxHash hex) of+ (Nothing, Nothing) -> return (Left (UserError "The parameter after is not a hash"))+ (block, tx) -> maybe (Left ThingNotFound) Right <$> atomically (eventsAfter st.eventLog block tx)++-- | Write what happens into the log of events for as long as the server+-- runs, each transaction that enters with what the database has of it+-- ('seenOf'), for the clients that choose what they hear about.+--+-- A store alone writes what it publishes. A member of a fleet writes what+-- the fleet tells ('memberEvents'), from the start of what Redis keeps, so+-- that the logs of all the servers of a fleet hold the same events and a+-- client can take up on any of them.+feedEvents :: WebState -> IO ()+feedEvents st = case own.fleet of+ Just member -> memberEvents member True (mapM_ happened . fleet) complain+ Nothing -> do+ queue <- newTQueueIO+ let listen = mapM_ (writeTQueue queue) . local+ bracket (subscribe own.pub listen) (unsubscribe own.pub) $ \_ ->+ forever (atomically (readTQueue queue) >>= happened)+ where+ own = st.config.store+ local = \case+ StoreBestBlock b -> Just (Left b)+ StoreMempoolNew t -> Just (Right (t, True))+ StoreMempoolDelete t -> Just (Right (t, False))+ _ -> Nothing+ fleet = \case+ FleetBlock h -> Left <$> hash h+ FleetEntered h -> Right . (,True) <$> hash h+ FleetLeft h -> Right . (,False) <$> hash h+ hash :: (Serialize x) => C.ByteString -> Maybe x+ hash = either (const Nothing) Just . decode+ happened = \case+ Left b -> atomically (appendEvent st.eventLog (BlockArrived b))+ Right (t, False) -> atomically (appendEvent st.eventLog (TxLeft t))+ Right (t, True) -> do+ -- a transaction that cannot be read is told to every client+ known <- st.direct.getTxData t `catchAny` \_ -> return Nothing+ atomically (appendEvent st.eventLog (TxEntered t (seenOf own.net own.ctx <$> known)))+ complain why = logError (withSource "Events" st.config.logger) ("Could not read the events: " <> cs why)++-- GET Address Transactions --++-- | Handler for @GET \/address\/:address\/transactions@: transactions of an address.+handleAddrTxs ::+ WebState ->+ GetAddrTxs ->+ IO [TxRef]+handleAddrTxs st (GetAddrTxs addr pLimits) =+ withMetrics st (.addressTx) $+ st.query.getAddressTxs addr =<< paramToLimits st False pLimits++-- | Handler for @GET \/address\/transactions?addresses=…@: transactions of several addresses.+handleAddrsTxs ::+ WebState ->+ GetAddrsTxs ->+ IO [TxRef]+handleAddrsTxs st (GetAddrsTxs addrs pLimits) =+ withMetrics st (.addressTx) $+ st.query.getAddressesTxs addrs =<< paramToLimits st False pLimits++-- | Handler for @GET \/address\/:address\/transactions\/full@: full transactions of an address.+handleAddrTxsFull ::+ WebState ->+ GetAddrTxsFull ->+ IO [Transaction]+handleAddrTxsFull st (GetAddrTxsFull addr pLimits) =+ withMetrics st (.addressTxFull) $ do+ txs <- st.query.getAddressTxs addr =<< paramToLimits st True pLimits+ catMaybes <$> C.getTransactions st.query (map (.txid) txs)++-- | Handler for @GET \/address\/transactions\/full?addresses=…@: full transactions of several addresses.+handleAddrsTxsFull ::+ WebState ->+ GetAddrsTxsFull ->+ IO [Transaction]+handleAddrsTxsFull st (GetAddrsTxsFull addrs pLimits) =+ withMetrics st (.addressTxFull) $ do+ txs <- st.query.getAddressesTxs addrs =<< paramToLimits st True pLimits+ catMaybes <$> C.getTransactions st.query (map (.txid) txs)++-- | Handler for @GET \/address\/:address\/balance@: balance of an address.+handleAddrBalance :: WebState -> GetAddrBalance -> IO Balance+handleAddrBalance st (GetAddrBalance addr) =+ withMetrics st (.addressBalance) $ C.getDefaultBalance st.query addr++-- | Handler for @GET \/address\/balances?addresses=…@: balances of several addresses.+handleAddrsBalance ::+ WebState ->+ GetAddrsBalance ->+ IO [Balance]+handleAddrsBalance st (GetAddrsBalance addrs) =+ withMetrics st (.addressBalance) $ st.query.getBalances addrs++-- | Handler for @GET \/address\/:address\/unspent@: unspent outputs of an address.+handleAddrUnspent ::+ WebState ->+ GetAddrUnspent ->+ IO [Unspent]+handleAddrUnspent st (GetAddrUnspent addr pLimits) =+ withMetrics st (.addressUnspent) $+ st.query.getAddressUnspents addr =<< paramToLimits st False pLimits++-- | Handler for @GET \/address\/unspent?addresses=…@: unspent outputs of several addresses.+handleAddrsUnspent ::+ WebState ->+ GetAddrsUnspent ->+ IO [Unspent]+handleAddrsUnspent st (GetAddrsUnspent addrs pLimits) =+ withMetrics st (.addressUnspent) $+ st.query.getAddressesUnspents addrs =<< paramToLimits st False pLimits++-- GET XPubs --++-- | Handler for @GET \/xpub\/:xpub@: summary of an extended public key.+handleXPub ::+ WebState ->+ GetXPub ->+ IO XPubSummary+handleXPub st (GetXPub xpub deriv (NoCache noCache)) =+ withMetrics st (.xpub) $+ let xs = XPubSpec xpub deriv+ in xPubSummary xs <$> xPubBalsChecked st noCache xs++-- | Handler for @DELETE \/xpub\/:xpub@: remove an extended public key from the cache. Returns 'True' if it was cached.+handleDelXPub ::+ WebState ->+ DelCachedXPub ->+ IO (GenericResult Bool)+handleDelXPub st (DelCachedXPub xpub deriv) =+ withMetrics st (.xpubDelete) $ do+ let c = st.config.store.cache+ r <- maybe (return False) (`cacheDelete` XPubSpec xpub deriv) c+ return $ GenericResult r++-- | Balances of the addresses of an extended public key ('xPubBals'), from+-- the cache unless told not to, refusing a wrong kind of address+-- ('checkDerive').+xPubBalsChecked :: WebState -> Bool -> XPubSpec -> IO [XPubBal]+xPubBalsChecked st noCache xs = do+ bals <- (cacheOr noCache st).query.xPubBals xs+ checkDerive st xs bals+ return bals++-- | Answer with an error if an extended public key has no transactions with+-- the kind of address asked for, but has some with another kind: the request+-- named the wrong kind.+--+-- An xpub does not record which kind of address its wallet derives, and the+-- same key gives different addresses for each kind, so a request with the+-- wrong @derive@ would otherwise get an empty wallet: a valid answer, and a+-- wrong one, that a wallet would show its user as a zero balance. An error+-- says what went wrong and how to fix it.+--+-- The check costs nothing for a wallet that has transactions. For one that+-- looks unused, it reads the balances of the first addresses of each other+-- kind, as many as the store reads to decide that an xpub is unused, in one+-- read per kind. A wallet unused with every kind is a new wallet, and gets its+-- empty answer. Networks without SegWit have one kind only, and no check.+checkDerive :: WebState -> XPubSpec -> [XPubBal] -> IO ()+checkDerive st xs bals =+ when (net.segWit && all (\b -> b.balance.txs == 0) bals) $ do+ used <- filterM usedAs others+ forM_ (take 1 used) $ \d ->+ raise st . UserError $+ unwords+ [ "This xpub has no transactions with derive=" <> name xs.deriv <> ",",+ "but has some with derive=" <> name d <> ";",+ "ask with derive=" <> name d <> "."+ ]+ where+ net = st.config.store.net+ ctx = st.config.store.ctx+ others = filter (/= xs.deriv) [DeriveNormal, DeriveP2SH, DeriveP2WPKH]+ name = T.unpack . deriveTypeToText+ usedAs d = do+ let external = pubSubKey ctx xs.key 0+ addrs =+ map snd . take (fromIntegral st.query.initialGap) $+ deriveAddresses (deriveFunction ctx d) external 0+ any (\b -> b.txs > 0) <$> st.query.getBalances addrs++-- | Transactions of an extended public key, optionally without the cache.+getXPubTxs ::+ WebState ->+ XPubKey ->+ DeriveType ->+ LimitsParam ->+ Bool ->+ IO [TxRef]+getXPubTxs st xpub deriv plimits nocache = do+ limits <- paramToLimits st False plimits+ let xs = XPubSpec xpub deriv+ st' = cacheOr nocache st+ xbals <- xPubBalsChecked st nocache xs+ st'.query.xPubTxs xs xbals limits++-- | Handler for @GET \/xpub\/:xpub\/transactions@: transactions of an extended public key.+handleXPubTxs ::+ WebState ->+ GetXPubTxs ->+ IO [TxRef]+handleXPubTxs st (GetXPubTxs xpub deriv plimits (NoCache nocache)) =+ withMetrics st (.xpubTx) $+ getXPubTxs st xpub deriv plimits nocache++-- | Handler for @GET \/xpub\/:xpub\/transactions\/full@: full transactions of an extended public key.+handleXPubTxsFull ::+ WebState ->+ GetXPubTxsFull ->+ IO [Transaction]+handleXPubTxsFull st (GetXPubTxsFull xpub deriv plimits (NoCache nocache)) =+ withMetrics st (.xpubTxFull) $+ fmap catMaybes $+ C.getTransactions (cacheOr nocache st).query . map (.txid)+ =<< getXPubTxs st xpub deriv plimits nocache++-- | Handler for @GET \/xpub\/:xpub\/balances@: balances of the used addresses of an extended public key.+handleXPubBalances ::+ WebState ->+ GetXPubBalances ->+ IO [XPubBal]+handleXPubBalances st (GetXPubBalances xpub deriv (NoCache noCache)) =+ withMetrics st (.xpubBalance) $+ xPubBalsChecked st noCache (XPubSpec xpub deriv)++-- | Handler for @GET \/xpub\/:xpub\/unspent@: unspent outputs of an extended public key.+handleXPubUnspent ::+ WebState ->+ GetXPubUnspent ->+ IO [XPubUnspent]+handleXPubUnspent st (GetXPubUnspent xpub deriv pLimits (NoCache noCache)) =+ withMetrics st (.xpubUnspent) $ do+ limits <- paramToLimits st False pLimits+ let xspec = XPubSpec xpub deriv+ st' = cacheOr noCache st+ xbals <- xPubBalsChecked st noCache xspec+ st'.query.xPubUnspents xspec xbals limits++---------------------------------------+-- Blockchain.info API Compatibility --+---------------------------------------++-- | Currency symbol of the coin of a network, in the Blockchain.info format.+netBinfoSymbol :: Network -> BinfoSymbol+netBinfoSymbol net+ | net == btc =+ BinfoSymbol+ { code = "BTC",+ symbol = "BTC",+ name = "Bitcoin",+ conversion = 100 * 1000 * 1000,+ after = True,+ local = False+ }+ | net == bch =+ BinfoSymbol+ { code = "BCH",+ symbol = "BCH",+ name = "Bitcoin Cash",+ conversion = 100 * 1000 * 1000,+ after = True,+ local = False+ }+ | otherwise =+ BinfoSymbol+ { code = "XTS",+ symbol = "¤",+ name = "Test",+ conversion = 100 * 1000 * 1000,+ after = False,+ local = False+ }++-- | Currency symbol of a fiat currency from its price ticker.+binfoTickerToSymbol :: Text -> BinfoTicker -> BinfoSymbol+binfoTickerToSymbol code t =+ BinfoSymbol+ { code,+ symbol = t.symbol,+ name,+ conversion = 100 * 1000 * 1000 / t.fifteen, -- sat/usd+ after = False,+ local = True+ }+ where+ name = case code of+ "EUR" -> "Euro"+ "USD" -> "U.S. dollar"+ "GBP" -> "British pound"+ x -> x++-- | Addresses and xpubs in a request parameter, separated by pipes or commas.+getBinfoAddrsParam ::+ WebState ->+ Req ->+ Text ->+ IO (HashSet BinfoAddr)+getBinfoAddrsParam st req name = do+ let net = st.config.store.net+ let ctx = st.config.store.ctx+ p <- fromMaybe "" <$> param req name+ if T.null p+ then return HashSet.empty+ else case parseBinfoAddr net ctx p of+ Nothing -> raise st (UserError "invalid address")+ Just xs -> return $ HashSet.fromList xs++-- | Xpubs and addresses in the @active@, @activeP2SH@ and @activeBech32@+-- parameters. Xpubs in each parameter derive legacy, nested SegWit and native+-- SegWit addresses.+getBinfoActive ::+ WebState ->+ Req ->+ IO (HashSet XPubSpec, HashSet Address)+getBinfoActive st req = do+ active <- getBinfoAddrsParam st req "active"+ p2sh <- getBinfoAddrsParam st req "activeP2SH"+ bech32 <- getBinfoAddrsParam st req "activeBech32"+ let xspec d b = (`XPubSpec` d) <$> xpub b+ xspecs =+ HashSet.fromList $+ concat+ [ mapMaybe (xspec DeriveNormal) (HashSet.toList active),+ mapMaybe (xspec DeriveP2SH) (HashSet.toList p2sh),+ mapMaybe (xspec DeriveP2WPKH) (HashSet.toList bech32)+ ]+ addrs = HashSet.fromList $ mapMaybe addr $ HashSet.toList active+ return (xspecs, addrs)+ where+ addr (BinfoAddr a) = Just a+ addr (BinfoXpub _) = Nothing+ xpub (BinfoXpub x) = Just x+ xpub (BinfoAddr _) = Nothing++-- | A kind of address to derive, as in the @derive@ parameter of the xpub+-- endpoints: SegWit kinds only on networks with SegWit.+readDerive :: Network -> Text -> Maybe DeriveType+readDerive net t = do+ d <- textToDeriveType t+ guard (net.segWit || d == DeriveNormal)+ return d++-- | Whether to identify transactions by short number, unless @txidindex@ is false.+getNumTxId :: Req -> IO Bool+getNumTxId req = not <$> paramOr req readBool "txidindex" False++-- | Height of the best header.+getChainHeight :: WebState -> IO H.BlockHeight+getChainHeight st =+ fmap (.height) $ chainGetBest =<< pure st.config.store.chain++-- | Handler for @GET\/POST \/blockchain\/unspent@: unspent outputs of addresses and xpubs, as Blockchain.info.+handleBinfoUnspent :: WebState -> Req -> IO Response+handleBinfoUnspent st req =+ withMetrics st (.binfoUnspent) $ do+ (xspecs, addrs) <- getBinfoActive st req+ numtxid <- getNumTxId req+ limit <- get_limit+ min_conf <- get_min_conf+ let net = st.config.store.net+ let ctx = st.config.store.ctx+ height <- getChainHeight st+ let mn u = min_conf > u.confirmations+ xbals <- getXBals st.query xspecs+ bus <-+ runConduit $+ getBinfoUnspents st.query numtxid height xbals xspecs addrs+ .| (dropWhileC mn >> takeC limit .| sinkList)+ streamEncoding (marshalEncoding (net, ctx) (BinfoUnspents bus))+ where+ get_limit = min 1000 . fromIntegral <$> paramOr req readNat "limit" 250+ get_min_conf = fromIntegral <$> paramOr req readNat "confirmations" 0++-- | Stream the unspent outputs of xpubs and addresses, newest first, in the+-- Blockchain.info format.+getBinfoUnspents ::+ StoreRead ->+ Bool ->+ H.BlockHeight ->+ HashMap XPubSpec [XPubBal] ->+ HashSet XPubSpec ->+ HashSet Address ->+ ConduitT () BinfoUnspent IO ()+getBinfoUnspents q numtxid height xbals xspecs addrs = do+ cs' <- conduits+ joinDescStreams (unspentKey . fst) cs' .| mapC (uncurry binfo)+ where+ binfo u xp =+ let conf = case u.block of+ MemRef _ -> 0+ -- an output from the cache may be in a block that the chain of+ -- this store does not have yet: it is confirmed at least once+ BlockRef h _ -> if h > height then 1 else height - h + 1+ in BinfoUnspent+ { txid = u.outpoint.hash,+ index = u.outpoint.index,+ script = u.script,+ value = u.value,+ confirmations = fromIntegral conf,+ txidx = encodeBinfoTxId numtxid u.outpoint.hash,+ xpub = xp+ }+ conduits = (<>) <$> xconduits <*> pure acounduits+ xconduits = lift $ do+ let f x (XPubUnspent u p) =+ let path = toSoft (listToPath p)+ xp = BinfoXPubPath x.key <$> path+ in (u, xp)+ g x = do+ let h = q.xPubUnspents x (xBals x xbals)+ l = let Limits {..} = def in Limits {limit = 16, ..}+ return $ streamThings h Nothing l .| mapC (f x)+ mapM g (HashSet.toList xspecs)+ acounduits =+ let f u = (u, Nothing)+ h = q.getAddressUnspents+ l = let Limits {..} = def in Limits {limit = 16, ..}+ g a = streamThings (h a) Nothing l .| mapC f+ in map g (HashSet.toList addrs)++-- | Balances of the used addresses of each xpub.+getXBals ::+ StoreRead ->+ HashSet XPubSpec ->+ IO (HashMap XPubSpec [XPubBal])+getXBals q =+ fmap HashMap.fromList . mapM f . HashSet.toList+ where+ f x = (x,) . filter (not . nullBalance . (.balance)) <$> q.xPubBals x++-- | Balances of an xpub, or none if missing.+xBals :: XPubSpec -> HashMap XPubSpec [XPubBal] -> [XPubBal]+xBals = HashMap.findWithDefault []++-- | Stream the references to the transactions of xpubs and addresses, newest+-- first, without duplicates. Each xpub and address is read page by page, only+-- as far as the consumer needs, in pages of the given size: about as many as+-- the consumer is expected to take, as every page is a query of its own.+binfoTxRefs ::+ StoreRead ->+ Word32 ->+ HashMap XPubSpec [XPubBal] ->+ HashSet XPubSpec ->+ HashSet Address ->+ ConduitT () TxRef IO ()+binfoTxRefs q pageSize xbals sxspecs saddrs =+ joinDescStreams txRefKey $+ map xpub_c (HashSet.toList sxspecs) <> map addr_c (HashSet.toList saddrs)+ where+ l = Limits {limit = pageSize, offset = 0, start = Nothing}+ xpub_c x = streamThings (q.xPubTxs x (xBals x xbals)) (Just (.txid)) l+ addr_c a = streamThings (q.getAddressTxs a) (Just (.txid)) l++-- | Stream the transactions of xpubs and addresses, newest first, in the+-- Blockchain.info format, with the running balance of the given addresses after+-- each, keeping only those that pass the filter.+getBinfoTxs ::+ StoreRead ->+ Word32 -> -- page size of the references, as for 'binfoTxRefs'+ HashMap XPubSpec [XPubBal] -> -- xpub balances+ HashMap Address (Maybe BinfoXPubPath) -> -- address book+ HashSet XPubSpec -> -- show xpubs+ HashSet Address -> -- show addrs+ HashSet Address -> -- balance addresses+ BinfoFilter ->+ Bool -> -- numtxid+ Bool -> -- prune outputs+ Int64 -> -- starting balance+ ConduitT () BinfoTx IO ()+getBinfoTxs+ q+ pageSize+ xbals+ abook+ sxspecs+ saddrs+ baddrs+ bfilter+ numtxid+ prune+ bal =+ binfoTxRefs q pageSize xbals sxspecs saddrs .| bodies .| go bal+ where+ binfo_tx = toBinfoTx numtxid abook prune+ compute_bal_change t =+ let ins = map (.output) t.inputs+ out = t.outputs+ f b BinfoTxOutput {..} =+ let val = fromIntegral value+ in case address of+ Nothing -> 0+ Just a+ | a `HashSet.member` baddrs ->+ if b then val else negate val+ | otherwise -> 0+ in sum $ map (f False) ins <> map (f True) out+ -- the bodies, read a page at a time, which is one round trip to Redis+ -- or one multi-get of the database instead of one per transaction+ bodies = do+ refs <- takeC (fromIntegral pageSize) .| sinkList+ unless (null refs) $ do+ lift (C.getTransactions q (map (.txid) refs)) >>= mapM_ yield+ bodies+ go b =+ await >>= \case+ Nothing -> return ()+ Just Nothing -> go b+ Just (Just x) -> do+ let a = binfo_tx b x+ b' = b - compute_bal_change a+ c = isJust a.blockHeight+ d = fst (fromJust a.balance)+ r = d + fromIntegral a.fee+ case bfilter of+ BinfoFilterAll ->+ yield a >> go b'+ BinfoFilterSent+ | 0 > r -> yield a >> go b'+ | otherwise -> go b'+ BinfoFilterReceived+ | r > 0 -> yield a >> go b'+ | otherwise -> go b'+ BinfoFilterMoved+ | r == 0 -> yield a >> go b'+ | otherwise -> go b'+ BinfoFilterConfirmed+ | c -> yield a >> go b'+ | otherwise -> go b'+ BinfoFilterMempool+ | c -> return ()+ | otherwise -> yield a >> go b'++-- | Page size to read the references of the transactions in, to show this+-- many after this offset: all of them in one page if they fit in 1000.+pageFor :: Int -> Int -> Word32+pageFor offset n = fromIntegral (max 16 (min 1000 (offset + n)))++-- | Whether to show addresses in CashAddr format (@cashaddr@).+getCashAddr :: Req -> IO Bool+getCashAddr req = paramOr req readBool "cashaddr" False++-- | Parse an address. Responds with not found if invalid.+getAddress :: WebState -> Text -> IO Address+getAddress st txt = do+ let net = st.config.store.net+ case textToAddr net txt of+ Nothing -> raise st ThingNotFound+ Just a -> return a++-- | Address or xpub in a path parameter. Passes to the next route if invalid.+getBinfoAddr :: WebState -> Req -> Text -> IO BinfoAddr+getBinfoAddr st req param' = do+ txt <- capture req param'+ let net = st.config.store.net+ let ctx = st.config.store.ctx+ let addr = BinfoAddr <$> textToAddr net txt+ xpub = BinfoXpub <$> xPubImport net ctx txt+ maybe next return (addr <|> xpub)++-- | Handler for @GET\/POST \/blockchain\/export-history@: daily history of the+-- balance of addresses and xpubs with prices, as Blockchain.info.+handleBinfoHistory :: WebState -> Req -> IO Response+handleBinfoHistory st req =+ withMetrics st (.binfoExportHistory) $ do+ (xspecs, addrs) <- getBinfoActive st req+ (startM, endM) <- get_dates+ (code, price') <- getPrice st req+ xbals <- (getXBals st.query xspecs)+ let xaddrs = HashSet.fromList $ concatMap (map get_addr) (HashMap.elems xbals)+ aaddrs = xaddrs <> addrs+ cur = price'.fifteen+ cs' = conduits (HashMap.toList xbals) addrs endM+ txs <-+ runConduit $+ joinDescStreams txRefKey cs'+ .| takeWhileC (is_newer startM)+ .| concatMapMC get_transaction+ .| sinkList+ let times = map (\Transaction {..} -> timestamp) txs+ let net = st.config.store.net+ let url = st.config.priceHistoryURL+ let session = st.session+ let logger = st.config.logger+ rates <- map (.price) <$> (getRates logger net session url code times)+ let hs = zipWith (convert cur aaddrs) txs (rates <> repeat 0.0)+ streamEncoding $ toEncoding hs+ where+ is_newer+ (Just BlockData {height = bh})+ TxRef {block = BlockRef {height = th}} =+ bh <= th+ is_newer Nothing TxRef {} = True+ is_newer _ _ = undefined+ get_addr = (.balance.address)+ get_transaction TxRef {txid = h} =+ C.getTransaction st.query h+ convert cur addrs tx rate =+ let ins = tx.inputs+ outs = tx.outputs+ fins = filter (input_addr addrs) ins+ fouts = filter (output_addr addrs) outs+ vin = fromIntegral . sum $ map (.value) fins+ vout = fromIntegral . sum $ map (.value) fouts+ fee = tx.fee+ v = vout - vin+ t = tx.timestamp+ h = txHash $ transactionData tx+ in toBinfoHistory v t rate cur fee h+ input_addr addrs' StoreInput {address = Just a} =+ a `HashSet.member` addrs'+ input_addr _ _ = False+ output_addr addrs' StoreOutput {address = Just a} =+ a `HashSet.member` addrs'+ output_addr _ _ = False+ get_dates = do+ BinfoDate start <- requireParam st req parseBinfoDate "start"+ BinfoDate end' <- requireParam st req parseBinfoDate "end"+ let end = end' + 24 * 60 * 60+ let ch = st.config.store.chain+ startM <- C.blockAtOrAfter st.query ch start+ endM <- C.blockAtOrBefore st.query ch end+ return (startM, endM)+ conduits xpubs addrs endM =+ map (uncurry (xpub_c endM)) xpubs+ <> map (addr_c endM) (HashSet.toList addrs)+ lim endM =+ Limits+ { limit = 16,+ offset = 0,+ start = AtBlock . (.height) <$> endM+ }+ addr_c endM a =+ streamThings (st.query.getAddressTxs a) (Just (.txid)) (lim endM)+ xpub_c endM x bs =+ streamThings (st.query.xPubTxs x bs) (Just (.txid)) (lim endM)++-- | Currency code in the @currency@ parameter, USD by default, in any case,+-- and its latest price. A currency that the price service does not list is+-- refused. Until the server has prices, which it may never have if the+-- service cannot be reached, every currency has a price of zero, rather than+-- every request being refused.+getPrice :: WebState -> Req -> IO (Text, BinfoTicker)+getPrice st req = do+ prices <- readTVarIO st.ticker+ let known c+ | HashMap.null prices = Just (c, def)+ | otherwise = (c,) <$> HashMap.lookup c prices+ paramOr req (known . T.toUpper) "currency" ("USD", fromMaybe def (HashMap.lookup "USD" prices))++-- | Symbol of the currency in the @currency@ parameter.+getSymbol :: WebState -> Req -> IO BinfoSymbol+getSymbol st req = uncurry binfoTickerToSymbol <$> getPrice st req++-- | Handler for @GET \/blockchain\/blocks\/:milliseconds@: the blocks of the day+-- before a time in milliseconds, as Blockchain.info.+handleBinfoBlocksDay :: WebState -> Req -> IO Response+handleBinfoBlocksDay st req =+ withMetrics st (.binfoBlock) $ do+ t <- min h . (`div` 1000) <$> captureWith req readNum "milliseconds"+ let ch = st.config.store.chain+ m <- C.blockAtOrBefore st.query ch t+ bs <- go (d t) m+ streamEncoding $ toEncoding $ map toBinfoBlockInfo bs+ where+ h = fromIntegral (maxBound :: H.Timestamp)+ d = subtract (24 * 3600)+ go _ Nothing = return []+ go t (Just b)+ | b.header.timestamp <= fromIntegral t =+ return []+ | otherwise = do+ b' <- st.query.getBlock b.header.prev+ (b :) <$> go t b'++-- | Handler for @GET\/POST \/blockchain\/multiaddr@: balances and transactions of+-- addresses and xpubs, as Blockchain.info.+handleMultiAddr :: WebState -> Req -> IO Response+handleMultiAddr st req =+ withMetrics st (.binfoMultiaddr) $ do+ (addrs', _, saddrs, sxpubs, xspecs) <- get_addrs+ numtxid <- getNumTxId req+ cashaddr <- getCashAddr req+ local' <- getSymbol st req+ offset <- getBinfoOffset st req+ n <- getBinfoCount st req "n"+ prune <- get_prune+ fltr <- get_filter+ xbals <- (getXBals st.query xspecs)+ xtxns <- get_xpub_tx_count xbals xspecs+ let sxbals = only_show_xbals sxpubs xbals+ xabals = compute_xabals xbals+ addrs = addrs' `HashSet.difference` HashMap.keysSet xabals+ abals <- get_abals addrs+ let sxspecs = only_show_xspecs sxpubs xspecs+ sxabals = compute_xabals sxbals+ sabals = only_show_abals saddrs abals+ sallbals = sabals <> sxabals+ sbal = compute_bal sallbals+ allbals = abals <> xabals+ abook = compute_abook addrs xbals+ sxaddrs = compute_xaddrs sxbals+ salladdrs = saddrs <> sxaddrs+ bal = compute_bal allbals+ ibal = fromIntegral sbal+ ftxs <-+ runConduit $+ getBinfoTxs+ st.query+ (pageFor offset n)+ xbals+ abook+ sxspecs+ saddrs+ salladdrs+ fltr+ numtxid+ prune+ ibal+ .| (dropC offset >> takeC n .| sinkList)+ -- counted without reading the transactions, which takes seconds for+ -- xpubs with tens of thousands of them: one xpub has its own count, from+ -- the cache when it is cached; for several xpubs and addresses, the+ -- database counts the distinct transactions of all their addresses+ ntx <- case (HashSet.toList sxspecs, HashSet.null saddrs) of+ ([x], True) -> return (HashMap.findWithDefault 0 x xtxns)+ _ -> fromIntegral <$> st.direct.getAddressesTxCount (HashSet.toList salladdrs)+ nfiltered <- case fltr of+ BinfoFilterAll -> return ntx+ _ ->+ runConduit $+ getBinfoTxs+ st.query+ 1000+ xbals+ abook+ sxspecs+ saddrs+ salladdrs+ fltr+ numtxid+ prune+ ibal+ .| lengthC+ let net = st.config.store.net+ let ctx = st.config.store.ctx+ best <- get_best_block+ peers <- get_peers+ let baddrs = toBinfoAddrs sabals sxbals xtxns+ abaddrs = toBinfoAddrs abals xbals xtxns+ recv = sum $ map (.received) abaddrs+ sent' = sum $ map (.sent) abaddrs+ wallet =+ BinfoWallet+ { balance = bal,+ txs = ntx,+ filtered = nfiltered,+ received = recv,+ sent = sent'+ }+ coin = netBinfoSymbol net+ let block =+ BinfoBlockInfo+ { hash = H.headerHash best.header,+ height = best.height,+ timestamp = best.header.timestamp,+ index = best.height+ }+ let info =+ BinfoInfo+ { connected = peers,+ conversion = 100 * 1000 * 1000,+ fiat = local',+ crypto = coin,+ head = block+ }+ let multiaddr =+ BinfoMultiAddr+ { addresses = baddrs,+ wallet = wallet,+ txs = ftxs,+ info = info,+ recommendFee = True,+ cashAddr = cashaddr+ }+ streamEncoding $ marshalEncoding (net, ctx) multiaddr+ where+ get_xpub_tx_count xbals =+ fmap HashMap.fromList+ . mapM+ ( \x ->+ (x,)+ . fromIntegral+ <$> st.query.xPubTxCount x (xBals x xbals)+ )+ . HashSet.toList+ get_filter = paramOr req parseBinfoFilter "filter" BinfoFilterAll+ get_best_block =+ st.query.getBestBlock >>= \case+ Nothing -> raise st ThingNotFound+ Just bh ->+ st.query.getBlock bh >>= \case+ Nothing -> raise st ThingNotFound+ Just b -> return b+ get_prune = do+ noCompact <- paramOr req readBool "no_compact" False+ not <$> paramOr req readBool "noCompact" noCompact+ only_show_xbals sxpubs =+ HashMap.filterWithKey (\k _ -> k.key `HashSet.member` sxpubs)+ only_show_xspecs sxpubs =+ HashSet.filter (\k -> k.key `HashSet.member` sxpubs)+ only_show_abals saddrs =+ HashMap.filterWithKey (\k _ -> k `HashSet.member` saddrs)+ addr (BinfoAddr a) = Just a+ addr (BinfoXpub _) = Nothing+ xpub (BinfoXpub x) = Just x+ xpub (BinfoAddr _) = Nothing+ get_addrs = do+ (xspecs, addrs) <- getBinfoActive st req+ sh <- getBinfoAddrsParam st req "onlyShow"+ let xpubs = HashSet.map (.key) xspecs+ actives =+ HashSet.map BinfoAddr addrs+ <> HashSet.map BinfoXpub xpubs+ sh' = if HashSet.null sh then actives else sh+ saddrs = HashSet.fromList $ mapMaybe addr $ HashSet.toList sh'+ sxpubs = HashSet.fromList $ mapMaybe xpub $ HashSet.toList sh'+ return (addrs, xpubs, saddrs, sxpubs, xspecs)+ get_abals =+ let f b = (b.address, b)+ g = HashMap.fromList . map f+ in fmap g . st.query.getBalances . HashSet.toList+ get_peers = do+ ps <- getPeersInformation st.config.store.peerMgr+ return (fromIntegral (length ps))+ compute_xabals =+ let f b = (b.balance.address, b.balance)+ in HashMap.fromList . concatMap (map f) . HashMap.elems+ compute_bal =+ let f b = b.confirmed + b.unconfirmed+ in sum . map f . HashMap.elems+ compute_abook addrs xbals =+ let f xs xb =+ let a = xb.balance.address+ e = error "lions and tigers and bears"+ s = toSoft (listToPath xb.path)+ in (a, Just (BinfoXPubPath xs.key (fromMaybe e s)))+ amap =+ HashMap.map (const Nothing) $+ HashSet.toMap addrs+ xmap =+ HashMap.fromList+ . concatMap (uncurry (map . f))+ $ HashMap.toList xbals+ in amap <> xmap+ compute_xaddrs =+ let f = map (.balance.address)+ in HashSet.fromList . concatMap f . HashMap.elems++-- | Number of items requested in a parameter, within the limits.+getBinfoCount :: WebState -> Req -> Text -> IO Int+getBinfoCount st req str = do+ let d = st.config.limits.defItemCount+ let x = st.config.limits.maxFullItemCount+ i <- fromIntegral <$> paramOr req readNat str (fromIntegral d)+ return (fromIntegral (if x == 0 then i else min x i) :: Int)++-- | Offset requested in the @offset@ parameter. Responds with an error if above the maximum.+getBinfoOffset ::+ WebState ->+ Req ->+ IO Int+getBinfoOffset st req = do+ let x = st.config.limits.maxOffset+ o <- fromIntegral <$> paramOr req readNat "offset" 0+ when (o > x) $+ raise st $+ UserError $+ "offset exceeded: " <> show o <> " > " <> show x+ return (fromIntegral o :: Int)++-- | Handler for @GET \/blockchain\/rawaddr\/:addr@: balance and transactions of an+-- address or xpub, as Blockchain.info; also at @\/blockchain\/address\/:addr@+-- and @\/blockchain\/xpub\/:addr@.+handleRawAddr :: WebState -> Req -> IO Response+handleRawAddr st req =+ withMetrics st (.binfoAddressRaw) $+ getBinfoAddr st req "addr" >>= \case+ BinfoAddr addr -> do_addr addr+ BinfoXpub xpub -> do_xpub xpub+ where+ do_xpub xpub = do+ numtxid <- getNumTxId req+ derive <- paramOr req (readDerive st.config.store.net) "derive" DeriveNormal+ let xspec = XPubSpec xpub derive+ n <- getBinfoCount st req "limit"+ off <- getBinfoOffset st req+ xbals <- (getXBals st.query (HashSet.singleton xspec))+ checkDerive st xspec (xBals xspec xbals)+ let net = st.config.store.net+ let summary = xPubSummary xspec (xBals xspec xbals)+ abook = compute_abook xpub (xBals xspec xbals)+ xspecs = HashSet.singleton xspec+ saddrs = HashSet.empty+ baddrs = HashMap.keysSet abook+ bfilter = BinfoFilterAll+ amnt = summary.confirmed + summary.unconfirmed+ txs <-+ runConduit $+ getBinfoTxs+ st.query+ (pageFor off n)+ xbals+ abook+ xspecs+ saddrs+ baddrs+ bfilter+ numtxid+ False+ (fromIntegral amnt)+ .| (dropC off >> takeC n .| sinkList)+ -- n_tx is every transaction of the xpub, as for an address, not the+ -- number on this page+ ntx <- st.query.xPubTxCount xspec (xBals xspec xbals)+ let ra =+ BinfoRawAddr+ { address = BinfoXpub xpub,+ balance = amnt,+ ntx = fromIntegral ntx,+ utxo = summary.utxo,+ received = summary.received,+ sent = fromIntegral summary.received - fromIntegral amnt,+ txs = txs+ }+ let ctx = st.config.store.ctx+ streamEncoding $ marshalEncoding (net, ctx) ra+ compute_abook xpub xbals =+ let f xb =+ let a = xb.balance.address+ e = error "black hole swallows all your code"+ s = toSoft $ listToPath xb.path+ m = fromMaybe e s+ in (a, Just (BinfoXPubPath xpub m))+ in HashMap.fromList $ map f xbals+ do_addr addr = do+ numtxid <- getNumTxId req+ n <- getBinfoCount st req "limit"+ off <- getBinfoOffset st req+ bal <- fromMaybe (zeroBalance addr) <$> st.query.getBalance addr+ let net = st.config.store.net+ let abook = HashMap.singleton addr Nothing+ xspecs = HashSet.empty+ saddrs = HashSet.singleton addr+ bfilter = BinfoFilterAll+ balance = bal.confirmed + bal.unconfirmed+ txs <-+ runConduit $+ getBinfoTxs+ st.query+ (pageFor off n)+ HashMap.empty+ abook+ xspecs+ saddrs+ saddrs+ bfilter+ numtxid+ False+ (fromIntegral balance)+ .| (dropC off >> takeC n .| sinkList)+ let ra =+ BinfoRawAddr+ { address = BinfoAddr addr,+ balance,+ ntx = bal.txs,+ utxo = bal.utxo,+ received = bal.received,+ sent = fromIntegral bal.received - fromIntegral balance,+ txs+ }+ let ctx = st.config.store.ctx+ streamEncoding $ marshalEncoding (net, ctx) ra++-- | Handler for @GET \/blockchain\/q\/getreceivedbyaddress\/:addr@: total received by an address.+handleBinfoReceived :: WebState -> Req -> IO Response+handleBinfoReceived st req =+ withMetrics st (.binfoQgetreceivedbyaddress) $ do+ a <- getAddress st =<< capture req "addr"+ b <- fromMaybe (zeroBalance a) <$> st.query.getBalance a+ textResponse $ cs $ show b.received++-- | Handler for @GET \/blockchain\/q\/getsentbyaddress\/:addr@: total sent by an address.+handleBinfoSent :: WebState -> Req -> IO Response+handleBinfoSent st req =+ withMetrics st (.binfoQgetsentbyaddress) $ do+ a <- getAddress st =<< capture req "addr"+ b <- fromMaybe (zeroBalance a) <$> st.query.getBalance a+ textResponse $ cs $ show $ b.received - b.confirmed - b.unconfirmed++-- | Handler for @GET \/blockchain\/q\/addressbalance\/:addr@: balance of an address.+handleBinfoAddrBalance :: WebState -> Req -> IO Response+handleBinfoAddrBalance st req =+ withMetrics st (.binfoQaddressbalance) $ do+ a <- getAddress st =<< capture req "addr"+ b <- fromMaybe (zeroBalance a) <$> st.query.getBalance a+ textResponse $ cs $ show $ b.confirmed + b.unconfirmed++-- | Handler for @GET \/blockchain\/q\/addressfirstseen\/:addr@: time of the first+-- block with a transaction of an address.+handleFirstSeen :: WebState -> Req -> IO Response+handleFirstSeen st req =+ withMetrics st (.binfoQaddressfirstseen) $ do+ a <- getAddress st =<< capture req "addr"+ let ch = st.config.store.chain+ bb <- chainGetBest ch+ let top = bb.height+ bot = 0+ i <- go ch bb a bot top+ textResponse $ cs $ show i+ where+ go ch bb a bot top = do+ let mid = bot + (top - bot) `div` 2+ n = top - bot < 2+ x <- hasone a bot+ y <- hasone a mid+ z <- hasone a top+ if+ | x -> getblocktime ch bb bot+ | n -> getblocktime ch bb top+ | y -> go ch bb a bot mid+ | z -> go ch bb a mid top+ | otherwise -> return 0+ getblocktime ch bb h =+ (chainGetAncestor ch h bb) >>= \case+ Just b -> return b.header.timestamp+ Nothing -> do+ let logger = st.config.logger+ logError logger $+ "Could not get ancestor at height "+ <> cs (show h)+ <> " for block "+ <> H.blockHashToHex (H.headerHash bb.header)+ error "Block ancestor retreival error"+ hasone a h = do+ let l = Limits 1 0 (Just (AtBlock h))+ not . null <$> st.query.getAddressTxs a l++-- | Handler for @GET\/POST \/blockchain\/balance@: short balances of addresses and xpubs, as Blockchain.info.+handleShortBal :: WebState -> Req -> IO Response+handleShortBal st req =+ withMetrics st (.binfoBalance) $ do+ (xspecs, addrs) <- getBinfoActive st req+ cashaddr <- getCashAddr req+ let net = st.config.store.net+ abals <- catMaybes <$> mapM (getabal net cashaddr) (HashSet.toList addrs)+ xbals <- mapM (getxbal net) (HashSet.toList xspecs)+ let res = HashMap.fromList (abals <> xbals)+ streamEncoding $ toEncoding res+ where+ shorten bal =+ BinfoShortBal+ { final = bal.confirmed + bal.unconfirmed,+ ntx = bal.txs,+ received = bal.received+ }+ getabal net cashaddr a =+ let net' =+ if+ | cashaddr -> net+ | net == bch -> btc+ | net == bchTest -> btcTest+ | net == bchTest4 -> btcTest+ | otherwise -> net+ in case addrToText net' a of+ Nothing -> return Nothing+ Just a' ->+ st.query.getBalance a >>= \case+ Nothing -> return $ Just (a', shorten (zeroBalance a))+ Just b -> return $ Just (a', shorten b)+ getxbal net xpub = do+ xbals <- st.query.xPubBals xpub+ ntx <- fromIntegral <$> st.query.xPubTxCount xpub xbals+ let val = sum $ map (.balance.confirmed) xbals+ zro = sum $ map (.balance.unconfirmed) xbals+ exs = [x | x@XPubBal {path = 0 : _} <- xbals]+ final = val + zro+ received = sum $ map (.balance.received) exs+ sbl = BinfoShortBal {final, ntx, received}+ let ctx = st.config.store.ctx+ return (xPubExport net ctx xpub.key, sbl)++-- | Whether to respond with hex (@format=hex@).+getBinfoHex :: Req -> IO Bool+getBinfoHex req = paramOr req format "format" False+ where+ format "hex" = Just True+ format "json" = Just False+ format _ = Nothing++-- | Handler for @GET \/blockchain\/block-height\/:height@: the blocks at a height, as Blockchain.info.+handleBinfoBlockHeight :: WebState -> Req -> IO Response+handleBinfoBlockHeight st req =+ withMetrics st (.binfoBlockHeight) $ do+ numtxid <- getNumTxId req+ height <- captureWith req readNum "height"+ bs <- fmap catMaybes $ st.query.getBlocksAtHeight height >>= mapM (st.query.getBlock)+ ns <- fmap catMaybes $ st.query.getBlocksAtHeight (height + 1) >>= mapM (st.query.getBlock)+ is <- mapM (get_binfo_blocks numtxid ns) bs+ let net = st.config.store.net+ let ctx = st.config.store.ctx+ streamEncoding $ marshalEncoding (net, ctx) is+ where+ get_binfo_blocks numtxid ns b = do+ txs <- catMaybes <$> C.getTransactions st.query b.txs+ let h x = H.headerHash x.header+ nbs = [h n | n <- ns, n.header.prev == h b]+ bts = map (toBinfoTxSimple numtxid) txs+ return $ toBinfoBlock b bts nbs++-- | Handler for @GET \/blockchain\/latestblock@: the best block, as Blockchain.info.+handleBinfoLatest :: WebState -> Req -> IO Response+handleBinfoLatest st req =+ withMetrics st (.binfoBlockLatest) $ do+ numtxid <- getNumTxId req+ b <- (bestBlock st.query) >>= maybe (raise st ThingNotFound) return+ streamEncoding $+ toEncoding+ BinfoHeader+ { hash = H.headerHash b.header,+ timestamp = b.header.timestamp,+ index = b.height,+ height = b.height,+ txids = map (encodeBinfoTxId numtxid) b.txs+ }++-- | Handler for @GET \/blockchain\/rawblock\/:block@: a block with its transactions, as Blockchain.info.+handleBinfoBlock :: WebState -> Req -> IO Response+handleBinfoBlock st req =+ withMetrics st (.binfoBlockRaw) $ do+ numtxid <- getNumTxId req+ hex <- getBinfoHex req+ captureWith req parseBinfoBlockId "block" >>= \case+ BinfoBlockHash bh -> go numtxid hex bh+ BinfoBlockIndex i ->+ st.query.getBlocksAtHeight i >>= \case+ [] -> raise st ThingNotFound+ bh : _ -> go numtxid hex bh+ where+ go numtxid hex bh = do+ b <- maybe (raise st ThingNotFound) return =<< st.query.getBlock bh+ txs <- catMaybes <$> C.getTransactions st.query b.txs+ nhs <- fmap catMaybes $ st.query.getBlocksAtHeight (b.height + 1) >>= mapM (st.query.getBlock)+ let h x = H.headerHash x.header+ nxt = [h n | n <- nhs, n.header.prev == h b]+ if hex+ then do+ let x = H.Block b.header (map transactionData txs)+ textResponse . encodeHexLazy . runPutL $ serialize x+ else do+ let bts = map (toBinfoTxSimple numtxid) txs+ y = toBinfoBlock b bts nxt+ let net = st.config.store.net+ let ctx = st.config.store.ctx+ streamEncoding $ marshalEncoding (net, ctx) y++-- | Transaction by hash or short number. Fails with 'TxIndexConflict' if a short number matches several.+getBinfoTx ::+ WebState ->+ BinfoTxId ->+ IO (Either Except Transaction)+getBinfoTx st txid = do+ tx <- case txid of+ BinfoTxIdHash h -> maybeToList <$> C.getTransaction st.query h+ BinfoTxIdIndex i -> C.getNumTransaction st.query i+ case tx of+ [t] -> return $ Right t+ [] -> return $ Left ThingNotFound+ ts ->+ let tids = map (txHash . transactionData) ts+ in return $ Left (TxIndexConflict tids)++-- | Handler for @GET \/blockchain\/rawtx\/:txid@: a transaction, as Blockchain.info.+handleBinfoTx :: WebState -> Req -> IO Response+handleBinfoTx st req =+ withMetrics st (.binfoTxRaw) $ do+ numtxid <- getNumTxId req+ hex <- getBinfoHex req+ txid <- captureWith req parseBinfoTxId "txid"+ tx <-+ getBinfoTx st txid >>= \case+ Right t -> return t+ Left e -> raise st e+ if hex then hx tx else js numtxid tx+ where+ js numtxid t = do+ let net = st.config.store.net+ let ctx = st.config.store.ctx+ streamEncoding $ marshalEncoding (net, ctx) $ toBinfoTxSimple numtxid t+ hx t = do+ textResponse . encodeHexLazy . runPutL . serialize $ transactionData t++-- | Handler for @GET \/blockchain\/q\/txtotalbtcoutput\/:txid@: total value of the outputs of a transaction.+handleBinfoTotalOut :: WebState -> Req -> IO Response+handleBinfoTotalOut st req =+ withMetrics st (.binfoQtxtotalbtcoutput) $ do+ txid <- captureWith req parseBinfoTxId "txid"+ tx <-+ getBinfoTx st txid >>= \case+ Right t -> return t+ Left e -> raise st e+ textResponse $ cs $ show $ sum $ map (.value) tx.outputs++-- | Handler for @GET \/blockchain\/q\/txfee\/:txid@: fee of a transaction.+handleBinfoTxFees :: WebState -> Req -> IO Response+handleBinfoTxFees st req =+ withMetrics st (.binfoQtxfee) $ do+ txid <- captureWith req parseBinfoTxId "txid"+ tx <-+ getBinfoTx st txid >>= \case+ Right t -> return t+ Left e -> raise st e+ let i = sum $ map (.value) $ filter f tx.inputs+ o = sum $ map (.value) tx.outputs+ textResponse . cs . show $ i - o+ where+ f StoreInput {} = True+ f StoreCoinbase {} = False++-- | Handler for @GET \/blockchain\/q\/txresult\/:txid\/:addr@: net amount that a transaction sends to an address.+handleBinfoTxResult :: WebState -> Req -> IO Response+handleBinfoTxResult st req =+ withMetrics st (.binfoQtxresult) $ do+ txid <- captureWith req parseBinfoTxId "txid"+ addr <- getAddress st =<< capture req "addr"+ tx <-+ getBinfoTx st txid >>= \case+ Right t -> return t+ Left e -> raise st e+ let i = toInteger $ sum $ map (.value) $ filter (f addr) tx.inputs+ o = toInteger $ sum $ map (.value) $ filter (g addr) tx.outputs+ textResponse $ cs $ show $ o - i+ where+ f addr StoreInput {address = Just a} = a == addr+ f _ _ = False+ g addr StoreOutput {address = Just a} = a == addr+ g _ _ = False++-- | Handler for @GET \/blockchain\/q\/txtotalbtcinput\/:txid@: total value of the inputs of a transaction.+handleBinfoTotalInput :: WebState -> Req -> IO Response+handleBinfoTotalInput st req =+ withMetrics st (.binfoQtxtotalbtcinput) $ do+ txid <- captureWith req parseBinfoTxId "txid"+ tx <-+ getBinfoTx st txid >>= \case+ Right t -> return t+ Left e -> raise st e+ textResponse $ cs $ show $ sum $ map (.value) $ filter f $ tx.inputs+ where+ f StoreInput {} = True+ f StoreCoinbase {} = False++-- | Handler for @GET \/blockchain\/unconfirmed-transactions@: mempool transactions, as Blockchain.info.+handleBinfoMempool :: WebState -> Req -> IO Response+handleBinfoMempool st req =+ withMetrics st (.binfoMempool) $ do+ numtxid <- getNumTxId req+ offset <- getBinfoOffset st req+ n <- getBinfoCount st req "limit"+ -- a limit of 0 would mean no limit to st.query.getMempoolPage+ txids <-+ if n <= 0+ then return []+ else+ map snd+ <$> st.query.getMempoolPage (Limits (fromIntegral n) (fromIntegral offset) Nothing)+ txs <- catMaybes <$> C.getTransactions st.query txids+ let net = st.config.store.net+ let mem = BinfoMempool $ map (toBinfoTxSimple numtxid) txs+ let ctx = st.config.store.ctx+ streamEncoding $ marshalEncoding (net, ctx) mem++-- | Handler for @GET \/blockchain\/q\/getblockcount@: height of the best header.+handleBinfoGetBlockCount :: WebState -> Req -> IO Response+handleBinfoGetBlockCount st _ =+ withMetrics st (.binfoQgetblockcount) $ do+ let ch = st.config.store.chain+ bn <- chainGetBest ch+ textResponse $ TL.pack $ show bn.height++-- | Handler for @GET \/blockchain\/q\/latesthash@: hash of the best header.+handleBinfoLatestHash :: WebState -> Req -> IO Response+handleBinfoLatestHash st _ =+ withMetrics st (.binfoQlatesthash) $ do+ let ch = st.config.store.chain+ bn <- chainGetBest ch+ textResponse $ TL.fromStrict $ H.blockHashToHex $ H.headerHash bn.header++-- | Handler for @GET \/blockchain\/q\/bcperblock@: block subsidy at the next height.+handleBinfoSubsidy :: WebState -> Req -> IO Response+handleBinfoSubsidy st _ =+ withMetrics st (.binfoQbcperblock) $ do+ let ch = st.config.store.chain+ let net = st.config.store.net+ bn <- chainGetBest ch+ textResponse $+ cs $+ show $+ (/ (100 * 1000 * 1000 :: Double)) $+ fromIntegral $+ H.computeSubsidy net (bn.height + 1)++-- | Handler for @GET \/blockchain\/q\/addresstohash\/:addr@: hash160 of an address in hex.+handleBinfoAddrToHash :: WebState -> Req -> IO Response+handleBinfoAddrToHash st req =+ withMetrics st (.binfoQaddresstohash) $ do+ addr <- getAddress st =<< capture req "addr"+ if+ | Just h <- addressHash160 addr ->+ textResponse $ encodeHexLazy $ runPutL $ serialize h+ | Just h <- addressHash256 addr ->+ textResponse $ encodeHexLazy $ runPutL $ serialize h+ | otherwise ->+ textResponse ""++-- | Handler for @GET \/blockchain\/q\/hashtoaddress\/:hash@: pay-to-public-key-hash address of a hash160.+handleBinfoHashToAddr :: WebState -> Req -> IO Response+handleBinfoHashToAddr st req =+ withMetrics st (.binfoQhashtoaddress) $ do+ bs <- maybe next return . decodeHex =<< capture req "hash"+ let net = st.config.store.net+ hash <- either (const next) return $ decode bs+ addr <- maybe next return $ addrToText net $ PubKeyAddress hash+ textResponse $ TL.fromStrict addr++-- | Handler for @GET \/blockchain\/q\/addrpubkey\/:pubkey@: address of a public key.+handleBinfoAddrPubkey :: WebState -> Req -> IO Response+handleBinfoAddrPubkey st req =+ withMetrics st (.binfoQaddrpubkey) $ do+ hex <- capture req "pubkey"+ let ctx = st.config.store.ctx+ pubkey <-+ maybe next (return . pubKeyAddr ctx) $+ eitherToMaybe . unmarshal ctx =<< decodeHex hex+ let net = st.config.store.net+ case addrToText net pubkey of+ Nothing -> raise st ThingNotFound+ Just a -> textResponse $ TL.fromStrict a++-- | Handler for @GET \/blockchain\/q\/pubkeyaddr\/:addr@: public key of an+-- address, found in a transaction input that spends from it.+handleBinfoPubKeyAddr :: WebState -> Req -> IO Response+handleBinfoPubKeyAddr st req =+ withMetrics st (.binfoQpubkeyaddr) $ do+ addr <- getAddress st =<< capture req "addr"+ mi <- strm addr+ i <- case mi of+ Nothing -> raise st ThingNotFound+ Just i -> return i+ pk <- case extr addr i of+ Left e -> raise st $ UserError e+ Right t -> return t+ textResponse $ encodeHexLazy $ L.fromStrict pk+ where+ strm addr = do+ runConduit $ do+ let l = Limits {limit = 8, offset = 0, start = Nothing}+ streamThings (st.query.getAddressTxs addr) (Just (.txid)) l+ .| concatMapMC (C.getTransaction st.query . (.txid))+ .| concatMapC (filter (inp addr) . (.inputs))+ .| headC+ inp addr StoreInput {address = Just a} = a == addr+ inp _ _ = False+ extr addr StoreInput {script, pkscript, witness} = do+ Script sig <- decode script+ Script pks <- decode pkscript+ case addr of+ PubKeyAddress {} ->+ case sig of+ [OP_PUSHDATA _ _, OP_PUSHDATA pub _] ->+ Right pub+ [OP_PUSHDATA _ _] ->+ case pks of+ [OP_PUSHDATA pub _, OP_CHECKSIG] ->+ Right pub+ _ -> Left "Could not parse scriptPubKey"+ _ -> Left "Could not parse scriptSig"+ WitnessPubKeyAddress {} ->+ case witness of+ [_, pub] -> return pub+ _ -> Left "Could not parse scriptPubKey"+ _ -> Left "Address does not have public key"+ extr _ _ = Left "Incorrect input type"++-- | Handler for @GET \/blockchain\/q\/hashpubkey\/:pubkey@: hash160 of a public key in hex.+handleBinfoHashPubkey :: WebState -> Req -> IO Response+handleBinfoHashPubkey st req =+ withMetrics st (.binfoQhashpubkey) $ do+ let ctx = st.config.store.ctx+ pkm <-+ fmap+ (eitherToMaybe . unmarshal ctx <=< decodeHex)+ (capture req "pubkey")+ addr <- case pkm of+ Nothing -> raise st $ UserError "Could not decode public key"+ Just pk -> return $ pubKeyAddr ctx pk+ if+ | Just h <- addressHash160 addr ->+ textResponse $ encodeHexLazy $ runPutL $ serialize h+ | Just h <- addressHash256 addr ->+ textResponse $ encodeHexLazy $ runPutL $ serialize h+ | otherwise ->+ textResponse ""++-- GET Network Information --++-- | Handler for @GET \/peers@: connected peers.+handlePeers ::+ WebState ->+ GetPeers ->+ IO [PeerInfo]+handlePeers st _ =+ withMetrics st (.peers) $ do+ getPeersInformation =<< pure st.config.store.peerMgr++-- | Obtain information about connected peers from peer manager process.+getPeersInformation ::+ PeerMgr -> IO [PeerInfo]+getPeersInformation mgr =+ mapMaybe toInfo <$> (getPeers mgr)+ where+ toInfo op = do+ ver <- op.version+ return+ PeerInfo+ { userAgent = ver.userAgent.get,+ address = show op.address,+ -- Signed, as the peer sent it. See 'PeerInfo'.+ version = ver.version,+ services = ver.services,+ relay = ver.relay+ }++-- | The route of @GET \/health@. Its status says in one number what a load+-- balancer needs to know:+--+-- * 200: every check passes;+--+-- * 203: every check passes, but Redis does not answer, so the store+-- answers from its own database and mempool alone;+--+-- * 503: a check fails.+--+-- = Why 203 for a store without its Redis+--+-- Such a store is not broken: its answers are right for what it has seen. A+-- load balancer should go on sending it requests, which it does for any+-- status in the 200s, so the status cannot be an error. But the answers are+-- no longer those of the fleet: they do not come from the copy that the+-- stores share. That is what 203, Non-Authoritative Information, says of a+-- response, and a monitor that expects exactly 200 will notice it. 207 was+-- the other candidate, for a body with a status for each part; it promises+-- a format that this body does not have. The response also names what is+-- missing in a @Haskoin-Degraded@ header.+--+-- = The check of Redis in the body+--+-- The body of a store that has a cache has one more check than the others,+-- 'Haskoin.Store.Data.redis', which says whether Redis answers. It does not+-- count towards the @ok@ of the whole, which stays the answer to "is this+-- store fit to serve".+pathHealth :: WebState -> Route+pathHealth st =+ route (renderStdMethod (resourceMethod proxy)) (capturePath proxy) ["pretty"] $ \req -> do+ proto <- serialAs req False+ h <- handleHealth st GetHealth+ let (status, extra)+ | not (isOK h) = (status503, [("Retry-After", "5")])+ | h.redis == Just False = (status203, [("Haskoin-Degraded", "redis")])+ | otherwise = (status200, [])+ return $+ responseLBS status ((hContentType, serialType proto) : extra) (protoSerial proto toEncoding toJSON h)+ where+ proxy = Proxy :: Proxy GetHealth++-- | Handler for @GET \/health@: health of the store.+handleHealth ::+ WebState ->+ GetHealth ->+ IO HealthCheck+handleHealth st _ =+ withMetrics st (.health) $ do+ h <- readTVarIO st.health+ up <- readTVarIO st.cacheUp+ -- a store without a cache has no Redis to be without+ let HealthCheck {blocks, lastBlock, lastTx, pendingTxs, peers, network, version, time} = h+ let shares = void st.config.store.cache <|> void st.config.store.fleet+ return HealthCheck {redis = up <$ shares, ..}++-- | Heights of the best header and best block, and the maximum lag allowed.+blockHealthCheck :: WebConfig -> IO BlockHealth+blockHealthCheck cfg = do+ let ch = cfg.store.chain+ db = cfg.store.db+ headers <- (.height) <$> chainGetBest ch+ blocks <-+ Q.getBestBlock db >>= \case+ Nothing -> return 0+ Just h -> maybe 0 (.height) <$> Q.getBlock db h+ return+ BlockHealth+ { headers,+ blocks,+ max = fromIntegral cfg.maxLaggingBlocks+ }++-- | Age of the best header and the maximum allowed.+lastBlockHealthCheck :: Chain -> WebLimits -> IO TimeHealth+lastBlockHealthCheck ch WebLimits {blockTimeout} = do+ n <- round <$> getPOSIXTime+ t <- fromIntegral . (.header.timestamp) <$> (chainGetBest ch)+ return+ TimeHealth+ { age = n - t,+ max = fromIntegral blockTimeout+ }++-- | Age of the newest mempool transaction or best header, and the maximum allowed.+lastTxHealthCheck :: WebConfig -> IO TimeHealth+lastTxHealthCheck WebConfig {noMempool, store = store', limits} = do+ n <- round <$> getPOSIXTime+ b <- fromIntegral . (.header.timestamp) <$> (chainGetBest ch)+ t <-+ Q.getMempoolPage store'.db (Limits 1 0 Nothing) >>= \case+ t : _ ->+ let x = fromIntegral $ fst t+ in return $ max x b+ [] -> return b+ return+ TimeHealth+ { age = n - t,+ max = fromIntegral to+ }+ where+ ch = store'.chain+ to =+ if noMempool+ then limits.blockTimeout+ else limits.txTimeout++-- | Transactions waiting to be imported and the maximum allowed.+pendingTxsHealthCheck :: WebConfig -> IO MaxHealth+pendingTxsHealthCheck cfg = do+ n <- blockStorePendingTxs cfg.store.block+ return+ MaxHealth+ { max = fromIntegral cfg.maxPendingTxs,+ count = fromIntegral n+ }++-- | Online peers and the minimum required.+peerHealthCheck :: WebConfig -> IO CountHealth+peerHealthCheck cfg = do+ count <- fromIntegral . length <$> (getPeers cfg.store.peerMgr)+ return CountHealth {min = fromIntegral cfg.minPeers, count}++-- | Run every health check.+healthCheck :: WebConfig -> IO HealthCheck+healthCheck cfg = do+ blocks <- blockHealthCheck cfg+ lastBlock <- lastBlockHealthCheck cfg.store.chain cfg.limits+ lastTx <- lastTxHealthCheck cfg+ pendingTxs <- pendingTxsHealthCheck cfg+ peers <- peerHealthCheck cfg+ time <- round <$> getPOSIXTime+ return+ HealthCheck+ { network = cfg.store.net.name,+ version = cfg.version,+ -- filled in when the check is served, from what the last request+ -- or ping found+ redis = Nothing,+ ..+ }++-- | Handler for @GET \/dbstats@: RocksDB memory use and statistics, as text.+handleDbStats :: WebState -> Req -> IO Response+handleDbStats st _ =+ withMetrics st (.db) $ do+ let db = st.config.store.db.db+ memory <- (memoryReport db)+ statsM <- (getProperty db Stats)+ textResponse . cs $ memory <> maybe "Could not get stats" cs statsM++-----------------------+-- Parameter Parsing --+-----------------------++-- | Returns @Nothing@ if the parameter is not supplied. Raises an exception on+-- parse failure.+paramOptional :: forall a. (Param a) => WebState -> Req -> IO (Maybe a)+paramOptional st req = do+ let net = st.config.store.net+ let ctx = st.config.store.ctx+ param req label >>= \case+ Nothing -> return Nothing -- Parameter was not supplied+ Just t -> Just <$> readParam (parseParam net ctx . T.splitOn ",") label t+ where+ label = proxyLabel (Proxy :: Proxy a)++-- | Raises an exception if the parameter is not supplied+paramRequired :: forall a. (Param a) => WebState -> Req -> IO a+paramRequired st req = maybe (raise st err) return =<< paramOptional st req+ where+ label = T.unpack (proxyLabel (Proxy :: Proxy a))+ err = UserError $ "Missing parameter " <> label++-- | Returns the default value of a parameter if it is not supplied. Raises an+-- exception on parse failure.+paramDef :: (Default a, Param a) => WebState -> Req -> IO a+paramDef st req = fromMaybe def <$> paramOptional st req++-- | Does not raise exceptions. Passes the request to the next route ('next') if the parameter is+-- not supplied or if parsing fails.+paramCapture :: forall a. (Param a) => WebState -> Req -> IO a+paramCapture st req = do+ let net = st.config.store.net+ let ctx = st.config.store.ctx+ p <- capture req label+ maybe next return (parseParam net ctx (T.splitOn "," p))+ where+ label = proxyLabel (Proxy :: Proxy a)++-- | Read the transaction that a request carries in its body.+parseTxBody :: WebState -> Req -> IO Tx+parseTxBody st req = do+ let net = st.config.store.net+ case decodeTxBody net (L.toStrict req.body) of+ Left _ -> raise st $ UserError "Failed to parse request body"+ Right tx -> return tx++-- | Read a transaction from a request body. The body is the transaction in+-- hex or as raw bytes. White space in the hex is ignored.+--+-- The body must hold one transaction and nothing else. Bytes after the+-- transaction are refused, as Bitcoin Core does for a raw transaction.+--+-- The transaction must be in the format that peers of the network read. A+-- network without SegWit, such as Bitcoin Cash, has the format without+-- witness only. Its nodes would not read the SegWit format, so the store+-- does not take it either.+decodeTxBody :: Network -> C.ByteString -> Either String Tx+decodeTxBody net body = fromHex <> fromBytes body+ where+ fromBytes = runGetS $ do+ tx <- getNetworkTx net+ done <- isEmpty+ unless done $ fail "Trailing bytes after transaction"+ return tx+ fromHex =+ case parseBase16 (C.filter (not . isSpace) body) of+ Right b16 -> fromBytes (decodeBase16 b16)+ Left e -> Left (cs e)++-- | Offset in the @offset@ parameter. Responds with an error if above the maximum.+parseOffset :: WebState -> Req -> IO OffsetParam+parseOffset st req = do+ res@(OffsetParam o) <- paramDef st req+ let limits = st.config.limits+ when (limits.maxOffset > 0 && fromIntegral o > limits.maxOffset) $+ raise st . UserError $+ "offset exceeded: " <> show o <> " > " <> show limits.maxOffset+ return res++-- | The best block, if there is one.+bestBlock :: StoreRead -> IO (Maybe BlockData)+bestBlock q = q.getBestBlock >>= maybe (return Nothing) q.getBlock++-- | Turn a start parameter into a position: a transaction, the block of a block+-- hash, a height, or the last block at or before a time.+parseStart ::+ WebState ->+ Maybe StartParam ->+ IO (Maybe Start)+parseStart _ Nothing = return Nothing+parseStart st (Just s) = case s of+ -- a transaction if there is one with that hash, else a block+ StartParamHash {hash = h} ->+ st.query.getTxData (TxHash h) >>= \case+ Just _ -> return (Just (AtTx (TxHash h)))+ Nothing -> fmap (AtBlock . (.height)) <$> st.query.getBlock (H.BlockHash h)+ StartParamHeight {height = h} -> return (Just (AtBlock (fromIntegral h)))+ StartParamTime {time = q} ->+ fmap (AtBlock . (.height)) <$> C.blockAtOrBefore st.query st.config.store.chain q++-- | Limit, offset and start in the parameters of a request.+parseLimits :: WebState -> Req -> IO LimitsParam+parseLimits st req = LimitsParam <$> paramOptional st req <*> parseOffset st req <*> paramOptional st req++-- | Pagination of a query from request parameters, within the limits for full or short items.+paramToLimits ::+ WebState ->+ Bool ->+ LimitsParam ->+ IO Limits+paramToLimits st full (LimitsParam limitM o startM) = do+ let wl = st.config.limits+ Limits (validateLimit wl full limitM) (fromIntegral o) <$> parseStart st startM++-- | Limit of a query: the requested one or the default, but no more than the maximum.+validateLimit :: WebLimits -> Bool -> Maybe LimitParam -> Word32+validateLimit wl full limitM =+ f m $ maybe d (fromIntegral . (.get)) limitM+ where+ m+ | full && wl.maxFullItemCount > 0 = wl.maxFullItemCount+ | otherwise = wl.maxItemCount+ d = wl.defItemCount+ f a 0 = a+ f 0 b = b+ f a b = min a b++---------------+-- Utilities --+---------------++-- | The queries that the web server answers with: those of the database,+-- with the Redis cache, if there is one, in front of the queries about xpubs+-- and of the bodies of transactions. An xpub that the cache does not have is+-- read from the database, and cached for next time if it is large enough.+--+-- When Redis does not answer, every query is answered from the database, as+-- if the store had no cache. The variable says whether Redis answered the+-- last time: a query that fails clears it, so that the next ones do not wait+-- for Redis in turn, and the health check sets it again when Redis is back.+webQuery :: Store -> TVar Bool -> StoreRead -> StoreRead+webQuery s up base = case s.cache of+ Nothing -> base+ Just c ->+ (base :: StoreRead)+ { getTxData = fmap listToMaybe' . txData c . pure,+ getManyTxData = txData c,+ xPubBals = \x ->+ cached (cacheXPubBals c x) >>= \case+ Just bals -> return bals+ Nothing -> do+ bals <- base.xPubBals x+ _ <- cached (Just <$> cacheRequest c x bals)+ return bals,+ xPubUnspents = \x bals l ->+ cached (cacheXPubUnspents c x bals l) >>= maybe (base.xPubUnspents x bals l) return,+ xPubTxs = \x bals l ->+ cached (cacheXPubTxs c x l) >>= maybe (base.xPubTxs x bals l) return,+ xPubTxCount = \x bals ->+ cached (cacheXPubTxCount c x) >>= maybe (base.xPubTxCount x bals) return+ }+ where+ -- ask Redis, unless it is down, and take a failure, or no answer within+ -- two seconds, for "not cached"+ cached :: IO (Maybe a) -> IO (Maybe a)+ cached ask =+ readTVarIO up >>= \case+ False -> return Nothing+ True ->+ (timeout 2000000 ask `catchAny` \_ -> return Nothing) >>= \case+ Just answer -> return answer+ Nothing -> atomically (writeTVar up False) >> return Nothing+ -- bodies from Redis when the cache has them, so that the transactions+ -- of a cached xpub are served in full even before this store has+ -- imported them, and from the database otherwise+ txData _ [] = return []+ txData c ths = do+ found <- cached (Just <$> cacheTxData c ths)+ let fromCache = fromMaybe (map (const Nothing) ths) found+ missing = [h | (h, Nothing) <- zip ths fromCache]+ fromDB <- HashMap.fromList . zip missing <$> base.getManyTxData missing+ -- deleted ones are left out, wherever they come from+ return $+ zipWith+ (\h m -> notDeleted (maybe (fromMaybe Nothing (HashMap.lookup h fromDB)) Just m))+ ths+ fromCache++-- | The state of the web server, answering without the cache if the flag is+-- 'True', as @nocache=true@ asks.+cacheOr :: Bool -> WebState -> WebState+cacheOr False st = st+cacheOr True st = (st :: WebState) {query = st.direct}++-- | The only element of a list of one optional value.+listToMaybe' :: [Maybe a] -> Maybe a+listToMaybe' [x] = x+listToMaybe' _ = Nothing++-- | Middleware that logs every request with its response status: errors at error level, others at debug level.+logIt :: Logger -> Middleware+logIt logger app req respond =+ app req $ \res -> do+ let s = responseStatus res+ msg = fmtReq req <> ": " <> fmtStatus s+ if statusIsSuccessful s+ then logDebug logger msg+ else logError logger msg+ respond res++-- | Describe a request for logs.+fmtReq :: Request -> Text+fmtReq req =+ let m = requestMethod req+ v = httpVersion req+ p = rawPathInfo req+ q = rawQueryString req+ in T.decodeUtf8 (m <> " " <> p <> q <> " " <> cs (show v))++-- | Describe a response status for logs.+fmtStatus :: Status -> Text+fmtStatus s = cs (show (statusCode s)) <> " " <> cs (statusMessage s)++-- | A parameter read with a parser, or the default if there is none. Answers+-- with an error if it does not parse ('readParam').+paramOr :: Req -> (Text -> Maybe a) -> Text -> a -> IO a+paramOr req f name d = param req name >>= maybe (return d) (readParam f name)++-- | A parameter read with a parser. Answers with an error if there is none or+-- it does not parse ('readParam').+requireParam :: WebState -> Req -> (Text -> Maybe a) -> Text -> IO a+requireParam st req f name =+ param req name >>= maybe (raise st (UserError ("Missing parameter " <> T.unpack name))) (readParam f name)++-- | The value of a parameter, read with a parser. Answers with an error that+-- names the parameter and the value if it does not parse: a value that the+-- server does not understand is a mistake of the client, and answering as if+-- it had not been given would hide the mistake.+readParam :: (Text -> Maybe a) -> Text -> Text -> IO a+readParam f name t =+ maybe (throwIO (invalidParam name t)) return (f t)++-- | The error for a value of a parameter that does not parse.+invalidParam :: Text -> Text -> Except+invalidParam name t =+ UserError . T.unpack $ "Invalid value for parameter " <> name <> ": " <> t++-- | A count, offset or number of confirmations: a whole number, not negative.+readNat :: Text -> Maybe Natural+readNat t = readMaybe (T.unpack t) >>= \(n :: Integer) -> if n < 0 then Nothing else Just (fromInteger n)++-- | @true@ or @false@, in any case.+readBool :: Text -> Maybe Bool+readBool t = case T.toLower t of+ "true" -> Just True+ "false" -> Just False+ _ -> Nothing++-- | A number in decimal.+readNum :: (Read a) => Text -> Maybe a+readNum = readMaybe . T.unpack
@@ -0,0 +1,88 @@+{-# LANGUAGE TemplateHaskell #-}++-- |+-- Module : Haskoin.Store.Web.Docs+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- The reference of the API, served by the server that it describes: the+-- page at the root of the server, and the description that the page reads.+--+-- = Why the server carries its own reference+--+-- @swagger.yaml@ is the reference of the API. A copy on a web site+-- describes whatever version that site was last given, and someone who+-- runs a server wants the reference of the server in front of them. So the+-- file is compiled into the program ('swaggerYaml'), and the root of the+-- server answers with a page that shows it ('swaggerPage'), with the+-- server itself first in its list of servers, so that requests tried from+-- the page go to it.+--+-- = What the page needs+--+-- The page is Swagger UI, loaded from a public CDN at a fixed version, as+-- the page of api.haskoin.com does. A browser without access to the+-- Internet shows an empty page; @\/swagger.yaml@ is served either way. The+-- program is not made a megabyte and a half larger to carry a copy of it.+module Haskoin.Store.Web.Docs+ ( swaggerYaml,+ swaggerPage,+ )+where++import Data.ByteString (ByteString)+import Data.ByteString qualified as B+import Data.FileEmbed (embedFile, makeRelativeToProject)++-- | @swagger.yaml@ as it was when the program was built, with this server+-- at the head of its list of servers: a relative address, which a reader of+-- the description resolves against where it found the description.+swaggerYaml :: ByteString+swaggerYaml = case B.breakSubstring marker file of+ (before, rest)+ | B.null rest -> file+ | otherwise -> before <> marker <> " - url: './'\n description: This server\n" <> B.drop (B.length marker) rest+ where+ marker = "servers:\n"+ file = $(makeRelativeToProject "swagger.yaml" >>= embedFile)++-- | The page at the root of the server: Swagger UI, showing 'swaggerYaml',+-- which it asks this server for, next to wherever the page itself is.+swaggerPage :: ByteString+swaggerPage =+ B.intercalate+ "\n"+ [ "<!DOCTYPE html>",+ "<html lang=\"en\">",+ "<head>",+ " <meta charset=\"UTF-8\">",+ " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",+ " <title>Haskoin Store API</title>",+ " <link rel=\"stylesheet\" href=\"" <> cdn <> "/swagger-ui.css\">",+ "</head>",+ "<body>",+ " <div id=\"swagger-ui\"></div>",+ " <noscript>The reference of this API is at <a href=\"swagger.yaml\">swagger.yaml</a>.</noscript>",+ " <script src=\"" <> cdn <> "/swagger-ui-standalone-preset.js\"></script>",+ " <script src=\"" <> cdn <> "/swagger-ui-bundle.js\" charset=\"UTF-8\"></script>",+ " <script>",+ " window.onload = function () {",+ " // next to this page, whether or not its address ends in a slash",+ " const here = window.location.href.split(/[?#]/)[0].replace(/\\/?$/, '/');",+ " window.ui = SwaggerUIBundle({",+ " url: new URL('swagger.yaml', here).href,",+ " dom_id: '#swagger-ui',",+ " presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],",+ " layout: 'StandaloneLayout'",+ " });",+ " };",+ " </script>",+ "</body>",+ "</html>",+ ""+ ]+ where+ cdn = "https://unpkg.com/swagger-ui-dist@5.17.14"
@@ -0,0 +1,380 @@+{-# LANGUAGE MultiWayIf #-}++-- |+-- Module : Haskoin.Store.Web.Events+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- What the clients of @\/events@ are told: a log of the last events that+-- every client reads at its own pace, and the choice that a client makes of+-- the transactions it wants to hear about.+--+-- = One log instead of a queue for each client+--+-- Every client used to have a queue of its own, filled as events came. A+-- queue knows nothing of what came before it was made, so a client that+-- lost its connection for a second had no way to ask for what it missed,+-- and had to read everything again.+--+-- Here the events are kept once, numbered, in an t'EventLog' of the last+-- 'logLength'. A client is a number, the next event that it has not read+-- ('readEvents'). A client that connects starts at the end+-- ('eventsEnd'), or after an event that it names by its hash+-- ('eventsAfter'), which is how it takes up where it left. A client that+-- is slower than the events come falls off the start of the log and is+-- told so, as it was when its queue filled.+--+-- An event is named by its hash and not by its number, because the number+-- is that of one server since it started. In a fleet every server reads+-- the same stream of events from Redis, from the start of what Redis+-- keeps, so the logs of the servers hold the same events in the same+-- order, and a client may come back to another server than the one it+-- left.+--+-- A transaction can be in the log twice, when it entered the mempool and+-- when it left. A client that names it is taken to have heard the first,+-- and hears everything after it again: an event told twice costs a+-- request, and an event not told is a payment that a wallet does not show.+--+-- = Choosing transactions+--+-- A client of the WebSocket can say which transactions it cares for, with+-- the commands of t'EventCommand': addresses, extended public keys, and a+-- Bloom filter as in BIP 37. What it chose is its t'Watch', and 'relevant'+-- decides for each transaction. So that it can, the log keeps the+-- transaction and its addresses with the event (see t'Seen'), read once when+-- the event comes, whatever the number of clients.+--+-- When the server does not know the transaction of an event, every client+-- is told of it. That happens when a transaction leaves the mempool long+-- after the log forgot that it entered, and it errs on the side of telling+-- too much.+--+-- An extended public key is watched as a wallet uses it: the addresses in+-- use and the gap after them on each of its two chains. When a transaction+-- touches one, the gap moves on ('relevant' returns the new t'Watch'), so+-- the client goes on hearing of payments to addresses it had not been+-- given yet when it subscribed. The same key may be watched under each+-- type of address, with a command for each.+--+-- The Bloom filter is tested as a node tests it for an SPV client+-- ('bloomRelevantUpdate'), except that the server does not add the+-- outputs of a matching transaction to the filter: the filter stays as the+-- client made it, and the client adds what it wants to follow+-- ('EventFilterAdd'). A filter that changed behind the back of the client+-- could not be told apart from its own after a few matches.+module Haskoin.Store.Web.Events+ ( -- * Log+ EventLog,+ Happened (..),+ Entry (..),+ Seen (..),+ seenOf,+ newEventLog,+ appendEvent,+ eventsEnd,+ eventsAfter,+ readEvents,+ logLength,+ logWeight,++ -- * Choice of a client+ Watch (..),+ everything,+ nothing,+ relevant,+ command,+ maxWatchAddresses,+ maxWatchXPubs,+ )+where++import Control.Concurrent.STM+ ( STM,+ TVar,+ atomically,+ modifyTVar',+ newTVarIO,+ readTVar,+ retry,+ stateTVar,+ )+import Data.ByteString qualified as B+import Data.Either (rights)+import Data.HashMap.Strict (HashMap)+import Data.HashMap.Strict qualified as HashMap+import Data.HashSet (HashSet)+import Data.HashSet qualified as HashSet+import Data.IntMap.Strict qualified as IntMap+import Data.Maybe (isJust, mapMaybe)+import Data.Sequence (Seq, ViewL (..), (|>))+import Data.Sequence qualified as Seq+import Data.Word (Word32, Word64)+import Haskoin+ ( Address,+ BlockHash,+ BloomFilter,+ Ctx,+ Network,+ Tx (..),+ TxHash,+ TxOut (..),+ bloomInsert,+ bloomRelevantUpdate,+ pubSubKey,+ scriptToAddressBS,+ )+import Haskoin.Store.Common (deriveFunction)+import Haskoin.Store.Data+ ( Event (..),+ EventCommand (..),+ Prev (..),+ TxData (..),+ XPubBal (..),+ XPubSpec (..),+ nullBalance,+ )++-- | What the server knows of a transaction that an event is about: enough+-- to say whether a client wants to hear of it.+data Seen = Seen+ { -- | the transaction, for the Bloom filter+ tx :: !Tx,+ -- | the addresses that it pays to and spends from+ addresses :: !(HashSet Address),+ -- | roughly the bytes that this takes in memory+ size :: !Int+ }+ deriving (Eq, Show)++-- | A transaction as the log keeps it.+seenOf :: Network -> Ctx -> TxData -> Seen+seenOf net ctx td =+ Seen+ { tx = td.tx,+ addresses = HashSet.fromList (rights (map (scriptToAddressBS net ctx) scripts)),+ size = 256 + sum (map B.length scripts) + sum (map (sum . map B.length) td.tx.witness)+ }+ where+ scripts = map (.script) (IntMap.elems td.prevs) <> map (.script) td.tx.outputs++-- | Something that the clients are told.+data Happened+ = -- | the best block changed to this one+ BlockArrived !BlockHash+ | -- | a transaction entered the mempool, with what is known of it+ TxEntered !TxHash !(Maybe Seen)+ | -- | a transaction left the mempool unconfirmed+ TxLeft !TxHash+ deriving (Eq, Show)++-- | An event in the log.+data Entry = Entry+ { -- | its place: one more than that of the event before+ number :: !Word64,+ -- | what the client is sent+ event :: !Event,+ -- | the transaction, when the event is about one that the server knows+ seen :: !(Maybe Seen),+ -- | the bytes that this entry is counted for in the log+ weight :: !Int+ }+ deriving (Eq, Show)++-- | The last events, oldest first.+data Log = Log+ { entries :: !(Seq Entry),+ -- | the number of the next event+ next :: !Word64,+ -- | the sum of the weights of the entries+ held :: !Int,+ -- | the transactions of the entries that say that one entered, to+ -- know them again when they leave+ bodies :: !(HashMap TxHash Seen)+ }++-- | The log of events that the clients of a server read.+newtype EventLog = EventLog (TVar Log)++-- | The most events that the log keeps: how far back a client can take up+-- from, and how far behind it may fall.+logLength :: Int+logLength = 10000++-- | The most bytes of transactions that the log keeps. Events of large+-- transactions make the log shorter instead of making the server larger.+logWeight :: Int+logWeight = 64 * 1024 * 1024++-- | An empty log.+newEventLog :: IO EventLog+newEventLog = EventLog <$> newTVarIO Log {entries = Seq.empty, next = 0, held = 0, bodies = HashMap.empty}++-- | Add an event at the end of the log, and drop from the start what the+-- log has no room for.+appendEvent :: EventLog -> Happened -> STM ()+appendEvent (EventLog var) what = modifyTVar' var $ \l ->+ let entry = case what of+ BlockArrived h -> Entry {number = l.next, event = EventBlock h, seen = Nothing, weight = 0}+ TxEntered h s -> Entry {number = l.next, event = EventTx h, seen = s, weight = maybe 0 (.size) s}+ TxLeft h -> Entry {number = l.next, event = EventTx h, seen = HashMap.lookup h l.bodies, weight = 0}+ bodies = case what of+ TxEntered h (Just s) -> HashMap.insert h s l.bodies+ _ -> l.bodies+ in trim Log {entries = l.entries |> entry, next = l.next + 1, held = l.held + entry.weight, bodies}+ where+ trim l = case Seq.viewl l.entries of+ e :< rest+ | Seq.length l.entries > logLength || l.held > logWeight ->+ trim Log {entries = rest, next = l.next, held = l.held - e.weight, bodies = forget e l.bodies}+ _ -> l+ forget e = case e.event of+ EventTx h | e.weight > 0 -> HashMap.delete h+ _ -> id++-- | Where a client that wants only what comes from now on starts reading.+eventsEnd :: EventLog -> STM Word64+eventsEnd (EventLog var) = (.next) <$> readTVar var++-- | Where a client that has heard of an event starts reading, given the+-- hash of the block or of the transaction: after the first event about+-- it. 'Nothing' when the log has no such event, because it never had or+-- because it is too long ago; the client then has to read what it follows+-- again and start from the end.+eventsAfter :: EventLog -> Maybe BlockHash -> Maybe TxHash -> STM (Maybe Word64)+eventsAfter (EventLog var) block tx = do+ l <- readTVar var+ let is e = case e.event of+ EventBlock h -> Just h == block+ EventTx h -> Just h == tx+ return $ (\i -> (Seq.index l.entries i).number + 1) <$> Seq.findIndexL is l.entries++-- | The events from a number on, waiting for one when there is none yet.+-- 'Nothing' when the log no longer has that number: the client fell too+-- far behind.+readEvents :: EventLog -> Word64 -> STM (Maybe (Seq Entry))+readEvents (EventLog var) from = do+ l <- readTVar var+ let first = l.next - fromIntegral (Seq.length l.entries)+ if+ | from < first -> return Nothing+ | from >= l.next -> retry+ | otherwise -> return (Just (Seq.drop (fromIntegral (from - first)) l.entries))++-- | What a client of the WebSocket chose to hear about.+data Watch = Watch+ { -- | whether it chose at all; a client that did not hears everything+ filtered :: !Bool,+ -- | the addresses that it named+ plain :: !(HashSet Address),+ -- | the keys that it named, with the number of addresses derived on+ -- the external and on the change chain+ keys :: !(HashMap XPubSpec (Word32, Word32)),+ -- | the addresses derived from the keys: the key, the chain and the+ -- index of each+ derived :: !(HashMap Address (XPubSpec, Word32, Word32)),+ -- | its Bloom filter+ bloom :: !(Maybe BloomFilter)+ }+ deriving (Eq, Show)++-- | The choice of a client that has not chosen: every transaction.+everything :: Watch+everything =+ Watch {filtered = False, plain = HashSet.empty, keys = HashMap.empty, derived = HashMap.empty, bloom = Nothing}++-- | The choice of a client that wants blocks alone, until it names+-- something to watch.+nothing :: Watch+nothing = everything {filtered = True}++-- | The most addresses that a client may name. Those derived from keys do+-- not count.+maxWatchAddresses :: Int+maxWatchAddresses = 100000++-- | The most extended public keys that a client may name. Each is read+-- from the database when it is named, as a request about it is.+maxWatchXPubs :: Int+maxWatchXPubs = 100++-- | Whether a client is told of an event, given what the log knows of its+-- transaction, and its choice afterwards: the gap of a key moves on when+-- the transaction touches one of its addresses. The gap is the number of+-- unused addresses to watch after the last one used.+--+-- A client that has not chosen is told of everything, and every client is+-- told of a block and of a transaction that the server does not know.+relevant :: Ctx -> Word32 -> Maybe Seen -> Watch -> (Bool, Watch)+relevant _ _ Nothing w = (True, w)+relevant ctx gap (Just s) w+ | not w.filtered = (True, w)+ | otherwise = (named || not (null hits) || matched, foldl' moved w hits)+ where+ touched = HashSet.toList s.addresses+ named = any (`HashSet.member` w.plain) touched+ hits = mapMaybe (`HashMap.lookup` w.derived) touched+ matched = maybe False (\f -> isJust (bloomRelevantUpdate ctx f s.tx)) w.bloom+ moved now (spec, chain, index) = extend ctx spec chain (index + 1 + gap) now++-- | Derive the addresses of a watched key on one chain up to a number of+-- them, if there are fewer.+extend :: Ctx -> XPubSpec -> Word32 -> Word32 -> Watch -> Watch+extend ctx spec chain upto w = case HashMap.lookup spec w.keys of+ Just (external, change)+ | upto > have ->+ w+ { keys = HashMap.insert spec (if chain == 0 then (upto, change) else (external, upto)) w.keys,+ derived = foldl' (\m i -> HashMap.insert (derive i) (spec, chain, i) m) w.derived [have .. upto - 1]+ }+ where+ have = if chain == 0 then external else change+ derive = deriveFunction ctx spec.deriv (pubSubKey ctx spec.key chain)+ _ -> w++-- | Do what a client asked with a command, and say why not when it cannot+-- be done, in which case the connection is to be closed. The function+-- reads the balances of an extended public key from the database.+command :: Ctx -> Word32 -> (XPubSpec -> IO [XPubBal]) -> TVar Watch -> EventCommand -> IO (Either String ())+command ctx gap balances var = \case+ EventWatch as -> change $ \w ->+ let plain = foldl' (flip HashSet.insert) w.plain as+ in if HashSet.size plain > maxWatchAddresses+ then Left "Too many addresses"+ else Right (chosen w) {plain}+ EventUnwatch as -> change $ \w -> Right (chosen w) {plain = foldl' (flip HashSet.delete) w.plain as}+ EventWatchXPub spec -> do+ -- read before the choice changes: nothing is held while the database+ -- is read+ used <- balances spec+ let upto chain = gap + maximum (0 : [i + 1 | XPubBal {path = [c, i], balance = b} <- used, c == chain, inUse b])+ change $ \w ->+ if+ | HashMap.member spec w.keys -> Right (chosen w)+ | HashMap.size w.keys >= maxWatchXPubs -> Left "Too many extended public keys"+ | otherwise ->+ let named = (chosen w) {keys = HashMap.insert spec (0, 0) w.keys}+ in Right (extend ctx spec 1 (upto 1) (extend ctx spec 0 (upto 0) named))+ EventUnwatchXPub spec -> change $ \w ->+ Right+ (chosen w)+ { keys = HashMap.delete spec w.keys,+ derived = HashMap.filter (\(s, _, _) -> s /= spec) w.derived+ }+ EventFilterLoad f -> change $ \w -> Right (chosen w) {bloom = Just f}+ EventFilterAdd d -> change $ \w -> case w.bloom of+ Nothing -> Left "No filter to add to"+ Just f -> Right w {bloom = Just (bloomInsert f d)}+ EventFilterClear -> change $ \w -> Right (chosen w) {bloom = Nothing}+ EventAll -> change (const (Right everything))+ EventNone -> change (const (Right nothing))+ where+ chosen w = w {filtered = True}+ inUse b = not (nullBalance b)+ change :: (Watch -> Either String Watch) -> IO (Either String ())+ change f = atomically . stateTVar var $ \w -> case f w of+ Left why -> (Left why, w)+ Right new -> (Right (), new)
@@ -0,0 +1,231 @@+-- |+-- Module : Haskoin.Store.Web.Router+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- Routing of requests to handlers, directly over WAI.+--+-- A 'Route' is a method, a path pattern such as @\/block\/:block@, the names+-- of the parameters it accepts in the query string or a form, and a handler:+-- a function from the request to a response in 'IO'. 'router' turns a list of+-- them into a WAI 'Application' that answers with the first route whose+-- method and path match. A handler that finds the request is not for it after+-- all, such as one whose path parameter does not parse as the kind of value+-- it serves, calls 'next', and the request goes to the next route that+-- matches.+--+-- = Parameters are declared+--+-- A request with a parameter that its route does not accept is refused+-- ('Unknown'), rather than answered as if the parameter were not there. A+-- misspelt parameter, such as @limt=10@, or one that an endpoint does not+-- support, would otherwise be ignored, and the client would get an answer to+-- a question it did not ask, with nothing to tell it so. The check runs+-- before the handler, so a refused request has no effect, which matters for+-- a handler that does something, such as broadcasting a transaction.+--+-- The declaration and the handler cannot drift apart: reading a parameter+-- that the route does not declare ('param') throws 'Undeclared', an error of+-- the program that the tests of each endpoint find, rather than a parameter+-- that is accepted and never read, or read and never accepted.+--+-- = Why not a web framework+--+-- The server needs routing, parameters and responses; a framework like+-- Scotty adds them in a monad transformer over 'IO' that every handler has to+-- run in, with its own exceptions for control flow and its own way to return+-- a response. Here a handler is a plain function: it gets everything about+-- the request in a 'Req', returns a WAI 'Response', and stops early by+-- throwing, which 'router' and the server catch. The whole router is this+-- module.+module Haskoin.Store.Web.Router+ ( -- * Routes+ Route (..),+ Miss (..),+ Undeclared (..),+ route,+ get,+ post,+ router,+ routerScoped,++ -- * Requests+ Req (..),+ Next (..),+ next,+ capture,+ captureWith,+ param,+ header,+ )+where++import Control.Applicative ((<|>))+import Control.Exception (Exception, catch, throwIO)+import Data.ByteString.Lazy qualified as L+import Data.CaseInsensitive qualified as CI+import Data.List (nub)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding (decodeUtf8Lenient, encodeUtf8)+import Network.HTTP.Types (Method, methodGet, methodPost, parseQueryText)+import Network.Wai++-- | A request being answered.+data Req = Req+ { -- | the request of WAI+ wai :: !Request,+ -- | the parameters captured from the path, by name+ captures :: ![(Text, Text)],+ -- | the parameters of the query string, then those of a form in the+ -- body; the first one of a name is the one that counts+ params :: ![(Text, Text)],+ -- | the body, read whole before the handler runs+ body :: !L.ByteString,+ -- | the names of the parameters that the route accepts+ accepted :: ![Text]+ }++-- | Where a request should go.+data Route = Route+ { method :: !Method,+ -- | the segments of the path: literals, and captures by name+ pattern :: ![Either Text Text],+ -- | the names of the parameters it accepts in the query string or a form+ accepts :: ![Text],+ handler :: Req -> IO Response+ }++-- | A route for a method, a path such as @\/block\/:block@, where a segment+-- that starts with a colon captures what is there under its name, and the+-- names of the parameters it accepts.+route :: Method -> Text -> [Text] -> (Req -> IO Response) -> Route+route m path = Route m [segment t | t <- T.splitOn "/" path, not (T.null t)]+ where+ segment t = maybe (Left t) Right (T.stripPrefix ":" t)++-- | A route for @GET@.+get :: Text -> [Text] -> (Req -> IO Response) -> Route+get = route methodGet++-- | A route for @POST@.+post :: Text -> [Text] -> (Req -> IO Response) -> Route+post = route methodPost++-- | Why no route answered a request.+data Miss+ = -- | no route has its method and path+ NoRoute+ | -- | the routes with its method and path do not accept these parameters+ Unknown ![Text]+ deriving stock (Show)++-- | What 'param' throws for a parameter that the route does not declare: a+-- mistake in the program, not in the request.+newtype Undeclared = Undeclared Text+ deriving stock (Show)++instance Exception Undeclared++-- | What a handler throws to pass the request to the next route that+-- matches.+data Next = Next+ deriving stock (Show)++instance Exception Next++-- | Pass the request to the next route that matches.+next :: IO a+next = throwIO Next++-- | An application that answers with the first route whose method and path+-- match the request, that accepts all its parameters, and whose handler does+-- not pass it on; or with the fallback, told why none did. The body of the+-- request is read whole first, and parsed as a form if it is one; handlers+-- that stream a response still stream it.+router :: [Route] -> (Req -> Miss -> IO Response) -> Application+router routes = routerScoped routes ($ routes)++-- | 'router' for handlers that need something that lasts as long as one+-- request does, such as a view of a database that must be the same for+-- everything the request reads and must be let go when it is answered.+--+-- The routes are given twice. The first list is used for its methods, paths+-- and parameters only, to find the route of a request. The second argument+-- runs an action with the same routes, in the same order, made for the one+-- request: the handler that answers is taken from those. The response is+-- sent inside that action, so that a handler that streams its response can+-- go on reading while it is sent.+--+-- The alternative, to make the routes once and have each handler take what+-- it needs for itself, would put that in every handler; and making all the+-- routes again for each request, to match against, would cost more than+-- many requests do.+routerScoped ::+ [Route] ->+ (([Route] -> IO (Maybe ResponseReceived)) -> IO (Maybe ResponseReceived)) ->+ (Req -> Miss -> IO Response) ->+ Application+routerScoped routes scoped fallback request respond = do+ body <- strictRequestBody request+ let form+ | lookup "Content-Type" (requestHeaders request)+ == Just "application/x-www-form-urlencoded" =+ parseQueryText (L.toStrict body)+ | otherwise = []+ params = [(k, maybe "" id v) | (k, v) <- queryToText (queryString request) <> form]+ req cs as = Req {wai = request, captures = cs, params = params, body = body, accepted = as}+ path = filter (not . T.null) (pathInfo request)+ -- the parameters that the first route matching the path refused, if any+ go unknown [] = fallback (req [] []) (maybe NoRoute Unknown unknown) >>= respond+ go unknown ((i, r) : rs)+ | r.method == requestMethod request,+ Just cs <- match r.pattern path =+ case nub [k | (k, _) <- params, k `notElem` r.accepts] of+ [] -> scoped (answer i (req cs r.accepts)) >>= maybe (go unknown rs) return+ ks -> go (unknown <|> Just ks) rs+ | otherwise = go unknown rs+ go Nothing (zip [0 :: Int ..] routes)+ where+ -- the response of the handler of a route, sent, or nothing if the+ -- handler passed the request on+ answer i r scopedRoutes =+ ((Just <$> (scopedRoutes !! i).handler r) `catch` \Next -> return Nothing) >>= mapM respond+ queryToText q = [(decodeUtf8Lenient k, decodeUtf8Lenient <$> v) | (k, v) <- q]++-- | The captures of a path, if it matches a pattern.+match :: [Either Text Text] -> [Text] -> Maybe [(Text, Text)]+match [] [] = Just []+match (Left l : ps) (t : ts) | l == t = match ps ts+match (Right n : ps) (t : ts) = ((n, t) :) <$> match ps ts+match _ _ = Nothing++-- | A parameter captured from the path. Passes the request on if there is+-- none of that name, which only a wrong pattern would cause.+capture :: Req -> Text -> IO Text+capture r n = maybe next return (lookup n r.captures)++-- | A parameter captured from the path, read with a parser. Passes the+-- request on if it does not parse: the path may be meant for another route,+-- such as an xpub where an address is expected.+captureWith :: Req -> (Text -> Maybe a) -> Text -> IO a+captureWith r f n = capture r n >>= maybe next return . f++-- | A parameter of the query string or of a form, if there is one. Throws+-- 'Undeclared' if the route does not accept a parameter of that name.+--+-- It is an action rather than a pure function so that the error is thrown+-- when the parameter is read, inside the handler, where the server answers+-- it, and not whenever the value happens to be evaluated, which may be while+-- the response is being sent.+param :: Req -> Text -> IO (Maybe Text)+param r n+ | n `elem` r.accepted = return (lookup n r.params)+ | otherwise = throwIO (Undeclared n)++-- | A header of the request, if there is one.+header :: Req -> Text -> Maybe Text+header r n = decodeUtf8Lenient <$> lookup (CI.mk (encodeUtf8 n)) (requestHeaders r.wai)
@@ -0,0 +1,913 @@+{-# LANGUAGE ApplicativeDo #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TemplateHaskell #-}++-- |+-- Module : Main+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- Stress test of the Redis cache of xpubs. Run it with+-- @scripts/stress-cache.sh@, which builds it, runs it and writes a report.+--+-- It starts a regtest chain of Bitcoin Core, Valkey and wrk in Podman+-- containers from the test image ("Haskoin.Store.Integration.Image"), and a+-- haskoin-store server with the cache on. A few wallets, whose keys this+-- program holds, get many coins; then the program makes transactions between+-- them, signed here and sent to the node in batches, and mines a block after+-- each batch. Every transaction changes two cached xpubs, so the cache+-- reloads them again and again while wrk asks the server about them on five+-- endpoints at once. At the end, the cached answers are compared with those+-- of the database.+--+-- Everything it measures goes to files in the output directory, as JSON+-- lines: @rounds.jsonl@ (one line per block), @samples.jsonl@ (one line per+-- second: heights, CPU, memory and the StatsD metrics of the server),+-- @wrk.jsonl@ (one line per run of wrk) and @summary.json@.+-- @scripts/stress-report.py@ turns them into a report.+--+-- = Why it works this way+--+-- * __Transactions are signed here.__ A node wallet makes a few hundred+-- transactions per second at best, and slower as it grows; signing with+-- the keys in memory and sending raw transactions in batches keeps the node+-- busy only with validating them.+-- * __Only confirmed coins are spent.__ A block is mined after each batch, so+-- chains of unconfirmed transactions, which the node limits, never form.+-- * __The server is a separate process__, the release binary, so that its+-- CPU and memory are its own, and it is configured from the command line+-- as in production.+-- * __Containers are removed__ when the program ends, even when it fails:+-- they run with @--rm@ inside brackets, and carry a label with the process+-- ID, so that @scripts/stress-cache.sh@ and the next run remove any that a+-- killed run left.+module Main (main) where++import Control.Concurrent.STM (TVar, atomically, newTVarIO, readTVarIO, writeTVar)+import Control.DeepSeq (force)+import Control.Exception (evaluate)+import Control.Lens ((&), (.~), (?~), (^.))+import Control.Monad (forM, forM_, unless, void, when)+import Data.Aeson (Value (..), eitherDecode, encode, object, (.=))+import Data.Aeson qualified as A+import Data.ByteString qualified as BS+import Data.ByteString.Char8 qualified as C8+import Data.ByteString.Lazy qualified as BL+import Data.Bytes.Put (runPutS)+import Data.Bytes.Serial (serialize)+import Data.FileEmbed (embedFile, makeRelativeToProject)+import Data.Foldable (toList)+import Data.HashMap.Strict (HashMap)+import Data.HashMap.Strict qualified as HashMap+import Data.IORef (IORef, atomicModifyIORef', newIORef)+import Data.List (sort)+import Data.Maybe (fromMaybe, mapMaybe)+import Data.Sequence (Seq)+import Data.Sequence qualified as Seq+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding (decodeUtf8)+import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime)+import Data.Word (Word64)+import Database.Redis qualified as Redis+import Haskoin hiding (Coin, Key)+import Haskoin.Store.Integration.Image (testImage, valkeyBin, wrkBin)+import Haskoin.Store.Integration.Node+import Haskoin.Store.Integration.Podman+import Haskoin.Store.Integration.Valkey (redisContainer, waitRedis)+import Network.Socket qualified as Socket+import Network.Socket.ByteString qualified as SocketB+import Network.Wreq qualified as Wreq+import Numeric (readHex)+import Options.Applicative+import System.Exit (ExitCode (..))+import System.IO (Handle, IOMode (..), hFlush, hPutStrLn, stderr, withFile)+import System.Process+import System.Random.Stateful (IOGenM, StdGen, mkStdGen, newIOGenM, uniformRM)+import Text.Read (readMaybe)+import UnliftIO (MVar, bracket, newMVar, tryAny, withAsync, withMVar)+import UnliftIO.Concurrent (threadDelay)+import UnliftIO.Directory (createDirectoryIfMissing, makeAbsolute)++-------------------------------------------------------------------------------+-- Options+-------------------------------------------------------------------------------++data Opts = Opts+ { txs :: !Int,+ wallets :: !Int,+ addresses :: !Int,+ blockTxs :: !Int,+ batch :: !Int,+ connections :: !Int,+ wrkSeconds :: !Int,+ server :: !FilePath,+ serverRts :: !String,+ out :: !FilePath,+ chainMb :: !Int,+ seed :: !Int+ }++opts :: Parser Opts+opts = do+ txs <- num "txs" 200000 "Transactions to make between the wallets"+ wallets <- num "wallets" 4 "Wallets, each an xpub"+ addresses <- num "addresses" 200 "Receiving and change addresses of each wallet"+ blockTxs <- num "block-txs" 2000 "Transactions per block"+ batch <- num "batch" 250 "Transactions per call to the node"+ connections <- num "connections" 2 "Connections of wrk to each endpoint"+ wrkSeconds <- num "wrk-seconds" 10 "Seconds of each run of wrk"+ server <- strOption (long "server" <> metavar "PATH" <> help "haskoin-store binary")+ serverRts <-+ strOption $+ long "server-rts"+ <> metavar "FLAGS"+ <> value "-N4 -M2g"+ <> showDefault+ <> help "RTS flags of the server"+ out <- strOption (long "out" <> metavar "DIR" <> help "Directory for the results")+ chainMb <- num "chain-mb" 1024 "Size of the tmpfs of the chain of the node, in MiB"+ seed <- num "seed" 1 "Seed of the random choices"+ return Opts {..}+ where+ num name def h = option auto (long name <> metavar "N" <> value def <> showDefault <> help h)++-------------------------------------------------------------------------------+-- Main+-------------------------------------------------------------------------------++main :: IO ()+main = do+ o <- execParser (info (opts <**> helper) (fullDesc <> progDesc "Stress test of the Redis cache of haskoin-store"))+ outDir <- makeAbsolute o.out+ createDirectoryIfMissing True outDir+ podmanUsable >>= either fail return+ say "Building or finding the test image"+ image <- testImage+ withContext $ \ctx ->+ withContainer (valkey image) $ \rc -> do+ redisURL <- ("redis://127.0.0.1:" <>) . show <$> hostPort rc 6379+ waitRedis redisURL+ withContainer (nodeSpec o image) $ \nc -> do+ rpcPort <- hostPort nc 18443+ p2p <- hostPort nc 18444+ let node = Node {kind = BitcoinCore, url = "http://127.0.0.1:" <> show rpcPort <> "/", p2p = p2p}+ waitRpc node+ withContainer (wrkSpec image outDir) $ \wc ->+ withStatsd $ \statsPort stats ->+ withRamDir $ \dir ->+ withServer o outDir dir redisURL p2p statsPort $ \srv ->+ stress o {out = outDir} ctx node redisURL srv stats wc++-- | Valkey with room for the bodies of every transaction.+valkey :: String -> ContainerSpec+valkey image =+ let spec = redisContainer image+ in spec+ { options = ["--memory", "1g", "--entrypoint", valkeyBin],+ command = map (\a -> if a == "128mb" then "768mb" else a) spec.command+ }++-- | The regtest node, with a larger chain and mempool than in the tests.+nodeSpec :: Opts -> String -> ContainerSpec+nodeSpec o image =+ let spec = nodeContainer BitcoinCore image+ in spec+ { options = map grow spec.options,+ command = map bigger spec.command <> ["-rpcworkqueue=64", "-rpcthreads=8"]+ }+ where+ grow "1g" = "3g"+ grow a+ | "/chain:" `T.isPrefixOf` T.pack a = "/chain:rw,size=" <> show o.chainMb <> "m,mode=1777"+ | otherwise = a+ bigger "-dbcache=16" = "-dbcache=128"+ bigger "-maxmempool=16" = "-maxmempool=128"+ bigger a = a++-- | A container that runs wrk against the server, on the network of the+-- host, with the output directory, where the scripts of wrk are, mounted.+wrkSpec :: String -> FilePath -> ContainerSpec+wrkSpec image dir =+ ContainerSpec+ { name = "wrk",+ image = image,+ options =+ [ "--network",+ "host",+ "--memory",+ "512m",+ "--security-opt",+ "label=disable",+ "--volume",+ dir <> ":/stress:ro",+ "--entrypoint",+ "sleep"+ ],+ command = ["infinity"],+ ports = []+ }++-------------------------------------------------------------------------------+-- The server+-------------------------------------------------------------------------------++data Server = Server+ { url :: !String,+ pid :: !Int+ }++-- | Run the haskoin-store server while an action runs.+withServer :: Opts -> FilePath -> FilePath -> String -> Int -> Int -> (Server -> IO a) -> IO a+withServer o outDir dir redisURL p2p statsPort act =+ withFile (outDir <> "/server.log") WriteMode $ \logH -> do+ -- another program could take the port before the server binds it, but+ -- the server then fails to start, which is reported+ port <- freePort+ let gap = o.addresses + 20+ args =+ [ "--dir",+ dir,+ "--host",+ "127.0.0.1",+ "--port",+ show port,+ "--net",+ "btcreg",+ "--peer",+ "127.0.0.1:" <> show p2p,+ "--max-peers",+ "1",+ "--redis",+ "--redis-url",+ redisURL,+ "--redis-min-addrs",+ "10",+ "--xpub-gap",+ show gap,+ "--xpub-gap-init",+ show gap,+ "--statsd",+ "--statsd-host",+ "127.0.0.1",+ "--statsd-port",+ show statsPort,+ "--statsd-prefix",+ "stress",+ "--statsd-aggregates",+ "--ticker-url",+ "http://127.0.0.1:9/",+ "--price-history-url",+ "http://127.0.0.1:9/",+ "+RTS"+ ]+ <> words o.serverRts+ <> ["-RTS"]+ cp = (proc o.server args) {std_out = UseHandle logH, std_err = UseHandle logH}+ bracket (createProcess cp) stop $ \(_, _, _, ph) -> do+ pid <- maybe (fail "The server did not start") (return . fromIntegral) =<< getPid ph+ writeFile (outDir <> "/server.pid") (show pid)+ let srv = Server {url = "http://127.0.0.1:" <> show port, pid = pid}+ waitFor 60 "the server to answer" $ do+ getProcessExitCode ph >>= \case+ Just c -> fail ("The server exited with " <> show c <> "; see server.log")+ Nothing -> either (const False) (const True) <$> tryAny (getJSON srv "/health")+ act srv+ where+ stop (_, _, _, ph) = terminateProcess ph >> void (waitForProcess ph)++-- | A free TCP port of the loopback interface.+freePort :: IO Int+freePort =+ bracket (Socket.socket Socket.AF_INET Socket.Stream Socket.defaultProtocol) Socket.close $ \s -> do+ Socket.bind s (Socket.SockAddrInet 0 (Socket.tupleToHostAddress (127, 0, 0, 1)))+ fromIntegral <$> Socket.socketPort s++-- | JSON answer of the server.+getJSON :: Server -> String -> IO Value+getJSON srv path = do+ let o = Wreq.defaults & Wreq.checkResponse ?~ (\_ _ -> return ())+ r <- Wreq.getWith o (srv.url <> path)+ either fail return (eitherDecode (r ^. Wreq.responseBody))++-------------------------------------------------------------------------------+-- Wallets+-------------------------------------------------------------------------------++-- | An address and its key.+data Key = Key+ { script :: !ScriptOutput,+ secret :: !SecKey+ }++-- | A wallet: its xpub, and its receiving and change addresses.+data Wallet = Wallet+ { xpub :: !Text,+ receive :: !(Seq Key),+ change :: !(Seq Key)+ }++-- | A coin that a wallet can spend.+data Coin = Coin+ { outpoint :: !OutPoint,+ value :: !Word64,+ wallet :: !Int,+ key :: !Key+ }++-- | The wallets, derived from a fixed seed, and the key of the miner.+makeWallets :: Ctx -> Opts -> ([Wallet], Key)+makeWallets ctx o = ([wallet (fromIntegral i) | i <- [0 .. o.wallets - 1]], keyOf (hardSubKey ctx root 9999) 0 0)+ where+ root = makeXPrvKey (C8.pack ("haskoin-store-stress-" <> show o.seed))+ wallet i =+ let w = hardSubKey ctx root i+ in Wallet+ { xpub = xPubExport btcRegTest ctx (deriveXPubKey ctx w),+ receive = Seq.fromList [keyOf w 0 j | j <- [0 .. fromIntegral o.addresses - 1]],+ change = Seq.fromList [keyOf w 1 j | j <- [0 .. fromIntegral o.addresses - 1]]+ }+ keyOf w c j =+ let k = prvSubKey ctx (prvSubKey ctx w c) j+ a = xPubAddr ctx (deriveXPubKey ctx k)+ in Key {script = fromMaybe (error "no script") (addressToOutput a), secret = k.key}++-- | Address of a key, as text.+keyAddress :: Key -> Text+keyAddress k = case k.script of+ PayPKHash h -> fromMaybe (error "no address") (addrToText btcRegTest (PubKeyAddress h))+ _ -> error "not a key hash"++-- | Sign a transaction that spends coins.+sign :: Ctx -> Tx -> [Coin] -> Tx+sign ctx tx coins =+ either error id $+ signTx+ btcRegTest+ ctx+ tx+ [SigInput c.key.script c.value c.outpoint sigHashAll Nothing | c <- coins]+ [c.key.secret | c <- coins]++-- | A payment from a coin to another wallet, with change back to the wallet+-- of the coin. Gives the transaction and its new coins.+pay :: Ctx -> IOGenM StdGen -> [Wallet] -> Coin -> IO (Tx, [Coin])+pay ctx g ws c = do+ w' <- (\i -> (c.wallet + i) `mod` length ws) <$> uniformRM (1, max 1 (length ws - 1)) g+ let to = ws !! w'+ from = ws !! c.wallet+ r <- (`Seq.index` 0) . flip Seq.drop to.receive <$> uniformRM (0, Seq.length to.receive - 1) g+ ch <- (`Seq.index` 0) . flip Seq.drop from.change <$> uniformRM (0, Seq.length from.change - 1) g+ amount <- uniformRM (10000, max 10000 (c.value `div` 2)) g+ -- every output is worth at least 10000, so a coin always pays the fee+ let fee = 1000+ outs+ | c.value >= amount + fee + 10000 = [(r.script, amount), (ch.script, c.value - amount - fee)]+ | otherwise = [(r.script, c.value - fee)]+ tx = sign ctx (buildTx ctx [c.outpoint] outs) [c]+ h = txHash tx+ owners = [(w', r), (c.wallet, ch)]+ coins = [Coin (OutPoint h i) v w k | (i, (_, v), (w, k)) <- zip3 [0 ..] outs owners]+ return (tx, coins)++-------------------------------------------------------------------------------+-- The node+-------------------------------------------------------------------------------++-- | Send transactions to the node in one call. Gives the error of each+-- transaction that the node refused.+sendRaw :: Node -> [Tx] -> IO [Maybe Value]+sendRaw node txs = do+ let o =+ Wreq.defaults+ & Wreq.auth ?~ Wreq.basicAuth "test" "test"+ & Wreq.checkResponse ?~ (\_ _ -> return ())+ & Wreq.header "Content-Type" .~ ["application/json"]+ body =+ A.toJSON+ [ object+ [ "jsonrpc" .= ("1.0" :: Text),+ "id" .= i,+ "method" .= ("sendrawtransaction" :: Text),+ "params" .= [encodeHex (runPutS (serialize tx))]+ ]+ | (i, tx) <- zip [0 :: Int ..] txs+ ]+ r <- Wreq.postWith o node.url body+ case eitherDecode (r ^. Wreq.responseBody) of+ Right (Array xs) ->+ return+ [ case field "error" x of+ Just Null -> Nothing+ Nothing -> Nothing+ e -> e+ | x <- toList xs+ ]+ Right v -> fail ("Unexpected answer to a batch: " <> take 400 (show v))+ Left e -> fail ("Bad answer to a batch: " <> e)++-- | The coinbase output of a block.+coinbaseOf :: Node -> Text -> IO (OutPoint, Word64)+coinbaseOf node h = do+ b <- rpc node "getblock" [String h, Number 2]+ case field "tx" b of+ Just (Array ts) | Just t <- Seq.lookup 0 (Seq.fromList (toList ts)) -> do+ txid <- maybe (fail "no txid") return (textField "txid" t >>= hexToTxHash)+ v <- case field "vout" t of+ Just (Array os) | o : _ <- toList os, Just v <- field "value" o -> return (sats v)+ _ -> fail "no coinbase output"+ return (OutPoint txid 0, v)+ _ -> fail ("No transactions in block " <> T.unpack h)++-- | Height of the best block of the node.+nodeHeight :: Node -> IO Int+nodeHeight node =+ rpc node "getblockcount" [] >>= \case+ Number n -> return (round n)+ v -> fail ("Unexpected block count: " <> show v)++-------------------------------------------------------------------------------+-- The stress test+-------------------------------------------------------------------------------++stress :: Opts -> Ctx -> Node -> String -> Server -> IORef Stats -> Container -> IO ()+stress o ctx node redisURL srv stats wc = do+ g <- newIOGenM (mkStdGen o.seed)+ let (ws, miner) = makeWallets ctx o+ minerAddr = keyAddress miner+ perFanOut = 1000+ fanOuts = max 1 ((o.blockTxs * 3 `div` 2 + perFanOut - 1) `div` perFanOut)+ writeJSON (o.out <> "/config.json") $+ object+ [ "txs" .= o.txs,+ "wallets" .= o.wallets,+ "addresses" .= o.addresses,+ "block_txs" .= o.blockTxs,+ "batch" .= o.batch,+ "connections" .= o.connections,+ "wrk_seconds" .= o.wrkSeconds,+ "server_rts" .= o.serverRts,+ "xpubs" .= map (.xpub) ws+ ]+ say ("Funding " <> show (length ws) <> " wallets with " <> show (fanOuts * perFanOut) <> " coins")+ funded <- generateTo node fanOuts minerAddr+ void $ generateTo node 100 minerAddr+ coins0 <- fmap concat . forM funded $ \h -> do+ (op, v) <- coinbaseOf node h+ let n = perFanOut+ each = (v - 200000) `div` fromIntegral n+ -- every receiving address of every wallet, in turn+ targets = take n (cycle [(wi, Seq.index w.receive j) | j <- [0 .. o.addresses - 1], (wi, w) <- zip [0 ..] ws])+ tx = sign ctx (buildTx ctx [op] [(k.script, each) | (_, k) <- targets]) [Coin op v (-1) miner]+ errs <- sendRaw node [tx]+ forM_ errs $ mapM_ (\e -> fail ("The node refused a funding transaction: " <> show e))+ return [Coin (OutPoint (txHash tx) i) each wi k | (i, (wi, k)) <- zip [0 ..] targets]+ void $ generateTo node 1 minerAddr+ say "Waiting for the server to sync and cache the xpubs"+ waitSynced node srv+ forM_ ws $ \w -> getJSON srv ("/xpub/" <> T.unpack w.xpub <> "/balances")+ waitFor 120 "the xpubs to be cached" $ do+ conn <- Redis.checkedConnect (either error id (Redis.parseConnectInfo redisURL))+ n <- either (const 0) id <$> Redis.runRedis conn (Redis.dbsize)+ Redis.disconnect conn+ return (n > 0)+ writeScripts o+ slowlogStart redisURL+ stop <- newTVarIO False+ start <- getCurrentTime+ withFile (o.out <> "/samples.jsonl") WriteMode $ \sh ->+ withFile (o.out <> "/wrk.jsonl") WriteMode $ \wh -> do+ wlock <- newMVar wh+ withAsync (sampler start node srv redisURL stats stop sh) $ \_ ->+ withAsync (wrkAll o ws srv wc start stop wlock) $ \_ -> do+ (sent, refused) <- withFile (o.out <> "/rounds.jsonl") WriteMode $ \rh ->+ rounds o ctx g node ws minerAddr start rh (Seq.fromList coins0)+ say "Waiting for the server to catch up"+ t0 <- getCurrentTime+ waitSynced node srv+ catchUp <- (`diffUTCTime` t0) <$> getCurrentTime+ atomically (writeTVar stop True)+ -- let the last runs of wrk and the sampler finish+ threadDelay ((o.wrkSeconds + 3) * 1000000)+ say "Comparing cached answers with the database"+ checks <- forM ws (consistent srv)+ slow <- slowlog redisURL+ end <- getCurrentTime+ height <- nodeHeight node+ writeJSON (o.out <> "/summary.json") $+ object+ [ "transactions_sent" .= sent,+ "transactions_refused" .= refused,+ "height" .= height,+ "seconds" .= (realToFrac (end `diffUTCTime` start) :: Double),+ "catch_up_seconds" .= (realToFrac catchUp :: Double),+ "consistency" .= concat checks,+ "redis_slow" .= slow+ ]+ say ("Done: " <> show sent <> " transactions, " <> show refused <> " refused")++-- | Make the transactions, a block at a time. Gives how many were sent and+-- how many the node refused.+rounds :: Opts -> Ctx -> IOGenM StdGen -> Node -> [Wallet] -> Text -> UTCTime -> Handle -> Seq Coin -> IO (Int, Int)+rounds o ctx g node ws minerAddr start rh = go 0 (0, 0)+ where+ total = o.txs+ go :: Int -> (Int, Int) -> Seq Coin -> IO (Int, Int)+ go r (sent, refused) pool+ | sent >= total = return (sent, refused)+ | otherwise = do+ let n = minimum [o.blockTxs, total - sent, Seq.length pool]+ (spend, rest) = Seq.splitAt n pool+ when (n == 0) $ fail "No coins left to spend"+ t0 <- getCurrentTime+ made <- mapM (pay ctx g ws) (toList spend)+ -- sign now, not when the transactions are sent+ mapM_ (evaluate . force . fst) made+ t1 <- getCurrentTime+ results <- fmap concat . forM (chunks o.batch (zip (toList spend) made)) $ \chunk -> do+ errs <- sendRaw node [tx | (_, (tx, _)) <- chunk]+ return (zip chunk errs)+ t2 <- getCurrentTime+ let accepted = [new | ((_, (_, new)), Nothing) <- results]+ back = [c | ((c, _), Just _) <- results]+ errors = [e | (_, Just e) <- results]+ case errors of+ e : _ ->+ say $+ unwords+ [ "The node refused",+ show (length errors),+ "transactions, such as",+ take 300 (show e)+ ]+ [] -> return ()+ void $ generateTo node 1 minerAddr+ t3 <- getCurrentTime+ height <- nodeHeight node+ let sent' = sent + length accepted+ appendLine rh $+ object+ [ "round" .= r,+ "t" .= secs start t3,+ "height" .= height,+ "sent" .= sent',+ "refused" .= length errors,+ "sign_ms" .= ms t0 t1,+ "submit_ms" .= ms t1 t2,+ "mine_ms" .= ms t2 t3+ ]+ when (r `mod` 10 == 0) $+ say ("Round " <> show r <> ": " <> show sent' <> " transactions, height " <> show height)+ go (r + 1) (sent', refused + length errors) (rest <> Seq.fromList (back <> concat accepted))+ chunks k xs = case splitAt k xs of+ ([], _) -> []+ (a, b) -> a : chunks k b++-- | Wait until the server has the best block of the node.+waitSynced :: Node -> Server -> IO ()+waitSynced node srv =+ waitFor 1800 "the server to sync" $ do+ h <- nodeHeight node+ serverHeight srv <&&> (== h)+ where+ a <&&> f = maybe False f <$> a++-- | Height of the best block of the server.+serverHeight :: Server -> IO (Maybe Int)+serverHeight srv =+ tryAny (getJSON srv "/block/best?notx=true") <&> \case+ Right v | Just (Number n) <- field "height" v -> Just (round n)+ _ -> Nothing+ where+ (<&>) = flip fmap++-- | Compare the cached answers about an xpub with those of the database,+-- for up to a minute, as the cache may still be catching up.+consistent :: Server -> Wallet -> IO [Value]+consistent srv w =+ forM paths $ \p -> do+ ok <- tryWait (60 :: Int) p+ return (object ["xpub" .= w.xpub, "path" .= p, "same" .= ok])+ where+ paths = ["", "/balances", "/transactions?limit=100", "/unspent?limit=100"]+ base = "/xpub/" <> T.unpack w.xpub+ sep p = if '?' `elem` p then "&" else "?"+ tryWait n p = do+ a <- getJSON srv (base <> p)+ b <- getJSON srv (base <> p <> sep p <> "nocache=true")+ if a == b || n <= 0+ then return (a == b)+ else threadDelay 1000000 >> tryWait (n - 1) p++-------------------------------------------------------------------------------+-- wrk+-------------------------------------------------------------------------------++-- | Endpoints that wrk asks about, each rotating over every xpub.+endpoints :: [(String, Text -> Text)]+endpoints =+ [ ("summary", \x -> "/xpub/" <> x),+ ("balances", \x -> "/xpub/" <> x <> "/balances"),+ ("transactions", \x -> "/xpub/" <> x <> "/transactions?limit=100"),+ ("unspent", \x -> "/xpub/" <> x <> "/unspent?limit=100"),+ ("multiaddr", \x -> "/blockchain/multiaddr?active=" <> x <> "&n=50")+ ]++-- | The script of wrk ("stress/wrk.lua"), copied into the output directory+-- where the container of wrk sees it. It asks for the paths it is given in+-- turn and prints its results as JSON. It is embedded when the program is+-- compiled, so the program finds it wherever it runs.+writeScripts :: Opts -> IO ()+writeScripts o = BS.writeFile (o.out <> "/wrk.lua") $(makeRelativeToProject "stress/wrk.lua" >>= embedFile)++-- | Run wrk on every endpoint at once, over and over, until told to stop.+wrkAll :: Opts -> [Wallet] -> Server -> Container -> UTCTime -> TVar Bool -> MVar Handle -> IO ()+wrkAll o ws srv wc start stop lock = go endpoints+ where+ go [] = return ()+ go ((name, path) : rest) = withAsync (loop name (map (path . (.xpub)) ws)) $ \_ -> go rest >> waitStop+ waitStop = readTVarIO stop >>= \s -> unless s (threadDelay 500000 >> waitStop)+ loop name paths = do+ s <- readTVarIO stop+ unless s $ do+ t <- getCurrentTime+ let args =+ [ "exec",+ wc.cid,+ wrkBin,+ "-t1",+ "-c" <> show o.connections,+ "-d" <> show o.wrkSeconds <> "s",+ "--timeout",+ "10s",+ "-s",+ "/stress/wrk.lua",+ srv.url,+ "--"+ ]+ <> map T.unpack paths+ (code, out, err) <- readProcessWithExitCode "podman" args ""+ let result = case [l | l <- lines out, take 1 l == "{"] of+ l : _ | Just (Object r) <- A.decode (BL.fromStrict (C8.pack l)) -> Object r+ _ -> object ["failed" .= True, "exit" .= show code, "stderr" .= take 300 err]+ withMVar lock $ \h ->+ appendLine h (merge (object ["t" .= secs start t, "endpoint" .= name]) result)+ loop name paths+ merge (Object a) (Object b) = Object (a <> b)+ merge a _ = a++-------------------------------------------------------------------------------+-- Sampling+-------------------------------------------------------------------------------++-- | Write a sample every second until told to stop: the heights of the node+-- and the server, the CPU and memory of the server, the memory of Valkey, and+-- the StatsD metrics of the server since the last sample.+sampler :: UTCTime -> Node -> Server -> String -> IORef Stats -> TVar Bool -> Handle -> IO ()+sampler start node srv redisURL stats stop h = do+ hz <- clockTicks+ conn <- Redis.checkedConnect (either error id (Redis.parseConnectInfo redisURL))+ let go prevTicks prevTime = do+ s <- readTVarIO stop+ unless s $ do+ threadDelay 1000000+ now <- getCurrentTime+ ticks <- cpuTicks srv.pid+ rss <- rssKb srv.pid+ (heap, lazyFree) <- ghcHeapKb srv.pid+ nh <- either (const Nothing) Just <$> tryAny (nodeHeight node)+ sh <- serverHeight srv+ vm <- valkeyMemory conn+ m <- drainStats stats+ let seconds = realToFrac (now `diffUTCTime` prevTime)+ cpu = fromIntegral (max 0 (ticks - prevTicks)) / fromIntegral hz / seconds * 100 :: Double+ appendLine h $+ object+ [ "t" .= secs start now,+ "node_height" .= nh,+ "server_height" .= sh,+ "cpu_percent" .= cpu,+ "rss_mb" .= (fromIntegral rss / 1024 :: Double),+ "heap_mb" .= (fromIntegral heap / 1024 :: Double),+ "heap_lazy_free_mb" .= (fromIntegral lazyFree / 1024 :: Double),+ "valkey_mb" .= ((/ (1024 * 1024)) . fromIntegral <$> vm :: Maybe Double),+ "counters" .= m.counters,+ "gauges" .= m.gauges,+ "timings" .= HashMap.map timingStats m.timings+ ]+ go ticks now+ t <- getCurrentTime+ ticks0 <- cpuTicks srv.pid+ go ticks0 t+ where+ timingStats vs =+ let s = sort vs+ n = length s+ at p = s !! min (n - 1) (floor (p * fromIntegral n :: Double))+ in object ["n" .= n, "p50" .= at 0.5, "p95" .= at 0.95, "max" .= last s, "sum" .= sum s]++-- | Ticks of the clock of the kernel per second.+clockTicks :: IO Int+clockTicks = do+ r <- tryAny (readProcessWithExitCode "getconf" ["CLK_TCK"] "")+ return $ case r of+ Right (ExitSuccess, o, _) | Just n <- readMaybe o -> n+ _ -> 100++-- | CPU time of a process, in ticks of the clock of the kernel.+cpuTicks :: Int -> IO Int+cpuTicks pid = do+ r <- tryAny (readFile ("/proc/" <> show pid <> "/stat"))+ return $ case r of+ -- the fields after the name, which is in parentheses and may have spaces+ Right s+ | fs <- words (drop 2 (dropWhile (/= ')') s)),+ length fs > 13 ->+ sum (mapMaybe readMaybe [fs !! 11, fs !! 12])+ _ -> 0++-- | Resident memory of a process, in KiB.+rssKb :: Int -> IO Int+rssKb pid = do+ r <- tryAny (readFile ("/proc/" <> show pid <> "/status"))+ return $ case r of+ Right s | (v : _) <- [w | l <- lines s, ("VmRSS:" : w : _) <- [words l]], Just n <- readMaybe v -> n+ _ -> 0++-- | Resident memory of the GHC heap of a process, in KiB: what is resident of+-- the address range that the runtime reserves for its heap, 1 TiB from+-- @0x4200000000@. The rest of the resident memory is outside the heap:+-- RocksDB, buffers of C libraries, stacks and the program itself. A heap that+-- grows while its live data does not is memory the runtime took for a peak+-- and kept, which a heap profile, made of live data, does not show.+--+-- Also gives how much of it the runtime has given back to the system with+-- @MADV_FREE@ (@LazyFree@): free for the kernel to take when it needs memory,+-- but counted as resident until it does.+ghcHeapKb :: Int -> IO (Int, Int)+ghcHeapKb pid = do+ r <- tryAny (readFile ("/proc/" <> show pid <> "/smaps"))+ return $ case r of+ Left _ -> (0, 0)+ Right s -> (go "Rss:" False 0 (lines s), go "LazyFree:" False 0 (lines s))+ where+ base = 0x4200000000 :: Integer+ top = base + 2 ^ (40 :: Int)+ go _ _ n [] = n+ go key inHeap n (l : ls)+ | (range : _) <- words l,+ (a, '-' : _) <- break (== '-') range,+ [(start, "")] <- readHex a =+ go key (start >= base && start < top) n ls+ | inHeap, (k : v : _) <- words l, k == key, Just x <- readMaybe v = go key inHeap (n + x) ls+ | otherwise = go key inHeap n ls++-- | Memory used by Valkey, in bytes.+valkeyMemory :: Redis.Connection -> IO (Maybe Integer)+valkeyMemory conn =+ Redis.runRedis conn Redis.info <&&> \case+ Right s ->+ case [v | l <- C8.lines s, Just v <- [C8.stripPrefix "used_memory:" l]] of+ v : _ -> fst <$> C8.readInteger v+ [] -> Nothing+ Left _ -> Nothing+ where+ a <&&> f = f <$> a++-- | Make Valkey log every command that takes 10 ms or more, keeping all of+-- them: a long script blocks every client of Redis, every server included.+slowlogStart :: String -> IO ()+slowlogStart url = withRedis url $ \conn -> Redis.runRedis conn $ do+ send ["CONFIG", "SET", "slowlog-log-slower-than", "10000"]+ send ["CONFIG", "SET", "slowlog-max-len", "100000"]+ send ["SLOWLOG", "RESET"]+ where+ send args = void (Redis.sendRequest args :: Redis.Redis (Either Redis.Reply Redis.Reply))++-- | The commands that took 10 ms or more: when, how long in milliseconds,+-- the command, and how many arguments it had. For a script of the cache,+-- also its name and, for a commit, whether it was of a full reload.+--+-- The slow log keeps the first 32 arguments of a command, each cut to 128+-- bytes, and ends the list with how many it left out. That is enough: the+-- first argument of an EVAL is the script, whose first line names it (as+-- @commitScript@), and the number of arguments of a commit grows with the+-- size of the change.+slowlog :: String -> IO [Value]+slowlog url = withRedis url $ \conn ->+ Redis.runRedis conn (Redis.sendRequest ["SLOWLOG", "GET", "100000"]) <&&> \case+ Right (Redis.MultiBulk (Just entries)) -> mapMaybe entry entries+ _ -> []+ where+ a <&&> f = f <$> a+ entry (Redis.MultiBulk (Just (_ : Redis.Integer ts : Redis.Integer us : Redis.MultiBulk (Just reply) : _))) =+ let args = [decodeUtf8 b | Redis.Bulk (Just b) <- reply]+ script = case args of+ _ : body : _ -> take 1 [w | w <- T.words body, "Script" `T.isSuffixOf` w]+ _ -> []+ in Just . object $+ [ "time" .= ts,+ "ms" .= (fromIntegral us / 1000 :: Double),+ "command" .= T.toUpper (T.take 24 (mconcat (take 1 args))),+ "args" .= argCount args+ ]+ <> ["script" .= s | s <- script]+ -- ARGV[8] of a commit is the time of a full reload, or empty+ <> ["full" .= not (T.null f) | script == ["commitScript"], f <- take 1 (drop 10 args)]+ entry _ = Nothing+ -- the arguments kept, the last of which may say how many were left out:+ -- "... (2048 more arguments)"+ argCount args = case reverse args of+ l : kept+ | Just rest <- T.stripPrefix "... (" l,+ [(n, _)] <- reads (T.unpack rest) ->+ length kept + n :: Int+ _ -> length args++withRedis :: String -> (Redis.Connection -> IO a) -> IO a+withRedis url = bracket (Redis.checkedConnect (either error id (Redis.parseConnectInfo url))) Redis.disconnect++-------------------------------------------------------------------------------+-- StatsD+-------------------------------------------------------------------------------++-- | Metrics received since they were last drained: counters added up,+-- gauges at their last value, and every timing.+data Stats = Stats+ { counters :: !(HashMap Text Double),+ gauges :: !(HashMap Text Double),+ timings :: !(HashMap Text [Double])+ }++-- | Receive the StatsD metrics of the server on a UDP port of the loopback+-- interface while an action runs.+withStatsd :: (Int -> IORef Stats -> IO a) -> IO a+withStatsd act =+ bracket (Socket.socket Socket.AF_INET Socket.Datagram Socket.defaultProtocol) Socket.close $ \s -> do+ Socket.bind s (Socket.SockAddrInet 0 (Socket.tupleToHostAddress (127, 0, 0, 1)))+ port <- fromIntegral <$> Socket.socketPort s+ ref <- newIORef (Stats HashMap.empty HashMap.empty HashMap.empty)+ let receive = do+ bs <- SocketB.recv s 65535+ forM_ (C8.lines bs) $ \l -> forM_ (parseLine l) $ \m ->+ atomicModifyIORef' ref (\st -> (add m st, ()))+ receive+ withAsync receive $ \_ -> act port ref+ where+ add (k, v, t) st = case t of+ 'c' -> st {counters = HashMap.insertWith (+) k v st.counters}+ 'g' -> st {gauges = HashMap.insert k v st.gauges}+ _ -> st {timings = HashMap.insertWith (<>) k [v] st.timings}++-- | A StatsD line, @key:value|type@ with an optional @|\@rate@: the key+-- without the prefix of the server, the value (scaled up by the rate for a+-- sampled counter), and the first letter of the type.+parseLine :: C8.ByteString -> Maybe (Text, Double, Char)+parseLine l = do+ let (k, rest) = C8.break (== ':') l+ (v, t, rate) <- case C8.split '|' (C8.drop 1 rest) of+ [v, t] -> Just (v, t, 1)+ [v, t, r] | Just ('@', x) <- C8.uncons r -> (v,t,) <$> readMaybe (C8.unpack x)+ _ -> Nothing+ x <- readMaybe (C8.unpack (C8.dropWhile (== '+') v))+ c <- fst <$> C8.uncons t+ let key = T.replace "stress.btcreg." "" (decodeUtf8 k)+ return (key, if c == 'c' then x / rate else x, c)++-- | Take the counters and timings received since the last call; gauges+-- keep their values.+drainStats :: IORef Stats -> IO Stats+drainStats ref = atomicModifyIORef' ref $ \st -> (st {counters = HashMap.empty, timings = HashMap.empty}, st)++-------------------------------------------------------------------------------+-- Helpers+-------------------------------------------------------------------------------++say :: String -> IO ()+say s = hPutStrLn stderr ("stress: " <> s) >> hFlush stderr++-- | Try a check every quarter of a second, for up to some seconds.+waitFor :: Int -> String -> IO Bool -> IO ()+waitFor secs' what check = go (secs' * 4)+ where+ go n =+ check >>= \case+ True -> return ()+ False+ | n <= 0 -> fail ("Timed out waiting for " <> what)+ | otherwise -> threadDelay 250000 >> go (n - 1)++secs :: UTCTime -> UTCTime -> Double+secs a b = realToFrac (b `diffUTCTime` a)++ms :: UTCTime -> UTCTime -> Double+ms a b = 1000 * secs a b++appendLine :: Handle -> Value -> IO ()+appendLine h v = BL.hPut h (encode v <> "\n") >> hFlush h++writeJSON :: FilePath -> Value -> IO ()+writeJSON path = BL.writeFile path . encode
@@ -0,0 +1,32 @@+-- The script of wrk for the stress test (stress/Main.hs). It asks for the+-- paths given after "--" on the command line in turn, and when wrk is done it+-- prints one line of JSON with the results, latencies in microseconds:+--+-- wrk -s wrk.lua http://server -- /xpub/a /xpub/b+local paths = {}+local i = 0++init = function(args)+ paths = args+end++request = function()+ i = i % #paths + 1+ return wrk.format("GET", paths[i])+end++done = function(summary, latency, requests)+ local e = summary.errors+ local fields = {+ '"requests":' .. summary.requests,+ '"duration_us":' .. summary.duration,+ '"errors":' .. (e.connect + e.read + e.write + e.timeout),+ '"non2xx":' .. e.status,+ '"p50_us":' .. latency:percentile(50),+ '"p90_us":' .. latency:percentile(90),+ '"p99_us":' .. latency:percentile(99),+ '"max_us":' .. latency.max,+ '"mean_us":' .. string.format("%.1f", latency.mean),+ }+ io.write("{" .. table.concat(fields, ",") .. "}\n")+end
@@ -0,0 +1,8042 @@+# Long values, such as the hexadecimal of transactions and blocks and the+# xpubs in the examples, are split into pieces of equal width with escaped line+# breaks in double quotes, which join the pieces with nothing between them, so+# that every line stays under 120 columns. An xpub used as a key is split the+# same way, as an explicit key ("? ").+openapi: 3.0.0++servers:+ - url: 'https://api.haskoin.com/bch/'+ - url: 'https://api.haskoin.com/btc/'+ - url: 'https://api.haskoin.com/bchtest4/'+ - url: 'https://api.haskoin.com/btctest4/'++info:+ version: &version 1.9.0+ title: Haskoin+ contact:+ name: JP Rupp+ url: https://github.com/jprupp+ email: jprupp@protonmail.ch+ license:+ name: MIT+ url: https://github.com/jprupp/haskoin-store/blob/master/haskoin-store/LICENSE+ description: |+ Haskoin Store indexes the Bitcoin (BTC) and Bitcoin Cash (BCH) block chains+ and answers the questions that a node cannot answer on its own: what is the+ balance of this address, which transactions belong to this wallet, which+ outputs can it spend. It connects to the peer-to-peer network itself, keeps+ its indices up to date with every block and every unconfirmed transaction,+ and serves them here.++ The servers of this reference are the public instance at api.haskoin.com, one per+ network: `btc`, `bch`, and their test networks `btctest4` and `bchtest4`. The+ same API is served by any Haskoin Store you run yourself.++ ## The basics++ - **Amounts** of coins are whole numbers of satoshis (1 coin is 100,000,000+ satoshis), never decimal numbers of coins: floating-point numbers cannot+ hold every satoshi amount exactly, and a balance that is off by one+ satoshi is a bug that is hard to find. Only prices in other currencies,+ in the Blockchain.info endpoints, are decimal.+ - **Hashes** of blocks and transactions are hexadecimal, in the byte order+ that nodes and block explorers display, so they can be copied between them+ and this API.+ - **Scripts and serialized data** are hexadecimal.+ - **Times** are Unix timestamps, in seconds.+ - **Addresses** are written as the network writes them: Base58 or Bech32 on+ BTC, CashAddr on BCH.++ ## Lists are newest first++ Every list of transactions or unspent outputs starts with the newest:+ unconfirmed transactions first, by the time this server first saw them, then+ confirmed ones, from the highest block down, and within a block from the last+ transaction to the first.++ This is the order in which wallets and explorers show activity, and the+ database is laid out in that order, so the first page of any list is a single+ lookup however long the history is.++ ## Paging++ Lists take three parameters:++ - `limit`: how many items to return. Without it the server returns its+ default number (100, unless the operator changed it). There is a maximum,+ lower for lists of full transactions, which are much larger; `limit=0`+ asks for that maximum.+ - `height`: where the page starts. It can be:+ - a **transaction id**: the page starts at that transaction, which is+ included;+ - a **block hash**: the page starts at that block;+ - a **block height**: the page starts at transactions confirmed at or below+ that height;+ - a **Unix time**: the page starts at the last block mined at or before+ that time.++ Numbers up to 1,230,768,000 are heights and larger ones are times. That+ number is midnight on 1 January 2009, before the first block, so no time+ of a block is mistaken for a height, and no chain will reach that height.+ - `offset`: how many items to skip after the start. The server has a+ maximum (50,000, unless the operator changed it).++ **To get the next page, pass the id of the last transaction you received as+ `height`, and `offset=1` to skip it.** An offset counts from the newest item,+ so every new transaction shifts every page by one, and a deep offset makes the+ server step over all the items before it. A transaction id is a fixed point:+ pages do not shift, and the server finds the start with one lookup however+ far back it is. The maximum offset is there because deep offsets are slow.++ ## Formats++ - **JSON** by default, without spaces, to keep responses small.+ - **Pretty JSON** with `pretty=true`, indented for people to read.+ - **Binary** with the header `Accept: application/octet-stream`: the compact+ serialization that the Haskell client in+ [haskoin-store-data](https://github.com/jprupp/haskoin-store-data)+ decodes. It is smaller and faster to decode than JSON. "Binary format"+ below gives every byte of it.++ Endpoints that end in `/raw` return data serialized as in the peer-to-peer+ protocol, in hexadecimal inside JSON (`{"result": "0100…"}`), or as bytes+ with the binary format.++ ## Parameters are checked++ Every endpoint lists the parameters it accepts, and refuses a request with+ any other, with 400 and a message that names it:++ ```json+ {"error": "user-error", "message": "Unknown parameter: limt"}+ ```++ A value that the endpoint cannot read is refused the same way, such as+ `limit=ten`, `notx=yes` or `derive=Legacy`. Without this, a misspelt+ parameter or a value the server does not understand would be ignored, and+ the client would get an answer to a question it did not ask, with nothing+ to tell it so. Requests are checked before anything is done, so a refused+ request has no effect.++ One thing is not refused, because it is not a mistake: a `limit` above the+ server's maximum is lowered to it, as a client cannot know the maximum of+ every server. Names of parameters are case-sensitive.++ ## Errors++ An error is a JSON object with a stable `error` code, for programs, and a+ `message`, for people:++ ```json+ {"error": "user-error", "message": "Invalid value for parameter limit: ten"}+ ```++ | Status | `error` | When |+ | --- | --- | --- |+ | 400 | `user-error`, `bad-request` | A parameter or the body is wrong; the message says which. |+ | 404 | `not-found-or-invalid-arg` | The thing does not exist, or a value in the path is not valid. |+ | 409 | `multiple-tx-index` | A short transaction number matches several transactions. |+ | 500 | `server-error` | Something went wrong on the server; it is logged there. |+ | 503 | | From `/health`: the server is not ready. |+ | 503 | `behind-fleet` | The server is one of a fleet and does not have the block that the fleet answers from; another server of the fleet does. |++ A request that asks for the binary format gets its errors in binary too:+ see "Binary format".++ `/health` also answers 203 for a server that works but cannot reach its+ Redis; see there.++ A value in the path that does not parse, such as a misspelt address, gives+ 404 rather than 400. Paths are matched by the shape of what is in them, and+ a segment that does not parse may belong to another endpoint (`/block/best`+ and `/block/{block}` share a prefix), so the server tries the others, and+ when none matches it answers that there is nothing there. Query parameters+ are not ambiguous, so a bad one gives 400, with its name in the message.++ A 409 comes only from the Blockchain.info endpoints, which can name a+ transaction by a short number; the error lists the matching ids in `txids`.++ The codes `server-timeout` and `request-too-large` are part of the format, for+ clients that decode it, but this version of the server does not send them.++ ## Unconfirmed transactions++ The mempool is indexed like the chain. A payment shows up in balances and+ histories as soon as this server sees it on the network, with+ `"block": {"mempool": <time first seen>}` instead of a block height.++ A transaction can leave the mempool without being confirmed: when a block+ confirms another transaction that spends the same coins, or, on BTC, when a+ replacement that pays a higher fee arrives (replace-by-fee). It is then+ removed from every balance and history, together with any transaction that+ spends its outputs. From then on the server answers as if it had never+ seen it: fetching it by its id gives 404. Earlier versions answered with+ the transaction and `"deleted": true`, which clients that did not look at+ that field took for a pending payment.++ ## Reorganisations++ When the network switches to a chain with more work, the server undoes the+ blocks that are no longer in it, puts their transactions back in the+ mempool, and applies the new blocks. Every answer reflects the chain with+ the most work at the time of the request. Blocks of an abandoned chain keep+ answering by hash, with `"mainchain": false`.++ ## What the server trusts++ The server follows the chain with the most proof of work, as the network+ reports it, and checks what it needs to keep its indices consistent: that+ inputs exist and are not spent twice. It does not validate scripts or every+ consensus rule, as a full node does. Its answers are what the network says;+ for payments where a mistake would be costly, wait for confirmations, as with+ any wallet.++ ## Extended public keys++ Most wallets are an extended public key (xpub) from which they derive their+ addresses. The `/xpub` endpoints answer for all of them at once: the server+ derives the receiving chain (`0`) and the change chain (`1`), and looks at+ every address up to the last used one of each chain and then a gap of unused+ addresses after it (32, unless the operator changed it), and always at least+ the first 20 addresses. A payment to an address further away is not seen,+ as a wallet that follows BIP 44 with the same gap would not see it either.++ The `derive` parameter says which kind of address the wallet derives. An+ xpub does not say it, and the same key gives different addresses for each+ kind, so asking for the wrong kind would find nothing, and a wallet would+ show its user a zero balance. Instead, when a wallet has no transactions+ with the kind asked for but has some with another kind, the server answers+ 400 with a message that names the right one. A wallet with no transactions+ of any kind is new, and gets an empty answer. The check only runs for+ wallets that look empty, so it costs nothing for wallets in use.++ Servers with a Redis cache keep large wallets ready: their first query reads+ the database and caches the result, and later ones are answered from memory.+ A cached answer is always the same as the database's; `nocache=true` skips+ the cache. The servers of a fleet share one such cache, and each answers+ from it what it would answer from its database at the block that the+ fleet answers from.++ ## Live updates++ Instead of polling, keep `/events` open: it sends a line for every new block+ and every change to the mempool, as a stream of JSON lines or over a+ WebSocket. Events carry only the hash; fetch what you need from it.++ The server keeps its last 10,000 events. A client that lost its connection+ comes back with `?after=` and the hash of the last event it heard, and gets+ what came after it. A client that falls further behind than that is+ disconnected, so that a slow client costs the server nothing.++ Over the WebSocket a client can choose the transactions it hears about: by+ address, by extended public key, or with a Bloom filter. See `/events`.++ The servers of a fleet all send the same events in the same order: those of+ the block and the mempool that the fleet shares, not what each server's own+ node saw. A transaction is told when it enters the shared mempool and when+ it leaves it unconfirmed, and a block when the fleet has moved to it, which+ is when every server can answer about it. A client can therefore reconnect+ to another server and ask it about what the first one told.++ ## Blockchain.info compatibility++ The endpoints under `/blockchain/` answer like those of Blockchain.info, with+ the same parameters, field names and conventions, so that software written+ for it can switch by changing its base URL. They are documented here where+ they differ or where the original is unclear. An operator may turn them off.++ ## Browsers++ Every response has the header `Access-Control-Allow-Origin: *`, so web+ applications can call the API directly from a browser. The API needs no+ credentials and keeps no sessions, so there is nothing for another site to+ abuse. A browser that first asks what it may send, with `OPTIONS`, is told:+ `GET`, `HEAD`, `POST` and `DELETE`, with the headers `Accept` and+ `Content-Type`.++ ## HTTP++ - **`HEAD`** works wherever `GET` does, and answers with the headers alone.+ - **Compression.** A request with `Accept-Encoding: gzip` gets its answer+ compressed, in either format. The events are not compressed: they have+ to leave as they happen.+ - **Long lists.** The endpoints that take a list of addresses, blocks,+ heights or transactions in a parameter take it under `POST` too, with+ the same parameters in a body of type+ `application/x-www-form-urlencoded`. A URL has room for some hundred+ addresses; a body has no such limit. `/transactions` is the exception:+ `POST` there publishes a transaction.+ - **`Vary: Accept, Accept-Encoding`** is on every answer, so that a cache+ keeps the formats apart.+ - **`Retry-After`** is on a 503: one second from a server that is behind+ its fleet, five from a health check that fails.+ - **The reference itself** is at the root of every server: `/` is this+ page, for the version that the server runs, and `/swagger.yaml` the file+ behind it.++ ## Binary format++ This section is enough to write a client in any language.++ ### Asking for it++ - Send the header `Accept: application/octet-stream`. In a list of types,+ binary is sent when that type has a `q` weight above 0 that is no less+ than the weight of `application/json`, if that is listed too. A+ wildcard such as `*/*`, or no header, gives JSON. There is no query+ parameter for it, and `pretty` has no effect on it.+ - Every answer has `Vary: Accept`, so that a cache keeps the two formats+ apart.+ - A binary answer has `Content-Type: application/octet-stream`. The body is+ one value, with nothing before or after it.+ - **An error comes in the format asked for**, like any other answer. In+ binary it is an `Except`, described below. Servers up to version 1.8.1+ sent every error as JSON: read the `Content-Type` of an error to tell.+ - `/health` answers 503 and 203 with a normal body, in the format asked for.+ - The only endpoint that reads a body is `POST /transactions`: one+ transaction as in the peer-to-peer protocol, as raw bytes or as hex. The+ server tells them apart by content, not by `Content-Type`.+ - The Blockchain.info endpoints and `/dbstats` have no binary form.+ - Over the WebSocket at `/events`, binary is binary frames: the server+ sends them when the handshake asked for binary, and from the first binary+ frame that the client sends.++ ### Primitives++ Everything is big-endian, except inside the peer-to-peer structures (`Tx`,+ `BlockHeader`, `OutPoint`, `Block`), which are byte for byte what nodes send.++ | Name | Encoding |+ | --- | --- |+ | `u8`, `u32`, `u64` | Unsigned integer of 1, 4 or 8 bytes, big-endian. |+ | `i32`, `i64` | Two's complement, big-endian. |+ | `u32le`, `u64le` | Little-endian; only in peer-to-peer structures. |+ | `bool` | One byte: `00` false, `01` true. |+ | `bytes` | `u64` length, then that many bytes. |+ | `text` | `bytes` that hold UTF-8. |+ | `list T` | `u64` count, then that many `T`. |+ | `opt T` | `00` for none; `01` then `T`. |+ | `map T` | `list` of pairs `u64` key, `T`; keys ascending, each once. |+ | `hash256` | 32 bytes, in the reverse order of the hex in JSON. |+ | `hash160` | 20 bytes, in the order of the hex. |+ | `varint` | Bitcoin's CompactSize, shortest form; only in peer-to-peer structures. |+ | `integer` | See below. |++ There are no variable-length integers outside the peer-to-peer structures:+ every length and count is a `u64`. A type with several forms starts with a+ tag byte, given in its table.++ `integer` holds a number of any size. `00` then `i32` when it fits 32 bits.+ Otherwise `01`, a sign byte (`01`, or `ff` for negative), a `u64` length,+ and the absolute value in that many bytes, least significant first, with no+ zero byte at the end.++ ### Peer-to-peer structures++ | Type | Layout |+ | --- | --- |+ | `OutPoint` | `hash256` transaction id, `u32le` output index. 36 bytes. |+ | `BlockHeader` | `u32le` version, `hash256` previous, `hash256` Merkle root, `u32le` time, bits, nonce. |+ | `Tx` | `u32le` version, `varint` count, inputs, `varint` count, outputs, `u32le` lock time. |+ | `Tx` input | `OutPoint`, `varint` length, script, `u32le` sequence. |+ | `Tx` output | `u64le` value, `varint` length, script. |+ | `Block` | `BlockHeader`, `varint` count, that many `Tx`. |++ A `Tx` with a witness has the SegWit form: `00 01` after the version, and+ before the lock time one stack per input, each a `varint` count of items,+ each a `varint` length and bytes. A `Tx` in which no input has a witness is+ written in the form without it, on every network.++ ### Address++ An address is a tag and a hash. It does not carry its network, so it decodes+ without knowing it. Writing it as text needs the network.++ | Tag | Then | Kind |+ | --- | --- | --- |+ | `00` | `hash160` | Pay to public key hash. |+ | `01` | `hash160` | Pay to script hash. |+ | `02` | `hash160` | Pay to witness public key hash. |+ | `03` | `hash256` | Pay to witness script hash. |+ | `04` | `u8` version, `bytes` program (40 at most) | Other SegWit address, such as Taproot. |+ | `05` | `hash256` | Pay to script hash of 32 bytes (BCH). |+ | `06` | `u8` type, `bytes` hash (64 at most) | Other CashAddr address. |++ The `hash256` of tags `03` and `05` is in the order of the script, not+ reversed.++ ### BlockRef and TxRef++ `BlockRef` says where a transaction is. Its numbers are **inverted**: each+ is written as the largest value of its width minus the number, so that the+ bytes sort newest first. Subtract again to read them.++ | Tag | Then | Meaning |+ | --- | --- | --- |+ | `00` | `u64`: 2^64 - 1 - time | In the mempool; time first seen. |+ | `01` | `u32`: 2^32 - 1 - height, `u32`: 2^32 - 1 - position | In a block, at that position in it. |++ `TxRef` is a `BlockRef`, then the `hash256` transaction id.++ ### Transaction++ | Field | Encoding | Note |+ | --- | --- | --- |+ | block | `BlockRef` | |+ | version | `u32` | |+ | locktime | `u32` | |+ | inputs | `list Input` | |+ | outputs | `list Output` | |+ | deleted | `bool` | |+ | rbf | `bool` | |+ | time | `u64` | Not inverted. |+ | txid | `hash256` | |+ | size | `u32` | With the witness. |+ | weight | `u32` | Present on every network. |+ | fee | `u64` | 0 for a coinbase. |++ `Input`, by tag:++ | Field | Encoding | `00` coinbase | `01` other |+ | --- | --- | --- | --- |+ | outpoint | `OutPoint` | yes | yes |+ | sequence | `u32` | yes | yes |+ | sigscript | `bytes` | yes | yes |+ | pkscript | `bytes` | | yes |+ | value | `u64` | | yes |+ | witness | `list bytes` | yes | yes |+ | address | `opt Address` | | yes |++ The witness list is present on every network, and empty where there is none.++ `Output`: `u64` value, `bytes` script, `opt Spender`, `opt Address`.++ `Spender`: `hash256` id of the spending transaction, `u32` input index.++ `TxData` is the record of a transaction in the database. No endpoint+ returns it; it is here for programs that read the database.++ | Field | Encoding | Note |+ | --- | --- | --- |+ | block | `BlockRef` | |+ | tx | `Tx` | |+ | prevs | `map Prev` | By input index. `Prev`: `bytes` script, `u64` value. |+ | deleted | `bool` | |+ | rbf | `bool` | |+ | time | `u64` | |+ | spenders | `map Spender` | By output index. |++ ### Blocks, balances and unspent outputs++ `BlockData`. The hash is not sent: it is the double SHA-256 of the header.++ | Field | Encoding | Note |+ | --- | --- | --- |+ | height | `u32` | |+ | mainchain | `bool` | |+ | work | `integer` | |+ | header | `BlockHeader` | |+ | size | `u32` | |+ | weight | `u32` | Present on every network. |+ | tx | `list hash256` | |+ | outputs | `u64` | |+ | fee | `u64` | |+ | subsidy | `u64` | |++ | Type | Layout |+ | --- | --- |+ | `Balance` | `Address`, then `u64` confirmed, unconfirmed, utxo, txs, received. |+ | `Unspent` | `BlockRef`, `OutPoint`, `u64` value, `bytes` script, `opt Address`. |+ | `XPubBal` | `list u32` path, `Balance`. |+ | `XPubUnspent` | `list u32` path, `Unspent`. |+ | `XPubSummary` | `u64` confirmed, unconfirmed, received, utxo; `u32` external, change. |+ | `TxId` | `hash256`. |+ | `Event` | Tag `00` new best block or `01` mempool transaction, then `hash256`. 33 bytes. |+ | `PeerInfo` | `bytes` user agent, `text` address, `i32` version, `u64` services, `bool` relay. |++ ### Commands of the event WebSocket++ What a client sends in a binary frame over the WebSocket at `/events`, one+ command to a frame: a tag, then what it carries.++ | Tag | Then | Command |+ | --- | --- | --- |+ | `00` | `list Address` | Watch these addresses too. |+ | `01` | `list Address` | Stop watching these addresses. |+ | `02` | `XPub`, `u8` derivation | Watch this extended public key too. |+ | `03` | `XPub`, `u8` derivation | Stop watching this key. |+ | `04` | `Filter` | Set the Bloom filter, in place of the one before. |+ | `05` | `bytes`, 520 at most | Add an element to the Bloom filter. |+ | `06` | | Remove the Bloom filter. |+ | `07` | | Forget all of it, and hear of every transaction. |+ | `08` | | Forget all of it, and hear of blocks alone. |++ `XPub` is the 78 bytes of the key as BIP 32 serializes it, which is what+ the Base58Check of its text decodes to: `u32` version of the network, `u8`+ depth, 4 bytes fingerprint of the parent, `u32` index, 32 bytes chain code,+ 33 bytes compressed public key. The derivation is `00` legacy, `01` compat,+ `02` segwit.++ `Filter` is the `filterload` message of the peer-to-peer protocol (BIP 37):+ `varint` length and that many bytes of filter, 36,000 at most, `u32le`+ number of hash functions, 50 at most, `u32le` tweak, `u8` flags.++ ### Health++ Each part starts with its own `ok`, which repeats what its numbers say.++ | Field | Encoding | Note |+ | --- | --- | --- |+ | ok | `bool` | The whole check. |+ | blocks | `bool` ok, `u32` headers, `u32` blocks, `i32` max | |+ | last-block | `bool` ok, `i64` age, `i64` max | |+ | last-tx | `bool` ok, `i64` age, `i64` max | |+ | pending-txs | `bool` ok, `i64` count, `i64` max | |+ | peers | `bool` ok, `i64` count, `i64` min | |+ | net | `text` | |+ | version | `text` | |+ | time | `u64` | A number, not the text of the JSON. |+ | redis | `u8` | `00` no cache, `01` not answering, `02` answering. |++ Older servers end the body before `redis`; read that as no cache.++ ### What each endpoint returns++ Lists are `list T`. The events are the one exception: they have no count,+ because they have no end.++ An error, with any status that is not a success, is an `Except`: one byte+ that says which, and for some a body.++ | Byte | Error | Body |+ | --- | --- | --- |+ | `00` | `not-found-or-invalid-arg` | |+ | `01` | `server-error` | |+ | `02` | `bad-request` | |+ | `03` | `user-error` | The message: `bytes`, UTF-8. |+ | `04` | `string-error` | The message: `bytes`, UTF-8. |+ | `05` | `multiple-tx-index` | `list hash256`: the transactions that match. |+ | `06` | `server-timeout` | |+ | `07` | `request-too-large` | |+ | `08` | `behind-fleet` | |++ | Endpoints | Body |+ | --- | --- |+ | `/block/{block}`, `/block/best`, `/block/time/…`, `/block/mtp/…` | `BlockData` |+ | `/blocks`, `/block/latest`, `/block/height/…`, `/block/heights` | `list BlockData` |+ | `/transaction/{txid}` | `Transaction` |+ | `/transactions`, `/transactions/block/…`, `…/transactions/full` | `list Transaction` |+ | `/address/…/transactions`, `/xpub/…/transactions` | `list TxRef` |+ | `/address/{address}/balance` | `Balance` |+ | `/address/balances` | `list Balance` |+ | `/address/…/unspent` | `list Unspent` |+ | `/xpub/{xpub}` | `XPubSummary` |+ | `/xpub/{xpub}/balances` | `list XPubBal` |+ | `/xpub/{xpub}/unspent` | `list XPubUnspent` |+ | `DELETE /xpub/{xpub}` | `bool` |+ | `/transaction/{txid}/after/{height}` | `opt bool` |+ | `POST /transactions` | `TxId` |+ | `/mempool` | `list hash256` |+ | `/peers` | `list PeerInfo` |+ | `/health` | Health, as above. |+ | `…/raw` of one block or transaction | `Block` or `Tx`, bare. |+ | `…/raw` of several | `list Block` or `list Tx`. Servers up to 1.8.1 sent them with no count. |+ | `/events` | An endless stream of `Event`, **no count** and nothing between them. |++ The wrappers add no bytes: the `result` object of the JSON+ (`GenericResult`, `RawResult`, `RawResultList` in the Haskell client) is+ its content alone, and `SerialList` is `list T`.++tags:+ - name: Address+ description: |+ Balances, transactions and unspent outputs of addresses, one at a time or+ many at once, and of extended public keys (xpubs), which stand for every+ address of a wallet.+ - name: Transaction+ description: |+ Transactions by id, the transactions of a block, the mempool, and+ broadcasting new transactions to the network.+ - name: Block+ description: |+ Blocks by hash, height or time, the newest blocks, and their serialized+ form.+ - name: Network+ description: |+ The state of the server and the network: health, peers, and a live stream of+ new blocks and transactions.+ - name: Blockchain+ description: |+ Endpoints that answer like those of Blockchain.info, for software written+ for it. An operator may turn them off, in which case they answer 404.++paths:+ '/health':+ get:+ tags:+ - Network+ summary: Health Check+ description: |+ Whether this server is ready to answer, for load balancers and monitoring.++ The status is **200** when every check passes and **503** when one fails;+ the body is the same either way and says which check failed. A server that is+ still catching up with the network answers 503, so a load balancer stops+ sending it traffic until its answers are current.++ A server that has a Redis cache and cannot reach it answers **203** when+ every check passes, with the header `Haskoin-Degraded: redis` and+ `"redis": {"ok": false}` in the body. It goes on+ answering every request, from its own database: the answers are right for+ what this server has seen, but they are no longer the ones that the servers+ sharing that Redis give together, and requests about large wallets are+ slower. A load balancer that takes any status in the 200s for healthy keeps+ sending it traffic, which is what it should do; a monitor that expects 200+ sees that something is wrong.++ The checks are run in the background every 30 seconds (unless the operator+ changed it), and this endpoint returns the latest result. It does no work of+ its own, so it is safe to call as often as a load balancer likes.+ operationId: healthCheck+ parameters:+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '203':+ description: |+ Every check passes, but the server cannot reach its Redis and answers+ from its own database alone. The body is that of 200.+ headers:+ Haskoin-Degraded:+ description: What the server is doing without; `redis`.+ schema:+ type: string+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/HealthCheck'+ '200':+ description: success+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/HealthCheck'+ example:+ ok: true+ net: bch+ blocks:+ ok: true+ max: 2+ blocks: 688794+ diff: 0+ headers: 688794+ pending-txs:+ ok: true+ max: 100+ count: 0+ version: *version+ last-tx:+ ok: true+ max: 300+ age: 0+ last-block:+ ok: true+ max: 7200+ age: 3279+ peers:+ ok: true+ count: 20+ min: 1+ time: '2021-06-14 10:21:43 UTC'+ application/octet-stream:+ schema:+ type: string+ format: binary+ '503':+ description: unavailable+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/HealthCheck'+ example:+ ok: false+ net: bch+ blocks:+ ok: false+ max: 2+ blocks: 688794+ diff: 4+ headers: 688798+ pending-txs:+ ok: true+ max: 100+ count: 0+ version: *version+ last-tx:+ ok: true+ max: 300+ age: 0+ last-block:+ ok: true+ max: 7200+ age: 3279+ peers:+ ok: false+ count: 0+ min: 1+ time: '2021-06-14 10:21:43 UTC'++ '/address/{address}/transactions':+ get:+ tags:+ - Address+ summary: Transactions for Address+ description: |+ The transactions that pay to or spend from an address, newest first, as+ transaction ids with their place in the chain.++ This is the light way to follow an address: fetch the transactions you do not+ have yet with `/transactions`. Use `/transactions/full` to get them in one+ call when you need them all. See *Paging* at the top for `limit`, `offset`+ and `height`.++ An address with no transactions gives an empty list. An address that does+ not parse for this network gives 404.+ operationId: getAddressTxs+ parameters:+ - $ref: '#/components/parameters/addressParam'+ - $ref: '#/components/parameters/heightParam'+ - $ref: '#/components/parameters/offsetParam'+ - $ref: '#/components/parameters/limitParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: Transaction list starting with most recent.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/TxRef'+ example:+ - txid: 00f304a6089a708168cd10d5ad8abd2977c36791156a55b040117cb5a3db7fad+ block:+ height: 688793+ position: 3+ - txid: b810589c80335083ed715c03abf62f23968846d2f54343bac305f890f292ec8e+ block:+ height: 688707+ position: 786+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Invalid address.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/address/{address}/transactions/full':+ get:+ tags:+ - Address+ summary: Detailed Address Transactions+ description: |+ Like `/address/{address}/transactions`, with the full transactions instead+ of their ids.++ Full transactions are much larger, so the most that one call returns is+ lower (5,000, unless the operator changed it).+ operationId: getAddressTxsFull+ parameters:+ - $ref: '#/components/parameters/addressParam'+ - $ref: '#/components/parameters/heightParam'+ - $ref: '#/components/parameters/offsetParam'+ - $ref: '#/components/parameters/limitParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: Detailed transactions starting with most recent.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/Transaction'+ example:+ - txid: 00f304a6089a708168cd10d5ad8abd2977c36791156a55b040117cb5a3db7fad+ size: 225+ version: 2+ locktime: 688792+ fee: 4500+ inputs:+ - coinbase: false+ txid: b810589c80335083ed715c03abf62f23968846d2f54343bac305f890f292ec8e+ output: 1+ sigscript: "47304402206a11f253c8035be2d9f5958a9ca838b3fd5a1a120441885e10e6ca\+ d0fef3911f02202056263939a88fe06e7fc94368ff44c46f71e7daa603936d75\+ 5b35b8b2a21c154121027eb504988cbc043dd4c9fac51685d68718a24d7f9f1d\+ 4c07b9f8c3d04327794c"+ sequence: 4294967294+ pkscript: 76a91437d070b3c6716a6174080722579fbdddad39bfb388ac+ value: 2275183814+ address: bitcoincash:qqmaqu9nceck5ct5pqrjy4ulhhw66wdlkv2s7f2l9u+ witness: []+ outputs:+ - address: bitcoincash:qqla8n6hwp9f86tc92t6f76680mlkxnqgu5flcqwfe+ pkscript: 76a9143fd3cf57704a93e9782a97a4fb5a3bf7fb1a604788ac+ value: 15197686+ spent: false+ spender: null+ - address: bitcoincash:qql2wpu4fa36yf07gcc8q80sjfxfaq66xssezyukg2+ pkscript: 76a9143ea707954f63a225fe4630701df0924c9e835a3488ac+ value: 2259981628+ spent: true+ spender:+ txid: 9e5fd342efb429df773d8208e3fde7af9b7440869e5963d761da0b317c4908ef+ input: 0+ block:+ height: 688793+ position: 3+ deleted: false+ time: 1621583361+ rbf: false+ weight: 900+ - txid: b810589c80335083ed715c03abf62f23968846d2f54343bac305f890f292ec8e+ size: 225+ version: 2+ locktime: 688706+ fee: 4500+ inputs:+ - coinbase: false+ txid: 741f605735c995ab6d79814da49002f7b4eea973656e70410cbf7969ebf467fd+ output: 0+ sigscript: "4730440220298e397d155bcd0c3371a17f11d6dc06392c72c6d4646ac27fad72\+ e86e7f18f50220471c7338a0f4573955e52a95c4c2717f5ae5d369ad153ae627\+ 49d0dcd9250482412103c60a0fd16eaf3c72dee4bf12a566ee3a6ae181a29b66\+ a1f2407442f7a6d39ad2"+ sequence: 4294967294+ pkscript: 76a9145124c4e68eed7ffc6110c2326d7d3739f55a09eb88ac+ value: 2775188314+ address: bitcoincash:qpgjf38x3mkhllrpzrprymtaxuul2ksfavle5hmhl4+ witness: []+ outputs:+ - address: bitcoincash:qqx0ende3cx70rdsmml6ap5pcc9nqmns5qlc8yvhga+ pkscript: 76a9140cfccdb98e0de78db0deffae8681c60b306e70a088ac+ value: 500000000+ spent: false+ spender: null+ - address: bitcoincash:qqmaqu9nceck5ct5pqrjy4ulhhw66wdlkv2s7f2l9u+ pkscript: 76a91437d070b3c6716a6174080722579fbdddad39bfb388ac+ value: 2275183814+ spent: true+ spender:+ txid: 00f304a6089a708168cd10d5ad8abd2977c36791156a55b040117cb5a3db7fad+ input: 0+ block:+ height: 688707+ position: 786+ deleted: false+ time: 1621533785+ rbf: false+ weight: 900+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Invalid address.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/address/transactions':+ get:+ tags:+ - Address+ summary: Multiple Address Transactions+ description: |+ The transactions of several addresses, merged into one list, newest first.+ A transaction that touches several of the addresses appears once.++ This is how to follow a wallet that is a list of addresses rather than an+ xpub: one call, and one consistent page, instead of one call per address.+ operationId: getMultipleAddressTxs+ parameters:+ - $ref: '#/components/parameters/addressesParam'+ - $ref: '#/components/parameters/heightParam'+ - $ref: '#/components/parameters/offsetParam'+ - $ref: '#/components/parameters/limitParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: List of transactions starting with most recent.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/TxRef'+ example:+ - txid: 9e5fd342efb429df773d8208e3fde7af9b7440869e5963d761da0b317c4908ef+ block:+ height: 688793+ position: 458+ - txid: 00f304a6089a708168cd10d5ad8abd2977c36791156a55b040117cb5a3db7fad+ block:+ height: 688793+ position: 3+ - txid: 00f304a6089a708168cd10d5ad8abd2977c36791156a55b040117cb5a3db7fad+ block:+ height: 688793+ position: 3+ - txid: b810589c80335083ed715c03abf62f23968846d2f54343bac305f890f292ec8e+ block:+ height: 688707+ position: 786+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Invalid address.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/address/transactions/full':+ get:+ tags:+ - Address+ summary: Multiple Address Detailed Transactions+ description: |+ Like `/address/transactions`, with the full transactions instead of their+ ids, and the lower maximum of full transactions.+ operationId: getMultipleAddressTxsFull+ parameters:+ - $ref: '#/components/parameters/addressesParam'+ - $ref: '#/components/parameters/heightParam'+ - $ref: '#/components/parameters/offsetParam'+ - $ref: '#/components/parameters/limitParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: Detailed transactions starting with most recent.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/Transaction'+ example:+ - txid: 9e5fd342efb429df773d8208e3fde7af9b7440869e5963d761da0b317c4908ef+ size: 225+ version: 2+ locktime: 688792+ fee: 4500+ inputs:+ - coinbase: false+ txid: 00f304a6089a708168cd10d5ad8abd2977c36791156a55b040117cb5a3db7fad+ output: 1+ sigscript: "4730440220362587be41ff4ca25ef097b6c4229b031c26420f53143560fc8bf2\+ 4f65418dac022034b0c97cef1ff51460346daca727f926ee675e1acaf63cb092\+ a8a39cb3b5d8f94121029832be5ec435a55cd8c09474348cf7ffdcf266ae4fc8\+ 8f390bde21d9a9c60d09"+ sequence: 4294967294+ pkscript: 76a9143ea707954f63a225fe4630701df0924c9e835a3488ac+ value: 2259981628+ address: bitcoincash:qql2wpu4fa36yf07gcc8q80sjfxfaq66xssezyukg2+ witness: []+ outputs:+ - address: bitcoincash:qpfscnfvsaydyru7wajy42lqumvpf7drzgc7wz87sj+ pkscript: 76a914530c4d2c8748d20f9e77644aabe0e6d814f9a31288ac+ value: 1400000000+ spent: false+ spender: null+ - address: bitcoincash:qrw8u9tctjl4ndggrl6l9ssam4qdse3agynldjdn48+ pkscript: 76a914dc7e15785cbf59b5081ff5f2c21ddd40d8663d4188ac+ value: 859977128+ spent: true+ spender:+ txid: 03b59d0fe4bb260587656f57a2f42a34e97f6502ffb7ee93b8e58f6f8933c99a+ input: 0+ block:+ height: 688793+ position: 458+ deleted: false+ time: 1621583381+ rbf: false+ weight: 900+ - txid: 00f304a6089a708168cd10d5ad8abd2977c36791156a55b040117cb5a3db7fad+ size: 225+ version: 2+ locktime: 688792+ fee: 4500+ inputs:+ - coinbase: false+ txid: b810589c80335083ed715c03abf62f23968846d2f54343bac305f890f292ec8e+ output: 1+ sigscript: "47304402206a11f253c8035be2d9f5958a9ca838b3fd5a1a120441885e10e6ca\+ d0fef3911f02202056263939a88fe06e7fc94368ff44c46f71e7daa603936d75\+ 5b35b8b2a21c154121027eb504988cbc043dd4c9fac51685d68718a24d7f9f1d\+ 4c07b9f8c3d04327794c"+ sequence: 4294967294+ pkscript: 76a91437d070b3c6716a6174080722579fbdddad39bfb388ac+ value: 2275183814+ address: bitcoincash:qqmaqu9nceck5ct5pqrjy4ulhhw66wdlkv2s7f2l9u+ witness: []+ outputs:+ - address: bitcoincash:qqla8n6hwp9f86tc92t6f76680mlkxnqgu5flcqwfe+ pkscript: 76a9143fd3cf57704a93e9782a97a4fb5a3bf7fb1a604788ac+ value: 15197686+ spent: false+ spender: null+ - address: bitcoincash:qql2wpu4fa36yf07gcc8q80sjfxfaq66xssezyukg2+ pkscript: 76a9143ea707954f63a225fe4630701df0924c9e835a3488ac+ value: 2259981628+ spent: true+ spender:+ txid: 9e5fd342efb429df773d8208e3fde7af9b7440869e5963d761da0b317c4908ef+ input: 0+ block:+ height: 688793+ position: 3+ deleted: false+ time: 1621583361+ rbf: false+ weight: 900+ - txid: 00f304a6089a708168cd10d5ad8abd2977c36791156a55b040117cb5a3db7fad+ size: 225+ version: 2+ locktime: 688792+ fee: 4500+ inputs:+ - coinbase: false+ txid: b810589c80335083ed715c03abf62f23968846d2f54343bac305f890f292ec8e+ output: 1+ sigscript: "47304402206a11f253c8035be2d9f5958a9ca838b3fd5a1a120441885e10e6ca\+ d0fef3911f02202056263939a88fe06e7fc94368ff44c46f71e7daa603936d75\+ 5b35b8b2a21c154121027eb504988cbc043dd4c9fac51685d68718a24d7f9f1d\+ 4c07b9f8c3d04327794c"+ sequence: 4294967294+ pkscript: 76a91437d070b3c6716a6174080722579fbdddad39bfb388ac+ value: 2275183814+ address: bitcoincash:qqmaqu9nceck5ct5pqrjy4ulhhw66wdlkv2s7f2l9u+ witness: []+ outputs:+ - address: bitcoincash:qqla8n6hwp9f86tc92t6f76680mlkxnqgu5flcqwfe+ pkscript: 76a9143fd3cf57704a93e9782a97a4fb5a3bf7fb1a604788ac+ value: 15197686+ spent: false+ spender: null+ - address: bitcoincash:qql2wpu4fa36yf07gcc8q80sjfxfaq66xssezyukg2+ pkscript: 76a9143ea707954f63a225fe4630701df0924c9e835a3488ac+ value: 2259981628+ spent: true+ spender:+ txid: 9e5fd342efb429df773d8208e3fde7af9b7440869e5963d761da0b317c4908ef+ input: 0+ block:+ height: 688793+ position: 3+ deleted: false+ time: 1621583361+ rbf: false+ weight: 900+ - txid: b810589c80335083ed715c03abf62f23968846d2f54343bac305f890f292ec8e+ size: 225+ version: 2+ locktime: 688706+ fee: 4500+ inputs:+ - coinbase: false+ txid: 741f605735c995ab6d79814da49002f7b4eea973656e70410cbf7969ebf467fd+ output: 0+ sigscript: "4730440220298e397d155bcd0c3371a17f11d6dc06392c72c6d4646ac27fad72\+ e86e7f18f50220471c7338a0f4573955e52a95c4c2717f5ae5d369ad153ae627\+ 49d0dcd9250482412103c60a0fd16eaf3c72dee4bf12a566ee3a6ae181a29b66\+ a1f2407442f7a6d39ad2"+ sequence: 4294967294+ pkscript: 76a9145124c4e68eed7ffc6110c2326d7d3739f55a09eb88ac+ value: 2775188314+ address: bitcoincash:qpgjf38x3mkhllrpzrprymtaxuul2ksfavle5hmhl4+ witness: []+ outputs:+ - address: bitcoincash:qqx0ende3cx70rdsmml6ap5pcc9nqmns5qlc8yvhga+ pkscript: 76a9140cfccdb98e0de78db0deffae8681c60b306e70a088ac+ value: 500000000+ spent: false+ spender: null+ - address: bitcoincash:qqmaqu9nceck5ct5pqrjy4ulhhw66wdlkv2s7f2l9u+ pkscript: 76a91437d070b3c6716a6174080722579fbdddad39bfb388ac+ value: 2275183814+ spent: true+ spender:+ txid: 00f304a6089a708168cd10d5ad8abd2977c36791156a55b040117cb5a3db7fad+ input: 0+ block:+ height: 688707+ position: 786+ deleted: false+ time: 1621533785+ rbf: false+ weight: 900+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Invalid address.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/address/{address}/balance':+ get:+ tags:+ - Address+ operationId: getBalance+ summary: Address Balance+ description: |+ The balance of an address: confirmed and unconfirmed amounts, the number of+ unspent outputs and of transactions, and the total ever received.++ An address that has never been used has a balance of zero rather than giving+ 404: every valid address exists, it just has not received anything yet.+ parameters:+ - $ref: '#/components/parameters/addressParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: Address balance and other stats.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Balance'+ example:+ address: bitcoincash:qqmaqu9nceck5ct5pqrjy4ulhhw66wdlkv2s7f2l9u+ confirmed: 0+ unconfirmed: 0+ utxo: 0+ txs: 2+ received: 2275183814+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Invalid address.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/address/balances':+ get:+ tags:+ - Address+ summary: Multiple Address Balances+ description: |+ The balances of several addresses, in one call. Every address in the request+ has a balance in the response, unused ones with zeros.+ operationId: getMultipleAddressBalances+ parameters:+ - $ref: '#/components/parameters/addressesParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: Address balances and other stats.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/Balance'+ example:+ - address: bitcoincash:qqmaqu9nceck5ct5pqrjy4ulhhw66wdlkv2s7f2l9u+ confirmed: 0+ unconfirmed: 0+ utxo: 0+ txs: 2+ received: 2275183814+ - address: bitcoincash:qql2wpu4fa36yf07gcc8q80sjfxfaq66xssezyukg2+ confirmed: 0+ unconfirmed: 0+ utxo: 0+ txs: 2+ received: 2259981628+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Invalid Address.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/address/{address}/unspent':+ get:+ tags:+ - Address+ summary: Address Unspent Outputs+ description: |+ The outputs paid to an address that are not spent yet, newest first: the+ coins a wallet can spend.++ Outputs spent by an unconfirmed transaction are left out, because spending+ them again would conflict with it. Unconfirmed outputs are included, with+ `"block": {"mempool": …}`; whether to spend them is the wallet's choice.+ operationId: getAddressUnspent+ parameters:+ - $ref: '#/components/parameters/addressParam'+ - $ref: '#/components/parameters/heightParam'+ - $ref: '#/components/parameters/offsetParam'+ - $ref: '#/components/parameters/limitParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: List of unspent outputs starting with most recent.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/Unspent'+ example:+ - address: bitcoincash:qqla8n6hwp9f86tc92t6f76680mlkxnqgu5flcqwfe+ block:+ height: 688793+ position: 3+ txid: 00f304a6089a708168cd10d5ad8abd2977c36791156a55b040117cb5a3db7fad+ index: 0+ pkscript: 76a9143fd3cf57704a93e9782a97a4fb5a3bf7fb1a604788ac+ value: 15197686+ - address: bitcoincash:qqla8n6hwp9f86tc92t6f76680mlkxnqgu5flcqwfe+ block:+ height: 688664+ position: 51+ txid: 1c9337a7bad182b5cf303b6c3ad7aeac8cbc162dee11ace30671d12d83f80501+ index: 1+ pkscript: 76a9143fd3cf57704a93e9782a97a4fb5a3bf7fb1a604788ac+ value: 28288039+ - address: bitcoincash:qqla8n6hwp9f86tc92t6f76680mlkxnqgu5flcqwfe+ block:+ height: 688601+ position: 3820+ txid: c41e77bb8ab9f78a0ca26c43ba8a9e687510ccc303a9637e86bfb8affcd1dcf6+ index: 0+ pkscript: 76a9143fd3cf57704a93e9782a97a4fb5a3bf7fb1a604788ac+ value: 42056933+ - address: bitcoincash:qqla8n6hwp9f86tc92t6f76680mlkxnqgu5flcqwfe+ block:+ height: 686234+ position: 3648+ txid: d4cad6861428215ed6f86406eb445305b107ab041f02e7ef5879381e9cc11ec0+ index: 0+ pkscript: 76a9143fd3cf57704a93e9782a97a4fb5a3bf7fb1a604788ac+ value: 24597765+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Invalid address.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/address/unspent':+ get:+ tags:+ - Address+ summary: Multiple Address Unspent Outputs+ description: |+ The unspent outputs of several addresses, merged into one list, newest+ first.+ operationId: getMultipleAddressUnspent+ parameters:+ - $ref: '#/components/parameters/addressesParam'+ - $ref: '#/components/parameters/heightParam'+ - $ref: '#/components/parameters/offsetParam'+ - $ref: '#/components/parameters/limitParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: List of unspent outputs starting with most recent.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/Unspent'+ example:+ - address: bitcoincash:qqla8n6hwp9f86tc92t6f76680mlkxnqgu5flcqwfe+ block:+ height: 688793+ position: 3+ txid: 00f304a6089a708168cd10d5ad8abd2977c36791156a55b040117cb5a3db7fad+ index: 0+ pkscript: 76a9143fd3cf57704a93e9782a97a4fb5a3bf7fb1a604788ac+ value: 15197686+ - address: bitcoincash:qqla8n6hwp9f86tc92t6f76680mlkxnqgu5flcqwfe+ block:+ height: 688664+ position: 51+ txid: 1c9337a7bad182b5cf303b6c3ad7aeac8cbc162dee11ace30671d12d83f80501+ index: 1+ pkscript: 76a9143fd3cf57704a93e9782a97a4fb5a3bf7fb1a604788ac+ value: 28288039+ - address: bitcoincash:qqla8n6hwp9f86tc92t6f76680mlkxnqgu5flcqwfe+ block:+ height: 688601+ position: 3820+ txid: c41e77bb8ab9f78a0ca26c43ba8a9e687510ccc303a9637e86bfb8affcd1dcf6+ index: 0+ pkscript: 76a9143fd3cf57704a93e9782a97a4fb5a3bf7fb1a604788ac+ value: 42056933+ - address: bitcoincash:qqla8n6hwp9f86tc92t6f76680mlkxnqgu5flcqwfe+ block:+ height: 686234+ position: 3648+ txid: d4cad6861428215ed6f86406eb445305b107ab041f02e7ef5879381e9cc11ec0+ index: 0+ pkscript: 76a9143fd3cf57704a93e9782a97a4fb5a3bf7fb1a604788ac+ value: 24597765+ - address: bitcoincash:qp5p2rhvl6dtl0jkvrzf2w7u3fxpylu3ks7wh3vtlp+ block:+ height: 685236+ position: 15161+ txid: f80be09fecde882e3b59e39f05f93b51ed92dfe099ee24384b6f30d1d324a995+ index: 0+ pkscript: 76a91468150eecfe9abfbe5660c4953bdc8a4c127f91b488ac+ value: 52128096+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Invalid address.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/block/best':+ get:+ tags:+ - Block+ summary: Block Chain Head+ description: |+ The newest block of the chain with the most work that this server has+ imported.++ It can be a block or two behind the network while the server imports a new+ one; `/health` says how far behind it is.+ operationId: getBlockHead+ parameters:+ - $ref: '#/components/parameters/notxParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: Best block data.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/BlockData'+ example:+ hash: 000000000000000001b64b6325acf226dd0b9c012c1d126917bf444f35408961+ height: 688797+ mainchain: true+ previous: 0000000000000000005ed0fb2297eee123e8970060ed52ce021ce415b3d21e0b+ time: 1621589645+ version: 805298176+ bits: 402828819+ nonce: 1843321275+ size: 14345+ tx:+ - a12f364c11eeb9f5b48295ed52de6d62246d14db884fde68510489ef8afe36dd+ - 0426d4a7d9635f805d657823395ba7f88c28391015c8ff7166f136d4cb1e7003+ - 078169b239fb052fd11947ca2e06d72bcaf2dcd8e2ca2de3641a99972da555aa+ - 09e1a01f75cc41d3a6ddd33cfc6c7c43cff546b6017c42098dc42c0380894d31+ - 1706b23e11d5e65a6b5a7e5b2883be7601037865c46db872b56cce959f25584b+ - 2638456e1b6874b978dbfa545b56e698344d5d776c7fcf9fd283f6ba7eeb0f2d+ - 2d11fe4dc71ffb9b3245927fb8a3821a440d555a2a7cdcba1b561be7d4d5851a+ - 2f4458e9b637b3be97db4c896ef6e396d4a4502e4c00493b8b12e4e672ff991d+ - 30adc4c7e8fc46b2756d7edd4c3ca13ba08d24ba1cff99e4cb59864b442d1b56+ - 30ffe61ae7d106b00a8a3a8b5195ddd5291deb0120fdc620fa42689b481923fa+ - 3166134ffddf6fe6cb816aceb59894274ffeea320406876d8e77b1afa4c98056+ - 3ba5dbc9ab73ef827898c624c7137f41f960efd6e06fb149f67a899731db831d+ - 3f7a69e8104aa103e0988a0201ac2d40347ed84ab4c3ed239ddbe079d269dae8+ - 3fb631d18bd4b86aa2d5156f01a69a89a958b9999cbad2abafe55b65d917c812+ - 4cadeaaf483ea263b8a5a26cfcf227795ec0e468400d16067163b0a20d4b0834+ - 51ebe726bdb690d70ad331e4fb08dddcdd7eaa1a45d8fcc0ac8cee56f54f0d72+ - 613229215ee961c1ec452685d2031f435ca65e1d96744615e590b87ebeb2828a+ - 6243831873230e731091c49d86d65de17cd5a9218d71a1e83213908d532c0014+ - 6ce70f7d51b0bb80c3ca6c84bb1131807c3316c760e080fdbd296cc4f319d097+ - 734fc62db583495c14b14ffccc12f89b2cea3e8d179e07c78ad6cd5746b018b7+ - 777981b9cf7e9222d33129e511c4c247883052622f44e2c61b0393018ba111af+ - 7985ef5d0ab5e7ff7791dd3d82db1673f66f1b9c0d95d27628025253497ca22e+ - 7c071a25ffa5e6333e65dcc86460c25a5ef41d7502603beabdc0541aef18d782+ - 7d237bf9a123281edebebd7029d77b4c7726df54f330ae1228359897b976ef11+ - 884f75299f7dc57cb9844f88b0752b8522c128a0d4915084a0a844cec538a38f+ - 9cb79e1b11c6bdea8ccab3f61411679bd5b19c17651fb71e0d9dbef04fa27d09+ - b7596e008884c68a0322c56ccd94c429613e6be8c958e5c0c93ced6915d32c86+ - be9e621846769a6a92c68882e0b5eb6352a3be3469e4d9beb3926b64039e1037+ - c186585d8e4cae9f04a6c2cb463c79f5f78331434c5bed3c5616672a06919b91+ - c6fc1a6d92877b9878ab08169c22ce39028c3e368d53544811f74f42cdc9e138+ - c8421314ebfe5a312066c2e0feb00eacf49fd28ca174c89461eabdbea5a7fdd5+ - d43a179f02356f62b8d42736c4d70da7584f3c1bbc29d8b2fbe147b9f89ebc04+ - d6312a20c5df4368e975438556aac9db816d261d03c57c3b831f13fa40d1a303+ - d7dc9b223ea0c2e21877611afe65df412164d5a08d2446763dda5bca72719c31+ - df562b911ee84726e9be7c2496aa60466f34a6d748c47f368a05cbd29f45aaca+ - e3b023c0cf9554dfb2ece828dfd94cce4438bfd28c57dfc62af843f9c09cbb4c+ - e9ef985c16a176c1ea3890edcf8fab187703356008016b3aa252844100dafa51+ - ed306dfe986b37aa64006e7deeb5c2342b563b4ca130de53b1e6cb6c8c57758a+ - edf9f11512782dab3e6e4efc47f0491f30ffb87d005f78dd623bae027fb92579+ - f36b81e3aa3340d03a50f7cf71a4c4cda91cf972198a508ecb32c9c41feb7773+ - ff85614565dcc8710dc4f6a96b64ea593e30e0b437980d3702e4adf99aab3f87+ merkle: f379f45cb00f781a94e5ea4be0c194c4d9715c374d02ec1494800869bfd542b8+ subsidy: 625000000+ fees: 87081+ outputs: 115319020328+ work: 440514608165117083892886234+ application/octet-stream:+ schema:+ type: string+ format: binary++ '/block/best/raw':+ get:+ tags:+ - Block+ summary: Raw Block Chain Head+ description: |+ The newest block, serialized as in the peer-to-peer protocol, with all its+ transactions.+ operationId: getRawBlockHead+ parameters:+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: >-+ Bitcoin protocol block.+ Hex string when using JSON.+ content:+ application/json:+ schema:+ type: object+ required:+ - result+ properties:+ result:+ type: string+ example: "010000006f187fddd5e28aa1b4065daa5d9eae0c487094fb20cf97ca02b81c84\+ 000000005b7b25b51797f83192f9fd2c3871bfb27570a7d6b56d3a50760613d1\+ a2fc1aeeab346849ffff001d36d9507101010000000100000000000000000000\+ 00000000000000000000000000000000000000000000ffffffff0704ffff001d\+ 0120ffffffff0100f2052a0100000043410408ab2f56361f83064e4ce51acc29\+ 1fb57c2cbcdb1d6562f6278c43a1406b548fd6cefc11bcc29eb620d5861cb9ed\+ 69dc39f2422f54b06a8af4f78c8276cfdc6bac00000000"+ application/octet-stream:+ schema:+ type: string+ format: binary++ '/block/latest':+ get:+ tags:+ - Block+ summary: Most Recent Blocks+ description: |+ The newest block and the ones before it, up to 100 blocks, newest first. For+ explorers that show recent blocks; use `notx=true` when you do not need the+ transaction ids, as blocks can have thousands of them.+ operationId: getLatestBlocks+ parameters:+ - $ref: '#/components/parameters/notxParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: List of most recent blocks.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/BlockData'+ example:+ - hash: 000000000000000001b64b6325acf226dd0b9c012c1d126917bf444f35408961+ height: 688797+ mainchain: true+ previous: 0000000000000000005ed0fb2297eee123e8970060ed52ce021ce415b3d21e0b+ time: 1621589645+ version: 805298176+ bits: 402828819+ nonce: 1843321275+ size: 14345+ tx:+ - a12f364c11eeb9f5b48295ed52de6d62246d14db884fde68510489ef8afe36dd+ merkle: f379f45cb00f781a94e5ea4be0c194c4d9715c374d02ec1494800869bfd542b8+ subsidy: 625000000+ fees: 87081+ outputs: 115319020328+ work: 440514608165117083892886234+ - hash: 0000000000000000005ed0fb2297eee123e8970060ed52ce021ce415b3d21e0b+ height: 688796+ mainchain: true+ previous: 000000000000000000f3e8c40d8fcb08fe68309d05c654d4a9626e71771c87bc+ time: 1621589661+ version: 536870912+ bits: 402828953+ nonce: 4121941127+ size: 466349+ tx:+ - 1be65880d02647bc4b8b5525b6dd33848ac4f36cc2c682a35676d0c5e10858c2+ merkle: a42b2321e225dd3812f1f1e502ea320eff30d9061f4089613250d5e6d9f3f1ca+ subsidy: 625000000+ fees: 2623401+ outputs: 5682996095523+ work: 440512846073222977677902178+ - hash: 000000000000000000f3e8c40d8fcb08fe68309d05c654d4a9626e71771c87bc+ height: 688795+ mainchain: true+ previous: 000000000000000000e2bd91f8f073c8a755335a1e16bd99d21d9a52497f80ca+ time: 1621589252+ version: 541065216+ bits: 402826569+ nonce: 4143833738+ size: 3789642+ tx:+ - 09aecab0edb71bbd37d2482948ddc6bc3475b6723ed485620ef926571ab6af04+ merkle: df42f51b831eda9ddcf84de248fa7fed56cec66a9f15545c9a0802f5e2ab1dd4+ subsidy: 625000000+ fees: 9075725+ outputs: 58906363954005+ work: 440511085324684718115244800+ application/octet-stream:+ schema:+ type: string+ format: binary++ '/block/{block}':+ get:+ tags:+ - Block+ operationId: getBlock+ summary: Block Information+ description: |+ A block by its hash, with the ids of its transactions.++ Blocks that are no longer in the main chain after a reorganisation keep+ answering, with `"mainchain": false`, so a link to a block never breaks.+ parameters:+ - $ref: '#/components/parameters/notxParam'+ - $ref: '#/components/parameters/blockParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: Block data.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/BlockData'+ example:+ hash: 000000000003ba27aa200b1cecaad478d2b00432346c3f1f3986da1afd33e506+ height: 100000+ mainchain: true+ previous: 000000000002d01c1fccc21636b607dfd930d31d01c3a62104612a1719011250+ time: 1293623863+ version: 1+ bits: 453281356+ nonce: 274148111+ size: 957+ tx:+ - 8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87+ - fff2525b8931402dd09222c50775608f75787bd2b87e56995a7bdd30f79702c4+ - 6359f0868171b1d194cbee1af2f16ea598ae8fad666d9b012c8ed2b79a236ec4+ - e9a66845e05d5abc0ad04ec80f774a7e585c6e8db975962d069a522137b80c1d+ merkle: f3e94742aca4b5ef85488dc37c06c3282295ffec960994b2c0d5ac2a25a95766+ subsidy: 5000000000+ fees: 0+ outputs: 5301000000+ work: 451709610344319134+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Block not found.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/block/{block}/raw':+ get:+ tags:+ - Block+ operationId: getRawBlock+ summary: Raw Block+ description: |+ A block by its hash, serialized as in the peer-to-peer protocol, with all+ its transactions.+ parameters:+ - $ref: '#/components/parameters/blockParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: >-+ Bitcoin protocol block.+ Hex string when using JSON.+ content:+ application/json:+ schema:+ type: object+ required:+ - result+ properties:+ result:+ type: string+ example: "0100000050120119172a610421a6c3011dd330d9df07b63616c2cc1f1cd00200\+ 000000006657a9252aacd5c0b2940996ecff952228c3067cc38d4885efb5a4ac\+ 4247e9f337221b4d4c86041b0f2b571004010000000100000000000000000000\+ 00000000000000000000000000000000000000000000ffffffff08044c86041b\+ 020602ffffffff0100f2052a010000004341041b0e8c2567c12536aa13357b79\+ a073dc4444acb83c4ec7a0e2f99dd7457516c5817242da796924ca4e99947d08\+ 7fedf9ce467cb9f7c6287078f801df276fdf84ac000000000100000001032e38\+ e9c0a84c6046d687d10556dcacc41d275ec55fc00779ac88fdf357a187000000\+ 008c493046022100c352d3dd993a981beba4a63ad15c209275ca9470abfcd57d\+ a93b58e4eb5dce82022100840792bc1f456062819f15d33ee7055cf7b5ee1af1\+ ebcc6028d9cdb1c3af7748014104f46db5e9d61a9dc27b8d64ad23e7383a4e6c\+ a164593c2527c038c0857eb67ee8e825dca65046b82c9331586c82e0fd1f633f\+ 25f87c161bc6f8a630121df2b3d3ffffffff0200e32321000000001976a914c3\+ 98efa9c392ba6013c5e04ee729755ef7f58b3288ac000fe208010000001976a9\+ 14948c765a6914d43f2a7ac177da2c2f6b52de3d7c88ac000000000100000001\+ c33ebff2a709f13d9f9a7569ab16a32786af7d7e2de09265e41c61d078294ecf\+ 010000008a4730440220032d30df5ee6f57fa46cddb5eb8d0d9fe8de6b342d27\+ 942ae90a3231e0ba333e02203deee8060fdc70230a7f5b4ad7d7bc3e628cbe21\+ 9a886b84269eaeb81e26b4fe014104ae31c31bf91278d99b8377a35bbce5b27d\+ 9fff15456839e919453fc7b3f721f0ba403ff96c9deeb680e5fd341c0fc3a7b9\+ 0da4631ee39560639db462e9cb850fffffffff0240420f00000000001976a914\+ b0dcbf97eabf4404e31d952477ce822dadbe7e1088acc060d211000000001976\+ a9146b1281eec25ab4e1e0793ff4e08ab1abb3409cd988ac0000000001000000\+ 010b6072b386d4a773235237f64c1126ac3b240c84b917a3909ba1c43ded5f51\+ f4000000008c493046022100bb1ad26df930a51cce110cf44f7a48c3c561fd97\+ 7500b1ae5d6b6fd13d0b3f4a022100c5b42951acedff14abba2736fd574bdb46\+ 5f3e6f8da12e2c5303954aca7f78f3014104a7135bfe824c97ecc01ec7d7e336\+ 185c81e2aa2c41ab175407c09484ce9694b44953fcb751206564a9c24dd094d4\+ 2fdbfdd5aad3e063ce6af4cfaaea4ea14fbbffffffff0140420f000000000019\+ 76a91439aa3d569e06a1d7926dc4be1193c99bf2eb9ee088ac00000000"+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Block not found.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/blocks':+ get:+ tags:+ - Block+ operationId: getBlocks+ summary: Information for Multiple Blocks+ description: |+ Several blocks by hash, in one call. Hashes that are not known are left out+ of the response rather than failing the whole request, and repeated hashes+ are answered once.+ parameters:+ - $ref: '#/components/parameters/notxParam'+ - $ref: '#/components/parameters/blocksParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: List of block data objects.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/BlockData'+ example:+ - hash: 000000000845517b31c6820d83f25cff46429bf136a7515fe504116427e60f8e+ height: 49999+ mainchain: true+ previous: 000000001a793b5b7732ee306b1e4196132e0193fac6457801f1e16e6ec89e6a+ time: 1270916354+ version: 1+ bits: 472518933+ nonce: 64497466+ size: 216+ tx:+ - 2c95132eb2fd2e7d2f5bd282595d3d47950836031727850579dca836254b1bd4+ merkle: 2c95132eb2fd2e7d2f5bd282595d3d47950836031727850579dca836254b1bd4+ subsidy: 5000000000+ fees: 0+ outputs: 0+ work: 363097871874800+ - hash: 000000001aeae195809d120b5d66a39c83eb48792e068f8ea1fea19d84a4278a+ height: 50000+ mainchain: true+ previous: 000000000845517b31c6820d83f25cff46429bf136a7515fe504116427e60f8e+ time: 1270916538+ version: 1+ bits: 472518933+ nonce: 58489010+ size: 216+ tx:+ - 27f1d66f8a1ee5280f4e92508dcb647e954d53004905d08a75574daee4988360+ merkle: 27f1d66f8a1ee5280f4e92508dcb647e954d53004905d08a75574daee4988360+ subsidy: 5000000000+ fees: 0+ outputs: 0+ work: 363124009197915+ application/octet-stream:+ schema:+ type: string+ format: binary++ '/block/height/{height}':+ get:+ tags:+ - Block+ operationId: getBlocksAtHeight+ summary: Blocks at Height+ description: |+ The blocks at a height. It is a list because the server keeps the blocks of+ chains that were abandoned in a reorganisation; the one in the main chain+ has `"mainchain": true`.+ parameters:+ - $ref: '#/components/parameters/blockHeightParam'+ - $ref: '#/components/parameters/notxParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: Block data objects including orphans.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/BlockData'+ example:+ - hash: 000000008f1a7008320c16b8402b7f11e82951f44ca2663caf6860ab2eeef320+ height: 200+ mainchain: true+ previous: 00000000b7691ccc084542565697eca256e56bb7f67e560b48789db27f0468eb+ time: 1231753120+ version: 1+ bits: 486604799+ nonce: 1832536838+ size: 215+ tx:+ - 2b1f06c2401d3b49a33c3f5ad5864c0bc70044c4068f9174546f3cfc1887d5ba+ merkle: 2b1f06c2401d3b49a33c3f5ad5864c0bc70044c4068f9174546f3cfc1887d5ba+ subsidy: 5000000000+ fees: 0+ outputs: 0+ work: 863301599433+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Block not found.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/block/height/{height}/raw':+ get:+ tags:+ - Block+ operationId: getRawBlocksAtHeight+ summary: Raw Blocks at Height+ description: |+ The blocks at a height, serialized as in the peer-to-peer protocol.+ parameters:+ - $ref: '#/components/parameters/blockHeightParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: >-+ Bitcoin protocol blocks.+ Hex strings when using JSON.+ content:+ application/json:+ schema:+ type: array+ items:+ type: string+ example: "01000000eb68047fb29d78480b567ef6b76be556a2ec975656424508cc1c69b7\+ 00000000bad58718fc3c6f5474918f06c44400c70b4c86d55a3f3ca3493b1d40\+ c2061f2ba00f6b49ffff001d064b3a6d01010000000100000000000000000000\+ 00000000000000000000000000000000000000000000ffffffff0704ffff001d\+ 0138ffffffff0100f2052a010000004341045e071dedd1ed03721c6e9bba28fc\+ 276795421a378637fb41090192bb9f208630dcbac5862a3baeb9df3ca6e4e256\+ b7fd2404824c20198ca1b004ee2197866433ac00000000"+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Block not found.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/block/time/{time}':+ get:+ tags:+ - Block+ operationId: getBlockAtTime+ summary: Block Mined at Time+ description: |+ The last block of the main chain whose timestamp is at or before a time:+ the block that was the newest at that moment, as far as the timestamps of+ blocks tell.++ Timestamps of blocks are set by miners and are only roughly right; they can+ even go backwards from one block to the next. For a time that only goes+ forward, use `/block/mtp/{time}`.+ parameters:+ - $ref: '#/components/parameters/timeParam'+ - $ref: '#/components/parameters/notxParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: Last block mined at or before specified time.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/BlockData'+ example:+ hash: 000000008f1a7008320c16b8402b7f11e82951f44ca2663caf6860ab2eeef320+ height: 200+ mainchain: true+ previous: 00000000b7691ccc084542565697eca256e56bb7f67e560b48789db27f0468eb+ time: 1231753120+ version: 1+ bits: 486604799+ nonce: 1832536838+ size: 215+ tx:+ - 2b1f06c2401d3b49a33c3f5ad5864c0bc70044c4068f9174546f3cfc1887d5ba+ merkle: 2b1f06c2401d3b49a33c3f5ad5864c0bc70044c4068f9174546f3cfc1887d5ba+ subsidy: 5000000000+ fees: 0+ outputs: 0+ work: 863301599433+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Block not found.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/block/time/{time}/raw':+ get:+ tags:+ - Block+ operationId: getRawBlockAtTime+ summary: Raw Block Mined at Time+ description: |+ Like `/block/time/{time}`, serialized as in the peer-to-peer protocol.+ parameters:+ - $ref: '#/components/parameters/timeParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: >-+ Bitcoin protocol block mined at or before specified time.+ Hex string when using JSON.+ content:+ application/json:+ schema:+ type: object+ required:+ - result+ properties:+ result:+ type: string+ example: "0100000000000000000000000000000000000000000000000000000000000000\+ 000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa\+ 4b1e5e4a29ab5f49ffff001d1dac2b7c01010000000100000000000000000000\+ 00000000000000000000000000000000000000000000ffffffff4d04ffff001d\+ 0104455468652054696d65732030332f4a616e2f32303039204368616e63656c\+ 6c6f72206f6e206272696e6b206f66207365636f6e64206261696c6f75742066\+ 6f722062616e6b73ffffffff0100f2052a01000000434104678afdb0fe554827\+ 1967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4\+ f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac00000000"+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Block not found.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/block/mtp/{time}':+ get:+ tags:+ - Block+ operationId: getBlockMTP+ summary: Block at Median Time Past+ description: |+ The first block of the main chain whose median time past is at or after a+ time.++ The median time past of a block is the median of the timestamps of the 11+ blocks up to it. Unlike the timestamps themselves, it never goes backwards,+ and it is the time that the consensus rules use for time locks, so it is the+ right one to answer "from which block on is this lock time reached".+ parameters:+ - $ref: '#/components/parameters/timeParam'+ - $ref: '#/components/parameters/notxParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: Block at Median Time Past+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/BlockData'+ example:+ hash: 000000007986095d6dce2f41fd76e8008e828282400e46d3b49f39d19e2b7a78+ height: 206+ mainchain: true+ previous: 00000000d7e3261b16abe2fc1811150812ee0d6f6fc3727cadd8821df2d96c45+ time: 1231757577+ version: 1+ bits: 486604799+ nonce: 125825145+ size: 215+ tx:+ - 32cbb7e4d08f02fdd78368a7a03ff54c13191d1023aa1e3f157027241d9840ae+ merkle: 32cbb7e4d08f02fdd78368a7a03ff54c13191d1023aa1e3f157027241d9840ae+ subsidy: 5000000000+ fees: 0+ outputs: 0+ work: 889071796431+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Block not found.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/block/mtp/{time}/raw':+ get:+ tags:+ - Block+ operationId: getRawBlockMTP+ summary: Raw Block at Median Time Past+ description: |+ Like `/block/mtp/{time}`, serialized as in the peer-to-peer protocol.+ parameters:+ - $ref: '#/components/parameters/timeParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: >-+ Bitcoin protocol block mined after specified median time past.+ Hex string when using JSON.+ content:+ application/json:+ schema:+ type: object+ required:+ - result+ properties:+ result:+ type: string+ example: "01000000456cd9f21d82d8ad7c72c36f6f0dee1208151118fce2ab161b26e3d7\+ 00000000ae40981d242770153f1eaa23101d19134cf53fa0a76883d7fd028fd0\+ e4b7cb3209216b49ffff001d79f07f0701010000000100000000000000000000\+ 00000000000000000000000000000000000000000000ffffffff0704ffff001d\+ 010effffffff0100f2052a01000000434104bc184a82d824a8ff59576e8b6843\+ 767407e4f35edb5ad725460764a30ddf91479fd0eb1b0c2b4371a06b3fdae345\+ 3262fcf3358afafe3dbebccf2fdefcd0853cac00000000"+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Block not found.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/block/heights':+ get:+ tags:+ - Block+ operationId: getBlocksAtHeights+ summary: Blocks at Multiple Heights+ description: |+ The blocks at several heights, in one call, all of them for each height.+ parameters:+ - $ref: '#/components/parameters/blockHeightsParam'+ - $ref: '#/components/parameters/notxParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: List of block data objects at specified heights.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/BlockData'+ example:+ - hash: 000000007bc154e0fa7ea32218a72fe2c1bb9f86cf8c9ebf9a715ed27fdb229a+ height: 100+ mainchain: true+ previous: 00000000cd9b12643e6854cb25939b39cd7a1ad0af31a9bd8b2efe67854b1995+ time: 1231660825+ version: 1+ bits: 486604799+ nonce: 1573057331+ size: 215+ tx:+ - 2d05f0c9c3e1c226e63b5fac240137687544cf631cd616fd34fd188fc9020866+ merkle: 2d05f0c9c3e1c226e63b5fac240137687544cf631cd616fd34fd188fc9020866+ subsidy: 5000000000+ fees: 0+ outputs: 0+ work: 433798316133+ - hash: 000000008f1a7008320c16b8402b7f11e82951f44ca2663caf6860ab2eeef320+ height: 200+ mainchain: true+ previous: 00000000b7691ccc084542565697eca256e56bb7f67e560b48789db27f0468eb+ time: 1231753120+ version: 1+ bits: 486604799+ nonce: 1832536838+ size: 215+ tx:+ - 2b1f06c2401d3b49a33c3f5ad5864c0bc70044c4068f9174546f3cfc1887d5ba+ merkle: 2b1f06c2401d3b49a33c3f5ad5864c0bc70044c4068f9174546f3cfc1887d5ba+ subsidy: 5000000000+ fees: 0+ outputs: 0+ work: 863301599433+ - hash: 0000000062b69e4a2c3312a5782d7798b0711e9ebac065cd5d19f946439f8609+ height: 300+ mainchain: true+ previous: 00000000b9b003c04b2fe21b4f7a2fa809eb643852573c3a1748653e2019f01c+ time: 1231832435+ version: 1+ bits: 486604799+ nonce: 951842076+ size: 215+ tx:+ - bc15f9dcbe637c187bb94247057b14637316613630126fc396c22e08b89006ea+ merkle: bc15f9dcbe637c187bb94247057b14637316613630126fc396c22e08b89006ea+ subsidy: 5000000000+ fees: 0+ outputs: 0+ work: 1292804882733+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Block not found.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/transaction/{txid}':+ get:+ tags:+ - Transaction+ operationId: getTransaction+ summary: Detailed Transaction+ description: |+ A transaction, with what this server knows about it beyond its raw form:+ where it is in the chain, the fee, the address and amount of every output it+ spends, and which transaction spends each of its outputs.++ Unconfirmed transactions are here too. A transaction that was removed from+ the mempool, or whose block was abandoned, is not found.+ parameters:+ - $ref: '#/components/parameters/txidParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: Detailed transaction object.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Transaction'+ example:+ txid: 03c16602a41ae7cbc2a25b0d2801fdc3295e2e8ebfb26774030b2f7d538f5568+ size: 219+ version: 2+ locktime: 0+ fee: 220+ inputs:+ - coinbase: false+ txid: 02ea64dfff60b1f215282bbb4b2d729fecc50d81d2d0ef09a60d550a5a69418f+ output: 1+ sigscript: "41e3dc2001cffff05e62751c071ac05dee8511b9af626fe5b07bf271ce48df66\+ cf4013d4b4b280bb74eac6206d772aa24f1e766bc813dc68a461736392ce97f8\+ a04121028d75bdff57fcecff68f2927092b15074a4524a8d8182b6de761f6af3\+ b8c6ba8f"+ sequence: 0+ pkscript: 76a914dd1eee80cebba451dc26f6423881b8836fe86a8388ac+ value: 8985694+ address: bitcoincash:qrw3am5qe6a6g5wuymmyywyphzpkl6r2svhy9jy2kz+ witness: []+ outputs:+ - address: bitcoincash:qrnltz5afhaxlh6grupll8nfdswpatwnvcsyucwcle+ pkscript: 76a914e7f58a9d4dfa6fdf481f03ff9e696c1c1eadd36688ac+ value: 1230+ spent: false+ spender: null+ - address: bitcoincash:qrw3am5qe6a6g5wuymmyywyphzpkl6r2svhy9jy2kz+ pkscript: 76a914dd1eee80cebba451dc26f6423881b8836fe86a8388ac+ value: 8984244+ spent: true+ spender:+ txid: 4670c8b46211c06aa1a1c42e8c7e090e971c2549d30aac65cdb12876782c2248+ input: 0+ block:+ height: 688804+ position: 13+ deleted: false+ time: 1621593351+ rbf: false+ weight: 876+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Transaction not found.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/transaction/{txid}/raw':+ get:+ tags:+ - Transaction+ operationId: getRawTransaction+ summary: Raw Transaction+ description: |+ A transaction serialized as in the peer-to-peer protocol, as a node would+ send it.+ parameters:+ - $ref: '#/components/parameters/txidParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: >-+ Bitcoin protocol transaction.+ Hex string if using JSON.+ content:+ application/json:+ schema:+ type: string+ example: "02000000018f41695a0a550da609efd0d2810dc5ec9f722d4bbb2b2815f2b160\+ ffdf64ea02010000006441e3dc2001cffff05e62751c071ac05dee8511b9af62\+ 6fe5b07bf271ce48df66cf4013d4b4b280bb74eac6206d772aa24f1e766bc813\+ dc68a461736392ce97f8a04121028d75bdff57fcecff68f2927092b15074a452\+ 4a8d8182b6de761f6af3b8c6ba8f0000000002ce040000000000001976a914e7\+ f58a9d4dfa6fdf481f03ff9e696c1c1eadd36688acb4168900000000001976a9\+ 14dd1eee80cebba451dc26f6423881b8836fe86a8388ac00000000"+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Transaction not found.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/transaction/{txid}/after/{height}':+ get:+ tags:+ - Transaction+ operationId: cbAfterHeight+ summary: Hard Fork Safety Check+ description: |+ Whether any ancestor of a transaction is a coinbase mined after a height.++ This answers a question that matters after a chain split, such as the one+ between BTC and BCH: coins created by miners after the split exist on one+ chain only, so a transaction that descends from them cannot be replayed on+ the other chain. The server follows the inputs of the transaction back+ through its ancestors, and answers `true` as soon as it finds such a+ coinbase, `false` when every path ends in older blocks, and `null` when it+ gives up after looking at 10,000 transactions, so that one request cannot+ keep the server busy for long.+ parameters:+ - $ref: '#/components/parameters/txidParam'+ - $ref: '#/components/parameters/blockHeightParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: >-+ Specify height of last common block to find out if this transaction+ is invalid on alternate chain.+ content:+ application/json:+ schema:+ type: object+ required:+ - result+ properties:+ result:+ type: boolean+ nullable: true+ example: true+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Invalid argument.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/transactions':+ get:+ tags:+ - Transaction+ operationId: getTransactions+ summary: Multiple Detailed Transactions+ description: |+ Several transactions by id, in one call. Ids that are not known are left out+ of the response rather than failing the whole request, and repeated ids are+ answered once.+ parameters:+ - $ref: '#/components/parameters/txidsParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: List of detailed transaction objects.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/Transaction'+ example:+ - txid: 02ea64dfff60b1f215282bbb4b2d729fecc50d81d2d0ef09a60d550a5a69418f+ size: 217+ version: 2+ locktime: 0+ fee: 218+ inputs:+ - coinbase: false+ txid: a7319f9118f2dc743a17476e668b83f61e9ee5252cb0417baf0bea41ddd95ba6+ output: 1+ sigscript: "41f1489e83baad016eb63e3ebfe6bda264652fa188877e2efed9261566265cb3\+ 1cff5091878f6a965b7184d538d8fe285b1ba4e3f18f53f44a30de3a36635750\+ c34121028d75bdff57fcecff68f2927092b15074a4524a8d8182b6de761f6af3\+ b8c6ba8f"+ sequence: 0+ pkscript: 76a914dd1eee80cebba451dc26f6423881b8836fe86a8388ac+ value: 8987143+ address: bitcoincash:qrw3am5qe6a6g5wuymmyywyphzpkl6r2svhy9jy2kz+ witness: []+ outputs:+ - address: bitcoincash:prgmpvfetym4fm724dddyrgnm5a7stjmlcp8v7z3tn+ pkscript: a914d1b0b139593754efcaab5ad20d13dd3be82e5bfe87+ value: 1231+ spent: false+ spender: null+ - address: bitcoincash:qrw3am5qe6a6g5wuymmyywyphzpkl6r2svhy9jy2kz+ pkscript: 76a914dd1eee80cebba451dc26f6423881b8836fe86a8388ac+ value: 8985694+ spent: true+ spender:+ txid: 03c16602a41ae7cbc2a25b0d2801fdc3295e2e8ebfb26774030b2f7d538f5568+ input: 0+ block:+ height: 688803+ position: 17+ deleted: false+ time: 1621593002+ rbf: false+ weight: 868+ - txid: 03c16602a41ae7cbc2a25b0d2801fdc3295e2e8ebfb26774030b2f7d538f5568+ size: 219+ version: 2+ locktime: 0+ fee: 220+ inputs:+ - coinbase: false+ txid: 02ea64dfff60b1f215282bbb4b2d729fecc50d81d2d0ef09a60d550a5a69418f+ output: 1+ sigscript: "41e3dc2001cffff05e62751c071ac05dee8511b9af626fe5b07bf271ce48df66\+ cf4013d4b4b280bb74eac6206d772aa24f1e766bc813dc68a461736392ce97f8\+ a04121028d75bdff57fcecff68f2927092b15074a4524a8d8182b6de761f6af3\+ b8c6ba8f"+ sequence: 0+ pkscript: 76a914dd1eee80cebba451dc26f6423881b8836fe86a8388ac+ value: 8985694+ address: bitcoincash:qrw3am5qe6a6g5wuymmyywyphzpkl6r2svhy9jy2kz+ witness: []+ outputs:+ - address: bitcoincash:qrnltz5afhaxlh6grupll8nfdswpatwnvcsyucwcle+ pkscript: 76a914e7f58a9d4dfa6fdf481f03ff9e696c1c1eadd36688ac+ value: 1230+ spent: false+ spender: null+ - address: bitcoincash:qrw3am5qe6a6g5wuymmyywyphzpkl6r2svhy9jy2kz+ pkscript: 76a914dd1eee80cebba451dc26f6423881b8836fe86a8388ac+ value: 8984244+ spent: true+ spender:+ txid: 4670c8b46211c06aa1a1c42e8c7e090e971c2549d30aac65cdb12876782c2248+ input: 0+ block:+ height: 688804+ position: 13+ deleted: false+ time: 1621593351+ rbf: false+ weight: 876+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Invalid argument.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ post:+ tags:+ - Transaction+ summary: Broadcast Transaction+ description: |+ Send a signed transaction to the network, and get its id back.++ The body is the transaction in hexadecimal (white space is ignored) or as+ raw bytes. It must be in the format that nodes of the network read: on BCH,+ which has no SegWit, the format without witness data.++ **A 200 means the transaction was sent, not that the network accepted it.**+ The server does not validate transactions; it gives the transaction to half+ of its peers, which check it as they would any other. If they accept it, they+ relay it back, and it appears in the mempool and in balances within seconds.+ To know whether it was accepted, look for it with `/transaction/{txid}` or+ watch `/events`.++ Five seconds later the server asks those peers for the transaction back. A+ peer that accepted it sends it, and the server imports it like any other+ transaction it hears about; a peer that refused it has nothing to send.+ operationId: postTransaction+ parameters:+ - $ref: '#/components/parameters/prettyParam'+ requestBody:+ description: >-+ Publish Transaction. The body holds one transaction and nothing+ else. Bytes after the transaction are refused. The transaction is+ in the format that peers of the network read. A network without+ SegWit, such as Bitcoin Cash, takes the format without witness+ only.+ content:+ text/plain:+ schema:+ type: string+ description: Hex-encoded Bitcoin protocol transaction.+ example: "0200000001bd7d375bfc0a4ac4246ccefba4df1c635e5627b607bdc4af22282e\+ cd5869d8f9010000006441f92d192c9e33b8184c58b88eb11f57a1eb5b5a2b0e\+ 72b326864021d9e946cd2a5275efcf385fbc0d9e7c8a76f9189d624efc00ff74\+ 657a52e47efc6ffa0bfe3b412102a20b2f9bd8b475252ad054fdedb49045bd40\+ c8926b65f64e312dd821b243a37c000000000211180000000000001976a91434\+ 04e444809abd438d85eb18e8d07c796e364afe88ac399b7c00000000001976a9\+ 1486a7f1a2047ed6719fa1ad5484f15c37c104887c88ac00000000"+ application/octet-stream:+ schema:+ type: string+ format: binary+ description: Bitcoin protocol transaction.+ responses:+ '200':+ description: Transaction accepted by network peer.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/TxId'+ example:+ txid: 0b7cb07d848959fbd02ad6e2ed21fac888b8e8011c6b8c5362d05c703066ae67+ application/octet-stream:+ schema:+ type: string+ format: binary+ '400':+ description: Transaction rejected.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'+ '500':+ description: |+ Publishing transaction failed for one of these reasons:++ * No network peers available.+ * Timeout while publishing, most likely silent rejection.+ * Network peer disconnected while publishing transaction.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/transactions/block/{block}':+ get:+ tags:+ - Transaction+ - Block+ operationId: getBlockTransactions+ summary: Transactions for Block+ description: |+ The full transactions of a block, in the order of the block.+ parameters:+ - $ref: '#/components/parameters/blockParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: Detailed transactions from specified Block.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/Transaction'+ example:+ - txid: 8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87+ size: 135+ version: 1+ locktime: 0+ fee: 0+ inputs:+ - coinbase: true+ txid: "0000000000000000000000000000000000000000000000000000000000000000"+ output: 4294967295+ sigscript: 044c86041b020602+ sequence: 4294967295+ pkscript: null+ value: null+ address: null+ witness: []+ outputs:+ - address: null+ pkscript: "41041b0e8c2567c12536aa13357b79a073dc4444acb83c4ec7a0e2f99dd74575\+ 16c5817242da796924ca4e99947d087fedf9ce467cb9f7c6287078f801df276f\+ df84ac"+ value: 5000000000+ spent: true+ spender:+ txid: f3e6066078e815bb24db0dfbff814f738943bddaaa76f8beba360cfe2882480a+ input: 12+ block:+ height: 100000+ position: 0+ deleted: false+ time: 1293623863+ rbf: false+ weight: 540+ - txid: fff2525b8931402dd09222c50775608f75787bd2b87e56995a7bdd30f79702c4+ size: 259+ version: 1+ locktime: 0+ fee: 0+ inputs:+ - coinbase: false+ txid: 87a157f3fd88ac7907c05fc55e271dc4acdc5605d187d646604ca8c0e9382e03+ output: 0+ sigscript: "493046022100c352d3dd993a981beba4a63ad15c209275ca9470abfcd57da93b\+ 58e4eb5dce82022100840792bc1f456062819f15d33ee7055cf7b5ee1af1ebcc\+ 6028d9cdb1c3af7748014104f46db5e9d61a9dc27b8d64ad23e7383a4e6ca164\+ 593c2527c038c0857eb67ee8e825dca65046b82c9331586c82e0fd1f633f25f8\+ 7c161bc6f8a630121df2b3d3"+ sequence: 4294967295+ pkscript: 76a91471d7dd96d9edda09180fe9d57a477b5acc9cad1188ac+ value: 5000000000+ address: bitcoincash:qpca0hvkm8ka5zgcpl5a27j80ddve89dzy4ya2rc9q+ witness: []+ outputs:+ - address: bitcoincash:qrpe3mafcwft5cqnchsyaeefw4000avtxgzc3zhzg2+ pkscript: 76a914c398efa9c392ba6013c5e04ee729755ef7f58b3288ac+ value: 556000000+ spent: true+ spender:+ txid: 5aa8e36f9423ee5fcf17c1d0d45d6988b8a5773eae8ad25d945bf34352040009+ input: 6+ - address: bitcoincash:qz2gcaj6dy2dg0e20tqh0k3v9a449h3a0smpvqhnkh+ pkscript: 76a914948c765a6914d43f2a7ac177da2c2f6b52de3d7c88ac+ value: 4444000000+ spent: true+ spender:+ txid: 220ebc64e21abece964927322cba69180ed853bb187fbc6923bac7d010b9d87a+ input: 0+ block:+ height: 100000+ position: 1+ deleted: false+ time: 1293623863+ rbf: false+ weight: 1036+ - txid: 6359f0868171b1d194cbee1af2f16ea598ae8fad666d9b012c8ed2b79a236ec4+ size: 257+ version: 1+ locktime: 0+ fee: 0+ inputs:+ - coinbase: false+ txid: cf4e2978d0611ce46592e02d7e7daf8627a316ab69759a9f3df109a7f2bf3ec3+ output: 1+ sigscript: "4730440220032d30df5ee6f57fa46cddb5eb8d0d9fe8de6b342d27942ae90a32\+ 31e0ba333e02203deee8060fdc70230a7f5b4ad7d7bc3e628cbe219a886b8426\+ 9eaeb81e26b4fe014104ae31c31bf91278d99b8377a35bbce5b27d9fff154568\+ 39e919453fc7b3f721f0ba403ff96c9deeb680e5fd341c0fc3a7b90da4631ee3\+ 9560639db462e9cb850f"+ sequence: 4294967295+ pkscript: 76a91435fbee6a3bf8d99f17724ec54787567393a8a6b188ac+ value: 300000000+ address: bitcoincash:qq6lhmn280udn8chwf8v23u82eee829xkyfzh8lz80+ witness: []+ outputs:+ - address: bitcoincash:qzcde0uha2l5gp8rrk2jga7wsgk6m0n7zq6q4n0ad0+ pkscript: 76a914b0dcbf97eabf4404e31d952477ce822dadbe7e1088ac+ value: 1000000+ spent: true+ spender:+ txid: ae1933ba2b7dc9702c96b99fb00f1df1392d9dea998a24b88d61974e8da8d2ab+ input: 0+ - address: bitcoincash:qp439q0wcfdtfc0q0yllfcy2kx4mxsyumyaya6lvur+ pkscript: 76a9146b1281eec25ab4e1e0793ff4e08ab1abb3409cd988ac+ value: 299000000+ spent: true+ spender:+ txid: 20ed3fc9688a13fceeae5fdd606266c351b1f15b90404d9df387644e4c3d5b90+ input: 3+ block:+ height: 100000+ position: 2+ deleted: false+ time: 1293623863+ rbf: false+ weight: 1028+ - txid: e9a66845e05d5abc0ad04ec80f774a7e585c6e8db975962d069a522137b80c1d+ size: 225+ version: 1+ locktime: 0+ fee: 0+ inputs:+ - coinbase: false+ txid: f4515fed3dc4a19b90a317b9840c243bac26114cf637522373a7d486b372600b+ output: 0+ sigscript: "493046022100bb1ad26df930a51cce110cf44f7a48c3c561fd977500b1ae5d6b\+ 6fd13d0b3f4a022100c5b42951acedff14abba2736fd574bdb465f3e6f8da12e\+ 2c5303954aca7f78f3014104a7135bfe824c97ecc01ec7d7e336185c81e2aa2c\+ 41ab175407c09484ce9694b44953fcb751206564a9c24dd094d42fdbfdd5aad3\+ e063ce6af4cfaaea4ea14fbb"+ sequence: 4294967295+ pkscript: 76a914c4eb47ecfdcf609a1848ee79acc2fa49d3caad7088ac+ value: 1000000+ address: bitcoincash:qrzwk3lvlh8kpxscfrh8ntxzlfya8j4dwqtfzxkdk7+ witness: []+ outputs:+ - address: bitcoincash:qqu6502kncr2r4ujdhztuyvnexdl96u7uqczwuuqu6+ pkscript: 76a91439aa3d569e06a1d7926dc4be1193c99bf2eb9ee088ac+ value: 1000000+ spent: true+ spender:+ txid: 1ebf2dd645b815a59e35485dd4ceebc0587a1b7588b810e42085b479ce507bfa+ input: 1+ block:+ height: 100000+ position: 3+ deleted: false+ time: 1293623863+ rbf: false+ weight: 900+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Block not found.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/transactions/block/{block}/raw':+ get:+ tags:+ - Transaction+ - Block+ operationId: getRawBlockTransactions+ summary: Raw Block Transactions+ description: |+ The transactions of a block, serialized as in the peer-to-peer protocol, in+ the order of the block.+ parameters:+ - $ref: '#/components/parameters/blockParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: >-+ Bitcoin protocol transactions.+ Hex strings if using JSON.+ content:+ application/json:+ schema:+ type: array+ items:+ type: string+ example:+ - "0100000001000000000000000000000000000000000000000000000000000000\+ 0000000000ffffffff08044c86041b020602ffffffff0100f2052a0100000043\+ 41041b0e8c2567c12536aa13357b79a073dc4444acb83c4ec7a0e2f99dd74575\+ 16c5817242da796924ca4e99947d087fedf9ce467cb9f7c6287078f801df276f\+ df84ac00000000"+ - "0100000001032e38e9c0a84c6046d687d10556dcacc41d275ec55fc00779ac88\+ fdf357a187000000008c493046022100c352d3dd993a981beba4a63ad15c2092\+ 75ca9470abfcd57da93b58e4eb5dce82022100840792bc1f456062819f15d33e\+ e7055cf7b5ee1af1ebcc6028d9cdb1c3af7748014104f46db5e9d61a9dc27b8d\+ 64ad23e7383a4e6ca164593c2527c038c0857eb67ee8e825dca65046b82c9331\+ 586c82e0fd1f633f25f87c161bc6f8a630121df2b3d3ffffffff0200e3232100\+ 0000001976a914c398efa9c392ba6013c5e04ee729755ef7f58b3288ac000fe2\+ 08010000001976a914948c765a6914d43f2a7ac177da2c2f6b52de3d7c88ac00\+ 000000"+ - "0100000001c33ebff2a709f13d9f9a7569ab16a32786af7d7e2de09265e41c61\+ d078294ecf010000008a4730440220032d30df5ee6f57fa46cddb5eb8d0d9fe8\+ de6b342d27942ae90a3231e0ba333e02203deee8060fdc70230a7f5b4ad7d7bc\+ 3e628cbe219a886b84269eaeb81e26b4fe014104ae31c31bf91278d99b8377a3\+ 5bbce5b27d9fff15456839e919453fc7b3f721f0ba403ff96c9deeb680e5fd34\+ 1c0fc3a7b90da4631ee39560639db462e9cb850fffffffff0240420f00000000\+ 001976a914b0dcbf97eabf4404e31d952477ce822dadbe7e1088acc060d21100\+ 0000001976a9146b1281eec25ab4e1e0793ff4e08ab1abb3409cd988ac000000\+ 00"+ - "01000000010b6072b386d4a773235237f64c1126ac3b240c84b917a3909ba1c4\+ 3ded5f51f4000000008c493046022100bb1ad26df930a51cce110cf44f7a48c3\+ c561fd977500b1ae5d6b6fd13d0b3f4a022100c5b42951acedff14abba2736fd\+ 574bdb465f3e6f8da12e2c5303954aca7f78f3014104a7135bfe824c97ecc01e\+ c7d7e336185c81e2aa2c41ab175407c09484ce9694b44953fcb751206564a9c2\+ 4dd094d42fdbfdd5aad3e063ce6af4cfaaea4ea14fbbffffffff0140420f0000\+ 0000001976a91439aa3d569e06a1d7926dc4be1193c99bf2eb9ee088ac000000\+ 00"+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Block not found.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/transactions/raw':+ get:+ tags:+ - Transaction+ operationId: getRawTransactions+ summary: Raw Transactions+ description: |+ Several transactions by id, serialized as in the peer-to-peer protocol. Ids+ that are not known are left out.+ parameters:+ - $ref: '#/components/parameters/txidsParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: >+ Bitcoin protocol transactions.+ Hex strings if using JSON.+ content:+ application/json:+ schema:+ type: array+ items:+ type: string+ example:+ - "02000000018f41695a0a550da609efd0d2810dc5ec9f722d4bbb2b2815f2b160\+ ffdf64ea02010000006441e3dc2001cffff05e62751c071ac05dee8511b9af62\+ 6fe5b07bf271ce48df66cf4013d4b4b280bb74eac6206d772aa24f1e766bc813\+ dc68a461736392ce97f8a04121028d75bdff57fcecff68f2927092b15074a452\+ 4a8d8182b6de761f6af3b8c6ba8f0000000002ce040000000000001976a914e7\+ f58a9d4dfa6fdf481f03ff9e696c1c1eadd36688acb4168900000000001976a9\+ 14dd1eee80cebba451dc26f6423881b8836fe86a8388ac00000000"+ - "0200000001a65bd9dd41ea0baf7b41b02c25e59e1ef6838b666e47173a74dcf2\+ 18919f31a7010000006441f1489e83baad016eb63e3ebfe6bda264652fa18887\+ 7e2efed9261566265cb31cff5091878f6a965b7184d538d8fe285b1ba4e3f18f\+ 53f44a30de3a36635750c34121028d75bdff57fcecff68f2927092b15074a452\+ 4a8d8182b6de761f6af3b8c6ba8f0000000002cf0400000000000017a914d1b0\+ b139593754efcaab5ad20d13dd3be82e5bfe875e1c8900000000001976a914dd\+ 1eee80cebba451dc26f6423881b8836fe86a8388ac00000000"+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Invalid argument.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/mempool':+ get:+ tags:+ - Transaction+ operationId: getMempool+ summary: Mempool Transaction Hashes+ description: |+ The ids of the unconfirmed transactions, newest first, by the time this+ server first saw them.++ There is no maximum `limit` here: the mempool is bounded by the network, and+ ids are small.+ parameters:+ - $ref: '#/components/parameters/limitParam'+ - $ref: '#/components/parameters/offsetParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: List of transaction hashes from mempool.+ content:+ application/json:+ schema:+ type: array+ items:+ type: string+ example:+ - a60e3b0775e08877d0a8edac874090034f2fa55a15fb339b98b18abb5c8cbdf3+ - c9c50b1f75b4a69319c51ffae3a47df7de99b02089e696b711244fd4591eb51e+ - 11acbc09285f99d2cc11b707b61952258d554dcffe9a91ab0f6a22d1b06ac618+ application/octet-stream:+ schema:+ type: string+ format: binary++ '/events':+ get:+ tags:+ - Block+ - Transaction+ operationId: getEventStream+ parameters:+ - $ref: '#/components/parameters/prettyParam'+ - name: after+ in: query+ required: false+ description: |+ Hash of the last event heard, a block or a transaction: the stream+ starts with what came after it.+ schema:+ type: string+ example: 3e225aa5d41b0f7560ee641f86b00728adc626102b348f9d319e0fdfd848a0db+ - name: quiet+ in: query+ required: false+ description: |+ `true` to hear of blocks alone, until something is watched.+ schema:+ type: boolean+ default: false+ - name: addresses+ in: query+ required: false+ description: Addresses to watch from the start, separated by commas.+ schema:+ type: string+ - name: xpub+ in: query+ required: false+ description: Extended public key to watch from the start.+ schema:+ type: string+ - name: derive+ in: query+ required: false+ description: |+ Types of the addresses of `xpub`: `legacy`, `compat` or `segwit`,+ one or several separated by commas. `legacy` when left out.+ schema:+ type: string+ example: legacy,segwit+ - name: filter+ in: query+ required: false+ description: |+ Bloom filter to start with, in hex, with `functions`, and `tweak`+ and `flags` when they are not 0.+ schema:+ type: string+ - name: functions+ in: query+ required: false+ description: Number of hash functions of `filter`.+ schema:+ type: integer+ - name: tweak+ in: query+ required: false+ description: Tweak of `filter`.+ schema:+ type: integer+ - name: flags+ in: query+ required: false+ description: Flags of `filter`.+ schema:+ type: integer+ summary: Stream Blocks and Transactions+ description: |+ A stream of what happens on the network: one event per new block and per+ transaction that enters or leaves the mempool, as one line of JSON each.++ ```+ {"type":"tx","id":"9a5c…"}+ {"type":"block","id":"0000000000000000000a1b…"}+ ```++ An event names what changed and nothing else, which keeps the stream+ small; fetch the block or transaction if you need it. A transaction+ that a block confirms has no event of its own: the block is the event.++ ## Taking up where you left++ The server keeps its last 10,000 events, fewer when their transactions+ are very large. With `after` the stream starts after the event with+ that hash, so a client that reconnects misses nothing. If the server+ does not have that event the answer is 404: read again what you+ follow, and connect without `after`. A client that falls further+ behind than the server keeps is disconnected.++ A transaction that entered the mempool and left it unconfirmed has two+ events with the same hash. The stream starts after the first of them,+ so an event may come twice, and none is left out.++ In a fleet every server keeps the same events, also from before it+ started: come back to any of them.++ ## WebSocket++ The same events are sent over a **WebSocket** at the same path, one+ message each, and `after` works the same. The server pings every 30+ seconds and drops clients that do not answer within 60.++ Messages are JSON in text frames or binary in binary frames ("Binary+ format" above). The server answers in the kind of frame that the+ client last sent; before the client has sent any, in binary if the+ `Accept` header of the handshake asked for it, else in JSON.++ ## Choosing transactions++ Over the WebSocket a client can say which transactions it wants to+ hear about, with these messages. Blocks are always sent. A client that+ sends none of them hears of every transaction.++ | Message | Does |+ | --- | --- |+ | `{"type":"watch","addresses":["…"]}` | Watch these addresses too. |+ | `{"type":"unwatch","addresses":["…"]}` | Stop watching them. |+ | `{"type":"watch","xpub":"…","derive":"segwit"}` | Watch a key too. |+ | `{"type":"unwatch","xpub":"…","derive":"segwit"}` | Stop watching it. |+ | `{"type":"filterload","filter":"hex","functions":11,"tweak":0,"flags":0}` | Set the Bloom filter. |+ | `{"type":"filteradd","data":"hex"}` | Add an element to the filter. |+ | `{"type":"filterclear"}` | Remove the filter. |+ | `{"type":"all"}` | Forget all of it: every transaction again. |+ | `{"type":"none"}` | Forget all of it: blocks alone. |++ A transaction is sent when it pays to or spends from a watched address+ or an address of a watched key, or when it matches the filter.++ ## Choosing from the start++ A client that connects and then sends its messages is sent every+ transaction until the first of them is read. To avoid that, say in the+ URL what to start with, on the WebSocket or on the HTTP stream:++ ```+ /events?quiet=true+ /events?addresses=bc1q…,1A1z…+ /events?xpub=xpub6C…&derive=legacy,segwit+ /events?filter=b50f8a…&functions=11+ ```++ `quiet=true` starts with blocks alone; send messages afterwards to+ watch more. The others watch what they name, and can be combined. A+ list of addresses too long for a URL goes in messages after+ `quiet=true`, or, for the HTTP stream, in the form of a `POST`.++ - **Keys.** `derive` is `legacy`, `compat` or `segwit`, as on the+ `/xpub` endpoints, and `legacy` when left out. To watch one key under+ several types of address, send a message for each. The server+ watches the addresses in use and the gap of unused ones after them,+ and moves the gap on as payments come. At most 100 keys.+ - **Addresses.** At most 100,000.+ - **Bloom filter.** The filter of BIP 37, matched as a node matches it+ for an SPV client: against the hash of the transaction, the data in+ its output scripts, the outputs it spends and the data in its input+ scripts. One filter to a connection. The server never changes it:+ `flags` is accepted, but the outputs of matching transactions are+ not added, so add what you want to follow with `filteradd`. An empty+ filter matches everything.+ - **No answers.** As between a node and an SPV client, nothing is sent+ back. A message that the server cannot read or will not do closes+ the connection with code 1008 and the reason.+ - When the server does not know the transaction of an event, which+ happens when one leaves the mempool long after it entered, the event+ goes to every client.+ responses:+ '200':+ description: Events stream each on one line.+ content:+ application/x-json-stream:+ schema:+ $ref: '#/components/schemas/Event'+ example:+ type: tx+ id: 3e225aa5d41b0f7560ee641f86b00728adc626102b348f9d319e0fdfd848a0db+ application/octet-stream:+ schema:+ type: string+ format: binary+ '400':+ description: A parameter could not be read, or names too much to watch.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'+ '404':+ description: The server has no event with the hash given in `after`.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/peers':+ get:+ tags:+ - Network+ operationId: getPeers+ parameters:+ - $ref: '#/components/parameters/prettyParam'+ summary: Connected Network Peers+ description: |+ The nodes of the peer-to-peer network that this server is connected to. It+ is where blocks and transactions come from, and where broadcast transactions+ go.+ responses:+ '200':+ description: List of peer objects.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/Peer'+ example:+ - useragent: /BCH Unlimited:1.9.2(EB32; AD12; 64bit)/+ address: 162.250.189.42:8333+ version: 80003+ services: "0000000000000875"+ relay: true+ - useragent: /BCH Unlimited:1.9.2(EB32; AD12; 64bit)/+ address: 154.57.12.149:8331+ version: 80003+ services: "0000000000000875"+ relay: true+ - useragent: /Bitcoin Cash Node:23.0.0(EB32.0)/+ address: 206.189.127.50:8333+ version: 70015+ services: "0000000000000425"+ relay: true+ application/octet-stream:+ schema:+ type: string+ format: binary++ '/xpub/{xpub}':+ get:+ tags:+ - Address+ operationId: getXPubSummary+ summary: Extended Public Key Summary+ description: |+ The summary of a wallet: its balance, and the index of the last used address+ of the receiving and of the change chain, so the wallet knows where to derive+ its next address.++ It is the cheapest way to check a wallet: it does not list transactions or+ outputs. See *Extended public keys* at the top for how the addresses are+ found.+ parameters:+ - $ref: '#/components/parameters/xpubParam'+ - $ref: '#/components/parameters/xpubDerive'+ - $ref: '#/components/parameters/nocacheParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: Information about extended public key.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/XPubSummary'+ example:+ balance:+ confirmed: 84994+ unconfirmed: 0+ received: 137003577+ utxo: 3+ indices:+ change: 27+ external: 73+ application/octet-stream:+ schema:+ type: string+ format: binary+ '400':+ description: |+ The wallet has no transactions with this `derive`, but has some with+ another kind of address; the message names it. A query parameter+ that does not parse also gives 400.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'+ example:+ error: user-error+ message: >-+ This xpub has no transactions with derive=legacy, but has+ some with derive=segwit; ask with derive=segwit.+ '404':+ description: Invalid extended public key.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'+ delete:+ tags:+ - Address+ operationId: deleteCachedXPub+ summary: Delete Cached Extended Key+ description: |+ Remove a wallet from the Redis cache of this server, if it has one. The+ answer says whether it was cached.++ There is rarely a reason to call this: cached wallets are kept up to date+ with every block and transaction, and the least recently used ones are+ evicted when the cache is full. It is there to free memory, or to rule out+ the cache when looking into a problem.+ parameters:+ - $ref: '#/components/parameters/xpubParam'+ - $ref: '#/components/parameters/xpubDerive'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: True if xpub got deleted from cache.+ content:+ application/json:+ schema:+ type: object+ required:+ - result+ properties:+ result:+ type: boolean+ nullable: false+ example: true+ application/octet-stream:+ schema:+ type: string+ format: binary+ '404':+ description: Invalid argument.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/xpub/{xpub}/transactions/full':+ get:+ tags:+ - Address+ operationId: getXPubTransactionsFull+ summary: Detailed Extended Public Key Transactions+ description: |+ Like `/xpub/{xpub}/transactions`, with the full transactions instead of+ their ids, and the lower maximum of full transactions.+ parameters:+ - $ref: '#/components/parameters/xpubParam'+ - $ref: '#/components/parameters/xpubDerive'+ - $ref: '#/components/parameters/heightParam'+ - $ref: '#/components/parameters/limitParam'+ - $ref: '#/components/parameters/offsetParam'+ - $ref: '#/components/parameters/nocacheParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: List of transaction objects.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/Transaction'+ example:+ - txid: f2ffa044fa3851c88e739ef76f13b960a828b5c1c7a71005a9315969c4924103+ size: 406+ version: 1+ locktime: 0+ fee: 675+ inputs:+ - coinbase: false+ txid: ae3fc465a80264f45fa6f865ede43017d9826560299b6fb344d5cde84416b931+ output: 1+ sigscript: "473044022073b48b298df7ffb4ed5bb54ae939968e82fa966bc92d61728c8f7e\+ 6e6d914e1b022042164959fdbbd3cda2cdaf541a2143be79f4be85144bfef790\+ 6223c8a8cb2b2a4121029c71d9b4b94e8aa48072d1affba3432560d3e7964849\+ 1d4b6351d3ddff51aa8f"+ sequence: 4294967295+ pkscript: 76a91474e607acf5ecdef5f9746ffa280744413a595cdb88ac+ value: 70000+ address: bitcoincash:qp6wvpav7hkdaa0ew3hl52q8g3qn5k2umvmh76m9eh+ witness: []+ - coinbase: false+ txid: 6fc0394cea4f5f82fe8f332118a9079469336be53bc73975e9d58a99fbbc7ca2+ output: 1+ sigscript: "47304402204dc38ccbb6b3f6dcf496b7405bfc3f1942c3834197a32af9e07e6a\+ 1f2d916c9502203a9f536951cb672b76111ec58cca35971c06af2bfaa481bd1a\+ ee67a03428e78341210250dce9058b388bbc4014d3600770b9bb59b7636a2eef\+ 591797d9572014065c94"+ sequence: 4294967295+ pkscript: 76a91473cdab4b3e2486f7cbe618845f106109e5920aa588ac+ value: 546+ address: bitcoincash:qpeum26t8cjgda7tucvgghcsvyy7tys255ej7ux8wg+ witness: []+ outputs:+ - address: bitcoincash:qz3k0wyd62g3njyucgcnpd72zq0r7rpsmumj82gvl4+ pkscript: 76a914a367b88dd29119c89cc23130b7ca101e3f0c30df88ac+ value: 30000+ spent: false+ spender: null+ - address: bitcoincash:qpfvtmtyvfh3m5ul9kyc8gxefdpk2fx0syxxf7f6py+ pkscript: 76a91452c5ed64626f1dd39f2d8983a0d94b436524cf8188ac+ value: 39325+ spent: false+ spender: null+ - address: bitcoincash:qrdf6e456fx6cmp6nwn9vhp4t6es7cuv9cfhvgdg5m+ pkscript: 76a914da9d66b4d24dac6c3a9ba6565c355eb30f638c2e88ac+ value: 546+ spent: true+ spender:+ txid: 8c9f0ab27f96baca66d7a2eee5db8acd6b0b5fc725afbc3c2ea69c3d5b04aa7d+ input: 1+ block:+ height: 680268+ position: 6664+ deleted: false+ time: 1616669866+ rbf: false+ weight: 1624+ - txid: ae3fc465a80264f45fa6f865ede43017d9826560299b6fb344d5cde84416b931+ size: 851+ version: 1+ locktime: 0+ fee: 1998+ inputs:+ - coinbase: false+ txid: 26ce9896f0f64349670735bdc30fb42adadac61348e403e78c5f92c3fb697355+ output: 0+ sigscript: "483045022100b8feba7bfc7a0809c616694237bd5e82f6f3a5fafbe0bad03668\+ 79732fa829bc02203dce83fe508b86a1449c404756c274e11ea47decc4f8802c\+ fd2ab7e92ed7b51b41210388e0b3a8e3aa50414ac8c03ae02ac1c9931d901ad7\+ b6c1b7b83d50554fb592d8"+ sequence: 4294967295+ pkscript: 76a914c58a0c635b60d94498c416d32d4a2c15b36f8a2388ac+ value: 40537+ address: bitcoincash:qrzc5rrrtdsdj3yccstdxt229s2mxmu2yvj7ppp3cl+ witness: []+ - coinbase: false+ txid: 41a422e6c003596954f07eb5021d9a2d6951da297dd519a5de3885772ac60e7c+ output: 1+ sigscript: "483045022100ad61cb3b0f08af0ada0ebb5be16213377fa193c9da16e4dca58a\+ 9350724106330220089ac5eea5193f0926a483668eef219c7add5fa7d2be99a6\+ 3e41da985fc104a241210223dc345218b6feb1815f0f103bc30a8508e3e248c4\+ f9b4bfdfed1d4498f879b4"+ sequence: 4294967295+ pkscript: 76a9145590a6b61c7d22a199692ce9c55cef294acebbeb88ac+ value: 40000+ address: bitcoincash:qp2epf4kr37j9gvedykwn32uau554n4mavzhpyg2ua+ witness: []+ - coinbase: false+ txid: de01f52644b2610d491d28ce474382c57655f102974526a3fba6b5a48f8ea812+ output: 0+ sigscript: "483045022100d29b0dd38462a33eac65eb8df53ba2b2905bac923057f5b37e20\+ 94f1a6072355022017480d0f2eedaa5f3ff2ca620a7b5b9301292524dfcaaa9c\+ 97859ee074f92b6e412102db25e4df201e02e95f167f63e06c55febe81fd0713\+ 3fb09f65b07a356aa645ec"+ sequence: 4294967295+ pkscript: 76a91457725700f16eda53d75608972ff322dd05642a8f88ac+ value: 3000+ address: bitcoincash:qpthy4cq79hd557h2cyfwtlnytws2ep23u6k07qh8v+ witness: []+ - coinbase: false+ txid: de01f52644b2610d491d28ce474382c57655f102974526a3fba6b5a48f8ea812+ output: 1+ sigscript: "4830450221008ee0107bfc71e063ff8d5ab98f83bb63f17bda387d049b2c9e7f\+ f885941a3859022015baabbc9280c70a19a6047f1028f261f5686b8342273316\+ b4e2d703ab72d6264121027b884db5a796d7f96412a2efbf5961ef824323c8b0\+ 64e375c3e0dd512c6a010a"+ sequence: 4294967295+ pkscript: 76a9145f8e761ff252d3349c32e6bc12a5e11ba20aa3f188ac+ value: 4130+ address: bitcoincash:qp0cuasl7ffdxdyuxtntcy49uyd6yz4r7y8u0ss3yd+ witness: []+ - coinbase: false+ txid: b0fddc9d29b86c695af3dd66b1834256c362698532585a597de9c800f04fde06+ output: 401+ sigscript: "473044022034251190cae8ba9197f81756778d5fbf071e63bb7460386fbb36cd\+ 74d47560a9022050a1cfec6076b8e7f23cd6e843b64cb2582476fd4b44fadb38\+ 7803ed9ad4f1e54121037779735684011f62563197944da9f9e074b17813c0c6\+ 2e79aeac7d875f9b8bf2"+ sequence: 4294967295+ pkscript: 76a91475dcf340cbb686b30bdaf6a2c8f8c68dadf93a5a88ac+ value: 546+ address: bitcoincash:qp6aeu6qewmgdvctmtm29j8cc6x6m7f6tgc6ey2mqu+ witness: []+ outputs:+ - address: bitcoincash:qpg4r52nu85ra68aj4uecg0mufzu9hkmj5pxlaxfp2+ pkscript: 76a9145151d153e1e83ee8fd95799c21fbe245c2dedb9588ac+ value: 15669+ spent: false+ spender: null+ - address: bitcoincash:qp6wvpav7hkdaa0ew3hl52q8g3qn5k2umvmh76m9eh+ pkscript: 76a91474e607acf5ecdef5f9746ffa280744413a595cdb88ac+ value: 70000+ spent: true+ spender:+ txid: f2ffa044fa3851c88e739ef76f13b960a828b5c1c7a71005a9315969c4924103+ input: 0+ - address: bitcoincash:qq0kufslm9n5v0t5m5vy9gtnuupvpep0wv57sfl7gj+ pkscript: 76a9141f6e261fd967463d74dd1842a173e702c0e42f7388ac+ value: 546+ spent: true+ spender:+ txid: d84d05a569d944a92bcb9aa63049b9279369cd44e41ef365da13312ce680530f+ input: 1+ block:+ height: 678972+ position: 832+ deleted: false+ time: 1615894044+ rbf: false+ weight: 3404+ - txid: de01f52644b2610d491d28ce474382c57655f102974526a3fba6b5a48f8ea812+ size: 406+ version: 1+ locktime: 0+ fee: 675+ inputs:+ - coinbase: false+ txid: 41a422e6c003596954f07eb5021d9a2d6951da297dd519a5de3885772ac60e7c+ output: 0+ sigscript: "473044022029a10619e7e98a3e7cb17821ab0725ef25b700a9b943965c8762ee\+ 3b4e19546202200be34da0f640cd3209e5f11a3b1824409df92cae6a0efd8449\+ d8e22faaae7865412103b3a86c342fd06c33e94dcbe2565fc3d9d6c4c081caa0\+ f1ec42bb45bbbbaf2e43"+ sequence: 4294967295+ pkscript: 76a9144abb2deefe6ca46c695132adafd70f72d50cab2688ac+ value: 7805+ address: bitcoincash:qp9tkt0wlek2gmrf2ye2mt7hpaed2r9tycna42c5ax+ witness: []+ - coinbase: false+ txid: 9a396103f3656b4e119f166610d7e41e24c01a5c45200a8fcb58d3b79fb0dcaf+ output: 315+ sigscript: "4730440220271872d09da2cc13b764b5bf13cc373da53d89421e0d31ac33a734\+ 20c5e6a03902206e01bf837db72fa4340428e3d061685cc49ec156a65e4b48ba\+ bcfd5d6c89018f4121033da5e50b4615d790b9b755cf5db213b0d08d489587d8\+ 946a7dfd1b8dc76a1829"+ sequence: 4294967295+ pkscript: 76a914485103c1b046fe0f3ca0ed9ced338b53437c0c8288ac+ value: 546+ address: bitcoincash:qpy9zq7pkpr0ureu5rkeemfn3df5xlqvsgf66pqne5+ witness: []+ outputs:+ - address: bitcoincash:qpthy4cq79hd557h2cyfwtlnytws2ep23u6k07qh8v+ pkscript: 76a91457725700f16eda53d75608972ff322dd05642a8f88ac+ value: 3000+ spent: true+ spender:+ txid: ae3fc465a80264f45fa6f865ede43017d9826560299b6fb344d5cde84416b931+ input: 2+ - address: bitcoincash:qp0cuasl7ffdxdyuxtntcy49uyd6yz4r7y8u0ss3yd+ pkscript: 76a9145f8e761ff252d3349c32e6bc12a5e11ba20aa3f188ac+ value: 4130+ spent: true+ spender:+ txid: ae3fc465a80264f45fa6f865ede43017d9826560299b6fb344d5cde84416b931+ input: 3+ - address: bitcoincash:qrcdtjvwn9v2r94y6flka67tvqt99a9cjq3fk9k8ws+ pkscript: 76a914f0d5c98e9958a196a4d27f6eebcb601652f4b89088ac+ value: 546+ spent: true+ spender:+ txid: a93609dbae718b5f6c9ab69cf3f8d979636f09a411f057db8fa65b17556de601+ input: 1+ block:+ height: 678151+ position: 657+ deleted: false+ time: 1615384806+ rbf: false+ weight: 1624++ application/octet-stream:+ schema:+ type: string+ format: binary+ '400':+ description: |+ The wallet has no transactions with this `derive`, but has some with+ another kind of address; the message names it. A query parameter+ that does not parse also gives 400.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'+ example:+ error: user-error+ message: >-+ This xpub has no transactions with derive=legacy, but has+ some with derive=segwit; ask with derive=segwit.+ '404':+ description: Invalid extended public key.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/xpub/{xpub}/transactions':+ get:+ tags:+ - Address+ operationId: getXPubTransactions+ summary: Extended Public Key Transaction List+ description: |+ The transactions of every address of a wallet, merged into one list, newest+ first, as transaction ids with their place in the chain. A transaction+ between two addresses of the wallet appears once.+ parameters:+ - $ref: '#/components/parameters/xpubParam'+ - $ref: '#/components/parameters/xpubDerive'+ - $ref: '#/components/parameters/heightParam'+ - $ref: '#/components/parameters/limitParam'+ - $ref: '#/components/parameters/offsetParam'+ - $ref: '#/components/parameters/nocacheParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: List of transactions.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/TxRef'+ example:+ - txid: f2ffa044fa3851c88e739ef76f13b960a828b5c1c7a71005a9315969c4924103+ block:+ height: 680268+ position: 6664+ - txid: ae3fc465a80264f45fa6f865ede43017d9826560299b6fb344d5cde84416b931+ block:+ height: 678972+ position: 832+ - txid: de01f52644b2610d491d28ce474382c57655f102974526a3fba6b5a48f8ea812+ block:+ height: 678151+ position: 657+ application/octet-stream:+ schema:+ type: string+ format: binary+ '400':+ description: |+ The wallet has no transactions with this `derive`, but has some with+ another kind of address; the message names it. A query parameter+ that does not parse also gives 400.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'+ example:+ error: user-error+ message: >-+ This xpub has no transactions with derive=legacy, but has+ some with derive=segwit; ask with derive=segwit.+ '404':+ description: Invalid extended public key.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/xpub/{xpub}/balances':+ get:+ tags:+ - Address+ operationId: getXPubBalances+ summary: Extended Public Key Balances+ description: |+ The balance of every address of a wallet that the server looked at, with its+ derivation path: each used address, and the unused ones of the gap, with+ zeros, so a wallet can see which addresses it may still hand out. For the+ total, use `/xpub/{xpub}`.+ parameters:+ - $ref: '#/components/parameters/xpubParam'+ - $ref: '#/components/parameters/xpubDerive'+ - $ref: '#/components/parameters/nocacheParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: Balances only for used addresses.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/XPubBalance'+ example:+ - path:+ - 0+ - 0+ balance:+ address: bitcoincash:qpyq63tvlk0pca8mah6a8c0zxrvq8qvjzymrrnwprn+ confirmed: 0+ unconfirmed: 0+ utxo: 0+ txs: 8+ received: 4530000+ - path:+ - 0+ - 1+ balance:+ address: bitcoincash:qqjfkpxln3ya6dq7l27ffdxcmq2h9hlfjvr70vk9l7+ confirmed: 0+ unconfirmed: 0+ utxo: 0+ txs: 2+ received: 10000+ - path:+ - 0+ - 2+ balance:+ address: bitcoincash:qzxfymuk5t4veu0kxf7t0cmkn76t69skuggxaq2q8d+ confirmed: 0+ unconfirmed: 0+ utxo: 0+ txs: 2+ received: 23000+ application/octet-stream:+ schema:+ type: string+ format: binary+ '400':+ description: |+ The wallet has no transactions with this `derive`, but has some with+ another kind of address; the message names it. A query parameter+ that does not parse also gives 400.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'+ example:+ error: user-error+ message: >-+ This xpub has no transactions with derive=legacy, but has+ some with derive=segwit; ask with derive=segwit.+ '404':+ description: Invalid extended public key.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/xpub/{xpub}/unspent':+ get:+ tags:+ - Address+ operationId: getXPubUnspent+ summary: Extended Public Key Unspent Outputs+ description: |+ The unspent outputs of a wallet, newest first, each with the derivation path+ of its address, so the wallet knows which key signs for it.+ parameters:+ - $ref: '#/components/parameters/xpubParam'+ - $ref: '#/components/parameters/xpubDerive'+ - $ref: '#/components/parameters/heightParam'+ - $ref: '#/components/parameters/limitParam'+ - $ref: '#/components/parameters/offsetParam'+ - $ref: '#/components/parameters/nocacheParam'+ - $ref: '#/components/parameters/prettyParam'+ responses:+ '200':+ description: List of unspent output objects.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/XPubUnspent'+ example:+ - unspent:+ address: bitcoincash:qz3k0wyd62g3njyucgcnpd72zq0r7rpsmumj82gvl4+ block:+ height: 680268+ position: 6664+ txid: f2ffa044fa3851c88e739ef76f13b960a828b5c1c7a71005a9315969c4924103+ index: 0+ pkscript: 76a914a367b88dd29119c89cc23130b7ca101e3f0c30df88ac+ value: 30000+ path:+ - 0+ - 73+ - unspent:+ address: bitcoincash:qpfvtmtyvfh3m5ul9kyc8gxefdpk2fx0syxxf7f6py+ block:+ height: 680268+ position: 6664+ txid: f2ffa044fa3851c88e739ef76f13b960a828b5c1c7a71005a9315969c4924103+ index: 1+ pkscript: 76a91452c5ed64626f1dd39f2d8983a0d94b436524cf8188ac+ value: 39325+ path:+ - 1+ - 27+ - unspent:+ address: bitcoincash:qpg4r52nu85ra68aj4uecg0mufzu9hkmj5pxlaxfp2+ block:+ height: 678972+ position: 832+ txid: ae3fc465a80264f45fa6f865ede43017d9826560299b6fb344d5cde84416b931+ index: 0+ pkscript: 76a9145151d153e1e83ee8fd95799c21fbe245c2dedb9588ac+ value: 15669+ path:+ - 1+ - 26+ application/octet-stream:+ schema:+ type: string+ format: binary+ '400':+ description: |+ The wallet has no transactions with this `derive`, but has some with+ another kind of address; the message names it. A query parameter+ that does not parse also gives 400.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'+ example:+ error: user-error+ message: >-+ This xpub has no transactions with derive=legacy, but has+ some with derive=segwit; ask with derive=segwit.+ '404':+ description: Invalid extended public key.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/blockchain/balance':+ get:+ tags:+ - Blockchain+ operationId: getBinfoBalance+ summary: Address or XPub Balances+ description: |+ Short balances of addresses and wallets, keyed by the address or xpub as it+ was given, as Blockchain.info answers.+ parameters:+ - $ref: '#/components/parameters/binfoActive'+ - $ref: '#/components/parameters/binfoActiveP2SH'+ - $ref: '#/components/parameters/binfoActiveBech32'+ - $ref: '#/components/parameters/binfoCashAddr'+ - $ref: '#/components/parameters/binfoJsonFormat'+ responses:+ '200':+ description: Balance object map.+ content:+ application/json:+ schema:+ type: object+ additionalProperties:+ $ref: '#/components/schemas/BinfoShortBalance'+ example:+ ? "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"+ :+ final_balance: 84994+ n_tx: 123+ total_received: 137003577+ '404':+ description: Invalid argument.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'+ post:+ tags:+ - Blockchain+ operationId: postBinfoBalance+ summary: Address or XPub Balances+ description: |+ Like the `GET`, with the parameters in a form in the body, for requests with+ too many addresses to fit in a URL.+ requestBody:+ content:+ application/x-www-form-urlencoded:+ schema:+ $ref: '#/components/schemas/BinfoBalanceReq'+ responses:+ '200':+ description: Balance object map.+ content:+ application/json:+ schema:+ type: object+ additionalProperties:+ $ref: '#/components/schemas/BinfoShortBalance'+ example:+ ? "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"+ :+ final_balance: 84994+ n_tx: 123+ total_received: 137003577+ '404':+ description: Invalid argument.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/blockchain/rawaddr/{address}':+ get:+ tags:+ - Blockchain+ operationId: getBinfoRawAddr+ summary: Address Balance & Transactions+ description: |+ The balance and transactions of an address or an xpub, as Blockchain.info+ answers. Also served at `/blockchain/address/{address}` and+ `/blockchain/xpub/{address}`, for clients that use those paths.+ parameters:+ - $ref: '#/components/parameters/binfoAddr'+ - $ref: '#/components/parameters/binfoLimit'+ - $ref: '#/components/parameters/binfoOffset'+ - $ref: '#/components/parameters/xpubDerive'+ - $ref: '#/components/parameters/binfoTxIdIndex'+ - $ref: '#/components/parameters/binfoJsonFormat'+ responses:+ '200':+ description: Address balance & transactions.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/BinfoRawAddr'+ example:+ hash160: 37d070b3c6716a6174080722579fbdddad39bfb3+ address: bitcoincash:qqmaqu9nceck5ct5pqrjy4ulhhw66wdlkv2s7f2l9u+ n_tx: 2+ n_unredeemed: 0+ total_received: 2275183814+ total_sent: 2275183814+ final_balance: 0+ txs:+ - hash: 00f304a6089a708168cd10d5ad8abd2977c36791156a55b040117cb5a3db7fad+ ver: 2+ vin_sz: 1+ vout_sz: 2+ size: 225+ weight: 900+ fee: 4500+ relayed_by: 0.0.0.0+ lock_time: 688792+ tx_index: 6104469036511106+ double_spend: false+ time: 1621583361+ block_index: 688793+ block_height: 688793+ inputs:+ - sequence: 4294967294+ witness: ""+ script: "47304402206a11f253c8035be2d9f5958a9ca838b3fd5a1a120441885e10e6ca\+ d0fef3911f02202056263939a88fe06e7fc94368ff44c46f71e7daa603936d75\+ 5b35b8b2a21c154121027eb504988cbc043dd4c9fac51685d68718a24d7f9f1d\+ 4c07b9f8c3d04327794c"+ index: 0+ prev_out:+ spent: true+ script: 76a91437d070b3c6716a6174080722579fbdddad39bfb388ac+ spending_outpoints:+ - tx_index: 6104469036511106+ n: 0+ tx_index: 5028695321485056+ value: 2275183814+ addr: bitcoincash:qqmaqu9nceck5ct5pqrjy4ulhhw66wdlkv2s7f2l9u+ n: 1+ type: 0+ out:+ - type: 0+ spent: false+ value: 15197686+ spending_outpoints: []+ n: 0+ tx_index: 6104469036511106+ script: 76a9143fd3cf57704a93e9782a97a4fb5a3bf7fb1a604788ac+ addr: bitcoincash:qqla8n6hwp9f86tc92t6f76680mlkxnqgu5flcqwfe+ - type: 0+ spent: true+ value: 2259981628+ spending_outpoints:+ - tx_index: 8410203892883835+ n: 0+ n: 1+ tx_index: 6104469036511106+ script: 76a9143ea707954f63a225fe4630701df0924c9e835a3488ac+ addr: bitcoincash:qql2wpu4fa36yf07gcc8q80sjfxfaq66xssezyukg2+ result: -2275183814+ balance: 0+ - hash: b810589c80335083ed715c03abf62f23968846d2f54343bac305f890f292ec8e+ ver: 2+ vin_sz: 1+ vout_sz: 2+ size: 225+ weight: 900+ fee: 4500+ relayed_by: 0.0.0.0+ lock_time: 688706+ tx_index: 5028695321485056+ double_spend: false+ time: 1621533785+ block_index: 688707+ block_height: 688707+ inputs:+ - sequence: 4294967294+ witness: ""+ script: "4730440220298e397d155bcd0c3371a17f11d6dc06392c72c6d4646ac27fad72\+ e86e7f18f50220471c7338a0f4573955e52a95c4c2717f5ae5d369ad153ae627\+ 49d0dcd9250482412103c60a0fd16eaf3c72dee4bf12a566ee3a6ae181a29b66\+ a1f2407442f7a6d39ad2"+ index: 0+ prev_out:+ spent: true+ script: 76a9145124c4e68eed7ffc6110c2326d7d3739f55a09eb88ac+ spending_outpoints:+ - tx_index: 5028695321485056+ n: 0+ tx_index: 8915933840879415+ value: 2775188314+ addr: bitcoincash:qpgjf38x3mkhllrpzrprymtaxuul2ksfavle5hmhl4+ n: 0+ type: 0+ out:+ - type: 0+ spent: false+ value: 500000000+ spending_outpoints: []+ n: 0+ tx_index: 5028695321485056+ script: 76a9140cfccdb98e0de78db0deffae8681c60b306e70a088ac+ addr: bitcoincash:qqx0ende3cx70rdsmml6ap5pcc9nqmns5qlc8yvhga+ - type: 0+ spent: true+ value: 2275183814+ spending_outpoints:+ - tx_index: 6104469036511106+ n: 0+ n: 1+ tx_index: 5028695321485056+ script: 76a91437d070b3c6716a6174080722579fbdddad39bfb388ac+ addr: bitcoincash:qqmaqu9nceck5ct5pqrjy4ulhhw66wdlkv2s7f2l9u+ result: 2275183814+ balance: 2275183814++ '400':+ description: |+ The wallet has no transactions with this `derive`, but has some with+ another kind of address; the message names it. A query parameter+ that does not parse also gives 400.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'+ example:+ error: user-error+ message: >-+ This xpub has no transactions with derive=legacy, but has+ some with derive=segwit; ask with derive=segwit.+ '404':+ description: Invalid argument.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/blockchain/unconfirmed-transactions':+ get:+ tags:+ - Blockchain+ operationId: getBinfoMempool+ summary: Mempool Transactions+ description: |+ Unconfirmed transactions, newest first, as Blockchain.info answers.+ parameters:+ - $ref: '#/components/parameters/binfoLimit'+ - $ref: '#/components/parameters/binfoOffset'+ - $ref: '#/components/parameters/binfoTxIdIndex'+ - $ref: '#/components/parameters/binfoJsonFormat'+ responses:+ '200':+ description: Mempool transactions array.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/BinfoTx'+ example:+ txs:+ - hash: 2e77a3d4991383bd9b5fa0e286ba00e5abcf86414edb87581a559957dc674bfa+ ver: 2+ vin_sz: 1+ vout_sz: 2+ size: 225+ weight: 900+ fee: 10000+ relayed_by: 0.0.0.0+ lock_time: 0+ tx_index: 8806456703513386+ double_spend: false+ time: 1621613080+ block_index: null+ block_height: null+ inputs:+ - sequence: 4294967295+ witness: ""+ script: "473044022027aaf94096c0b4c3e0a8405078db67f3c8680b0e1def70c5dac307\+ cd0f4f6c4b022079ce8b9dbf26f9114a9a16575b478214294274d4158672ffaa\+ 758c8904aff91b4121031b3c18ce8efcf9a1ce214cb5c62489e842663b47908b\+ 43f3af789d840af71efb"+ index: 0+ prev_out:+ spent: true+ script: 76a914842b152a0bbd4647afaeceec8a6afaa90668e7c788ac+ spending_outpoints:+ - tx_index: 8806456703513386+ n: 0+ tx_index: 4182415564665005+ value: 267599400+ addr: bitcoincash:qzzzk9f2pw75v3a04m8wezn2l25sv688cua892qu7q+ n: 0+ type: 0+ out:+ - type: 0+ spent: false+ value: 1002000+ spending_outpoints: []+ n: 0+ tx_index: 8806456703513386+ script: 76a914e2102caa6155620b64f363295adc9c21074a26bb88ac+ addr: bitcoincash:qr3pqt92v92kyzmy7d3jjkkunssswj3xhv4zmfpu9p+ - type: 0+ spent: false+ value: 266587400+ spending_outpoints: []+ n: 1+ tx_index: 8806456703513386+ script: 76a914842b152a0bbd4647afaeceec8a6afaa90668e7c788ac+ addr: bitcoincash:qzzzk9f2pw75v3a04m8wezn2l25sv688cua892qu7q+ - hash: 12a015002de59ce4f6d3510df37e56ebde864138acdd35051618603173ca6ee5+ ver: 2+ vin_sz: 1+ vout_sz: 2+ size: 219+ weight: 876+ fee: 220+ relayed_by: 0.0.0.0+ lock_time: 0+ tx_index: 8072448182725635+ double_spend: false+ time: 1621613078+ block_index: null+ block_height: null+ inputs:+ - sequence: 0+ witness: ""+ script: "41c49770d0b68eb9d4552a45d3ce23ab7dc84ecded730f76903b9a4ff948ace2\+ 92ba282b63ea1fa3a4203f79edffc8d00b7ef68be4a42bc530be9e9c9a6a0d2c\+ d4412102ed4717b0f0ae1fd42423d8fa2c9ae36783bc53f7acd8839f68dc5ad5\+ 8a9cf8b2"+ index: 0+ prev_out:+ spent: true+ script: 76a9145638dda8466c4a9c7fc8557d504272b7403f4dc288ac+ spending_outpoints:+ - tx_index: 8072448182725635+ n: 0+ tx_index: 1836562911653034+ value: 7514960+ addr: bitcoincash:qptr3hdggeky48rlep2h65zzw2m5q06dcgnpyymlh5+ n: 1+ type: 0+ out:+ - type: 0+ spent: false+ value: 733+ spending_outpoints: []+ n: 0+ tx_index: 8072448182725635+ script: 76a9145d0bafa91a9d13e1958a8c2c57de70047abd5f6688ac+ addr: bitcoincash:qpwshtafr2w38cv432xzc477wqz8402lvczv7zvppp+ - type: 0+ spent: false+ value: 7514007+ spending_outpoints: []+ n: 1+ tx_index: 8072448182725635+ script: 76a9145638dda8466c4a9c7fc8557d504272b7403f4dc288ac+ addr: bitcoincash:qptr3hdggeky48rlep2h65zzw2m5q06dcgnpyymlh5+++ '/blockchain/multiaddr':+ get:+ tags:+ - Blockchain+ operationId: getBinfoMultiAddr+ summary: Wallet Data+ description: |+ The balances and transactions of a wallet made of addresses and xpubs, as+ Blockchain.info answers, with the current price of the coin in a currency+ of your choice.++ Transactions show the change to the balance of the wallet as a whole, and,+ by default, incoming transactions leave out outputs to other addresses+ (`noCompact` keeps them): a payment from an exchange can have hundreds of+ outputs to other people, which the wallet has no use for.+ parameters:+ - $ref: '#/components/parameters/binfoActive'+ - $ref: '#/components/parameters/binfoActiveP2SH'+ - $ref: '#/components/parameters/binfoActiveBech32'+ - $ref: '#/components/parameters/binfoOnlyShow'+ - $ref: '#/components/parameters/binfoCount'+ - $ref: '#/components/parameters/binfoOffset'+ - $ref: '#/components/parameters/binfoNoCompact'+ - $ref: '#/components/parameters/binfoCurrency'+ - $ref: '#/components/parameters/binfoCashAddr'+ - $ref: '#/components/parameters/binfoTxIdIndex'+ - $ref: '#/components/parameters/binfoFilter'+ - $ref: '#/components/parameters/binfoJsonFormat'+ responses:+ '200':+ description: Wallet information object.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/BinfoMultiAddr'+ '404':+ description: Invalid argument.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'+ post:+ tags:+ - Blockchain+ operationId: postBinfoMultiAddr+ summary: Wallet Data+ description: |+ Like the `GET`, with the parameters in a form in the body, for wallets with+ too many addresses to fit in a URL.+ requestBody:+ content:+ application/x-www-form-urlencoded:+ schema:+ $ref: '#/components/schemas/BinfoMultiAddrReq'+ responses:+ '200':+ description: Wallet information object.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/BinfoMultiAddr'+ example:+ addresses:+ - address: "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"+ change_index: 28+ account_index: 74+ final_balance: 84994+ n_tx: 123+ total_received: 137003577+ total_sent: 136918583+ wallet:+ final_balance: 84994+ n_tx: 2+ n_tx_filtered: 2+ total_received: 137003577+ total_sent: 136918583+ txs:+ - hash: 2c7316f0b66bf4c5a2078cdaacd679d96c2249a32736ea7d3ce36af8b6a215c0+ ver: 1+ vin_sz: 2+ vout_sz: 3+ size: 406+ weight: 1624+ fee: 675+ relayed_by: 0.0.0.0+ lock_time: 0+ tx_index: 2c7316f0b66bf4c5a2078cdaacd679d96c2249a32736ea7d3ce36af8b6a215c0+ double_spend: false+ time: 1613556477+ block_index: 675214+ block_height: 675214+ inputs:+ - sequence: 4294967295+ witness: ""+ script: "47304402200e85d6f05cf9675f60382fda3e245695e12bb478128c701447e8b8\+ 5d0321921d02200a8624652009ce2413718d748194ab020aef2945721c9a29e8\+ 59de034a00291841210266490eabd32e0a0eb4de6089590e6dbf5bd8b5411a3e\+ c4b78cf40091e7200c23"+ index: 0+ prev_out:+ spent: true+ script: 76a91437be7ac52f6f08899051efe89f228c14a171bb2588ac+ xpub:+ path: M/0/66+ m: "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"+ spending_outpoints:+ - tx_index: 2c7316f0b66bf4c5a2078cdaacd679d96c2249a32736ea7d3ce36af8b6a215c0+ n: 0+ tx_index: 671ba5996381a919c7b88415513ca1db54528e3741282a44228f7eea4d23ffad+ value: 21000+ addr: bitcoincash:qqmmu7k99ahs3zvs28h738ez3s22zudmy5squrulad+ n: 1+ type: 0+ - sequence: 4294967295+ witness: ""+ script: "473044022030ade2f8405e47001dfe816fb2047cb4f6beff185c942e325d7b30\+ 70dca66622022001c0bb027e88d3ea2fce18c47553461dd063a0a26aa0f69252\+ dcce8e26fe8cbc41210269115d4edff9823c494195a0e3a9497d868c61c43c76\+ c37f851e28655d4fad60"+ index: 1+ prev_out:+ spent: true+ script: 76a914f1d4f42b339a3bc881085e4babfb0db98db8887888ac+ spending_outpoints:+ - tx_index: 2c7316f0b66bf4c5a2078cdaacd679d96c2249a32736ea7d3ce36af8b6a215c0+ n: 1+ tx_index: ac99b50918e84857b84a13b27a0635a9f0474d6e20ddae0b5d05f3b8503d3e5c+ value: 546+ addr: bitcoincash:qrcafaptxwdrhjyppp0yh2lmpkucmwyg0qf590pgku+ n: 1+ type: 0+ out:+ - type: 0+ spent: true+ value: 4485+ spending_outpoints:+ - tx_index: 26ce9896f0f64349670735bdc30fb42adadac61348e403e78c5f92c3fb697355+ n: 2+ n: 0+ tx_index: 2c7316f0b66bf4c5a2078cdaacd679d96c2249a32736ea7d3ce36af8b6a215c0+ script: 76a9147cf27910c2aa3e86e9d69f5bb8dc0f677a0e83fb88ac+ addr: bitcoincash:qp70y7gsc24raphf6604hwxupanh5r5rlvh3jvjjan+ xpub:+ path: M/0/68+ m: "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"+ - type: 0+ spent: true+ value: 15840+ spending_outpoints:+ - tx_index: 26ce9896f0f64349670735bdc30fb42adadac61348e403e78c5f92c3fb697355+ n: 3+ n: 1+ tx_index: 2c7316f0b66bf4c5a2078cdaacd679d96c2249a32736ea7d3ce36af8b6a215c0+ script: 76a91457743547f518ef1ebf928e0065036230f246680288ac+ addr: bitcoincash:qpthgd2875vw784lj28qqegrvgc0y3ngqga8fpcy8k+ xpub:+ path: M/1/22+ m: "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"+ - type: 0+ spent: false+ value: 546+ spending_outpoints: []+ n: 2+ tx_index: 2c7316f0b66bf4c5a2078cdaacd679d96c2249a32736ea7d3ce36af8b6a215c0+ script: 76a9140c511401eb2e64aee8f1fc3dc49869b32819845f88ac+ addr: bitcoincash:qqx9z9qpavhxfthg787rm3ycdxejsxvytu8zyrvhsf+ result: -675+ balance: 91015+ - hash: 0e81fa97ae5106f6e967de912908ee261473575e890e815fa78084f9d1b68382+ ver: 1+ vin_sz: 4+ vout_sz: 3+ size: 703+ weight: 2812+ fee: 1557+ relayed_by: 0.0.0.0+ lock_time: 0+ tx_index: 0e81fa97ae5106f6e967de912908ee261473575e890e815fa78084f9d1b68382+ double_spend: false+ time: 1613556294+ block_index: 675214+ block_height: 675214+ inputs:+ - sequence: 4294967295+ witness: ""+ script: "483045022100a3d05e493ed722b9c9519ea77d2d3c139d70bb117497d8e517fb\+ 8b5b196ceeaf0220608a93847c452c461ad841eabccc75e065c310e8ae93ad6e\+ 60953e6c089a297c412103b4c6da6474554411af1f6ed8deeeafab9848bc6bfc\+ 6cb716f135e7dd674f7a26"+ index: 0+ prev_out:+ spent: true+ script: 76a9141369d9bd3ea79896797c4143c97e9748cc06262d88ac+ xpub:+ path: M/0/65+ m: "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"+ spending_outpoints:+ - tx_index: 0e81fa97ae5106f6e967de912908ee261473575e890e815fa78084f9d1b68382+ n: 0+ tx_index: 4890ab0fed725daa08879afcea302a4c7925c094c98676cab3edcaa186adbc41+ value: 7000+ addr: bitcoincash:qqfknkda86ne39ne03q58jt7jayvcp3x95u9mrv7ra+ n: 0+ type: 0+ - sequence: 4294967295+ witness: ""+ script: "483045022100b2e36d340c3ba421f762ce1e4adde0a8c7cf550aa30022a4add6\+ ac1003ea1a7d02200cd9d8ccba03a33eccd6f4108189962067597d25d969b9f3\+ ab019212a0bb6824412102e14eaaa6cec9deefe5983dec61bbb2c9fc82dbbc12\+ 4d395d97d26494864f30ec"+ index: 1+ prev_out:+ spent: true+ script: 76a9148ecd9c021c8650db874940103f1f77e6da14d88e88ac+ xpub:+ path: M/1/19+ m: "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"+ spending_outpoints:+ - tx_index: 0e81fa97ae5106f6e967de912908ee261473575e890e815fa78084f9d1b68382+ n: 1+ tx_index: 4890ab0fed725daa08879afcea302a4c7925c094c98676cab3edcaa186adbc41+ value: 45732+ addr: bitcoincash:qz8vm8qzrjr9pku8f9qpq0clwlnd59xc3cvrfxdpkr+ n: 1+ type: 0+ - sequence: 4294967295+ witness: ""+ script: "483045022100ff406af352e0680f20c3efcf8a7915ee8c2f1322073fce5736cd\+ c7d264576ad00220637cec6bd985f68395bc5a29e7c15fd8a5b74dafd169c2ae\+ d86e453c91eb196a4121025b8a5938fad2f4e6cc7d7a32dd2b6a206a0082e2c6\+ ecdd5ab5a15e855e1a2ff3"+ index: 2+ prev_out:+ spent: true+ script: 76a914bcd2b9001eea656e1a69f280cdc8dd86dbc8bf9288ac+ xpub:+ path: M/1/20+ m: "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"+ spending_outpoints:+ - tx_index: 0e81fa97ae5106f6e967de912908ee261473575e890e815fa78084f9d1b68382+ n: 2+ tx_index: 671ba5996381a919c7b88415513ca1db54528e3741282a44228f7eea4d23ffad+ value: 19515+ addr: bitcoincash:qz7d9wgqrm4x2ms6d8egpnwgmkrdhj9ljg80neqzgv+ n: 0+ type: 0+ - sequence: 4294967295+ witness: ""+ script: "4730440220445da9cea01832fbb4882b8aeb12189a91f32c9e0eec42c2b8ab0f\+ 5af40c38de02202378f8f3e774c3515e7082460c34faf5631dbadc0fff072084\+ 9aabcc70b41dc44121028c8ec4b2d812141f34ecc23ed6b533ee1c36e1654619\+ 05dae99595072529957e"+ index: 3+ prev_out:+ spent: true+ script: 76a91401b513b7affb4ff8ca96d1e25a78431687d7709e88ac+ spending_outpoints:+ - tx_index: 0e81fa97ae5106f6e967de912908ee261473575e890e815fa78084f9d1b68382+ n: 3+ tx_index: adbaa9b985968e42369f5dc96890480c84ee68537fb0c200a2a09dc3bdd6c2d3+ value: 546+ addr: bitcoincash:qqqm2yah4la5l7x2jmg7ykncgvtg04msncm9ta8v9e+ n: 0+ type: 0+ out:+ - type: 0+ spent: true+ value: 690+ spending_outpoints:+ - tx_index: 26ce9896f0f64349670735bdc30fb42adadac61348e403e78c5f92c3fb697355+ n: 0+ n: 0+ tx_index: 0e81fa97ae5106f6e967de912908ee261473575e890e815fa78084f9d1b68382+ script: 76a914be0322af705fa04d0550606dee421dbfc28d7d9988ac+ addr: bitcoincash:qzlqxg40wp06qng92psxmmjzrklu9rtanyazwxtjqa+ xpub:+ path: M/1/21+ m: "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"+ - type: 0+ spent: true+ value: 70000+ spending_outpoints:+ - tx_index: 26ce9896f0f64349670735bdc30fb42adadac61348e403e78c5f92c3fb697355+ n: 1+ n: 1+ tx_index: 0e81fa97ae5106f6e967de912908ee261473575e890e815fa78084f9d1b68382+ script: 76a914e53b8bc9d022aecb0cdd2f3eb874b6d563e73d8f88ac+ addr: bitcoincash:qrjnhz7f6q32ajcvm5hnawr5km2k8eea3u5mcg7n9s+ xpub:+ path: M/0/67+ m: "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"+ - type: 0+ spent: false+ value: 546+ spending_outpoints: []+ n: 2+ tx_index: 0e81fa97ae5106f6e967de912908ee261473575e890e815fa78084f9d1b68382+ script: 76a914c45f36d8156fa82e2f380452b0c42abd0a7aeedc88ac+ addr: bitcoincash:qrz97dkcz4h6st308qz99vxy927s57hwmszhkl5hdv+ result: -1557+ balance: 91690+ info:+ nconnected: 20+ conversion: 1.0e8+ symbol_local:+ code: EUR+ symbol: €+ name: Euro+ conversion: 170436.14609786443+ symbolAppearsAfter: false+ local: true+ symbol_btc:+ code: BCH+ symbol: BCH+ name: Bitcoin Cash+ conversion: 1.0e8+ symbolAppearsAfter: true+ local: false+ latest_block:+ hash: 0000000000000000005d55b8ab4de4c864b815edf9fb52278d69a614c3c7c72a+ height: 688824+ time: 1621613356+ block_index: 688824+ recommend_include_fee: true+ cash_addr: true+ '404':+ description: Invalid argument.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/blockchain/rawblock/{block}':+ get:+ tags:+ - Blockchain+ operationId: getBinfoBlock+ summary: Raw Block+ description: |+ A block with its transactions, by hash or height, as Blockchain.info answers.+ With `format=hex`, the block serialized as in the peer-to-peer protocol.+ parameters:+ - $ref: '#/components/parameters/binfoBlockId'+ - $ref: '#/components/parameters/binfoFormat'+ - $ref: '#/components/parameters/binfoTxIdIndex'+ responses:+ '200':+ description: Raw block with transaction data.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/BinfoBlock'+ example:+ hash: 000000001aeae195809d120b5d66a39c83eb48792e068f8ea1fea19d84a4278a+ ver: 1+ prev_block: 000000000845517b31c6820d83f25cff46429bf136a7515fe504116427e60f8e+ mrkl_root: 27f1d66f8a1ee5280f4e92508dcb647e954d53004905d08a75574daee4988360+ time: 1270916538+ bits: 472518933+ next_block:+ - 000000001c920d495e1eeef2452b6d1c6c229a919b28196c103ecffebabee141+ fee: 0+ nonce: 58489010+ n_tx: 1+ size: 216+ block_index: 50000+ main_chain: true+ height: 50000+ weight: 0+ tx:+ - hash: 27f1d66f8a1ee5280f4e92508dcb647e954d53004905d08a75574daee4988360+ ver: 1+ vin_sz: 1+ vout_sz: 1+ size: 135+ weight: 540+ fee: 0+ relayed_by: 0.0.0.0+ lock_time: 0+ tx_index: 3395786307389866+ double_spend: false+ time: 1270916538+ block_index: 50000+ block_height: 50000+ inputs:+ - sequence: 4294967295+ witness: ""+ script: 0415112a1c02c100+ index: 0+ prev_out: null+ out:+ - type: 0+ spent: true+ value: 5000000000+ spending_outpoints:+ - tx_index: 2434377525635237+ n: 24+ n: 0+ tx_index: 3395786307389866+ script: "4104a9d6840fdd1497b3067b8066db783acf90bf42071a38fe2cf6d2d8a04835\+ d0b5c45716d8d6012ab5d56c7824c39718f7bc7486d389cd0047f53785f9a63c\+ 0c9dac"+ addr: bitcoincash:qr52xkse9lqlpmfuzlx2lshmx4qnchljns9fmxxydw+ application/text:+ schema:+ type: string+ example: "010000008e0fe627641104e55f51a736f19b4246ff5cf2830d82c6317b514508\+ 00000000608398e4ae4d57758ad0054900534d957e64cb8d50924e0f28e51e8a\+ 6fd6f127baa5c04b15112a1cb2787c0301010000000100000000000000000000\+ 00000000000000000000000000000000000000000000ffffffff080415112a1c\+ 02c100ffffffff0100f2052a01000000434104a9d6840fdd1497b3067b8066db\+ 783acf90bf42071a38fe2cf6d2d8a04835d0b5c45716d8d6012ab5d56c7824c3\+ 9718f7bc7486d389cd0047f53785f9a63c0c9dac00000000"+ '404':+ description: Block not found.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/blockchain/latestblock':+ get:+ tags:+ - Blockchain+ operationId: getBinfoLatest+ summary: Latest Block+ description: |+ The newest block, as Blockchain.info answers, with the ids of its+ transactions.+ parameters:+ - $ref: '#/components/parameters/binfoTxIdIndex'+ - $ref: '#/components/parameters/binfoJsonFormat'+ responses:+ '200':+ description: Latest block and list of transactions.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/BinfoHeader'+ example:+ height: 688824+ hash: 0000000000000000005d55b8ab4de4c864b815edf9fb52278d69a614c3c7c72a+ time: 1621613356+ block_index: 1621613356+ txIndexes:+ - 4c38eb2b05ee5ed21d4f04aca264b6b5c2ecdebc463979880f34ff11d61b9df7+ - 01823b17775de166d63f1a04cfb6dcdb758a864037808d8855db20e186db8a25+ - 0448ff64f09a9b2942098edb92bf9ceb75277a21e4f3df5e33867b293aad7ec3+ - 069023d56d47fa2c6f0f435b50fd160f7d2696ad73008753e344264750f48414+ - 07145f7740f1204908194f9fec585b233d7b2b6dd260e43e4879f2affea02851+ - 07ff00c1b6b058ee5823005fe05209f54ee71ed2059528a378aaa0bda9b8c2d6+ - 08756d7b411d0ac4993f890fd216501944b1b4bc61df69ddaa9f721a4c75ec2a+ - 0983b46978824feafcc7c67b5b0df164c81d8a71d9c02e0dd9b470d5f66c1a58+ - 0a23d6bf0a8f7298bd422576d51eaab3e6a51e6b176424bfc61df48063b87a6c+ - 0c08237a85fd2583958bfef936ef4c43784d0f13c57753e12492d304b7cf6155+ - 0c090bd29a8b29f9f0ac36ad88c9ff0b5087f08ac51342de8a44f36bcfe20911+ - 0cb873849e4f72fdab5c91b37bc3937c2c1b495349fa5424f57d4c9c72f62ead+ - 0e5b552bffa5e732307f2c33ffbbc5ac8f523bfa991fd5a2711c8a5e698fe626+ - 0f75524e1f8e0018275fb70a7463b0ac92f712485348bd0f0229baaf7149c039+ - 1713d89ecaa24b8a82fee48740c032969a1be453608c760a36e9ac0c2925c568+ - 1ab310ab552ce3a8103a565b9b63988898da47c0371d25cd51ad718c07173a9e+ - 1c2ed964c3c5f825dfe67e3b1bd432cbfff712c69c94c9972cc790cd6d9690cf+ - 1ffedd33d7df4777c4f623669b678a3a0ddda9649539d098522caa51ef4f8a61+ - 2216e249cbf1eedc8fc525c74a64db4336da381cca724161bafbbef2cf713510+ - 22a9d5a370cf548c2e5155109eca8e29c0cd88f5b16e2db8c2e63fa0a71c48d2+ - 23e4aa5d99ee70c33dedd634435d208182f8b50f64a301766ba88c4143619cb1+ - 26ad4b3701e7d23ef4cee7408a21393afe28ad03e4524788cc1fc18949a2a29a+ - 270b9cc678b56a04aba36be6254acdc21ab11c9e3eb1ab6539595dcc35180742+ - 29e14b7b36226f089b84778508434cbee387ac643a286a38f314d46649cabd2d+ - 2b977eadd3052f2640ebfa33b2e602bea12af04bb30d7912634066159bafd738+ - 2d283d80d8244bf4e21e2bf958b35d9946a7dacc6032b8ca75955ce649889619+ - 33feebf0f5712ddaba475f4cf56cc10c33c1e8b6630885c1d16f0903b5f28f49+ - 3b4749b05d4489fe3509a0eaeaa3bf84ebd18d9e899a4c0f93cec4906f0b4c72+ - 3c445791dc93009e9e82a2cc7e1f063d2aaaba3541c79a5278e24b05d092f373+ - 3e3e7b8c6a8ed50b44358d3501b4172cce9f7e6be2c3fa682cef69bc1b77ebbc+ - 420d4e957e75a248a003349c6f36780bd7bac479af31e26d52bfc1580f1e05f4+ - 43c659f05fe4bac95041ee8ad9cb487fb5c178f260ad111f79555c6208d465d6+ - 448b6610021f4f74a7376da139167a85fc8fd34a9aa0c845841439a574676df0+ - 44e23144d415417fae2d03f071621f7f87ffa4847a131b28e220238a82858384+ - 45a34bf311e2544428a35924701a216306da67a1fd921a95520c130e164ceb40+ - 45f96d25c71bac0f5026428b4a114ea4821e6923e7223fc9730e9cbacc52a476+ - 47c83dde8a4ce80efcd63981f27fd90780f8f51694f7a83de66f15be201deaf0+ - 47f43223ebded3e0397d143692ad93197d0c36fd2033cb6c21619cac26372783+ - 4b86e1d7d66159eb795f059996ce2d25ae9b7adf1c4a08b7b77e3fd29d683588+ - 4c237092cc3a1f6c3a6077aa929a53fa71879d607030db66158b899f6b691329+ - 4c6370f1d46aea314c51d7221f436cc3d897d78c7de9b6413b17c7fa0d2d77e5+ - 4c6fd1d42c445bd51c135ee3cecfb541d2467d40b2678663d56a39c20c12ced1+ - 4cd70717f5dd726b19527837c58b1df094b83eda05c001c21a6fa9b9b8a6b9c3+ - 4d5fd51538bfa055ed138ad83437ec1744186d71dcb10f2b668bc9ea3f889bde+ - 4e84251545b58a7d76b6ec21973b99b317edcf6afc071bea7854a3c5b5a8d524+ - 54dc82365400c51292ebe2a2351a172615527164c024b327688c435bf34a88d8+ - 57e1c65a83b47dfdecadbecab3da05c65d0134ef997e88f5b9a478307be21cec+ - 590d9ff2561efdfb2330ae736ca44486da720ef0d7f63168c9b5691dec93ab03+ - 5b0cc365c8fa1256464f5bcb92ba937f778b489eab9b5903aa59ce35fb8d7ac6+ - 5b565353fb2109962fdb7e550c7e3735890cedd45efd338d829a08bcff222224+ - 5c46cc7305f0ca412bacf44c3f7c727aa55db0daeeb011b7d1042660563081df+ - 60d2778361afe1230a0f8eafc2396d5cee8975fc13a8119e6205a61022ec9fd9+ - 61b28c70c16c2e0dca2f8176bdb2eee168596bdb5c1101827f36c2a78e5963dd+ - 63001aadaafa6f6c375368c653fbf38075f181f19d28e196879115b378fa4e0d+ - 6787eb3bc645d7e1526ccb32efbf1e19c759c66559a8da2bbbb4b43ed4f2f101+ - 687f32bd1f01e84bea246c9dbac219bf2904863d882d653bba1c13c1d4549e0a+ - 6928119044c4718d2a146ee77a63d76d0f53d20a8c6d8166bb14c455e6db194d+ - 6a8afc569208b5aadfe917eeb00cb63742b85f2bfb620e35480aa179c7bcc91a+ - 6b19caf7d11225dc62bceba5835c5a9f676d5626138cfef7c717729758fd8120+ - 6b73c2a0a8da9dd434eea8363a784fc4d3199266dc79598a15482519e15cd957+ - 6c7fb02034c7768a0fd377823b90a03003be26973fc33e32cfe31709003dd0d8+ - 73e99e7fd98eca88447bdc23f1f622c71eee973c24e16804431ca9540bdb3eba+ - 75be45e58da8e1945d691dd0d6e37dd09e71bfa79aa3216bdb5d908523f68a0d+ - 762eac92e4e6fe747c97c5894b2367a529e25774874afa0d00ba75c86a99a778+ - 76e89ea97e8a465e1dd28f38620e755dface6b58fdc7466a8fefca0b172d14ae+ - 785063b0221fddd9541395e636a440ecd03d20762371dedc73243c1ca835acad+ - 789a6c7f59a852ab02974d7d3588e1d1f63850f320edd6d68cc8a20343b20353+ - 791aaba586b112c7c40d503ec00d0cd092cfa11f460c2c7d03fb2b92f9d839ce+ - 7b1a96d3dc25129876584fca0b354820e2315fc327c156e0074c824d1985ccbc+ - 7b275d4724d3d79037e27a8ec2e6b9c25be2cc5d475f50f8e10f91c74b5836b5+ - 7c234031e50aa853509fbe8eab5380bef517340064c9ddf7bd7529bda93265be+ - 7cbee59f16df830c37ec51bc3f364731e17e71df95f743879aabfe3a5f10b401+ - 7d79a6db1788061453f846d0a0f95dd0f6d76b223dd6f8eec0ad7154cbc6ff26+ - 819e4070d42221831dc899b3ce1b8afadce75e2c29a7254a3704476729471a10+ - 8621220b62b96f4fd16601b958536cb0574c9da0af8a9c0e0bfb88688ee82cb8+ - 873c4755e36fabed41e9d340073536c6e328c243685b74f2dc6348f66d865458+ - 94d9389300590814bf05309a141338ab0c096c16f469b3c64e8fd5e292fc35c3+ - 98cafc73d825f0625e57f6a13513abead77f98d837f1a9ca41cf846f0a746d73+ - 98ff1ea70619428f049acbfa914fc672b6fafabf754f3d8dc6368a2d5e7f169c+ - 9cc4658b94a2c294f599fc5b621e0a718ef4237075bd31ee354e786c577fc9dc+ - a258b428c7ff1d3fe238262b9c5c041cba8b90615a31df2073c6bf7730b50558+ - ab50430d07205a389be9d7b48a5f1a8876fb362db7b1255e1c24543f8d07b57b+ - ab7ed19e04e02b02652bad917fe47b08ec9ea5136ab83aee9d735e58aafa95cb+ - ace73995984c36e16ef4eeb01cb0e92d361845f3d96bfa37d89404b8d56f4458+ - ae2ff184a71aa3ac315d8df3b7575b221a96a65d5b7e821e28bc51d2b79a7728+ - aff7771f7d855f3fdb82eed34d6bc2425726ae31050d19d84670b5f1a0cdb581+ - b1afdb5db3855c10a3ab88bbcdc30a43abdc1b3358c3a3219c5dc23b29a164ac+ - b994ba455aa12155589aea3dd37dc1e7de4a4075db90af3b39607d72727894b0+ - b9aa95a14fe6da28fd0f5b25b1ffb3837aa0645b868e120b5d81f08376d2946e+ - ba916c099c4595fa0e5629228e6339e7a9a516657ec0178af8cec7401ab99571+ - bbf377dc25d8178cca37defea3fbbc1219c55c4988502eae61924f4e8c7b15c4+ - bc0da2b7f532c318a0c5f087c65de8e175f11218178261b48e4b5a34237e307c+ - be80998651ed346b57dde55b62daabf3f2b7ab1fa23949d74206eebf9e0dddbe+ - beb4dd6722a980a68215f44824d71c68e85d476916f191e2b9b64aba70df5011+ - bff3d9c73b79f2df57907f9585962be5005de77a38dcafb4ed45b2d767bd5f9c+ - c16c7991aa4843435234ea2d430a2c8cffa89f9df7d6da573c766f90f1b02fb2+ - c1e102bf7cc4fad1d653cf8bf2f6dfdb56cc15c90eba48826f499748ffbb453b+ - c467c9e1597fec2186261553669f3a030bc1fde0d71a51a7138a62007dd45d26+ - ca007fa6a1dd1cc19cabdfefed711868a903f945a44c23a736f8508fc595e779+ - ccab8fbc8cf6e22fc18142ce528aa4f9d513699e1c9d5acbd085c6c2500ff621+ - cd959b2aba269c1132b7250edc34a9ef85fe13bf6c78285c3511b97b8b7350f9+ - cdff2a8b1ca912c733e45ab44a2b3f97962076e4a60996b6525f4a4fd3d701ec+ - d0d28136d03f5bac5fa4cc9479a3ad7ad76ffc3e731abe061149be8e19d3a1f3+ - d649b5579ffe5ad98ff93f0715a91ccf1ebf60a564cefc01baba969eb6fc024a+ - d67df8392a5822c420ee057ac19c6b123fb514853e43d40173f84c9853f54835+ - d8bab30bd3cefdbb2ccbbe94efd22387584373256e26fd914fc3a4e7fdb06aab+ - d951327e966ae8bede3ec5a0dceef158479c40dcb5805fe291e7a9149e84c418+ - da74f9ec7c74131689bca50d48a8b3eabd6676668d828b74782d2f5e386d10e4+ - db0666026842647e0de6cf67fa873eab94b541995daaec66af0169aa65186dbb+ - dc5a07a8f0e3ee69a21c4da7f7ca3220154930c9d45c497ec738fdee2e9e79f0+ - de629914e7be3f2f40ad87f70f6aeaca08e45f8c8031c2e587e48457ff05b33e+ - def688cb3656706f4bbc98f0b23ef28c45de111e4171f5ccea872f15bfdb3606+ - e1706c7cedf8dc991e52dffcd7d13f968471cce2e448532dfc4b42176111862f+ - e3e332c6817077f6cb79253bb5ce2a680f097f7d85fc2d544738d6bf7ffc0c77+ - e42d25d5fd76c7ee79794c2f3601b5616c518f54ce89f6c016fa10a6b41af055+ - e54814a2125e258680824bcca3968eb7b9004bba955db3921326112050aeb366+ - e55057e4e52a0ec20f4e00462f2974ac46ace4b31ba7b8764a4f8f371787f72f+ - e5c9ed1d58b2a682219960ee90fd662519023913e741ea7caa5fdd6700890cd2+ - e938943a6e66089f6200ffa6c211ec219beaaee383c3ac73c9cc15ccc9b67a6e+ - e9af46657882c81d9280515f3db830628af7acb54d4598832f12081b9a2aab4b+ - ebcb08f6a064672ee79d97949ee4ad783490cac01dba3188c02b457603882cd0+ - ec0e5b73cf988356cf3d7140177c456811949cc610690537c484f37bfc586fe1+ - ed4d51bd70497507a836f76d111b09ef659a912b7f2c34c4a85c83718603cdad+ - f2a745c03817fb06f8c734eb84a535476a01ee3fca7a6eb4b58af06919a194c9+ - f36881938c4081d5c3c07ea7c472436010a9bc50a272e5f98f2ba0c5be4541eb+ - f5dd09f486fd31bb4ca2455b1ded267daf5b6b4afe77496e2f832537aae25ea2+ - f5f93376df7b16aa5724b0999b7e512f1daf65671c952d544dce66d832ac7ffe+ - f9bf3c16de953a3e4992c278706fd7ff7776da6b6c6aa14dfe8db4461cd04243+ - fc9b718701edecf2c4031c7043a510c2cb9233138e2061caa8b18327f7d1a2b4+ - fca2aa70b428bed7ed7d463c880491e720b6a46ad649601ed9a25048ab72dac3+ - fef4d9bff10845b426cdb246c5450207a947cea02b014ec8523da5d8ed987a58++ '/blockchain/blocks/{milliseconds}':+ get:+ tags:+ - Blockchain+ operationId: getBinfoBlocksDay+ summary: Blocks for a Day+ description: |+ The blocks of the 24 hours before a time, newest first, as Blockchain.info+ answers. The time is in **milliseconds**, as Blockchain.info takes it.+ parameters:+ - name: milliseconds+ in: path+ required: true+ schema:+ type: integer+ example: 1304208000000+ - $ref: '#/components/parameters/binfoJsonFormat'+ responses:+ '200':+ description: List of blocks for a 24 hour period ending at given timestamp.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/BinfoBlockInfo'+ example:+ - hash: 000000000000475e465114fac64a267ef55a79f0431af774f0ec90d42ea1f2d6+ height: 121126+ time: 1304207878+ block_index: 121126+ - hash: 00000000000037351a4fa2e4cc97852cb4a498419844d79fb29e119c6d4cb32e+ height: 121125+ time: 1304207785+ block_index: 121125+ - hash: 0000000000006429474c3f6635636d3824f68841935debde15a763bce02a7c18+ height: 121124+ time: 1304207337+ block_index: 121124+ - hash: 000000000000223b64b7083652f2c3c79b0e5f175bd885697377a39e79502297+ height: 121123+ time: 1304206894+ block_index: 121123+ - hash: 0000000000009487db37d79be368408e08196c327e124a033ae164db10753dc9+ height: 121122+ time: 1304206476+ block_index: 121122+ - hash: 0000000000004d8ce1babf4fdbf153f813ec4776ea53f7e36cd4897886349f0d+ height: 121121+ time: 1304206238+ block_index: 121121+ - hash: 000000000000538fd5bb2e5e98ca0019eb5d121fb429021cefbac1413fe56ccd+ height: 121120+ time: 1304205547+ block_index: 121120+ - hash: 00000000000049fdef5f0074a089e9f82bed072d5e6d975fc411cb1ba6d64c00+ height: 121119+ time: 1304205476+ block_index: 121119+ - hash: 000000000000555ba01c35729fd735cc063ef7900db77cddca3656db755be830+ height: 121118+ time: 1304205252+ block_index: 121118+ - hash: 0000000000008264df3eee52f65383383e4d852d2636718632f8b3b9a1cefe42+ height: 121117+ time: 1304205083+ block_index: 121117+ - hash: 0000000000001f04dc63a2c8965e8ed033aa4f51a3d57905814f883a6f2e5590+ height: 121116+ time: 1304204752+ block_index: 121116+ - hash: 00000000000007cdee3c6a4aa379d4e8464692882572a4f9e71f5e2401752284+ height: 121115+ time: 1304203893+ block_index: 121115+ - hash: 000000000000560f1d2a1da96a60bc7f57a35d31686fadf2ded8a71347f049db+ height: 121114+ time: 1304203470+ block_index: 121114+ - hash: 00000000000080e7dbe1831f5ad5190ff80da15196829bec92ece2c7b577f36b+ height: 121113+ time: 1304202421+ block_index: 121113+ - hash: 0000000000000cd3ff350131f2879af7917390825cd027150d05beb8147582c3+ height: 121112+ time: 1304202114+ block_index: 121112+ - hash: 000000000000220aec71a1052b33d11444db6f88e3a6e6313159c4609d0b92ca+ height: 121111+ time: 1304200737+ block_index: 121111+ - hash: 0000000000008c32948edaeec42a90afd6cebb14d4e773e1b1ec5b4102d8bc52+ height: 121110+ time: 1304200549+ block_index: 121110+ - hash: 000000000000876ce07b94635e3a8959ae23bda70a0ae1965998f4830ab2095d+ height: 121109+ time: 1304200510+ block_index: 121109+ - hash: 0000000000008ce13ce258833d3551bf5031f08ee9fae3d1ffaad4e9967cad90+ height: 121108+ time: 1304200222+ block_index: 121108+ - hash: 00000000000067ac45b2c3e1e5bdf1bfd04442adf0931943f9be47ba81c42b17+ height: 121107+ time: 1304200205+ block_index: 121107+ - hash: 0000000000002384a7723c2e09e9b20a3ca23773d1539d64494ea06a1637272b+ height: 121106+ time: 1304199799+ block_index: 121106+ - hash: 0000000000003da280d8a0a9689a96812b0ab29ac807eb814809e4ee78fd7576+ height: 121105+ time: 1304199563+ block_index: 121105+ - hash: 0000000000005f22c5b947dd24621e55a24fb52cfe445c50836bbc8017ce4ef4+ height: 121104+ time: 1304199026+ block_index: 121104+ - hash: 00000000000073011e748bfd2d6307285cbe28568a613ac9f8115e8fb1f1bd11+ height: 121103+ time: 1304198789+ block_index: 121103+ - hash: 00000000000038e334a9c559b7f49cd76fb8f2d99537be7fc8d1cf69ed01bfdb+ height: 121102+ time: 1304198658+ block_index: 121102+ - hash: 00000000000095015156b62244ae673fa593f901c61904ab112928bac95525bb+ height: 121101+ time: 1304198316+ block_index: 121101+ - hash: 000000000000177ef47ee4d1b9ccbe1f18e5b58ad0c4c067c889a149486dd327+ height: 121100+ time: 1304198133+ block_index: 121100+ - hash: 0000000000006f134512ebd1d7f8049ec2922045219227883bef6875ca7dad07+ height: 121099+ time: 1304197977+ block_index: 121099+ - hash: 00000000000079458e8d4b02e9592e68990807028b57a6e604504820a4c956cd+ height: 121098+ time: 1304197956+ block_index: 121098+ - hash: 000000000000941e8f34fae1ae47cee6f0232aac4bed2651f42ceb9c274940b8+ height: 121097+ time: 1304196739+ block_index: 121097+ - hash: 000000000000013f70963dae6aabc2567494e4df00b77b21de27f9f7d51cda37+ height: 121096+ time: 1304196601+ block_index: 121096+ - hash: 0000000000005678a39dbea710b9afc0d270d9a26cc9be9291bdc07957e53c8f+ height: 121095+ time: 1304196158+ block_index: 121095+ - hash: 00000000000015b35809c71bf09ee6fa1273d9aa7912c7f73fcfdec983ac8616+ height: 121094+ time: 1304196111+ block_index: 121094+ - hash: 0000000000006dba6d8651749fe59df7f784ba02a3af389e0d8de87556a7f2d9+ height: 121093+ time: 1304194495+ block_index: 121093+ - hash: 0000000000000c347a3da7bfee84419ee0442483ad6bc398d447197381e1b435+ height: 121092+ time: 1304194427+ block_index: 121092+ - hash: 0000000000001f50198f4121b99f8cf411a3677357e1963c96f37454aa4b91ff+ height: 121091+ time: 1304194291+ block_index: 121091+ - hash: 0000000000003e5942271bbecdf8f2ea806b10f174fdcf4216c80cc96853ceae+ height: 121090+ time: 1304193527+ block_index: 121090+ - hash: 000000000000983fa6142a7729efefa0cb792af99aa2c8fab9896a1556ba512b+ height: 121089+ time: 1304192511+ block_index: 121089+ - hash: 00000000000077f33539182dab2d04bebdc6c1d74ccc7f9f7d4329575967a833+ height: 121088+ time: 1304192188+ block_index: 121088+ - hash: 0000000000003db3d299a94b459863fdf9eb18f671f3d8bb16d94bea54ddd575+ height: 121087+ time: 1304190190+ block_index: 121087+ - hash: 000000000000369f02d4c94f18ae5681b4286b7170a9193c85b5647ebc544c63+ height: 121086+ time: 1304190025+ block_index: 121086+ - hash: 000000000000931f5591dddf392068a34ee7332943a04ad5925414e038882a33+ height: 121085+ time: 1304189376+ block_index: 121085+ - hash: 0000000000007d65cd99d0711480f26aba3ba5c02a4a40360eb0d4c09ef8b1ab+ height: 121084+ time: 1304188567+ block_index: 121084+ - hash: 0000000000006fefa18e7c8941b3829c17579757bcd5f5165af0fdaeaca29ae2+ height: 121083+ time: 1304188375+ block_index: 121083+ - hash: 00000000000043cec6cd5155c65ce226db650faf2c54878db4702505cbdbbd4e+ height: 121082+ time: 1304187578+ block_index: 121082+ - hash: 00000000000060316bb5b36d752d49890ccbb73a184f7cbc060680b6933584ef+ height: 121081+ time: 1304187368+ block_index: 121081+ - hash: 00000000000068f040c2cde5c82894727b33610de4c05ea1b1233d2aa2fca6bd+ height: 121080+ time: 1304187123+ block_index: 121080+ - hash: 00000000000028c05d4651de60cd3b44b5be2b1816243d8d965528a81ae7875a+ height: 121079+ time: 1304187008+ block_index: 121079+ - hash: 000000000000472bd5cf01798c1dbd0e6d25dab52f8d696753580008db80e893+ height: 121078+ time: 1304186628+ block_index: 121078+ - hash: 000000000000372744c19efaf112182bbed961a0fee0d89a6f04b4984e4f4584+ height: 121077+ time: 1304186553+ block_index: 121077+ - hash: 0000000000003d4b7b5da478538beb0060ca553c54b35c8946f9a5ccb24512e0+ height: 121076+ time: 1304185981+ block_index: 121076+ - hash: 000000000000033dcfc64fde1d82e1d8bc5c58e27a8f8d13c0bf4cba5d9d2adf+ height: 121075+ time: 1304185447+ block_index: 121075+ - hash: 000000000000247a36e8f3abf31335721f15c2444f627e0a248671b9c907f7e5+ height: 121074+ time: 1304183466+ block_index: 121074+ - hash: 00000000000072cb4cdd156e0eb5136f8c6c6b575f785c22949608236508f7ca+ height: 121073+ time: 1304182511+ block_index: 121073+ - hash: 000000000000444ae09cec4ce9f07fe7b32cef925ec85b0891352a8ce4b1451b+ height: 121072+ time: 1304182067+ block_index: 121072+ - hash: 0000000000001c6430c1997d19f4feaacf39dae96097255faf9fd20281cfa69a+ height: 121071+ time: 1304180846+ block_index: 121071+ - hash: 0000000000001fc948f18a1a71312ad0cf8dd9163e4b5b4fab8c440915af16f7+ height: 121070+ time: 1304180836+ block_index: 121070+ - hash: 0000000000000adbec0c504df39383277cfcde45965a598381226c9d69ac49d4+ height: 121069+ time: 1304179491+ block_index: 121069+ - hash: 000000000000418f02482273ac6a32d16edec9b32ab0ba4df6cedace84296896+ height: 121068+ time: 1304179436+ block_index: 121068+ - hash: 0000000000000d80f675420be668f669c8fe2fb47dd37973bc03acfdb65f85b8+ height: 121067+ time: 1304179294+ block_index: 121067+ - hash: 00000000000040b5f954e6fafcee88e83bfc518652e38c4ddf0093fefbff180a+ height: 121066+ time: 1304178353+ block_index: 121066+ - hash: 0000000000003fda5b8545f616c8669068ec0d55df9a2ab7c3eef56f1d27c7b2+ height: 121065+ time: 1304178052+ block_index: 121065+ - hash: 0000000000009366035bfaac09768eda7c6c00f30dc43a82f242b03cded2af61+ height: 121064+ time: 1304177919+ block_index: 121064+ - hash: 0000000000009149970f8b22ebc89bc39f94ecf62fb2d103895b140cd1e6929c+ height: 121063+ time: 1304176797+ block_index: 121063+ - hash: 0000000000002fd2fcb88fb3161f8b1dc957edec768a92197a2682ef1e411111+ height: 121062+ time: 1304176684+ block_index: 121062+ - hash: 000000000000423ab1d771c156011400c26796aeaa4a696e50883110d106e189+ height: 121061+ time: 1304176496+ block_index: 121061+ - hash: 00000000000051f16c0a2856278559367d52f78e2edbe4e1209665899f504cb4+ height: 121060+ time: 1304176203+ block_index: 121060+ - hash: 00000000000031a668ff1516b7760192175e11493a783cfc1fedced3fdf1ec68+ height: 121059+ time: 1304175932+ block_index: 121059+ - hash: 00000000000024d6fbd7eb65351e6ed7891ff1adbcaab04ab806a623b375d702+ height: 121058+ time: 1304175282+ block_index: 121058+ - hash: 0000000000008e60cef7434121c415367e71d331f66a7391c95d21eafed494f7+ height: 121057+ time: 1304175040+ block_index: 121057+ - hash: 0000000000008e6ff594414d2ca8c59f7165664c97f46380e7fc7549f93850cb+ height: 121056+ time: 1304174913+ block_index: 121056+ - hash: 0000000000008290778cf07c8352a105667c87209e0acd330efb871ef75add7d+ height: 121055+ time: 1304174863+ block_index: 121055+ - hash: 000000000000443b9bf8b6471bf1ffd7d6cac5925bf6c4225eecd0ae9e7291e6+ height: 121054+ time: 1304173391+ block_index: 121054+ - hash: 0000000000003795781dafa7d1634569ee4f553da536b34f5dbe24f381f3e0a4+ height: 121053+ time: 1304173084+ block_index: 121053+ - hash: 0000000000001e35337c744b329dd4149732a5e72600ad8d3b16e541da0bbfeb+ height: 121052+ time: 1304172896+ block_index: 121052+ - hash: 000000000000563c5e4e43915fdf15e5d64edff4f376d6293b1cd72078d563b7+ height: 121051+ time: 1304172724+ block_index: 121051+ - hash: 0000000000007bbb29af8f47ac5f262af8b5eee80e33fbf450525e2bfcfa36b7+ height: 121050+ time: 1304172668+ block_index: 121050+ - hash: 00000000000096640555f4cab1cfafc51cd71637fde69145d06eca19524eaeda+ height: 121049+ time: 1304172150+ block_index: 121049+ - hash: 000000000000200bae132975386a7dda629616a7a0e64ee9d140510dae7d8e7b+ height: 121048+ time: 1304171784+ block_index: 121048+ - hash: 00000000000012b170573c74b308a605eccb4bc7ab7bcb0b3ba83534ba9133a3+ height: 121047+ time: 1304171467+ block_index: 121047+ - hash: 000000000000597cbe5b6e79c61486d44370f163da0d9fb6752ddf4d59f7d5b9+ height: 121046+ time: 1304170765+ block_index: 121046+ - hash: 0000000000001071171398c29d9ef827e1bf53378cea6a963c9b3f129febbf1b+ height: 121045+ time: 1304170541+ block_index: 121045+ - hash: 0000000000003f8500cd6c42ecff802315281fd87f80af54c5ec2b063d771fb2+ height: 121044+ time: 1304170240+ block_index: 121044+ - hash: 0000000000008900678ecf84aaca49f1274ea64bfacbe0b1edd08f6a8f0bfc39+ height: 121043+ time: 1304170185+ block_index: 121043+ - hash: 0000000000005640d1cecde32f56f82200507aa2ccf1ed044a3d849a0fa26a6e+ height: 121042+ time: 1304169993+ block_index: 121042+ - hash: 0000000000003784a99e57cb832ea4ff50c0c2ccb6e69a22fbfc6a752f2d6170+ height: 121041+ time: 1304169545+ block_index: 121041+ - hash: 00000000000072353d12f50a216d98d8b6080ad09210e32cef475f018d8f7648+ height: 121040+ time: 1304168718+ block_index: 121040+ - hash: 0000000000006b7e67295ede5da1a1185268367b092ac569cb74a32be6f75b59+ height: 121039+ time: 1304167964+ block_index: 121039+ - hash: 00000000000050f019b53f0d9378879f30fe38cb73d614fcb600809ca447236f+ height: 121038+ time: 1304167287+ block_index: 121038+ - hash: 000000000000056017a46b9eb7543a9f99651cf93e764d52aadf2dfad7071d05+ height: 121037+ time: 1304167052+ block_index: 121037+ - hash: 00000000000043e5c795a4d21051d4281e303526bcc552302441be294b0da0a8+ height: 121036+ time: 1304166837+ block_index: 121036+ - hash: 0000000000005c89ac2411fe50f9b0ccaac59ea566561a02e4ae0dc4289244cb+ height: 121035+ time: 1304166380+ block_index: 121035+ - hash: 0000000000008ba010cc430bece41b71417b7ed4102dcb4b5b3ba759231e909e+ height: 121034+ time: 1304166346+ block_index: 121034+ - hash: 0000000000005a6e318a7cd5fda64ffa3c21688afdb92b2ba75208ac283222f1+ height: 121033+ time: 1304165870+ block_index: 121033+ - hash: 00000000000054a4313bcabfc559bdeddfa072b47858d35c18f3f128b9a8f827+ height: 121032+ time: 1304165849+ block_index: 121032+ - hash: 0000000000001a2585f316f81b20623e693845515f216f3ecb787583f4b79874+ height: 121031+ time: 1304165435+ block_index: 121031+ - hash: 0000000000002df0d65141941ff46360950bd6d59e840e7543d7b1a02a587cdf+ height: 121030+ time: 1304163816+ block_index: 121030+ - hash: 00000000000021116f3a21c0ffa0d44173274b53bddce4e3e3619d18a7e65036+ height: 121029+ time: 1304163476+ block_index: 121029+ - hash: 00000000000072a74cc890fd65a1f1fc870e53ffa3bad2e2ed366316ae46533d+ height: 121028+ time: 1304163417+ block_index: 121028+ - hash: 000000000000555d687591c8ee63ab8483166688e529eee9e09f1ccada907ede+ height: 121027+ time: 1304161895+ block_index: 121027+ - hash: 000000000000148b26490d48f10bea50ee749ee78d06cb8a4bd7a664e4e5556d+ height: 121026+ time: 1304161868+ block_index: 121026+ - hash: 0000000000006b01c6bcc0c42fb60dd8ffb3abc3c43c77acddf51968439b96d9+ height: 121025+ time: 1304161256+ block_index: 121025+ - hash: 00000000000098d028553328669d0d32593c084d28dd2a000d90e486d32f6f91+ height: 121024+ time: 1304160847+ block_index: 121024+ - hash: 0000000000004eae95bb184dacd3fe1e272e4eeb8e2c6dfeee3314bd88901470+ height: 121023+ time: 1304160368+ block_index: 121023+ - hash: 00000000000033b9d36ac45cba7fd17ce0a641c88175a4e3b2c9f0a8cac22f58+ height: 121022+ time: 1304160199+ block_index: 121022+ - hash: 0000000000008fbfc9fec3ea613bae59118c86eddd3e0df1ef645058c3998c3b+ height: 121021+ time: 1304159323+ block_index: 121021+ - hash: 0000000000000a93ce027acf500aa294e2c6ff58e74499bde8070e848135adf4+ height: 121020+ time: 1304158552+ block_index: 121020+ - hash: 00000000000069cbf9663a3f1dd07f52eabc55c9432310ca9ef4490bd57243a0+ height: 121019+ time: 1304157443+ block_index: 121019+ - hash: 0000000000004441bb7e8d53adbf047a281a06dbde22a79b05b0e7a2bddc9f9d+ height: 121018+ time: 1304157425+ block_index: 121018+ - hash: 0000000000003549dbf901d348ed95e2e920e7311c304b109f8e6791ea6c4853+ height: 121017+ time: 1304157199+ block_index: 121017+ - hash: 00000000000088aafd531b7c10e0d3cd2e5f9fad0870786eaed442194b9b12e3+ height: 121016+ time: 1304156123+ block_index: 121016+ - hash: 0000000000008ebdfb11ffc304957ffd46bd34fe8f958fa3bc485992a24478c3+ height: 121015+ time: 1304155401+ block_index: 121015+ - hash: 00000000000043c8d4081d8259ab0d45b696f2b920ede34aa41e359aded77662+ height: 121014+ time: 1304155199+ block_index: 121014+ - hash: 0000000000002ae7ed932ab9bab081de9d7aa7d5834e24a33707ade32cab328a+ height: 121013+ time: 1304154121+ block_index: 121013+ - hash: 00000000000065342830dd781b0228588bce6783e14153b759c1f07044fdf90b+ height: 121012+ time: 1304153720+ block_index: 121012+ - hash: 00000000000025adfa13b670aa30dcbfbd188807dcb586e92d671ea14561d333+ height: 121011+ time: 1304153294+ block_index: 121011+ - hash: 00000000000069e01c12dace34a54f202593dea75c729297e512084e87d3b4c1+ height: 121010+ time: 1304152433+ block_index: 121010+ - hash: 00000000000088b7820a82e16e46533399bad43f33c1bff76e3ab31fdf9babe7+ height: 121009+ time: 1304152394+ block_index: 121009+ - hash: 000000000000495dfd2d08a2985b53f18cb4f0cf35500a4f5b1433ca0c8924c1+ height: 121008+ time: 1304151121+ block_index: 121008+ - hash: 0000000000003c08e84cb8e3b12abee5b70bc48d16841b4ecaa1e2fc94a7fcf6+ height: 121007+ time: 1304150249+ block_index: 121007+ - hash: 000000000000904daae12718c3166c646e564cf27878992b5de64ddac87d5273+ height: 121006+ time: 1304149962+ block_index: 121006+ - hash: 00000000000055880368265ceddf3d540af70c46c20de79b4f23f7300a1f8341+ height: 121005+ time: 1304149231+ block_index: 121005+ - hash: 0000000000004d471350cdbe5f5aba72d6441364ffdfa2a9553ec86ea4caec64+ height: 121004+ time: 1304149175+ block_index: 121004+ - hash: 000000000000685609514052e0715eba6a1e7c0deaf59a24315c259e9fbc1732+ height: 121003+ time: 1304147388+ block_index: 121003+ - hash: 0000000000008379f91db9289063f330e39a6b480723b86abe0bcbd2f0d46628+ height: 121002+ time: 1304146407+ block_index: 121002+ - hash: 00000000000087390012d022582eeb5abe18f817b11dd9a89be678c0ea293dde+ height: 121001+ time: 1304146354+ block_index: 121001+ - hash: 0000000000008addb06debe0ba6b11e220b14b2b531750fda710b62bb1d3d706+ height: 121000+ time: 1304146291+ block_index: 121000+ - hash: 00000000000075216989a1ad8b35d434f9aa4fa3bb5b998da8f422ee53923d9b+ height: 120999+ time: 1304146163+ block_index: 120999+ - hash: 00000000000091a7063abd88ad281dc11f88fbc7e3766a831ddaee151470ec57+ height: 120998+ time: 1304146157+ block_index: 120998+ - hash: 0000000000003160d4319a2d75851bb2c7adf63b68313bc63753d86cb5138a40+ height: 120997+ time: 1304146052+ block_index: 120997+ - hash: 00000000000006d844c0d73ab70de03066f4d3c6143246dc15ea1484dc9cfc81+ height: 120996+ time: 1304145624+ block_index: 120996+ - hash: 000000000000127eaca1e5dca20b151356197fffc51e19c7735427f986568a81+ height: 120995+ time: 1304145067+ block_index: 120995+ - hash: 0000000000001bb36473406f41a0018376d1e3686c956fd0bb72013007edba39+ height: 120994+ time: 1304144388+ block_index: 120994+ - hash: 0000000000004b741ac6b5514fd4e19c8ed1ff3b78bb80bc0a6eed7ed61e6822+ height: 120993+ time: 1304143780+ block_index: 120993+ - hash: 000000000000941e65511aa5408d5db8a0140a686bd01e64bbdd34d5c7831246+ height: 120992+ time: 1304143492+ block_index: 120992+ - hash: 00000000000091fc5ae72d2c5a3c32f9e31206fd0bc9f6d3c80a9fb614dcff1e+ height: 120991+ time: 1304143441+ block_index: 120991+ - hash: 0000000000002f283c01d88eed0b948fab4f885ef39c66b109d5a17fc549e42f+ height: 120990+ time: 1304143399+ block_index: 120990+ - hash: 0000000000002812eecd335460ff3c2171d11df51989d247460e6e11ab8bef6e+ height: 120989+ time: 1304143270+ block_index: 120989+ - hash: 0000000000005aef4b7065038db827431088b52216f1a5249692e7046223a071+ height: 120988+ time: 1304143015+ block_index: 120988+ - hash: 00000000000062033f6c4ed1e44e1538a6c543c8937c030bccdb4d368eb62ceb+ height: 120987+ time: 1304142945+ block_index: 120987+ - hash: 00000000000062c28dfa926828d6b7b43bac3366a153542bd9e7f156d882b34c+ height: 120986+ time: 1304142356+ block_index: 120986+ - hash: 00000000000027987257100dd3c6337e3d41391f4736a9bdd1721a2108111e87+ height: 120985+ time: 1304142114+ block_index: 120985+ - hash: 00000000000000bb19cf058b82c71b021f0de98ac1ae36315396b0e65805e9bb+ height: 120984+ time: 1304141057+ block_index: 120984+ - hash: 0000000000004334e532084c786d0e15fbcd10ed096784319cf8944d973fb97d+ height: 120983+ time: 1304140925+ block_index: 120983+ - hash: 000000000000448253842df0ae6bedb71af40ff5dbf4f8f9443471361fc3b6f6+ height: 120982+ time: 1304140819+ block_index: 120982+ - hash: 0000000000008ba2594881b59f927b618d7539b44c6315dabe9173d237f2079c+ height: 120981+ time: 1304140628+ block_index: 120981+ - hash: 00000000000079f504aca0b771e37eb71d2ea263acf38c7bab77f8ad58e7c104+ height: 120980+ time: 1304139778+ block_index: 120980+ - hash: 0000000000002b5cecc48211794512bffe632337a10bb8dbf5cb5a82f386aa9f+ height: 120979+ time: 1304139554+ block_index: 120979+ - hash: 0000000000003648d4aff647ea4e4b61bb6ee8c055a0be09c5d10252d7aa2532+ height: 120978+ time: 1304139523+ block_index: 120978+ - hash: 000000000000609d4147b41cead0f8a7d966b6e721e0585c6257ebe6caddf45e+ height: 120977+ time: 1304139435+ block_index: 120977+ - hash: 0000000000003a8c92682547fbf0acab4cc2223192d77f6df8e437858b49b86b+ height: 120976+ time: 1304139383+ block_index: 120976+ - hash: 0000000000001dcc62bca24efa6271c4c9ffc5e60067a3c2ad64e352fd13f2d3+ height: 120975+ time: 1304139145+ block_index: 120975+ - hash: 00000000000026bc82ee2da9b2a7a240aae8a9112bc81a78895f1d138d9a1db5+ height: 120974+ time: 1304138241+ block_index: 120974+ - hash: 0000000000000e011816b36f39b1e0b84682e84720e79ad58643e741b295b060+ height: 120973+ time: 1304137450+ block_index: 120973+ - hash: 00000000000086bdcbbb0c4a66b999fdff516717d22ccfda08ea1592561ef37f+ height: 120972+ time: 1304136499+ block_index: 120972+ - hash: 0000000000000edd47e67c32af711f0e14ab50d6a83854df2792152dace03a26+ height: 120971+ time: 1304136159+ block_index: 120971+ - hash: 00000000000017a29a60c514c4cadd52dd95ae171a324f9805fa5491d27c1611+ height: 120970+ time: 1304136043+ block_index: 120970+ - hash: 00000000000006d46cba774fa65a02486974b85442dab196b55c550fd6896726+ height: 120969+ time: 1304135319+ block_index: 120969+ - hash: 000000000000922d45711f822fc486373d5754d7033730393e93755f8e7af95a+ height: 120968+ time: 1304135196+ block_index: 120968+ - hash: 0000000000005bcc470f7d6fc5d4cb43526e16811413a7a6dd1ec06a6f7e9ed2+ height: 120967+ time: 1304134362+ block_index: 120967+ - hash: 0000000000007251370503ea542f688e576d3d48a6355e14ab71a0f4d132b9c6+ height: 120966+ time: 1304133971+ block_index: 120966+ - hash: 000000000000135da63a64e0f50210b8701ed4bbb2fc0fee1bb51abe65ea3448+ height: 120965+ time: 1304133543+ block_index: 120965+ - hash: 0000000000004cbe5edd3762ca610edba5332926e1c9496e4726452943797a8b+ height: 120964+ time: 1304132764+ block_index: 120964+ - hash: 0000000000002dc81281b024cfd4097e386e0ae7b0797b0c13ec5a3d4328f9e5+ height: 120963+ time: 1304132750+ block_index: 120963+ - hash: 00000000000036e40cb69cdb0e44695d06e06fac00953b73ef4f99b777b2b1ba+ height: 120962+ time: 1304132626+ block_index: 120962+ - hash: 00000000000039ade9fc6ac0c4d198fe7ba1079a1d5ff65fd92adea7b4c12383+ height: 120961+ time: 1304132499+ block_index: 120961+ - hash: 0000000000002c920cf7e4406b969ae9c807b5c4f271f490ca3de1b0770836fc+ height: 120960+ time: 1304131980+ block_index: 120960+ - hash: 000000000000a384acb522e4e5935ad2bc31366ecf1f16f1f11023e967ef033d+ height: 120959+ time: 1304131540+ block_index: 120959+ - hash: 00000000000018af296a8d96a27105b528e8ab8905e01f2d7c8d4d12f0b09a7f+ height: 120958+ time: 1304131535+ block_index: 120958+ - hash: 000000000000832d2dfa3edf46fc7eadb2477946dd73b0cfd54f6a93a3fa75e9+ height: 120957+ time: 1304129824+ block_index: 120957+ - hash: 00000000000031ca732091ab864e0ca3d01c382d3270aaffb96c755d14be4a15+ height: 120956+ time: 1304129464+ block_index: 120956+ - hash: 00000000000096954f1ea00788a00fd1263b9699f9baa3b6ffaf5a44b9e6e892+ height: 120955+ time: 1304128815+ block_index: 120955+ - hash: 00000000000066f10dd6f10f890f511eb86f61f15dbdb2945a6ab3b7b894f811+ height: 120954+ time: 1304128647+ block_index: 120954+ - hash: 0000000000001e163570aa00712337aeccd4e4cc6d721d46412bdbab40d4f2f5+ height: 120953+ time: 1304128013+ block_index: 120953+ - hash: 0000000000000e0365819cafabe8dee9c85959f911546382503ce3e108fbe9f3+ height: 120952+ time: 1304127626+ block_index: 120952+ - hash: 00000000000095c349dd239d1b2129d57c3323affd8f0604fd5945b6bb94974c+ height: 120951+ time: 1304126785+ block_index: 120951+ - hash: 00000000000067e5370cbdc09ed3b6d07656f697e0551b11fb983b2cb7878121+ height: 120950+ time: 1304126698+ block_index: 120950+ - hash: 00000000000046bc557910c589a83e6117763ee3aae6a1a83df646e72d37235b+ height: 120949+ time: 1304125871+ block_index: 120949+ - hash: 0000000000000fa04c2783eaefd10bcde5917ef54dd6ca628886711c4b0aa805+ height: 120948+ time: 1304125780+ block_index: 120948+ - hash: 000000000000b50ad67288000882c78a19c6d237520c6975e7b048d331d678b3+ height: 120947+ time: 1304125012+ block_index: 120947+ - hash: 0000000000007425ae5ed2957026812f654b9a431cdac9b349b812b964ff6f1a+ height: 120946+ time: 1304124382+ block_index: 120946+ - hash: 000000000000a2c8f100a28745fe76f8a1e70f753d3301c5dbb50becef0884e2+ height: 120945+ time: 1304124257+ block_index: 120945+ - hash: 000000000000ad8259a2a50e456c5af3cab76906e338a062a245d934585b548b+ height: 120944+ time: 1304124200+ block_index: 120944+ - hash: 0000000000000ba80470c0420793db59ace3d4428c71e84b3ad9d351c5c0dc3a+ height: 120943+ time: 1304123958+ block_index: 120943+ - hash: 00000000000038128f013486d0654d304c6c1934e613336f38879e0fe42dea94+ height: 120942+ time: 1304123312+ block_index: 120942+ - hash: 000000000000676c8fda22d1fa9d4e89361045dbdd5a136a5ff79577e4f1d350+ height: 120941+ time: 1304123030+ block_index: 120941+ - hash: 0000000000001734d7eda67ca7c38acef939e68433fbe04d7c1d1b98c3090752+ height: 120940+ time: 1304122790+ block_index: 120940+ - hash: 000000000000922ffbbb889287edc43e0e7dade424c88c2f0623165a793af23f+ height: 120939+ time: 1304122769+ block_index: 120939+ - hash: 000000000000476e861117a3a9c2fd4de1765ad93e120325749c15b9ecef4ced+ height: 120938+ time: 1304122310+ block_index: 120938++ '/blockchain/block-height/{height}':+ get:+ tags:+ - Blockchain+ operationId: getBinfoBlockHeight+ summary: Blocks at Height+ description: |+ The blocks at a height, with their transactions, as Blockchain.info answers.+ parameters:+ - name: height+ in: path+ required: true+ schema:+ type: integer+ example: 100+ - $ref: '#/components/parameters/binfoTxIdIndex'+ - $ref: '#/components/parameters/binfoJsonFormat'+ responses:+ '200':+ description: List of blocks with transaction data.+ content:+ application/json:+ schema:+ type: object+ required:+ - blocks+ properties:+ blocks:+ type: array+ items:+ $ref: '#/components/schemas/BinfoBlock'+ example:+ blocks:+ - hash: 000000007bc154e0fa7ea32218a72fe2c1bb9f86cf8c9ebf9a715ed27fdb229a+ ver: 1+ prev_block: 00000000cd9b12643e6854cb25939b39cd7a1ad0af31a9bd8b2efe67854b1995+ mrkl_root: 2d05f0c9c3e1c226e63b5fac240137687544cf631cd616fd34fd188fc9020866+ time: 1231660825+ bits: 486604799+ next_block:+ - 00000000b69bd8e4dc60580117617a466d5c76ada85fb7b87e9baea01f9d9984+ fee: 0+ nonce: 1573057331+ n_tx: 1+ size: 215+ block_index: 100+ main_chain: true+ height: 100+ weight: 0+ tx:+ - hash: 2d05f0c9c3e1c226e63b5fac240137687544cf631cd616fd34fd188fc9020866+ ver: 1+ vin_sz: 1+ vout_sz: 1+ size: 134+ weight: 536+ fee: 0+ relayed_by: 0.0.0.0+ lock_time: 0+ tx_index: 3589906961130271+ double_spend: false+ time: 1231660825+ block_index: 100+ block_height: 100+ inputs:+ - sequence: 4294967295+ witness: ""+ script: 04ffff001d014d+ index: 0+ prev_out: null+ out:+ - type: 0+ spent: false+ value: 5000000000+ spending_outpoints: []+ n: 0+ tx_index: 3589906961130271+ script: "4104e70a02f5af48a1989bf630d92523c9d14c45c75f7d1b998e962bff6ff999\+ 5fc5bdb44f1793b37495d80324acba7c8f537caaf8432b8d47987313060cc82d\+ 8a93ac"+ addr: bitcoincash:qqt6g6wul02yakpt05amm0hey67lhh7wagrrqxcmys++ '/blockchain/rawtx/{txid}':+ get:+ tags:+ - Blockchain+ operationId: getBinfoRawTx+ summary: Transaction Data+ description: |+ A transaction by id or by short number, as Blockchain.info answers. With+ `format=hex`, the transaction serialized as in the peer-to-peer protocol.++ A short number that matches several transactions gives 409 with their ids.+ parameters:+ - $ref: '#/components/parameters/binfoTxId'+ - $ref: '#/components/parameters/binfoFormat'+ - $ref: '#/components/parameters/binfoTxIdIndex'+ responses:+ '200':+ description: Transaction data.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/BinfoTx'+ example:+ hash: 2d05f0c9c3e1c226e63b5fac240137687544cf631cd616fd34fd188fc9020866+ ver: 1+ vin_sz: 1+ vout_sz: 1+ size: 134+ weight: 536+ fee: 0+ relayed_by: 0.0.0.0+ lock_time: 0+ tx_index: 3589906961130271+ double_spend: false+ time: 1231660825+ block_index: 100+ block_height: 100+ inputs:+ - sequence: 4294967295+ witness: ""+ script: 04ffff001d014d+ index: 0+ prev_out: null+ out:+ - type: 0+ spent: false+ value: 5000000000+ spending_outpoints: []+ n: 0+ tx_index: 3589906961130271+ script: "4104e70a02f5af48a1989bf630d92523c9d14c45c75f7d1b998e962bff6ff999\+ 5fc5bdb44f1793b37495d80324acba7c8f537caaf8432b8d47987313060cc82d\+ 8a93ac"+ addr: bitcoincash:qqt6g6wul02yakpt05amm0hey67lhh7wagrrqxcmys+ application/text:+ schema:+ type: string+ example: "0100000001000000000000000000000000000000000000000000000000000000\+ 0000000000ffffffff0704ffff001d014dffffffff0100f2052a010000004341\+ 04e70a02f5af48a1989bf630d92523c9d14c45c75f7d1b998e962bff6ff9995f\+ c5bdb44f1793b37495d80324acba7c8f537caaf8432b8d47987313060cc82d8a\+ 93ac00000000"+ '404':+ description: Transaction not found.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'+ '409':+ description: Transaction index conflict.+ content:+ applicaton/json:+ schema:+ $ref: '#/components/schemas/Error'+ example:+ error: multiple-tx-index+ message: Multiple txs match that index++ '/blockchain/unspent':+ get:+ tags:+ - Blockchain+ operationId: getBinfoUnspent+ summary: Unspent Outputs+ description: |+ The unspent outputs of addresses and xpubs, newest first, as Blockchain.info+ answers. At most 1,000 per call, 250 by default.+ parameters:+ - $ref: '#/components/parameters/binfoActive'+ - $ref: '#/components/parameters/binfoActiveP2SH'+ - $ref: '#/components/parameters/binfoActiveBech32'+ - $ref: '#/components/parameters/binfoLimit'+ - $ref: '#/components/parameters/binfoConfirmations'+ - $ref: '#/components/parameters/binfoTxIdIndex'+ - $ref: '#/components/parameters/binfoJsonFormat'+ responses:+ '200':+ description: Unspent outputs.+ content:+ application/json:+ schema:+ type: object+ required:+ - unspent_outputs+ properties:+ unspent_outputs:+ type: array+ items:+ $ref: '#/components/schemas/BinfoUnspent'+ notice:+ type: string+ example:+ notice: ""+ unspent_outputs:+ - tx_hash_big_endian: f2ffa044fa3851c88e739ef76f13b960a828b5c1c7a71005a9315969c4924103+ tx_hash: 034192c4695931a90510a7c7c1b528a860b9136ff79e738ec85138fa44a0fff2+ tx_output_n: 0+ script: 76a914a367b88dd29119c89cc23130b7ca101e3f0c30df88ac+ value: 30000+ value_hex: "7530"+ confirmations: 8557+ tx_index: 114565443300134+ xpub:+ m: "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"+ path: M/0/73+ - tx_hash_big_endian: f2ffa044fa3851c88e739ef76f13b960a828b5c1c7a71005a9315969c4924103+ tx_hash: 034192c4695931a90510a7c7c1b528a860b9136ff79e738ec85138fa44a0fff2+ tx_output_n: 1+ script: 76a91452c5ed64626f1dd39f2d8983a0d94b436524cf8188ac+ value: 39325+ value_hex: 00999d+ confirmations: 8557+ tx_index: 114565443300134+ xpub:+ m: "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"+ path: M/1/27+ - tx_hash_big_endian: ae3fc465a80264f45fa6f865ede43017d9826560299b6fb344d5cde84416b931+ tx_hash: 31b91644e8cdd544b36f9b29606582d91730e4ed65f8a65ff46402a865c43fae+ tx_output_n: 0+ script: 76a9145151d153e1e83ee8fd95799c21fbe245c2dedb9588ac+ value: 15669+ value_hex: 3d35+ confirmations: 9853+ tx_index: 1749472394418618+ xpub:+ m: "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"+ path: M/1/26+ '404':+ description: Invalid argument.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'+ post:+ tags:+ - Blockchain+ operationId: postBinfoUnspent+ summary: Unspent Outputs+ description: |+ Like the `GET`, with the parameters in a form in the body.+ requestBody:+ content:+ application/x-www-form-urlencoded:+ schema:+ $ref: '#/components/schemas/BinfoUnspentReq'+ responses:+ '200':+ description: Unspent outputs.+ content:+ application/json:+ schema:+ type: object+ required:+ - unspent_outputs+ properties:+ unspent_outputs:+ type: array+ items:+ $ref: '#/components/schemas/BinfoUnspent'+ notice:+ type: string+ example:+ notice: ""+ unspent_outputs:+ - tx_hash_big_endian: f2ffa044fa3851c88e739ef76f13b960a828b5c1c7a71005a9315969c4924103+ tx_hash: 034192c4695931a90510a7c7c1b528a860b9136ff79e738ec85138fa44a0fff2+ tx_output_n: 0+ script: 76a914a367b88dd29119c89cc23130b7ca101e3f0c30df88ac+ value: 30000+ value_hex: "7530"+ confirmations: 8557+ tx_index: 114565443300134+ xpub:+ m: "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"+ path: M/0/73+ - tx_hash_big_endian: f2ffa044fa3851c88e739ef76f13b960a828b5c1c7a71005a9315969c4924103+ tx_hash: 034192c4695931a90510a7c7c1b528a860b9136ff79e738ec85138fa44a0fff2+ tx_output_n: 1+ script: 76a91452c5ed64626f1dd39f2d8983a0d94b436524cf8188ac+ value: 39325+ value_hex: 00999d+ confirmations: 8557+ tx_index: 114565443300134+ xpub:+ m: "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"+ path: M/1/27+ '404':+ description: Invalid argument.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/blockchain/export-history':+ get:+ tags:+ - Blockchain+ operationId: getBinfoHistory+ summary: Transaction History+ description: |+ The history of a wallet between two dates, one entry per transaction, with+ its value in a currency at the time and now, for accounting.++ Prices come from the price service that the operator configured+ (Blockchain.info by default). If it cannot be reached, the prices are zero+ and the rest of the history is still there.+ parameters:+ - $ref: '#/components/parameters/binfoActive'+ - $ref: '#/components/parameters/binfoActiveP2SH'+ - $ref: '#/components/parameters/binfoActiveBech32'+ - $ref: '#/components/parameters/binfoStart'+ - $ref: '#/components/parameters/binfoEnd'+ - $ref: '#/components/parameters/binfoCurrency'+ - $ref: '#/components/parameters/binfoJsonFormat'+ responses:+ '200':+ description: Transaction history with price.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/BinfoHistory'+ example:+ - tx: f2ffa044fa3851c88e739ef76f13b960a828b5c1c7a71005a9315969c4924103+ amount: -6.75e-6+ time: 10:57:46 GMT +00:00+ exchange_rate_then: 404.5806367+ date: "2021-03-25"+ value_now: -3.86586e-3+ type: sent+ value_then: -2.730919297725e-3+ fee: 6.75e-06+ - tx: ae3fc465a80264f45fa6f865ede43017d9826560299b6fb344d5cde84416b931+ amount: -1.998e-5+ time: 11:27:24 GMT +00:00+ exchange_rate_then: 440.3835254+ date: "2021-03-16"+ value_now: -1.14429456e-2+ type: sent+ value_then: -8.798862837492e-3+ fee: 1.998e-05+ '404':+ description: Invalid argument.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'+ post:+ tags:+ - Blockchain+ operationId: postBinfoHistory+ summary: Transaction History+ description: |+ Like the `GET`, with the parameters in a form in the body.+ requestBody:+ content:+ application/x-www-form-urlencoded:+ schema:+ $ref: '#/components/schemas/BinfoHistoryReq'+ responses:+ '200':+ description: Transaction history with price.+ content:+ application/json:+ schema:+ type: array+ items:+ $ref: '#/components/schemas/BinfoHistory'+ example:+ - tx: f2ffa044fa3851c88e739ef76f13b960a828b5c1c7a71005a9315969c4924103+ amount: -6.75e-6+ time: 10:57:46 GMT +00:00+ exchange_rate_then: 404.5806367+ date: "2021-03-25"+ value_now: -3.86586e-3+ type: sent+ value_then: -2.730919297725e-3+ fee: 6.75e-06+ - tx: ae3fc465a80264f45fa6f865ede43017d9826560299b6fb344d5cde84416b931+ amount: -1.998e-5+ time: 11:27:24 GMT +00:00+ exchange_rate_then: 440.3835254+ date: "2021-03-16"+ value_now: -1.14429456e-2+ type: sent+ value_then: -8.798862837492e-3+ fee: 1.998e-05+ '404':+ description: Invalid argument.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/blockchain/q/addresstohash/{address}':+ get:+ tags:+ - Blockchain+ operationId: getAddressHash+ summary: Address Hash+ description: |+ The hash160 of an address, in hexadecimal, as plain text.++ The `/blockchain/q/` endpoints answer in plain text rather than JSON, as+ those of Blockchain.info do, so that a shell script can use the answer as it+ is.+ parameters:+ - $ref: '#/components/parameters/addressParam'+ responses:+ '200':+ description: Address hash.+ content:+ text/plain:+ schema:+ type: string+ example: 37d070b3c6716a6174080722579fbdddad39bfb3+ '404':+ description: Invalid address.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/blockchain/q/hashtoaddress/{hash}':+ get:+ tags:+ - Blockchain+ operationId: getHashAddress+ summary: Hash Address+ description: |+ The pay-to-public-key-hash address of a hash160, as plain text.+ parameters:+ - $ref: '#/components/parameters/hashParam'+ responses:+ '200':+ description: Hash address.+ content:+ text/plain:+ schema:+ type: string+ example: qqmaqu9nceck5ct5pqrjy4ulhhw66wdlkv2s7f2l9u+ '404':+ description: Invalid hash.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/blockchain/q/addrpubkey/{pubkey}':+ get:+ tags:+ - Blockchain+ operationId: getPubKeyAddress+ summary: Public Key Address+ description: |+ The pay-to-public-key-hash address of a public key, as plain text.+ parameters:+ - $ref: '#/components/parameters/pubKeyParam'+ responses:+ '200':+ description: Public key address.+ content:+ text/plain:+ schema:+ type: string+ example: bitcoincash:qqmaqu9nceck5ct5pqrjy4ulhhw66wdlkv2s7f2l9u+ '404':+ description: Invalid public key.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/blockchain/q/pubkeyaddr/{address}':+ get:+ tags:+ - Blockchain+ operationId: getAddressPubKey+ summary: Address Public Key+ description: |+ The public key of an address, in hexadecimal, as plain text.++ An address is a hash of its public key, so the key can only be found once the+ address has spent something: the server looks for an input that spends from+ the address among its recent transactions, and reads the key from it. An+ address that has never spent gives 404.+ parameters:+ - $ref: '#/components/parameters/addressParam'+ responses:+ '200':+ description: Address public key.+ content:+ text/plain:+ schema:+ type: string+ example: 027eb504988cbc043dd4c9fac51685d68718a24d7f9f1d4c07b9f8c3d04327794c+ '404':+ description: Invalid address.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/blockchain/q/hashpubkey/{pubkey}':+ get:+ tags:+ - Blockchain+ operationId: getPubKeyHash+ summary: Public Key Hash+ description: |+ The hash160 of a public key, in hexadecimal, as plain text.+ parameters:+ - $ref: '#/components/parameters/pubKeyParam'+ responses:+ '200':+ description: Public key hash.+ content:+ text/plain:+ schema:+ type: string+ example: 37d070b3c6716a6174080722579fbdddad39bfb3+ '404':+ description: Invalid public key.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/blockchain/q/getblockcount':+ get:+ tags:+ - Blockchain+ operationId: getBlockCount+ summary: Block Count+ description: |+ The height of the newest block header, as plain text. Headers arrive before+ blocks, so it can be ahead of `/block/best`.+ responses:+ '200':+ description: Height of block chain.+ content:+ text/plain:+ schema:+ type: number+ example: 688824++ '/blockchain/q/latesthash':+ get:+ tags:+ - Blockchain+ operationId: getLatestHash+ summary: Latest Block Hash+ description: |+ The hash of the newest block header, as plain text.+ responses:+ '200':+ description: Latest block hash.+ content:+ text/plain:+ schema:+ type: string+ example: 0000000000000000005d55b8ab4de4c864b815edf9fb52278d69a614c3c7c72a++ '/blockchain/q/bcperblock':+ get:+ tags:+ - Blockchain+ operationId: getBlockSubsidy+ summary: Block Subsidy+ description: |+ The subsidy of the next block, in satoshis, as plain text: the new coins its+ miner will create, which halve every 210,000 blocks.+ responses:+ '200':+ description: Block subsidy for next block.+ content:+ text/plain:+ schema:+ type: number+ example: 6.25++ '/blockchain/q/txtotalbtcoutput/{txid}':+ get:+ tags:+ - Blockchain+ operationId: getTotalOutput+ summary: Total Output+ description: |+ The total value of the outputs of a transaction, in satoshis, as plain text.+ parameters:+ - $ref: '#/components/parameters/binfoTxId'+ responses:+ '200':+ description: Total amount in transaction outputs.+ content:+ text/plain:+ schema:+ type: number+ example: 9.94e-4+ '404':+ description: Invalid transaction.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/blockchain/q/txtotalbtcinput/{txid}':+ get:+ tags:+ - Blockchain+ operationId: getTotalInput+ summary: Total Spent+ description: |+ The total value of the outputs that a transaction spends, in satoshis, as+ plain text.+ parameters:+ - $ref: '#/components/parameters/binfoTxId'+ responses:+ '200':+ description: Total amount spent by transaction.+ content:+ text/plain:+ schema:+ type: number+ example: 9.9973e-4+ '404':+ description: Invalid transaction.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/blockchain/q/txfee/{txid}':+ get:+ tags:+ - Blockchain+ operationId: getTxFee+ summary: Transaction Fee+ description: |+ The fee of a transaction, in satoshis, as plain text: what it spends minus+ what it pays out.+ parameters:+ - $ref: '#/components/parameters/binfoTxId'+ responses:+ '200':+ description: Fees paid by transaction.+ content:+ text/plain:+ schema:+ type: number+ example: 5.73e-6+ '404':+ description: Invalid transaction.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/blockchain/q/txresult/{txid}/{address}':+ get:+ tags:+ - Blockchain+ operationId: getTxResult+ summary: Transaction Address Amount+ description: |+ How much a transaction changes the balance of an address, in satoshis, as+ plain text: what it pays to the address minus what it spends from it.+ parameters:+ - $ref: '#/components/parameters/addressParam'+ - $ref: '#/components/parameters/binfoTxId'+ responses:+ '200':+ description: Value of transaction affecting address balance.+ content:+ text/plain:+ schema:+ type: number+ example: 0+ '404':+ description: Invalid transaction or address.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/blockchain/q/getreceivedbyaddress/{address}':+ get:+ tags:+ - Blockchain+ operationId: getReceivedByAddress+ summary: Total Received by Address+ description: |+ The total ever received by an address, in satoshis, as plain text.+ parameters:+ - $ref: '#/components/parameters/addressParam'+ responses:+ '200':+ description: Value of total received by address+ content:+ text/plain:+ schema:+ type: number+ example: 2275183814+ '404':+ description: Invalid address.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/blockchain/q/getsentbyaddress/{address}':+ get:+ tags:+ - Blockchain+ operationId: getSentByAddress+ summary: Total Sent by Address+ description: |+ The total ever sent from an address, in satoshis, as plain text.+ parameters:+ - $ref: '#/components/parameters/addressParam'+ responses:+ '200':+ description: Value of total sent by address+ content:+ text/plain:+ schema:+ type: number+ example: 2275183814+ '404':+ description: Invalid address.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/blockchain/q/addressbalance/{address}':+ get:+ tags:+ - Blockchain+ operationId: getAddressBalance+ summary: Balance for Address+ description: |+ The balance of an address, confirmed and unconfirmed, in satoshis, as plain+ text.+ parameters:+ - $ref: '#/components/parameters/addressParam'+ responses:+ '200':+ description: Total unspent balance for address.+ content:+ text/plain:+ schema:+ type: number+ example: 0+ '404':+ description: Invalid address.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'++ '/blockchain/q/addressfirstseen/{address}':+ get:+ tags:+ - Blockchain+ operationId: getAddressFirstSeen+ summary: Address First Seen+ description: |+ The time of the first block with a transaction of an address, as a Unix+ timestamp in plain text, or `0` if it has none.+ parameters:+ - $ref: '#/components/parameters/addressParam'+ responses:+ '200':+ description: Time when address was first seen.+ content:+ text/plain:+ schema:+ type: number+ example: 1621533785+ '404':+ description: Invalid address.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error'+++ '/dbstats':+ get:+ tags:+ - Network+ summary: Database Statistics+ operationId: getDbStats+ description: |+ How the database of this server uses memory, by column family, and the+ statistics that RocksDB keeps, as plain text for the operator to read. The+ format is that of RocksDB and may change between versions, so do not parse+ it.+ responses:+ '200':+ description: Memory use and statistics of RocksDB.+ content:+ text/plain:+ schema:+ type: string++components:+ schemas:++ BlockRef:+ description: |+ Where a transaction or output is: in a block, with its height and position,+ or in the mempool, with the time this server first saw it. A transaction+ moves from one form to the other when it is confirmed, or back after a+ reorganisation.+ oneOf:+ - $ref: '#/components/schemas/Block'+ - $ref: '#/components/schemas/Mempool'++ Block:+ type: object+ description: |+ A place in a block.+ required:+ - height+ - position+ properties:+ height:+ type: integer+ description: |+ Height of the block.+ position:+ type: integer+ description: |+ Position of the transaction in the block, from 0.++ Mempool:+ type: object+ description: |+ A place in the mempool.+ required:+ - mempool+ properties:+ mempool:+ type: integer+ description: |+ Unix time when this server first saw the transaction. Other servers may+ have seen it at another time; it orders the unconfirmed transactions.+ format: int64++ XPubBalance:+ type: object+ description: |+ The balance of one address of a wallet, with its derivation path below the+ extended public key.+ required:+ - path+ - balance+ properties:+ path:+ $ref: '#/components/schemas/XPubPath'+ balance:+ $ref: '#/components/schemas/Balance'++ XPubPath:+ type: array+ description: |+ Derivation path of an address below the extended public key: the chain+ (`0` receiving, `1` change) and the index of the address in it, so `[1, 4]`+ is the fifth change address.+ items:+ type: integer++ XPubUnspent:+ type: object+ description: |+ An unspent output of a wallet, with the derivation path of its address, so+ the wallet knows which key signs for it.+ required:+ - path+ - unspent+ properties:+ path:+ $ref: '#/components/schemas/XPubPath'+ unspent:+ $ref: '#/components/schemas/Unspent'++ XPubSummary:+ type: object+ description: |+ The balance of a wallet and how far it has used its addresses.+ required:+ - balance+ - indices+ properties:+ balance:+ type: object+ description: |+ Totals over every address of the wallet, in satoshis: `confirmed`,+ `unconfirmed` and `received` as in an address balance, and `utxo`, the+ number of unspent outputs.+ required:+ - confirmed+ - unconfirmed+ - received+ - utxo+ properties:+ confirmed:+ description: Confirmed balance for all addresses.+ type: integer+ unconfirmed:+ description: Unconfirmed balance for all addresses.+ type: integer+ received:+ description: Total received on all external addresses.+ type: integer+ utxo:+ description: Unspent transaction output count.+ type: integer+ indices:+ type: object+ description: |+ Index of the last used address of the receiving chain (`external`) and of+ the change chain (`change`). The next unused address is one past it.+ required:+ - external+ - change+ properties:+ external:+ type: integer+ change:+ type: integer++ TxRef:+ type: object+ description: |+ A transaction id and where the transaction is. Lists of transactions return+ these rather than full transactions, which are much larger; fetch the ones+ you need.+ required:+ - block+ - txid+ properties:+ block:+ $ref: '#/components/schemas/BlockRef'+ txid:+ type: string+ description: |+ Transaction id.++ Balance:+ type: object+ description: |+ The balance of an address. `confirmed` plus `unconfirmed` is what the address+ holds now, counting unconfirmed transactions; `confirmed` alone is what it+ holds in coins that are in blocks and are not being spent.+ required:+ - address+ - confirmed+ - unconfirmed+ - utxo+ - received+ - txs+ properties:+ address:+ type: string+ description: |+ The address.+ confirmed:+ type: integer+ description: |+ Sum of the unspent outputs of the address that are confirmed, in satoshis.+ An output spent by an unconfirmed transaction no longer counts.+ format: int64+ unconfirmed:+ type: integer+ description: |+ Sum of the unspent outputs of the address that are not confirmed yet, in+ satoshis.+ format: int64+ utxo:+ type: integer+ description: |+ Number of unspent outputs.+ format: int64+ received:+ type: integer+ description: |+ Total ever paid to the address, spent or not, confirmed or not, in+ satoshis.+ format: int64+ txs:+ type: integer+ description: |+ Number of transactions that pay to or spend from the address.+ format: int64++ Unspent:+ type: object+ description: |+ An output that is not spent yet: a coin that can be spent.+ required:+ - address+ - block+ - txid+ - index+ - pkscript+ - value+ properties:+ address:+ type: string+ description: |+ Address of the output. Null for scripts that have no address, such as+ data outputs or bare multisig.+ nullable: true+ block:+ $ref: '#/components/schemas/BlockRef'+ txid:+ type: string+ description: |+ Id of the transaction that created the output.+ index:+ type: integer+ description: |+ Position of the output in that transaction, from 0.+ pkscript:+ type: string+ description: |+ Output script in hexadecimal, needed to sign a spend.+ value:+ type: integer+ description: |+ Value in satoshis.+ format: int64++ BlockData:+ type: object+ description: |+ A block, with its header and what the server computed from it.+ required:+ - hash+ - height+ - mainchain+ - previous+ - time+ - version+ - bits+ - nonce+ - size+ - tx+ - merkle+ - subsidy+ - fees+ - outputs+ - work+ properties:+ hash:+ type: string+ description: |+ Block hash.+ size:+ type: integer+ description: |+ Size of the serialized block, in bytes, witness data included.+ height:+ type: integer+ description: |+ Height in its chain.+ mainchain:+ type: boolean+ description: |+ Whether the block is in the chain with the most work. Blocks of chains+ abandoned in a reorganisation are kept, with `false`.+ previous:+ type: string+ description: |+ Hash of the previous block.+ time:+ type: integer+ description: |+ Timestamp set by the miner, as a Unix time. Only roughly right.+ format: int64+ version:+ type: integer+ description: |+ Block version.+ format: int64+ bits:+ type: integer+ description: |+ Proof-of-work target, in compact form.+ format: int64+ nonce:+ type: integer+ description: |+ Nonce found by the miner.+ format: int64+ tx:+ type: array+ description: |+ Ids of the transactions, in order; the first is the coinbase.+ items:+ type: string+ merkle:+ type: string+ description: |+ Merkle root of the transactions.+ subsidy:+ type: integer+ description: |+ New coins created by the block, in satoshis.+ format: int64+ fees:+ type: integer+ description: |+ Sum of the fees of its transactions, in satoshis.+ format: int64+ outputs:+ type: integer+ description: |+ Sum of the outputs of its transactions, in satoshis.+ format: int64+ weight:+ type: integer+ description: |+ Block weight, on networks with SegWit only.+ work:+ type: integer+ description: |+ Proof of work accumulated by the chain up to this block.++ Input:+ type: object+ description: |+ An input of a transaction, with the address and value of the output it+ spends, which the raw transaction does not contain and which the server+ looks up.+ required:+ - coinbase+ - txid+ - output+ - sigscript+ - sequence+ - pkscript+ - witness+ - value+ - address+ properties:+ coinbase:+ type: boolean+ description: |+ Whether this is the input of a coinbase, which spends nothing.+ txid:+ type: string+ description: |+ Id of the transaction whose output is spent.+ output:+ type: integer+ description: |+ Position of that output in its transaction.+ sigscript:+ type: string+ description: |+ Input script in hexadecimal.+ value:+ type: integer+ description: |+ Value of the output spent, in satoshis.+ format: int64+ nullable: true+ sequence:+ type: integer+ description: |+ Sequence number.+ format: int64+ address:+ type: string+ description: |+ Address of the output spent; null if it has none.+ nullable: true+ pkscript:+ type: string+ description: |+ Script of the output spent, in hexadecimal.+ nullable: true+ witness:+ type: array+ description: |+ Witness stack, each item in hexadecimal; empty without SegWit.+ items:+ type: string++ Spender:+ type: object+ description: |+ The input that spends an output.+ required:+ - txid+ - input+ properties:+ txid:+ type: string+ description: |+ Id of the spending transaction.+ input:+ type: integer+ description: |+ Position of the input in that transaction.++ Output:+ type: object+ description: |+ An output of a transaction, with whether and by what it is spent.+ required:+ - address+ - pkscript+ - value+ - spent+ properties:+ address:+ type: string+ description: |+ Address of the output; null for scripts without one.+ nullable: true+ pkscript:+ type: string+ description: |+ Output script in hexadecimal.+ value:+ type: integer+ description: |+ Value in satoshis.+ format: int64+ spent:+ type: boolean+ description: |+ Whether a transaction spends it, confirmed or not.+ spender:+ description: |+ The input that spends it, if any.+ allOf:+ - $ref: '#/components/schemas/Spender'++ Transaction:+ type: object+ description: |+ A transaction with what the server knows about it beyond its raw form: its+ place in the chain, its fee, and, for each input and output, the address and+ value involved.+ required:+ - txid+ - size+ - version+ - locktime+ - fee+ - inputs+ - outputs+ - block+ - deleted+ - time+ - rbf+ - weight+ properties:+ txid:+ type: string+ description: |+ Transaction id.+ size:+ type: integer+ description: |+ Size of the serialized transaction, in bytes, witness data included.+ version:+ type: integer+ description: |+ Transaction version.+ format: int64+ locktime:+ type: integer+ description: |+ Lock time.+ format: int64+ fee:+ type: integer+ description: |+ Fee in satoshis: inputs minus outputs; 0 for a coinbase.+ format: int64+ inputs:+ type: array+ items:+ $ref: '#/components/schemas/Input'+ outputs:+ type: array+ items:+ $ref: '#/components/schemas/Output'+ block:+ $ref: '#/components/schemas/BlockRef'+ deleted:+ type: boolean+ description: |+ Always false. The server no longer returns transactions that were+ removed: replaced by fee, double-spent by a confirmed transaction, or+ the coinbase of a block that left the main chain. The field is kept so+ that clients that read it go on working.+ time:+ type: integer+ description: |+ Timestamp of its block, or, if unconfirmed, the Unix time this server first+ saw it; in a fleet, the time at which the first server of the fleet did. A+ confirmed transaction has the time of its block on every server, whether+ the server saw it before the block or not: until database version 22 a+ server that had seen it unconfirmed went on giving the time of that. A+ transaction that goes back to the mempool because its block left the main+ chain keeps the time of that block.+ format: int64+ rbf:+ type: boolean+ description: |+ Whether the transaction can still be replaced by one paying a higher fee:+ it signals replace-by-fee (BIP 125), or spends an unconfirmed transaction+ that does. Only meaningful while unconfirmed, and on BTC.+ weight:+ type: integer+ description: |+ Transaction weight, as SegWit counts it.++ Event:+ type: object+ description: |+ Something that happened, as `/events` sends it.+ required:+ - type+ - id+ properties:+ type:+ type: string+ description: |+ `block` for a new best block, `tx` for a mempool change.+ enum:+ - tx+ - block+ id:+ type: string+ description: |+ Hash of the block or id of the transaction.++ Peer:+ type: object+ description: |+ A node of the peer-to-peer network that the server is connected to.+ required:+ - useragent+ - address+ - version+ - services+ - relay+ properties:+ useragent:+ type: string+ description: |+ Software of the peer, as it announces itself.+ address:+ type: string+ description: |+ Network address of the peer.+ version:+ type: integer+ description: |+ Protocol version from the version message of the peer.+ format: int32+ services:+ type: string+ description: |+ Services the peer offers, as a bit field in hexadecimal.+ relay:+ type: boolean+ description: |+ Whether the peer relays transactions.++ BlockHealth:+ type: object+ description: |+ Whether the blocks are caught up with the headers.+ required:+ - headers+ - blocks+ - diff+ - max+ - ok+ properties:+ headers:+ type: integer+ description: |+ Height of the newest header.+ blocks:+ type: integer+ description: |+ Height of the newest imported block.+ diff:+ type: integer+ description: |+ Headers minus blocks.+ max:+ type: integer+ description: |+ Most blocks the server may be behind and still be healthy.+ ok:+ type: boolean+ description: |+ Whether `diff` is at most `max`.++ TimeHealth:+ type: object+ description: |+ Whether something happened recently enough.+ required:+ - age+ - max+ - ok+ properties:+ age:+ type: integer+ description: |+ Seconds since it happened.+ max:+ type: integer+ description: |+ Most seconds allowed.+ ok:+ type: boolean+ description: |+ Whether `age` is at most `max`.++ CountHealth:+ type: object+ description: |+ Whether there are enough of something.+ required:+ - count+ - min+ - ok+ properties:+ count:+ type: integer+ description: |+ How many there are.+ min:+ type: integer+ description: |+ How many are needed.+ ok:+ type: boolean+ description: |+ Whether `count` is at least `min`.++ MaxHealth:+ type: object+ description: |+ Whether there are not too many of something.+ required:+ - count+ - max+ - ok+ properties:+ count:+ type: integer+ description: |+ How many there are.+ max:+ type: integer+ description: |+ Most allowed.+ ok:+ type: boolean+ description: |+ Whether `count` is at most `max`.++ HealthCheck:+ type: object+ description: |+ The result of the latest health check. `ok` is true when every check passes.+ The limits are set by the operator of the server.+ required:+ - blocks+ - last-block+ - last-tx+ - pending-txs+ - peers+ - net+ - version+ - ok+ properties:+ blocks:+ description: |+ Whether the imported blocks have caught up with the headers.+ allOf:+ - $ref: '#/components/schemas/BlockHealth'+ last-block:+ description: |+ Age of the newest header. A network that has found no block for hours+ usually means the server lost touch with it.+ allOf:+ - $ref: '#/components/schemas/TimeHealth'+ last-tx:+ description: |+ Age of the newest unconfirmed transaction (or block, if newer). A busy+ network sends transactions all the time, so silence means trouble.+ allOf:+ - $ref: '#/components/schemas/TimeHealth'+ pending-txs:+ description: |+ Transactions received but not imported yet.+ allOf:+ - $ref: '#/components/schemas/MaxHealth'+ peers:+ description: |+ Connected peers.+ allOf:+ - $ref: '#/components/schemas/CountHealth'+ redis:+ type: object+ description: |+ Whether the Redis cache answers. Only for a server that has one. It does+ not count towards `ok`: a server without its Redis still answers every+ request, from its own database, and `/health` gives 203.+ required:+ - ok+ properties:+ ok:+ type: boolean+ net:+ type: string+ description: |+ Network of the server.+ version:+ type: string+ description: |+ Version of Haskoin Store.+ ok:+ type: boolean+ description: |+ Whether every check passed.+ time:+ type: string+ description: |+ When the check ran.++ Error:+ type: object+ description: |+ An error. `error` is a stable code for programs to test; `message` is for+ people and may change. See *Errors* at the top.+ required:+ - error+ properties:+ error:+ type: string+ description: |+ Error code, such as `not-found-or-invalid-arg` or `user-error`.+ example: not-found-or-invalid-arg+ message:+ type: string+ description: |+ What went wrong, for people.+ example: Item not found or argument invalid+ TxId:+ type: object+ description: |+ The id of a transaction.+ required:+ - txid+ properties:+ txid:+ type: string+ description: |+ Transaction id.++ BinfoTxIdHash:+ type: string++ BinfoTxIdIndex:+ type: integer++ BinfoTxId:+ description: |+ A transaction id in hexadecimal, or its short number (see the `txidindex`+ parameter).+ oneOf:+ - $ref: '#/components/schemas/BinfoTxIdHash'+ - $ref: '#/components/schemas/BinfoTxIdIndex'+ example: 2d05f0c9c3e1c226e63b5fac240137687544cf631cd616fd34fd188fc9020866++ BinfoWallet:+ type: object+ required:+ - final_balance+ - n_tx+ - n_tx_filtered+ - total_received+ - total_sent+ properties:+ final_balance:+ type: integer+ n_tx:+ type: integer+ description: |+ Number of transactions of the addresses and extended public keys+ shown (`onlyShow`, or all the active ones), each counted once, not+ only those on this page.+ n_tx_filtered:+ type: integer+ description: |+ Number of those transactions that pass the `filter`, not only+ those on this page.+ total_received:+ type: integer+ total_sent:+ type: integer+++ BinfoSymbol:+ type: object+ required:+ - code+ - symbol+ - name+ - conversion+ - symbolAppearsAfter+ - local+ properties:+ code:+ type: string+ symbol:+ type: string+ name:+ type: string+ conversion:+ type: number+ format: double+ symbolAppearsAfter:+ type: boolean+ local:+ type: boolean++ BinfoBlockInfo:+ type: object+ required:+ - hash+ - height+ - time+ - block_index+ properties:+ hash:+ type: string+ height:+ type: integer+ time:+ type: integer+ block_index:+ type: integer++ BinfoInfo:+ type: object+ required:+ - nconnected+ - conversion+ - symbol_local+ - symbol_btc+ - latest_block+ properties:+ nconnected:+ type: integer+ conversion:+ type: number+ format: double+ symbol_local:+ $ref: '#/components/schemas/BinfoSymbol'+ symbol_btc:+ $ref: '#/components/schemas/BinfoSymbol'+ latest_block:+ $ref: '#/components/schemas/BinfoBlockInfo'++ BinfoXPubBalance:+ type: object+ required:+ - address+ - final_balance+ - n_tx+ - total_received+ - total_sent+ - account_index+ - change_index+ properties:+ address:+ type: string+ final_balance:+ type: integer+ format: int64+ n_tx:+ type: integer+ total_received:+ type: integer+ format: int64+ total_sent:+ type: integer+ format: int64+ account_index:+ type: integer+ change_index:+ type: integer+++ BinfoAddrBalance:+ type: object+ required:+ - address+ - final_balance+ - n_tx+ - total_received+ - total_sent+ properties:+ address:+ type: string+ final_balance:+ type: integer+ format: int64+ n_tx:+ type: integer+ total_received:+ type: integer+ format: int64+ total_sent:+ type: integer+ format: int64++ BinfoRawAddr:+ type: object+ required:+ - hash160+ - address+ - n_tx+ - n_unredeemed+ - total_received+ - total_sent+ - final_balance+ - txs+ properties:+ hash160:+ type: string+ address:+ type: string+ n_tx:+ type: integer+ description: |+ Number of transactions of the address or extended public key, not+ only those on this page.+ n_unredeemed:+ type: integer+ total_received:+ type: integer+ format: int64+ total_sent:+ type: integer+ format: int64+ final_balance:+ type: integer+ format: int64+ txs:+ type: array+ items:+ $ref: '#/components/schemas/BinfoTx'++ BinfoBalance:+ oneOf:+ - $ref: '#/components/schemas/BinfoXPubBalance'+ - $ref: '#/components/schemas/BinfoAddrBalance'++ BinfoShortBalance:+ type: object+ required:+ - final_balance+ - n_tx+ - total_received+ properties:+ final_balance:+ type: integer+ format: int64+ n_tx:+ type: integer+ total_received:+ type: integer+ format: int64++ BinfoMultiAddr:+ type: object+ required:+ - wallet+ - info+ - addresses+ - recommend_include_fee+ properties:+ wallet:+ $ref: '#/components/schemas/BinfoWallet'+ info:+ $ref: '#/components/schemas/BinfoInfo'+ addresses:+ type: array+ items:+ $ref: '#/components/schemas/BinfoBalance'+ txs:+ type: array+ items:+ $ref: '#/components/schemas/BinfoTx'+ recommend_include_fee:+ type: boolean+ cash_addr:+ type: boolean++ BinfoTxInput:+ type: object+ required:+ - index+ - script+ - prev_out+ - sequence+ - witness+ properties:+ index:+ type: integer+ script:+ type: string+ prev_out:+ $ref: '#/components/schemas/BinfoTxOutput'+ sequence:+ type: integer+ witness:+ type: string++ BinfoXPubPath:+ type: object+ required:+ - 'm'+ - path+ properties:+ m:+ type: string+ path:+ type: string++ BinfoSpender:+ type: object+ required:+ - tx_index+ - 'n'+ properties:+ tx_index:+ $ref: '#/components/schemas/BinfoTxId'+ n:+ type: integer++ BinfoTxOutput:+ type: object+ required:+ - tx_index+ - 'n'+ - type+ - spent+ - value+ - spending_outpoints+ - script+ properties:+ type:+ type: integer+ spent:+ type: boolean+ value:+ type: integer+ format: int64+ spending_outpoints:+ $ref: '#/components/schemas/BinfoSpender'+ n:+ type: integer+ tx_index:+ $ref: '#/components/schemas/BinfoTxId'+ script:+ type: string+ addr:+ type: string+ xpub:+ $ref: '#/components/schemas/BinfoXPubPath'++ BinfoHeader:+ type: object+ required:+ - hash+ - time+ - block_index+ - height+ - txIndexes+ properties:+ hash:+ type: string+ time:+ type: integer+ block_index:+ type: integer+ height:+ type: integer+ txIndexes:+ type: array+ items:+ $ref: '#/components/schemas/BinfoTxId'+++ BinfoBlock:+ type: object+ required:+ - hash+ - ver+ - prev_block+ - mrkl_root+ - time+ - bits+ - next_block+ - fee+ - nonce+ - n_tx+ - size+ - block_index+ - main_chain+ - height+ - weight+ - tx+ properties:+ hash:+ type: string+ ver:+ type: integer+ prev_block:+ type: string+ mrkl_root:+ type: string+ time:+ type: integer+ bits:+ type: integer+ next_block:+ type: array+ items:+ type: string+ fee:+ type: integer+ format: int64+ nonce:+ type: integer+ n_tx:+ type: integer+ size:+ type: integer+ block_index:+ type: integer+ main_chain:+ type: boolean+ height:+ type: integer+ weight:+ type: integer+ tx:+ type: array+ items:+ $ref: '#/components/schemas/BinfoTx'++ BinfoHistory:+ type: object+ required:+ - date+ - time+ - type+ - amount+ - value_then+ - value_now+ - exchange_rate_then+ - tx+ - fee+ properties:+ date:+ type: string+ time:+ type: string+ type:+ type: string+ enum:+ - sent+ - received+ amount:+ type: number+ value_then:+ type: number+ value_now:+ type: number+ exchange_rate_then:+ type: number+ tx:+ type: string+ fee:+ type: number++ BinfoTx:+ type: object+ required:+ - hash+ - ver+ - vin_sz+ - vout_sz+ - size+ - weight+ - fee+ - relayed_by+ - lock_time+ - tx_index+ - double_spend+ - time+ - block_index+ - block_height+ - inputs+ - out+ properties:+ hash:+ type: string+ ver:+ type: integer+ vin_sz:+ type: integer+ vout_sz:+ type: integer+ size:+ type: integer+ weight:+ type: integer+ fee:+ type: integer+ format: int64+ relayed_by:+ type: string+ lock_time:+ type: integer+ tx_index:+ $ref: '#/components/schemas/BinfoTxId'+ double_spend:+ type: boolean+ time:+ type: integer+ block_index:+ type: integer+ block_height:+ type: integer+ inputs:+ type: array+ items:+ $ref: '#/components/schemas/BinfoTxInput'+ out:+ type: array+ items:+ $ref: '#/components/schemas/BinfoTxOutput'+ result:+ type: integer+ format: int64+ balance:+ type: integer+ format: int64+ rbf:+ type: boolean++ BinfoUnspent:+ type: object+ required:+ - tx_hash_big_endian+ - tx_hash+ - tx_output_n+ - script+ - value+ - value_hex+ - confirmations+ - tx_index+ properties:+ tx_hash_big_endian:+ type: string+ tx_hash:+ type: string+ tx_output_n:+ type: integer+ script:+ type: string+ value:+ type: integer+ format: int64+ value_hex:+ type: string+ confirmations:+ type: integer+ tx_index:+ $ref: '#/components/schemas/BinfoTxId'++ BinfoBalanceReq:+ type: object+ properties:+ active:+ type: array+ items:+ type: string+ example: "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"+ activeP2SH:+ type: array+ items:+ type: string+ activeBech32:+ type: array+ items:+ type: string+ cashaddr:+ type: boolean+ example: true++ BinfoHistoryReq:+ type: object+ properties:+ active:+ type: array+ items:+ type: string+ example: "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"+ activeP2SH:+ type: array+ items:+ type: string+ activeBech32:+ type: array+ items:+ type: string+ currency:+ type: string+ example: eur+ start:+ type: string+ example: 20-10-2020+ end:+ type: string+ example: 09-04-2021++ BinfoMultiAddrReq:+ type: object+ properties:+ active:+ type: array+ items:+ type: string+ example: "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"+ activeP2SH:+ type: array+ items:+ type: string+ activeBech32:+ type: array+ items:+ type: string+ onlyShow:+ type: array+ items:+ type: string+ n:+ type: integer+ example: 2+ offset:+ type: integer+ example: 5+ noCompact:+ type: boolean+ example: false+ currency:+ type: string+ example: eur+ cashaddr:+ type: boolean+ example: true+ txidindex:+ type: boolean+ example: true+ filter:+ type: integer+ enum: [0,1,2,3,5,7]+ example: 5++ BinfoUnspentReq:+ type: object+ properties:+ active:+ type: array+ items:+ type: string+ example: "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"+ activeP2SH:+ type: array+ items:+ type: string+ activeBech32:+ type: array+ items:+ type: string+ limit:+ type: integer+ example: 2+ confirmations:+ type: integer+ example: 6+ txidindex:+ type: boolean+ example: true++ parameters:++ pubKeyParam:+ name: pubkey+ in: path+ description: |+ A public key in hexadecimal, compressed or not.+ required: true+ schema:+ type: string+ example: 027eb504988cbc043dd4c9fac51685d68718a24d7f9f1d4c07b9f8c3d04327794c++ addressParam:+ name: address+ in: path+ description: |+ An address of the network of the server. An address that does not parse,+ or is of another network, gives 404.+ required: true+ schema:+ type: string+ example: qqmaqu9nceck5ct5pqrjy4ulhhw66wdlkv2s7f2l9u++ hashParam:+ name: hash+ in: path+ description: |+ A hash160 (20 bytes) in hexadecimal.+ required: true+ schema:+ type: string+ example: 37d070b3c6716a6174080722579fbdddad39bfb3++ addressesParam:+ name: addresses+ in: query+ description: |+ Addresses, separated by commas. Every address must be valid: one that is not+ fails the whole request with 400, rather than giving an answer that silently+ leaves it out.+ explode: false+ required: true+ schema:+ type: array+ items:+ type: string+ example:+ - qqmaqu9nceck5ct5pqrjy4ulhhw66wdlkv2s7f2l9u+ - qql2wpu4fa36yf07gcc8q80sjfxfaq66xssezyukg2++ limitParam:+ name: limit+ in: query+ description: |+ How many items to return at most. Without it, the server's default (100+ unless the operator changed it). `0` asks for the server's maximum. A+ larger number is lowered to the maximum rather than refused. See *Paging* at+ the top.+ required: false+ schema:+ type: integer++ heightParam:+ name: height+ in: query+ description: |+ Where the page starts: a transaction id (included), a block hash, a block+ height (transactions confirmed at or below it), or a Unix time (the last+ block at or before it). Numbers up to 1,230,768,000 are heights, larger ones+ times. Unconfirmed transactions come first in every list, and a page that+ starts at a height, a block or a time has none of them, even when the height+ is above the best block.++ To get the next page, pass the id of the last transaction you received here,+ with `offset=1`. See *Paging* at the top for why this is better than a large+ offset.+ required: false+ schema:+ oneOf:+ - type: integer+ - type: string++ offsetParam:+ name: offset+ in: query+ description: |+ How many items to skip after the start. Above the server's maximum (50,000+ unless the operator changed it), the request fails with 400; use `height` to+ go further back.+ required: false+ schema:+ type: integer++ nocacheParam:+ name: nocache+ in: query+ description: |+ Answer from the database of this server, without reading the Redis cache and+ without caching the wallet. The answer is the same; this is for comparing,+ and for servers whose cache is shared with others that may be ahead of or+ behind this one. A server of a fleet still answers from the block and the+ mempool that the fleet shares, only without its cache of wallets.+ required: false+ schema:+ type: boolean++ notxParam:+ name: notx+ in: query+ description: |+ Leave out the transaction ids, except the first (the coinbase). A block can+ have thousands of transactions, and pages that list blocks rarely need them.+ required: false+ schema:+ type: boolean++ blockParam:+ name: block+ in: path+ description: |+ A block hash in hexadecimal.+ required: true+ schema:+ type: string+ example: 000000000003ba27aa200b1cecaad478d2b00432346c3f1f3986da1afd33e506++ blocksParam:+ name: blocks+ in: query+ description: |+ Block hashes in hexadecimal, separated by commas.+ explode: false+ required: true+ schema:+ type: array+ items:+ type: string+ example:+ - 000000001aeae195809d120b5d66a39c83eb48792e068f8ea1fea19d84a4278a+ - 000000000845517b31c6820d83f25cff46429bf136a7515fe504116427e60f8e++ blockHeightParam:+ name: height+ in: path+ description: |+ A block height. The first block is at height 0.+ required: true+ schema:+ type: integer+ example: 200++ blockHeightsParam:+ name: heights+ in: query+ description: |+ Block heights, separated by commas.+ explode: false+ required: true+ schema:+ type: array+ items:+ type: integer+ example:+ - 100+ - 200+ - 300++ txidParam:+ name: txid+ in: path+ description: |+ A transaction id in hexadecimal.+ required: true+ schema:+ type: string+ example: 03c16602a41ae7cbc2a25b0d2801fdc3295e2e8ebfb26774030b2f7d538f5568++ timeParam:+ name: time+ in: path+ description: |+ A Unix timestamp, in seconds.+ required: true+ schema:+ type: integer+ example: 1231753120++ txidsParam:+ name: txids+ in: query+ description: |+ Transaction ids in hexadecimal, separated by commas.+ explode: false+ required: true+ schema:+ type: array+ items:+ type: string+ example:+ - 03c16602a41ae7cbc2a25b0d2801fdc3295e2e8ebfb26774030b2f7d538f5568+ - 02ea64dfff60b1f215282bbb4b2d729fecc50d81d2d0ef09a60d550a5a69418f++ xpubParam:+ name: xpub+ in: path+ description: |+ An extended public key of the network of the server: `xpub` on BTC and BCH,+ `tpub` on test networks. Keys that wallets export as `ypub` or `zpub` are+ not accepted; export them as `xpub` and say the kind of address with+ `derive`.+ required: true+ schema:+ type: string+ example: "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"++ xpubDerive:+ name: derive+ in: query+ description: |+ The kind of address the wallet derives from the key:++ - `legacy`: pay-to-public-key-hash (P2PKH), addresses starting with `1`+ on BTC; the default, and the only kind on networks without SegWit, such as+ BCH. `normal`, its name up to version 1.8, is still accepted, so old+ URLs keep working;+ - `compat`: SegWit nested in pay-to-script-hash (P2SH-P2WPKH), addresses+ starting with `3`, as BIP 49 wallets use;+ - `segwit`: native SegWit (P2WPKH), addresses starting with `bc1q`, as BIP+ 84 wallets use.++ An xpub does not record which kind its wallet uses, and each kind gives+ different addresses from the same key. When the wallet has no+ transactions with this kind but has some with another, the request+ fails with 400 and a message that names the right kind, rather than+ answering with an empty wallet.+ required: false+ schema:+ type: string+ enum: [legacy, compat, segwit, normal]+ default: legacy++ prettyParam:+ name: pretty+ in: query+ description: |+ Indent the JSON, for people to read. Responses are compact by default to+ save bandwidth.+ required: false+ schema:+ type: boolean++ binfoAddr:+ name: address+ in: path+ description: |+ An address or an extended public key.+ required: true+ schema:+ type: string+ example: qqmaqu9nceck5ct5pqrjy4ulhhw66wdlkv2s7f2l9u++ binfoActive:+ name: active+ in: query+ description: |+ Addresses and extended public keys of the wallet, separated by `|` or `,`.+ Extended public keys here derive legacy (P2PKH) addresses.+ required: false+ schema:+ type: string+ example: "xpub6By8JDaPr5L6oHfgQDc47quD69qH1hTwnFYbuia8paiYxSE9u84K\+ ZfYqn6xLMUqxKK3wNpsgP4Kwu1gzXHD5xBxj5HrLposEYL6PwZzpAMZ"++ binfoActiveP2SH:+ name: activeP2SH+ in: query+ description: |+ Extended public keys that derive SegWit addresses nested in+ pay-to-script-hash (P2SH-P2WPKH), separated by `|` or `,`.+ required: false+ schema:+ type: string++ binfoActiveBech32:+ name: activeBech32+ in: query+ description: |+ Extended public keys that derive native SegWit (P2WPKH) addresses, separated+ by `|` or `,`.+ required: false+ schema:+ type: string++ binfoOnlyShow:+ name: onlyShow+ in: query+ description: |+ The addresses and extended public keys, among the active ones, whose+ balances to list and from whose point of view to compute the result of each+ transaction, separated by `|` or `,`. All the active ones if left out.+ required: false+ schema:+ type: string++ binfoFilter:+ name: filter+ in: query+ description: |+ Which transactions to list (`0` or `6` for all of them):++ - `1`: those that send coins out of the wallet;+ - `2`: those that bring coins into the wallet;+ - `3`: those that move coins within the wallet, changing its balance by+ the fee only;+ - `5`: confirmed ones;+ - `7`: unconfirmed ones.+ required: false+ schema:+ type: integer+ enum: [0,1,2,3,5,7]+ example: 5++ binfoLimit:+ name: limit+ in: query+ description: |+ How many items to return at most. The default and maximum are those of the+ server.+ required: false+ schema:+ type: integer+ example: 2++ binfoCount:+ name: n+ in: query+ description: |+ How many transactions to return at most. The default and maximum are those+ of the server for full transactions.+ required: false+ schema:+ type: integer+ example: 2++ binfoOffset:+ name: offset+ in: query+ description: |+ How many items to skip at the start. Above the server's maximum, the request+ fails with 400.+ required: false+ schema:+ type: integer++ binfoConfirmations:+ name: confirmations+ in: query+ description: |+ Only return outputs with at least this many confirmations. `0`, the default,+ includes unconfirmed ones.+ required: false+ schema:+ type: integer++ binfoNoCompact:+ name: noCompact+ in: query+ description: |+ Keep the outputs of incoming transactions that pay to other addresses. They+ are left out by default, as they belong to other people and can be many.+ `no_compact` is accepted as another name for it; `noCompact` wins if both+ are given.+ required: false+ schema:+ type: boolean+ example: false++ binfoCurrency:+ name: currency+ in: query+ description: |+ The currency for the `info` section, such as `USD`, `EUR` or `GBP`, in any+ case. `USD` by default. A currency that the price service does not list+ is refused. Until the server has prices, which it does not if the price+ service cannot be reached, every currency is accepted with a price of+ zero.+ required: false+ schema:+ type: string+ example: eur++ binfoCashAddr:+ name: cashaddr+ in: query+ description: |+ Write Bitcoin Cash addresses in CashAddr format. They are in the legacy+ format by default, as Blockchain.info writes them, so that software written+ for it reads them.+ required: false+ schema:+ type: boolean+ example: true++ binfoTxIdIndex:+ name: txidindex+ in: query+ description: |+ Identify transactions by their id (`true`) rather than by a short number+ (the default).++ Blockchain.info identifies transactions by a number, and software written+ for it expects one. The server derives it from the transaction id: the first+ 64 bits, shifted right by 11, so that it fits in the 53 bits that numbers in+ JavaScript hold exactly. Two transactions can share a number, rarely; the+ ids never collide, so use them when you can.+ required: false+ schema:+ type: boolean+ example: true++ binfoBlockId:+ name: block+ in: path+ description: |+ A block hash in hexadecimal, or a block height.+ required: true+ schema:+ oneOf:+ - type: string+ - type: integer+ example: 50000++ binfoTxId:+ name: txid+ in: path+ description: |+ A transaction id in hexadecimal, or the short number of a transaction (see+ `txidindex`).+ required: true+ schema:+ oneOf:+ - type: string+ - type: integer+ example: 369c2a28fc84b060e4423e26ac898fa4ce9eda8943a36d0a48df66e6a6275aa6++ binfoJsonFormat:+ name: format+ in: query+ description: |+ Only `json` is accepted. Software written for Blockchain.info often sends+ `format=json`, so it is accepted; any other value is refused rather than+ answered in JSON anyway.+ required: false+ schema:+ type: string+ enum: [json]++ binfoFormat:+ name: format+ in: query+ description: |+ `json`, the default, or `hex` for the data serialized as in the peer-to-peer+ protocol.+ required: false+ schema:+ type: string+ enum:+ - json+ - hex++ binfoStart:+ name: start+ in: query+ description: |+ The first day of the history, as `dd-mm-yyyy` or `dd/mm/yyyy`.+ required: true+ schema:+ type: string+ example: 20-10-2020++ binfoEnd:+ name: end+ in: query+ description: |+ The last day of the history, included, as `dd-mm-yyyy` or `dd/mm/yyyy`.+ required: true+ schema:+ type: string+ example: 09-04-2021
@@ -0,0 +1,94 @@+-- | Mempool transactions on their way into the database.+module Haskoin.Store.BlockStore.PendingSpec (spec) where++import Data.ByteString (ByteString)+import Data.HashMap.Strict qualified as HashMap+import Data.HashSet qualified as HashSet+import Data.List (elemIndex)+import Data.Time.Calendar (fromGregorian)+import Data.Time.Clock (UTCTime (..), addUTCTime)+import Haskoin+import Haskoin.Store.BlockStore.Pending+import Test.Hspec+import Test.Hspec.QuickCheck (prop)+import Test.QuickCheck++t0 :: UTCTime+t0 = UTCTime (fromGregorian 2026 11 9) 0++-- | Transactions where each one spends an output from outside, or an output+-- of an earlier one: parents come first.+family :: Gen [Tx]+family = do+ n <- choose (1, 12)+ go n []+ where+ go 0 acc = return (reverse acc)+ go k acc = do+ parents <- sublistOf (map txHash acc)+ let outside = OutPoint (TxHash (sha256 ("outside" :: ByteString))) (fromIntegral k)+ ins = [TxIn op "" maxBound | op <- outside : [OutPoint h 0 | h <- parents]]+ tx = Tx 2 ins [TxOut 1000 "", TxOut 1000 ""] [] (fromIntegral k)+ go (k - 1 :: Int) (tx : acc)++-- | The parents of a transaction among others.+parentsIn :: [Tx] -> Tx -> [TxHash]+parentsIn txs tx = [op.hash | i <- tx.inputs, let op = i.outpoint, op.hash `elem` map txHash txs]++-- | Receive transactions, each missing those of its parents that are not+-- received before it.+receiveAll :: [Tx] -> Pending -> Pending+receiveAll txs p0 = foldl (\p tx -> received t0 tx (HashSet.fromList (parentsIn txs tx)) p) p0 txs++spec :: Spec+spec = describe "Pending transactions" $ do+ prop "takes only transactions with no missing parents, parents first" $+ forAll family $ \txs -> forAll (sublistOf txs) $ \done -> forAll (choose (0, 20)) $ \n ->+ let p = foldr (imported . txHash) (receiveAll txs empty) done+ (taken, rest) = takeReady n p+ order = map (txHash . (.tx)) taken+ in conjoin+ [ property (length taken <= n),+ property (all (null . (.deps)) taken),+ conjoin+ [ counterexample "a child before its parent" $+ maybe True (> i) (elemIndex h order)+ | (i, t) <- zip [0 ..] taken,+ h <- [txHash c | c <- txs, txHash t.tx `elem` parentsIn txs c]+ ],+ queued rest === queued p - length taken+ ]++ prop "makes a child ready once all its parents are in the database" $+ forAll family $ \txs ->+ let p = foldr (imported . txHash) (receiveAll txs empty) txs+ in fst (takeReady (length txs) p) `shouldMatchList'` txs++ prop "does not request again what is requested or received" $+ forAll family $ \txs ->+ let hs = map txHash txs+ (asked, got) = splitAt (length hs `div` 2) txs+ p = receiveAll got (requested t0 (map txHash asked) empty)+ in all (`isPending` p) hs .&&. size p === length hs++ prop "forgets requests after twenty seconds, and transactions after an hour" $+ forAll family $ \txs ->+ let hs = map txHash txs+ asked = requested t0 hs empty+ got = receiveAll txs empty+ in conjoin+ [ property (all (`isPending` prune (addUTCTime 20 t0) asked) hs),+ property (not (any (`isPending` prune (addUTCTime 21 t0) asked) hs)),+ queued (prune (addUTCTime 3600 t0) got) === length txs,+ queued (prune (addUTCTime 3601 t0) got) === 0+ ]++ prop "drops what reached the database and updates what still waits" $+ forAll family $ \txs ->+ let p = receiveAll txs empty+ (gone, still) = splitAt (length txs `div` 2) (map txHash txs)+ p' = refresh (HashMap.fromList ([(h, Nothing) | h <- gone] <> [(h, Just HashSet.empty) | h <- still])) p+ in queued p' === length still .&&. null (waiting p')+ where+ shouldMatchList' taken txs =+ HashSet.fromList (map (txHash . (.tx)) taken) === HashSet.fromList (map txHash txs)
@@ -0,0 +1,129 @@+-- | The state machine of the block download, driven by random sequences of+-- events, with its rules checked after every step.+module Haskoin.Store.BlockStore.SyncSpec (spec) where++import Data.ByteString.Char8 qualified as C8+import Data.List (nub)+import Data.Maybe (fromMaybe, isNothing)+import Data.Time.Calendar (fromGregorian)+import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, diffUTCTime)+import Haskoin (BlockHash (..), sha256)+import Haskoin.Store.BlockStore.Sync+import Test.Hspec+import Test.Hspec.QuickCheck (prop)+import Test.QuickCheck++-- | Something that happens to the block store, from one of three peers.+data Event+ = Start Int+ | Release Int+ | Request [Int]+ | Import Int+ | Forget+ | Wait Int+ deriving stock (Show)++instance Arbitrary Event where+ arbitrary =+ frequency+ [ (2, Start <$> peer),+ (1, Release <$> peer),+ (3, Request <$> listOf block),+ (3, Import <$> block),+ (1, pure Forget),+ (2, Wait <$> choose (0, 30))+ ]+ where+ peer = choose (0, 2)+ block = choose (0, 9)++-- | A state and the time it is at.+type World = (UTCTime, Sync Int)++hash :: Int -> BlockHash+hash = BlockHash . sha256 . C8.pack . show++t0 :: UTCTime+t0 = UTCTime (fromGregorian 2026 11 9) 0++-- | Apply an event as the block store does: imports only what 'accept'+-- takes.+step :: World -> Event -> World+step (now, s) = \case+ Start p -> (now, fromMaybe s (start now p s))+ Release p -> (now, snd (release p s))+ Request bs -> (now, request (map hash bs) s)+ Import b -> case syncingPeer s of+ Just p | accept p (hash b) s == Requested -> (now, imported now (hash b) s)+ _ -> (now, s)+ Forget -> (now, forgetRequests s)+ Wait secs -> (addUTCTime (fromIntegral secs) now, s)++-- | Every state that a sequence of events goes through.+run :: [Event] -> [World]+run = scanl step (t0, Idle)++timeout :: NominalDiffTime+timeout = 60++spec :: Spec+spec = describe "Block download" $ do+ prop "never has a block outstanding twice" $ \es ->+ all (\(_, s) -> nub (outstanding s) == outstanding s) (run es)++ prop "has a syncing peer only after starting, and keeps it until released" $ \es ->+ and+ [ case e of+ Start _ -> syncingPeer s' == syncingPeer s || isNothing (syncingPeer s)+ Release p+ | syncingPeer s == Just p -> syncingPeer s' == Nothing+ _ -> syncingPeer s' == syncingPeer s+ | ((_, s), e, (_, s')) <- zip3 (run es) es (drop 1 (run es))+ ]++ prop "accepts a block only from the syncing peer, and only if outstanding" $ \es ->+ forAll (choose (0, 2)) $ \peer -> forAll (hash <$> choose (0, 9)) $ \h ->+ let (_, s) = last (run es)+ in accept peer h s === case syncingPeer s of+ Just q+ | q /= peer -> NotSyncingPeer+ | h `elem` outstanding s -> Requested+ | otherwise -> NotRequested+ Nothing -> NotSyncingPeer++ prop "keeps the requested blocks in the order they were asked for" $ \es bs ->+ let (_, s) = last (run es)+ s' = request (map hash bs) s+ in case syncingPeer s of+ Nothing -> outstanding s' === []+ Just _ -> take (length (outstanding s)) (outstanding s') === outstanding s++ prop "times out only a peer that delivered nothing for longer than the timeout" $ \es ->+ and+ [ case s of+ Idle -> isNothing (timedOut timeout now s)+ Syncing d -> (timedOut timeout now s == Just d.peer) == (now `diffUTCTime` d.time > timeout)+ | (now, s) <- run es+ ]++ prop "asks for more blocks exactly when the window has room" $ \es (Positive window) ->+ let (_, s) = last (run es)+ in wantsMore window s === case syncingPeer s of+ Just p | needsMoreBlocks window (length (outstanding s)) -> Just p+ _ -> Nothing++ describe "needsMoreBlocks" $ do+ it "asks once fewer than a quarter of the window remain" $ do+ needsMoreBlocks 64 16 `shouldBe` False+ needsMoreBlocks 64 15 `shouldBe` True+ it "asks once none remain, even with a window below four" $ do+ needsMoreBlocks 1 1 `shouldBe` False+ needsMoreBlocks 1 0 `shouldBe` True+ needsMoreBlocks 3 0 `shouldBe` True++ it "resets the clock of the peer when it delivers a block" $ do+ let s = Syncing (Download (0 :: Int) t0 [])+ later = addUTCTime 50 t0+ s' = imported later (hash 1) (request [hash 1] s)+ timedOut timeout (addUTCTime 100 t0) s `shouldBe` Just 0+ timedOut timeout (addUTCTime 100 t0) s' `shouldBe` Nothing
@@ -1,16 +0,0 @@-module Haskoin.Store.BlockStoreSpec (spec) where--import Haskoin.Store.BlockStore (needsMoreBlocks)-import Test.Hspec--spec :: Spec-spec =- describe "needsMoreBlocks" $ do- it "asks for more blocks once fewer than a quarter of the window remain" $ do- -- 64 / 4 = 16- needsMoreBlocks 64 16 `shouldBe` False- needsMoreBlocks 64 15 `shouldBe` True- it "asks for more blocks once none remain, even with a window below four" $ do- needsMoreBlocks 1 1 `shouldBe` False- needsMoreBlocks 1 0 `shouldBe` True- needsMoreBlocks 3 0 `shouldBe` True
@@ -0,0 +1,158 @@+-- | The protocol of stores sharing one Redis, against a model of Redis:+-- several stores catch up with a chain in which every block changes one+-- cached xpub, in a random interleaving, and decide with the functions of+-- "Haskoin.Store.Cache.Protocol" what to do at each step.+module Haskoin.Store.Cache.ProtocolSpec (spec) where++import Data.IntMap.Strict (IntMap)+import Data.IntMap.Strict qualified as IntMap+import Haskoin.Store.Cache.Protocol+import Test.Hspec+import Test.Hspec.QuickCheck (prop)+import Test.QuickCheck++-- | The claim of the change of a block.+data Key = Absent | BusyBy Int | DoneKey+ deriving stock (Eq, Show)++-- | Redis: the height the entry is stamped with, and the claims.+data Redis = Redis+ { stamp :: !(Maybe Integer),+ claims :: !(IntMap Key)+ }+ deriving stock (Show)++-- | A store: its height, the change it holds, and changes to claim again.+data Store = Store+ { height :: !Int,+ holding :: !(Maybe Int),+ retry :: ![Int]+ }++-- | What happened.+data Outcome = Outcome+ { -- | the stamp written by each write, by the store and change it was for+ writes :: ![(Int, Int, Integer)],+ -- | claims given up+ releases :: !Int,+ redis :: !Redis,+ -- | whether every store reached the tip with nothing left to do+ finished :: !Bool+ }+ deriving stock (Show)++-- | Try to claim the change of a block, and do what 'afterClaim' says.+tryClaim :: Int -> Int -> Redis -> Store -> (Redis, Store)+tryClaim s i r st =+ let found = case IntMap.findWithDefault Absent i r.claims of+ Absent -> Claimed+ DoneKey -> Done+ BusyBy _ -> Busy+ in case afterClaim found of+ ReloadNow -> (r {claims = IntMap.insert i (BusyBy s) r.claims}, st {holding = Just i})+ Skip -> (r, st)+ ClaimLater -> (r, st {retry = st.retry <> [i]})++-- | A step of a store, if it can take one: reload for the change it holds,+-- claim a change again, or import the next block and claim its change.+steps :: Int -> Int -> Outcome -> Store -> [(Outcome, Store)]+steps tip s o st = case st.holding of+ Just i ->+ let h = toInteger st.height+ -- the entry has a transaction the store has not seen if it is+ -- stamped with a later block, as each block adds one+ unknown = maybe False (> h) o.redis.stamp+ reload = if mayReplace o.redis.stamp (fromIntegral st.height) unknown then Reloaded else Behind+ r = o.redis+ in case afterReload reload of+ Finish ->+ [ ( o+ { writes = o.writes <> [(s, i, h)],+ redis = r {stamp = Just h, claims = IntMap.insert i DoneKey r.claims}+ },+ st {holding = Nothing}+ )+ ]+ Release ->+ [ ( o {releases = o.releases + 1, redis = r {claims = IntMap.insert i Absent r.claims}},+ st {holding = Nothing}+ )+ ]+ Hold -> []+ Nothing ->+ [ let (r, st') = tryClaim s i o.redis st {retry = rest} in (o {redis = r}, st')+ | i : rest <- [st.retry]+ ]+ <> [ let (r, st') = tryClaim s (st.height + 1) o.redis st {height = st.height + 1}+ in (o {redis = r}, st')+ | st.height < tip+ ]++-- | Run stores that start at the given heights until none can take a step,+-- picking the next step at random. The changes of the blocks up to the+-- lowest store were applied before.+simulate :: Int -> [Int] -> Gen Outcome+simulate tip starts =+ simulateFrom+ (Redis (Just (toInteger (minimum starts))) (IntMap.fromList [(i, DoneKey) | i <- [1 .. minimum starts]]))+ tip+ starts++-- | Like 'simulate', from a state of Redis.+simulateFrom :: Redis -> Int -> [Int] -> Gen Outcome+simulateFrom r0 tip starts = go (10000 :: Int) start0 (IntMap.fromList (zip [0 ..] stores0))+ where+ stores0 = [Store h Nothing [] | h <- starts]+ start0 = Outcome {writes = [], releases = 0, redis = r0, finished = False}+ go 0 o _ = return o+ go n o stores =+ case [(s, x) | (s, st) <- IntMap.toList stores, x <- steps tip s o st] of+ [] -> return o {finished = True}+ xs -> do+ (s, (o', st')) <- elements xs+ go (n - 1) o' (IntMap.insert s st' stores)++-- | A small chain and up to four stores at random heights on it.+chain :: Gen (Int, [Int])+chain = do+ tip <- choose (1, 12)+ stores <- choose (1, 4)+ starts <- vectorOf stores (choose (0, tip))+ return (tip, starts)++spec :: Spec+spec = describe "Stores sharing Redis" $ do+ prop "all reach the tip with nothing left to do" $+ forAll chain $+ \(tip, starts) -> forAll (simulate tip starts) $ \o -> o.finished++ prop "end with the entry of the tip" $+ forAll chain $ \(tip, starts) -> forAll (simulate tip starts) $ \o ->+ o.redis.stamp === Just (toInteger tip)++ prop "never replace an entry with one that knows less" $+ forAll chain $ \(tip, starts) -> forAll (simulate tip starts) $ \o ->+ let stamps = [h | (_, _, h) <- o.writes]+ in and (zipWith (<=) stamps (drop 1 stamps))++ prop "write once per change, and again only for a change given up" $+ forAll chain $ \(tip, starts) -> forAll (simulate tip starts) $ \o ->+ length o.writes <= tip - minimum starts + o.releases++ prop "leave nothing to do for stores that catch up after the first" $+ forAll chain $ \(tip, starts) ->+ forAll (simulate tip [minimum starts]) $ \first ->+ forAll (simulateFrom first.redis tip starts) $ \rest ->+ rest.writes === []++ it "claims, skips or tries again as the claim says" $+ map afterClaim [minBound .. maxBound] `shouldBe` [ReloadNow, Skip, ClaimLater]++ it "finishes, releases or holds as the reload says" $+ map afterReload [minBound .. maxBound] `shouldBe` [Finish, Release, Hold]++ it "replaces an entry of the same height or lower, never a higher one" $ do+ mayReplace Nothing 5 False `shouldBe` True+ mayReplace (Just 5) 5 False `shouldBe` True+ mayReplace (Just 6) 5 False `shouldBe` False+ mayReplace (Just 4) 5 True `shouldBe` False
@@ -1,24 +1,46 @@ module Haskoin.Store.CacheSpec (spec) where -import Data.List (sort)-import Haskoin.Store.Cache (blockRefScore, scoreBlockRef)-import Haskoin.Store.Data (BlockRef (..))-import Test.Hspec (Spec, describe)+import Data.ByteString qualified as BS+import Data.ByteString.Char8 qualified as C8+import Data.List (nub, sort)+import Haskoin (bch, btc, deriveXPubKey, makeXPrvKey, prepareContext)+import Haskoin.Store.Cache (blockRefScore, cachePrefix, fullReloadAfter, fullReloadEvery, scoreBlockRef)+import Haskoin.Store.Data (BlockRef (..), DeriveType (..), XPubSpec (..))+import Test.Hspec (Spec, describe, it, shouldBe, shouldSatisfy) import Test.Hspec.QuickCheck (prop) import Test.QuickCheck (Gen, choose, forAll, listOf, oneof) spec :: Spec spec = do describe "Score for block reference" $ do- prop "sorts correctly" $+ prop "sorts newest first" $ forAll arbitraryBlockRefs $ \ts -> let scores = map blockRefScore (sort ts) in sort scores == reverse scores prop "respects identity" $ forAll arbitraryBlockRef $ \b ->- let score = blockRefScore b- ref = scoreBlockRef score- in ref == b+ scoreBlockRef (blockRefScore b) == b+ describe "Keys" $+ it "start with a prefix of their own for each network" $ do+ cachePrefix btc `shouldBe` "hs2:btc:"+ cachePrefix bch `shouldBe` "hs2:bch:"+ -- no key of the old cache can start like a key of this one, nor the+ -- other way round: its keys are these names, or one of the one-letter+ -- prefixes followed by a serialized xpub or address+ let old = ["head", "max", "mempool", "lock", "xlock-", "b", "t", "u", "a", "i"]+ p = cachePrefix btc+ filter (\o -> o `BS.isPrefixOf` p || p `BS.isPrefixOf` o) old `shouldBe` []++ prepareContext $ \ctx ->+ describe "Scheduled full reloads" $+ it "come every five to ten minutes, at intervals spread over xpubs" $ do+ let seed i = C8.pack ("seed of the test xpub " <> show i)+ xpub i = XPubSpec (deriveXPubKey ctx (makeXPrvKey (seed i))) DeriveNormal+ after = map (fullReloadAfter . xpub) [1 .. 200 :: Int]+ half = fullReloadEvery `div` 2+ after `shouldSatisfy` all (\a -> a >= half && a < fullReloadEvery)+ -- each of the five minutes of the range has some of them+ sort (nub [(a - half) `div` 60000 | a <- after]) `shouldBe` [0 .. 4] arbitraryBlockRefs :: Gen [BlockRef] arbitraryBlockRefs = listOf arbitraryBlockRef
@@ -1,21 +1,37 @@-{-# LANGUAGE OverloadedRecordDot #-}- module Haskoin.Store.CommonSpec (spec) where import Conduit (runConduit, runConduitPure, sinkList, takeC, yieldMany, (.|))-import Control.Monad.State.Strict (State, modify', runState)-import Data.List (sortOn)+import Control.Monad.State.Strict (State, evalState, execState, modify', runState)+import Data.ByteString.Char8 qualified as C8+import Data.HashMap.Strict qualified as HashMap+import Data.HashSet qualified as HashSet+import Data.IntSet qualified as IntSet+import Data.List (nub, sortOn) import Data.Ord (Down (..))+import Data.Serialize (encode) import Data.Word (Word32)+import Haskoin (Address (..), OutPoint (..), TxHash (..), ripemd160, sha256) import Haskoin.Store.Common ( Limits (..), applyLimit, applyLimits, applyLimitsC, deOffset,+ joinDescStreams, mergeDescGroups,+ scanXPubBals, streamThings,+ txRefKey,+ unspentKey, )+import Haskoin.Store.Data+ ( Balance (..),+ BlockRef (..),+ TxRef (..),+ Unspent (..),+ XPubBal (..),+ zeroBalance,+ ) import Test.Hspec import Test.QuickCheck @@ -30,8 +46,8 @@ it "reads a number of rows in proportion to the rows taken" $ property $ \(NonNegative n) -> let xs = [1 .. 100000 :: Int]- (ys, reads) = paged 16 xs (Just n)- in ys === take n xs .&&. counterexample (show reads) (reads <= 4 * n + 2048)+ (ys, rowsRead) = paged 16 xs (Just n)+ in ys === take n xs .&&. counterexample (show rowsRead) (rowsRead <= 4 * n + 2048) describe "mergeDescGroups" $ do it "pages like sorting the first offset plus limit items of every stream" $ property $ \(Streams ss) ->@@ -45,6 +61,137 @@ limits = Limits 2 0 Nothing in merged limits ss `shouldBe` [(9, 3), (9, 1)] + describe "scanXPubBals" $ do+ it "keeps exactly the gap after the last used address of each chain, and the initial block" $+ property $ \(Wallet igap gap ext chg) ->+ let paths = map (.path) (scan igap gap ext chg)+ in paths === modelPaths igap gap ext chg+ it "reads no address past the gap, beyond the initial block" $+ property $ \(Wallet igap gap ext chg) ->+ let seen = execState (scanXPubBals (countReads ext chg) igap gap (chain 0) (chain 1)) []+ limit m used+ | m == 0 = max (fromIntegral igap - 1) (lastReached igap gap used + fromIntegral gap)+ | otherwise = lastReached 0 gap used + fromIntegral gap+ ok (m, n) = n <= limit m (if m == 0 then ext else chg)+ in counterexample (show seen) (all ok seen)+ it "reads only the initial block of an unused xpub" $+ property $ \(NonNegative igap) (Positive gap) ->+ let seen = execState (scanXPubBals (countReads [] []) (igap `mod` 40) (gap `mod` 40) (chain 0) (chain 1)) []+ in sortOn id seen === [(0, n) | n <- [0 .. fromIntegral (igap `mod` 40) - 1]]+ it "counts the unused addresses at the end of the initial block" $+ -- index 0 used, initial gap 20, gap 32: the store looks at indices 1 to+ -- 32 and no further, while it used to read up to index 83+ let paths = map (.path) (scan 20 32 [0] [])+ in maximum [n | [0, n] <- paths] `shouldBe` 32+ it "does not see a payment 34 addresses past the last used one" $ do+ -- the wallet of the Blockchain.com comparison: receive indices 0 to 35+ -- used, then a payment to index 69+ let ext = [0 .. 35] <> [69]+ seen g = [n | XPubBal {path = [0, n], balance = b} <- scan 20 g ext [], b.txs > 0]+ maximum (seen 32) `shouldBe` 35+ maximum (seen 34) `shouldBe` 69+ describe "order of ties" $ do+ -- RocksDB and Redis both order entries of the same block position by+ -- their serialized bytes; the merges must agree with them+ it "puts transactions of one position in the order of their bytes" $+ property $ \(NonNegative t) (Positive n) ->+ let refs = [TxRef {block = MemRef t, txid = TxHash (sha256 (encode i))} | i <- [1 .. n `mod` 20 :: Int]]+ in map (.txid) (sortOn (Down . txRefKey) refs) === map (.txid) (sortOn (encode . (.txid)) refs)+ it "puts outputs of one position in the order of their bytes" $+ property $ \(Positive n) ->+ forAll (vectorOf (n `mod` 20) (choose (0, 70000))) $ \is ->+ let h = TxHash (sha256 ("tx" :: C8.ByteString))+ unspent i =+ Unspent {block = BlockRef 7 1, outpoint = OutPoint h i, value = 1, script = "", address = Nothing}+ us = map unspent (nub is)+ in map (.outpoint) (sortOn (Down . unspentKey) us) === map (.outpoint) (sortOn (encode . (.outpoint)) us)+ describe "joinDescStreams with txRefKey" $ do+ it "merges without duplicates mempool transactions of the same second" $+ property $ \(TxStreams all' ss) ->+ let merged' = runConduitPure $ joinDescStreams txRefKey (map yieldMany ss) .| sinkList+ want = sortOn (Down . txRefKey) (nub (concat ss))+ in counterexample (show (length all')) (merged' === want)++-- | Addresses used on each chain, with the initial gap and the gap.+data Wallet = Wallet Word32 Word32 [Int] [Int]+ deriving (Show)++instance Arbitrary Wallet where+ arbitrary = do+ igap <- choose (0, 30)+ gap <- choose (1, 40)+ let used = fmap (IntSet.toList . IntSet.fromList) . listOf $ frequency [(4, choose (0, 40)), (1, choose (0, 150))]+ Wallet igap gap <$> used <*> used++-- | A fake address for a path.+fakeAddr :: Word32 -> Word32 -> Address+fakeAddr m n = PubKeyAddress (ripemd160 (C8.pack (show (m, n))))++chain :: Word32 -> [(Word32, Address)]+chain m = [(n, fakeAddr m n) | n <- [0 ..]]++-- | Balances of fake addresses: the used ones have one transaction.+balancesOf :: [Int] -> [Int] -> [Address] -> [Balance]+balancesOf ext chg = map f+ where+ used = HashSet.fromList ([fakeAddr 0 (fromIntegral n) | n <- ext] <> [fakeAddr 1 (fromIntegral n) | n <- chg])+ f a+ | a `HashSet.member` used = (zeroBalance a) {txs = 1, received = 1}+ | otherwise = zeroBalance a++scan :: Word32 -> Word32 -> [Int] -> [Int] -> [XPubBal]+scan igap gap ext chg =+ evalState (scanXPubBals (return . balancesOf ext chg) igap gap (chain 0) (chain 1)) ()++-- | Read balances, recording the paths read.+countReads :: [Int] -> [Int] -> [Address] -> State [(Word32, Int)] [Balance]+countReads ext chg as = do+ modify' ([p | a <- as, Just p <- [HashMap.lookup a pathOf]] <>)+ return (balancesOf ext chg as)++-- | Path of each fake address, derived once.+pathOf :: HashMap.HashMap Address (Word32, Int)+pathOf = HashMap.fromList [(fakeAddr m n, (m, fromIntegral n)) | m <- [0, 1], n <- [0 .. 400]]++-- | Last used index that a wallet following the gap reaches, or -1.+lastReached :: Word32 -> Word32 -> [Int] -> Int+lastReached igap gap used = go start+ where+ start = maximum ((-1) : filter (< fromIntegral igap) used)+ go l = case filter (\u -> u > l && u <= l + fromIntegral gap) used of+ [] -> l+ us -> go (maximum us)++-- | Paths that an exact gap gives: up to the gap after the last used address+-- of each chain, and the whole initial block of the external chain.+modelPaths :: Word32 -> Word32 -> [Int] -> [Int] -> [[Word32]]+modelPaths igap gap ext chg+ | not (any (< fromIntegral igap) ext) = [[0, fromIntegral n] | n <- [0 .. fromIntegral igap - 1 :: Int]]+ | otherwise =+ upto 0 (max (fromIntegral igap - 1) (lastReached igap gap ext + fromIntegral gap))+ <> upto 1 (lastReached 0 gap chg + fromIntegral gap)+ where+ upto m l = [[m, fromIntegral n] | n <- [0 .. l]]++-- | Transaction references and streams of subsets of them, each sorted as the+-- database sorts the transactions of an address, with many mempool+-- transactions that arrived in the same second.+data TxStreams = TxStreams [TxRef] [[TxRef]]+ deriving (Show)++instance Arbitrary TxStreams where+ arbitrary = do+ n <- choose (0, 40)+ refs <- mapM ref [1 .. n]+ k <- choose (1, 5)+ ss <- vectorOf k (sublistOf refs)+ return $ TxStreams refs (map (sortOn (Down . txRefKey)) ss)+ where+ ref :: Int -> Gen TxRef+ ref i = do+ b <- oneof [MemRef <$> choose (1, 3), BlockRef <$> choose (1, 3) <*> pure (fromIntegral i)]+ return TxRef {block = b, txid = TxHash (sha256 (encode i))}+ -- | The paging of xpub unspent outputs before 'mergeDescGroups': the first -- offset plus limit items of each stream, all sorted, then the page. reference :: Limits -> [[(Int, Int)]] -> [(Int, Int)]@@ -54,7 +201,7 @@ merged :: Limits -> [[(Int, Int)]] -> [(Int, Int)] merged limits ss = runConduitPure $- mergeDescGroups fst quota (map yieldMany ss)+ mergeDescGroups fst id quota (map yieldMany ss) .| applyLimitsC limits .| sinkList where
@@ -0,0 +1,490 @@+{-# LANGUAGE DeriveAnyClass #-}++module Haskoin.Store.Database.MigrateSpec (spec) where++import Control.Monad (foldM, forM_, void, when)+import Data.ByteString qualified as B+import Data.Either (isRight, rights)+import Data.IORef (modifyIORef', newIORef, readIORef)+import Data.IntMap.Strict qualified as IntMap+import Data.List (sort)+import Data.Map.Strict qualified as Map+import Data.Maybe (catMaybes, listToMaybe)+import Data.Serialize (decode, encode)+import Data.Text qualified as T+import Data.Word (Word16, Word32, Word64)+import Database.RocksDB (BatchOp (..))+import Database.RocksDB.Query (insert, insertOp, retrieve, writeBatch)+import Haskoin+ ( BlockHeader (..),+ Ctx,+ Network,+ OutPoint (..),+ Tx (..),+ TxHash (..),+ TxIn (..),+ TxOut (..),+ btcRegTest,+ headerHash,+ prepareContext,+ scriptToAddressBS,+ sha256,+ txHash,+ )+import Haskoin.Store.Data (Balance (..), BlockData (..), BlockRef (..), Spender (..), TxData (..))+import Haskoin.Store.Database.Mempool qualified as M+import Haskoin.Store.Database.Migrate+import Haskoin.Store.Database.Query qualified as Q+import Haskoin.Store.Database.Query.Layered qualified as L+import Haskoin.Store.Database.Reader+ ( DatabaseReader (..),+ mempoolAddrCF,+ mempoolCF,+ mempoolInsertOps,+ spenderCF,+ txCF,+ )+import Haskoin.Store.Database.Types (MigrationKey (..), SpenderKey (..), SpenderVal (..), VersionKey (..))+import Haskoin.Store.Histories+import Haskoin.Store.TestBlocks (script)+import Test.Hspec+import Test.Hspec.QuickCheck (modifyMaxSuccess)+import Test.QuickCheck (forAll, ioProperty, property, tabulate, vectorOf)+import UnliftIO (Exception, throwIO, try, withSystemTempDirectory)++spec :: Spec+spec = prepareContext $ \ctx -> describe "migration to the layered layout" $ do+ it "takes the mempool out of the chain's indices, keeping every answer" $+ withSystemTempDirectory "haskoin-store-migrate-" $ \dir -> do+ -- A coin of a block; a payment from it in the mempool, with change;+ -- and a transaction that spends that change, also in the mempool.+ (hist, old) <- mixedDatabase ctx dir $ \db hist0 -> do+ let payment = Tx 2 [TxIn (coinOf hist0) "" maxBound] [TxOut 3000 (p2pkh 1), TxOut 2000 script] [] 0+ child = Tx 2 [TxIn (OutPoint (txHash payment) 1) "" maxBound] [TxOut 1500 (p2pkh 2)] [] 0+ foldM (addTx db) hist0 [payment, child]+ (miner, one, two) <- case rights (map (scriptToAddressBS net ctx) [script, p2pkh 1, p2pkh 2]) of+ [a, b, c] -> return (a, b, c)+ _ -> fail "a script has no address"+ let numbers bs a = fmap (\b -> (b.confirmed, b.unconfirmed, b.utxo, b.txs, b.received)) (lookup a bs)+ -- the coin is spent in the mempool: it no longer counts as confirmed+ numbers old.balances miner `shouldBe` Just (0, 0, 0, 3, 5000002000)+ numbers old.balances one `shouldBe` Just (0, 3000, 1, 1, 3000)+ numbers old.balances two `shouldBe` Just (0, 1500, 1, 1, 1500)+ openLayered net ctx dir $ \db -> do+ migrateLayered db `shouldReturn` Migration {moved = 2, batches = 1, checked = 2, rewritten = 0}+ retrieve db.db VersionKey `shouldReturn` Just layeredVersion+ (retrieve db.db MigrationKey :: IO (Maybe Word32)) `shouldReturn` Nothing+ -- the chain has the coin again, whole and unspent+ chainBalance <- fmap (\b -> (b.confirmed, b.unconfirmed, b.utxo, b.txs, b.received)) <$> Q.getBalance db miner+ chainBalance `shouldBe` Just (5000000000, 0, 1, 1, 5000000000)+ Q.getBalance db one `shouldReturn` Nothing+ Q.getBalance db two `shouldReturn` Nothing+ -- and with the mempool on top, every answer is what it was+ -- the queries read a database of the version that the store runs on+ settle db+ layered db (ask hist) `shouldReturn` old+ -- a second run finds nothing to do+ migrateLayered db `shouldReturn` Migration {moved = 0, batches = 0, checked = 0, rewritten = 0}++ modifyMaxSuccess (const 50) $+ it "keeps every answer and leaves the chain as if the mempool had been wiped, for random histories" $+ property . forAll ((,) <$> vectorOf 45 anyNumber <*> vectorOf 12 anyNumber) $ \(seeds, last') -> ioProperty $ do+ withSystemTempDirectory "haskoin-store-migrate-" $ \dir -> do+ -- anything at first, then payments, so that the mempool has something+ (hist, old) <- mixedDatabase ctx dir $ \db hist0 -> foldM (step db) hist0 (seeds <> onlyPayments last')+ withSystemTempDirectory "haskoin-store-chain-" $ \dir' ->+ open20 net ctx dir' $ \fresh -> do+ -- the same blocks, in a database that never saw the mempool+ void (run fresh mixedRules.initBest) :: IO ()+ forM_ (reverse hist.chain) $ \(block, node) ->+ run fresh (\w -> mixedRules.importBlock w block node) >>= either (fail . show) (\_ -> return ())+ openLayered net ctx dir $ \db -> do+ done <- migrateLayeredWith defaultMigrationOptions {chunk = 3} db+ done.checked `shouldBe` length old.mempool+ done.moved `shouldBe` length old.mempool+ -- every answer is what it was+ -- the queries read a database of the version that the store runs on+ settle db+ layered db (ask hist) `shouldReturn` old+ -- the chain's column families are those of the fresh database,+ -- but for the times at which transactions were first seen+ migrated <- chainOf db+ expected <- chainOf fresh+ length expected.addressTxs `shouldSatisfy` (>= length hist.chain)+ migrated {firstSeen = []} `shouldBe` expected {firstSeen = []}+ -- the mempool area is the keys of its transactions, each worked+ -- out from that transaction alone: no more and no less+ moved <- catMaybes <$> mapM (M.getMempoolTx db . snd) old.mempool+ let records = [M.mempoolRecords net ctx time td | td <- moved, MemRef time <- [td.block]]+ length records `shouldBe` length old.mempool+ dump db (mempoolCF db.db) `shouldReturn` sort (concatMap (fst . M.recordEntries) records)+ dump db (mempoolAddrCF db.db) `shouldReturn` sort (M.addressArea records)+ -- without the mempool area, the answers are those of the chain+ void (M.wipeMempoolArea db)+ wiped <- layered db (ask hist)+ alone <- mixed fresh (ask hist)+ (wiped.balances, wiped.addressTxs, wiped.addressUnspents, wiped.unspents, wiped.mempool)+ `shouldBe` (alone.balances, alone.addressTxs, alone.addressUnspents, alone.unspents, [])+ let inMempool = [td | (_, Just td) <- old.txs, not td.deleted, MemRef {} <- [td.block]]+ unconfirmed = map (txHash . (.tx)) inMempool+ chained = [() | td <- inMempool, i <- td.tx.inputs, i.outpoint.hash `elem` unconfirmed]+ bucket n+ | n == 0 = "none"+ | n <= 3 = "1 to 3"+ | n <= 9 = "4 to 9"+ | otherwise = "10 or more" :: String+ return+ . tabulate "mempool transactions" [bucket (length inMempool)]+ . tabulate "of them spending another" [bucket (length chained)]+ . tabulate "deleted transactions" [bucket (length [() | (_, Just td) <- old.txs, td.deleted])]+ $ tabulate "blocks" [bucket (length hist.chain)] True++ it "carries on after being interrupted, between any two writes" $+ forM_ [1 .. 6] $ \stopAt ->+ withSystemTempDirectory "haskoin-store-migrate-" $ \dir -> do+ (hist, old) <- mixedDatabase ctx dir $ \db hist0 -> foldM (addTx db) hist0 (payments (coinOf hist0))+ length old.mempool `shouldBe` 8+ -- one transaction in each write, and the plug pulled after a few+ let pull n = when (n == stopAt) (throwIO Interrupted)+ interrupted = defaultMigrationOptions {chunk = 1, afterBatch = pull}+ openLayered net ctx dir $ \db -> do+ try (migrateLayeredWith interrupted db) `shouldReturn` Left Interrupted+ -- a store of version 20 would refuse the database from here on+ retrieve db.db VersionKey `shouldReturn` Just layeredVersion+ (retrieve db.db MigrationKey :: IO (Maybe Word32)) `shouldReturn` Just 20+ openLayered net ctx dir $ \db -> do+ done <- migrateLayered db+ done.moved `shouldBe` length old.mempool - stopAt+ done.checked `shouldBe` length old.mempool+ (retrieve db.db MigrationKey :: IO (Maybe Word32)) `shouldReturn` Nothing+ -- the queries read a database of the version that the store runs on+ settle db+ layered db (ask hist) `shouldReturn` old++ it "refuses a database that it cannot handle, and leaves it untouched" $ do+ withSystemTempDirectory "haskoin-store-migrate-" $ \dir ->+ openLayered net ctx dir $ \db -> do+ migrateLayered db `shouldThrow` (== UnknownVersion Nothing)+ insert db.db VersionKey (19 :: Word32)+ migrateLayered db `shouldThrow` (== UnknownVersion (Just 19))+ withSystemTempDirectory "haskoin-store-migrate-" $ \dir -> do+ -- the mempool lists a transaction that has no body+ let lost = TxHash (sha256 ("lost" :: B.ByteString))+ _ <- mixedDatabase ctx dir $ \db hist0 -> do+ hist1 <- foldM (addTx db) hist0 (take 5 (payments (coinOf hist0)))+ writeBatch db.db (mempoolInsertOps db.db lost 7)+ return hist1+ openLayered net ctx dir $ \db -> do+ migrateLayered db `shouldThrow` \case+ Inconsistent _ -> True+ _ -> False+ retrieve db.db VersionKey `shouldReturn` Just (20 :: Word32)+ dump db (mempoolAddrCF db.db) `shouldReturn` []+ -- the mempool column family has its list and its index by time only+ written <- dump db (mempoolCF db.db)+ written `shouldSatisfy` all ((`elem` ["\x08", "\x0b"]) . B.take 1 . fst)++ it "brings a database of any version that the store knows to the layout it runs on" $ do+ -- a new database+ withSystemTempDirectory "haskoin-store-migrate-" $ \dir ->+ openLayered net ctx dir $ \db -> do+ upgradeDatabase (\_ -> return ()) db+ `shouldReturn` Migration {moved = 0, batches = 0, checked = 0, rewritten = 0}+ retrieve db.db VersionKey `shouldReturn` Just currentVersion+ -- one of version 20 with a mempool, and then the same one again+ withSystemTempDirectory "haskoin-store-migrate-" $ \dir -> do+ (hist, old) <- mixedDatabase ctx dir $ \db hist0 ->+ foldM (addTx db) hist0 (take 5 (payments (coinOf hist0)))+ forM_ [5, 0] $ \moved ->+ openLayered net ctx dir $ \db -> do+ done <- upgradeDatabase (\_ -> return ()) db+ done.moved `shouldBe` moved+ retrieve db.db VersionKey `shouldReturn` Just currentVersion+ (retrieve db.db MigrationKey :: IO (Maybe Word32)) `shouldReturn` Nothing+ layered db (ask hist) `shouldReturn` old+ M.wrongSummaries db `shouldReturn` []+ -- one of version 19, which had no index of the mempool by time+ withSystemTempDirectory "haskoin-store-migrate-" $ \dir -> do+ (hist, old) <- mixedDatabase ctx dir $ \db hist0 ->+ foldM (addTx db) hist0 (take 3 (payments (coinOf hist0)))+ openLayered net ctx dir $ \db -> do+ index <- dump db (mempoolCF db.db)+ writeBatch db.db $+ insertOp VersionKey (19 :: Word32)+ : [DelCF (mempoolCF db.db) k | (k, _) <- index, B.take 1 k == "\x0b"]+ done <- upgradeDatabase (\_ -> return ()) db+ done.moved `shouldBe` 3+ layered db (ask hist) `shouldReturn` old+ -- and one of a version that it does not know+ withSystemTempDirectory "haskoin-store-migrate-" $ \dir ->+ openLayered net ctx dir $ \db -> do+ insert db.db VersionKey (17 :: Word32)+ upgradeDatabase (\_ -> return ()) db `shouldThrow` (== UnknownVersion (Just 17))++ describe "the time of a confirmed transaction" $ do+ it "is the time of its block, not of when the store first saw it" $+ withSystemTempDirectory "haskoin-store-times-" $ \dir ->+ openLayered net ctx dir $ \db -> do+ hist <- start layeredRules db+ let tx = Tx 2 [TxIn (coinOf hist) "" maxBound] [TxOut 4000 (p2pkh 1)] [] 0+ stored = do+ rows <- dump db (txCF db.db)+ return [td :: TxData | (_, v) <- rows, Right td <- [decode v], txHash td.tx == txHash tx]+ (.result) <$> apply layeredRules db (Pay tx 1000) `shouldReturn` Right "True"+ -- in the mempool it has the time at which it came+ fmap (.timestamp) <$> M.getMempoolTx db (txHash tx) `shouldReturn` Just 1000+ mined <- blockOn db [tx] >>= apply layeredRules db+ isRight mined.result `shouldBe` True+ best <- Q.getBestBlock db >>= maybe (fail "no best block") (Q.getBlock db)+ time <- maybe (fail "no block") (return . fromIntegral . (.header.timestamp)) best+ time `shouldNotBe` (1000 :: Word64)+ map (\td -> (td.block, td.timestamp)) <$> stored `shouldReturn` [(BlockRef 2 1, time)]+ wrongTimes db `shouldReturn` []+ -- and when the block is reverted it goes back to the mempool+ -- with that time, which every store has for it+ reverted <- apply layeredRules db (Revert (headerHash (maybe (error "no block") (.header) best)))+ isRight reverted.result `shouldBe` True+ fmap (.timestamp) <$> M.getMempoolTx db (txHash tx) `shouldReturn` Just time+ modifyMaxSuccess (const 20) $+ it "is the time of its block, after any history" $+ -- whatever a store saw in its mempool before, and through blocks+ -- that are reverted and replaced+ property . forAll (vectorOf 60 anyNumber) $ \seeds -> ioProperty $+ withSystemTempDirectory "haskoin-store-times-" $ \dir ->+ openLayered net ctx dir $ \db -> withShadow net ctx $ \shadow -> do+ hist0 <- start layeredRules db+ void (foldM (walk shadow db) hist0 seeds)+ late <- wrongTimes db+ late `shouldBe` []+ modifyMaxSuccess (const 20) $+ it "is set by the migration to version 22, and nothing else is changed" $+ -- A database as the layered rules write it has the time of its+ -- block for every confirmed transaction. Some of them are given+ -- another time, as a store of version 21 that saw them in its+ -- mempool had, and the database is marked as of that version. The+ -- migration must give back the database as it was, byte for byte.+ property . forAll (vectorOf 40 anyNumber) $ \seeds -> ioProperty $+ withSystemTempDirectory "haskoin-store-times-" $ \dir ->+ openLayered net ctx dir $ \db -> withShadow net ctx $ \shadow -> do+ hist0 <- start layeredRules db+ void (foldM (walk shadow db) hist0 seeds)+ original <- dump db (txCF db.db)+ let confirmed = [(k, td :: TxData) | (k, v) <- original, Right td <- [decode v], inChain td]+ -- every other one, and all of them when there are few+ picked = [x | (i, x) <- zip [0 :: Int ..] confirmed, even i || length confirmed < 4]+ writeBatch db.db $+ insertOp VersionKey layeredVersion+ : [PutCF (txCF db.db) k (encode (later td)) | (k, td) <- picked]+ wrongTimes db >>= (`shouldBe` length picked) . length+ done <- upgradeDatabase (\_ -> return ()) db+ done.rewritten `shouldBe` length picked+ retrieve db.db VersionKey `shouldReturn` Just currentVersion+ (retrieve db.db MigrationKey :: IO (Maybe Word32)) `shouldReturn` Nothing+ dump db (txCF db.db) `shouldReturn` original+ -- and again, it finds nothing to do+ upgradeDatabase (\_ -> return ()) db+ `shouldReturn` Migration {moved = 0, batches = 0, checked = 0, rewritten = 0}+ modifyMaxSuccess (const 20) $+ it "is in a record that is written once, and not when its outputs are spent" $+ -- After every step of a history, the record of each transaction+ -- that was confirmed before the step and still is, is the same+ -- bytes as before: blocks that spend its outputs, and blocks that+ -- are reverted, write keys of their own and leave it alone. No+ -- record has spenders in it.+ property . forAll (vectorOf 60 anyNumber) $ \seeds -> ioProperty $+ withSystemTempDirectory "haskoin-store-times-" $ \dir ->+ openLayered net ctx dir $ \db -> withShadow net ctx $ \shadow -> do+ hist0 <- start layeredRules db+ let records = do+ rows <- dump db (txCF db.db)+ return (Map.fromList [(k, v) | (k, v) <- rows, Right td <- [decode v], inChain td])+ turn (hist, was) seed = do+ hist' <- walk shadow db hist seed+ now <- records+ Map.filter id (Map.intersectionWith (/=) was now) `shouldBe` Map.empty+ return (hist', now)+ first <- records+ (_, final) <- foldM turn (hist0, first) seeds+ [td.spenders | v <- Map.elems final, Right (td :: TxData) <- [decode v], not (IntMap.null td.spenders)]+ `shouldBe` []+ it "has the spenders of its outputs under a key for each sixteen of them" $+ withSystemTempDirectory "haskoin-store-times-" $ \dir ->+ openLayered net ctx dir $ \db -> do+ hist <- start layeredRules db+ -- forty outputs, of which three are spent, in three groups+ let wide = Tx 2 [TxIn (coinOf hist) "" maxBound] (replicate 40 (TxOut 1000 (p2pkh 1))) [] 0+ spend i = Tx 2 [TxIn (OutPoint (txHash wide) i) "" maxBound] [TxOut 500 (p2pkh 2)] [] 0+ spends = map spend [3, 17, 39]+ keysNow = do+ rows <- dump db (spenderCF db.db)+ return+ [ (g, IntMap.keys m)+ | (k, v) <- rows,+ Right (SpenderKey h g) <- [decode k],+ h == txHash wide,+ Right (SpenderVal m) <- [decode v]+ ]+ mine txs = do+ mined <- blockOn db txs >>= apply layeredRules db+ isRight mined.result `shouldBe` True+ mine [wide]+ written <- dump db (txCF db.db)+ keysNow `shouldReturn` []+ mine spends+ keysNow `shouldReturn` [(0, [3]), (1, [17]), (2, [39])]+ -- the record is as the first block wrote it+ now <- dump db (txCF db.db)+ [v | (k, v) <- now, k `elem` map fst written] `shouldBe` map snd written+ -- and the transaction is read with its spenders, each also by itself+ found <- L.withView db (`L.getTxData` txHash wide)+ let expected = IntMap.fromList [(fromIntegral i, Spender (txHash (spend i)) 0) | i <- [3, 17, 39]]+ fmap (.spenders) found `shouldBe` Just expected+ forM_ [3, 4, 17, 39] $ \i ->+ L.withView db (`L.getSpender` OutPoint (txHash wide) i)+ `shouldReturn` IntMap.lookup (fromIntegral i) expected+ -- a block that is reverted takes its spenders along, and a group+ -- with none left has no key+ best <- Q.getBestBlock db >>= maybe (fail "no best block") return+ reverted <- apply layeredRules db (Revert best)+ isRight reverted.result `shouldBe` True+ keysNow `shouldReturn` []+ Q.getChainSpenders db (txHash wide) `shouldReturn` IntMap.empty+ -- the transactions that spent them are back in the mempool, and+ -- the transaction is read with them as its spenders still+ fmap (.spenders) <$> L.withView db (`L.getTxData` txHash wide) `shouldReturn` Just expected+ modifyMaxSuccess (const 20) $+ it "has the spenders of its outputs moved to keys of their own by the migration" $+ -- A database as the layered rules write it has a key for each+ -- spent output. The spenders are put where version 21 had them,+ -- in the record of the transaction that made the output, the keys+ -- are deleted, and the database is marked as of that version. The+ -- migration must give back the records and the keys as they were.+ property . forAll (vectorOf 40 anyNumber) $ \seeds -> ioProperty $+ withSystemTempDirectory "haskoin-store-times-" $ \dir ->+ openLayered net ctx dir $ \db -> withShadow net ctx $ \shadow -> do+ hist0 <- start layeredRules db+ void (foldM (walk shadow db) hist0 seeds)+ records <- dump db (txCF db.db)+ keys <- dump db (spenderCF db.db)+ let spent =+ [ (h, m :: IntMap.IntMap Spender)+ | (k, v) <- keys,+ Right (SpenderKey h _) <- [decode k],+ Right (SpenderVal m) <- [decode v]+ ]+ inside td = IntMap.unions [m | (h, m) <- spent, h == txHash td.tx]+ old (k, v) = case decode v of+ Right td | inChain td -> (k, encode (embed td (inside td)))+ _ -> (k, v)+ writeBatch db.db $+ insertOp VersionKey layeredVersion+ : [DelCF (spenderCF db.db) k | (k, _) <- keys]+ <> [PutCF (txCF db.db) k v | (k, v) <- map old records]+ done <- upgradeDatabase (\_ -> return ()) db+ -- every record that had a spender in it was written again+ done.rewritten `shouldBe` length [() | (k, v) <- records, old (k, v) /= (k, v)]+ dump db (txCF db.db) `shouldReturn` records+ dump db (spenderCF db.db) `shouldReturn` keys+ it "is set by a migration that carries on after it was interrupted" $+ withSystemTempDirectory "haskoin-store-times-" $ \dir ->+ openLayered net ctx dir $ \db -> withShadow net ctx $ \shadow -> do+ hist0 <- start layeredRules db+ -- payments, and then blocks, which confirm some of them+ void (foldM (walk shadow db) hist0 (onlyPayments [1 .. 6] <> [8 .. 11]))+ original <- dump db (txCF db.db)+ let confirmed = [(k, td :: TxData) | (k, v) <- original, Right td <- [decode v], inChain td]+ length confirmed `shouldSatisfy` (> 0)+ -- as a migration leaves it that stopped before its first change+ writeBatch db.db $+ [insertOp VersionKey currentVersion, insertOp MigrationKey layeredVersion]+ <> [PutCF (txCF db.db) k (encode ((td :: TxData) {timestamp = 7})) | (k, td) <- confirmed]+ said <- newIORef []+ done <- upgradeDatabase (\line -> modifyIORef' said (line :)) db+ done.rewritten `shouldBe` length confirmed+ dump db (txCF db.db) `shouldReturn` original+ (retrieve db.db MigrationKey :: IO (Maybe Word32)) `shouldReturn` Nothing+ readIORef said >>= (`shouldSatisfy` any ("Carrying on" `T.isPrefixOf`))+ it "is not guessed for a transaction whose block the database does not have" $+ withSystemTempDirectory "haskoin-store-times-" $ \dir ->+ openLayered net ctx dir $ \db -> do+ _ <- start layeredRules db+ rows <- dump db (txCF db.db)+ let confirmed = [(k, td :: TxData) | (k, v) <- rows, Right td <- [decode v], inChain td]+ (k, td) <- maybe (fail "no confirmed transaction") return (listToMaybe confirmed)+ writeBatch+ db.db+ [ insertOp VersionKey layeredVersion,+ PutCF (txCF db.db) k (encode ((td :: TxData) {block = BlockRef 5000 0}))+ ]+ upgradeDatabase (\_ -> return ()) db `shouldThrow` \case+ Inconsistent _ -> True+ _ -> False++net :: Network+net = btcRegTest++-- | Choose one operation of a history from a database of version 20 that+-- has gone through it ('withShadow'), and carry it out there and, under+-- the layered rules, in a database of the layered layout.+walk :: DatabaseReader -> DatabaseReader -> History -> Word16 -> IO History+walk shadow db hist seed =+ plan shadow hist seed >>= \case+ (hist', Nothing) -> return hist'+ (hist', Just op) -> do+ void (apply mixedRules shadow op)+ record db hist' op <$> apply layeredRules db op++-- | A transaction with a time that is not that of its block, and marked as+-- one that can be replaced, which a confirmed transaction cannot be.+later :: TxData -> TxData+later td = (td :: TxData) {timestamp = td.timestamp + 12345, rbf = True}++-- | Bring a database of version 21 to the version that the store runs on.+settle :: DatabaseReader -> IO ()+settle db = void (migrateRecords (\_ -> return ()) db)++-- | A transaction with spenders of its outputs in its record.+embed :: TxData -> IntMap.IntMap Spender -> TxData+embed td inside = (td :: TxData) {spenders = inside}++-- | Whether a transaction is in the chain.+inChain :: TxData -> Bool+inChain td = case td.block of+ BlockRef {} -> not td.deleted+ MemRef {} -> False++-- | The confirmed transactions of a database whose time is not that of+-- their block, each with both times.+wrongTimes :: DatabaseReader -> IO [(TxHash, Word64, Maybe Word64)]+wrongTimes db = do+ rows <- dump db (txCF db.db)+ fmap catMaybes . mapM check $ [td :: TxData | (_, v) <- rows, Right td <- [decode v], inChain td]+ where+ check td = do+ hashes <- Q.getBlocksAtHeight db td.block.height+ blocks <- catMaybes <$> mapM (Q.getBlock db) hashes+ let time = listToMaybe [fromIntegral b.header.timestamp | b <- blocks, b.main]+ return (if time == Just td.timestamp then Nothing else Just (txHash td.tx, td.timestamp, time))++data Interrupted = Interrupted+ deriving (Eq, Show, Exception)++-- | Make a database of version 20 in a directory: the genesis block, a block+-- with a coin, and whatever the function does next. Gives the history, and+-- the answers of the database to every query about it.+mixedDatabase ::+ Ctx ->+ FilePath ->+ (DatabaseReader -> History -> IO History) ->+ IO (History, Answers)+mixedDatabase ctx dir go =+ open20 net ctx dir $ \db -> do+ hist <- start mixedRules db >>= go db+ answers <- mixed db (ask hist)+ return (hist, answers)++-- | Add a transaction to the mempool, by the rules of today's layout.+addTx :: DatabaseReader -> History -> Tx -> IO History+addTx db hist tx = record db hist op <$> apply mixedRules db op+ where+ op = Pay tx hist.clock
@@ -0,0 +1,173 @@+module Haskoin.Store.Database.Query.LayeredSpec (spec) where++import Control.Monad (foldM, forM_, void)+import Data.ByteString (ByteString)+import Data.Default (def)+import Data.List (sort)+import Haskoin+ ( Address,+ OutPoint (..),+ Tx (..),+ TxHash (..),+ TxIn (..),+ TxOut (..),+ bchRegTest,+ btcRegTest,+ prepareContext,+ scriptToAddressBS,+ sha256,+ txHash,+ )+import Haskoin.Store.Common (Limits (..), Start (..), StoreRead (..))+import Haskoin.Store.Data+ ( Balance (..),+ TxData (..),+ TxRef (..),+ Unspent (..),+ confirmed,+ )+import Haskoin.Store.Database.Migrate (upgradeDatabase)+import Haskoin.Store.Database.Query.Layered qualified as L+import Haskoin.Store.Database.Reader (DatabaseReader (..))+import Haskoin.Store.Histories+import Haskoin.Store.TestBlocks (coinbaseTx, script)+import Test.Hspec+import Test.Hspec.QuickCheck (modifyMaxSuccess)+import Test.QuickCheck (arbitrary, forAll, ioProperty, property, tabulate, vectorOf)+import Test.QuickCheck.Gen (unGen)+import Test.QuickCheck.Random (mkQCGen)+import UnliftIO (withSystemTempDirectory)++spec :: Spec+spec = prepareContext $ \ctx -> describe "queries of the layered layout" $ do+ forM_ [("Bitcoin", btcRegTest), ("Bitcoin Cash", bchRegTest)] $ \(name, net) ->+ modifyMaxSuccess (const 30) $+ it ("give the pages of today's queries on a migrated database, on " <> name) $+ property . forAll ((,,) <$> vectorOf 45 anyNumber <*> vectorOf 12 anyNumber <*> arbitrary) $+ \(seeds, last', seed) -> ioProperty $+ withSystemTempDirectory "haskoin-store-pages-" $ \dir -> do+ -- anything at first, then payments, so that the mempool has+ -- something; and questions about what came of it+ (asked, was, starts, fair) <- open20 net ctx dir $ \db -> do+ hist <- start mixedRules db >>= \hist0 -> foldM (step db) hist0 (seeds <> onlyPayments last')+ let asked = unGen (questions net ctx hist 150) (mkQCGen seed) 30+ was <- replies mixed db asked+ starts <- mixed db (\queries -> mapM (from queries) asked)+ -- pages that start at a deleted transaction are not+ -- compared: the layered layout does not know those+ fair <- mapM (startsKnown db) asked+ return (asked, was, starts, fair)+ now <- openLayered net ctx dir $ \db -> do+ void (upgradeDatabase (\_ -> return ()) db)+ replies layered db asked+ length now `shouldBe` length was+ let kept :: [x] -> [x]+ kept xs = [x | (x, True) <- zip xs (fair <> fair)]+ take 2 (differences (kept (asked <> asked)) (kept was) (kept now)) `shouldBe` []+ let content reply = if size reply == 0 then "nothing in it" else "something in it"+ pages = [place <> ", " <> content reply | (Just place, reply) <- zip starts was]+ return (tabulate "pages that start at" pages True)++ it "cut pages from a list that is part in the mempool and part in the chain" $+ withSystemTempDirectory "haskoin-store-pages-" $ \dir ->+ openLayered btcRegTest ctx dir $ \db -> do+ hist <- start layeredRules db+ -- a payment from the coin of the first block, with change to the+ -- miner, and a payment from that change, in the same second+ let one = Tx 2 [TxIn (coinOf hist) "" maxBound] [TxOut 3000 (p2pkh 1), TxOut 2000 script] [] 0+ two = Tx 2 [TxIn (OutPoint (txHash one) 1) "" maxBound] [TxOut 1500 (p2pkh 2)] [] 0+ coinbase = txHash (coinbaseTx 1)+ -- those of one second come in the order of their hashes+ unconfirmed = sort [txHash one, txHash two]+ never = TxHash (sha256 ("never made" :: ByteString))+ page limits = L.withView db $ \v -> map (.txid) <$> L.getAddressTxs v miner limits+ outputs = L.withView db $ \v -> map (.outpoint) <$> L.getAddressUnspents v miner def+ amounts = L.withView db $ \v -> fmap numbers <$> L.getBalance v miner+ numbers b = (b.confirmed, b.unconfirmed, b.utxo, b.txs, b.received)+ miner :: Address+ miner = either error id (scriptToAddressBS btcRegTest ctx script)+ forM_ [one, two] $ \tx ->+ (.result) <$> apply layeredRules db (Pay tx 1000) `shouldReturn` Right "True"+ page def `shouldReturn` unconfirmed <> [coinbase]+ page def {limit = 1} `shouldReturn` take 1 unconfirmed+ page def {limit = 2, offset = 1} `shouldReturn` drop 1 unconfirmed <> [coinbase]+ page def {offset = 2} `shouldReturn` [coinbase]+ -- from a mempool transaction: it, and what is older+ page def {start = Just (AtTx (unconfirmed !! 1))} `shouldReturn` drop 1 unconfirmed <> [coinbase]+ page def {start = Just (AtTx (unconfirmed !! 1)), offset = 1} `shouldReturn` [coinbase]+ -- from a confirmed transaction: nothing of the mempool+ page def {start = Just (AtTx coinbase)} `shouldReturn` [coinbase]+ -- from a height: nothing of the mempool either, even above the best+ -- block, as today+ page def {start = Just (AtBlock 5)} `shouldReturn` [coinbase]+ page def {start = Just (AtBlock 0)} `shouldReturn` []+ page def {start = Just (AtTx never)} `shouldReturn` []+ -- the mempool spends the coin, and the change that it made+ outputs `shouldReturn` []+ amounts `shouldReturn` Just (0, 0, 0, 3, 5000002000)+ -- a block confirms the first payment and not the second+ block <- blockOn db [one]+ (.removed) <$> apply layeredRules db block `shouldReturn` [txHash one]+ let reward = txHash (coinbaseTx 2)+ page def `shouldReturn` [txHash two, txHash one, reward, coinbase]+ page def {start = Just (AtTx (txHash one))} `shouldReturn` [txHash one, reward, coinbase]+ page def {start = Just (AtBlock 2), offset = 1, limit = 1} `shouldReturn` [reward]+ -- the change is in the chain now, and the mempool still spends it+ outputs `shouldReturn` [OutPoint reward 0]+ amounts `shouldReturn` Just (5000000000, 0, 1, 4, 10000002000)++ it "answer from one moment, whatever is written in the meantime" $+ withSystemTempDirectory "haskoin-store-pages-" $ \dir ->+ openLayered btcRegTest ctx dir $ \db -> do+ hist0 <- start layeredRules db+ let chained = payments (coinOf hist0)+ pay hist tx = record db hist (Pay tx hist.clock) <$> apply layeredRules db (Pay tx hist.clock)+ hist <- foldM pay hist0 (take 4 chained)+ let asked = unGen (questions btcRegTest ctx hist 100) (mkQCGen 7) 30+ everything queries = (,) <$> ask hist queries <*> mapM (answer queries) asked+ (early, during) <- L.withView db $ \v -> do+ early <- everything (L.layeredRead v)+ -- a block confirms two of the payments, and two more arrive+ block <- blockOn db (take 2 chained)+ mined <- apply layeredRules db block+ length mined.removed `shouldBe` 2+ forM_ (take 2 (drop 4 chained)) $ \tx ->+ (.result) <$> apply layeredRules db (Pay tx 2000) `shouldReturn` Right "True"+ during <- everything (L.layeredRead v)+ return (early, during)+ during `shouldBe` early+ -- a view taken now sees all of it+ late <- layered db everything+ length (fst late).mempool `shouldBe` 4+ (fst late).mempool `shouldNotBe` (fst early).mempool+ (fst late).balances `shouldNotBe` (fst early).balances+ take 1 (differences asked (snd early) (snd late)) `shouldNotBe` []++-- | The answers of a database to some questions: asked with the gaps that+-- the tests open their databases with, which reach every address of the+-- wallet that is paid, and asked again with small gaps, under which the+-- later ones are found only when earlier ones are used.+replies ::+ (DatabaseReader -> (StoreRead -> IO [Reply]) -> IO [Reply]) ->+ DatabaseReader ->+ [Question] ->+ IO [Reply]+replies with db asked = concat <$> mapM under [(db.initGap, db.maxGap), (2, 3)]+ where+ under (initial, gap) =+ with (db :: DatabaseReader) {initGap = initial, maxGap = gap} $ \queries ->+ mapM (answer queries) asked++-- | Where the page of a question starts, for the questions that have a+-- page, given the queries of today's layout.+from :: StoreRead -> Question -> IO (Maybe String)+from queries question = case startOf question of+ Nothing -> return Nothing+ Just Nothing -> return (Just "the top")+ Just (Just (AtBlock _)) -> return (Just "a height")+ Just (Just (AtTx h)) -> Just . maybe "a transaction that is not known" known <$> queries.getTxData h+ where+ known td+ | td.deleted = "a deleted transaction"+ | confirmed td.block = "a confirmed transaction"+ | otherwise = "a mempool transaction"
@@ -0,0 +1,67 @@+module Haskoin.Store.Database.ViewsSpec (spec) where++import Control.Monad (void)+import Data.List (sort)+import Haskoin (BlockHash, btcRegTest, prepareContext)+import Haskoin.Store.Database.Query qualified as Q+import Haskoin.Store.Database.Views+import Haskoin.Store.Histories+import Test.Hspec+import UnliftIO (withSystemTempDirectory)++spec :: Spec+spec = prepareContext $ \ctx -> describe "views of the database by block" $ do+ let withChain go =+ withSystemTempDirectory "haskoin-store-views-" $ \dir ->+ open20 btcRegTest ctx dir $ \db -> do+ void (start mixedRules db)+ views <- newViews db+ let best = Q.getBestBlock db >>= maybe (fail "no best block") return+ mine = do+ block <- blockOn db []+ void (apply mixedRules db block)+ b <- best+ takeView views b+ return b+ go db views best mine `shouldReturn` ()+ closeViews views++ it "answer from the block that each was taken at, whatever came after" $+ withChain $ \db views best mine -> do+ first <- best+ takeView views first+ second <- mine+ third <- mine+ sort <$> heldViews views `shouldReturn` sort [first, second, third]+ Q.getBestBlock db `shouldReturn` Just third+ withViewAt views first Q.getBestBlock `shouldReturn` Just (Just first)+ withViewAt views second Q.getBestBlock `shouldReturn` Just (Just second)+ -- the second block was not there yet when the first view was taken+ (fmap (fmap (const ())) <$> withViewAt views first (`Q.getBlock` second)) `shouldReturn` Just Nothing++ it "let go of the views that are not to be kept" $+ withChain $ \_ views best mine -> do+ first <- best+ takeView views first+ second <- mine+ keepViews views [second]+ heldViews views `shouldReturn` [second]+ withViewAt views first Q.getBestBlock `shouldReturn` (Nothing :: Maybe (Maybe BlockHash))+ withViewAt views second Q.getBestBlock `shouldReturn` Just (Just second)++ it "keep a view that is being read until its reader is done" $+ withChain $ \_ views best mine -> do+ first <- best+ takeView views first+ second <- mine+ result <- withViewAt views first $ \old -> do+ keepViews views [second]+ -- gone for anyone who asks now+ heldViews views `shouldReturn` [second]+ withViewAt views first Q.getBestBlock `shouldReturn` (Nothing :: Maybe (Maybe BlockHash))+ -- and still there for the one that was reading, while blocks go on+ third <- mine+ (,third) <$> Q.getBestBlock old+ fmap fst result `shouldBe` Just (Just first)+ -- the reader is done: the view is gone, and the others are as they were+ sort <$> heldViews views `shouldReturn` sort (second : maybe [] (pure . snd) result)
@@ -0,0 +1,312 @@+module Haskoin.Store.Fleet.MemberSpec (spec) where++import Control.Monad (foldM_, void, when)+import Data.Default (def)+import Data.Either (isRight)+import Data.IORef (modifyIORef', newIORef, readIORef)+import Data.Serialize (encode)+import Data.Text qualified as T+import Data.Word (Word16)+import Haskoin (btcRegTest, prepareContext, txHash)+import Haskoin.Node (LogLevel (..))+import Haskoin.Store.Common (Limits (..), Start (..), StoreRead)+import Haskoin.Store.Database.Query qualified as Q+import Haskoin.Store.Database.Query.Layered (From (..))+import Haskoin.Store.Database.Query.Pooled (pooledRead)+import Haskoin.Store.Database.Views (closeViews)+import Haskoin.Store.Fleet.Member+import Haskoin.Store.Fleet.Redis (FleetKeys (..), poolWithdraw)+import Haskoin.Store.Fleet.RedisSpec (withRedis)+import Haskoin.Store.Fleet.XPub+import Haskoin.Store.Histories+import Haskoin.Store.Integration.Podman (podmanUsable)+import Test.Hspec+import Test.Hspec.QuickCheck (modifyMaxSuccess)+import Test.QuickCheck (forAll, ioProperty, property, sized, vectorOf)+import UnliftIO (withSystemTempDirectory)++spec :: Spec+spec = prepareContext $ \ctx -> do+ usable <- runIO podmanUsable+ describe "member of a fleet" $ case usable of+ Left why -> it "answers as the store does when it is the whole fleet" (pendingWith why)+ Right () -> aroundAll withRedis $ do+ modifyMaxSuccess (const 20) $+ it "answers as the store does when it is the whole fleet" $ \(conn, fresh) ->+ -- A store alone in its fleet, through a random history of+ -- payments, replacements, blocks and reorganisations. The+ -- history is chosen from a database of the layout of version 20+ -- that goes through the same, since that is what 'plan' reads. After each+ -- step it takes its turn as a member, and then every answer that+ -- it gives from the view at the fleet tip with the pool of Redis+ -- on top must be the answer that it gives from its own database.+ property . forAll (vectorOf 60 anyNumber) $ \seeds -> ioProperty $+ withSystemTempDirectory "haskoin-store-member-" $ \dir ->+ openLayered btcRegTest ctx dir $ \db -> withShadow btcRegTest ctx $ \shadow -> do+ keys <- fresh+ let config =+ MemberConfig+ { prefix = keys.prefix,+ name = "a",+ lease = 60000,+ depth = 6,+ expiry = 0,+ cacheMax = 0,+ cacheMinAddrs = 0,+ cacheMinTxs = 0,+ stats = Nothing,+ note = \_ _ -> return ()+ }+ best = Q.getBestBlock db >>= maybe (fail "no best block") return+ m <- newMember config conn db+ hist0 <- start layeredRules db+ best >>= noteBest m+ memberTick m+ let turn hist seed =+ plan shadow hist seed >>= \case+ (hist', Nothing) -> return hist'+ (hist', Just op) -> do+ void (apply mixedRules shadow op)+ out <- apply layeredRules db op+ let hist'' = record db hist' op out+ -- the store tells its member what it deletes+ memberForget m out.removed+ when (isRight out.result) $ case op of+ Mine _ _ -> best >>= noteBest m+ Revert _ -> best >>= noteBest m+ -- a store deletes what is too old, and the pool+ -- does the same by its own clock+ Delete _ -> void (poolWithdraw conn keys 32 (map encode out.removed))+ Pay _ _ -> return ()+ memberTick m+ own <- layered db (ask hist'')+ answering m (\r pool -> ask hist'' (pooledRead r pool)) >>= \case+ Answered shared -> (show op, shared) `shouldBe` (show op, own)+ Alone -> expectationFailure "the fleet has no tip"+ Behind -> expectationFailure "the store has no view of the fleet tip"+ return hist''+ foldM_ turn hist0 seeds+ closeViews m.views+ modifyMaxSuccess (const 20) $+ it "answers about a wallet from the cache as it does from the database" $ \(conn, fresh) ->+ -- The same store and histories, with the cache of extended+ -- public keys on. After each step, every question about the+ -- wallet of the histories is asked of the view at the fleet tip+ -- with the pool on top, and again with the cache in front: the+ -- answers must be the same, whether the cache has the wallet,+ -- has it with other addresses, or does not have it. The wallet+ -- is cached after some steps and not after others, so that+ -- blocks and payments find it in the cache and out of it. Right+ -- after it is cached, the cache must be what answers, and only+ -- to a caller that has the wallet with as many addresses.+ --+ -- At one step in five the fleet tip is moved by the same store+ -- taking its turn without the cache, as a store does that stops+ -- between moving the tip and bringing the cache along. The+ -- cache is then at another block than the tip, and must not+ -- answer.+ --+ -- The first history is always 'leftAndBack'.+ let histories = sized (\n -> if n == 0 then return leftAndBack else vectorOf 60 anyNumber)+ in property . forAll histories $ \seeds -> ioProperty $+ withSystemTempDirectory "haskoin-store-member-" $ \dir ->+ openLayered btcRegTest ctx dir $ \db -> withShadow btcRegTest ctx $ \shadow -> do+ keys <- fresh+ said <- newIORef []+ let config =+ MemberConfig+ { prefix = keys.prefix,+ name = "a",+ lease = 60000,+ depth = 6,+ expiry = 0,+ cacheMax = 10,+ cacheMinAddrs = 0,+ cacheMinTxs = 0,+ stats = Nothing,+ note = \level line -> modifyIORef' said ((level, line) :)+ }+ best = Q.getBestBlock db >>= maybe (fail "no best block") return+ key = wallet ctx+ m <- newMember config conn db+ -- the same store, as it takes its turns without the cache+ bare <- newMember (config {cacheMax = 0} :: MemberConfig) conn db+ hits <- newIORef (0 :: Int)+ hist0 <- start layeredRules db+ best >>= \b -> noteBest m b >> noteBest bare b+ memberTick m+ let tipNow = memberTip m >>= maybe (fail "the fleet has no tip") return+ inFleet :: (StoreRead -> StoreRead -> IO a) -> IO a+ inFleet action = do+ tip <- tipNow+ answeringCached m tip (\plain cached _ -> action plain cached)+ >>= maybe (fail "the store has no view of the fleet tip") return+ turn hist seed =+ plan shadow hist seed >>= \case+ (hist', Nothing) -> return hist'+ (hist', Just op) -> do+ void (apply mixedRules shadow op)+ out <- apply layeredRules db op+ let hist'' = record db hist' op out+ memberForget m out.removed+ memberForget bare out.removed+ when (isRight out.result) $ case op of+ Mine _ _ -> best >>= \b -> noteBest m b >> noteBest bare b+ Revert _ -> best >>= \b -> noteBest m b >> noteBest bare b+ Delete _ -> void (poolWithdraw conn keys 32 (map encode out.removed))+ Pay _ _ -> return ()+ if seed `mod` 5 == 0+ then do+ -- the tip moves, if the step made a block,+ -- and the cache stays where it was: not in+ -- every history, but in most+ memberTick bare+ -- the member with the cache learns the tip+ memberTick m+ else memberTick m+ let made = map txHash hist''.made+ pick xs = xs !! (fromIntegral seed `mod` length xs)+ starts =+ [Nothing, Just (AtBlock (fromIntegral seed `mod` 8))]+ <> [Just (AtTx (pick made)) | not (null made)]+ limits =+ [ Limits {limit, offset, start = from}+ | from <- starts,+ (limit, offset) <- [(0, 0), (2, 0), (3, fromIntegral seed `mod` 3)]+ ]+ asked =+ [WalletBalances, WalletCount]+ <> map WalletTxs limits+ <> map WalletUnspents limits+ (direct, cached) <- inFleet $ \plain cached ->+ (,) <$> mapM (answer plain) asked <*> mapM (answer cached) asked+ (show op, differences asked direct cached) `shouldBe` (show op, [])+ -- cache the wallet after two steps of three+ when (seed `mod` 3 /= 0) $ do+ tip <- tipNow+ built <- answeringAt m tip (\r pool _ -> cacheBuild conn keys tip r pool 1 key)+ at <- cacheTip conn keys+ if at /= Just tip+ then do+ -- the cache is behind the tip: nothing is+ -- written to it, and it does not answer+ built `shouldSatisfy` (`elem` [Just Abandoned, Just (Built 0)])+ cachedEntry conn keys tip 20 32 1 key `shouldReturn` Miss+ else do+ built `shouldSatisfy` \case+ Just (Built _) -> True+ _ -> False+ cachedEntry conn keys tip 20 32 1 key >>= \case+ Hit e -> do+ modifyIORef' hits (+ 1)+ let (n0, n1) = e.sizes+ isHit = \case+ Hit _ -> True+ _ -> False+ cachedTxs conn keys tip key (n0, n1) Top def >>= (`shouldSatisfy` isHit)+ cachedCount conn keys tip key (n0, n1) >>= (`shouldSatisfy` isHit)+ -- not for a caller with other addresses+ cachedTxs conn keys tip key (n0 + 1, n1) Top def `shouldReturn` Miss+ cachedCount conn keys tip key (n0, n1 + 1) `shouldReturn` Miss+ let more = e {sizes = (n0 + 1, n1)}+ cachedUnspents conn keys tip key more Top def `shouldReturn` Miss+ other -> expectationFailure ("the cache did not answer: " <> show other)+ (direct', cached') <- inFleet $ \plain through ->+ (,) <$> mapM (answer plain) asked <*> mapM (answer through) asked+ (show op, differences asked direct' cached') `shouldBe` (show op, [])+ return hist''+ foldM_ turn hist0 seeds+ -- the least recently used entries go when there are too+ -- many, and nothing of them is left+ memberTick m+ memberTick m+ tip <- tipNow+ cacheTip conn keys `shouldReturn` Just tip+ _ <- answeringAt m tip (\r pool _ -> cacheBuild conn keys tip r pool 1 key)+ -- its size is one entry, with the addresses of the wallet+ (n0, n1) <- cacheHave conn keys key+ cacheSize conn keys `shouldReturn` (1, n0 + n1)+ cacheEvict conn keys 1 `shouldReturn` 0+ cacheEvict conn keys 0 `shouldReturn` 1+ cacheSize conn keys `shouldReturn` (0, 0)+ cachedEntry conn keys tip 20 32 1 key `shouldReturn` Miss+ cacheHave conn keys key `shouldReturn` (0, 0)+ closeViews m.views+ closeViews bare.views+ -- what the member logged: that it emptied the cache, and+ -- why, as information, and nothing else above debugging+ logged <- readIORef said+ let emptied = [line | (LevelInfo, line) <- logged]+ [() | (level, _) <- logged, level /= LevelInfo, level /= LevelDebug] `shouldBe` []+ emptied `shouldSatisfy` all ("Emptied the xpub cache at block " `T.isPrefixOf`)+ readIORef hits >>= (`shouldSatisfy` (> 10))++-- | A history that a run of the tests found: a block is mined, reverted and+-- mined again while the cache is not brought along, so that the fleet tip+-- comes back to the block that the cache was at. The cache then looked+-- current, with entries that the pool had taken the transactions of that+-- block out of on the way. It is why the cache is at a move of the tip and+-- not at a block.+leftAndBack :: [Word16]+leftAndBack =+ [ 2944,+ 33599,+ 29992,+ 62497,+ 57974,+ 57422,+ 44271,+ 60202,+ 57257,+ 19310,+ 53832,+ 39324,+ 44849,+ 28951,+ 37975,+ 2064,+ 15159,+ 34699,+ 16471,+ 3642,+ 14550,+ 867,+ 22752,+ 14436,+ 39637,+ 398,+ 25449,+ 11005,+ 20967,+ 47441,+ 46443,+ 63224,+ 4787,+ 36990,+ 56953,+ 1543,+ 36370,+ 24815,+ 19050,+ 39880,+ 16054,+ 22273,+ 62670,+ 13555,+ 30149,+ 1048,+ 1444,+ 45704,+ 39517,+ 1970,+ 43989,+ 15518,+ 54685,+ 58539,+ 63687,+ 1789,+ 3783,+ 27016,+ 43138,+ 28658+ ]
@@ -0,0 +1,182 @@+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)
@@ -0,0 +1,423 @@+module Haskoin.Store.Fleet.RedisSpec (spec, withRedis) where++import Control.Monad (foldM_, forM_)+import Data.ByteString (ByteString)+import Data.ByteString qualified as B+import Data.ByteString.Char8 qualified as C+import Data.Containers.ListUtils (nubOrd, nubOrdOn)+import Data.IORef (atomicModifyIORef', newIORef)+import Data.List (sort, sortOn)+import Data.Map.Strict qualified as Map+import Data.Ord (Down (..))+import Data.Serialize (decode, encode)+import Data.Set qualified as Set+import Data.Word (Word32, Word64)+import Database.Redis qualified as Redis+import Haskoin.Store.Fleet (Time, empty, live, mayReport, move, report)+import Haskoin.Store.Fleet qualified as F+import Haskoin.Store.Fleet.Pool qualified as P+import Haskoin.Store.Fleet.Redis+import Haskoin.Store.Integration.Image (testImage)+import Haskoin.Store.Integration.Podman (hostPort, podmanUsable, withContainer)+import Haskoin.Store.Integration.Valkey (redisContainer, waitRedis)+import Test.Hspec+import Test.Hspec.QuickCheck (modifyMaxSuccess)+import Test.QuickCheck++-- | A step of a fleet of three stores with blocks numbered from 0 to 9.+data Step+ = -- | a store reports some blocks, having views of some+ Report !Int ![Word32] ![Word32]+ | -- | a store tries to move the tip: from the tip as it is, or from+ -- another block, as one that was beaten to it would+ Shift !(Maybe Word32)+ | -- | milliseconds pass+ Wait !Time+ deriving (Show)++instance Arbitrary Step where+ arbitrary =+ frequency+ [ (5, Report <$> choose (0, 2) <*> blocks <*> blocks),+ (4, pure (Shift Nothing)),+ (1, Shift . Just <$> choose (0, 9)),+ (2, Wait <$> choose (1, 6))+ ]+ where+ blocks = choose (0, 5) >>= \n -> vectorOf n (choose (0, 9))++spec :: Spec+spec = do+ usable <- runIO podmanUsable+ describe "fleet tip in Redis" $ case usable of+ Left why -> it "does what the rules say" (pendingWith why)+ Right () -> aroundAll withRedis $ do+ modifyMaxSuccess (const 300) $+ it "does what the rules say, step by step" $ \(conn, fresh) ->+ property $ \steps -> ioProperty $ do+ keys <- fresh+ foldM_ (check conn keys) (empty :: F.Fleet ByteString ByteString, 1000) (steps :: [Step])+ it "counts leases on the clock of Redis when given no other" $ \(conn, fresh) -> do+ keys <- fresh+ fleetReport conn keys Nothing 60000 "a" [block 2, block 1] [] `shouldReturn` True+ fleetMove conn keys Nothing Nothing `shouldReturn` MovedTo (block 2)+ fleetTip conn keys `shouldReturn` Just (block 2)+ -- a store without a view of the tip is kept out while the first is live+ fleetReport conn keys Nothing 60000 "b" [block 1] [block 1] `shouldReturn` False+ fleetReport conn keys Nothing 60000 "b" [block 2] [block 2] `shouldReturn` True+ map fst <$> fleetLive conn keys `shouldReturn` ["a", "b"]+ modifyMaxSuccess (const 300) $+ it "keeps the shared mempool as the rules say, step by step" $ \(conn, fresh) ->+ property $ \steps -> ioProperty $ do+ keys <- fresh+ foldM_ (pooled conn keys) (P.empty, Nothing, 0) (steps :: [PoolStep])+ it "turns away an offer that was checked against another tip" $ \(conn, fresh) -> do+ keys <- fresh+ fleetReport conn keys Nothing 60000 "a" [block 1] [] `shouldReturn` True+ fleetMove conn keys Nothing Nothing `shouldReturn` MovedTo (block 1)+ let seven = (txOf 7 [out 0 0]) {body = "seven"}+ poolOffer conn keys 4 Nothing seven [True] [] `shouldReturn` Late+ poolOffer conn keys 4 (Just (block 1)) seven [True] [] `shouldReturn` Taken+ poolBody conn keys (block 7) `shouldReturn` Just "seven"+ -- the tip does not move to a block that the stores do not agree on+ fleetAdvance conn keys 4 Nothing (Just (block 1)) (block 2) (Just nothing) [] `shouldReturn` Raced+ fleetReport conn keys Nothing 60000 "a" [block 2, block 1] [block 1] `shouldReturn` True+ let confirms = nothing {confirmed = [block 7], spent = [out 0 0]}+ fleetAdvance conn keys 4 Nothing (Just (block 1)) (block 2) (Just confirms) [] `shouldReturn` MovedTo (block 2)+ poolSpends conn keys `shouldReturn` []+ poolBody conn keys (block 7) `shouldReturn` Nothing+ it "tells what happens to the pool and to the tip, in the order in which it happens" $ \(conn, fresh) -> do+ keys <- fresh+ fleetReport conn keys Nothing 60000 "a" [block 1] [] `shouldReturn` True+ fleetAdvance conn keys 4 Nothing Nothing (block 1) (Just nothing) [] `shouldReturn` MovedTo (block 1)+ -- a store that starts to listen hears what comes after+ start <- fleetEventsEnd conn keys+ let seven = txOf 7 [out 0 0]+ eight = txOf 8 [out 7 0]+ nine = txOf 9 [out 0 1]+ forM_ [seven, eight, nine] $ \t ->+ poolOffer conn keys 4 (Just (block 1)) t [True] [] `shouldReturn` Taken+ -- what spends a transaction that is withdrawn leaves with it+ _ <- poolWithdraw conn keys 4 [block 7]+ -- a block confirms the third, which is not an event of the mempool,+ -- and the tip moves through two blocks+ fleetReport conn keys Nothing 60000 "a" [block 3, block 2, block 1] [block 1] `shouldReturn` True+ let confirms = nothing {confirmed = [block 9], spent = [out 0 1]}+ fleetAdvance conn keys 4 Nothing (Just (block 1)) (block 3) (Just confirms) [block 2, block 3]+ `shouldReturn` MovedTo (block 3)+ heard <- map snd <$> fleetEventsAfter conn keys start 10+ take 3 heard `shouldBe` map FleetEntered [block 7, block 8, block 9]+ sort (take 2 (drop 3 heard)) `shouldBe` map FleetLeft [block 7, block 8]+ drop 5 heard `shouldBe` map FleetBlock [block 2, block 3]+ -- and nothing twice: from the last event on there is nothing+ end <- fleetEventsEnd conn keys+ fleetEventsAfter conn keys end 10 `shouldReturn` []+ it "empties the pool for a store that is alone and does not know the way" $ \(conn, fresh) -> do+ keys <- fresh+ fleetReport conn keys (Just 0) 10 "a" [block 1] [] `shouldReturn` True+ fleetAdvance conn keys 4 (Just 0) Nothing (block 1) Nothing [] `shouldReturn` MovedTo (block 1)+ poolOffer conn keys 4 (Just (block 1)) (txOf 7 [out 0 0]) [True] [] `shouldReturn` Taken+ -- with another store live, the tip waits for one that knows+ fleetReport conn keys (Just 0) 10 "a" [block 2, block 1] [block 1] `shouldReturn` True+ fleetReport conn keys (Just 0) 10 "b" [block 2, block 1] [block 1] `shouldReturn` True+ fleetAdvance conn keys 4 (Just 0) (Just (block 1)) (block 2) Nothing [] `shouldReturn` Raced+ map fst <$> poolSpends conn keys `shouldReturn` [block 7]+ -- the other store's lease runs out+ fleetReport conn keys (Just 20) 10 "a" [block 2, block 1] [block 1] `shouldReturn` True+ fleetAdvance conn keys 4 (Just 20) (Just (block 1)) (block 2) Nothing [] `shouldReturn` MovedTo (block 2)+ poolSpends conn keys `shouldReturn` []+ (Redis.runRedis conn (Redis.keys (keys.prefix <> "pool:*")) >>= either (fail . show) return)+ `shouldReturn` []+ it "reads the pool by the page, as it is at the tip that is asked for" $ \(conn, fresh) -> do+ keys <- fresh+ fleetReport conn keys (Just 0) 10 "a" [block 1] [] `shouldReturn` True+ fleetAdvance conn keys 4 (Just 0) Nothing (block 1) Nothing [] `shouldReturn` MovedTo (block 1)+ let tip = Just (block 1)+ a = address 1+ -- nine transactions, of which the fifth spends the first output of+ -- the first two+ forM_ [0 .. 8] $ \t ->+ poolOffer conn keys 4 tip (txOf t [out m 0 | t == 4, m <- [0, 1]]) [False | t == 4, _ <- [0, 1 :: Int]] []+ `shouldReturn` Taken+ poolRead conn keys 4 Nothing "page" ["0", "0"] `shouldReturn` Nothing+ -- the list of the pool: newest first, and by id downwards in a second+ Just listed <- poolRead conn keys 4 tip "page" ["0", "0"]+ pairs listed `shouldBe` [(block t, time) | (time, t) <- sortOn Down [(timeOf t, t) | t <- [0 .. 8]]]+ Just some <- poolRead conn keys 4 tip "page" ["2", "3"]+ pairs some `shouldBe` take 3 (drop 2 (pairs listed))+ -- an address: its sums, and its transactions from the top and from+ -- one of them+ let expected = P.addresses [(block t, timeOf t, entriesOf t [out m 0 | t == 4, m <- [0, 1]]) | t <- [0 .. 8]]+ mine = expected Map.! a+ totalled = P.totals mine+ Just [sums, none] <- poolRead conn keys 4 tip "sums" [a, "nobody"]+ map (read . C.unpack) (C.words sums)+ `shouldBe` [ totalled.txs,+ totalled.paid,+ totalled.paidCount,+ totalled.spent,+ totalled.spentCount,+ totalled.both,+ totalled.bothCount+ ]+ none `shouldBe` ""+ totalled.bothCount `shouldSatisfy` (> 0)+ Just txs <- poolRead conn keys 4 tip "txs" [a, "", "", "100"]+ pairs txs `shouldBe` [(t, time) | (time, t) <- mine.txs]+ forM_ (zip [0 ..] mine.txs) $ \(i, (time, t)) -> do+ Just from <- poolRead conn keys 4 tip "txs" [a, C.pack (show time), t, "2"]+ pairs from `shouldBe` take 2 (drop i [(t', time') | (time', t') <- mine.txs])+ -- a transaction that is not there: from where it would be+ let (time0, t0) = (fst (last mine.txs), block 99)+ Just later <- poolRead conn keys 4 tip "txs" [a, C.pack (show time0), t0, "100"]+ pairs later `shouldBe` [(t, time) | (time, t) <- mine.txs, (Down time, t) >= (Down time0, t0)]+ -- its outputs, each with whether the pool spends it+ Just outs <- poolRead conn keys 4 tip "outs" [a, "", "", "100"]+ [(o, time, C.unpack flag) | [o, time, _, flag] <- chunks 4 outs]+ `shouldBe` [ (o, C.pack (show time), if Map.member o mine.spent then "1" else "0")+ | (time, o, _) <- mine.paid+ ]+ Just [flags] <- poolRead conn keys 4 tip "spent" [a, B.concat [o | (_, o, _) <- mine.paid]]+ C.unpack flags `shouldBe` [if Map.member o mine.spent then '1' else '0' | (_, o, _) <- mine.paid]+ -- bodies, spenders with the number of the input, and ids by prefix+ -- bodies come with whether they can be replaced by fee, and with+ -- what spends their outputs: the fifth says itself that it can be+ -- replaced and nothing spends from it; the second does not say so,+ -- and the second input of the fifth spends its first output+ poolRead conn keys 4 tip "bodies" [block 4 <> block 99 <> block 1]+ `shouldReturn` Just ["body", "1", "0", "", "0", "0", "body", "0", "1", out 1 0, block 4, "1"]+ poolRead conn keys 4 tip "spenders" [out 1 0 <> out 0 0 <> out 5 0]+ `shouldReturn` Just [block 4, "1", block 4, "0", "", ""]+ poolRead conn keys 4 tip "ids" [B.take 3 (block 4)] `shouldReturn` Just (map block [0 .. 8])+ poolRead conn keys 4 tip "ids" [block 4] `shouldReturn` Just [block 4]+ where+ -- carry a step out in Redis and in the model, and compare+ check conn keys (model, now) = \case+ Wait ms -> return (model, now + ms)+ Report s recent views -> do+ let name = C.pack (show s)+ allowed = mayReport now name (`elem` map block views) model+ fleetReport conn keys (Just now) lease name (map block recent) (map block views)+ `shouldReturn` allowed+ let model' = if allowed then report now lease name (map block recent) model else model+ same conn keys now model'+ return (model', now)+ Shift wrong -> do+ let seen = maybe model.tip (Just . block) wrong+ (expected, model') = case move now seen model of+ F.Moved next -> (maybe Stays MovedTo next.tip, next)+ F.Stays -> (Stays, model)+ F.Raced -> (Raced, model)+ fleetMove conn keys (Just now) seen `shouldReturn` expected+ same conn keys now model'+ return (model', now)+ nothing = PoolChange {returned = [], gone = [], confirmed = [], spent = []}+ -- carry a step of the pool out in Redis and in the model, and compare+ pooled conn keys (model, tip, n) = \case+ Give t inputs replaced -> do+ -- a transaction spends from those of lower numbers only, as none+ -- can spend from one that spends from it+ let outs = [(out a i, ok) | (a, i, ok) <- nubOrdOn (\(a, i, _) -> (a, i)) inputs, a < t]+ unspent o = lookup o outs == Just True+ (expected, model') = case P.offer maker unspent (block t) (map fst outs) (map block replaced) model of+ P.Taken next -> (Taken, next)+ P.Refused P.Known -> (Known, model)+ P.Refused (P.Conflict rivals) -> (Conflict rivals, model)+ P.Refused (P.Orphan os) -> (Orphan os, model)+ got <- poolOffer conn keys 4 tip (txOf t (map fst outs)) (map snd outs) (map block replaced)+ sorted got `shouldBe` sorted expected+ samePool conn keys model'+ return (model', tip, n)+ Take ts -> do+ let model' = P.withdraw (map block ts) model+ left <- poolWithdraw conn keys 4 (map block ts)+ Set.fromList left `shouldBe` Map.keysSet model.spends `Set.difference` Map.keysSet model'.spends+ samePool conn keys model'+ return (model', tip, n)+ Go back dead done used -> do+ -- one store, which reports a block that no step has had+ let target = block (1000 + n)+ returned' = [(block t, [out a i | (a, i) <- nubOrd ins, a < t]) | (t, ins) <- back]+ change = P.Change returned' (map block dead) (map block done) [out a i | (a, i) <- used]+ sent =+ PoolChange+ [txOf (number t) os | (t, os) <- returned']+ change.gone+ change.confirmed+ change.spent+ fleetReport conn keys (Just 0) 10 "a" [target] (maybe [] pure tip) `shouldReturn` True+ fleetAdvance conn keys 4 (Just 0) tip target (Just sent) [] `shouldReturn` MovedTo target+ let model' = P.advance maker change model+ samePool conn keys model'+ return (model', Just target, n + 1)+ maker = B.take 4+ sorted = \case+ Conflict rivals -> Conflict (sort rivals)+ other -> other+ samePool conn keys model = do+ let key k = keys.prefix <> k+ list :: Redis.Redis (Either Redis.Reply a) -> IO a+ list = fmap (either (error . show) id) . Redis.runRedis conn+ spends <- poolSpends conn keys+ Map.fromList spends `shouldBe` Map.map B.concat model.spends+ spender <- list (Redis.hgetall (key "pool:spender"))+ Map.fromList spender `shouldBe` model.spender+ kids <- list (Redis.keys (key "pool:kids:*"))+ Set.fromList kids `shouldBe` Set.map (key . ("pool:kids:" <>)) (Map.keysSet model.children)+ forM_ (Map.toList model.children) $ \(t, expected) -> do+ members <- list (Redis.smembers (key ("pool:kids:" <> t)))+ Set.fromList members `shouldBe` expected+ -- those that can be replaced by fee are those that say so and what+ -- spends from them+ canBe <- list (Redis.smembers (key "pool:rbf"))+ Set.fromList canBe `shouldBe` P.replaceable signalling maker model+ -- and what it holds for each address is what the entries of its+ -- transactions come to+ let members = [(t, timeOf (number t), entriesOf (number t) os) | (t, os) <- Map.toList model.spends]+ expected = P.addresses members+ scored :: [(ByteString, Double)] -> [(Word64, ByteString)]+ scored rows = [(round (negate s), m) | (m, s) <- rows]+ listed <- list (Redis.zrangeWithscores (key "pool:time") 0 (-1))+ [(round s, t) | (t, s) <- listed] `shouldBe` sort [(time, t) | (t, time, _) <- members]+ forM_ (map address [0 .. 3]) $ \a -> do+ txs <- list (Redis.zrangeWithscores (key ("pool:atx:" <> a)) 0 (-1))+ outs <- list (Redis.zrangeWithscores (key ("pool:aout:" <> a)) 0 (-1))+ values <- Map.fromList <$> list (Redis.hgetall (key ("pool:aval:" <> a)))+ spent <- list (Redis.hgetall (key ("pool:aspent:" <> a)))+ sums <- Map.fromList <$> list (Redis.hgetall (key ("pool:asum:" <> a)))+ let amount o = maybe 0 (read . C.unpack . C.takeWhile (/= ' ')) (Map.lookup o values)+ found =+ P.AddressPool+ { txs = scored txs,+ paid = [(time, o, amount o) | (time, o) <- scored outs],+ spent = Map.fromList [(o, read (C.unpack v)) | (o, v) <- spent]+ }+ sumOf f = maybe 0 (read . C.unpack) (Map.lookup f sums)+ totalled =+ P.Totals+ { txs = sumOf "txs",+ paid = sumOf "paid",+ paidCount = sumOf "paidCount",+ spent = sumOf "spent",+ spentCount = sumOf "spentCount",+ both = sumOf "both",+ bothCount = sumOf "bothCount"+ }+ case Map.lookup a expected of+ Nothing -> (a, found, Map.null sums) `shouldBe` (a, P.AddressPool [] [] Map.empty, True)+ Just p -> (a, found, totalled) `shouldBe` (a, p, P.totals p)+ Map.keysSet values `shouldBe` Set.fromList [o | (_, o) <- scored outs]+ same conn keys now model = do+ fleetTip conn keys `shouldReturn` (model :: F.Fleet ByteString ByteString).tip+ leases <- fleetLive conn keys+ Map.keys (Map.filter (> now) (Map.fromList leases)) `shouldBe` Map.keys (live now model)+ lease = 10++-- | The items of a list two by two, each second one a number.+pairs :: [ByteString] -> [(ByteString, Word64)]+pairs rows = [(x, read (C.unpack n)) | [x, n] <- chunks 2 rows]++-- | The items of a list so many at a time.+chunks :: Int -> [a] -> [[a]]+chunks _ [] = []+chunks n xs = take n xs : chunks n (drop n xs)++-- | A block of the tests, or a transaction: four bytes.+block :: Word32 -> ByteString+block = encode++-- | An output of a transaction of the tests.+out :: Word32 -> Word32 -> ByteString+out t i = encode t <> encode i++-- | The number of a block or of a transaction of the tests.+number :: ByteString -> Word32+number = either error id . decode++-- | One of the four addresses of the tests.+address :: Word32 -> ByteString+address n = C.pack ("address " <> show (n `mod` 4))++-- | The address that an output of the tests is paid to, and its amount.+-- Outputs of different transactions go to the same addresses, and a+-- transaction that spends an output does so at the address that it was+-- paid to, so that the pool has addresses with outputs both paid and spent.+paidTo :: ByteString -> (ByteString, Word64)+paidTo o = (address (t + i), fromIntegral (t * 10 + i + 1))+ where+ (t, i) = (number (B.take 4 o), number (B.drop 4 o))++-- | The time at which a transaction of the tests entered a mempool: one of+-- three, so that several have the same.+timeOf :: Word32 -> Word64+timeOf t = fromIntegral (t `mod` 3) + 100++-- | What a transaction of the tests that spends some outputs does at each+-- address: it pays two outputs of its own.+entriesOf :: Word32 -> [ByteString] -> [P.Entry ByteString ByteString]+entriesOf t spends =+ map P.Touches (nubOrd (map (fst . paidTo) (spends <> made)))+ <> [P.Spends a o v | o <- spends, let (a, v) = paidTo o]+ <> [P.Pays a o v | o <- made, let (a, v) = paidTo o]+ where+ made = [out t 0, out t 1]++-- | Whether a transaction of the tests says itself that it can be replaced+-- by fee: one in four does.+signalling :: ByteString -> Bool+signalling t = number t `mod` 4 == 0++-- | A transaction of the tests as the pool takes it.+txOf :: Word32 -> [ByteString] -> PoolTx+txOf t spends =+ PoolTx+ { txid = block t,+ time = timeOf t,+ inputs = spends,+ body = "body",+ signals = signalling (block t),+ entries = map entry (entriesOf t spends)+ }+ where+ entry = \case+ P.Touches a -> PoolTouches a+ P.Spends a o v -> PoolSpends a o v+ P.Pays a o v -> PoolPays a o v "script"++-- | A step of the shared mempool, with transactions numbered from 0 to 11.+data PoolStep+ = -- | a transaction is offered, spending some outputs, each found unspent+ -- at the tip or not, and replacing some transactions+ Give !Word32 ![(Word32, Word32, Bool)] ![Word32]+ | -- | transactions are withdrawn+ Take ![Word32]+ | -- | the tip moves: some transactions come back with what they spend,+ -- some are gone, some are confirmed, and some outputs are spent+ Go ![(Word32, [(Word32, Word32)])] ![Word32] ![Word32] ![(Word32, Word32)]+ deriving (Show)++instance Arbitrary PoolStep where+ arbitrary =+ frequency+ [ (8, Give <$> tx <*> few ((,,) <$> tx <*> choose (0, 1) <*> arbitrary) <*> few tx),+ (1, Take <$> few tx),+ (3, Go <$> few ((,) <$> tx <*> few output) <*> few tx <*> few tx <*> few output)+ ]+ where+ tx = choose (0, 11)+ output = (,) <$> tx <*> choose (0, 1)+ few :: Gen a -> Gen [a]+ few one = choose (0, 3) >>= (`vectorOf` one)++-- | A Redis in a container, and a way to get the keys of a fleet that no+-- test has used.+withRedis :: ((Redis.Connection, IO FleetKeys) -> IO ()) -> IO ()+withRedis go = do+ image <- testImage+ withContainer (redisContainer image) $ \c -> do+ port <- hostPort c 6379+ let url = "redis://127.0.0.1:" <> show port+ waitRedis url+ conn <- Redis.checkedConnect (either error id (Redis.parseConnectInfo url))+ counter <- newIORef (0 :: Int)+ go (conn, (\n -> fleetKeys (C.pack ("t" <> show n <> ":")) 4) <$> atomicModifyIORef' counter (\n -> (n + 1, n)))
@@ -0,0 +1,77 @@+module Haskoin.Store.Fleet.XPubSpec (spec) where++import Data.Functor.Identity (Identity (..))+import Data.HashMap.Strict qualified as HashMap+import Data.HashSet qualified as HashSet+import Data.Serialize (encode)+import Data.Word (Word32)+import Haskoin (Address (..), addressHash)+import Haskoin.Store.Common (scanXPubBals)+import Haskoin.Store.Data (Balance (..), BlockRef (..), XPubBal (..), zeroBalance)+import Haskoin.Store.Fleet.XPub+import Test.Hspec+import Test.Hspec.QuickCheck (prop)+import Test.QuickCheck++spec :: Spec+spec = describe "cache of extended public keys of a fleet" $ do+ describe "score" $ do+ prop "is undone by position, in the chain" $ \(h, p) ->+ let ref = BlockRef {height = h `mod` 0x08000000, position = p `mod` 0x04000000}+ in position (score ref) === ref+ prop "is undone by position, in the mempool" $ \t ->+ let ref = MemRef {timestamp = fromIntegral (t :: Word32) + 1}+ in position (score ref) === ref+ prop "puts the newest first" $ \(a, b) ->+ let ref (h, p) = BlockRef {height = h `mod` 0x08000000, position = p `mod` 0x04000000}+ in compare (score (ref a)) (score (ref b)) === compare (ref b) (ref a)+ it "puts the mempool before the chain, newest first" $ do+ score (MemRef 20) `shouldSatisfy` (< score (MemRef 10))+ score (MemRef 10) `shouldSatisfy` (< score (BlockRef 500 0))+ describe "window" $ do+ -- which addresses of the two chains of a key are used, and the gaps+ let key = do+ igap <- choose (1, 8)+ gap <- choose (1, 8)+ ext <- HashSet.fromList <$> listOf (choose (0, 30 :: Word32))+ chg <- HashSet.fromList <$> listOf (choose (0, 30 :: Word32))+ return (igap, gap, ext, chg)+ address m n = PubKeyAddress (addressHash (encode (m :: Word32, n :: Word32)))+ balance used m n =+ let zero = zeroBalance (address m n)+ in if HashSet.member n used then zero {txs = 1, received = 5} else zero+ scan (igap, gap, ext, chg) =+ let chain m = [(n, address m n) | n <- [0 ..]]+ known =+ HashMap.fromList $+ [(address 0 n, balance ext 0 n) | n <- [0 .. 80]]+ <> [(address 1 n, balance chg 1 n) | n <- [0 .. 80]]+ bal a = HashMap.lookupDefault (zeroBalance a) a known+ in runIdentity (scanXPubBals (return . map bal) igap gap (chain 0) (chain 1))+ rows found = [(m, n, b.balance) | b@XPubBal {path = [m, n]} <- found]+ it "gives the scan when the entry has the addresses that it reads" $+ property . forAll key $ \k@(igap, gap, _, _) ->+ let found = scan k in window igap gap (rows found) === Just found+ it "gives nothing when the entry lacks the last address of a chain" $+ property . forAll key $ \k@(igap, gap, _, _) ->+ let found = scan k+ final m = maximum [n | (m', n, _) <- rows found, m' == m]+ lacking m = [r | r@(m', n, _) <- rows found, m' /= m || n /= final m]+ in conjoin [window igap gap (lacking m) === Nothing | m <- [0, 1], any (\(m', _, _) -> m' == m) (rows found)]+ it "gives nothing when the entry has an address more" $+ property . forAll key $ \k@(igap, gap, ext, chg) ->+ let found = scan k+ next m = length [() | (m', _, _) <- rows found, m' == m]+ more m used = rows found <> [(m, fromIntegral (next m), balance used m (fromIntegral (next m)))]+ -- one more is another key, unless the scan read no change+ -- addresses at all and the one more is the first of them+ changeless = next 1 == 0+ in conjoin+ [ window igap gap (more 0 ext) === Nothing,+ changeless .||. window igap gap (more 1 chg) === Nothing+ ]+ it "gives nothing when a chain has a hole" $+ property . forAll key $ \k@(igap, gap, _, _) ->+ let found = scan k+ holed = [r | r@(m, n, _) <- rows found, (m, n) /= (0, 0)]+ in length found > 1 ==> window igap gap holed === Nothing
@@ -0,0 +1,199 @@+module Haskoin.Store.FleetSpec (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+import Test.Hspec+import Test.Hspec.QuickCheck (modifyMaxSuccess)+import Test.QuickCheck++-- | A store of the model: its main chain, newest block first, and the blocks+-- that it has views of.+data Node = Node+ { chain :: ![Int],+ views :: !(Set Int)+ }+ deriving (Show)++-- | A fleet of three stores, the chain that the network has, newest block+-- first, the state in Redis, and the time.+data World = World+ { network :: ![Int],+ fresh :: !Int,+ nodes :: !(Map Int Node),+ redis :: !(Fleet Int Int),+ now :: !Time+ }+ deriving (Show)++-- | Something that happens.+data Op+ = -- | the network finds a block+ Grow+ | -- | the network replaces its last blocks, as many as given, with as many+ -- new ones and one more+ Reorg !Int+ | -- | a store catches up with the network by so many steps, each the+ -- reversal or the import of one block+ Sync !Int !Int+ | -- | a store reports, moves the tip if it can, and lets go of the views+ -- that it need not keep+ Beat !Int+ | -- | time passes+ Tick !Time+ deriving (Show)++instance Arbitrary Op where+ arbitrary =+ frequency+ [ (3, pure Grow),+ (1, Reorg <$> choose (1, 3)),+ (8, Sync <$> choose (0, 2) <*> choose (1, 4)),+ (8, Beat <$> choose (0, 2)),+ (3, Tick <$> choose (1, 6))+ ]++-- | How a store of the model behaves: whether it asks before it reports,+-- and whether it keeps the views that the rules tell it to.+data Rules = Rules+ { asks :: !Bool,+ keeps :: !Bool+ }++byTheRules :: Rules+byTheRules = Rules {asks = True, keeps = True}++-- | Leases last ten ticks, and a store reports its six newest blocks.+lease :: Time+lease = 10++window :: Int+window = 6++start :: World+start =+ World+ { network = [0],+ fresh = 1,+ nodes = Map.fromList [(s, Node {chain = [0], views = Set.singleton 0}) | s <- [0 .. 2]],+ redis = empty,+ now = 0+ }++step :: Rules -> World -> Op -> World+step rules w = \case+ Grow -> w {network = w.fresh : w.network, fresh = w.fresh + 1}+ Reorg n ->+ let kept = drop (min n (length w.network - 1)) w.network+ new = [w.fresh + n, w.fresh + n - 1 .. w.fresh]+ in w {network = new <> kept, fresh = w.fresh + n + 1}+ Sync s n -> w {nodes = Map.adjust (\node -> iterate catchUp node !! n) s w.nodes}+ Tick dt -> w {now = w.now + dt}+ Beat s ->+ let node = w.nodes Map.! s+ has b = Set.member b node.views+ -- only what it still has a view of: a view that was let go cannot be+ -- had again+ blocks = filter has (take window node.chain)+ allowed = not rules.asks || mayReport w.now s has w.redis+ reported = report w.now lease s blocks w.redis+ moved = case move w.now reported.tip reported of+ Moved fleet -> fleet+ _ -> reported+ kept+ | rules.keeps = Set.fromList (keep blocks moved.tip) `Set.intersection` node.views+ | otherwise = Set.fromList (take 1 node.chain)+ in if allowed+ then w {redis = moved, nodes = Map.insert s node {views = kept} w.nodes}+ else w+ where+ -- one step towards the chain of the network: revert a block that it+ -- does not have, or import the next one that it has+ catchUp node+ | b : rest <- node.chain, b `notElem` w.network = node {chain = rest}+ | next : _ <- reverse (takeWhile (`notElem` node.chain) w.network) =+ node {chain = next : node.chain, views = Set.insert next node.views}+ | otherwise = node++-- | The stores whose lease has not run out and that have no view of the+-- tip: those that would have to refuse a request although they are counted+-- in.+stranded :: World -> [Int]+stranded w =+ [ s+ | Just t <- [w.redis.tip],+ s <- Map.keys (live w.now w.redis),+ not (Set.member t (w.nodes Map.! s).views)+ ]++-- | Every state that a list of operations goes through.+states :: Rules -> [Op] -> [World]+states rules = scanl (step rules) start++spec :: Spec+spec = describe "fleet tip" $ do+ it "is the newest block that every live store reported" $ do+ let fleet = foldl' (\f (s, bs) -> report 0 10 s bs f) (empty :: Fleet Int Int) reports'+ reports' = [(0, [5, 4, 3, 2]), (1, [4, 3, 2, 1]), (2, [7, 6, 3, 2])]+ agreed 0 fleet `shouldBe` Just 3+ -- a store whose lease has run out no longer counts+ agreed 12 (report 12 10 0 [5, 4, 3] (report 12 10 1 [4, 3] fleet)) `shouldBe` Just 4+ agreed 20 fleet `shouldBe` Nothing++ it "moves once when two stores try at the same time" $ do+ let fleet = report 0 10 0 [2, 1] (report 0 10 1 [2, 1] (empty :: Fleet Int Int))+ case move 0 Nothing fleet of+ Moved next -> do+ next.tip `shouldBe` Just 2+ -- the second store saw the same tip as the first, and is too late+ move 0 Nothing next `shouldBe` Raced+ move 0 (Just 2) next `shouldBe` Stays+ other -> expectationFailure (show other)++ it "keeps a store that is behind from pulling the tip back" $ do+ let ahead = report 0 10 0 [9, 8, 7, 6] (empty :: Fleet Int Int)+ fleet = case move 0 Nothing ahead of+ Moved f -> f+ _ -> ahead+ fleet.tip `shouldBe` Just 9+ mayReport 1 1 (`elem` [7, 6, 5 :: Int]) fleet `shouldBe` False+ mayReport 1 1 (`elem` [9, 8, 7 :: Int]) fleet `shouldBe` True+ -- when the store that was ahead is gone, the other may start again+ mayReport 11 1 (`elem` [7, 6, 5 :: Int]) fleet `shouldBe` True++ it "answers from the tip, from its own block before there is one, or not at all" $ do+ let fleet = (empty :: Fleet Int Int) {tip = Just 4}+ answer (const True) (empty :: Fleet Int Int) `shouldBe` Alone+ answer (== 4) fleet `shouldBe` At 4+ answer (== 5) fleet `shouldBe` Behind++ modifyMaxSuccess (const 2000) $ do+ it "never leaves a live store without a view of the tip" $+ property $ \ops ->+ let trouble = [(w.redis.tip, stranded w) | w <- states byTheRules ops, not (null (stranded w))]+ in counterexample (show (take 1 trouble)) (null trouble)++ it "moves along and across branches, never to a block that no store has" $+ property $ \ops ->+ let tips = [t | w <- states byTheRules ops, Just t <- [w.redis.tip]]+ made w = w.fresh+ in conjoin+ [ cover 5 (length (Set.fromList tips) > 3) "the tip was at more than three blocks" True,+ property (all (< made (last (states byTheRules ops))) tips)+ ]++ it "ends at the best block once every store has caught up and reported" $+ property $ \ops ->+ let settled = foldl' (step byTheRules) (last (states byTheRules ops)) settle+ settle = [Tick 20] <> [Sync s 100 | s <- [0 .. 2]] <> [Beat s | s <- [0 .. 2]] <> [Beat 0]+ in (maybe [] pure settled.redis.tip, stranded settled) === (take 1 settled.network, [])++ it "needs the store to ask before it reports" $+ expectFailure . property $ \ops ->+ all (null . stranded) (states byTheRules {asks = False} ops)++ it "needs the store to keep the views of what it reported" $+ expectFailure . property $ \ops ->+ all (null . stranded) (states byTheRules {keeps = False} ops)
@@ -0,0 +1,783 @@+-- | Random histories of a store, for tests that compare two ways of keeping+-- its database: the layout in use today, where the chain and the mempool+-- share their indices, and the layered layout of @docs/layered-database.md@.+--+-- A history is a sequence of operations: transactions for the mempool,+-- blocks, deletions and reversals, some of them invalid. Each is chosen from+-- a number and from what a database of today's layout holds at that point,+-- so that the same history can be given to both sets of rules.+--+-- Payments go to a handful of fixed scripts and to a few addresses of an+-- extended public key, 'wallet', so that the queries about extended public+-- keys have something to find.+module Haskoin.Store.Histories+ ( -- * Databases and rules+ open20,+ openLayered,+ withShadow,+ Rules (..),+ mixedRules,+ layeredRules,+ run,++ -- * Histories+ History (..),+ Op (..),+ Outcome (..),+ start,+ plan,+ apply,+ record,+ step,+ blockOn,+ coinOf,+ payments,+ anyNumber,+ onlyPayments,+ p2pkh,+ hashLock,+ addressesOf,+ wallet,++ -- * Answers+ mixed,+ layered,+ pooled,+ Answers (..),+ ask,++ -- * Pages+ Question (..),+ Reply (..),+ questions,+ answer,+ differences,+ startOf,+ startsKnown,+ size,++ -- * Column families+ Chain (..),+ chainOf,+ dump,+ )+where++import Control.Monad (void)+import Data.Bifunctor (first)+import Data.Bits (shiftR)+import Data.ByteString (ByteString)+import Data.ByteString qualified as B+import Data.Default (def)+import Data.Either (isLeft, rights)+import Data.Hashable (hashWithSalt)+import Data.IntMap.Strict (IntMap)+import Data.IntMap.Strict qualified as IntMap+import Data.List (sort, sortOn)+import Data.Maybe (catMaybes, fromMaybe, mapMaybe)+import Data.Serialize (decode, encode, getWord64be, runGet)+import Data.Word (Word16, Word32, Word64, Word8)+import Database.RocksDB (ColumnFamily, iterEntry, iterFirst, iterNext, withIterCF)+import Haskoin+ ( Address,+ Block (..),+ BlockHash,+ BlockHeader (..),+ BlockHeight,+ BlockNode (..),+ Ctx,+ Network,+ OutPoint (..),+ Tx (..),+ TxHash (..),+ TxIn (..),+ TxOut (..),+ addressToScriptBS,+ deriveXPubKey,+ headerHash,+ makeXPrvKey,+ nullOutPoint,+ pubSubKey,+ scriptToAddressBS,+ sha256,+ txHash,+ )+import Haskoin.Node (LogLevel (..), Logger (..))+import Haskoin.Store.Common+ ( Limits (..),+ Start (..),+ StoreRead (..),+ deriveAddresses,+ deriveFunction,+ sortTxs,+ )+import Haskoin.Store.Data+ ( Balance (..),+ BlockData (..),+ BlockRef (..),+ DeriveType (..),+ Prev (..),+ Spender (..),+ TxData (..),+ TxRef (..),+ UnixTime,+ Unspent (..),+ XPubBal (..),+ XPubSpec (..),+ XPubUnspent (..),+ confirmed,+ zeroBalance,+ )+import Haskoin.Store.Database.Query qualified as Q+import Haskoin.Store.Database.Query.Layered qualified as L+import Haskoin.Store.Database.Query.Pooled qualified as P+import Haskoin.Store.Database.Reader+ ( DatabaseReader (..),+ addrOutCF,+ addrTxCF,+ balanceCF,+ spenderCF,+ txCF,+ unspentCF,+ withDatabaseReader,+ withLayeredReader,+ )+import Haskoin.Store.Database.Types (BalVal (..), SpenderKey (..), SpenderVal (..))+import Haskoin.Store.Database.Writer (MempoolChanges (..), Writer, runWriterMempool)+import Haskoin.Store.Logic (ImportException)+import Haskoin.Store.Logic qualified as Mixed+import Haskoin.Store.Logic.Layered qualified as Layered+import Haskoin.Store.TestBlocks (nextBlock, script)+import Test.QuickCheck (Gen, choose, elements, frequency, vectorOf)+import UnliftIO (try, withSystemTempDirectory)++-------------------------------------------------------------------------------+-- Databases and rules+-------------------------------------------------------------------------------++-- | Open a database as the store does today, and as the layered layout+-- needs it.+open20, openLayered :: Network -> Ctx -> FilePath -> (DatabaseReader -> IO a) -> IO a+open20 net ctx dir = withDatabaseReader net ctx 20 32 dir False Nothing Nothing Nothing Nothing Nothing+openLayered net ctx dir = withLayeredReader net ctx 20 32 dir False Nothing Nothing Nothing Nothing Nothing++-- | Run an action with a new database of the layout of version 20 that has+-- the start of a history ('start'), in a directory of its own.+--+-- 'plan' chooses the next operation of a history by reading a database of+-- that layout: what is unspent, what the mempool has. Given a database of+-- the layered layout it finds almost none of the mempool, and its blocks+-- confirm next to nothing. So a test of the layered layout keeps one of+-- these beside its own database, plans from it, and applies each+-- operation to both, as 'mixedRules' and 'layeredRules' take the same+-- operations and give the same outcomes.+withShadow :: Network -> Ctx -> (DatabaseReader -> IO a) -> IO a+withShadow net ctx action =+ withSystemTempDirectory "haskoin-store-shadow-" $ \dir ->+ open20 net ctx dir $ \shadow -> start mixedRules shadow >> action shadow++-- | The rules that import blocks and transactions, for one layout.+data Rules = Rules+ { initBest :: Writer -> IO (),+ importBlock :: Writer -> Block -> BlockNode -> IO (BlockData, [TxData]),+ revertBlock :: Writer -> BlockHash -> IO (),+ newMempoolTx :: Writer -> Tx -> UnixTime -> IO Bool,+ deleteUnconfirmedTx :: Writer -> Bool -> TxHash -> IO ()+ }++-- | The rules of the layout in use today.+mixedRules :: Rules+mixedRules =+ Rules+ { initBest = Mixed.initBest,+ importBlock = Mixed.importBlock,+ revertBlock = Mixed.revertBlock,+ newMempoolTx = Mixed.newMempoolTx,+ deleteUnconfirmedTx = Mixed.deleteUnconfirmedTx+ }++-- | The rules of the layered layout.+layeredRules :: Rules+layeredRules =+ Rules+ { initBest = Layered.initBest,+ importBlock = Layered.importBlock,+ revertBlock = Layered.revertBlock,+ newMempoolTx = Layered.newMempoolTx,+ deleteUnconfirmedTx = Layered.deleteUnconfirmedTx+ }++-- | Run a batch of writes, and give what it returned with how it changed+-- the mempool, or the reason why it wrote nothing.+run :: DatabaseReader -> (Writer -> IO a) -> IO (Either ImportException (a, MempoolChanges))+run db = try . runWriterMempool db.net db.ctx db quiet+ where+ quiet = Logger {level = LevelError, source = "", push = \_ -> return ()}++-------------------------------------------------------------------------------+-- Histories+-------------------------------------------------------------------------------++-- | What happened to a database: its main chain, newest block first, every+-- transaction ever made for it, taken or not, coinbases included, the+-- outputs made that have no address, which no index of addresses lists, the+-- time to give the next mempool transaction, and the scripts that payments+-- go to.+data History = History+ { chain :: ![(Block, BlockNode)],+ made :: ![Tx],+ loose :: ![OutPoint],+ clock :: !UnixTime,+ scripts :: ![ByteString]+ }++-- | Something that happens to a store.+data Op+ = -- | a transaction arrives for the mempool, at a time+ Pay !Tx !UnixTime+ | -- | a block arrives, on the best block+ Mine !Block !BlockNode+ | -- | a mempool transaction is deleted, with its descendants+ Delete !TxHash+ | -- | the best block is reverted+ Revert !BlockHash+ deriving (Show)++-- | What an operation did: what the rules returned or why they refused, and+-- the transactions that entered and left the mempool.+data Outcome = Outcome+ { result :: !(Either ImportException String),+ added :: ![TxHash],+ removed :: ![TxHash]+ }+ deriving (Eq, Show)++-- | Give an operation to a set of rules.+apply :: Rules -> DatabaseReader -> Op -> IO Outcome+apply rules db = \case+ Pay tx time -> outcome <$> run db (\w -> rules.newMempoolTx w tx time)+ -- the data of the transactions is left out: it comes with the spenders+ -- that each layout keeps in a body+ Mine block node -> outcome . fmap (first fst) <$> run db (\w -> rules.importBlock w block node)+ Delete h -> outcome <$> run db (\w -> rules.deleteUnconfirmedTx w False h)+ Revert h -> outcome <$> run db (\w -> rules.revertBlock w h)+ where+ outcome :: (Show a) => Either ImportException (a, MempoolChanges) -> Outcome+ outcome (Left e) = Outcome {result = Left e, added = [], removed = []}+ outcome (Right (x, c)) = Outcome {result = Right (show x), added = sort c.added, removed = sort c.removed}++-- | Note what an operation did in a history. Transactions are noted whether+-- they were taken or not, so that questions are asked about all of them.+record :: DatabaseReader -> History -> Op -> Outcome -> History+record db hist op out = case op of+ Pay tx _ -> noted [tx]+ Mine block node+ | isLeft out.result -> noted block.txs+ | otherwise -> (noted block.txs) {chain = (block, node) : hist.chain}+ Delete _ -> hist+ Revert _+ | isLeft out.result -> hist+ | otherwise -> hist {chain = drop 1 hist.chain}+ where+ known = map txHash hist.made+ noted txs =+ let new = [tx | tx <- txs, txHash tx `notElem` known]+ unlisted =+ [ OutPoint (txHash tx) i+ | tx <- new,+ (i, o) <- zip [0 ..] tx.outputs,+ isLeft (scriptToAddressBS db.net db.ctx o.script)+ ]+ in hist {made = new <> hist.made, loose = unlisted <> hist.loose}++-- | A database with the genesis block and one block with a coin, under a+-- set of rules, and its history so far.+start :: Rules -> DatabaseReader -> IO History+start rules db = do+ void (run db rules.initBest) :: IO ()+ let hist = History {chain = [], made = [], loose = [], clock = 1000, scripts = places db.ctx}+ block <- blockOn db []+ record db hist block <$> apply rules db block++-- | A block with these transactions on the best block of a database.+blockOn :: DatabaseReader -> [Tx] -> IO Op+blockOn db txs = do+ best <- Q.getBestBlock db >>= maybe (fail "no best block") return+ parent <- Q.getBlock db best >>= maybe (fail "no data for the best block") return+ return (uncurry Mine (nextBlock db.net parent.height parent.header txs))++-- | The output of the coinbase of the best block.+coinOf :: History -> OutPoint+coinOf hist = case hist.chain of+ (Block {txs = coinbase : _}, _) : _ -> OutPoint (txHash coinbase) 0+ _ -> error "no block with a coinbase"++-- | The scripts that the payments of a history go to: one that has no+-- address on Bitcoin, that of every coinbase, four more of the same kind,+-- and those of five addresses of the 'wallet'.+places :: Ctx -> [ByteString]+places ctx = hashLock : script : map p2pkh [1 .. 4] <> mapMaybe (addressToScriptBS ctx) paid+ where+ spec = wallet ctx+ branch m = map snd (deriveAddresses (deriveFunction ctx spec.deriv) (pubSubKey ctx spec.key m) 0)+ -- with addresses that are never paid between them, so that whether the+ -- later ones are within the gap depends on which earlier ones are used+ paid = map (branch 0 !!) [0, 2, 5] <> map (branch 1 !!) [0, 2]++-- | An extended public key. Five of its addresses are paid in histories:+-- three that it gives out to be paid, and two for its change.+wallet :: Ctx -> XPubSpec+wallet ctx = XPubSpec {key = deriveXPubKey ctx (makeXPrvKey seed), deriv = DeriveNormal}+ where+ seed = "the wallet of the histories of the tests of haskoin-store" :: ByteString++p2pkh :: Word8 -> ByteString+p2pkh n = "\x76\xa9\x14" <> B.replicate 20 n <> "\x88\xac"++-- | A script that has no address on Bitcoin.+hashLock :: ByteString+hashLock = "\xaa\x20" <> B.replicate 32 0xbb <> "\x87"++-- | The addresses of the scripts of a history that have one on a network.+addressesOf :: Network -> Ctx -> History -> [Address]+addressesOf net ctx hist = rights (map (scriptToAddressBS net ctx) hist.scripts)++-- | Eight payments, each from the change of the one before, starting from+-- a coin: a chain in the mempool.+payments :: OutPoint -> [Tx]+payments coin = take 8 (go coin 5000000000)+ where+ go from value =+ let left = value - 10000+ tx = Tx 2 [TxIn from "" maxBound] [TxOut left script, TxOut 9000 (p2pkh 3)] [] 0+ in tx : go (OutPoint (txHash tx) 0) left++-- | A number for 'plan', any of them as likely as any other. QuickCheck's+-- own generator of numbers starts with small ones, which would make the+-- first histories of every run nothing but the same payment.+anyNumber :: Gen Word16+anyNumber = choose (minBound, maxBound)++-- | Turn numbers into numbers that 'plan' reads as payments.+onlyPayments :: [Word16] -> [Word16]+onlyPayments ps = [p - p `mod` 16 + p `mod` 6 | p <- ps]++-- | Choose and carry out one operation under the rules of today's layout,+-- which is the layout that 'plan' reads.+step :: DatabaseReader -> History -> Word16 -> IO History+step db hist seed =+ plan db hist seed >>= \case+ (hist', Nothing) -> return hist'+ (hist', Just op) -> record db hist' op <$> apply mixedRules db op++-- | Choose the next operation of a history from a number, given a database+-- of today's layout that has gone through the history so far. Out of+-- sixteen: six are payments between the scripts of the history, from outputs+-- that are unspent, in a block or in the mempool, a few of them with ten+-- outputs and the others with up to three; one is a transaction that+-- must be refused; one conflicts with a mempool transaction; four are+-- blocks, with part of the mempool, with a transaction that the mempool has+-- not seen, or with one that conflicts with the mempool; two are deletions;+-- one is the reversal of the best block; and one sends an earlier+-- transaction again. The clock moves on with some of them, so that mempool+-- transactions share a time now and then.+plan :: DatabaseReader -> History -> Word16 -> IO (History, Maybe Op)+plan db hist0 seed = (hist,) <$> chosen+ where+ hist = hist0 {clock = hist0.clock + fromIntegral (seed `mod` 2)}+ kind = seed `mod` 16+ s = fromIntegral seed `div` 16 :: Int+ chosen+ | kind <= 5 = pay+ | kind == 6 = refused+ | kind == 7 = rival (\tx -> return (Just (Pay tx hist.clock)))+ | kind <= 9 = block . shuffled =<< some+ | kind == 10 = rival (\tx -> Just <$> blockOn db [tx]) >>= maybe (block []) (return . Just)+ | kind == 11 = unseen+ | kind == 12 = delete+ | kind == 13 = deleteOther+ | kind == 14 = revert+ | otherwise = again+ rotate k xs = let k' = k `mod` max 1 (length xs) in drop k' xs <> take k' xs+ target j = hist.scripts !! ((s `div` 11 + j) `mod` length hist.scripts)+ spending us values =+ let outputs = [TxOut v (target j) | (j, v) <- zip [0 ..] values]+ in Tx 2 [TxIn u.outpoint "" signal | u <- us] outputs [] 0+ -- some signal that they can be replaced+ signal = if even (s `div` 13) then maxBound else 0+ block txs = Just <$> blockOn db txs+ shuffled = sortOn (hashWithSalt s . encode . txHash)+ pool = map snd <$> Q.getMempool db+ -- outputs that nothing spends, in a block or in the mempool+ unspent = do+ listed <- concat <$> mapM (\a -> Q.getAddressUnspents db a def) (addressesOf db.net db.ctx hist)+ (listed <>) . catMaybes <$> mapM (Q.getUnspent db) hist.loose+ -- a payment from some of them, in equal shares: now and then in ten of+ -- them, for a transaction with many outputs to spend+ payment us =+ let total = sum (map (.value) us)+ count = if s `mod` 13 == 5 && total >= 50000 then 10 else 1 + (s `div` 7) `mod` 3+ share = (total - 500) `div` fromIntegral count+ in if null us || total < 500 + 600 * fromIntegral count+ then Nothing+ else Just (spending us (replicate count share))+ pay = do+ us <- take (1 + s `mod` 3) . rotate (s `div` 3) <$> unspent+ return ((`Pay` hist.clock) <$> payment us)+ -- part of the mempool, in an order in which it can be mined+ some = do+ tds <- catMaybes <$> (pool >>= Q.getManyTxData db)+ let ordered = map snd (sortTxs (map (.tx) tds))+ return (take (s `mod` (length ordered + 1)) ordered)+ -- a block with part of the mempool and a payment that the mempool has+ -- not seen, from outputs of blocks+ unseen = do+ us <- take (1 + s `mod` 2) . rotate (s `div` 3) . filter (confirmed . (.block)) <$> unspent+ txs <- some+ block (shuffled (txs <> maybe [] pure (payment us)))+ -- a transaction that spends what a mempool transaction spends, when+ -- that is an output of a block+ rival :: (Tx -> IO (Maybe Op)) -> IO (Maybe Op)+ rival make =+ rotate s <$> pool >>= \case+ [] -> return Nothing+ h : _ ->+ Q.getTxData db h >>= \case+ Just td+ | i : _ <- td.tx.inputs,+ Just p <- IntMap.lookup 0 td.prevs,+ p.value > 2000 ->+ Q.getTxData db i.outpoint.hash >>= \case+ Just parent+ | confirmed parent.block ->+ let value = p.value - 700 - fromIntegral (s `mod` 50)+ in make (Tx 2 [TxIn i.outpoint "" maxBound] [TxOut value (target 0)] [] 0)+ _ -> return Nothing+ _ -> return Nothing+ -- a transaction or a block that the rules must refuse+ refused = do+ us <- rotate (s `div` 8) <$> unspent+ let spent = [i.outpoint | (b, _) <- hist.chain, mined <- drop 1 b.txs, i <- take 1 mined.inputs]+ unknown = Tx 2 [TxIn (OutPoint (TxHash (sha256 (encode seed))) 0) "" maxBound] [TxOut 1000 script] [] 0+ again' p = Tx 2 [TxIn p "" maxBound] [TxOut 1000 script] [] 0+ send tx = return (Just (Pay tx hist.clock))+ case (s `mod` 8, us, rotate s spent) of+ -- an output that a confirmed transaction has spent+ (1, _, p : _) -> send (again' p)+ -- the same output twice+ (2, u : _, _) -> send (Tx 2 (replicate 2 (TxIn u.outpoint "" maxBound)) [TxOut 1000 script] [] 0)+ -- more than it has+ (3, u : _, _) -> send (Tx 2 [TxIn u.outpoint "" maxBound] [TxOut (u.value + 1) script] [] 0)+ -- a coinbase+ (4, _, _) -> send (Tx 1 [TxIn nullOutPoint (encode seed) maxBound] [TxOut 1000 script] [] 0)+ -- a block that spends an output that does not exist+ (5, _, _) -> block [unknown]+ -- a block that spends an output that a confirmed transaction has+ -- spent+ (6, _, p : _) -> block [again' p]+ -- a block that does not build on the best block, but beside it+ (7, _, _) | (_, best) : _ <- hist.chain -> do+ parent <- Q.getBlock db best.header.prev >>= maybe (fail "no data for the parent") return+ return . Just $ uncurry Mine (nextBlock db.net parent.height parent.header [])+ -- an output that does not exist+ _ -> send unknown+ delete =+ fmap Delete . take 1 . rotate s <$> pool >>= \case+ [] -> return Nothing+ d : _ -> return (Just d)+ -- the deletion of a confirmed transaction, or of one that is not there+ deleteOther =+ let mined = [txHash tx | (b, _) <- hist.chain, tx <- b.txs]+ in return . Just . Delete $ case rotate s mined of+ h : _ | even s -> h+ _ -> TxHash (sha256 (encode seed))+ revert = return $ case hist.chain of+ (b, _) : _ : _ -> Just (Revert (headerHash b.header))+ _ -> Nothing+ -- an earlier transaction, which may be confirmed, in the mempool,+ -- deleted, or missing what it spends+ again = return $ case rotate s hist.made of+ tx : _ -> Just (Pay tx hist.clock)+ [] -> Nothing++-------------------------------------------------------------------------------+-- Answers+-------------------------------------------------------------------------------++-- | Run an action with the queries of a database: those of today's layout,+-- where one set of indices has the chain and the mempool mixed, or those of+-- the layered layout, on a view of the database as it is now.+mixed, layered, pooled :: DatabaseReader -> (StoreRead -> IO a) -> IO a+mixed db action = action (Q.databaseRead db)+layered db action = L.withView db (action . L.layeredRead)+-- the queries of a fleet, with the store's own mempool area as the pool+pooled db action = L.withView db $ \v ->+ let r = L.viewReader v in action (P.pooledRead r (P.localPool r))++-- | Everything that the API could say about a history, in whole lists.+data Answers = Answers+ { balances :: ![(Address, Balance)],+ addressTxs :: ![(Address, [TxRef])],+ addressUnspents :: ![(Address, [Unspent])],+ txs :: ![(TxHash, Maybe TxData)],+ numbered :: ![(TxHash, [TxData])],+ spenders :: ![(OutPoint, Maybe Spender)],+ unspents :: ![(OutPoint, Maybe Unspent)],+ mempool :: ![(UnixTime, TxHash)]+ }+ deriving (Eq, Show)++-- | Ask everything about the addresses of a history and about every+-- transaction made, by its hash and by its short number, with each of its+-- outputs and one that it does not have. An address that was never used and+-- one whose balance is zero are the same to the API, and are the same here.+ask :: History -> StoreRead -> IO Answers+ask hist queries = do+ balances <- mapM (\a -> (a,) . fromMaybe (zeroBalance a) <$> queries.getBalance a) as+ addressTxs <- mapM (\a -> (a,) <$> queries.getAddressTxs a def) as+ addressUnspents <- mapM (\a -> (a,) <$> queries.getAddressUnspents a def) as+ txs <- mapM (\h -> (h,) <$> queries.getTxData h) hashes+ numbered <- mapM (\h -> (h,) <$> queries.getNumTxData (shortNumber h)) hashes+ spenders <- mapM (\p -> (p,) <$> queries.getSpender p) outpoints+ unspents <- mapM (\p -> (p,) <$> queries.getUnspent p) outpoints+ mempool <- queries.getMempool+ return Answers {balances, addressTxs, addressUnspents, txs, numbered, spenders, unspents, mempool}+ where+ as = addressesOf queries.net queries.ctx hist+ hashes = map txHash hist.made+ outpoints = [OutPoint (txHash tx) i | tx <- hist.made, i <- [0 .. fromIntegral (length tx.outputs)]]++-------------------------------------------------------------------------------+-- Pages+-------------------------------------------------------------------------------++-- | A question whose answer is a page of a list, or is read in a way that+-- the questions of 'ask' do not use: many at a time, by number, or through+-- an extended public key.+data Question+ = -- | a page of the transactions of an address+ AddressTxs !Address !Limits+ | -- | a page of the transactions of some addresses+ AddressesTxs ![Address] !Limits+ | -- | a page of the unspent outputs of an address+ AddressUnspents !Address !Limits+ | -- | a page of the unspent outputs of some addresses+ AddressesUnspents ![Address] !Limits+ | -- | the balances of some addresses+ Balances ![Address]+ | -- | the data of some transactions+ Bodies ![TxHash]+ | -- | the transactions with a short number of the Blockchain.info API+ Numbered !Word64+ | -- | a page of the mempool+ Pool !Limits+ | -- | the balances of the addresses of the 'wallet'+ WalletBalances+ | -- | a page of the transactions of the 'wallet'+ WalletTxs !Limits+ | -- | a page of the unspent outputs of the 'wallet'+ WalletUnspents !Limits+ | -- | the number of transactions of the 'wallet'+ WalletCount+ | -- | a page of the transactions of some addresses, read as those of an+ -- extended public key are+ SetTxs ![Address] !Limits+ | -- | a page of the unspent outputs of some addresses, read as those of+ -- an extended public key are+ SetUnspents ![Address] !Limits+ | -- | the number of distinct transactions of some addresses+ SetCount ![Address]+ deriving (Eq, Show)++-- | The answer to a 'Question'.+data Reply+ = Refs ![TxRef]+ | Outputs ![Unspent]+ | Amounts ![Balance]+ | Data ![Maybe TxData]+ | Matches ![TxData]+ | Listed ![(UnixTime, TxHash)]+ | Paths ![XPubBal]+ | PathOutputs ![XPubUnspent]+ | Number !Word32+ deriving (Eq, Show)++-- | Ask a question.+answer :: StoreRead -> Question -> IO Reply+answer queries = \case+ AddressTxs a limits -> Refs <$> queries.getAddressTxs a limits+ AddressesTxs as limits -> Refs <$> queries.getAddressesTxs as limits+ AddressUnspents a limits -> Outputs <$> queries.getAddressUnspents a limits+ AddressesUnspents as limits -> Outputs <$> queries.getAddressesUnspents as limits+ Balances as -> Amounts <$> queries.getBalances as+ Bodies hs -> Data <$> queries.getManyTxData hs+ Numbered i -> Matches <$> queries.getNumTxData i+ Pool limits -> Listed <$> queries.getMempoolPage limits+ WalletBalances -> Paths <$> queries.xPubBals spec+ WalletTxs limits -> Refs <$> (queries.xPubBals spec >>= \bals -> queries.xPubTxs spec bals limits)+ WalletUnspents limits -> PathOutputs <$> (queries.xPubBals spec >>= \bals -> queries.xPubUnspents spec bals limits)+ WalletCount -> Number <$> (queries.xPubBals spec >>= queries.xPubTxCount spec)+ SetTxs as limits -> Refs <$> (set as >>= \bals -> queries.xPubTxs spec bals limits)+ SetUnspents as limits -> PathOutputs <$> (set as >>= \bals -> queries.xPubUnspents spec bals limits)+ SetCount as -> Number <$> (set as >>= queries.xPubTxCount spec)+ where+ spec = wallet queries.ctx+ -- the queries about an extended public key take the balances of its+ -- addresses, so any addresses can be given to them+ set as = zipWith (\i b -> XPubBal {path = [0, i], balance = b}) [0 ..] <$> queries.getBalances as++-- | Whether the page of a question starts somewhere that both layouts+-- know, given a database of version 20: not at a transaction that left+-- the mempool unconfirmed. Version 20 keeps the bodies of those, and+-- starts a page where such a transaction was; the layered layout keeps+-- nothing of them, and its page is empty.+startsKnown :: DatabaseReader -> Question -> IO Bool+startsKnown db question = case startOf question of+ Just (Just (AtTx h)) -> maybe True (not . (.deleted)) <$> Q.getTxRecord db h+ _ -> return True++-- | The questions that two databases answer differently, with the answers+-- of both.+differences :: [Question] -> [Reply] -> [Reply] -> [(Question, Reply, Reply)]+differences asked was now = [(q, a, b) | (q, a, b) <- zip3 asked was now, a /= b]++-- | How much there is in an answer.+size :: Reply -> Int+size = \case+ Refs xs -> length xs+ Outputs xs -> length xs+ Amounts xs -> length xs+ Data xs -> length (catMaybes xs)+ Matches xs -> length xs+ Listed xs -> length xs+ Paths xs -> length xs+ PathOutputs xs -> length xs+ Number n -> fromIntegral n++-- | Where the page of a question starts, if it has a page.+startOf :: Question -> Maybe (Maybe Start)+startOf = \case+ AddressTxs _ limits -> Just limits.start+ AddressesTxs _ limits -> Just limits.start+ AddressUnspents _ limits -> Just limits.start+ AddressesUnspents _ limits -> Just limits.start+ WalletTxs limits -> Just limits.start+ WalletUnspents limits -> Just limits.start+ SetTxs _ limits -> Just limits.start+ SetUnspents _ limits -> Just limits.start+ _ -> Nothing++-- | Questions about a history: pages with small limits and offsets, from the+-- top, from any transaction ever made, whatever became of it, from one that+-- was never made, and from heights up to above the best block; about the+-- addresses of the history, one at a time and several together, with one+-- that was never used and with repetitions; and about the 'wallet'.+questions :: Network -> Ctx -> History -> Int -> Gen [Question]+questions net ctx hist n = vectorOf n question+ where+ question =+ frequency+ [ (4, AddressTxs <$> address <*> limits),+ (4, AddressesTxs <$> several address <*> limits),+ (4, AddressUnspents <$> address <*> limits),+ (4, AddressesUnspents <$> several address <*> limits),+ (1, Balances <$> several address),+ (1, Bodies <$> several hash),+ (1, Numbered . shortNumber <$> hash),+ (1, Pool <$> limits),+ (1, pure WalletBalances),+ (3, WalletTxs <$> limits),+ (3, WalletUnspents <$> limits),+ (1, pure WalletCount),+ (3, SetTxs <$> several address <*> limits),+ (3, SetUnspents <$> several address <*> limits),+ (1, SetCount <$> several address)+ ]+ address = frequency [(12, elements (addressesOf net ctx hist)), (1, elements unused)]+ unused = rights [scriptToAddressBS net ctx (p2pkh 9)]+ hash = frequency [(12, elements (map txHash hist.made)), (1, pure (TxHash (sha256 ("never made" :: ByteString))))]+ several :: Gen a -> Gen [a]+ several one = frequency [(1, pure 0), (12, choose (1, 6))] >>= (`vectorOf` one)+ limits =+ Limits+ <$> frequency [(1, pure 0), (5, choose (1, 6))]+ <*> frequency [(3, pure 0), (2, choose (1, 3))]+ <*> frequency+ [ (3, pure Nothing),+ (6, Just . AtTx <$> hash),+ (2, Just . AtBlock <$> choose (0, best + 2))+ ]+ best :: BlockHeight+ best = case hist.chain of+ (_, node) : _ -> node.height+ [] -> 0++-- | The short number of a transaction in the Blockchain.info API: the first+-- 53 bits of its hash.+shortNumber :: TxHash -> Word64+shortNumber h = either error (`shiftR` 11) (runGet getWord64be (encode h))++-------------------------------------------------------------------------------+-- The chain's column families+-------------------------------------------------------------------------------++-- | What the chain's column families hold, but for what version 20 left+-- there that does not come from the chain: bodies marked as deleted, and+-- balances at zero. The time a transaction was first seen is apart from its+-- body, because a database that never saw the mempool has another.+data Chain = Chain+ { bodies :: ![(ByteString, (BlockRef, Tx, IntMap Prev, Bool))],+ firstSeen :: ![(ByteString, UnixTime)],+ addressTxs :: ![(ByteString, ByteString)],+ addressOutputs :: ![(ByteString, ByteString)],+ unspents :: ![(ByteString, ByteString)],+ balances :: ![(ByteString, BalVal)],+ -- | the inputs of blocks that spend outputs, each with the output that+ -- it spends, whether the database has it under a key of version 22 or+ -- in the record of the transaction that made the output, as the+ -- versions before did: where they are is not what this compares+ spenders :: ![(ByteString, ByteString)]+ }+ deriving (Eq, Show)++chainOf :: DatabaseReader -> IO Chain+chainOf db = do+ txs <- dump db (txCF db.db) >>= mapM (\(k, v) -> (k,) <$> decoded v)+ addressTxs <- dump db (addrTxCF db.db)+ addressOutputs <- dump db (addrOutCF db.db)+ unspents <- dump db (unspentCF db.db)+ balances <- dump db (balanceCF db.db) >>= mapM (\(k, v) -> (k,) <$> decoded v)+ spenders <- dump db (spenderCF db.db)+ let live = [(k, td :: TxData) | (k, td) <- txs, not td.deleted]+ return+ Chain+ { bodies = [(k, (td.block, td.tx, td.prevs, td.rbf)) | (k, td) <- live],+ firstSeen = [(k, td.timestamp) | (k, td) <- live],+ addressTxs,+ addressOutputs,+ unspents,+ balances = filter ((/= def) . snd) balances,+ spenders =+ sort $+ [ (encode (OutPoint h (fromIntegral i)), encode spender)+ | (k, v) <- spenders,+ Right (SpenderKey h _) <- [decode k],+ Right (SpenderVal m) <- [decode v],+ (i, spender) <- IntMap.toList m+ ]+ <> [ (encode (OutPoint (txHash td.tx) (fromIntegral i)), encode spender)+ | (_, td) <- live,+ (i, spender) <- IntMap.toList td.spenders+ ]+ }+ where+ decoded v = either fail return (decode v)++-- | Every key and value of a column family.+dump :: DatabaseReader -> ColumnFamily -> IO [(ByteString, ByteString)]+dump db cf = withIterCF db.db cf $ \iter -> iterFirst iter >> go iter+ where+ go iter =+ iterEntry iter >>= \case+ Nothing -> return []+ Just e -> iterNext iter >> (e :) <$> go iter
@@ -0,0 +1,1958 @@+-- |+-- Module : Haskoin.Store.IntegrationSpec+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- Tests of the whole store against regtest chains of Bitcoin Core and Bitcoin+-- Cash Node, with a Redis cache, all in Podman containers.+--+-- The unit tests check pieces against models; these check that the pieces+-- fit: that the store syncs from a real node, that the xpub endpoints find+-- the payments a wallet following the gap would find and no others, and that+-- the Redis cache answers exactly like the database. Each chain gets one node,+-- one Redis and one store. The store is served twice, by a web server with+-- the cache and by one without ('cache' set to 'Nothing'), so both read the+-- same database, mempool times included, and every difference between their+-- answers is a difference made by the cache.+--+-- Every test makes its own xpubs from a fixed seed, sends transactions to+-- chosen derivation paths, mines them, and checks the answers against a+-- ledger that it keeps itself: balance, transactions, unspent outputs, used+-- paths and indices. Then it compares the two web servers on every xpub and+-- Blockchain.info endpoint, with pages, starting points and filters. The cache+-- writer updates Redis in the background, so the comparison is retried for up+-- to 30 seconds before it fails.+--+-- The tests are skipped when Podman cannot run containers, or when+-- @HASKOIN_STORE_NO_PODMAN@ is set. Containers are removed when the tests+-- end, data is kept in tmpfs mounts and in a temporary directory under+-- @\/dev\/shm@ (or @$XDG_RUNTIME_DIR@), and every container has a memory+-- limit. Set @HASKOIN_TEST_LOG@ to see the log of the store.+module Haskoin.Store.IntegrationSpec (spec) where++import Control.Lens ((&), (.~), (?~), (^.))+import Control.Monad (forM_, forever, unless, void, when)+import Data.Aeson (Value (..), eitherDecode, object, toJSON, (.=))+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KeyMap+import Data.ByteString.Char8 qualified as C8+import Data.ByteString.Lazy qualified as BL+import Data.Bytes.Put (runPutS)+import Data.Bytes.Serial (serialize)+import Data.Default (def)+import Data.Either (isRight)+import Data.Foldable (toList)+import Data.HashSet qualified as HashSet+import Data.IORef (IORef, modifyIORef', newIORef, readIORef)+import Data.IntMap.Strict qualified as IntMap+import Data.List (nub, sort, sortOn)+import Data.List.NonEmpty qualified as NonEmpty+import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe, isJust, mapMaybe)+import Data.Ord (Down (..))+import Data.Serialize (decode, encode)+import Data.String.Conversions (cs)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Word (Word32, Word64)+import Database.Redis qualified as Redis+import Haskoin+ ( Address (..),+ BloomFlags (..),+ Ctx,+ Network,+ OutPoint (..),+ TxHash (..),+ XPrvKey,+ addrToText,+ addressToScriptBS,+ blockHashToHex,+ bloomCreate,+ bloomInsert,+ deriveXPubKey,+ encodeHex,+ hexToBlockHash,+ hexToTxHash,+ makeXPrvKey,+ marshal,+ marshalJSON,+ prepareContext,+ prvSubKey,+ pubSubKey,+ sha256,+ txHashToHex,+ xPrvWif,+ xPubAddr,+ xPubCompatWitnessAddr,+ xPubExport,+ xPubWitnessAddr,+ )+import Haskoin.Node (LogLevel (..), Logger (..), withConnection, withLogger)+import Haskoin.Store+ ( DeriveType (..),+ Store (..),+ StoreConfig (..),+ WebConfig (..),+ XPubSpec (..),+ runWebSocket,+ withStore,+ )+import Haskoin.Store.Cache+ ( Cache,+ CacheCounters (..),+ Reload (..),+ cacheCounters,+ cacheEvict,+ cacheIsCached,+ cachePrefix,+ cacheRefresh,+ cacheTxData,+ cacheXPubBals,+ xpubId,+ )+import Haskoin.Store.Common (StoreEvent (..))+import Haskoin.Store.Data+ ( Balance (..),+ BlockRef (..),+ Event (..),+ EventCommand (..),+ Except (..),+ Spender (..),+ TxData (..),+ XPubBal (..),+ )+import Haskoin.Store.Database.Query qualified as Q+import Haskoin.Store.Database.Query.Layered qualified as L+import Haskoin.Store.Fleet.Member (memberTip)+import Haskoin.Store.Integration.Image (testImage)+import Haskoin.Store.Integration.Node+import Haskoin.Store.Integration.Podman+import Haskoin.Store.Integration.Valkey+import NQE (receive, withSubscription)+import Network.Socket qualified as Socket+import Network.WebSockets qualified as WS+import Network.Wreq qualified as Wreq+import Network.Wreq.Types (FormParam ((:=)))+import System.Environment (lookupEnv)+import System.Exit (ExitCode (..))+import System.Process (readProcessWithExitCode)+import Test.Hspec+import UnliftIO (async, bracket, cancel, link, throwIO, try, tryAny, withAsync, withSystemTempDirectory)+import UnliftIO.Concurrent (threadDelay)++spec :: Spec+spec = do+ usable <- runIO podmanUsable+ describe "Regtest chains in Podman" $ case usable of+ Left why ->+ it "check the store against Bitcoin Core and Bitcoin Cash Node" $+ pendingWith why+ Right () -> prepareContext $ \ctx -> do+ aroundAll (withEnv BitcoinCore ctx) $+ describe "Bitcoin Core" (chainSpec BitcoinCore)+ aroundAll (withEnv BitcoinCashNode ctx) $+ describe "Bitcoin Cash Node" (chainSpec BitcoinCashNode)++chainSpec :: NodeKind -> SpecWith Env+chainSpec kind = do+ it "sees payments up to exactly the gap, and those the gap reaches later" gapTest+ it "does not see the payment 34 addresses past the last used one" noteTest+ it "follows spends and change, confirmed and in the mempool" spendTest+ it "pages mempool transactions of the same second without duplicates" tiesTest+ it "counts every transaction of a multiaddr wallet" multiaddrTest+ it "answers the address, block and transaction endpoints like the node" endpointsTest+ when (kind == BitcoinCore) $ do+ it "derives native and nested SegWit addresses" segwitTest+ it "refuses the wrong kind of address for an xpub" deriveTest+ it "refuses unknown parameters and values it cannot read" strictTest+ it "does not use or fill the cache when asked not to" noCacheTest+ it "recovers from damaged entries and keeps newer data" damagedTest+ it "updates cached xpubs from what changed, and reads them whole when unsure" incrementalTest+ it "catches up with changes that the database has before their events" catchUpTest+ it "keeps the spenders of cached transactions up to date" spenderTest+ it "evicts the least recently used xpubs" evictTest+ it "answers an error in the format that was asked for" errorFormatTest+ it "speaks HTTP as browsers, proxies and long requests need it" httpTest+ it "serves HTTPS, and nothing else, when given a certificate" tlsTest+ it "tells each client of the transactions it chose, and takes up where it left" eventsTest+ it "drops a transaction double-spent in a reorganisation" reorgTest+ it "applies each change once with two stores sharing Redis" onceTest+ it "does not replace what it does not know" unknownTest+ it "leaves the keys of older caches alone" oldKeysTest+ it "answers from its database, and says so, while Redis does not answer" redisDownTest+ it "gives the same answers from every store of a fleet, from one block and one mempool" fleetTest++-------------------------------------------------------------------------------+-- Environment+-------------------------------------------------------------------------------++-- | A node, a store syncing from it with a Redis cache, and the two web+-- servers of the store.+data Env = Env+ { kind :: !NodeKind,+ node :: !Node,+ net :: !Network,+ ctx :: !Ctx,+ store :: !Store,+ -- | base URL of the web server with the cache+ cached :: !String,+ -- | base URL of the web server without the cache+ plain :: !String,+ -- | address of the wallet of the node, to mine to+ miner :: !Text,+ -- | the cache of the store+ redisCache :: !Cache,+ -- | connection to Redis, to look at the keys+ redis :: !Redis.Connection,+ -- | configuration of a store like this one with its database in a+ -- directory, to start another one+ configIn :: FilePath -> StoreConfig,+ -- | the same for a store that is a member of a fleet+ memberIn :: FilePath -> StoreConfig+ }++-- | Initial gap and gap of the store under test, the defaults.+initGap, gap :: Word32+initGap = 20+gap = 32++withEnv :: NodeKind -> Ctx -> (Env -> IO ()) -> IO ()+withEnv kind ctx go = do+ image <- testImage+ withContainer (redisContainer image) $ \redis -> do+ redisPort <- hostPort redis 6379+ let redisURL = "redis://127.0.0.1:" <> show redisPort+ waitRedis redisURL+ writeOldKeys redisURL+ withContainer (nodeContainer kind image) $ \c -> do+ rpcPort <- hostPort c 18443+ p2pPort <- hostPort c 18444+ let node =+ Node+ { kind = kind,+ url = "http://127.0.0.1:" <> show rpcPort <> "/",+ p2p = p2pPort+ }+ waitRpc node+ ensureWallet node+ miner <- getNewAddress node+ -- enough mature coinbases for the wallet to fund every test without+ -- long chains of unconfirmed change+ void $ generateTo node 150 miner+ withRamDir $ \dir -> withTestLogger $ \logger -> do+ let net = nodeNetwork kind+ cfg = storeConfig net ctx logger dir redisURL p2pPort False+ withStore cfg $ \store -> do+ listening $ \(cachedSock, cachedPort) -> listening $ \(plainSock, plainPort) -> do+ let web s = runWebSocket (webConfig logger s)+ conn <- Redis.checkedConnect (either error id (Redis.parseConnectInfo redisURL))+ cache <- maybe (fail "The store has no cache") return store.cache+ withAsync (web store cachedSock) $ \a1 ->+ withAsync (web store {cache = Nothing} plainSock) $ \a2 -> do+ link a1+ link a2+ let env =+ Env+ { kind = kind,+ node = node,+ net = net,+ ctx = ctx,+ store = store,+ cached = "http://127.0.0.1:" <> show cachedPort,+ plain = "http://127.0.0.1:" <> show plainPort,+ miner = miner,+ redisCache = cache,+ redis = conn,+ configIn = \d -> storeConfig net ctx logger d redisURL p2pPort False,+ memberIn = \d -> storeConfig net ctx logger d redisURL p2pPort True+ }+ waitSynced env+ go env++-- | A logger that prints the log of the store if @HASKOIN_TEST_LOG@ is set,+-- and discards it otherwise.+withTestLogger :: (Logger -> IO a) -> IO a+withTestLogger f =+ lookupEnv "HASKOIN_TEST_LOG" >>= \case+ Just v | v `notElem` ["", "0"] -> withLogger LevelDebug f+ _ -> f Logger {level = LevelError, source = "", push = \_ -> return ()}++-- | Store with the default gaps, caching every used xpub, with small RocksDB+-- buffers and caches, as a member of a fleet or not.+storeConfig :: Network -> Ctx -> Logger -> FilePath -> String -> Int -> Bool -> StoreConfig+storeConfig net ctx logger dir redisURL p2pPort fleet =+ StoreConfig+ { maxPeers = 1,+ initPeers = ["127.0.0.1:" <> show p2pPort],+ discover = False,+ db = dir,+ net = net,+ redis = Just redisURL,+ ctx = ctx,+ initGap = initGap,+ gap = gap,+ redisMinAddrs = 1,+ redisMinTxs = 1000,+ redisMaxXPubs = 10000,+ noMempool = False,+ wipeMempool = False,+ fleet = fleet,+ syncMempool = True,+ blockWindow = 64,+ mempoolTimeout = 14,+ peerTimeout = 120,+ maxPeerLife = 48 * 3600,+ connect = withConnection,+ stats = Nothing,+ bloom = True,+ maxOpenFiles = Just 256,+ writeBufferSize = Just (16 * 1024 * 1024),+ maxWalSize = Just (64 * 1024 * 1024),+ blockCache = Just (16 * 1024 * 1024),+ logger = logger+ }++webConfig :: Logger -> Store -> WebConfig+webConfig logger store =+ WebConfig+ { host = "127.0.0.1",+ port = 0,+ store = store,+ maxLaggingBlocks = 100,+ maxPendingTxs = 10000,+ minPeers = 0,+ limits = def,+ version = "test",+ noMempool = False,+ stats = Nothing,+ tickerRefresh = 90 * 1000 * 1000,+ tickerURL = "http://127.0.0.1:9/",+ priceHistoryURL = "http://127.0.0.1:9/",+ noXPub = False,+ noBlockchainInfo = False,+ healthCheckInterval = 5,+ tls = Nothing,+ logger = logger+ }++-- | A socket that listens on a port of the loopback interface that the+-- system chose, with that port. Nothing else can take the port between+-- choosing it and serving on it.+listening :: ((Socket.Socket, Int) -> IO a) -> IO a+listening f =+ bracket (Socket.socket Socket.AF_INET Socket.Stream Socket.defaultProtocol) Socket.close $ \s -> do+ Socket.bind s (Socket.SockAddrInet 0 (Socket.tupleToHostAddress (127, 0, 0, 1)))+ Socket.listen s 128+ port <- Socket.socketPort s+ f (s, fromIntegral port)++-------------------------------------------------------------------------------+-- Talking to the store+-------------------------------------------------------------------------------++-- | Status and JSON body of a request. A body that is not JSON, as the+-- plain-text Blockchain.info queries give, is returned as a string.+getJSON :: String -> String -> IO (Int, Value)+getJSON base path = do+ r <- Wreq.getWith opts (base <> path)+ return (r ^. Wreq.responseStatus . Wreq.statusCode, body (r ^. Wreq.responseBody))+ where+ opts = Wreq.defaults & Wreq.checkResponse ?~ (\_ _ -> return ())++-- | Like 'getJSON', posting a form.+postForm :: String -> String -> [(C8.ByteString, Text)] -> IO (Int, Value)+postForm base path params = do+ r <- Wreq.postWith opts (base <> path) [k := v | (k, v) <- params]+ return (r ^. Wreq.responseStatus . Wreq.statusCode, body (r ^. Wreq.responseBody))+ where+ opts = Wreq.defaults & Wreq.checkResponse ?~ (\_ _ -> return ())++body :: BL.ByteString -> Value+body b = either (const (String (cs b))) id (eitherDecode b)++-- | Body of a successful request to the server without the cache.+plainJSON :: Env -> String -> IO Value+plainJSON env path = do+ (code, v) <- getJSON env.plain path+ unless (code == 200) $+ expectationFailure ("GET " <> path <> " answered " <> show code <> ": " <> show v)+ return v++-- | Check that both web servers answer every request in the same way, and+-- successfully. The cache is updated in the background, so try for up to 30+-- seconds.+sameAnswers :: Env -> [String] -> IO ()+sameAnswers env paths = forM_ paths $ \p -> go p (120 :: Int)+ where+ go p n = do+ a <- getJSON env.cached p+ b <- getJSON env.plain p+ if a == b && fst a == 200+ then return ()+ else+ if n <= 0+ then+ expectationFailure $+ "GET "+ <> p+ <> "\nstatus with and without the cache: "+ <> show (fst a, fst b)+ <> "\nfirst difference: "+ <> difference "" (snd a) (snd b)+ else threadDelay 250000 >> go p (n - 1)++-- | Where two JSON values first differ, with the two values there.+difference :: String -> Value -> Value -> String+difference at' (Object x) (Object y)+ | KeyMap.keys x /= KeyMap.keys y = at' <> ": keys " <> show (KeyMap.keys x) <> " and " <> show (KeyMap.keys y)+ | otherwise =+ case [ difference (at' <> "." <> show k) v w+ | (k, v) <- KeyMap.toList x,+ Just w <- [KeyMap.lookup k y],+ v /= w+ ] of+ d : _ -> d+ [] -> at' <> ": none"+difference at' (Array x) (Array y)+ | length x /= length y = at' <> ": " <> show (length x) <> " and " <> show (length y) <> " elements"+ | otherwise =+ case [ difference (at' <> "[" <> show i <> "]") v w+ | (i, v, w) <- zip3 [0 :: Int ..] (toList x) (toList y),+ v /= w+ ] of+ d : _ -> d+ [] -> at' <> ": none"+difference at' x y = at' <> ": " <> take 1000 (show x) <> " and " <> take 1000 (show y)++-- | Wait until the store has the best block of the node and every+-- transaction of its mempool, for up to 60 seconds.+waitSynced :: Env -> IO ()+waitSynced env = go (240 :: Int)+ where+ go n = do+ best <- bestBlockHash env.node+ pool <- rawMempool env.node+ r <- tryAny $ do+ (_, b) <- getJSON env.plain "/block/best?notx=true"+ (_, m) <- getJSON env.plain "/mempool"+ let ours = [t | String t <- arr m]+ return (textField "hash" b == Just best && all (`elem` ours) pool)+ case r of+ Right True -> return ()+ _+ | n <= 0 -> do+ (_, b) <- getJSON env.plain "/block/best?notx=true"+ (_, m) <- getJSON env.plain "/mempool"+ let ours = [t | String t <- arr m]+ expectationFailure $+ "The store did not sync to block "+ <> cs best+ <> "; its best block is "+ <> show (textField "hash" b)+ <> " and it lacks these transactions of the mempool of the node: "+ <> show (filter (`notElem` ours) pool)+ | otherwise -> threadDelay 250000 >> go (n - 1)++-- | Mine blocks and wait for the store to have them.+mine :: Env -> Int -> IO [Text]+mine env n = do+ hs <- generateTo env.node n env.miner+ waitSynced env+ return hs++-------------------------------------------------------------------------------+-- Test wallets+-------------------------------------------------------------------------------++-- | An xpub of a test, with the key to spend from it.+data XPub = XPub+ { prv :: !XPrvKey,+ spec :: !XPubSpec,+ -- | serialized xpub+ text :: !String,+ -- | derivation path of the first addresses of each chain, by output script+ scripts :: !(Map.Map Text [Word32])+ }++-- | Xpub from a seed, different for each chain.+newXPub :: Env -> String -> DeriveType -> XPub+newXPub env seed deriv =+ XPub+ { prv = prv,+ spec = xspec,+ text = cs (xPubExport env.net env.ctx pub),+ scripts =+ Map.fromList+ [ (scriptHex env (addrOf env x [m, i]), [m, i])+ | m <- [0, 1],+ i <- [0 .. 120]+ ]+ }+ where+ prv = makeXPrvKey (C8.pack ("haskoin-store integration " <> show env.kind <> " " <> seed))+ pub = deriveXPubKey env.ctx prv+ xspec = XPubSpec {key = pub, deriv = deriv}+ x = XPub {prv = prv, spec = xspec, text = "", scripts = Map.empty}++-- | Address at a derivation path, computed with haskoin-core, not the store.+addrOf :: Env -> XPub -> [Word32] -> Address+addrOf env x [m, i] = f (pubSubKey env.ctx (pubSubKey env.ctx x.spec.key m) i)+ where+ f = case x.spec.deriv of+ DeriveNormal -> xPubAddr env.ctx+ DeriveP2SH -> xPubCompatWitnessAddr env.ctx+ DeriveP2WPKH -> xPubWitnessAddr env.ctx+addrOf _ _ p = error ("Bad path: " <> show p)++addrText :: Env -> Address -> Text+addrText env a = fromMaybe (error "Cannot encode address") (addrToText env.net a)++-- | Address at a path of an xpub, as text.+at :: Env -> XPub -> [Word32] -> Text+at env x = addrText env . addrOf env x++scriptHex :: Env -> Address -> Text+scriptHex env a =+ encodeHex (fromMaybe (error "No script for address") (addressToScriptBS env.ctx a))++-- | Private key of the address at a path, as WIF.+wifOf :: Env -> XPub -> [Word32] -> Text+wifOf env x [m, i] = xPrvWif env.net (prvSubKey env.ctx (prvSubKey env.ctx x.prv m) i)+wifOf _ _ p = error ("Bad path: " <> show p)++-- | Parameter that selects the derivation of an xpub.+deriveParam :: XPub -> String+deriveParam x = case x.spec.deriv of+ DeriveNormal -> "legacy"+ DeriveP2SH -> "compat"+ DeriveP2WPKH -> "segwit"++-------------------------------------------------------------------------------+-- Ledger: what the test expects the store to know+-------------------------------------------------------------------------------++-- | Output paid to an address of an xpub.+data Coin = Coin+ { txid :: !Text,+ vout :: !Int,+ path :: ![Word32],+ value :: !Word64+ }+ deriving (Eq, Show)++-- | The outputs paid to an xpub, which of them are spent and by what, and+-- which transactions touch which paths.+data Ledger = Ledger+ { coins :: ![Coin],+ spent :: ![((Text, Int), Text)],+ touched :: ![(Text, [Word32])]+ }++newLedger :: IO (IORef Ledger)+newLedger = newIORef (Ledger [] [] [])++-- | Add a transaction to the ledger of an xpub, reading it from the node.+record :: Env -> XPub -> IORef Ledger -> Text -> IO ()+record env x ref txid = do+ tx <- getRawTx env.node txid+ let ins =+ [ (t, fromIntegral (sats' n))+ | i <- arr ((tx ! "vin")),+ Just t <- [textField "txid" i],+ Just n <- [field "vout" i]+ ]+ outs =+ [ (fromIntegral (sats' n), h, sats v)+ | o <- arr ((tx ! "vout")),+ Just n <- [field "n" o],+ Just v <- [field "value" o],+ Just h <- [textField "hex" =<< field "scriptPubKey" o]+ ]+ modifyIORef' ref $ \l ->+ let mine' = [c | c <- l.coins, (c.txid, c.vout) `elem` ins]+ new =+ [ Coin {txid = txid, vout = n, path = p, value = v}+ | (n, h, v) <- outs,+ Just p <- [Map.lookup h x.scripts]+ ]+ in Ledger+ { coins = l.coins <> new,+ spent = l.spent <> [((c.txid, c.vout), txid) | c <- mine'],+ touched =+ l.touched+ <> nub ([(txid, c.path) | c <- mine'] <> [(txid, c.path) | c <- new])+ }+ where+ -- integer JSON numbers+ sats' (Number n) = round n :: Integer+ sats' v = error ("Not a number: " <> show v)++-- | Remove a transaction from a ledger, as if it never happened.+forget :: IORef Ledger -> Text -> IO ()+forget ref txid = modifyIORef' ref $ \l ->+ Ledger+ { coins = [c | c <- l.coins, c.txid /= txid],+ spent = [s | s@(_, t) <- l.spent, t /= txid],+ touched = [t | t@(h, _) <- l.touched, h /= txid]+ }++-- | Unspent output at a path.+coinAt :: Ledger -> [Word32] -> Coin+coinAt l p =+ case [c | c <- l.coins, c.path == p, (c.txid, c.vout) `notElem` map fst l.spent] of+ c : _ -> c+ [] -> error ("No unspent output at " <> show p)++-- | Last used index that a wallet following the gap reaches from the used+-- indices of a chain, starting from those in the first @start@ addresses, or+-- -1 if it reaches none.+reach :: Word32 -> [Int] -> Int+reach start used = go first+ where+ first = maximum ((-1) : filter (< fromIntegral start) used)+ go l = case filter (\u -> u > l && u <= l + fromIntegral gap) used of+ [] -> l+ us -> go (maximum us)++-- | What the store should answer for an xpub.+data Expect = Expect+ { balance :: !Word64,+ txids :: ![Text],+ utxos :: ![(Text, Int)],+ used :: ![[Word32]],+ external :: !Int,+ change :: !Int+ }+ deriving (Eq, Show)++expect :: Ledger -> Expect+expect l =+ Expect+ { balance = sum [c.value | c <- live],+ txids = sort (nub [t | (t, p) <- l.touched, p `elem` visible]),+ utxos = sort [(c.txid, c.vout) | c <- live],+ used = sort visible,+ external = maximum (0 : [fromIntegral i | [0, i] <- visible]),+ change = maximum (0 : [fromIntegral i | [1, i] <- visible])+ }+ where+ paths = nub (map snd l.touched)+ ext = [fromIntegral i | [0, i] <- paths]+ chg = [fromIntegral i | [1, i] <- paths]+ lastExt = reach initGap ext+ lastChg = reach 0 chg+ visible+ | lastExt < 0 = []+ | otherwise =+ [ p+ | p@[m, i] <- paths,+ fromIntegral i <= if m == 0 then lastExt else lastChg+ ]+ live =+ [ c+ | c <- l.coins,+ c.path `elem` visible,+ (c.txid, c.vout) `notElem` map fst l.spent+ ]++-------------------------------------------------------------------------------+-- Checks+-------------------------------------------------------------------------------++-- | Check what the store says about an xpub against the ledger, then that+-- the cache says the same on every endpoint, and that the xpub is cached.+checkXPub :: Env -> XPub -> IORef Ledger -> IO ()+checkXPub env x ref = do+ e <- expect <$> readIORef ref+ let b = "/xpub/" <> x.text+ q = "?derive=" <> deriveParam x+ summary <- plainJSON env (b <> q)+ let bal = (summary ! "balance")+ idx = (summary ! "indices")+ (num "confirmed" bal + num "unconfirmed" bal) `shouldBe` e.balance+ num "utxo" bal `shouldBe` fromIntegral (length e.utxos)+ num "external" idx `shouldBe` fromIntegral e.external+ num "change" idx `shouldBe` fromIntegral e.change+ txs <- arr <$> plainJSON env (b <> "/transactions" <> q)+ let ts = mapMaybe (textField "txid") txs+ ts `shouldBe` nub ts+ sort ts `shouldBe` e.txids+ newestFirst (map blockOf txs)+ us <- arr <$> plainJSON env (b <> "/unspent" <> q)+ sort+ [ (t, fromIntegral (num "index" u'))+ | u <- us,+ Just u' <- [field "unspent" u],+ Just t <- [textField "txid" u']+ ]+ `shouldBe` e.utxos+ bs <- arr <$> plainJSON env (b <> "/balances" <> q)+ sort+ [ p+ | xb <- bs,+ Just bb <- [field "balance" xb],+ num "txs" bb > 0,+ Just (Array p') <- [field "path" xb],+ let p = map (fromIntegral . numV) (toList p')+ ]+ `shouldBe` e.used+ let heights = nub [show h | BlockRef h _ <- map blockOf txs]+ unless (null e.used) $ do+ _ <- getJSON env.cached (b <> "/balances" <> q)+ waitCached env x+ sameAnswers env (xpubPaths x ts heights)+ unless (null e.used) $ do+ -- every body of the xpub is in Redis, and is the body of the database,+ -- spenders included+ let hs = map (\t -> fromMaybe (error "bad txid") (hexToTxHash t)) e.txids+ waitFor "cached bodies" $ do+ fromCache <- cacheTxData env.redisCache hs+ fromDB <- L.withView env.store.db (`L.getManyTxData` hs)+ return (fromCache == fromDB && all isJust fromCache)++-- | Requests about an xpub to compare between the servers. The balances come+-- first, as they are what puts an xpub in the cache.+xpubPaths :: XPub -> [Text] -> [String] -> [String]+xpubPaths x ts heights =+ [ b <> "/balances" <> q,+ b <> q,+ t,+ t <> "&limit=1",+ t <> "&limit=2&offset=1",+ t <> "&limit=3&offset=2",+ b <> "/transactions/full" <> q <> "&limit=4",+ u,+ u <> "&limit=1",+ u <> "&limit=2&offset=1",+ b <> q <> "&nocache=true",+ t <> "&nocache=true",+ u <> "&nocache=true"+ ]+ <> concat+ [ [ t <> "&height=" <> s <> "&limit=2",+ t <> "&height=" <> s <> "&limit=2&offset=1",+ u <> "&height=" <> s <> "&limit=2"+ ]+ | s <- map cs ts <> heights+ ]+ <> binfo+ where+ b = "/xpub/" <> x.text+ q = "?derive=" <> deriveParam x+ t = b <> "/transactions" <> q+ u = b <> "/unspent" <> q+ active = case x.spec.deriv of+ DeriveNormal -> "active="+ DeriveP2SH -> "activeP2SH="+ DeriveP2WPKH -> "activeBech32="+ m = "/blockchain/multiaddr?" <> active <> x.text+ binfo =+ [ m,+ m <> "&n=1",+ m <> "&n=2&offset=1",+ m <> "&filter=2",+ m <> "&filter=5",+ m <> "&filter=7",+ "/blockchain/unspent?" <> active <> x.text,+ "/blockchain/balance?" <> active <> x.text,+ "/blockchain/rawaddr/" <> x.text <> "?derive=" <> deriveParam x,+ "/blockchain/rawaddr/" <> x.text <> "?derive=" <> deriveParam x <> "&limit=2&offset=1",+ "/blockchain/xpub/" <> x.text <> "?derive=" <> deriveParam x+ ]++-- | Block of a transaction or unspent output in a JSON answer.+blockOf :: Value -> BlockRef+blockOf v = case field "block" v of+ Just b+ | Just m <- field "mempool" b -> MemRef (fromIntegral (numV m))+ | Just h <- field "height" b,+ Just p <- field "position" b ->+ BlockRef (fromIntegral (numV h)) (fromIntegral (numV p))+ _ -> error ("No block in " <> show v)++-- | Check that blocks come newest first: the mempool, then by height and+-- position.+newestFirst :: [BlockRef] -> IO ()+newestFirst bs = bs `shouldBe` sortOn Down bs++-- | Field of a JSON object, or null.+(!) :: Value -> Text -> Value+v ! k = fromMaybe Null (field k v)++arr :: Value -> [Value]+arr (Array a) = toList a+arr v = error ("Not an array: " <> take 500 (show v))++numV :: Value -> Integer+numV (Number n) = round n+numV v = error ("Not a number: " <> show v)++num :: Text -> Value -> Word64+num k v = maybe (error ("No " <> cs k <> " in " <> take 500 (show v))) (fromIntegral . numV) (field k v)++-- | Send from the wallet of the node to paths of an xpub, in one+-- transaction, and record it.+pay :: Env -> XPub -> IORef Ledger -> [([Word32], Word64)] -> IO Text+pay env x ref outs = do+ txid <- sendMany env.node [(at env x p, v) | (p, v) <- outs]+ record env x ref txid+ return txid++-- | Spend outputs of an xpub, signing with its keys, and record it.+spend :: Env -> XPub -> IORef Ledger -> [Coin] -> [(Text, Word64)] -> IO Text+spend env x ref cs' outs = do+ let ins = [object ["txid" .= c.txid, "vout" .= c.vout] | c <- cs']+ prevs =+ [ object+ [ "txid" .= c.txid,+ "vout" .= c.vout,+ "scriptPubKey" .= scriptHex env (addrOf env x c.path),+ "amount" .= btc c.value+ ]+ | c <- cs'+ ]+ wifs = nub [wifOf env x c.path | c <- cs']+ txid <- signAndSend env ins outs wifs prevs+ record env x ref txid+ return txid++-- | Create, sign with keys and send a transaction. Returns its hash.+signAndSend :: Env -> [Value] -> [(Text, Word64)] -> [Text] -> [Value] -> IO Text+signAndSend env ins outs wifs prevs = do+ raw <- rpc env.node "createrawtransaction" [toJSON ins, object [Key.fromText a .= btc v | (a, v) <- outs]]+ signed <- rpc env.node "signrawtransactionwithkey" [raw, toJSON wifs, toJSON prevs]+ unless (field "complete" signed == Just (Bool True)) $+ expectationFailure ("Could not sign: " <> show signed)+ hex <- maybe (fail "No hex") return (field "hex" signed)+ rpc env.node "sendrawtransaction" [hex] >>= \case+ String t -> return t+ v -> fail ("Unexpected answer to sendrawtransaction: " <> show v)++-------------------------------------------------------------------------------+-- Tests+-------------------------------------------------------------------------------++-- | Payments at the edge of the gap. Index 37 is 32 past index 5, the last+-- used one before it, so it is seen; index 70 is 33 past index 37, so it is+-- not. Then a payment to index 69, 32 past index 37, makes index 70 be seen+-- too: the cache has to derive and read further on its own to find it.+gapTest :: Env -> IO ()+gapTest env = do+ let x = newXPub env "gap" DeriveNormal+ ref <- newLedger+ void $+ pay env x ref $+ [([0, 0], 100000), ([0, 1], 200000), ([0, 2], 300000), ([0, 5], 400000)]+ <> [([0, 37], 500000), ([0, 70], 600000)]+ void $ mine env 1+ e1 <- expect <$> readIORef ref+ e1.used `shouldBe` [[0, 0], [0, 1], [0, 2], [0, 5], [0, 37]]+ e1.balance `shouldBe` 1500000+ checkXPub env x ref+ void $ pay env x ref [([0, 69], 700000)]+ waitSynced env+ e2 <- expect <$> readIORef ref+ e2.used `shouldBe` [[0, 0], [0, 1], [0, 2], [0, 5], [0, 37], [0, 69], [0, 70]]+ checkXPub env x ref+ void $ mine env 1+ checkXPub env x ref++-- | The wallet of the Blockchain.com comparison: receive indices 0 to 35+-- used, and a payment to index 69, 34 past index 35. With a gap of 32 it is+-- not seen, as Blockchain.com does not see it.+noteTest :: Env -> IO ()+noteTest env = do+ let x = newXPub env "blockchain.com" DeriveNormal+ ref <- newLedger+ void $ pay env x ref $ [([0, i], 10000 + fromIntegral i) | i <- [0 .. 35]] <> [([0, 69], 280348)]+ void $ mine env 1+ e <- expect <$> readIORef ref+ e.external `shouldBe` 35+ e.balance `shouldBe` sum [10000 + i | i <- [0 .. 35]]+ checkXPub env x ref+ ma <- plainJSON env ("/blockchain/multiaddr?active=" <> x.text)+ let w = (ma ! "wallet")+ num "final_balance" w `shouldBe` e.balance+ num "n_tx" w `shouldBe` 1+ case arr ((ma ! "addresses")) of+ [a] -> do+ num "account_index" a `shouldBe` 36+ num "change_index" a `shouldBe` 0+ num "n_tx" a `shouldBe` 1+ as -> expectationFailure ("Expected one xpub, got " <> show as)++-- | Spends from two addresses of an xpub at once, with change, then a spend+-- of the change while it is still in the mempool.+spendTest :: Env -> IO ()+spendTest env = do+ let x = newXPub env "spend" DeriveNormal+ ref <- newLedger+ void $ pay env x ref [([0, 0], 1000000), ([0, 1], 2000000)]+ void $ mine env 1+ checkXPub env x ref+ out1 <- getNewAddress env.node+ l1 <- readIORef ref+ void $+ spend env x ref [coinAt l1 [0, 0], coinAt l1 [0, 1]] $+ [(out1, 1500000), (at env x [1, 0], 1490000)]+ waitSynced env+ checkXPub env x ref+ out2 <- getNewAddress env.node+ l2 <- readIORef ref+ void $ spend env x ref [coinAt l2 [1, 0]] [(out2, 500000), (at env x [1, 1], 980000)]+ waitSynced env+ e <- expect <$> readIORef ref+ e.balance `shouldBe` 980000+ e.change `shouldBe` 1+ checkXPub env x ref+ void $ mine env 1+ checkXPub env x ref++-- | Mempool transactions sent one after the other, most of them in the same+-- second, touching the same addresses several times, one of them paying+-- three addresses of the xpub. The store orders those of the same second by+-- hash; it used to list some twice. Then page through the transactions one+-- at a time, as 'streamThings' does, and check that the pages add up.+tiesTest :: Env -> IO ()+tiesTest env = do+ let x = newXPub env "ties" DeriveNormal+ ref <- newLedger+ void $ pay env x ref [([0, 0], 100000)]+ void $ mine env 1+ void $ pay env x ref [([0, 1], 110000), ([0, 2], 120000), ([0, 3], 130000)]+ void $ pay env x ref [([0, 2], 140000), ([0, 4], 150000)]+ void $ pay env x ref [([0, 1], 160000), ([0, 4], 170000)]+ void $ pay env x ref [([0, 3], 180000)]+ void $ pay env x ref [([0, 0], 190000), ([0, 1], 200000)]+ waitSynced env+ checkXPub env x ref+ forM_ [env.plain, env.cached] (pagesAddUp x)+ void $ mine env 1+ checkXPub env x ref+ forM_ [env.plain, env.cached] (pagesAddUp x)+ where+ pagesAddUp x base = do+ let t = "/xpub/" <> x.text <> "/transactions?derive=legacy"+ (_, whole) <- getJSON base t+ let go acc start = do+ let p = case start of+ Nothing -> t <> "&limit=1"+ Just s -> t <> "&limit=1&offset=1&height=" <> cs s+ (_, page) <- getJSON base p+ case mapMaybe (textField "txid") (arr page) of+ [] -> return (reverse acc)+ [h] -> go (h : acc) (Just h)+ hs -> fail ("Page of more than one: " <> show hs)+ paged <- go [] Nothing+ paged `shouldBe` mapMaybe (textField "txid") (arr whole)++-- | A multiaddr wallet of two xpubs and an address, with a transaction that+-- touches all three. The wallet counts every transaction once, whatever the+-- page size, and @onlyShow@ narrows the count.+multiaddrTest :: Env -> IO ()+multiaddrTest env = do+ let xa = newXPub env "multi a" DeriveNormal+ xb = newXPub env "multi b" DeriveNormal+ xq = newXPub env "multi address" DeriveNormal+ q = at env xq [0, 0]+ ra <- newLedger+ rb <- newLedger+ rq <- newLedger+ t1 <- sendMany env.node [(at env xa [0, 0], 100000), (at env xb [0, 0], 200000), (q, 300000)]+ forM_ [(xa, ra), (xb, rb), (xq, rq)] $ \(x, r) -> record env x r t1+ void $ pay env xa ra [([0, 1], 110000)]+ void $ pay env xb rb [([0, 1], 120000)]+ void $ mine env 1+ void $ pay env xq rq [([0, 0], 130000)]+ waitSynced env+ forM_ [(xa, ra), (xb, rb)] $ uncurry (checkXPub env)+ let active = xa.text <> "|" <> xb.text <> "|" <> cs q+ m = "/blockchain/multiaddr?active=" <> active+ w1 <- plainJSON env (m <> "&n=1")+ length (arr ((w1 ! "txs"))) `shouldBe` 1+ let wallet1 = (w1 ! "wallet")+ num "n_tx" wallet1 `shouldBe` 4+ num "n_tx_filtered" wallet1 `shouldBe` 4+ num "final_balance" wallet1 `shouldBe` (100000 + 200000 + 300000 + 110000 + 120000 + 130000)+ w2 <- plainJSON env (m <> "&onlyShow=" <> cs q)+ num "n_tx" ((w2 ! "wallet")) `shouldBe` 2+ length (arr ((w2 ! "txs"))) `shouldBe` 2+ w3 <- plainJSON env (m <> "&n=2&offset=1")+ length (arr ((w3 ! "txs"))) `shouldBe` 2+ forM_ (arr ((w1 ! "addresses"))) $ \a ->+ case textField "address" a of+ Just t+ | t == cs xa.text -> num "account_index" a `shouldBe` 2+ | t == cs xb.text -> num "account_index" a `shouldBe` 2+ | t == q -> num "final_balance" a `shouldBe` 430000+ _ -> return ()+ sameAnswers env $+ [ m,+ m <> "&n=1",+ m <> "&n=2&offset=1",+ m <> "&onlyShow=" <> cs q,+ m <> "&onlyShow=" <> xa.text,+ m <> "&filter=1",+ m <> "&filter=2",+ m <> "&filter=5",+ m <> "&filter=7",+ "/blockchain/unspent?active=" <> active,+ "/blockchain/balance?active=" <> active+ ]+ -- the request as the Blockchain.com wallet sends it: a form+ posted <-+ postForm+ env.cached+ "/blockchain/multiaddr"+ [("active", cs active), ("n", "10"), ("offset", "0"), ("format", "json")]+ got <- getJSON env.plain (m <> "&n=10&offset=0")+ posted `shouldBe` got++-- | Endpoints that do not involve xpubs, checked against the node.+endpointsTest :: Env -> IO ()+endpointsTest env = do+ let x = newXPub env "endpoints" DeriveNormal+ a = at env x [0, 0]+ ref <- newLedger+ t1 <- pay env x ref [([0, 0], 400000)]+ void $ mine env 1+ t2 <- pay env x ref [([0, 0], 50000)]+ waitSynced env+ best <- bestBlockHash env.node+ height <- rpc env.node "getblockcount" []+ b <- plainJSON env "/block/best?notx=true"+ textField "hash" b `shouldBe` Just best+ field "height" b `shouldBe` Just height+ hs <- arr <$> plainJSON env ("/block/height/" <> show (numV height) <> "?notx=true")+ map (textField "hash") hs `shouldBe` [Just best]+ tx <- plainJSON env ("/transaction/" <> cs t1)+ textField "txid" tx `shouldBe` Just t1+ rawNode <- rpc env.node "getrawtransaction" [String t1, Bool False]+ raw <- plainJSON env ("/transaction/" <> cs t1 <> "/raw")+ field "result" raw `shouldBe` Just rawNode+ bal <- plainJSON env ("/address/" <> cs a <> "/balance")+ num "confirmed" bal `shouldBe` 400000+ num "unconfirmed" bal `shouldBe` 50000+ num "txs" bal `shouldBe` 2+ ats <- arr <$> plainJSON env ("/address/" <> cs a <> "/transactions")+ mapMaybe (textField "txid") ats `shouldBe` [t2, t1]+ aus <- arr <$> plainJSON env ("/address/" <> cs a <> "/unspent")+ length aus `shouldBe` 2+ pool <- arr <$> plainJSON env "/mempool"+ (String t2 `elem` pool) `shouldBe` True+ lb <- plainJSON env "/blockchain/latestblock"+ textField "hash" lb `shouldBe` Just best+ (_, count) <- getJSON env.plain "/blockchain/q/getblockcount"+ count `shouldBe` height+ rt <- plainJSON env ("/blockchain/rawtx/" <> cs t1)+ textField "hash" rt `shouldBe` Just t1+ rb <- plainJSON env ("/blockchain/rawblock/" <> cs best)+ textField "hash" rb `shouldBe` Just best+ ab <- plainJSON env ("/blockchain/q/addressbalance/" <> cs a)+ ab `shouldBe` Number 450000+ ra <- plainJSON env ("/blockchain/rawaddr/" <> cs a)+ num "n_tx" ra `shouldBe` 2+ num "final_balance" ra `shouldBe` 450000+ peers <- arr <$> plainJSON env "/peers"+ length peers `shouldBe` 1+ sameAnswers env $+ [ "/block/best",+ "/block/height/" <> show (numV height),+ "/transaction/" <> cs t1,+ "/transaction/" <> cs t2,+ "/address/" <> cs a <> "/balance",+ "/address/" <> cs a <> "/transactions",+ "/address/" <> cs a <> "/transactions/full",+ "/address/" <> cs a <> "/unspent",+ "/mempool",+ "/blockchain/rawaddr/" <> cs a,+ "/blockchain/multiaddr?active=" <> cs a,+ "/blockchain/unspent?active=" <> cs a,+ "/blockchain/latestblock",+ "/blockchain/unconfirmed-transactions"+ ]++-- | Xpubs of native and nested SegWit addresses, with a spend from a native+-- SegWit address. Only Bitcoin has SegWit.+segwitTest :: Env -> IO ()+segwitTest env = do+ let xs = newXPub env "segwit" DeriveP2WPKH+ xc = newXPub env "compat" DeriveP2SH+ rs <- newLedger+ rc <- newLedger+ void $ pay env xs rs [([0, 0], 300000), ([0, 1], 400000)]+ void $ pay env xc rc [([0, 0], 500000), ([0, 3], 600000)]+ void $ mine env 1+ checkXPub env xs rs+ checkXPub env xc rc+ out <- getNewAddress env.node+ l <- readIORef rs+ void $ spend env xs rs [coinAt l [0, 0]] [(out, 100000), (at env xs [1, 0], 190000)]+ waitSynced env+ checkXPub env xs rs+ void $ mine env 1+ checkXPub env xs rs+ sameAnswers env ["/blockchain/multiaddr?activeBech32=" <> xs.text <> "&activeP2SH=" <> xc.text]++-- | A parameter that an endpoint does not accept, or a value that it cannot+-- read, must give 400 with a message that names it, rather than an answer+-- that ignores it; and a value it can read must still be accepted.+strictTest :: Env -> IO ()+strictTest env = do+ let x = newXPub env "strict" DeriveNormal+ a = at env x [0, 0]+ (_, best) <- getJSON env.plain "/block/best"+ coinbase <- case best ! "tx" of+ Array ts | String t : _ <- toList ts -> return t+ _ -> fail "No coinbase"+ let refused =+ [ ("/block/best?limt=5", "Unknown parameter: limt"),+ ("/block/best?notx=yes", "Invalid value for parameter notx: yes"),+ ("/block/best?pretty=maybe", "Invalid value for parameter pretty"),+ ("/mempool?limit=-1", "Invalid value for parameter limit"),+ ("/address/" <> cs a <> "/transactions?offset=x", "Invalid value for parameter offset"),+ ("/xpub/" <> x.text <> "?derive=Legacy", "Invalid value for parameter derive"),+ ("/blockchain/latestblock?txidindex=1", "Invalid value for parameter txidindex"),+ ("/blockchain/latestblock?format=hex", "Invalid value for parameter format"),+ ("/blockchain/latestblock?pretty=true", "Unknown parameter: pretty"),+ ("/blockchain/rawtx/" <> cs coinbase <> "?format=xml", "Invalid value for parameter format"),+ ("/blockchain/multiaddr?active=" <> x.text <> "&filter=4", "Invalid value for parameter filter"),+ ("/blockchain/unspent?active=" <> cs a <> "&confirmations=-2", "Invalid value for parameter confirmations"),+ ("/blockchain/rawaddr/" <> cs a <> "?limit=ten", "Invalid value for parameter limit")+ ]+ forM_ refused $ \(p, msg) -> do+ (code, v) <- getJSON env.plain p+ (p, code, textField "error" v) `shouldBe` (p, 400, Just "user-error")+ fmap (T.isInfixOf msg) (textField "message" v) `shouldBe` Just True+ -- refused before anything is done: the body is not even read+ (code, v) <- postForm env.plain "/transactions" [("hex", "00")]+ (code, textField "message" v) `shouldBe` (400, Just "Unknown parameter: hex")+ forM_+ [ "/block/best?notx=true&pretty=true",+ "/blockchain/latestblock?format=json&txidindex=true",+ "/xpub/" <> x.text <> "?derive=normal&nocache=false"+ ]+ $ \p -> do+ (code', _) <- getJSON env.plain p+ (p, code') `shouldBe` (p, 200)++-- | An xpub asked about with the wrong @derive@ must give an error that names+-- the right one, from the database and from the cache, and from every xpub+-- endpoint; an xpub unused with every kind must still give an empty wallet.+deriveTest :: Env -> IO ()+deriveTest env = do+ let x = newXPub env "derive" DeriveP2WPKH+ fresh = newXPub env "derive-fresh" DeriveP2WPKH+ ref <- newLedger+ void $ pay env x ref [([0, 0], 300000)]+ void $ mine env 1+ checkXPub env x ref+ let b = "/xpub/" <> x.text+ ends = ["", "/transactions", "/transactions/full", "/balances", "/unspent"]+ wrong = [b <> e <> "?derive=" <> d | e <- ends, d <- ["legacy", "normal", "compat"]]+ forM_ (wrong <> ["/blockchain/rawaddr/" <> x.text <> "?derive=normal"]) $ \p ->+ forM_ [env.plain, env.cached] $ \base -> do+ (code, v) <- getJSON base p+ (p, code) `shouldBe` (p, 400)+ textField "error" v `shouldBe` Just "user-error"+ fmap (T.isInfixOf "derive=segwit") (textField "message" v) `shouldBe` Just True+ -- legacy is the default, and normal its old name+ forM_ ["", "?derive=legacy", "?derive=normal"] $ \q -> do+ (code, v) <- getJSON env.plain ("/xpub/" <> fresh.text <> q)+ code `shouldBe` 200+ num "confirmed" (v ! "balance") `shouldBe` 0++-- | A transaction confirmed in a block that a reorganisation removes, and+-- double-spent in the new chain, must disappear from the store and from the+-- cache.+--+-- Bitcoin Cash Node cannot mine a chosen transaction (@generateblock@), so+-- the double spend is set up with coinbase maturity: transaction A spends a+-- coinbase in the first block where it may, block X. Invalidating the parent+-- of X makes the coinbase immature again, so the node drops A from its+-- mempool. One block later the coinbase is mature, B spends it to another+-- address, and two more blocks make the new chain longer than the old one.+reorgTest :: Env -> IO ()+reorgTest env = do+ let x = newXPub env "reorg" DeriveNormal+ k = newXPub env "coinbase" DeriveNormal+ ref <- newLedger+ void $ mine env 1+ [cbBlock] <- generateTo env.node 1 (at env k [0, 0])+ block <- rpc env.node "getblock" [String cbBlock]+ cbTxid <- case field "tx" block of+ Just (Array ts) | String t : _ <- toList ts -> return t+ _ -> fail "No coinbase"+ cbTx <- getRawTx env.node cbTxid+ cbValue <- case field "vout" cbTx of+ Just (Array os) | o : _ <- toList os, Just v <- field "value" o -> return (sats v)+ _ -> fail "No coinbase output"+ let coinbaseSpend to amount =+ signAndSend+ env+ [object ["txid" .= cbTxid, "vout" .= (0 :: Int)]]+ [(to, amount)]+ [wifOf env k [0, 0]]+ [ object+ [ "txid" .= cbTxid,+ "vout" .= (0 :: Int),+ "scriptPubKey" .= scriptHex env (addrOf env k [0, 0]),+ "amount" .= btc cbValue+ ]+ ]+ -- 99 blocks on top: the coinbase may be spent in the next block+ void $ mine env 99+ txA <- coinbaseSpend (at env x [0, 0]) (cbValue - 100000)+ record env x ref txA+ [blockX] <- mine env 1+ checkXPub env x ref+ header <- rpc env.node "getblockheader" [String blockX]+ parent <- maybe (fail "No parent") return (textField "previousblockhash" header)+ void $ rpc env.node "invalidateblock" [String parent]+ pool <- rawMempool env.node+ (txA `elem` pool) `shouldBe` False+ -- mined to the address of the invalidated parent, in the same second and+ -- without transactions, the block would be the parent again, which the+ -- node refuses as known to be invalid+ other <- getNewAddress env.node+ void $ generateTo env.node 1 other+ txB <- coinbaseSpend (at env x [0, 1]) (cbValue - 200000)+ void $ mine env 2+ forget ref txA+ record env x ref txB+ e <- expect <$> readIORef ref+ e.txids `shouldBe` [txB]+ checkXPub env x ref++-------------------------------------------------------------------------------+-- Cache+-------------------------------------------------------------------------------++-- | Try a check for up to 30 seconds.+waitFor :: String -> IO Bool -> IO ()+waitFor what check = go (120 :: Int)+ where+ go n =+ check >>= \case+ True -> return ()+ False+ | n <= 0 -> expectationFailure ("Timed out waiting for " <> what)+ | otherwise -> threadDelay 250000 >> go (n - 1)++-- | Wait until the cache has an xpub.+waitCached :: Env -> XPub -> IO ()+waitCached env x = waitFor ("xpub " <> x.text <> " to be cached") (isCached env x)++-- | Whether an xpub is cached.+isCached :: Env -> XPub -> IO Bool+isCached env x = cacheIsCached env.redisCache x.spec++-- | Key of the entry of an xpub in Redis.+xpubKey :: Env -> XPub -> C8.ByteString -> C8.ByteString+xpubKey env x suffix = cachePrefix env.net <> "x:" <> xpubId x.spec <> ":" <> suffix++-- | Run Redis commands of a test.+redisRun :: Env -> Redis.Redis (Either Redis.Reply a) -> IO a+redisRun env f = Redis.runRedis env.redis f >>= either (fail . show) return++-- | Keys of the cache of older versions of the store, which the new cache+-- must not touch.+oldKeys :: [(C8.ByteString, C8.ByteString)]+oldKeys =+ [ ("head", "old head"),+ ("lock", "old lock"),+ ("mempool", "old mempool"),+ ("b\x01xpub", "old balances"),+ ("t\x01xpub", "old transactions"),+ ("a\x02address", "old address")+ ]++writeOldKeys :: String -> IO ()+writeOldKeys url = do+ conn <- Redis.checkedConnect (either error id (Redis.parseConnectInfo url))+ void . Redis.runRedis conn $ mapM_ (uncurry Redis.set) oldKeys+ Redis.disconnect conn++-- | Queries with @nocache=true@ must neither read the cache nor put the xpub+-- in it; a query without it then caches the xpub.+noCacheTest :: Env -> IO ()+noCacheTest env = do+ let x = newXPub env "nocache" DeriveNormal+ b = "/xpub/" <> x.text+ ref <- newLedger+ void $ pay env x ref [([0, 0], 100000), ([0, 1], 100000)]+ void $ mine env 1+ forM_ ["/transactions", "/transactions/full", "/unspent", "/balances", ""] $ \p -> do+ (code, _) <- getJSON env.cached (b <> p <> "?nocache=true")+ code `shouldBe` 200+ threadDelay 1000000+ isCached env x `shouldReturn` False+ checkXPub env x ref++-- | A cached entry that cannot be decoded is cached again instead of failing+-- the request, and a reload does not replace an entry stamped with a higher+-- block, as one written by a store that is ahead would be.+damagedTest :: Env -> IO ()+damagedTest env = do+ let x = newXPub env "damaged" DeriveNormal+ ref <- newLedger+ void $ pay env x ref [([0, 0], 100000), ([0, 1], 200000)]+ void $ mine env 1+ checkXPub env x ref+ void . redisRun env $ Redis.set (xpubKey env x "bal") "garbage"+ checkXPub env x ref+ bal <- redisRun env $ Redis.get (xpubKey env x "bal")+ (decode <$> bal :: Maybe (Either String [XPubBal])) `shouldSatisfy` maybe False isRight+ void . redisRun env $ Redis.hset (xpubKey env x "meta") (pure ("height", "999999999"))+ cacheRefresh env.redisCache x.spec HashSet.empty `shouldReturn` Behind+ void . redisRun env $ Redis.hset (xpubKey env x "meta") (pure ("height", "0"))+ cacheRefresh env.redisCache x.spec HashSet.empty `shouldReturn` Reloaded+ checkXPub env x ref+ -- a reload writes only differences, computed from what Redis has, so+ -- members that should not be there go, and the generation goes up+ gen0 <- redisRun env $ Redis.hget (xpubKey env x "meta") "gen"+ void . redisRun env $ Redis.zadd (xpubKey env x "txs") [(1, "not a transaction")]+ void . redisRun env $ Redis.zadd (xpubKey env x "utxo") [(1, "not an output")]+ cacheRefresh env.redisCache x.spec HashSet.empty `shouldReturn` Reloaded+ redisRun env (Redis.zscore (xpubKey env x "txs") "not a transaction") `shouldReturn` Nothing+ redisRun env (Redis.zscore (xpubKey env x "utxo") "not an output") `shouldReturn` Nothing+ gen1 <- redisRun env $ Redis.hget (xpubKey env x "meta") "gen"+ (read . C8.unpack <$> gen1) `shouldBe` ((+ (1 :: Int)) . read . C8.unpack <$> gen0)+ checkXPub env x ref++-- | Changes of a cached xpub are applied from the transactions that make+-- them, without a full reload: the time of the last full reload in the entry+-- stays, and the answers stay those of the database. When Redis has a balance+-- that no changed transaction explains, a change was missed, and the next+-- reload reads the xpub whole, which repairs it.+incrementalTest :: Env -> IO ()+incrementalTest env = do+ let x = newXPub env "incremental" DeriveNormal+ meta k = redisRun env (Redis.hget (xpubKey env x "meta") k)+ ref <- newLedger+ void $ pay env x ref [([0, 0], 100000), ([0, 1], 200000), ([1, 0], 300000)]+ void $ mine env 1+ checkXPub env x ref+ full0 <- meta "full"+ full0 `shouldSatisfy` isJust+ meta "ver" `shouldReturn` Just "3"+ -- a payment in the mempool, its confirmation, and a spend+ void $ pay env x ref [([0, 2], 400000)]+ waitSynced env+ checkXPub env x ref+ void $ mine env 1+ checkXPub env x ref+ out <- getNewAddress env.node+ l <- readIORef ref+ void $ spend env x ref [coinAt l [0, 1]] [(out, 50000), (at env x [1, 1], 140000)]+ waitSynced env+ checkXPub env x ref+ void $ mine env 1+ checkXPub env x ref+ meta "full" `shouldReturn` full0+ -- the index of outputs by outpoint matches the outputs+ nu <- redisRun env (Redis.zcard (xpubKey env x "utxo"))+ no <- redisRun env (Redis.hlen (xpubKey env x "uop"))+ no `shouldBe` nu+ -- a balance that Redis has wrong, at an address the next change does not+ -- touch, as if a change had been missed+ Just blob <- redisRun env (Redis.get (xpubKey env x "bal"))+ bals <- either fail return (decode blob :: Either String [XPubBal])+ let target = addrOf env x [0, 0]+ wrong :: XPubBal -> XPubBal+ wrong b+ | b.balance.address == target =+ XPubBal {path = b.path, balance = (b.balance :: Balance) {received = b.balance.received + 1}}+ | otherwise = b+ void . redisRun env $ Redis.set (xpubKey env x "bal") (encode (map wrong bals))+ void $ pay env x ref [([0, 3], 500000)]+ waitSynced env+ checkXPub env x ref+ full1 <- meta "full"+ (full1 > full0) `shouldBe` True+ -- the wrong balance is gone: Redis has the balances of the database+ fromDB <- L.withView env.store.db (`L.xPubBals` x.spec)+ Just blob' <- redisRun env (Redis.get (xpubKey env x "bal"))+ decode blob' `shouldBe` Right fromDB++-- | Under load, the database can have transactions whose events the cache has+-- not applied yet, and the balances that a reload reads include them. The+-- reload finds them from the addresses whose balances moved, without reading+-- the xpub whole. Here the entry is wound back to before a payment, as if+-- its event had not come, and a reload is told only of an older change.+catchUpTest :: Env -> IO ()+catchUpTest env = do+ let x = newXPub env "catch-up" DeriveNormal+ key = xpubKey env x+ meta k = redisRun env (Redis.hget (key "meta") k)+ ref <- newLedger+ old <- pay env x ref [([0, 0], 100000)]+ void $ mine env 1+ checkXPub env x ref+ full0 <- meta "full"+ Just blob0 <- redisRun env (Redis.get (key "bal"))+ -- a payment in the mempool, applied by the cache+ newT <- pay env x ref [([0, 1], 200000)]+ waitSynced env+ checkXPub env x ref+ let new = fromMaybe (error "bad txid") (hexToTxHash newT)+ oldH = fromMaybe (error "bad txid") (hexToTxHash old)+ -- wind the entry back: balances, transaction and outputs of the payment+ uop <- redisRun env (Redis.hgetall (key "uop"))+ let paidBy o = either (const False) (\p -> p.hash == new) (decode o :: Either String OutPoint)+ ours = [(op, u) | (op, u) <- uop, paidBy op]+ outs <- maybe (fail "no outputs of the payment in the entry") return (NonEmpty.nonEmpty ours)+ void . redisRun env $ Redis.set (key "bal") blob0+ void . redisRun env $ Redis.zrem (key "txs") (NonEmpty.singleton (encode new))+ void . redisRun env $ Redis.zrem (key "utxo") (snd <$> outs)+ void . redisRun env $ Redis.hdel (key "uop") (fst <$> outs)+ cacheRefresh env.redisCache x.spec (HashSet.singleton oldH) `shouldReturn` Reloaded+ meta "full" `shouldReturn` full0+ redisRun env (Redis.zscore (key "txs") (encode new)) >>= (`shouldSatisfy` isJust)+ checkXPub env x ref+ fromDB <- L.withView env.store.db (`L.xPubBals` x.spec)+ Just blob1 <- redisRun env (Redis.get (key "bal"))+ decode blob1 `shouldBe` Right fromDB++-- | An output of a cached transaction that pays an address outside the xpub+-- is spent by a transaction that does not touch the xpub. The xpub is not+-- reloaded, but the cached body must show the spender.+spenderTest :: Env -> IO ()+spenderTest env = do+ let x = newXPub env "spender" DeriveNormal+ ref <- newLedger+ w <- getNewAddress env.node+ raw <-+ rpc+ env.node+ "createrawtransaction"+ [ toJSON ([] :: [Value]),+ object [Key.fromText (at env x [0, 0]) .= btc 200000, Key.fromText w .= btc 300000]+ ]+ funded <- rpc env.node "fundrawtransaction" [raw]+ signed <- rpc env.node "signrawtransactionwithwallet" [(funded ! "hex")]+ txT <-+ rpc env.node "sendrawtransaction" [(signed ! "hex")] >>= \case+ String t -> return t+ v -> fail (show v)+ record env x ref txT+ void $ mine env 1+ checkXPub env x ref+ info <- rpc env.node "getaddressinfo" [String w]+ tx <- getRawTx env.node txT+ let wScript = textField "scriptPubKey" info+ vout <- case [ numV n+ | o <- arr ((tx ! "vout")),+ Just n <- [field "n" o],+ (textField "hex" =<< field "scriptPubKey" o) == wScript+ ] of+ v : _ -> return v+ [] -> fail "No output to the wallet address"+ w2 <- getNewAddress env.node+ raw2 <-+ rpc+ env.node+ "createrawtransaction"+ [ toJSON [object ["txid" .= txT, "vout" .= vout]],+ object [Key.fromText w2 .= btc 290000]+ ]+ signed2 <- rpc env.node "signrawtransactionwithwallet" [raw2]+ txU <-+ rpc env.node "sendrawtransaction" [(signed2 ! "hex")] >>= \case+ String t -> return t+ v -> fail (show v)+ waitSynced env+ let th = fromMaybe (error "bad txid") (hexToTxHash txT)+ spenderOf = \case+ [Just td] -> (.txid) <$> IntMap.lookup (fromIntegral vout) td.spenders+ _ -> Nothing+ waitFor "the spender of a cached body" $+ (== hexToTxHash txU) . spenderOf <$> cacheTxData env.redisCache [th]+ sameAnswers env ["/xpub/" <> x.text <> "/transactions/full", "/transaction/" <> cs txT]+ void $ mine env 1+ sameAnswers env ["/xpub/" <> x.text <> "/transactions/full", "/transaction/" <> cs txT]++-- | Evicting down to one xpub keeps the one used last, and removes everything+-- of the others: their entries, their addresses in the index, and the+-- bodies that no other cached xpub refers to.+evictTest :: Env -> IO ()+evictTest env = do+ let xa = newXPub env "evict a" DeriveNormal+ xb = newXPub env "evict b" DeriveNormal+ ra <- newLedger+ rb <- newLedger+ void $ pay env xa ra [([0, 0], 100000)]+ void $ pay env xb rb [([0, 0], 100000), ([0, 1], 100000)]+ void $ mine env 1+ checkXPub env xb rb+ checkXPub env xa ra+ _ <- getJSON env.cached ("/xpub/" <> xa.text <> "/balances")+ n <- cacheEvict env.redisCache 1+ n `shouldSatisfy` (>= 1)+ isCached env xa `shouldReturn` True+ isCached env xb `shouldReturn` False+ left <- redisRun env $ Redis.keys (xpubKey env xb "*")+ left `shouldBe` []+ addrs <- redisRun env $ Redis.smembers (cachePrefix env.net <> "a:" <> encode (addrOf env xb [0, 0]))+ addrs `shouldBe` []+ eb <- expect <$> readIORef rb+ forM_ eb.txids $ \t -> do+ let h = fromMaybe (error "bad txid") (hexToTxHash t)+ stored <- redisRun env $ Redis.exists (cachePrefix env.net <> "tx:" <> encode h)+ stored `shouldBe` False+ -- the evicted xpub is answered from the database and cached again+ checkXPub env xb rb++-- | The keys of older caches, written before the store started, are as they+-- were.+oldKeysTest :: Env -> IO ()+oldKeysTest env =+ forM_ oldKeys $ \(k, v) ->+ redisRun env (Redis.get k) `shouldReturn` Just v++-- | While Redis does not answer, the store answers from its own database,+-- and its health check says that it does with status 203: not a failure,+-- since the store works, and not the 200 of a store that has everything.+-- When Redis is back, so is the 200. Redis is told to hold every client for+-- a while, which is what a server that hangs looks like from outside.+redisDownTest :: Env -> IO ()+redisDownTest env = do+ let x = newXPub env "redisdown" DeriveNormal+ ref <- newLedger+ void $ pay env x ref [([0, 0], 100000), ([1, 0], 50000)]+ void $ mine env 1+ checkXPub env x ref+ let redisOK (_, v) = (v ! "redis") ! "ok"+ (code0, healthy) <- getJSON env.cached "/health"+ code0 `shouldBe` 200+ redisOK (code0, healthy) `shouldBe` Bool True+ -- a store without a cache has no such check+ (_, bare) <- getJSON env.plain "/health"+ bare `shouldSatisfy` \case+ Object o -> not (KeyMap.member "redis" o)+ _ -> False+ held <- redisRun env (Redis.sendRequest ["CLIENT", "PAUSE", "12000"])+ held `shouldBe` Redis.Ok+ -- the first request waits two seconds for Redis and gives up on it+ (code, _) <- getJSON env.cached ("/xpub/" <> x.text <> "/balances")+ code `shouldBe` 200+ waitFor "the health check to notice that Redis is gone" $+ (== 203) . fst <$> getJSON env.cached "/health"+ redisOK <$> getJSON env.cached "/health" `shouldReturn` Bool False+ checkXPub env x ref+ waitFor "the health check to notice that Redis is back" $+ (== 200) . fst <$> getJSON env.cached "/health"+ checkXPub env x ref++-- | An error is answered in binary to a request that asks for binary, and+-- in JSON otherwise, and every answer says that it depends on what was+-- asked for.+errorFormatTest :: Env -> IO ()+errorFormatTest env = do+ let unknown = "/transaction/" <> replicate 64 'a'+ ask accept = do+ let opts =+ Wreq.defaults+ & Wreq.checkResponse ?~ (\_ _ -> return ())+ & maybe id (\a -> Wreq.header "Accept" .~ [a]) accept+ r <- Wreq.getWith opts (env.plain <> unknown)+ return+ ( r ^. Wreq.responseStatus . Wreq.statusCode,+ r ^. Wreq.responseHeader "Content-Type",+ r ^. Wreq.responseHeader "Vary",+ r ^. Wreq.responseBody+ )+ (code, kind, vary, bytes) <- ask (Just "application/octet-stream")+ (code, kind, vary) `shouldBe` (404, "application/octet-stream", "Accept, Accept-Encoding")+ -- the error, as haskoin-store-data encodes it+ decode (BL.toStrict bytes) `shouldBe` Right ThingNotFound+ -- in a list with a weight, which used to give JSON+ (_, listed, _, _) <- ask (Just "text/plain;q=0.1, application/octet-stream;q=0.9")+ listed `shouldBe` "application/octet-stream"+ (code', kind', vary', json) <- ask Nothing+ (code', kind', vary') `shouldBe` (404, "application/json", "Accept, Accept-Encoding")+ (body json ! "error") `shouldBe` String "not-found-or-invalid-arg"++-- | A web server on the store of the tests, given a certificate that is+-- made for the occasion: it answers over HTTPS, and does not answer a+-- request that is not encrypted. The requests are made with curl, told+-- not to mind who signed the certificate.+tlsTest :: Env -> IO ()+tlsTest env = withSystemTempDirectory "haskoin-store-tls-" $ \dir -> withTestLogger $ \logger -> do+ let certificate = dir <> "/cert.pem"+ key = dir <> "/key.pem"+ subject = ["-subj", "/CN=localhost", "-days", "1"]+ files = ["-keyout", key, "-out", certificate]+ (made, _, why) <-+ readProcessWithExitCode "openssl" (["req", "-x509", "-newkey", "rsa:2048", "-nodes"] <> files <> subject) ""+ (made, why) `shouldSatisfy` ((== ExitSuccess) . fst)+ listening $ \(sock, port) -> do+ let config = (webConfig logger env.store) {tls = Just (certificate, key)} :: WebConfig+ status scheme = do+ let url = scheme <> "://127.0.0.1:" <> show port <> "/health"+ let quietly = ["-sk", "-m", "5", "-o", "/dev/null", "-w", "%{http_code}"]+ (_, code, _) <- readProcessWithExitCode "curl" (quietly <> [url]) ""+ return code+ withAsync (runWebSocket config sock) $ \web -> do+ link web+ waitFor "the server to answer over HTTPS" ((== "200") <$> status "https")+ status "http" `shouldNotReturn` "200"++-- | The parts of HTTP that are not about any one endpoint: a request for+-- the headers alone, the question that a browser asks before a request+-- from another site, compression, a list of addresses in the body of a+-- request, and the reference of the API at the root of the server.+httpTest :: Env -> IO ()+httpTest env = do+ let x = newXPub env "http" DeriveNormal+ addresses = T.intercalate "," [at env x [0, i] | i <- [0 .. 2]]+ quiet = Wreq.defaults & Wreq.checkResponse ?~ (\_ _ -> return ())+ statusOf r = r ^. Wreq.responseStatus . Wreq.statusCode+ -- the headers of an answer, without the answer+ headed <- Wreq.headWith quiet (env.plain <> "/block/best")+ (statusOf headed, headed ^. Wreq.responseHeader "Content-Type") `shouldBe` (200, "application/json")+ -- what may be asked from another site+ asked <- Wreq.optionsWith quiet (env.plain <> "/transactions")+ statusOf asked `shouldBe` 204+ asked ^. Wreq.responseHeader "Access-Control-Allow-Methods" `shouldBe` "GET, HEAD, POST, DELETE, OPTIONS"+ asked ^. Wreq.responseHeader "Access-Control-Allow-Origin" `shouldBe` "*"+ -- compressed for a client that reads it, and the same answer+ let balances = "/address/balances?addresses=" <> T.unpack addresses+ packed <- Wreq.getWith (quiet & Wreq.header "Accept-Encoding" .~ ["gzip"]) (env.plain <> balances)+ packed ^. Wreq.responseHeader "Content-Encoding" `shouldBe` "gzip"+ (_, inQuery) <- getJSON env.plain balances+ -- the same list in the body of a request, where it may be as long as it likes+ (posted, inBody) <- postForm env.plain "/address/balances" [("addresses", addresses)]+ posted `shouldBe` 200+ inBody `shouldBe` inQuery+ length (arr inBody) `shouldBe` 3+ -- the reference of the API, as a page and as what the page shows+ page <- Wreq.getWith quiet (env.plain <> "/")+ (statusOf page, page ^. Wreq.responseHeader "Content-Type") `shouldBe` (200, "text/html; charset=utf-8")+ BL.toStrict (page ^. Wreq.responseBody) `shouldSatisfy` C8.isInfixOf "swagger-ui"+ reference <- Wreq.getWith quiet (env.plain <> "/swagger.yaml")+ statusOf reference `shouldBe` 200+ BL.toStrict (reference ^. Wreq.responseBody) `shouldSatisfy` C8.isInfixOf "description: This server"++-- | The events of the server over a WebSocket. A client that says nothing+-- hears of every transaction; one that names an extended public key, an+-- address or a Bloom filter hears of the transactions that touch them, in+-- JSON or in binary as it spoke, and all hear of the block. The key is+-- followed past the gap it had when it was named. A client that comes+-- back with the hash of an event hears what came after it, and one that+-- sends what the server cannot read is disconnected.+eventsTest :: Env -> IO ()+eventsTest env = do+ let x = newXPub env "events" DeriveNormal+ y = newXPub env "events-other" DeriveNormal+ port = read (reverse (takeWhile (/= ':') (reverse env.plain))) :: Int+ client path = WS.runClient "127.0.0.1" port path+ json = BL.toStrict . marshalJSON (env.net, env.ctx)+ binary = marshal (env.net, env.ctx)+ -- every event that a client hears after saying something, in order+ hearing path say heard = client path $ \c -> do+ mapM_ (either (WS.sendTextData c) (WS.sendBinaryData c)) (say :: [Either C8.ByteString C8.ByteString])+ forever $+ WS.receiveDataMessage c >>= \case+ WS.Text bytes _ -> modifyIORef' heard (either error id (eitherDecode bytes) :)+ WS.Binary bytes -> modifyIORef' heard (either error id (decode (BL.toStrict bytes)) :)+ eventId = \case+ EventTx h -> txHashToHex h+ EventBlock h -> blockHashToHex h+ element = \case+ PubKeyAddress h -> runPutS (serialize h)+ _ -> error "Not the hash of a key"+ bloom = bloomInsert (bloomCreate 10 0.0000001 0 BloomUpdateNone) (element (addrOf env y [0, 1]))+ [all', key, address, filtered, quiet, keyFirst, addressFirst] <- mapM (const (newIORef [])) [1 .. 7 :: Int]+ listeners <-+ mapM+ async+ [ hearing "/events" [] all',+ hearing "/events" [Left (json (EventWatchXPub x.spec))] key,+ hearing "/events" [Right (binary (EventWatch [addrOf env y [0, 0]]))] address,+ hearing "/events" [Right (binary (EventFilterLoad bloom))] filtered,+ -- chosen in the URL, before the first event: blocks alone, a key+ -- under a type of address, an address+ hearing "/events?quiet=true" [] quiet,+ hearing ("/events?derive=legacy&xpub=" <> x.text) [] keyFirst,+ hearing ("/events?addresses=" <> T.unpack (at env y [0, 0])) [] addressFirst+ ]+ mapM_ link listeners+ threadDelay 1000000+ ref <- newLedger+ other <- newLedger+ let told heard n = waitFor "a client to hear of the events" ((>= n) . length <$> readIORef heard)+ paid who ledger path = do+ txid <- pay env who ledger [(path, 100000)]+ -- one at a time, so that the order of the events is known+ told all' . (+ 1) . length =<< readIORef all'+ maybe (fail "Not a hash") (return . EventTx) (hexToTxHash txid)+ one <- paid x ref [0, 0]+ two <- paid y other [0, 0]+ three <- paid y other [0, 1]+ -- past the gap of the key as it was named, within it since its payment+ four <- paid x ref [0, gap]+ [mined] <- mine env 1+ block <- maybe (fail "Not a hash") (return . EventBlock) (hexToBlockHash mined)+ told all' 5+ told key 3+ told address 2+ told filtered 2+ told quiet 1+ told keyFirst 3+ told addressFirst 2+ threadDelay 500000+ mapM_ cancel listeners+ (reverse <$> readIORef all') `shouldReturn` [one, two, three, four, block]+ (reverse <$> readIORef key) `shouldReturn` [one, four, block]+ (reverse <$> readIORef address) `shouldReturn` [two, block]+ (reverse <$> readIORef filtered) `shouldReturn` [three, block]+ (reverse <$> readIORef quiet) `shouldReturn` [block]+ (reverse <$> readIORef keyFirst) `shouldReturn` [one, four, block]+ (reverse <$> readIORef addressFirst) `shouldReturn` [two, block]+ -- from an event on: what came after it+ let since = "/events?after=" <> T.unpack (eventId one)+ again <- newIORef []+ resumed <- async (hearing since [] again)+ told again 4+ cancel resumed+ (reverse <$> readIORef again) `shouldReturn` [two, three, four, block]+ -- and the same without a WebSocket, one event to a line+ streamed <- eventLines (env.plain <> since) 4+ map (either error id . eitherDecode . BL.fromStrict) streamed `shouldBe` [two, three, four, block]+ -- the stream too can be of what touches an address+ chosen <- eventLines (env.plain <> since <> "&addresses=" <> T.unpack (at env y [0, 1])) 2+ map (either error id . eitherDecode . BL.fromStrict) chosen `shouldBe` [three, block]+ fst <$> getJSON env.plain "/events?addresses=nonsense" `shouldReturn` 400+ fst <$> getJSON env.plain "/events?derive=segwit" `shouldReturn` 400+ -- an event that the server does not have, and a hash that is not one+ fst <$> getJSON env.plain ("/events?after=" <> replicate 64 '0') `shouldReturn` 404+ fst <$> getJSON env.plain "/events?after=nonsense" `shouldReturn` 400+ refused <- tryAny (client ("/events?after=" <> replicate 64 '0') (\_ -> return ()))+ either (const True) (const False) refused `shouldBe` True+ -- a command that cannot be read ends the connection, with the reason+ closed <- try . client "/events" $ \c -> do+ WS.sendTextData c ("{\"type\":\"nonsense\"}" :: C8.ByteString)+ void (WS.receiveDataMessage c)+ case closed of+ Left (WS.CloseRequest code why) -> (code, why) `shouldBe` (1008, "Could not read the command")+ wrong -> expectationFailure ("Not closed: " <> show wrong)++-- | The first lines of a stream of events, which has no end: the request+-- is given up once they are there.+eventLines :: String -> Int -> IO [C8.ByteString]+eventLines url n = do+ got <- newIORef C8.empty+ let collect () chunk = do+ modifyIORef' got (<> chunk)+ enough <- (>= n) . C8.count '\n' <$> readIORef got+ when enough (throwIO (userError "enough"))+ _ <- tryAny (Wreq.foldGet collect () url)+ take n . C8.lines <$> readIORef got++-- | Two more stores, each with its own database, syncing from the same+-- node, as members of one fleet on the Redis of the tests, each with a web+-- server. The fleet agrees on the best block; a payment that both stores+-- hear of from the node reaches the pool once, and both answer every+-- question about it alike, with the one time that the pool has for it; and+-- when a block confirms it, the fleet moves there and the pool lets it go.+--+-- The payment is to an extended public key, which the fleet's cache of+-- them takes after the first request about it. Each store must then+-- answer about the key from the cache as it does when asked to answer from+-- its database, with the payment in the pool and after the block: the+-- cache is brought to the block by whichever store moved the tip.+fleetTest :: Env -> IO ()+fleetTest env = withTestLogger $ \logger -> member logger $ \a urlA -> member logger $ \b urlB -> do+ one <- maybe (fail "The store is not a member of a fleet") return a.fleet+ let x = newXPub env "fleet" DeriveNormal+ q = "?derive=" <> deriveParam x+ address = "/address/" <> T.unpack (at env x [0, 0])+ xpub = "/xpub/" <> x.text+ paths txid =+ [ address <> "/balance",+ address <> "/transactions/full",+ address <> "/unspent",+ xpub <> q,+ xpub <> "/transactions/full" <> q,+ xpub <> "/unspent" <> q,+ "/mempool",+ "/transaction/" <> T.unpack txid+ ]+ atTip = waitFor "the fleet tip to be the best block of the node" $ do+ best <- bestBlockHash env.node+ tip <- memberTip one+ return (fmap blockHashToHex (tip >>= either (const Nothing) Just . decode) == Just best)+ alike what ps = waitFor what $ do+ fromA <- mapM (getJSON urlA) ps+ fromB <- mapM (getJSON urlB) ps+ return (fromA == fromB && all ((== 200) . fst) fromA)+ inPool txid = do+ let h = fromMaybe (error "bad txid") (hexToTxHash txid)+ redisRun env (Redis.hexists (cachePrefix env.net <> "pool:spends") (encode h))+ listed url = length . arr . snd <$> getJSON url (address <> "/transactions")+ about =+ [ xpub <> q,+ xpub <> "/balances" <> q,+ xpub <> "/transactions" <> q,+ xpub <> "/transactions/full" <> q,+ xpub <> "/unspent" <> q+ ]+ cachedKey = cachePrefix env.net <> "fx:e:" <> xpubId x.spec <> ":meta"+ -- the cache has the key, at the fleet tip, and each store answers+ -- about it as it does without the cache, and as the other store does+ throughCache what = do+ waitFor ("the cache to have the key, " <> what) $ do+ _ <- getJSON urlA (xpub <> "/balances" <> q)+ has <- redisRun env (Redis.exists cachedKey)+ tip <- memberTip one+ moves <- redisRun env (Redis.get (cachePrefix env.net <> "fleet:moves"))+ cached <- redisRun env (Redis.get (cachePrefix env.net <> "fx:tip"))+ let here = (\n t -> n <> "@" <> t) <$> moves <*> tip+ return (has && isJust tip && cached == here)+ alike ("the two stores to answer alike from the cache, " <> what) about+ waitFor ("each store to answer from the cache as from its database, " <> what) $ do+ let direct = map (<> "&nocache=true") about+ cachedA <- mapM (getJSON urlA) about+ directA <- mapM (getJSON urlA) direct+ cachedB <- mapM (getJSON urlB) about+ directB <- mapM (getJSON urlB) direct+ return (cachedA == directA && cachedB == directB && cachedA == cachedB)+ waitStore env a+ waitStore env b+ atTip+ -- what each store tells its clients from here on+ heardA <- newIORef []+ heardB <- newIORef []+ let said = \case+ StoreBestBlock h -> Just ("block " <> blockHashToHex h)+ StoreMempoolNew t -> Just ("new " <> txHashToHex t)+ StoreMempoolDelete t -> Just ("gone " <> txHashToHex t)+ _ -> Nothing+ listen st heard = withSubscription st.events $ \sub ->+ forever (receive sub >>= \e -> forM_ (said e) (\line -> modifyIORef' heard (line :)))+ listenA <- async (listen a heardA)+ listenB <- async (listen b heardB)+ threadDelay 500000+ ref <- newLedger+ txid <- pay env x ref [([0, 0], 100000)]+ waitFor "the pool of the fleet to have the payment" (inPool txid)+ waitFor "both stores to list the payment" $ (== (1, 1)) <$> ((,) <$> listed urlA <*> listed urlB)+ alike "the two stores to answer alike, with the payment in the pool" (paths txid)+ throughCache "with the payment in the pool"+ -- the payment is unconfirmed in the answers, at the time that the pool has+ (_, unconfirmed) <- getJSON urlA ("/transaction/" <> T.unpack txid)+ (unconfirmed ! "block") `shouldSatisfy` \case+ Object o -> KeyMap.member "mempool" o+ _ -> False+ void (mine env 1)+ waitStore env a+ waitStore env b+ atTip+ inPool txid `shouldReturn` False+ alike "the two stores to answer alike, with the payment confirmed" (paths txid)+ throughCache "with the payment confirmed"+ -- both told the same events in the same order: the payment as it+ -- entered the pool, and then the block, which is not told as the+ -- payment leaving+ best <- bestBlockHash env.node+ waitFor "both stores to tell of the block" $ do+ told <- (,) <$> readIORef heardA <*> readIORef heardB+ return (("block " <> best) `elem` fst told && ("block " <> best) `elem` snd told)+ toldA <- reverse <$> readIORef heardA+ toldB <- reverse <$> readIORef heardB+ toldA `shouldBe` toldB+ toldA `shouldBe` ["new " <> txid, "block " <> best]+ cancel listenA+ cancel listenB+ -- and a client that heard of the payment from one takes up on the other+ let since = "/events?after=" <> T.unpack txid+ then' = "{\"type\":\"block\",\"id\":\"" <> cs best <> "\"}"+ eventLines (urlA <> since) 1 `shouldReturn` [then']+ eventLines (urlB <> since) 1 `shouldReturn` [then']+ (_, confirmed) <- getJSON urlB ("/transaction/" <> T.unpack txid)+ (confirmed ! "block") `shouldSatisfy` \case+ Object o -> KeyMap.member "height" o+ _ -> False+ -- the health check runs every few seconds, and first ran before the+ -- store had synced+ waitFor "the health check of a member to pass" ((== 200) . fst <$> getJSON urlA "/health")+ (_, health) <- getJSON urlA "/health"+ ((health ! "redis") ! "ok") `shouldBe` Bool True+ where+ member logger f = withRamDir $ \dir ->+ withStore (env.memberIn dir) $ \s ->+ listening $ \(sock, port) ->+ withAsync (runWebSocket (webConfig logger s) sock) $ \web ->+ link web >> f s ("http://127.0.0.1:" <> show port)++-- | Run a second store, with its own database, syncing from the same node+-- and sharing the same Redis, while an action runs.+withOther :: Env -> (Store -> IO a) -> IO a+withOther env f = withRamDir $ \dir -> withStore (env.configIn dir) f++-- | Wait until a store has the best block of the node.+waitStore :: Env -> Store -> IO ()+waitStore env st =+ waitFor "the second store to sync" $ do+ best <- bestBlockHash env.node+ ours <- Q.getBestBlock st.db+ return (fmap blockHashToHex ours == Just best)++-- | Two stores share a Redis and see the same events. A payment to a cached+-- xpub, then the block that confirms it, are each applied once: one store+-- claims each change and reloads the xpub, the other finds it done.+--+-- The stores record their own times for mempool transactions, so which of+-- them wrote the entry shows in the times of its transactions; only the+-- balances are compared with the database here.+onceTest :: Env -> IO ()+onceTest env = withOther env $ \other -> do+ cache2 <- maybe (fail "The second store has no cache") return other.cache+ let x = newXPub env "once" DeriveNormal+ both = do+ a <- cacheCounters env.redisCache+ b <- cacheCounters cache2+ return (a.reloads + b.reloads, a.behind + b.behind)+ synced = waitSynced env >> waitStore env other+ balancesMatch =+ waitFor "the cached balances" $ do+ cached <- cacheXPubBals env.redisCache x.spec+ stored <- L.withView env.store.db (`L.xPubBals` x.spec)+ return (cached == Just stored)+ ref <- newLedger+ void $ pay env x ref [([0, 0], 100000)]+ void $ mine env 1+ synced+ _ <- getJSON env.cached ("/xpub/" <> x.text <> "/balances")+ waitCached env x+ threadDelay 1000000+ (r0, b0) <- both+ void $ pay env x ref [([0, 1], 200000)]+ synced+ balancesMatch+ void $ mine env 1+ synced+ balancesMatch+ threadDelay 2000000+ (r1, b1) <- both+ (r1 - r0, b1 - b0) `shouldBe` (2, 0)++-- | Redis has a transaction of a cached xpub that this store has never seen:+-- a store that is behind must not replace the entry, which would drop it.+unknownTest :: Env -> IO ()+unknownTest env = do+ let x = newXPub env "unknown" DeriveNormal+ unknown = encode (TxHash (sha256 ("never seen" :: C8.ByteString)))+ ref <- newLedger+ void $ pay env x ref [([0, 0], 100000)]+ void $ mine env 1+ checkXPub env x ref+ void . redisRun env $ Redis.zadd (xpubKey env x "txs") [(1, unknown)]+ cacheRefresh env.redisCache x.spec HashSet.empty `shouldReturn` Behind+ void . redisRun env $ Redis.zrem (xpubKey env x "txs") (pure unknown)+ cacheRefresh env.redisCache x.spec HashSet.empty `shouldReturn` Reloaded+ checkXPub env x ref
@@ -0,0 +1,264 @@+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)
@@ -1,20 +1,17 @@-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE ImportQualifiedPost #-}-{-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-}- module Haskoin.Store.LogicSpec (spec) where import Control.Monad (forM_, void)-import Control.Monad.Reader (ask, liftIO, runReaderT) import Data.ByteString qualified as B+import Data.Functor ((<&>)) import Data.List (sort, sortOn)+import Data.Maybe (fromMaybe) import Data.Ord (Down (..)) import Data.Text qualified as T import Data.Word (Word32, Word64) import Database.RocksDB.Query (insert, insertCF, matchingAsListCF, retrieve) import Haskoin- ( Block (..),+ ( Address,+ Block (..), BlockHash, BlockHeader (..), BlockNode (..),@@ -23,7 +20,6 @@ OutPoint (..), Tx (..), TxHash (..),- sha256, TxIn (..), TxOut (..), bchRegTest,@@ -32,10 +28,11 @@ headerHash, prepareContext, scriptToAddressBS,+ sha256, txHash, ) import Haskoin.Node (LogLevel (..), Logger (..))-import Haskoin.Store.Common (Limits (..), StoreReadBase (..), StoreWrite (..), getActiveTxData, getDefaultBalance)+import Haskoin.Store.Common (Limits (..), StoreRead (..)) import Haskoin.Store.Data ( Balance (..), BlockData (..),@@ -43,13 +40,21 @@ Spender (..), TxData (..), Unspent (..),+ zeroBalance, )-import Haskoin.Store.Database.Reader (DatabaseReader (..), DatabaseReaderT, getMempoolBefore, memoryReport, mempoolCF, withDatabaseReader)+import Haskoin.Store.Database.Query qualified as Q+import Haskoin.Store.Database.Reader (DatabaseReader (..), memoryReport, mempoolCF, withDatabaseReader) import Haskoin.Store.Database.Types (MemKey (..), MemTimeKey (..), MemTxKey (..), VersionKey (..))-import Haskoin.Store.Database.Writer (MempoolChanges (..), runWriter, runWriterMempool)+import Haskoin.Store.Database.Writer+ ( MempoolChanges (..),+ Writer,+ addToMempool,+ deleteFromMempool,+ runWriter,+ runWriterMempool,+ ) import Haskoin.Store.Logic ( ImportException (..),- ImportM, deleteUnconfirmedTx, importBlock, initBest,@@ -68,7 +73,7 @@ withDB btcRegTest ctx $ \db -> do importing db initBest `shouldReturn` Right () importing db initBest `shouldReturn` Right ()- reading db getBestBlock `shouldReturn` Just genesis+ Q.getBestBlock db `shouldReturn` Just genesis describe "memoryReport" $ it "lists the memory of every column family" $@@ -81,70 +86,70 @@ describe "data version 18" $ it "moves the mempool into its column family when opened" $ withSystemTempDirectory "haskoin-store-migrate-" $ \dir -> do- let open :: DatabaseReaderT IO () -> IO ()+ let open :: (DatabaseReader -> IO ()) -> IO () open = withDatabaseReader btcRegTest ctx 20 32 dir False Nothing Nothing Nothing Nothing Nothing h1 = TxHash (sha256 ("one" :: B.ByteString)) h2 = TxHash (sha256 ("two" :: B.ByteString)) old = [(200, h2), (100, h1)] :: [(Word64, TxHash)]- open $ ask >>= \r -> liftIO $ do+ open $ \r -> do insert r.db VersionKey (18 :: Word32) insert r.db MemKey old- open $ ask >>= \r -> liftIO $ do- reading r getMempool `shouldReturn` old- reading r (getMempoolPage (Limits 1 1 Nothing)) `shouldReturn` drop 1 old+ open $ \r -> do+ Q.getMempool r `shouldReturn` old+ Q.getMempoolPage r (Limits 1 1 Nothing) `shouldReturn` drop 1 old (retrieve r.db MemKey :: IO (Maybe [(Word64, TxHash)])) `shouldReturn` Nothing retrieve r.db VersionKey `shouldReturn` Just (20 :: Word32) describe "data version 19" $ it "indexes the mempool by time when opened" $ withSystemTempDirectory "haskoin-store-migrate-" $ \dir -> do- let open :: DatabaseReaderT IO () -> IO ()+ let open :: (DatabaseReader -> IO ()) -> IO () open = withDatabaseReader btcRegTest ctx 20 32 dir False Nothing Nothing Nothing Nothing Nothing old = sortOn Down (zip [300, 100, 200] (hashes 3))- open $ ask >>= \r -> liftIO $ do+ open $ \r -> do insert r.db VersionKey (19 :: Word32) forM_ old $ \(t, h) -> insertCF r.db (mempoolCF r.db) (MemTxKey h) t (matchingAsListCF r.db (mempoolCF r.db) MemTimeKeyS :: IO [(MemTimeKey, ())]) `shouldReturn` []- open $ ask >>= \r -> liftIO $ do- reading r getMempool `shouldReturn` old+ open $ \r -> do+ Q.getMempool r `shouldReturn` old retrieve r.db VersionKey `shouldReturn` Just (20 :: Word32) describe "mempool index" $ do let entries = zip [5, 3, 5, 1, 4, 3, 2] (hashes 7) newest = sortOn Down entries- fill db = importing db (forM_ entries (\(t, h) -> addToMempool h t))+ fill db = importing db (\w -> forM_ entries (\(t, h) -> addToMempool w h t)) it "pages the mempool newest first" $ withDB btcRegTest ctx $ \db -> do _ <- fill db- reading db getMempool `shouldReturn` newest+ Q.getMempool db `shouldReturn` newest forM_ [(0, 0), (3, 0), (3, 2), (2, 5), (4, 7), (0, 3)] $ \(l, o) ->- reading db (getMempoolPage (Limits l o Nothing))+ Q.getMempoolPage db (Limits l o Nothing) `shouldReturn` take' l (drop (fromIntegral o) newest) it "reads the oldest entries before a time" $ withDB btcRegTest ctx $ \db -> do _ <- fill db let oldest = reverse newest- reading db (getMempoolBefore 4 10) `shouldReturn` takeWhile ((< 4) . fst) oldest- reading db (getMempoolBefore 4 2) `shouldReturn` take 2 oldest- reading db (getMempoolBefore 1 10) `shouldReturn` []+ Q.getMempoolBefore db 4 10 `shouldReturn` takeWhile ((< 4) . fst) oldest+ Q.getMempoolBefore db 4 2 `shouldReturn` take 2 oldest+ Q.getMempoolBefore db 1 10 `shouldReturn` [] it "reports and indexes what a batch changes" $ withDB btcRegTest ctx $ \db -> do let hs = hashes 3 (h1, h2, h3) = (hs !! 0, hs !! 1, hs !! 2)- batch :: ImportM () -> IO MempoolChanges+ batch :: (Writer -> IO ()) -> IO MempoolChanges batch = fmap snd . runWriterMempool db.net db.ctx db quietLogger- c <- batch (addToMempool h1 10 >> addToMempool h2 20)+ c <- batch (\w -> addToMempool w h1 10 >> addToMempool w h2 20) (sort c.added, c.removed) `shouldBe` (sort [h1, h2], []) -- a new time moves the entry in the index, and is no change- batch (addToMempool h1 30) `shouldReturn` MempoolChanges [] []- reading db getMempool `shouldReturn` [(30, h1), (20, h2)]+ batch (\w -> addToMempool w h1 30) `shouldReturn` MempoolChanges [] []+ Q.getMempool db `shouldReturn` [(30, h1), (20, h2)] -- removing what is not there, or adding and removing in one batch, -- is no change either- batch (deleteFromMempool h3) `shouldReturn` MempoolChanges [] []- batch (addToMempool h3 40 >> deleteFromMempool h3) `shouldReturn` MempoolChanges [] []- batch (deleteFromMempool h2) `shouldReturn` MempoolChanges [] [h2]- reading db getMempool `shouldReturn` [(30, h1)]+ batch (\w -> deleteFromMempool w h3) `shouldReturn` MempoolChanges [] []+ batch (\w -> addToMempool w h3 40 >> deleteFromMempool w h3) `shouldReturn` MempoolChanges [] []+ batch (\w -> deleteFromMempool w h2) `shouldReturn` MempoolChanges [] [h2]+ Q.getMempool db `shouldReturn` [(30, h1)] (matchingAsListCF db.db (mempoolCF db.db) MemTimeKeyS :: IO [(MemTimeKey, ())]) `shouldReturn` [(MemTimeKey 30 h1, ())] @@ -152,10 +157,10 @@ it "imports a transaction and spends its input" $ withCoin btcRegTest ctx $ \db coin -> do let tx = spend [coin] [1000, 2000]- importing db (newMempoolTx tx 1700000000) `shouldReturn` Right True- reading db getMempool `shouldReturn` [(1700000000, txHash tx)]- reading db (getSpender coin) `shouldReturn` Just (Spender (txHash tx) 0)- fmap (.value) <$> reading db (getUnspent (out tx 1)) `shouldReturn` Just 2000+ importing db (\w -> newMempoolTx w tx 1700000000) `shouldReturn` Right True+ Q.getMempool db `shouldReturn` [(1700000000, txHash tx)]+ Q.getSpender db coin `shouldReturn` Just (Spender (txHash tx) 0)+ fmap (.value) <$> Q.getUnspent db (out tx 1) `shouldReturn` Just 2000 it "ignores a transaction it already has" $ withCoin btcRegTest ctx $ \db coin -> do@@ -177,7 +182,7 @@ withCoin btcRegTest ctx $ \db coin -> do let parent = spendRBF [coin] [1000] child = spend [out parent 0] [900]- importing db (mapM (`newMempoolTx` 0) [parent, child])+ importing db (\w -> mapM (\t -> newMempoolTx w t 0) [parent, child]) `shouldReturn` Right [True, True] mapM (rbfFlag db) [parent, child] `shouldReturn` [Just True, Just True] @@ -200,8 +205,15 @@ mapM (addTx db) [original, child, replacement] `shouldReturn` [Right True, Right True, Right True] mempool db `shouldReturn` [txHash replacement]- reading db (getSpender coin) `shouldReturn` Just (Spender (txHash replacement) 0)- fmap (.deleted) <$> reading db (getTxData (txHash original)) `shouldReturn` Just True+ Q.getSpender db coin `shouldReturn` Just (Spender (txHash replacement) 0)+ fmap (.deleted) <$> Q.getTxData db (txHash original) `shouldReturn` Just True+ -- the database keeps the replaced ones, and the queries that the API+ -- answers with do not return them, by hash or among others+ let queries = Q.databaseRead db+ wanted = map txHash [original, child, replacement]+ queries.getTxData (txHash original) `shouldReturn` Nothing+ map (fmap (txHash . (.tx))) <$> queries.getManyTxData wanted+ `shouldReturn` [Nothing, Nothing, Just (txHash replacement)] it "replaces a transaction and its descendant when both conflict" $ withCoin btcRegTest ctx $ \db coin1 -> do@@ -223,13 +235,13 @@ addTx db original `shouldReturn` Right True -- The block store imports mempool transactions in batches and skips -- the ones that are rejected.- let tryAdd tx = newMempoolTx tx 0 `catch` skip- skip :: ImportException -> ImportM Bool+ let tryAdd w tx = newMempoolTx w tx 0 `catch` skip+ skip :: ImportException -> IO Bool skip _ = return False unfunded = spend [coin1] [5000000001] confirmedConflict = spend [coin1, coin2] [2000] spendsReplaced = spend [coin1, out original 0] [900]- importing db (mapM tryAdd [unfunded, confirmedConflict, spendsReplaced])+ importing db (\w -> mapM (tryAdd w) [unfunded, confirmedConflict, spendsReplaced]) `shouldReturn` Right [False, False, False] mempool db `shouldReturn` [txHash original] @@ -249,10 +261,10 @@ withCoin btcRegTest ctx $ \db coin -> do let good = spend [coin] [1000] orphan = spend [out (coinbaseTx 9) 0] [1]- importing db (newMempoolTx good 0 >> newMempoolTx orphan 0)+ importing db (\w -> newMempoolTx w good 0 >> newMempoolTx w orphan 0) `shouldReturn` Left Orphan mempool db `shouldReturn` []- reading db (getSpender coin) `shouldReturn` Nothing+ Q.getSpender db coin `shouldReturn` Nothing describe "importBlock" $ do it "confirms mempool transactions" $@@ -262,7 +274,7 @@ _ <- mine db [tx] mempool db `shouldReturn` [] fmap (\t -> (t.block, t.rbf)) <$> txData db tx `shouldReturn` Just (BlockRef 2 1, False)- fmap (.block) <$> reading db (getUnspent (out tx 0)) `shouldReturn` Just (BlockRef 2 1)+ fmap (.block) <$> Q.getUnspent db (out tx 0) `shouldReturn` Just (BlockRef 2 1) it "confirms a transaction whose outputs are spent in the mempool" $ withCoin btcRegTest ctx $ \db coin -> do@@ -272,7 +284,7 @@ _ <- mine db [parent] mempool db `shouldReturn` [txHash child] fmap (.block) <$> txData db parent `shouldReturn` Just (BlockRef 2 1)- reading db (getSpender (out parent 0)) `shouldReturn` Just (Spender (txHash child) 0)+ Q.getSpender db (out parent 0) `shouldReturn` Just (Spender (txHash child) 0) it "stops flagging a descendant once its signalling ancestor confirms" $ withCoin btcRegTest ctx $ \db coin -> do@@ -287,7 +299,7 @@ let parent = spend [coin] [4000000000] child = spend [out parent 0] [3999990000] block <- mine db [child, parent]- Just bd <- reading db (getBlock (headerHash block.header))+ Just bd <- Q.getBlock db (headerHash block.header) (bd.height, bd.main, bd.txs) `shouldBe` (2, True, map txHash block.txs) (bd.subsidy, bd.fee, bd.outputs) `shouldBe` (5000000000, 1000010000, 7999990000) fmap (.block) <$> txData db child `shouldReturn` Just (BlockRef 2 1)@@ -304,17 +316,17 @@ it "rejects blocks that spend unknown outputs, changing nothing" $ withCoin btcRegTest ctx $ \db _ -> do- best <- reading db getBestBlock+ best <- Q.getBestBlock db tryMine db [spend [out (coinbaseTx 9) 0] [1]] `shouldReturn` Left Orphan- reading db getBestBlock `shouldReturn` best+ Q.getBestBlock db `shouldReturn` best it "rejects blocks that do not build on the best block" $ withDB btcRegTest ctx $ \db -> do let (block1, node1) = nextBlock btcRegTest 0 (genesisNode btcRegTest).header [] (block2, node2) = nextBlock btcRegTest 1 node1.header []- importing db (void (importBlock block1 node1)) `shouldReturn` Left BestBlockNotFound+ importing db (\w -> void (importBlock w block1 node1)) `shouldReturn` Left BestBlockNotFound importing db initBest `shouldReturn` Right ()- importing db (void (importBlock block2 node2)) `shouldReturn` Left PrevBlockNotBest+ importing db (\w -> void (importBlock w block2 node2)) `shouldReturn` Left PrevBlockNotBest describe "revertBlock" $ do it "returns the block's transactions to the mempool" $@@ -322,10 +334,10 @@ let tx = spendRBF [coin] [1000] block <- mine db [tx] let h = headerHash block.header- importing db (revertBlock h) `shouldReturn` Right ()- reading db getBestBlock `shouldReturn` Just block.header.prev- fmap (.main) <$> reading db (getBlock h) `shouldReturn` Just False- reading db getMempool `shouldReturn` [(1296689802, txHash tx)]+ importing db (`revertBlock` h) `shouldReturn` Right ()+ Q.getBestBlock db `shouldReturn` Just block.header.prev+ fmap (.main) <$> Q.getBlock db h `shouldReturn` Just False+ Q.getMempool db `shouldReturn` [(1296689802, txHash tx)] rbfFlag db tx `shouldReturn` Just True txData db (coinbaseTx 2) `shouldReturn` Nothing @@ -336,15 +348,15 @@ block <- mine db [parent] addTx db child `shouldReturn` Right True rbfFlag db child `shouldReturn` Just False- importing db (revertBlock (headerHash block.header)) `shouldReturn` Right ()+ importing db (`revertBlock` (headerHash block.header)) `shouldReturn` Right () rbfFlag db child `shouldReturn` Just True it "only reverts the best block" $ withDB btcRegTest ctx $ \db -> do- importing db (revertBlock genesis) `shouldReturn` Left BestBlockNotFound+ importing db (`revertBlock` genesis) `shouldReturn` Left BestBlockNotFound importing db initBest `shouldReturn` Right () _ <- mine db []- importing db (revertBlock genesis) `shouldReturn` Left BlockNotBest+ importing db (`revertBlock` genesis) `shouldReturn` Left BlockNotBest describe "deleteUnconfirmedTx" $ do it "deletes a transaction and its descendants" $@@ -352,18 +364,18 @@ let parent = spend [coin] [1000] child = spend [out parent 0] [900] mapM (addTx db) [parent, child] `shouldReturn` [Right True, Right True]- importing db (deleteUnconfirmedTx False (txHash parent)) `shouldReturn` Right ()+ importing db (\w -> deleteUnconfirmedTx w False (txHash parent)) `shouldReturn` Right () mempool db `shouldReturn` []- fmap (.value) <$> reading db (getUnspent coin) `shouldReturn` Just 5000000000+ fmap (.value) <$> Q.getUnspent db coin `shouldReturn` Just 5000000000 it "ignores unknown transactions" $ withCoin btcRegTest ctx $ \db _ ->- importing db (deleteUnconfirmedTx False (txHash (coinbaseTx 9)))+ importing db (\w -> deleteUnconfirmedTx w False (txHash (coinbaseTx 9))) `shouldReturn` Right () it "refuses to delete confirmed transactions" $ withCoin btcRegTest ctx $ \db _ ->- importing db (deleteUnconfirmedTx False (txHash (coinbaseTx 1)))+ importing db (\w -> deleteUnconfirmedTx w False (txHash (coinbaseTx 1))) `shouldReturn` Left TxConfirmed describe "address balances" $@@ -373,14 +385,14 @@ let tx = spend [coin] [1000, 2000] -- confirmed, unconfirmed, outputs, transactions, received balance = do- b <- reading db (getDefaultBalance a)+ b <- defaultBalance db a return (b.confirmed, b.unconfirmed, b.utxo, b.txs, b.received) balance `shouldReturn` (5000000000, 0, 1, 1, 5000000000) addTx db tx `shouldReturn` Right True balance `shouldReturn` (0, 3000, 2, 2, 5000003000) block <- mine db [tx] balance `shouldReturn` (5000003000, 0, 3, 3, 10000003000)- importing db (revertBlock (headerHash block.header)) `shouldReturn` Right ()+ importing db (`revertBlock` (headerHash block.header)) `shouldReturn` Right () balance `shouldReturn` (0, 3000, 2, 2, 5000003000) describe "a coin whose script has no address on the network" $@@ -391,14 +403,14 @@ let hashLock = "\xaa\x20" <> B.replicate 32 0xbb <> "\x87" tx = Tx 2 [TxIn coin "" maxBound] [TxOut 1000 hashLock] [] 0 addTx db tx `shouldReturn` Right True- coinAddress <- fmap (.address) <$> reading db (getUnspent (out tx 0))+ coinAddress <- fmap (.address) <$> Q.getUnspent db (out tx 0) coinAddress `shouldBe` Just Nothing -- | Run with an empty database in a temporary directory. withDB :: Network -> Ctx -> (DatabaseReader -> IO a) -> IO a withDB net ctx go = withSystemTempDirectory "haskoin-store-logic-" $ \dir ->- withDatabaseReader net ctx 20 32 dir False Nothing Nothing Nothing Nothing Nothing (ask >>= liftIO . go)+ withDatabaseReader net ctx 20 32 dir False Nothing Nothing Nothing Nothing Nothing go -- | Run with the genesis block and one mined block, passing the output of -- that block's coinbase, worth 5000000000.@@ -409,7 +421,7 @@ _ <- mine db [] go db (out (coinbaseTx 1) 0) -importing :: DatabaseReader -> ImportM a -> IO (Either ImportException a)+importing :: DatabaseReader -> (Writer -> IO a) -> IO (Either ImportException a) importing db = try . runWriter db.net db.ctx db quietLogger -- | Build every message, so that log lines are tested too, but print none.@@ -430,28 +442,32 @@ take' 0 = id take' l = take (fromIntegral l) -reading :: DatabaseReader -> DatabaseReaderT IO a -> IO a-reading db f = runReaderT f db+-- | Balance of an address, or a zero balance if never used.+defaultBalance :: DatabaseReader -> Address -> IO Balance+defaultBalance db a = fromMaybe (zeroBalance a) <$> Q.getBalance db a addTx :: DatabaseReader -> Tx -> IO (Either ImportException Bool)-addTx db tx = importing db (newMempoolTx tx 0)+addTx db tx = importing db (\w -> newMempoolTx w tx 0) txData :: DatabaseReader -> Tx -> IO (Maybe TxData)-txData db = reading db . getActiveTxData . txHash+txData db tx =+ Q.getTxData db (txHash tx) <&> \case+ Just td | not td.deleted -> Just td+ _ -> Nothing rbfFlag :: DatabaseReader -> Tx -> IO (Maybe Bool) rbfFlag db tx = fmap (.rbf) <$> txData db tx mempool :: DatabaseReader -> IO [TxHash]-mempool db = sort . map snd <$> reading db getMempool+mempool db = sort . map snd <$> Q.getMempool db -- | Mine a block with these transactions on top of the best block. tryMine :: DatabaseReader -> [Tx] -> IO (Either ImportException Block) tryMine db txs = do- Just h <- reading db getBestBlock- Just parent <- reading db (getBlock h)+ Just h <- Q.getBestBlock db+ Just parent <- Q.getBlock db h let (block, node) = nextBlock db.net parent.height parent.header txs- fmap (const block) <$> importing db (importBlock block node)+ fmap (const block) <$> importing db (\w -> importBlock w block node) mine :: DatabaseReader -> [Tx] -> IO Block mine db txs = tryMine db txs >>= either (fail . ("Block rejected: " <>) . show) return
@@ -1,7 +1,3 @@-{-# LANGUAGE ImportQualifiedPost #-}-{-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-}- -- | Regtest blocks for tests. Their headers carry valid proof of work, so -- they pass haskoin-node's header checks as well as the store's import. module Haskoin.Store.TestBlocks
@@ -0,0 +1,191 @@+module Haskoin.Store.Web.EventsSpec (spec) where++import Control.Concurrent.STM (atomically, newTVarIO, orElse, readTVarIO, stateTVar)+import Data.ByteString qualified as B+import Data.Bytes.Put (runPutS)+import Data.Bytes.Serial (serialize)+import Data.Foldable (toList)+import Data.HashMap.Strict qualified as HashMap+import Data.HashSet qualified as HashSet+import Data.Maybe (fromMaybe)+import Data.Word (Word32, Word8)+import Haskoin+import Haskoin.Store.Common (deriveFunction)+import Haskoin.Store.Data+import Haskoin.Store.Web.Events+import Test.Hspec++-- | An address that nothing else here has.+address :: Word8 -> Address+address n = PubKeyAddress (addressHash (B.replicate 33 n))++-- | A transaction that pays to addresses, told apart by a number.+paying :: Ctx -> Word32 -> [Address] -> Seen+paying ctx n to =+ Seen {tx, addresses = HashSet.fromList to, size = 1000}+ where+ tx = Tx {version = 1, inputs = [], outputs = map out to, witness = [], locktime = n}+ out a = TxOut {value = 1000, script = fromMaybe (error "No script") (addressToScriptBS ctx a)}++-- | A key, and its address at a place on a chain.+key :: Ctx -> DeriveType -> XPubSpec+key ctx deriv = XPubSpec {key = deriveXPubKey ctx (makeXPrvKey (B.replicate 32 7)), deriv}++keyAddress :: Ctx -> XPubSpec -> Word32 -> Word32 -> Address+keyAddress ctx x chain = deriveFunction ctx x.deriv (pubSubKey ctx x.key chain)++-- | A balance of a key's address that has been paid to.+used :: Ctx -> XPubSpec -> Word32 -> Word32 -> XPubBal+used ctx x chain i =+ XPubBal+ { path = [chain, i],+ balance =+ Balance+ { address = keyAddress ctx x chain i,+ confirmed = 1,+ unconfirmed = 0,+ utxo = 1,+ txs = 1,+ received = 1+ }+ }++spec :: Spec+spec = prepareContext $ \ctx -> do+ describe "log of events" $ do+ it "gives a reader what came after its place, and waits at the end" $ do+ events <- newEventLog+ let one = paying ctx 1 [address 1]+ h1 = txHash one.tx+ block = BlockHash (txHash (paying ctx 2 []).tx).get+ start <- atomically (eventsEnd events)+ atomically $ do+ appendEvent events (TxEntered h1 (Just one))+ appendEvent events (BlockArrived block)+ appendEvent events (TxLeft h1)+ Just (Just read') <- atomically ((Just <$> readEvents events start) `orElse` return Nothing)+ map (.event) (toList read') `shouldBe` [EventTx h1, EventBlock block, EventTx h1]+ -- a transaction that leaves is known as it was when it entered+ map (.seen) (toList read') `shouldBe` [Just one, Nothing, Just one]+ end <- atomically (eventsEnd events)+ waiting <- atomically ((Just <$> readEvents events end) `orElse` return Nothing)+ fmap (fmap length) waiting `shouldBe` Nothing+ it "takes up after the first event about a hash, and not after one it does not have" $ do+ events <- newEventLog+ let seen n = paying ctx n [address 1]+ hashes = map (txHash . (.tx) . seen) [1 .. 3]+ h1 = hashes !! 0+ h2 = hashes !! 1+ atomically $ do+ mapM_ (\n -> appendEvent events (TxEntered (txHash (seen n).tx) (Just (seen n)))) [1 .. 3]+ appendEvent events (TxLeft h1)+ Just from <- atomically (eventsAfter events Nothing (Just h1))+ Just later <- atomically (readEvents events from)+ map (.event) (toList later) `shouldBe` map EventTx [h2, hashes !! 2, h1]+ -- a hash is that of a block or of a transaction, as the caller reads it+ atomically (eventsAfter events (Just (BlockHash h1.get)) Nothing) `shouldReturn` Nothing+ atomically (eventsAfter events Nothing (Just (txHash (seen 9).tx))) `shouldReturn` Nothing+ it "forgets the oldest events, and tells a reader that fell off the start" $ do+ events <- newEventLog+ let seen n = paying ctx n [address 1]+ enter n = appendEvent events (TxEntered (txHash (seen n).tx) (Just (seen n)))+ extra = 5+ atomically (mapM_ enter [1 .. fromIntegral logLength + extra])+ atomically (readEvents events 0) `shouldReturn` Nothing+ Just kept <- atomically (readEvents events (fromIntegral extra))+ length kept `shouldBe` logLength+ atomically (eventsAfter events Nothing (Just (txHash (seen 1).tx))) `shouldReturn` Nothing+ -- the transaction of a forgotten event is not known when it leaves+ atomically (appendEvent events (TxLeft (txHash (seen 1).tx)))+ end <- atomically (eventsEnd events)+ Just left <- atomically (readEvents events (end - 1))+ map (.seen) (toList left) `shouldBe` [Nothing]+ it "gets shorter when its transactions are large" $ do+ events <- newEventLog+ let large :: Word32 -> Seen+ large n =+ let s = paying ctx n [address 1]+ in Seen {tx = s.tx, addresses = s.addresses, size = logWeight `div` 4}+ atomically (mapM_ (\n -> appendEvent events (TxEntered (txHash (large n).tx) (Just (large n)))) [1 .. 10])+ end <- atomically (eventsEnd events)+ atomically (readEvents events (end - 5)) `shouldReturn` Nothing+ Just kept <- atomically (readEvents events (end - 4))+ length kept `shouldBe` 4++ describe "choice of a client" $ do+ let gap = 3 :: Word32+ unused _ = return [] :: IO [XPubBal]+ told c var seen = atomically (stateTVar var (relevant c gap seen))+ it "is everything until the client chooses, and again when it says so" $ do+ var <- newTVarIO everything+ told ctx var (Just (paying ctx 1 [address 1])) `shouldReturn` True+ command ctx gap unused var (EventWatch [address 2]) `shouldReturn` Right ()+ told ctx var (Just (paying ctx 1 [address 1])) `shouldReturn` False+ told ctx var (Just (paying ctx 2 [address 1, address 2])) `shouldReturn` True+ -- what the server does not know is told+ told ctx var Nothing `shouldReturn` True+ command ctx gap unused var (EventUnwatch [address 2]) `shouldReturn` Right ()+ told ctx var (Just (paying ctx 2 [address 1, address 2])) `shouldReturn` False+ command ctx gap unused var EventAll `shouldReturn` Right ()+ readTVarIO var `shouldReturn` everything+ -- blocks alone, which are events without a transaction+ command ctx gap unused var EventNone `shouldReturn` Right ()+ told ctx var (Just (paying ctx 1 [address 1])) `shouldReturn` False+ told ctx var Nothing `shouldReturn` True+ it "watches a key up to its gap, which moves on with its payments" $ do+ var <- newTVarIO everything+ let x = key ctx DeriveNormal+ pay chain i = Just (paying ctx (chain * 1000 + i) [keyAddress ctx x chain i])+ -- used up to the second address of the external chain+ command ctx gap (\_ -> return [used ctx x 0 1]) var (EventWatchXPub x) `shouldReturn` Right ()+ w <- readTVarIO var+ HashMap.lookup x w.keys `shouldBe` Just (5, 3)+ HashMap.size w.derived `shouldBe` 8+ told ctx var (pay 0 5) `shouldReturn` False+ told ctx var (pay 1 3) `shouldReturn` False+ told ctx var (pay 0 4) `shouldReturn` True+ -- the payment to the fifth address moved the gap to the eighth+ told ctx var (pay 0 7) `shouldReturn` True+ told ctx var (pay 0 11) `shouldReturn` False+ told ctx var (pay 1 2) `shouldReturn` True+ told ctx var (pay 1 5) `shouldReturn` True+ -- the same key with another type of address is another key+ let y = key ctx DeriveP2WPKH+ other = Just (paying ctx 77 [keyAddress ctx y 0 0])+ told ctx var other `shouldReturn` False+ command ctx gap unused var (EventWatchXPub y) `shouldReturn` Right ()+ told ctx var other `shouldReturn` True+ command ctx gap unused var (EventUnwatchXPub x) `shouldReturn` Right ()+ told ctx var (pay 0 0) `shouldReturn` False+ told ctx var other `shouldReturn` True+ it "matches a Bloom filter as a node does, next to the addresses" $ do+ var <- newTVarIO everything+ let element = \case+ PubKeyAddress h -> runPutS (serialize h)+ _ -> error "Not the hash of a key"+ filter' = bloomInsert (bloomCreate 10 0.0000001 0 BloomUpdateAll) (element (address 1))+ one = paying ctx 1 [address 1]+ two = paying ctx 2 [address 2]+ three = paying ctx 3 [address 3]+ command ctx gap unused var (EventFilterAdd "x") `shouldReturn` Left "No filter to add to"+ command ctx gap unused var (EventFilterLoad filter') `shouldReturn` Right ()+ command ctx gap unused var (EventWatch [address 3]) `shouldReturn` Right ()+ told ctx var (Just one) `shouldReturn` True+ told ctx var (Just two) `shouldReturn` False+ told ctx var (Just three) `shouldReturn` True+ -- the server does not add to the filter by itself+ (.bloom) <$> readTVarIO var `shouldReturn` Just filter'+ -- by the hash of the transaction, and by what the client adds+ command ctx gap unused var (EventFilterAdd (runPutS (serialize (txHash two.tx)))) `shouldReturn` Right ()+ told ctx var (Just two) `shouldReturn` True+ command ctx gap unused var EventFilterClear `shouldReturn` Right ()+ told ctx var (Just one) `shouldReturn` False+ told ctx var (Just three) `shouldReturn` True+ it "refuses more addresses or keys than a client may name" $ do+ var <- newTVarIO everything+ let numbered n = PubKeyAddress (addressHash (runPutS (serialize n)))+ many = map numbered [1 .. fromIntegral maxWatchAddresses :: Word32]+ command ctx gap unused var (EventWatch many) `shouldReturn` Right ()+ command ctx gap unused var (EventWatch [address 1]) `shouldReturn` Left "Too many addresses"+ -- a refused command changes nothing+ HashSet.size . (.plain) <$> readTVarIO var `shouldReturn` maxWatchAddresses
@@ -0,0 +1,75 @@+module Haskoin.Store.Web.RouterSpec (spec) where++import Control.Exception (try)+import Data.ByteString.Builder (toLazyByteString)+import Data.ByteString.Lazy.Char8 qualified as L8+import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)+import Data.Maybe (fromMaybe)+import Data.Text qualified as T+import Haskoin.Store.Web.Router+import Network.HTTP.Types (methodGet, parseQuery, status200, status404, statusCode)+import Network.Wai+import Network.Wai.Internal (Response (..), ResponseReceived (..))+import Test.Hspec++spec :: Spec+spec = describe "router" $ do+ it "answers with the route whose path matches, with its captures" $+ call "/block/abc" `shouldReturn` (200, "block abc")+ it "gives the parameters that the route accepts to its handler" $+ call "/block/abc?notx=true" `shouldReturn` (200, "block abc notx=true")+ it "refuses a parameter that the route does not accept, without running it" $+ call "/block/abc?limt=5" `shouldReturn` (404, "unknown limt")+ it "names every unknown parameter once" $+ call "/block/abc?a=1&b=2&a=3" `shouldReturn` (404, "unknown a,b")+ it "tries the next route when a handler passes, keeping its parameters" $+ call "/block/best?notx=false" `shouldReturn` (200, "best notx=false")+ it "falls back when no route has the path" $+ call "/nothing" `shouldReturn` (404, "no route")+ it "fails when a handler reads a parameter that its route does not declare" $ do+ r <- try (call "/sloppy")+ either (\(Undeclared n) -> n) (const "") r `shouldBe` "secret"++ it "answers inside the scope of a request, and makes one for each route that it tries" $ do+ events <- newIORef []+ let note e = modifyIORef' events (<> [e])+ scoped answer = note "open" >> answer routes <* note "close"+ req = defaultRequest {requestMethod = methodGet, pathInfo = ["block", "best"]}+ _ <- routerScoped routes scoped (\_ _ -> fail "no route") req $ \_ ->+ note "respond" >> return ResponseReceived+ -- the first route passes the request on, the second answers it+ readIORef events `shouldReturn` ["open", "close", "open", "respond", "close" :: String]++-- | Routes for the tests: the first matches any block but passes on+-- "best", for the second to answer.+routes :: [Route]+routes =+ [ get "/block/:block" ["notx"] $ \req -> do+ b <- capture req "block"+ if b == "best" then next else notx req >>= \n -> ok (T.unwords (["block", b] <> n)),+ get "/block/best" ["notx"] $ \req -> notx req >>= \n -> ok (T.unwords ("best" : n)),+ get "/sloppy" [] $ \req -> param req "secret" >>= ok . fromMaybe ""+ ]+ where+ notx req = maybe [] (\v -> ["notx=" <> v]) <$> param req "notx"+ ok t = return (responseLBS status200 [] (L8.pack (T.unpack t)))++-- | Status and body of the answer to a GET of a path with a query string.+call :: String -> IO (Int, String)+call url = do+ let (path, query) = break (== '?') url+ req =+ defaultRequest+ { requestMethod = methodGet,+ pathInfo = filter (not . T.null) (T.splitOn "/" (T.pack path)),+ queryString = parseQuery (L8.toStrict (L8.pack query))+ }+ out <- newIORef Nothing+ _ <- router routes miss req $ \res -> writeIORef out (Just res) >> return ResponseReceived+ readIORef out >>= \case+ Just (ResponseBuilder s _ b) -> return (statusCode s, L8.unpack (toLazyByteString b))+ _ -> fail "no response"+ where+ miss _ NoRoute = return (responseLBS status404 [] "no route")+ miss _ (Unknown ks) =+ return (responseLBS status404 [] (L8.pack ("unknown " <> T.unpack (T.intercalate "," ks))))
@@ -1,6 +1,3 @@-{-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-}- module Haskoin.Store.WebSpec (spec) where import Data.ByteString (ByteString)@@ -8,7 +5,7 @@ import Data.Maybe (fromJust) import Data.Text.Encoding (decodeUtf8) import Haskoin (Tx (..), bch, btc, decodeHex, txHash, txHashToHex)-import Haskoin.Store.Web (decodeTxBody)+import Haskoin.Store.Web (acceptsBinary, decodeTxBody) import Test.Hspec -- | A transaction written by hand, in the format without witness. It has@@ -35,7 +32,25 @@ \00000000" spec :: Spec-spec =+spec = do+ describe "acceptsBinary" $ do+ it "takes the binary type by itself" $+ acceptsBinary "application/octet-stream" `shouldBe` True+ it "takes it in a list, in any case and with spaces" $ do+ acceptsBinary "text/html, Application/Octet-Stream" `shouldBe` True+ acceptsBinary "application/octet-stream , */*" `shouldBe` True+ it "takes it with a weight above zero" $+ acceptsBinary "application/octet-stream;q=0.5" `shouldBe` True+ it "gives JSON to a wildcard, to nothing and to JSON" $ do+ acceptsBinary "*/*" `shouldBe` False+ acceptsBinary "" `shouldBe` False+ acceptsBinary "application/json" `shouldBe` False+ it "gives JSON when binary is refused with a weight of zero" $+ acceptsBinary "application/octet-stream;q=0, application/json" `shouldBe` False+ it "follows the weights when both are named" $ do+ acceptsBinary "application/json;q=0.9, application/octet-stream" `shouldBe` True+ acceptsBinary "application/json, application/octet-stream;q=0.9" `shouldBe` False+ acceptsBinary "application/json;q=0.5, application/octet-stream; q=0.5" `shouldBe` True describe "decodeTxBody" $ do -- The id is the double SHA-256 of the bytes of plainTxHex, reversed. It -- was computed outside this library. The witness is not part of the id,
@@ -1,17 +1,9 @@-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE ImportQualifiedPost #-}-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-}-{-# LANGUAGE NoFieldSelectors #-} module Haskoin.StoreSpec (spec) where import Conduit import Control.Monad-import Control.Monad.Reader import Data.ByteString (ByteString) import Data.ByteString qualified as B import Data.ByteString.Base64@@ -22,13 +14,14 @@ import Data.Time.Clock.POSIX import Data.Word import GHC.Stats (GCDetails (..), RTSStats (..), getRTSStats, getRTSStatsEnabled)-import System.Mem (performMajorGC) import Haskoin import Haskoin.Node import Haskoin.Store+import Haskoin.Store.Database.Query qualified as Q import Haskoin.Store.TestBlocks (nextBlock) import NQE import Network.Socket+import System.Mem (performMajorGC) import System.Random import Test.Hspec import UnliftIO@@ -51,14 +44,14 @@ StoreBestBlock b -> Just b _ -> Nothing let bestHash = last bs- bestNodeM <- liftIO $ chainGetBlock testStoreChain bestHash+ bestNodeM <- chainGetBlock testStoreChain bestHash bestNodeM `shouldSatisfy` isJust let bestNode = fromJust bestNodeM bestHeight = bestNode.height bestHeight `shouldBe` 8 it "get a block and its transactions" $ withTestStore bchRegTest ctx "get-block-txs" $ \TestStore {..} ->- flip runReaderT testStoreDB $ do+ do let h1 = "5369ef2386c72acdf513ffd80aeba2a1774e2f004d120761e54a8bf614173f3e" get_the_block h = receive testStoreEvents >>= \case@@ -68,20 +61,19 @@ get_the_block ((h :: Int) - 1) _ -> get_the_block h bh <- get_the_block 15- m <- getBlock bh+ m <- Q.getBlock testStoreDB bh let bd = fromMaybe (error "Could not get block") m- t1 <- getTransaction h1- lift $ do- bd.height `shouldBe` 15- length bd.txs `shouldBe` 1- listToMaybe bd.txs `shouldBe` Just h1- fmap (txHash . transactionData) t1 `shouldBe` Just h1+ t1 <- Q.getTxData testStoreDB h1+ bd.height `shouldBe` 15+ length bd.txs `shouldBe` 1+ listToMaybe bd.txs `shouldBe` Just h1+ fmap (txHash . (.tx)) t1 `shouldBe` Just h1 it "downloads 80 blocks with a request for 64 and one for the remaining 16" $ withTestStore bchRegTest ctx "block-window" $ \TestStore {..} -> do bs <- replicateM 80 . receiveMatch testStoreEvents $ \case StoreBestBlock b -> Just b _ -> Nothing- bestNodeM <- liftIO $ chainGetBlock testStoreChain (last bs)+ bestNodeM <- chainGetBlock testStoreChain (last bs) fmap (.height) bestNodeM `shouldBe` Just 80 readTVarIO testStoreBlockRequests `shouldReturn` [64, 16] @@ -117,23 +109,21 @@ growth `shouldSatisfy` (< 1024 * 1024) withTestStore ::- (MonadUnliftIO m) => Network -> Ctx -> String ->- (TestStore -> m a) ->- m a+ (TestStore -> IO a) ->+ IO a withTestStore = withTestStoreBlocks allBlocks -- | Like 'withTestStore', with the mock peer serving these blocks. withTestStoreBlocks ::- (MonadUnliftIO m) => [Block] -> Network -> Ctx -> String ->- (TestStore -> m a) ->- m a+ (TestStore -> IO a) ->+ IO a withTestStoreBlocks blocks net ctx t f = withSystemTempDirectory ("haskoin-store-test-" <> t <> "-") $ \w -> do requests <- newTVarIO []@@ -144,8 +134,10 @@ cfg = StoreConfig { maxPeers = 20,- initPeers = [],- discover = True,+ -- the mock peer, by name: since haskoin-node 2.5 the seed of+ -- a regtest network is not an address that discovery takes+ initPeers = ["127.0.0.1:18444"],+ discover = False, db = w, net = net, ctx = ctx,@@ -153,9 +145,11 @@ gap = gap, initGap = 20, redisMinAddrs = 100,- redisMaxKeys = 100 * 1000 * 1000,+ redisMinTxs = 1000,+ redisMaxXPubs = 10000, noMempool = False, wipeMempool = False,+ fleet = False, syncMempool = False, blockWindow = 64, mempoolTimeout = 14,@@ -163,7 +157,6 @@ maxPeerLife = 48 * 3600, connect = dummyPeerConnect blocks requests net ad, stats = Nothing,- redisSyncInterval = 30, bloom = True, maxOpenFiles = Nothing, writeBufferSize = Nothing,