haskoin-store 1.12.1 → 1.13.0
raw patch · 29 files changed
+1565/−760 lines, 29 filesPVP ok
version bump matches the API change (PVP)
API changes (from Hackage documentation)
- Haskoin.Store.Web: [publishTimeout] :: WebLimits -> !Word32
- Haskoin.Store.Web.Publish: Accepted :: Verdict
- Haskoin.Store.Web.Publish: Busy :: Answer
- Haskoin.Store.Web.Publish: Done :: !Verdict -> Slot
- Haskoin.Store.Web.Publish: Dropped :: Verdict
- Haskoin.Store.Web.Publish: Flying :: Slot
- Haskoin.Store.Web.Publish: Fresh :: Ticket
- Haskoin.Store.Web.Publish: Full :: Ticket
- Haskoin.Store.Web.Publish: Joined :: Ticket
- Haskoin.Store.Web.Publish: Known :: !Verdict -> Ticket
- Haskoin.Store.Web.Publish: Limits :: !NominalDiffTime -> !NominalDiffTime -> !Int -> !Int -> !NominalDiffTime -> Limits
- Haskoin.Store.Web.Publish: Silent :: Verdict
- Haskoin.Store.Web.Publish: Unreachable :: Verdict
- Haskoin.Store.Web.Publish: Verdict :: !Verdict -> Answer
- Haskoin.Store.Web.Publish: [capacity] :: Limits -> !Int
- Haskoin.Store.Web.Publish: [events] :: Publishing -> !Publisher StoreEvent
- Haskoin.Store.Web.Publish: [handover] :: Limits -> !NominalDiffTime
- Haskoin.Store.Web.Publish: [limits] :: Publishing -> !Limits
- Haskoin.Store.Web.Publish: [memory] :: Limits -> !NominalDiffTime
- Haskoin.Store.Web.Publish: [mempool] :: Publishing -> !Bool
- Haskoin.Store.Web.Publish: [nodes] :: Publishing -> !IO [SockAddr]
- Haskoin.Store.Web.Publish: [outbox] :: Publishing -> !TVar Outbox
- Haskoin.Store.Web.Publish: [present] :: Publishing -> !TxHash -> IO Bool
- Haskoin.Store.Web.Publish: [tries] :: Limits -> !Int
- Haskoin.Store.Web.Publish: [wait] :: Limits -> !NominalDiffTime
- Haskoin.Store.Web.Publish: data Limits
- Haskoin.Store.Web.Publish: data Outbox
- Haskoin.Store.Web.Publish: data Slot
- Haskoin.Store.Web.Publish: data Ticket
- Haskoin.Store.Web.Publish: data Verdict
- Haskoin.Store.Web.Publish: emptyOutbox :: Outbox
- Haskoin.Store.Web.Publish: enter :: Limits -> UTCTime -> Hash256 -> Outbox -> (Ticket, Outbox)
- Haskoin.Store.Web.Publish: instance GHC.Classes.Eq Haskoin.Store.Web.Publish.Limits
- Haskoin.Store.Web.Publish: instance GHC.Classes.Eq Haskoin.Store.Web.Publish.Outbox
- Haskoin.Store.Web.Publish: instance GHC.Classes.Eq Haskoin.Store.Web.Publish.Slot
- Haskoin.Store.Web.Publish: instance GHC.Classes.Eq Haskoin.Store.Web.Publish.Ticket
- Haskoin.Store.Web.Publish: instance GHC.Classes.Eq Haskoin.Store.Web.Publish.Verdict
- Haskoin.Store.Web.Publish: instance GHC.Internal.Show.Show Haskoin.Store.Web.Publish.Limits
- Haskoin.Store.Web.Publish: instance GHC.Internal.Show.Show Haskoin.Store.Web.Publish.Outbox
- Haskoin.Store.Web.Publish: instance GHC.Internal.Show.Show Haskoin.Store.Web.Publish.Slot
- Haskoin.Store.Web.Publish: instance GHC.Internal.Show.Show Haskoin.Store.Web.Publish.Ticket
- Haskoin.Store.Web.Publish: instance GHC.Internal.Show.Show Haskoin.Store.Web.Publish.Verdict
- Haskoin.Store.Web.Publish: settle :: UTCTime -> Hash256 -> Verdict -> Outbox -> Outbox
+ Haskoin.Store: [getReplaced] :: StoreRead -> TxHash -> IO (Maybe Replaced)
+ Haskoin.Store.Common: Replaced :: !TxHash -> !BlockHeight -> Replaced
+ Haskoin.Store.Common: [by] :: Replaced -> !TxHash
+ Haskoin.Store.Common: [getReplaced] :: StoreRead -> TxHash -> IO (Maybe Replaced)
+ Haskoin.Store.Common: [height] :: Replaced -> !BlockHeight
+ Haskoin.Store.Common: data Replaced
+ Haskoin.Store.Common: instance Data.Serialize.Serialize Haskoin.Store.Common.Replaced
+ Haskoin.Store.Common: instance GHC.Classes.Eq Haskoin.Store.Common.Replaced
+ Haskoin.Store.Common: instance GHC.Internal.Generics.Generic Haskoin.Store.Common.Replaced
+ Haskoin.Store.Common: instance GHC.Internal.Show.Show Haskoin.Store.Common.Replaced
+ Haskoin.Store.Common: replacedKept :: BlockHeight
+ Haskoin.Store.Database.Query: getReplaced :: DatabaseReader -> TxHash -> IO (Maybe Replaced)
+ Haskoin.Store.Database.Query: getReplacedAt :: DatabaseReader -> BlockHeight -> IO [TxHash]
+ Haskoin.Store.Database.Query.Pooled: [replaced] :: PoolReads -> TxHash -> IO (Maybe Replaced)
+ Haskoin.Store.Database.Types: ReplacedAtKey :: !BlockHeight -> !TxHash -> ReplacedAtKey
+ Haskoin.Store.Database.Types: ReplacedAtKeyH :: !BlockHeight -> ReplacedAtKey
+ Haskoin.Store.Database.Types: ReplacedKey :: !TxHash -> ReplacedKey
+ Haskoin.Store.Database.Types: data ReplacedAtKey
+ Haskoin.Store.Database.Types: data ReplacedKey
+ Haskoin.Store.Database.Types: instance Data.Hashable.Class.Hashable Haskoin.Store.Database.Types.ReplacedAtKey
+ Haskoin.Store.Database.Types: instance Data.Hashable.Class.Hashable Haskoin.Store.Database.Types.ReplacedKey
+ Haskoin.Store.Database.Types: instance Data.Serialize.Serialize Haskoin.Store.Database.Types.ReplacedAtKey
+ Haskoin.Store.Database.Types: instance Data.Serialize.Serialize Haskoin.Store.Database.Types.ReplacedKey
+ Haskoin.Store.Database.Types: instance Database.RocksDB.Query.Key Haskoin.Store.Database.Types.ReplacedAtKey
+ Haskoin.Store.Database.Types: instance Database.RocksDB.Query.Key Haskoin.Store.Database.Types.ReplacedKey
+ Haskoin.Store.Database.Types: instance Database.RocksDB.Query.KeyValue Haskoin.Store.Database.Types.ReplacedAtKey ()
+ Haskoin.Store.Database.Types: instance Database.RocksDB.Query.KeyValue Haskoin.Store.Database.Types.ReplacedKey Haskoin.Store.Common.Replaced
+ Haskoin.Store.Database.Types: instance GHC.Classes.Eq Haskoin.Store.Database.Types.ReplacedAtKey
+ Haskoin.Store.Database.Types: instance GHC.Classes.Eq Haskoin.Store.Database.Types.ReplacedKey
+ Haskoin.Store.Database.Types: instance GHC.Classes.Ord Haskoin.Store.Database.Types.ReplacedAtKey
+ Haskoin.Store.Database.Types: instance GHC.Classes.Ord Haskoin.Store.Database.Types.ReplacedKey
+ Haskoin.Store.Database.Types: instance GHC.Internal.Generics.Generic Haskoin.Store.Database.Types.ReplacedAtKey
+ Haskoin.Store.Database.Types: instance GHC.Internal.Generics.Generic Haskoin.Store.Database.Types.ReplacedKey
+ Haskoin.Store.Database.Types: instance GHC.Internal.Read.Read Haskoin.Store.Database.Types.ReplacedAtKey
+ Haskoin.Store.Database.Types: instance GHC.Internal.Read.Read Haskoin.Store.Database.Types.ReplacedKey
+ Haskoin.Store.Database.Types: instance GHC.Internal.Show.Show Haskoin.Store.Database.Types.ReplacedAtKey
+ Haskoin.Store.Database.Types: instance GHC.Internal.Show.Show Haskoin.Store.Database.Types.ReplacedKey
+ Haskoin.Store.Database.Writer: deleteReplaced :: Writer -> TxHash -> IO ()
+ Haskoin.Store.Database.Writer: getReplacedAt :: Writer -> BlockHeight -> IO [TxHash]
+ Haskoin.Store.Database.Writer: insertReplaced :: Writer -> TxHash -> Replaced -> IO ()
+ Haskoin.Store.Fleet.Pool: advanceReplacing :: (Ord t, Ord o) => (o -> t) -> Change t o -> (o -> Maybe w) -> [(t, t, w)] -> Map t (t, w) -> Pool t o -> (Pool t o, [(t, w)], Map t (t, w))
+ Haskoin.Store.Fleet.Redis: [height] :: PoolChange -> !Maybe Word32
+ Haskoin.Store.Fleet.Redis: [lost] :: PoolChange -> ![ByteString]
+ Haskoin.Store.Logic: revertBlockAt :: Writer -> UnixTime -> BlockHash -> IO ()
+ Haskoin.Store.Web: instance GHC.Internal.Exception.Type.Exception Haskoin.Store.Web.Gone
+ Haskoin.Store.Web: instance GHC.Internal.Show.Show Haskoin.Store.Web.Gone
+ Haskoin.Store.Web.Publish: BadTokens :: !Int -> Refusal
+ Haskoin.Store.Web.Publish: BadVersion :: !Word32 -> Refusal
+ Haskoin.Store.Web.Publish: InBlock :: Answer
+ Haskoin.Store.Web.Publish: NoPeers :: Answer
+ Haskoin.Store.Web.Publish: NotMinimal :: !Int -> Refusal
+ Haskoin.Store.Web.Publish: NotPushOnly :: !Int -> Refusal
+ Haskoin.Store.Web.Publish: ToNode :: Answer
+ Haskoin.Store.Web.Publish: ToPeers :: !Int -> Answer
+ Haskoin.Store.Web.Publish: TokensUnbacked :: !String -> Refusal
+ Haskoin.Store.Web.Publish: [fetch] :: Publishing -> !TxHash -> IO ()
+ Haskoin.Store.Web.Publish: [peers] :: Publishing -> !IO [SockAddr]
+ Haskoin.Store.Web.Publish: askAgain :: [NominalDiffTime]
+ Haskoin.Store.Web.Publish: askFor :: Asking -> TxHash -> IO ()
+ Haskoin.Store.Web.Publish: askLoop :: Asking -> (TxHash -> IO Bool) -> (TxHash -> IO ()) -> IO ()
+ Haskoin.Store.Web.Publish: data Asking
+ Haskoin.Store.Web.Publish: due :: UTCTime -> HashMap TxHash (UTCTime, [NominalDiffTime]) -> ([TxHash], HashMap TxHash (UTCTime, [NominalDiffTime]))
+ Haskoin.Store.Web.Publish: newAsking :: IO Asking
+ Haskoin.Store.Web.Publish: tokenRules :: Tx -> [TxOut] -> Either Refusal ()
- Haskoin.Store: StoreRead :: !Network -> !Ctx -> IO (Maybe BlockHash) -> (BlockHeight -> IO [BlockHash]) -> (BlockHash -> IO (Maybe BlockData)) -> (TxHash -> IO (Maybe TxData)) -> ([TxHash] -> IO [Maybe TxData]) -> (OutPoint -> IO (Maybe Spender)) -> (Address -> IO (Maybe Balance)) -> (OutPoint -> IO (Maybe Unspent)) -> IO [(UnixTime, TxHash)] -> (Limits -> IO [(UnixTime, TxHash)]) -> ([Address] -> Limits -> IO [TxRef]) -> ([Address] -> Limits -> IO [Unspent]) -> !Word32 -> !Word32 -> (Word64 -> IO [TxData]) -> ([Address] -> IO [Balance]) -> (Address -> Limits -> IO [TxRef]) -> (Address -> Limits -> IO [Unspent]) -> (XPubSpec -> IO [XPubBal]) -> (XPubSpec -> [XPubBal] -> Limits -> IO [XPubUnspent]) -> (XPubSpec -> [XPubBal] -> Limits -> IO [TxRef]) -> (XPubSpec -> [XPubBal] -> IO Word32) -> ([Address] -> IO Word32) -> StoreRead
+ Haskoin.Store: StoreRead :: !Network -> !Ctx -> IO (Maybe BlockHash) -> (BlockHeight -> IO [BlockHash]) -> (BlockHash -> IO (Maybe BlockData)) -> (TxHash -> IO (Maybe TxData)) -> ([TxHash] -> IO [Maybe TxData]) -> (OutPoint -> IO (Maybe Spender)) -> (Address -> IO (Maybe Balance)) -> (OutPoint -> IO (Maybe Unspent)) -> IO [(UnixTime, TxHash)] -> (Limits -> IO [(UnixTime, TxHash)]) -> ([Address] -> Limits -> IO [TxRef]) -> ([Address] -> Limits -> IO [Unspent]) -> !Word32 -> !Word32 -> (Word64 -> IO [TxData]) -> ([Address] -> IO [Balance]) -> (Address -> Limits -> IO [TxRef]) -> (Address -> Limits -> IO [Unspent]) -> (XPubSpec -> IO [XPubBal]) -> (XPubSpec -> [XPubBal] -> Limits -> IO [XPubUnspent]) -> (XPubSpec -> [XPubBal] -> Limits -> IO [TxRef]) -> (XPubSpec -> [XPubBal] -> IO Word32) -> ([Address] -> IO Word32) -> (TxHash -> IO (Maybe Replaced)) -> StoreRead
- Haskoin.Store.Common: StoreRead :: !Network -> !Ctx -> IO (Maybe BlockHash) -> (BlockHeight -> IO [BlockHash]) -> (BlockHash -> IO (Maybe BlockData)) -> (TxHash -> IO (Maybe TxData)) -> ([TxHash] -> IO [Maybe TxData]) -> (OutPoint -> IO (Maybe Spender)) -> (Address -> IO (Maybe Balance)) -> (OutPoint -> IO (Maybe Unspent)) -> IO [(UnixTime, TxHash)] -> (Limits -> IO [(UnixTime, TxHash)]) -> ([Address] -> Limits -> IO [TxRef]) -> ([Address] -> Limits -> IO [Unspent]) -> !Word32 -> !Word32 -> (Word64 -> IO [TxData]) -> ([Address] -> IO [Balance]) -> (Address -> Limits -> IO [TxRef]) -> (Address -> Limits -> IO [Unspent]) -> (XPubSpec -> IO [XPubBal]) -> (XPubSpec -> [XPubBal] -> Limits -> IO [XPubUnspent]) -> (XPubSpec -> [XPubBal] -> Limits -> IO [TxRef]) -> (XPubSpec -> [XPubBal] -> IO Word32) -> ([Address] -> IO Word32) -> StoreRead
+ Haskoin.Store.Common: StoreRead :: !Network -> !Ctx -> IO (Maybe BlockHash) -> (BlockHeight -> IO [BlockHash]) -> (BlockHash -> IO (Maybe BlockData)) -> (TxHash -> IO (Maybe TxData)) -> ([TxHash] -> IO [Maybe TxData]) -> (OutPoint -> IO (Maybe Spender)) -> (Address -> IO (Maybe Balance)) -> (OutPoint -> IO (Maybe Unspent)) -> IO [(UnixTime, TxHash)] -> (Limits -> IO [(UnixTime, TxHash)]) -> ([Address] -> Limits -> IO [TxRef]) -> ([Address] -> Limits -> IO [Unspent]) -> !Word32 -> !Word32 -> (Word64 -> IO [TxData]) -> ([Address] -> IO [Balance]) -> (Address -> Limits -> IO [TxRef]) -> (Address -> Limits -> IO [Unspent]) -> (XPubSpec -> IO [XPubBal]) -> (XPubSpec -> [XPubBal] -> Limits -> IO [XPubUnspent]) -> (XPubSpec -> [XPubBal] -> Limits -> IO [TxRef]) -> (XPubSpec -> [XPubBal] -> IO Word32) -> ([Address] -> IO Word32) -> (TxHash -> IO (Maybe Replaced)) -> StoreRead
- Haskoin.Store.Database.Query.Pooled: PoolReads :: ([Address] -> IO [Maybe MemSummary]) -> (Address -> Maybe (UnixTime, TxHash) -> Int -> IO [(UnixTime, TxHash)]) -> (Address -> Maybe (UnixTime, OutPoint) -> Int -> IO [(Unspent, Bool)]) -> (Address -> [OutPoint] -> IO [Bool]) -> ([TxHash] -> IO [(Maybe TxData, IntMap Spender)]) -> ([OutPoint] -> IO [Maybe Spender]) -> (Word32 -> Word32 -> IO [(UnixTime, TxHash)]) -> ((Word32, Word16) -> IO [TxHash]) -> PoolReads
+ Haskoin.Store.Database.Query.Pooled: PoolReads :: ([Address] -> IO [Maybe MemSummary]) -> (Address -> Maybe (UnixTime, TxHash) -> Int -> IO [(UnixTime, TxHash)]) -> (Address -> Maybe (UnixTime, OutPoint) -> Int -> IO [(Unspent, Bool)]) -> (Address -> [OutPoint] -> IO [Bool]) -> ([TxHash] -> IO [(Maybe TxData, IntMap Spender)]) -> ([OutPoint] -> IO [Maybe Spender]) -> (Word32 -> Word32 -> IO [(UnixTime, TxHash)]) -> ((Word32, Word16) -> IO [TxHash]) -> (TxHash -> IO (Maybe Replaced)) -> PoolReads
- Haskoin.Store.Fleet.Redis: PoolChange :: ![PoolTx] -> ![ByteString] -> ![ByteString] -> ![ByteString] -> PoolChange
+ Haskoin.Store.Fleet.Redis: PoolChange :: ![PoolTx] -> ![ByteString] -> ![ByteString] -> ![ByteString] -> !Maybe Word32 -> ![ByteString] -> PoolChange
- Haskoin.Store.Logic.Layered: revertBlock :: Writer -> BlockHash -> IO ()
+ Haskoin.Store.Logic.Layered: revertBlock :: Writer -> UnixTime -> BlockHash -> IO ()
- Haskoin.Store.Web: WebLimits :: !Word32 -> !Word32 -> !Word32 -> !Word32 -> !Word32 -> !Word32 -> !Word64 -> !Word64 -> !Word32 -> !Word32 -> WebLimits
+ Haskoin.Store.Web: WebLimits :: !Word32 -> !Word32 -> !Word32 -> !Word32 -> !Word32 -> !Word32 -> !Word64 -> !Word64 -> !Word32 -> WebLimits
- Haskoin.Store.Web.Publish: Publishing :: !Limits -> !TVar Outbox -> !IO Rules -> !Tx -> IO Direct -> !TxHash -> IO Held -> !OutPoint -> IO Coin -> !TxHash -> IO Bool -> !Publisher StoreEvent -> !Bool -> !IO [SockAddr] -> !SockAddr -> Tx -> IO Delivery -> !LogLevel -> Text -> IO () -> Publishing
+ Haskoin.Store.Web.Publish: Publishing :: !IO Rules -> !Tx -> IO Direct -> !TxHash -> IO Held -> !OutPoint -> IO Coin -> !IO [SockAddr] -> !SockAddr -> Tx -> IO Delivery -> !TxHash -> IO () -> !LogLevel -> Text -> IO () -> Publishing
- Haskoin.Store.Web.Publish: Refused :: !Text -> Verdict
+ Haskoin.Store.Web.Publish: Refused :: !Text -> Answer
- Haskoin.Store.Web.Publish: Unverified :: !Int -> Verdict
+ Haskoin.Store.Web.Publish: Unverified :: !Int -> Answer
- Haskoin.Store.Web.Publish: [net] :: Courier -> !Network
+ Haskoin.Store.Web.Publish: [net] :: Rules -> !Network
Files
- CHANGELOG.md +56/−0
- README.md +34/−39
- app/Main.hs +3/−10
- haskoin-store.cabal +1/−1
- src/Haskoin/Store/BlockStore.hs +2/−1
- src/Haskoin/Store/Common.hs +44/−2
- src/Haskoin/Store/Database/Query.hs +15/−1
- src/Haskoin/Store/Database/Query/Layered.hs +4/−2
- src/Haskoin/Store/Database/Query/Pooled.hs +13/−3
- src/Haskoin/Store/Database/Types.hs +47/−0
- src/Haskoin/Store/Database/Writer.hs +52/−2
- src/Haskoin/Store/Fleet/Member.hs +23/−4
- src/Haskoin/Store/Fleet/Pool.hs +48/−2
- src/Haskoin/Store/Fleet/Redis.hs +19/−6
- src/Haskoin/Store/Fleet/XPub.hs +2/−1
- src/Haskoin/Store/Fleet/lua/pool.lua +95/−16
- src/Haskoin/Store/Fleet/lua/read.lua +7/−0
- src/Haskoin/Store/Logic.hs +14/−2
- src/Haskoin/Store/Logic/Layered.hs +30/−13
- src/Haskoin/Store/Web.hs +110/−50
- src/Haskoin/Store/Web/Publish.hs +334/−325
- swagger.yaml +71/−36
- test/Haskoin/Store/Database/MigrateSpec.hs +5/−4
- test/Haskoin/Store/Fleet/MemberSpec.hs +2/−2
- test/Haskoin/Store/Fleet/RedisSpec.hs +55/−12
- test/Haskoin/Store/Histories.hs +7/−7
- test/Haskoin/Store/IntegrationSpec.hs +219/−56
- test/Haskoin/Store/Logic/LayeredSpec.hs +54/−8
- test/Haskoin/Store/Web/PublishSpec.hs +199/−155
CHANGELOG.md view
@@ -4,6 +4,62 @@ 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) +## [1.13.0] - 2026-11-13++Transactions are handed over at once, a payment that a block replaced is+answered 410, and what a reorganisation puts back stays in the mempool.++### Added+- **A transaction that a block replaced is answered 410, Gone**, at+ `/transaction/:txid` and its raw form, with the transaction of the block+ that won (`replaced-by`) and its height, for 2016 blocks. It was 404, as+ for a transaction that never was, and a payment that a reorganisation+ replaced simply vanished. A store keeps it with the block and deletes it+ with its revert. The stores of a fleet keep it in the shared pool, as+ the fleet tip moves past the block, and answer from that alike: a+ store's own record depends on its own mempool. A transaction replaced in+ the mempool is still 404. The client of haskoin-store-data reports the+ 410 as an error it does not know, until that package learns it.++### Changed+- **`POST /transactions` answers 202, "handed over", at once.** The store+ checks the transaction, hands it to every peer, each on a connection of+ its own, and answers as soon as they took it, with the id and a message.+ It no longer waits up to `--publish-timeout` for a node to announce the+ transaction back: the wait was arbitrary, and said less than it seemed,+ as Bitcoin Core refuses in silence. The transaction shows in the store+ when a node announces it, usually within a second. A transaction that the+ store has in a block is still answered 200, and one that a node refuses+ over RPC, or that every peer refuses with a reason, 400. The client of+ haskoin-store-data takes the new answer as it took the old one.+- With `--node-rpc`, a transaction that the node takes is answered 202 too.+- In the library, `Haskoin.Store.Web.Publish` answers with what was done+ (`Answer`: `ToPeers`, `ToNode`, `InBlock` and refusals) in place of+ `Verdict`, and has no `Limits` or `Outbox`. `Haskoin.Store.Logic.Layered`'s+ `revertBlock` takes the time of the revert, and `Haskoin.Store.Logic` has+ `revertBlockAt`, which the tests compare it with.++### Removed+- **`--publish-timeout`** (`PUBLISH_TIMEOUT`), and the outbox that kept+ failed transactions from being sent again for ten minutes.++### Fixed+- **Transactions that cost the store its connections are refused.** On+ regtest, a signature pushed in a longer form than it needs, and more+ CashTokens out than in, both passed the store's checks, and Bitcoin Cash+ Node closed every connection of the store when it was sent one. The+ store now refuses an input script that does more than push data or+ pushes it in a longer form than it needs, tokens that break the rules of+ CashTokens, and a version that nodes do not take: 1 and 2 on Bitcoin+ Cash, 1 to 3 on Bitcoin. `docs/publishing.md` has what was measured.+- **A transaction that a reorganisation puts back in the mempool stays+ there.** It went back with the time of its block, and the store deletes+ mempool transactions older than `--mempool-timeout` (14 days) on every+ clock tick: a transaction of a block older than that was gone in under a+ second, while the nodes kept it. On a regtest chain whose blocks are+ older than the timeout, every reorganisation did it. It now goes back+ with the time of the revert.+ ## [1.12.1] - 2026-11-13 ### Fixed
README.md view
@@ -227,15 +227,14 @@ curl -X POST --data-binary @tx.hex localhost:3000/transactions ``` -The body is the signed transaction in hex. The store hands it to one full-node and answers with the transaction id once a node has announced it back-and the store has it: the wallet's history shows it on the next request.+The body is the signed transaction in hex. The store checks it, hands it to+its peers and answers 202 with the transaction id at once. When a node+announces it, usually within a second, the wallet's history shows it. A transaction that cannot be valid, or that a node refuses with a reason, is-answered 400 with the reason. A 500 means that the store could not find out:-post the same transaction again, which is safe. A transaction that the store-lists as unconfirmed is given to a node again when it is posted, in case the-network forgot it.+answered 400 with the reason. Posting the same transaction again is safe: it+is handed over again, which gets a payment that the network forgot back to+it. [Publishing](#publishing) has the rest. ### 6. Stay up to date without polling@@ -345,8 +344,7 @@ | `--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) | | `--slow-request` | `SLOW_REQUEST` | `5` | Seconds a request may take before the log warns of it (0 = never) |-| `--publish-timeout` | `PUBLISH_TIMEOUT` | `30` | Seconds that `POST /transactions` waits for a node to announce the transaction back |-| `--node-rpc` | `NODE_RPC` | none | JSON-RPC URL of a full node, such as `http://user:password@127.0.0.1:8332/`, to publish transactions through; without it, or when it does not answer, they go to a peer |+| `--node-rpc` | `NODE_RPC` | none | JSON-RPC URL of a full node, such as `http://user:password@127.0.0.1:8332/`, to publish transactions through; without it, or when it does not answer, they go to the peers | | `--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 |@@ -578,41 +576,37 @@ The store does not check scripts, so a transaction that a client posts never goes into its mempool directly: anyone could show a payment that will-never confirm. It is handed to one full node, and comes in when a node-announces it back.+never confirm. It is handed over, and comes in when a node announces it. -- **A connection of its own.** The transaction goes to a node on a- connection made for it, not on those the store syncs from. A node does- not announce a transaction to the connection that gave it. On the others- the store hears of it as of any transaction, and imports it the same way.-- **The answer waits for that**, up to `--publish-timeout` seconds. A 200- means that a full node accepted the transaction and the store has it.-- **The store checks what it can first**: that the inputs exist and are- not spent, that a coinbase is old enough, the amounts, the lock time,- the size and the dust rule of relay, and the signature of every coin- that is locked to one key, on both networks. A transaction that fails- is refused with the reason, and nothing is sent. A script of any other- kind is left to the node.+- **Checked first**: that the inputs exist and are not spent, that a+ coinbase is old enough, the amounts, the lock time, the version, that+ the input scripts only push data in its shortest form, the tokens of+ Bitcoin Cash, the size and the dust rule of relay, and the signature of+ every coin that is locked to one key. A transaction that fails is+ refused with the reason, and nothing is sent.+- **Handed to every peer, each on a connection of its own**, and answered+ 202 as soon as they took it. The store does not wait to see what the+ network makes of it. A node does not announce a transaction to the+ connection that gave it; on those the store syncs from it hears of it as+ of any transaction, and imports it the same way. - **Over the RPC of a node, when the store has it.** With `--node-rpc`- the store gives the transaction to a full node over its RPC. The node- checks everything and says why it refuses. If the node does not- answer, the transaction goes to a peer instead.+ the store gives the transaction to a full node over its RPC instead. The+ node checks everything and says why it refuses. If the node does not+ answer, the transaction goes to the peers. - **To a peer, only what the store verified.** A peer that is sent a- transaction it finds invalid may close every connection of the store.- So a transaction with an input that the store cannot verify, a script- that is not a key or a multisig, is refused unless there is an RPC to- send it over. Give the store the RPC of a node to publish those.-- **The same transaction is sent once**, however often it is posted.+ transaction it finds invalid may close every connection of the store,+ and Bitcoin Cash Node does. So a transaction with an input that the store+ cannot verify, a script that is not a key or a multisig, is refused+ unless there is an RPC to send it over; and so are the transactions that+ were measured to cost the store its connections. - **Whitelist the store on your nodes** (`-whitelist` in Bitcoin Core and Bitcoin Cash Node). A node announces to a whitelisted peer without- delay, so the answer takes milliseconds; otherwise Bitcoin Core takes- five seconds on average. And a node that finds a transaction invalid may- close every connection of the peer that sent it, which it never does to- a whitelisted one. The store then connects again, does not send that- transaction a second time, and goes on publishing the others.+ delay, so the transaction shows in the store at once; otherwise Bitcoin+ Core takes five seconds on average. And a node never closes the+ connections of a whitelisted peer. `docs/publishing.md` has the design, what was measured on Bitcoin Core and-Bitcoin Cash Node, and how it differs from versions before 1.11.0.+Bitcoin Cash Node, and how it differs from earlier versions. ### The database @@ -627,7 +621,8 @@ changes the chain's data. - **A transaction that leaves the mempool unconfirmed is forgotten**: it was replaced, double-spent by a block, or too old. The API answers 404 for- it.+ it, or 410, with the transaction that won, for one that a block replaced+ in the last 2016 blocks. - **Each request reads one snapshot** of the database, so everything in an answer is of the same moment. @@ -743,7 +738,7 @@ | `Haskoin.Store.Fleet.Member`, `Haskoin.Store.Fleet.XPub` | A fleet, and its cache | | `Haskoin.Store.Web`, `Haskoin.Store.Web.Events` | The web server, and `/events` | | `Haskoin.Store.Web.Pieces` | Answers that are sent while they are read |-| `Haskoin.Store.Web.Publish` | Publishing a transaction through a full node |+| `Haskoin.Store.Web.Publish` | Checking a transaction and handing it to the peers, or to a full node over RPC | The rules and queries of version 20 (`Haskoin.Store.Logic`, `Haskoin.Store.Database.Query`) stay in the library as a reference. The
app/Main.hs view
@@ -272,8 +272,6 @@ env "TX_TIMEOUT" d.txTimeout readMaybe slowRequest <- env "SLOW_REQUEST" d.slowRequest readMaybe- publishTimeout <-- env "PUBLISH_TIMEOUT" d.publishTimeout readMaybe return WebLimits {..} -- | Names of the supported networks, separated by pipes.@@ -442,13 +440,6 @@ <> helpEnv "SLOW_REQUEST" "Warn in the log of requests that take this long (0 = never)" <> showDefault <> value c.webLimits.slowRequest- publishTimeout <-- option auto $- metavar "SECONDS"- <> long "publish-timeout"- <> helpEnv "PUBLISH_TIMEOUT" "Wait this long for a published transaction to come back from a node"- <> showDefault- <> value c.webLimits.publishTimeout return WebLimits {..} redis <- flag c.redis True $@@ -615,7 +606,9 @@ strOption $ metavar "URL" <> long "node-rpc"- <> helpEnv "NODE_RPC" "Publish transactions over the JSON-RPC of this full node, and through a peer when it fails"+ <> helpEnv+ "NODE_RPC"+ "Publish transactions over the JSON-RPC of this full node, and through the peers when it fails" <> value c.nodeRpc tlsCert <- strOption $
haskoin-store.cabal view
@@ -5,7 +5,7 @@ -- see: https://github.com/sol/hpack name: haskoin-store-version: 1.12.1+version: 1.13.0 synopsis: Storage and index for Bitcoin and Bitcoin Cash 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
src/Haskoin/Store/BlockStore.hs view
@@ -895,7 +895,8 @@ logWarn self $ "Reverting best block: " <> blockHashToHex h- runImport self net ctx (`revertBlock` h) >>= \case+ now <- floor . utcTimeToPOSIXSeconds <$> getCurrentTime+ runImport self net ctx (\wr -> revertBlock wr now h) >>= \case Left e -> do logError self $ "Could not revert block "
src/Haskoin/Store/Common.hs view
@@ -31,6 +31,8 @@ Start (..), StoreRead (..), StoreEvent (..),+ Replaced (..),+ replacedKept, getActiveBlock, getActiveTxData, notDeleted,@@ -84,7 +86,8 @@ import Data.Map.Strict qualified as Map import Data.Maybe (catMaybes, fromMaybe, mapMaybe) import Data.Ord (Down (..))-import Data.Serialize (encode)+import Data.Serialize (Serialize (..), encode)+import GHC.Generics (Generic) import Data.Time.Clock.System ( getSystemTime, systemNanoseconds,@@ -228,8 +231,47 @@ xPubTxCount :: XPubSpec -> [XPubBal] -> IO Word32, -- | number of distinct transactions of a set of addresses, counted -- without reading them- getAddressesTxCount :: [Address] -> IO Word32+ getAddressesTxCount :: [Address] -> IO Word32,+ -- | what is kept of a transaction that a block of the main chain+ -- replaced, if it did in the last 'replacedKept' blocks+ getReplaced :: TxHash -> IO (Maybe Replaced) }++-- | What the store keeps of a mempool transaction that a block replaced:+-- the block confirmed a transaction that spends an output that it spent,+-- or that one of its unconfirmed ancestors spent.+--+-- The store keeps nothing else of a transaction that leaves the mempool+-- unconfirmed, and a wallet that asked for it got a 404, as for a+-- transaction that never was: it could not tell a payment that lost to+-- another from one that it mistyped. With this it is told which+-- transaction won, and in which block.+--+-- A store keeps it with the chain: written when the block is imported and+-- deleted when it is reverted, for 'replacedKept' blocks. That depends on+-- the mempool that the block found as well, which is each store's own, so+-- the stores of a fleet answer from what the shared pool keeps instead+-- ("Haskoin.Store.Fleet.Pool".'Haskoin.Store.Fleet.Pool.advanceReplacing'),+-- which is the same for all of them.+data Replaced = Replaced+ { -- | the transaction of the block that spends what it, or an ancestor,+ -- spent+ by :: !TxHash,+ -- | the height of the block+ height :: !BlockHeight+ }+ deriving (Eq, Show, Generic)++instance Serialize Replaced where+ put r = put r.by >> put r.height+ get = Replaced <$> get <*> get++-- | Blocks for which a 'Replaced' is kept: 2016, two weeks of blocks of ten+-- minutes, as long as a mempool keeps a transaction by default+-- (@--mempool-timeout@). A wallet that has not looked for two weeks finds+-- the transaction unknown.+replacedKept :: BlockHeight+replacedKept = 2016 -- | Data of a block, or 'Nothing' if it is unknown or not in the main chain. getActiveBlock :: StoreRead -> BlockHash -> IO (Maybe BlockData)
src/Haskoin/Store/Database/Query.hs view
@@ -35,6 +35,8 @@ getBestBlock, getBlocksAtHeight, getBlock,+ getReplaced,+ getReplacedAt, -- * Transactions getTxData,@@ -140,6 +142,7 @@ ) import Haskoin.Store.Common ( Limits (..),+ Replaced, Start (..), StoreRead (..), applyLimitsC,@@ -196,7 +199,8 @@ xPubUnspents = \_ -> xPubUnspents r, xPubTxs = \_ -> xPubTxs r, xPubTxCount = \_ -> xPubTxCount r,- getAddressesTxCount = getAddressesTxCount r+ getAddressesTxCount = getAddressesTxCount r,+ getReplaced = getReplaced r } -- | Count something in the metrics, if they are enabled.@@ -221,6 +225,16 @@ Just ls -> do count r $ \s -> incrementCounter s.dataBlockCount (length ls) return ls++-- | What is kept of a transaction that a block replaced ('Replaced').+getReplaced :: DatabaseReader -> TxHash -> IO (Maybe Replaced)+getReplaced r h = retrieveCF r.db (blockCF r.db) (ReplacedKey h)++-- | The transactions that the block at a height replaced.+getReplacedAt :: DatabaseReader -> BlockHeight -> IO [TxHash]+getReplacedAt r h = do+ rows <- matchingAsListCF r.db (heightCF r.db) (ReplacedAtKeyH h)+ return [t | (ReplacedAtKey _ t, ()) <- rows] -- | Data of a block. getBlock :: DatabaseReader -> BlockHash -> IO (Maybe BlockData)
src/Haskoin/Store/Database/Query/Layered.hs view
@@ -260,7 +260,8 @@ xPubUnspents = \_ -> xPubUnspents v, xPubTxs = \_ -> xPubTxs v, xPubTxCount = \_ -> xPubTxCount v,- getAddressesTxCount = getAddressesTxCount v+ getAddressesTxCount = getAddressesTxCount v,+ getReplaced = Q.getReplaced r } -- | The queries of a database as a t'StoreRead', each on a view of its@@ -295,7 +296,8 @@ 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)+ getAddressesTxCount = \as -> one (\q -> q.getAddressesTxCount as),+ getReplaced = \h -> one (\q -> q.getReplaced h) } where one :: (StoreRead -> IO a) -> IO a
src/Haskoin/Store/Database/Query/Pooled.hs view
@@ -84,6 +84,7 @@ ) import Haskoin.Store.Common ( Limits (..),+ Replaced, Start (..), StoreRead (..), applyLimitsC,@@ -160,7 +161,10 @@ 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]+ startingWith :: (Word32, Word16) -> IO [TxHash],+ -- | what the pool keeps of a transaction that a block replaced as the+ -- tip moved past it ('Haskoin.Store.Fleet.Pool.advanceReplacing')+ replaced :: TxHash -> IO (Maybe Replaced) } -- | The queries of a store that answers from the chain as a reader has it,@@ -194,7 +198,11 @@ xPubUnspents = \_ -> xPubUnspents r pool, xPubTxs = \_ xbals -> getAddressesTxs r pool (Q.usedAddresses xbals), xPubTxCount = \_ xbals -> getAddressesTxCount r pool (Q.usedAddresses xbals),- getAddressesTxCount = getAddressesTxCount r pool+ getAddressesTxCount = getAddressesTxCount r pool,+ -- from the pool, which keeps what the blocks replaced as the tip+ -- moves past them, the same for every store: not from the store's+ -- own database, which has what its own mempool lost+ getReplaced = pool.replaced } where only = \case@@ -511,7 +519,9 @@ 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]+ return [h | (TxKey h, _ :: TxData) <- rows],+ -- what the blocks replaced, as the store's own database keeps it+ replaced = Q.getReplaced r } where entries :: Address -> IO [(MemAddrKey, MemAddrVal)]
src/Haskoin/Store/Database/Types.hs view
@@ -88,6 +88,8 @@ MemSummary (..), MemDeletedKey (..), MigrationKey (..),+ ReplacedKey (..),+ ReplacedAtKey (..), SpenderKey (..), SpenderVal (..), spenderGroup,@@ -127,6 +129,7 @@ getWord32be, getWord64be, getWord8,+ putWord32be, putWord64be, putWord8, runGet,@@ -146,6 +149,7 @@ scriptToAddressBS, ) import Haskoin.Crypto+import Haskoin.Store.Common (Replaced) import Haskoin.Store.Data ( Balance (..), BlockData,@@ -641,6 +645,49 @@ instance Key MigrationKey instance KeyValue MigrationKey Word32++-- | Key of what is kept of a mempool transaction that a block replaced+-- ('Replaced'), in the @block@ column family, since haskoin-store 1.13.0.+-- It is part of the chain: written and deleted only by the import and the+-- revert of blocks.+data ReplacedKey = ReplacedKey !TxHash+ deriving (Show, Read, Eq, Ord, Generic, Hashable)++instance Serialize ReplacedKey where+ -- 0x11 · TxHash+ put (ReplacedKey h) = putWord8 0x11 >> put h+ get = do+ guard . (== 0x11) =<< getWord8+ ReplacedKey <$> get++instance Key ReplacedKey++instance KeyValue ReplacedKey Replaced++-- | The transactions replaced by the block at a height, in the @height@+-- column family, which the revert of the block forgets, and which are+-- forgotten 'Haskoin.Store.Common.replacedKept' blocks later. The height comes first, written+-- big-endian, so that a scan of one height finds them all. The column+-- family has no prefix extractor, which a scan by five bytes needs.+data ReplacedAtKey+ = -- | a transaction replaced at a height+ ReplacedAtKey !BlockHeight !TxHash+ | -- | short key that matches the transactions replaced at a height+ ReplacedAtKeyH !BlockHeight+ deriving (Show, Read, Eq, Ord, Generic, Hashable)++instance Serialize ReplacedAtKey where+ -- 0x12 · height · TxHash+ put (ReplacedAtKey h t) = put (ReplacedAtKeyH h) >> put t+ -- 0x12 · height+ put (ReplacedAtKeyH h) = putWord8 0x12 >> putWord32be h+ get = do+ guard . (== 0x12) =<< getWord8+ ReplacedAtKey <$> getWord32be <*> get++instance Key ReplacedAtKey++instance KeyValue ReplacedAtKey () -- | Key of the inputs of blocks that spend some outputs of a transaction, -- in the @spender@ column family, since database version 22: the
src/Haskoin/Store/Database/Writer.hs view
@@ -91,6 +91,11 @@ insertMempoolAddr, deleteMempoolAddr, getMempoolAddr,++ -- * Transactions that blocks replaced+ insertReplaced,+ deleteReplaced,+ getReplacedAt, ) where @@ -125,6 +130,7 @@ txHash, ) import Haskoin.Node (Logger)+import Haskoin.Store.Common (Replaced (..)) import Haskoin.Store.Data import Haskoin.Store.Database.Mempool qualified as M import Haskoin.Store.Database.Query qualified as Q@@ -197,6 +203,10 @@ -- | Changed entries of the mempool by address: 'Nothing' for a deletion. type MemAddrTable = H.BasicHashTable MemAddrKey (Maybe MemAddrVal) +-- | Changed records of transactions that blocks replaced: 'Nothing' for a+-- deletion.+type ReplacedTable = H.BasicHashTable TxHash (Maybe Replaced)+ -- | Changes made in a batch, not yet written to the database. data Memory = Memory { -- | network constants@@ -230,7 +240,9 @@ -- | 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+ spenderTable :: !SpenderTable,+ -- | what is kept of transactions that blocks replaced+ replacedTable :: !ReplacedTable } -- | Set the best block.@@ -388,7 +400,8 @@ memTxOps db memTxTable, memSpenderOps db memSpenderTable, memAddrOps db memAddrTable,- spenderOps db spenderTable+ spenderOps db spenderTable,+ replacedOps db replacedTable ] -- | Operation for the change of the best block, if any.@@ -406,6 +419,23 @@ f k (Just d) = insertOpCF (blockCF db) (BlockKey k) d f k Nothing = deleteOpCF (blockCF db) (BlockKey k) +-- | Operations for changed records of replaced transactions: the record,+-- and its entry at the height of its block. A record that is deleted, or+-- kept again at another height, takes its old entry with it.+replacedOps :: DB -> ReplacedTable -> IO [BatchOp]+replacedOps db t = concat <$> (H.toList t >>= mapM f)+ where+ f (h, new) = do+ old <- retrieveCF db (blockCF db) (ReplacedKey h) :: IO (Maybe Replaced)+ let forget =+ [ deleteOpCF (heightCF db) (ReplacedAtKey o.height h)+ | Just o <- [old],+ fmap (.height) new /= Just o.height+ ]+ return . (forget <>) $ case new of+ Just r -> [insertOpCF (blockCF db) (ReplacedKey h) r, insertOpCF (heightCF db) (ReplacedAtKey r.height h) ()]+ Nothing -> [deleteOpCF (blockCF db) (ReplacedKey h)]+ -- | Operations for changed block heights. blockHeightOps :: DB -> HeightTable -> IO [BatchOp] blockHeightOps db t = map (uncurry f) <$> (H.toList t)@@ -627,6 +657,25 @@ kept = IntMap.filterWithKey (\i _ -> (fromIntegral i `div` spenderGroup) `notElem` replaced) stored return (IntMap.unions (kept : map snd changed)) +-- | Keep what a block replaced.+insertReplaced :: Writer -> TxHash -> Replaced -> IO ()+insertReplaced w h r = H.insert w.memory.replacedTable h (Just r)++-- | Forget what was kept of a replaced transaction.+deleteReplaced :: Writer -> TxHash -> IO ()+deleteReplaced w h = H.insert w.memory.replacedTable h Nothing++-- | The transactions that the block at a height replaced, with the changes+-- of the batch applied.+getReplacedAt :: Writer -> BlockHeight -> IO [TxHash]+getReplacedAt w height = do+ stored <- Q.getReplacedAt w.reader height+ changes <- H.toList w.memory.replacedTable+ let changed = HashMap.fromList changes+ kept = filter (not . (`HashMap.member` changed)) stored+ added = [h | (h, Just r) <- changes, r.height == height]+ return (kept <> added)+ -- | The mempool in the database with the changes of this batch applied, -- newest first. getMempool :: Writer -> IO [(UnixTime, TxHash)]@@ -657,6 +706,7 @@ memSpenderTable <- liftIO H.new memAddrTable <- liftIO H.new spenderTable <- liftIO H.new+ replacedTable <- liftIO H.new return Memory {..} -- | Read the best block from the changes of the batch: 'Nothing' if unchanged, @Just Nothing@ if deleted.
src/Haskoin/Store/Fleet/Member.hs view
@@ -112,7 +112,7 @@ import Data.List (nub, sortOn) import Data.Map.Strict qualified as Map import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, mapMaybe)-import Data.Serialize (Serialize, decode, encode)+import Data.Serialize (Serialize, decode, encode, get, getWord32be, putWord32be, runGet, runPut) import Data.Text (Text) import Data.Text qualified as T import Data.Text.Encoding (decodeUtf8)@@ -134,7 +134,7 @@ txHash, ) import Haskoin.Node (LogLevel (..))-import Haskoin.Store.Common (Limits (..), StoreRead (..))+import Haskoin.Store.Common (Limits (..), Replaced (..), StoreRead (..)) import Haskoin.Store.Data ( Balance (..), BlockData (..),@@ -642,12 +642,27 @@ Right added -> do old <- concat <$> mapM (bodies view) (reverse abandoned) new <- concat <$> mapM (confirmedBy r) (reverse added)+ -- those of the abandoned blocks that this store has as+ -- replaced: every store had them confirmed, and says the same+ let named h rep = encode h <> encode rep.by <> runPut (putWord32be rep.height)+ lostOnes <- fmap catMaybes . forM [h | (h, Nothing) <- old] $ \h ->+ fmap (named h) <$> Q.getReplaced r h 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]+ -- with what spends each output and where, for what+ -- the blocks replaced+ spent =+ [ encode i.outpoint <> encode (txHash td.tx) <> runPut (putWord32be h)+ | td <- new,+ BlockRef {height = h} <- [td.block],+ i <- td.tx.inputs,+ i.outpoint /= nullOutPoint+ ],+ height = Just bd.height,+ lost = lostOnes } let path = map (headerHash . (.header)) (reverse added) return (Way Between {pool, added = new, reorganised = not (null abandoned), path})@@ -1121,7 +1136,11 @@ 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))]+ startingWith = \start -> rights . map decode <$> ask "ids" [B.drop 1 (encode (TxKeyS start))],+ replaced = \h ->+ ask "replaced" [encode h] >>= \case+ [value] | Right (by, height) <- runGet ((,) <$> get <*> getWord32be) value -> return (Just Replaced {..})+ _ -> return Nothing } where ask what args = poolRead conn keys 32 (Just tip) what args >>= maybe (throwIO TipMoved) (<$ checked)
src/Haskoin/Store/Fleet/Pool.hs view
@@ -73,6 +73,7 @@ -- * Following the tip Change (..), advance,+ advanceReplacing, -- * What the pool holds for each address Entry (..),@@ -213,7 +214,40 @@ -- 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+advance maker change pool = let (p, _, _) = advanceReplacing maker change (const Nothing) [] Map.empty pool in p++-- | 'advance', and what the new blocks replaced. Given a label for each+-- output that they spend, transactions of the abandoned blocks that the+-- store has as replaced, each with the transaction that replaced it and a+-- label, and those of earlier moves that still wait for theirs. Gives the+-- pool, what was replaced, each with its label, and what still waits.+--+-- * A transaction of the pool that leaves because it, or an unconfirmed+-- ancestor of it, spent an output that the new blocks spend, was+-- replaced, with the label of the output. The outputs are taken in+-- order, and one that left for two of them has the label of the first.+--+-- * A transaction of an abandoned block that the store has as replaced is+-- not in the pool and does not come back to it: the store has imported+-- the new blocks by then, and deleted it. Every store had it confirmed,+-- so every store says the same of it. It was replaced once the tip+-- reaches the block that confirms the transaction that replaced it, which+-- may be in this move or in a later one: the tip may stop at the fork+-- before it goes on.+--+-- The store labels with the transaction that won and the height of its+-- block, and the pool keeps them for a while, so that a wallet that asks+-- for a transaction that lost is told what won.+advanceReplacing ::+ (Ord t, Ord o) =>+ (o -> t) ->+ Change t o ->+ (o -> Maybe w) ->+ [(t, t, w)] ->+ Map t (t, w) ->+ Pool t o ->+ (Pool t o, [(t, w)], Map t (t, w))+advanceReplacing maker change label lost waiting pool0 = (final, now <> clashed, still) where back = foldl' comeBack pool0 change.returned -- a transaction that was confirmed has the better claim to what it@@ -225,7 +259,19 @@ 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 waits for a transaction that the new blocks confirm was replaced+ confirmed = Set.fromList change.confirmed+ waitingNow = foldl' (\m (t, by, w) -> Map.insert t (by, w) m) waiting lost+ (reached, still) = Map.partition (\(by, _) -> Set.member by confirmed) waitingNow+ now = [(t, w) | (t, (_, w)) <- Map.toList reached]+ -- what spends an output that the blocks spend leaves, with all that+ -- spends it+ (final, clashed) = foldl' clash (settled, []) change.spent+ clash (p, replaced) o = case Map.lookup o p.spender of+ Nothing -> (p, replaced)+ Just s ->+ let leaving = Set.toList (descendants [s] p)+ in (withdraw [s] p, replaced <> maybe [] (\w -> [(t, w) | t <- leaving]) (label o)) -- | What a transaction does at an address, for addresses of type @a@: the -- entries by address of "Haskoin.Store.Database.Mempool", which a store
src/Haskoin/Store/Fleet/Redis.hs view
@@ -88,7 +88,7 @@ import Data.ByteString.Char8 qualified as C import Data.FileEmbed (embedFile, makeRelativeToProject) import Data.Maybe (fromMaybe)-import Data.Word (Word64)+import Data.Word (Word32, Word64) import Database.Redis (Connection, Redis, Reply) import Database.Redis qualified as R import Haskoin.Store.Fleet (Time)@@ -218,8 +218,18 @@ gone :: ![ByteString], -- | transactions that the new blocks confirm confirmed :: ![ByteString],- -- | outputs that the new blocks spend- spent :: ![ByteString]+ -- | outputs that the new blocks spend, each followed by the transaction+ -- of the block that spends it and the height of that block in four+ -- bytes, big-endian: what replaces a transaction of the pool that+ -- spends it ('Haskoin.Store.Fleet.Pool.advanceReplacing')+ spent :: ![ByteString],+ -- | the height of the block that the tip moves to, by which what was+ -- replaced 2016 blocks before is forgotten, if it is known+ height :: !(Maybe Word32),+ -- | transactions of the abandoned blocks that the store has as+ -- replaced, each followed like an output of 'spent' by what replaced+ -- it: kept as replaced once the tip reaches it+ lost :: ![ByteString] } deriving (Eq, Show) @@ -279,7 +289,9 @@ [ByteString] -> IO Moved fleetAdvance conn keys tsize clock seen target known =- advance conn keys tsize clock seen target (maybe "1" (const "0") known) (fromMaybe (PoolChange [] [] [] []) known)+ advance conn keys tsize clock seen target (maybe "1" (const "0") known) (fromMaybe unchanged known)+ where+ unchanged = PoolChange [] [] [] [] Nothing [] -- | Move the tip to a block and empty the pool, for a store that has both -- the tip and the block and finds the way between them longer than it@@ -294,7 +306,7 @@ -- count of 'fleetRestarts' went up. fleetRestart :: Connection -> FleetKeys -> Int -> Maybe Time -> Maybe ByteString -> ByteString -> IO Moved fleetRestart conn keys tsize clock seen target =- advance conn keys tsize clock seen target "2" (PoolChange [] [] [] []) []+ advance conn keys tsize clock seen target "2" (PoolChange [] [] [] [] Nothing []) [] -- | How many times the pool was emptied to move the tip. A store that sees -- another number than at its last turn offers all of its mempool again:@@ -325,7 +337,8 @@ where args = ["advance", keys.prefix, num tsize, maybe "" id seen, target, maybe "" num clock, num keys.size, how]- <> [B.concat change.gone, B.concat change.confirmed, B.concat change.spent]+ <> [maybe "" num change.height]+ <> [B.concat change.gone, B.concat change.confirmed, B.concat change.spent, B.concat change.lost] <> [num (length change.returned)] <> concatMap txArgs change.returned <> [B.concat path]
src/Haskoin/Store/Fleet/XPub.hs view
@@ -212,7 +212,8 @@ bodies = return . map (const (Nothing, IntMap.empty)), spenders = return . map (const Nothing), page = \_ _ -> return [],- startingWith = \_ -> return []+ startingWith = \_ -> return [],+ replaced = \_ -> return Nothing } -------------------------------------------------------------------------------
src/Haskoin/Store/Fleet/lua/pool.lua view
@@ -26,12 +26,26 @@ -- -- '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'}.+-- bytes, '1' to empty the pool instead of changing it or '0', the height of+-- the block to move to or '', the transactions that are gone, those that+-- are confirmed, the outputs that are spent, each followed by the+-- transaction of the block that spends it and the height of that block in+-- four bytes, big-endian, the transactions of the abandoned blocks that the+-- store has as replaced, each followed in the same way, 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'}. --+-- What the new blocks replaced is kept: each transaction of the pool that+-- leaves because it, or an ancestor, spent an output that a transaction of+-- the blocks spends, with that transaction and the height of its block.+-- A transaction of the abandoned blocks that the store names as replaced+-- waits until a move confirms the transaction that replaced it, and is+-- kept then. What is kept is forgotten when the transaction that replaced+-- it comes back from an abandoned block; it, and what waits, are forgotten+-- 2016 blocks after their height, by the height of the block that the tip+-- moves to. Haskoin.Store.Fleet.Pool.advanceReplacing says the same.+-- -- The pool is emptied instead of changed in two cases. With '1' the store -- cannot say what the blocks between the two tips did, because it does -- not have the old tip: only when no other store is live, and {'raced'}@@ -91,6 +105,12 @@ aval = prefix .. 'pool:aval:', aspent = prefix .. 'pool:aspent:', asum = prefix .. 'pool:asum:',+ rep = prefix .. 'pool:rep',+ repby = prefix .. 'pool:repby:',+ repat = prefix .. 'pool:repat',+ lostval = prefix .. 'pool:lost',+ lostby = prefix .. 'pool:lostby:',+ lostat = prefix .. 'pool:lostat', of = prefix .. 'fx:of:', entry = prefix .. 'fx:e:' }@@ -319,6 +339,30 @@ return withdraw(split(ARGV[4], tsize)) end +-- forget that these transactions were replaced+local function unreplaced(ts)+ for _, t in ipairs(ts) do+ local value = redis.call('HGET', key.rep, t)+ if value then+ redis.call('SREM', key.repby .. string.sub(value, 1, tsize), t)+ redis.call('HDEL', key.rep, t)+ redis.call('ZREM', key.repat, t)+ end+ end+end++-- keep that a transaction was replaced: by a transaction, which comes+-- first in the value, at a height, which follows in four bytes. What was+-- kept of it before goes, with its place among what its winner replaced.+local function replaced(t, value)+ local by, h = string.sub(value, 1, tsize), string.sub(value, tsize + 1, tsize + 4)+ local b1, b2, b3, b4 = string.byte(h, 1, 4)+ unreplaced({t})+ redis.call('HSET', key.rep, t, value)+ redis.call('SADD', key.repby .. by, t)+ redis.call('ZADD', key.repat, ((b1 * 256 + b2) * 256 + b3) * 256 + b4, t)+end+ local tip = redis.call('GET', key.tip) or '' if tip ~= ARGV[4] then return {'raced'}@@ -432,11 +476,13 @@ end -- the abandoned blocks: what can come back does, and what spends the rest--- leaves-local at = 13-for _ = 1, tonumber(ARGV[12]) do+-- leaves. What a transaction that comes back replaced is not replaced any+-- more.+local at = 15+for _ = 1, tonumber(ARGV[14]) do local tx tx, at = read(at)+ unreplaced(redis.call('SMEMBERS', key.repby .. tx.id)) if not member(tx.id) then -- it was confirmed: what spends the same in the pool gives way local rivals = {}@@ -450,8 +496,21 @@ insert(tx) end end+-- what the store has as replaced of the abandoned blocks waits for the+-- transaction that replaced it+for _, item in ipairs(split(ARGV[13], 2 * tsize + 4)) do+ local t, value = string.sub(item, 1, tsize), string.sub(item, tsize + 1)+ local old = redis.call('HGET', key.lostval, t)+ if old then+ redis.call('SREM', key.lostby .. string.sub(old, 1, tsize), t)+ end+ local b1, b2, b3, b4 = string.byte(string.sub(value, tsize + 1, tsize + 4), 1, 4)+ redis.call('HSET', key.lostval, t, value)+ redis.call('SADD', key.lostby .. string.sub(value, 1, tsize), t)+ redis.call('ZADD', key.lostat, ((b1 * 256 + b2) * 256 + b3) * 256 + b4, t)+end local orphaned = {}-for _, t in ipairs(split(ARGV[9], tsize)) do+for _, t in ipairs(split(ARGV[10], tsize)) do for _, kid in ipairs(redis.call('SMEMBERS', key.kids .. t)) do orphaned[#orphaned + 1] = kid end@@ -459,18 +518,38 @@ 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+-- spend leaves with all that spends it, and is kept as replaced+for _, t in ipairs(split(ARGV[11], 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)+-- what waited for a transaction that they confirm was replaced+for _, t in ipairs(split(ARGV[11], tsize)) do+ for _, waiting in ipairs(redis.call('SMEMBERS', key.lostby .. t)) do+ replaced(waiting, redis.call('HGET', key.lostval, waiting))+ redis.call('HDEL', key.lostval, waiting)+ redis.call('ZREM', key.lostat, waiting)+ end+ redis.call('DEL', key.lostby .. t)+end+for _, item in ipairs(split(ARGV[12], osize + tsize + 4)) do+ local s = redis.call('HGET', key.spender, string.sub(item, 1, osize)) if s then- clashing[#clashing + 1] = s+ for _, t in ipairs(withdraw({s})) do+ replaced(t, string.sub(item, osize + 1))+ end end end-withdraw(clashing)+-- forget what was replaced, or waits, 2016 blocks before the block of the+-- tip+if ARGV[9] ~= '' and tonumber(ARGV[9]) >= 2016 then+ local limit = tonumber(ARGV[9]) - 2016+ unreplaced(redis.call('ZRANGEBYSCORE', key.repat, '-inf', limit))+ for _, t in ipairs(redis.call('ZRANGEBYSCORE', key.lostat, '-inf', limit)) do+ redis.call('SREM', key.lostby .. string.sub(redis.call('HGET', key.lostval, t), 1, tsize), t)+ redis.call('HDEL', key.lostval, t)+ redis.call('ZREM', key.lostat, t)+ end+end redis.call('SET', key.tip, target) redis.call('INCR', key.moves)
src/Haskoin/Store/Fleet/lua/read.lua view
@@ -34,6 +34,9 @@ -- the newest, an id and a time for each. -- -- 'ids': some bytes. The transactions whose id starts with them.+--+-- 'replaced': transactions. For each, the transaction of a block that+-- replaced it and the height of the block in four bytes, or ''. 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@@ -167,6 +170,10 @@ '[' .. ARGV[5], '(' .. ARGV[5] .. string.rep(string.char(255), tsize)) for _, t in ipairs(found) do answer[#answer + 1] = t+ end+elseif what == 'replaced' then+ for i = 5, #ARGV do+ answer[#answer + 1] = redis.call('HGET', prefix .. 'pool:rep', ARGV[i]) or '' end end return answer
src/Haskoin/Store/Logic.hs view
@@ -59,6 +59,7 @@ ( ImportException (..), initBest, revertBlock,+ revertBlockAt, importBlock, newMempoolTx, deleteUnconfirmedTx,@@ -236,7 +237,18 @@ -- main chain, move its transactions back to the mempool, and delete its -- coinbase. Throws 'BlockNotBest' if the block is not the best block. revertBlock :: Writer -> BlockHash -> IO ()-revertBlock wr bh = do+revertBlock wr = revertWith wr id++-- | 'revertBlock', with the transactions put back at a time, the time of+-- the revert, as the layered layout puts them back+-- ("Haskoin.Store.Logic.Layered"): the tests compare the two.+revertBlockAt :: Writer -> UnixTime -> BlockHash -> IO ()+revertBlockAt wr now = revertWith wr (\td -> (td :: TxData) {timestamp = now})++-- | Revert the best block, changing each transaction that goes back to the+-- mempool first.+revertWith :: Writer -> (TxData -> TxData) -> BlockHash -> IO ()+revertWith wr restamp bh = do bd <- bestBlockData wr >>= \b -> if headerHash b.header == bh@@ -257,7 +269,7 @@ insertBlock wr bd {main = False} logDebug wr $ "Updated as not in main chain: " <> blockHashToHex bh- forM_ (drop 1 tds) (unConfirmTx wr)+ forM_ (drop 1 tds) (unConfirmTx wr . restamp) logDebug wr $ "Unconfirmed " <> cs (show (length tds)) <> " transactions" deleteConfirmedTx wr (txHash (tds !! 0).tx)
src/Haskoin/Store/Logic/Layered.hs view
@@ -109,7 +109,7 @@ txHashToHex, ) import Haskoin.Node (LogLevel (..), logAt)-import Haskoin.Store.Common (nub', sortTxs)+import Haskoin.Store.Common (Replaced (..), nub', replacedKept, sortTxs) import Haskoin.Store.Data import Haskoin.Store.Database.Mempool (MemRecords (..), mempoolRecords) import Haskoin.Store.Database.Types (MemAddrKey (..), MemAddrVal (..), MemSummary (..))@@ -252,6 +252,9 @@ setBlocksAtHeight wr (nub (headerHash n.header : bs)) n.height setBest wr (headerHash n.header) tds <- importTxs wr n b.txs+ -- forget what a block replaced 'replacedKept' blocks ago+ when (n.height >= replacedKept) $+ getReplacedAt wr (n.height - replacedKept) >>= mapM_ (deleteReplaced wr) let bd = BlockData { height = n.height,@@ -279,7 +282,9 @@ in fromIntegral $ s * 3 + x -- | Import the transactions of a block, in dependency order, after deleting--- the mempool transactions that conflict with them.+-- the mempool transactions that conflict with them. What is kept of each+-- one deleted ('Replaced') names the transaction of the block that it+-- conflicts with, or that its unconfirmed ancestor conflicts with. importTxs :: Writer -> BlockNode -> [Tx] -> IO [TxData] importTxs wr bn txns = do mapM_ (freeOutputs . snd) (reverse txs)@@ -288,7 +293,10 @@ 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)+ freeOutputs tx =+ getConflicts wr True False tx >>= mapM_ (\gone -> do+ deleteSingleTx wr (txHash gone)+ insertReplaced wr (txHash gone) Replaced {by = txHash tx, height = bn.height}) action i tx = getMempoolTx wr (txHash tx) >>= \case Just td -> do@@ -357,10 +365,19 @@ -- | 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+-- the mempool at a time, the time of the revert, and delete its coinbase+-- with whatever spends it. Throws 'BlockNotBest' if the block is not the+-- best block.+--+-- The transactions go back with the time that they are put back, not that+-- of their block. The mempool deletes what is older than its timeout+-- (@--mempool-timeout@), by that time: with the time of the block, a+-- transaction of a block older than the timeout was deleted as soon as it+-- came back, while the nodes kept it. Stores of a fleet give it the same+-- time all the same, as the shared mempool keeps the time of the first+-- store that offers it.+revertBlock :: Writer -> UnixTime -> BlockHash -> IO ()+revertBlock wr now bh = do bd <- bestBlockData wr >>= \b -> if headerHash b.header == bh@@ -371,6 +388,8 @@ tds <- mapM inChain bd.txs setBest wr bd.header.prev insertBlock wr bd {main = False}+ -- what the block replaced is not replaced any more+ getReplacedAt wr bd.height >>= mapM_ (deleteReplaced wr) case tds of [] -> return () coinbase : rest -> do@@ -380,14 +399,12 @@ 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+ -- into the mempool, at the time of the revert. 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}+ addMempoolTx wr now (td :: TxData) {rbf = rbf, timestamp = now} updateSpendersRBF wr td.tx -- a coinbase cannot be in the mempool: it is deleted, and so is -- everything that spends it
src/Haskoin/Store/Web.hs view
@@ -233,11 +233,13 @@ parseQuery, renderStdMethod, status200,+ status202, status203, status204, status400, status404, status409,+ status410, status413, status500, status503,@@ -300,6 +302,7 @@ catch, catchAny, checkSTM,+ Exception, fromException, handleAny, link,@@ -347,17 +350,13 @@ blockTimeout :: !Word64, -- | seconds that a request may take before the log has a warning -- about it ('logIt')- slowRequest :: !Word32,- -- | seconds that @POST \/transactions@ waits for a node to announce- -- the transaction back ("Haskoin.Store.Web.Publish")- publishTimeout :: !Word32+ slowRequest :: !Word32 } 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,--- block timeout of four hours, a warning for requests of five seconds, and--- thirty seconds for a published transaction to come back.+-- block timeout of four hours, and a warning for requests of five seconds. instance Default WebLimits where def = WebLimits@@ -369,8 +368,7 @@ xpubGapInit = 20, txTimeout = 3600 `div` 2, blockTimeout = 4 * 3600,- slowRequest = 5,- publishTimeout = 30+ slowRequest = 5 } -- | Configuration of the web server.@@ -446,9 +444,9 @@ cacheUp :: !(TVar Bool), -- | the events that the clients of @\/events@ read ('feedEvents') eventLog :: !EventLog,- -- | the transactions being published, and those that failed not long- -- ago ("Haskoin.Store.Web.Publish")- outbox :: !(TVar Publish.Outbox)+ -- | the transactions that were handed over and that the store asks+ -- for until it has them ("Haskoin.Store.Web.Publish")+ asking :: !Publish.Asking } -- | StatsD metrics of the web server: a timing for each kind of request,@@ -674,7 +672,7 @@ forM_ config.store.fleet $ \member -> atomically (writeTVar member.healthy (isOK first)) cacheUp <- cacheCheck >>= newTVarIO eventLog <- newEventLog- outbox <- newTVarIO Publish.emptyOutbox+ asking <- Publish.newAsking 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))@@ -749,9 +747,10 @@ withAsync (priceUpdater session ticker) $ \_a1 -> withAsync (healthCheckLoop health cacheUp saidWell saidRedis pinged newBlock) $ \_a2 -> withAsync (feedEvents st) $ \a3 ->- link a3 >> do- let logged = logIt config.logger config.accessLog config.limits.slowRequest- serve . webSocketEvents st . logged . anyOrigin . preflight . autohead . compressed . cappedBody st $ app+ withAsync (askingLoop st) $ \_a4 ->+ link a3 >> do+ let logged = logIt config.logger config.accessLog config.limits.slowRequest+ serve . webSocketEvents st . logged . anyOrigin . preflight . autohead . compressed . cappedBody st $ app where priceUpdater session = unless config.noBlockchainInfo@@ -869,10 +868,47 @@ | Just Next <- fromException e = throwIO Next | Just TipMoved <- fromException e = throwIO TipMoved | Just x <- fromException e = errorResponse st x+ | Just g <- fromException e = goneResponse st g | otherwise = do logError st.config.logger $ fmtReq req.wai <> ": " <> cs (show e) errorResponse st ServerError +-- | A transaction that a block replaced ('Replaced'), asked for by its id:+-- answered 410, Gone, with the transaction that replaced it and the height+-- of its block, where one that never was is 404.+--+-- The errors of the API are those of haskoin-store-data, whose client+-- reads them. This one is not among them yet, so it is answered here, in+-- the same form: the client takes it for an error it does not know.+data Gone = Gone !TxHash !Replaced+ deriving (Show)++instance Exception Gone++-- | The answer to 'Gone', counted with the errors of clients.+goneResponse :: WebState -> Gone -> IO Response+goneResponse st (Gone txid r) = do+ forM_ st.metrics $ \m -> incrementCounter m.clientErrors 1+ return . responseLBS status410 [(hContentType, "application/json")] . A.encode $+ A.object+ [ "error" .= ("replaced" :: Text),+ "message"+ .= T.unwords+ [ "Transaction",+ txHashToHex txid,+ "was replaced by",+ txHashToHex r.by,+ "in the block at height",+ T.pack (show r.height)+ ],+ "replaced-by" .= r.by,+ "height" .= r.height+ ]++-- | Not found: 'Gone' if a block replaced the transaction, else 404.+notFoundTx :: WebState -> TxHash -> IO a+notFoundTx st txid = st.query.getReplaced txid >>= maybe (raise st ThingNotFound) (throwIO . Gone txid)+ -- | 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@@ -1028,7 +1064,7 @@ 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),+ pathPosted [] (\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),@@ -1204,6 +1240,19 @@ toProxy = const Proxy proxy = toProxy parser +-- | The route of an API resource whose handler chooses the status of its+-- answer: @POST \/transactions@, which answers 202 for a transaction that+-- it handed over.+pathPosted :: (ApiResource a b) => [Text] -> (Req -> IO a) -> (a -> IO (Status, Encoding)) -> Route+pathPosted ps parser action =+ route (renderStdMethod (resourceMethod proxy)) (capturePath proxy) ps $ \req -> do+ (status, res) <- action =<< parser req+ return $ responseLBS status jsonType (encodingToLazyByteString res)+ where+ toProxy :: (Req -> IO a) -> Proxy a+ toProxy = const Proxy+ proxy = toProxy parser+ -- | The route of an API resource that is answered while it is read, as -- "Haskoin.Store.Web.Pieces" explains: the handler gives the bytes of the -- response in pieces.@@ -1427,7 +1476,7 @@ handleTx :: WebState -> GetTx -> IO Transaction handleTx st (GetTx txid) = withMetrics st (.tx) $- C.getTransaction st.query txid >>= maybe (raise st ThingNotFound) return+ C.getTransaction st.query txid >>= maybe (notFoundTx st txid) return -- | Handler for @GET \/transactions?txids=…@: several transactions. handleTxs ::@@ -1447,7 +1496,7 @@ withMetrics st (.txRaw) $ C.getTransaction st.query txid >>= \case- Nothing -> raise st ThingNotFound+ Nothing -> notFoundTx st txid Just tx -> return $ RawResult (transactionData tx) -- | Handler for @GET \/transactions\/raw?txids=…@: several transactions, serialized.@@ -1528,27 +1577,36 @@ -- POST Transaction -- --- | Handler for @POST \/transactions@: hand the transaction to a full node,--- and answer with its hash once a node has announced it back--- ("Haskoin.Store.Web.Publish"). One that the store has unconfirmed is--- handed to a node again, and answered once the node took it. What the--- store or a node refuses is 400 with the reason; no node, no answer and--- no announcement are 500.-handlePostTx :: WebState -> PostTx -> IO TxId+-- | Handler for @POST \/transactions@: check the transaction and hand it+-- over ("Haskoin.Store.Web.Publish"). The answer is 202 with its id and a+-- message once it was handed over, over the RPC of a node or to the peers,+-- and 200 with its id for one that the store has in a block. What the+-- store or a node refuses is 400 with the reason; no peer is 500.+handlePostTx :: WebState -> PostTx -> IO (Status, Encoding) handlePostTx st (PostTx tx) = withMetrics st (.txPost) $ Publish.publish (publishing st) tx >>= \case- Publish.Verdict Publish.Accepted -> return (TxId (txHash tx))+ Publish.InBlock -> return (status200, toEncoding (TxId txid))+ Publish.ToNode -> handed "A full node took the transaction."+ Publish.ToPeers n ->+ handed . T.unwords $+ [ "Handed over to",+ T.pack (show n),+ if n == 1 then "peer." else "peers.",+ "The network decides whether to take it."+ ] Publish.Invalid why -> raise st (UserError (show why))- Publish.Verdict (Publish.Refused why) ->+ Publish.Refused why -> raise st (UserError ("A node refused the transaction: " <> T.unpack why))- Publish.Verdict (Publish.Unverified i) ->+ Publish.Unverified i -> raise st . UserError $ "This server cannot verify the signature of input " <> show i <> ", and sends to its peers only what it has verified"- Publish.Verdict Publish.Silent -> raise st ServerTimeout- _ -> raise st ServerError+ Publish.NoPeers -> raise st ServerError+ where+ txid = txHash tx+ handed message = return (status202, A.pairs ("txid" .= txid <> "message" .= (message :: Text))) -- | The chain as the store has it, for the checks of a transaction to -- publish: the height of the best block, and the median of the times of@@ -1586,7 +1644,7 @@ -- It is an option because the store needs nothing of a node but to be -- its peer, and this needs an account on one. A node that does not -- answer, or answers what cannot be read, is 'Publish.Unavailable': the--- transaction then goes to a peer, if the store verified it. A+-- transaction then goes to the peers, if the store verified it. A -- transaction that the node has already was sent. sendNode :: WebState -> String -> Tx -> IO Publish.Direct sendNode st url tx =@@ -1623,31 +1681,17 @@ -- | What "Haskoin.Store.Web.Publish" needs, for a request. ----- The nodes are the peers of the store that relay transactions, in the--- order of the peer manager.+-- The peers are those of the store that take transactions. A transaction+-- that was handed over is asked for again after the answer+-- ('askingLoop'), unless the store keeps no mempool. publishing :: WebState -> Publish.Publishing publishing st = Publish.Publishing- { limits =- Publish.Limits- { wait = fromIntegral st.config.limits.publishTimeout,- handover = 10,- tries = 2,- capacity = 1000,- memory = 600- },- outbox = st.outbox,- rules = chainRules st,+ { rules = chainRules st, direct = maybe (\_ -> return Publish.Unavailable) (sendNode st) st.config.nodeRpc, holds = fmap heldOf . st.query.getTxData, coin = Publish.coinOf st.query,- present = \h -> L.withView own.db $ \v ->- maybe False (not . (.deleted)) <$> (L.layeredRead v).getTxData h,- events = own.events,- mempool = not st.config.noMempool,- nodes =- map (.address) . filter (maybe False (.relay) . (.version))- <$> getPeers own.peerMgr,+ peers = map (.address) . filter (maybe False (.relay) . (.version)) <$> getPeers own.peerMgr, courier = Publish.deliver Publish.Courier@@ -1656,6 +1700,7 @@ patience = 10, logger = withSource "Courier" st.config.logger },+ fetch = if st.config.noMempool then \_ -> return () else Publish.askFor st.asking, note = \level -> logAt level (withSource "Publish" st.config.logger) } where@@ -1666,6 +1711,21 @@ | confirmed td.block -> Publish.Confirmed | otherwise -> Publish.Pending Nothing -> Publish.Absent++-- | Ask for the transactions that were handed over until the store has+-- them ("Haskoin.Store.Web.Publish".'Publish.Asking'): as if each peer had+-- announced them, so that the block store asks one of them. A failure is+-- logged and the asking goes on.+askingLoop :: WebState -> IO ()+askingLoop st = forever $ do+ tryAny (Publish.askLoop st.asking present ask) >>= \case+ Left e -> logWarn (withSource "Publish" st.config.logger) ("Asking for transactions: " <> cs (show e))+ Right () -> return ()+ threadDelay 1000000+ where+ own = st.config.store+ present h = L.withView own.db $ \v -> maybe False (not . (.deleted)) <$> (L.layeredRead v).getTxData h+ ask h = getPeers own.peerMgr >>= mapM_ (\op -> blockStoreTxHash op.mailbox [h] own.block) -- GET Mempool / Events --
src/Haskoin/Store/Web/Publish.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE DuplicateRecordFields #-}+{-# LANGUAGE MultiWayIf #-} {-# LANGUAGE OverloadedRecordDot #-} {-# OPTIONS_GHC -Wno-ambiguous-fields #-} @@ -12,158 +13,125 @@ -- -- Publishing a transaction that a client posted: @POST \/transactions@. ----- = The problem------ The store does not run scripts. It can check the signature of a coin--- locked to one key, and cannot tell whether a script of another kind is--- satisfied, so it cannot tell every valid transaction from a forged one.--- Two things follow. It must not put a--- transaction from a client into its mempool, or anyone could show a--- payment that will never confirm. And it must not send one over the--- connections it syncs from: a node that finds a transaction invalid may--- drop every connection from the address that sent it, and Bitcoin Cash--- Node does.--- -- = What it does ----- The store does not broadcast. It hands the transaction to one full node--- and listens for it to come back:--- -- 1. __Check__ what the store can decide from its own index ('judge'): the -- coins, the amounts, the lock time, the rules of relay that do not--- change, and the signature of every coin locked to one key. A--- transaction that the store has in a block is answered at once.+-- change, the form of the input scripts, the tokens of Bitcoin Cash, and+-- the signature of every coin locked to one key. A transaction that the+-- store has in a block is answered at once, and nothing is sent. -- -- 2. __Send over RPC__, where the store was given the RPC of a full node--- ('Publishing'.@direct@). The node checks everything, says why it--- refuses, and what it refuses costs the store nothing. The next step--- is for when there is no such node, or it does not answer.------ 3. __Hand over__ ('deliver'), only a transaction whose every signature--- the store verified: on a connection of its own, the courier, to--- a node that the store syncs from. The transaction, then a @ping@. A--- node handles the messages of a connection in order, so the @pong@--- says that it is done with the transaction.------ 4. __Listen__: the connections that the store syncs from never mentioned--- the transaction, so a node that accepted it announces it there as it--- does any other, and the store imports it as it does any other. That--- announcement, the echo, is the only way into the mempool.+-- ('Publishing'.@direct@). The node checks everything, and says at once+-- whether it took the transaction and why not. It is the only way to+-- publish a spend of a coin that the store cannot verify. ----- 5. __Answer__ when the transaction is in the mempool, or when the node--- said that it refused it, or after 'Limits'.@wait@ seconds.+-- 3. __Hand it to every peer__ otherwise, if the store verified every+-- input: to all at once, each on a connection of its own ('deliver'),+-- and answer that it was handed over as soon as they took it. What the+-- network does with it is the network's to decide. Bitcoin Core refuses+-- without saying so; Bitcoin Cash Node says why, and if every peer that+-- answered refused it, the answer is that reason. ----- 6. __Remember__ ('Outbox'): a transaction on its way, and one that was--- refused, are kept for a while by the hash of their bytes. The same--- bytes posted again join the first request, or get its answer, and are--- not sent again.+-- 4. __It comes back__ as any transaction does: a peer that takes it+-- announces it on the connections that the store syncs from, which did+-- not send it, and the store imports it. That takes a moment with a node+-- that whitelists the store, and seconds with one that does not. The+-- store also asks for it a few times ('Asking'), in case none does. ----- A transaction that the store has in its mempool, unconfirmed, is--- checked and sent again in the same way, and answered from what the node--- did with it: there is no echo to wait for, as the store has it already.--- The store does not hold its mempool to its node's, so it may list a--- transaction that the nodes forgot: after a reorganisation, or when a--- node lost its mempool. Posting it again is how a wallet gets it back to--- them. Answering at once, as for a confirmed one, left the wallet with no--- way out but a fee bump, which Bitcoin Cash does not have, until the--- store dropped the transaction weeks later. It costs a node a--- transaction it may have already, which it ignores.+-- A transaction that the store has unconfirmed is checked and sent again in+-- the same way. The store does not hold its mempool to its node's, so it+-- may list a transaction that the nodes forgot: after a reorganisation, or+-- when a node lost its mempool. Posting it again is how a wallet gets it+-- back to them. -- -- = Why it is this way ----- * __A connection of its own.__ A node never announces a transaction to--- the connection that gave it, and answers @notfound@ when asked for it--- there until its timer for announcements has run. So on the connection--- that sent it there is no telling what the node made of it. On any--- other connection the store is a peer that does not have the--- transaction, and hears of it by the ordinary relay, which every node--- does. Bitcoin Core does the same for its own transactions when told--- to broadcast them privately.------ * __The transaction, not an announcement of it.__ A node waits two--- seconds before asking a peer it did not choose for what it announced,--- and does not ask at all for what it knows or has refused.+-- * __No waiting for the echo.__ Until 1.12.1 the request waited, up to+-- @--publish-timeout@, for a node to announce the transaction back, and+-- said what came of it. The wait was arbitrary, Bitcoin Core does not say+-- when it refuses, and the store cannot see the network: a transaction+-- that one node refuses may be taken by others. The client is told what+-- the store knows, that it was handed over, and finds out the rest the+-- way it finds out about any transaction. ----- * __One node.__ What is sent to one node reaches the others if it is--- good. Sending it to many spreads what is bad.+-- * __A connection of its own.__ A node never announces a transaction to+-- the connection that gave it, and gives it back there only once it+-- relayed it to another peer, or announced something else there. Sent on+-- the connections that the store syncs from, a transaction did not come+-- back to a store whose node had no other peer until a block confirmed+-- it. On a connection of its own it comes back as any other. ----- * __No breaker.__ A node that closes the courier on a transaction has--- found it invalid, and may have closed the other connections from this--- address too. That transaction is not sent again. The store does not--- stop publishing the others: someone who posts forged transactions--- would then keep everyone else from publishing, which is worse than--- what it guards against. The store connects to its node again, and a--- node that whitelists the store closes nothing.+-- * __Every peer.__ A peer that is sent a transaction it finds invalid may+-- close its connections to the sender, and Bitcoin Cash Node closes them+-- all. So the store sends a peer only what it verified, and checks the+-- rules whose breach was measured to cost it its connections: the form of+-- the input scripts and the tokens ("docs/publishing.md" has what was+-- measured). A transaction that only breaks a rule of relay, such as one+-- that pays too little, is refused by the nodes without harm. ----- The alternatives: sending through a node's RPC would make the store--- need an account on a node, where it needs only to be a peer; the RPC is--- an option, to ask before sending. Running scripts here would need an--- interpreter of every script of both chains, held to the nodes rule for--- rule; the store checks a signature where there is no script to run.--- Giving the transaction to--- half the peers and listening on the others needs two peers, and puts the--- connections that the store syncs from at risk.+-- * __The RPC first.__ A node that the store can ask says no with a reason,+-- from Bitcoin Core too, and runs every script. -- -- = What it cannot do ----- Only a full node runs a script. A peer that is sent a transaction it--- finds invalid may drop every connection of the sender, so the store--- sends a peer only what it verified, and refuses a spend of a coin--- that a script locks unless it has the RPC of a node to send it over. A node that--- whitelists the store never does, and announces without delay, so a--- store should be whitelisted by the nodes it is run next to. Bitcoin--- Core does not say to a peer when it refuses a transaction: the client--- learns it when the wait is over, unless the store asks over RPC.+-- Only a full node runs a script. The store refuses a spend of a coin that+-- a script locks unless it has the RPC of a node to send it over. It does+-- not know whether the network took a transaction until it sees it: a+-- client that wants to know watches for it, or posts it again. module Haskoin.Store.Web.Publish ( -- * Publishing Publishing (..),- Limits (..), Answer (..),- Verdict (..), Direct (..), Held (..), publish, + -- * The courier+ Delivery (..),+ Courier (..),+ deliver,++ -- * Asking for what was handed over+ Asking,+ newAsking,+ askFor,+ askLoop,+ askAgain,+ due,+ -- * What the store can check Coin (..), Refusal (..), Rules (..), judge,+ tokenRules, isDust, maxStandardSize, coinOf, maxTxSize,-- -- * What is on its way- Outbox,- Slot (..),- Ticket (..),- emptyOutbox,- enter,- settle,-- -- * The courier- Delivery (..),- Courier (..),- deliver, ) where -import Control.Monad (unless, when)+import Control.Monad (forM_, forever, unless, when)+import Data.Bits (shiftL, (.&.), (.|.))+import Data.ByteString (ByteString) import Data.ByteString qualified as B import Data.HashMap.Strict (HashMap) import Data.HashMap.Strict qualified as HashMap-import Data.Maybe (listToMaybe)-import Data.Serialize (encode)+import Data.List (partition)+import Data.Maybe (isJust, listToMaybe)+import Data.Serialize (decode, encode) import Data.String.Conversions (cs) import Data.Text (Text) import Data.Text qualified as T import Data.Text.Encoding qualified as T-import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime)+import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, getCurrentTime) import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)-import Data.Word (Word32, Word64)+import Data.Word (Word32, Word64, Word8) import Haskoin- ( Hash256,+ ( BlockHeight,+ InputVerdict (..), Message (..), MessageCommand (..), Network (..),@@ -172,16 +140,17 @@ Ping (..), Pong (..), Reject (..),+ Script (..), Tx (..), TxHash,- BlockHeight,- InputVerdict (..), TxIn (..), TxOut (..), VarString (..),- doubleSHA256,+ isMinimalPush,+ isPushOp, nullOutPoint, sockToHostAddress,+ splitTokenPrefix, txHash, txHashToHex, )@@ -198,21 +167,19 @@ wrapPeer, ) import Haskoin.Node.Peer.Lock (Lock (..))-import Haskoin.Store.Common (StoreEvent (..), StoreRead (..))+import Haskoin.Store.Common (StoreRead (..)) import Haskoin.Store.Data (BlockRef (..), Spender (..), TxData (..), isCoinbaseTx)-import NQE (Publisher, newMailbox, receive, withPublisher, withSubscription)+import NQE (newMailbox, receive, withPublisher, withSubscription) import Network.Socket (SockAddr (..)) import System.Random (randomIO) import UnliftIO- ( STM,- TVar,+ ( TVar, atomically,+ mapConcurrently,+ modifyTVar', newTVarIO,- onException, race,- readTVar, readTVarIO,- retrySTM, stateTVar, timeout, waitCatch,@@ -221,38 +188,6 @@ ) import UnliftIO.Concurrent (threadDelay) --- | How long to wait, and how much to remember.-data Limits = Limits- { -- | seconds that a request waits for the transaction to come back- wait :: !NominalDiffTime,- -- | seconds that the courier may take to connect and hand over- handover :: !NominalDiffTime,- -- | nodes to try when one cannot be reached- tries :: !Int,- -- | transactions that the outbox holds; more are turned away- capacity :: !Int,- -- | seconds that an answer other than success is remembered- memory :: !NominalDiffTime- }- deriving (Eq, Show)---- | What a full node, or the lack of one, made of a transaction.-data Verdict- = -- | a node announced it back, or the store has it- Accepted- | -- | a node refused it and said why- Refused !Text- | -- | a node closed the connection on receiving it- Dropped- | -- | no node could be reached- Unreachable- | -- | a node took it and nothing came back in time- Silent- | -- | not sent: the store could not verify the signature of this input,- -- and had no node to give the transaction to over RPC- Unverified !Int- deriving (Eq, Show)- -- | Whether the store has a transaction, and where. data Held = -- | the store does not have it@@ -276,12 +211,22 @@ -- | The answer to a request to publish. data Answer- = -- | what came of handing it to a node- Verdict !Verdict+ = -- | the store has it in a block: nothing was sent+ InBlock+ | -- | a full node took it over its RPC+ ToNode+ | -- | handed to this many peers+ ToPeers !Int+ | -- | a full node refused it over its RPC and said why, or every peer+ -- that answered refused it+ Refused !Text | -- | refused by the store, before anything was sent Invalid !Refusal- | -- | the outbox is full- Busy+ | -- | not sent: the store could not verify the signature of this input,+ -- and had no node to give the transaction to over RPC+ Unverified !Int+ | -- | not handed over: the store has no peer, or reached none+ NoPeers deriving (Eq, Show) -- | An output that a transaction spends, as the store knows it.@@ -317,6 +262,11 @@ | TooHeavy !Int | Dust !Int | Forged !Int+ | BadVersion !Word32+ | NotPushOnly !Int+ | NotMinimal !Int+ | BadTokens !Int+ | TokensUnbacked !String deriving (Eq) instance Show Refusal where@@ -337,6 +287,11 @@ show (TooHeavy n) = "The transaction is larger than nodes relay: " <> show n show (Dust i) = "Output " <> show i <> " is worth less than it costs to spend, and nodes do not relay it" show (Forged i) = "The signature of input " <> show i <> " does not verify"+ show (BadVersion v) = "Nodes do not take a transaction of version " <> show v+ show (NotPushOnly i) = "The script of input " <> show i <> " does more than push data"+ show (NotMinimal i) = "The script of input " <> show i <> " pushes data in a longer form than it needs"+ show (BadTokens i) = "The tokens of output " <> show i <> " are not written as tokens are"+ show (TokensUnbacked c) = "The transaction gives tokens of category " <> c <> " that its inputs do not hold" showOutPoint :: OutPoint -> String showOutPoint o = cs (txHashToHex o.hash) <> ":" <> show o.index@@ -386,6 +341,14 @@ -- output so small that spending it costs more than it holds ('isDust'). -- These are rules of relay and not of the chain; they are the ones that -- have not changed in years.+-- * __Input scripts__ only push data, each push in its shortest form. On+-- Bitcoin Cash that is a rule of the chain, and a node drops every+-- connection of a peer that sends a transaction that breaks it.+-- * __The version__: 1 or 2 on Bitcoin Cash, a rule of the chain there; 1+-- to 3 on Bitcoin, which nodes relay.+-- * __Tokens__ of Bitcoin Cash ('tokenRules'): written as tokens are, and+-- given only as the inputs hold them. A node drops every connection of a+-- peer that sends a transaction that breaks them. -- * __Signatures__, where there is no script to run: an input that spends -- a coin locked to one key with a signature that does not verify -- ('judgeInputs' of haskoin-core). An input that is a script, or that@@ -407,7 +370,10 @@ when (null tx.outputs) $ Left NoOutputs when (any ((== nullOutPoint) . (.outpoint)) tx.inputs) $ Left IsCoinbase mapM_ (Left . DuplicateInput) (firstDuplicate (map (.outpoint) tx.inputs))+ unless (tx.version `elem` versions) $ Left (BadVersion tx.version)+ mapM_ pushes (zip [0 ..] tx.inputs) spent <- mapM known (zip (map (.outpoint) tx.inputs) coins)+ when cash $ tokenRules tx spent -- sums of 64-bit values: compared as integers, which do not wrap unless (sum (map (toInteger . (.value)) spent) >= sum (map (toInteger . (.value)) tx.outputs)) $ Left Overspent mapM_ mature (zip (map (.outpoint) tx.inputs) coins)@@ -418,6 +384,20 @@ mapM_ (Left . Forged) (take 1 [i | (i, InputForged) <- verdicts]) return (listToMaybe [i | (i, InputUndecided) <- verdicts]) where+ cash = isJust rules.net.sigHashForkId+ -- Bitcoin Cash takes versions 1 and 2 only, by its rules since May+ -- 2023; Bitcoin Core relays those and version 3+ versions+ | cash = [1, 2]+ | otherwise = [1, 2, 3]+ -- an input script that only pushes data, each push in its shortest+ -- form: rules of Bitcoin Cash, relay rules of Bitcoin Core+ pushes (i, input) = case decode input.script of+ Right (Script ops)+ | not (all isPushOp ops) -> Left (NotPushOnly i)+ | not (all isMinimalPush ops) -> Left (NotMinimal i)+ | otherwise -> Right ()+ Left _ -> Left (NotPushOnly i) size = B.length (encode tx) -- what the limit of relay counts: weight with SegWit, bytes without heavy@@ -501,81 +481,10 @@ confirmed BlockRef {height} = Just height confirmed MemRef {} = Nothing --- | A transaction of the outbox.-data Slot- = -- | a request is publishing it- Flying- | -- | what came of it- Done !Verdict- deriving (Eq, Show)---- | The transactions on their way and those that failed not long ago, by--- the hash of their bytes.------ The hash is of the bytes as posted, witness included. By the--- transaction id, someone could post a transaction of another with its--- witness spoiled, and have the good one answered as refused.-data Outbox = Outbox- { slots :: !(HashMap Hash256 (UTCTime, Slot))- }- deriving (Eq, Show)---- | An outbox with nothing on its way.-emptyOutbox :: Outbox-emptyOutbox = Outbox HashMap.empty---- | What a request is told when it comes with a transaction.-data Ticket- = -- | publish it: it has a slot now- Fresh- | -- | another request is publishing it: wait for that one- Joined- | -- | it failed not long ago, this way- Known !Verdict- | -- | no room- Full- deriving (Eq, Show)---- | Come with a transaction. What is too old is forgotten first.-enter :: Limits -> UTCTime -> Hash256 -> Outbox -> (Ticket, Outbox)-enter limits now key old =- case HashMap.lookup key box.slots of- Just (_, Flying) -> (Joined, box)- Just (_, Done v) -> (Known v, box)- Nothing- | HashMap.size box.slots >= limits.capacity -> (Full, box)- | otherwise -> (Fresh, box {slots = HashMap.insert key (now, Flying) box.slots})- where- box = Outbox {slots = HashMap.filter kept old.slots}- -- a request that died without settling does not hold its slot for ever- kept (t, Flying) = diffUTCTime now t < limits.wait + 2 * limits.handover * fromIntegral limits.tries + 60- kept (t, Done _) = diffUTCTime now t < limits.memory---- | Say what came of a transaction. Success and a node that could not be--- reached are not remembered: the first is in the database, and the--- second is worth trying again at once. They stay only long enough for--- the requests that joined to read them ('enter' forgets them).-settle :: UTCTime -> Hash256 -> Verdict -> Outbox -> Outbox-settle now key v box =- box- { slots = HashMap.insert key (stamp, Done v) box.slots- }- where- stamp = case v of- Accepted -> long- Unreachable -> long- -- the node may answer over RPC the next time- Unverified _ -> long- _ -> now- -- older than any memory- long = addUTCTime (-1000000000) now---- | What publishing needs. The courier and the nodes are functions, so+-- | What publishing needs. The peers and the courier are functions, so -- that the rules can be tested without a network. data Publishing = Publishing- { limits :: !Limits,- outbox :: !(TVar Outbox),- -- | the chain as the store has it now+ { -- | the chain as the store has it now rules :: !(IO Rules), -- | give the transaction to a full node over its RPC, where the store -- was given the RPC of one@@ -585,17 +494,14 @@ holds :: !(TxHash -> IO Held), -- | the coin of an output, from the queries of the request ('coinOf') coin :: !(OutPoint -> IO Coin),- -- | whether the store has the transaction now, read anew: the queries- -- of a request see the database as it was when the request came- present :: !(TxHash -> IO Bool),- -- | what the store tells its clients- events :: !(Publisher StoreEvent),- -- | whether the store has a mempool to see the transaction come into- mempool :: !Bool,- -- | the nodes to hand a transaction to, the first first- nodes :: !(IO [SockAddr]),- -- | hand a transaction to a node+ -- | the addresses of the peers that the store has now, that take+ -- transactions+ peers :: !(IO [SockAddr]),+ -- | hand a transaction to a peer on a connection of its own ('deliver') courier :: !(SockAddr -> Tx -> IO Delivery),+ -- | ask for a transaction that was handed over, after the answer: see+ -- 'Asking'. It must not wait.+ fetch :: !(TxHash -> IO ()), note :: !(LogLevel -> Text -> IO ()) } @@ -603,105 +509,205 @@ publish :: Publishing -> Tx -> IO Answer publish p tx = p.holds txid >>= \case- Confirmed -> return (Verdict Accepted)- held ->+ Confirmed -> return InBlock+ _ -> (judge <$> p.rules <*> pure tx <*> mapM (p.coin . (.outpoint)) tx.inputs) >>= \case Left r -> return (Invalid r)- Right unverified -> do- now <- getCurrentTime- atomically (stateTVar p.outbox (enter p.limits now key)) >>= \case- Full -> p.note LevelWarn "Outbox full: transaction turned away" >> return Busy- Joined -> Verdict <$> joined- -- sent already, and the node may only have been slow to announce- Known Silent -> Verdict <$> listening (return Silent)- Known v -> return (Verdict v)- Fresh -> do- let go- | held == Pending = again unverified- | otherwise = listening (send unverified)- v <- go `onException` done Unreachable- done v- say v- return (Verdict v)+ Right unverified ->+ p.direct tx >>= \case+ Sent -> do+ p.note LevelDebug ("A node took " <> name <> " over its RPC")+ p.fetch txid+ return ToNode+ Declined why -> do+ p.note LevelInfo ("A node refused " <> name <> ": " <> why)+ return (Refused why)+ Unavailable -> case unverified of+ Just i -> do+ p.note LevelInfo ("Not sent to a peer, input " <> T.pack (show i) <> " not verified: " <> name)+ return (Unverified i)+ Nothing -> do+ addresses <- p.peers+ delivered <- mapConcurrently (\a -> p.courier a tx) addresses+ let handed = length [() | d <- delivered, d `elem` [Took, NoAnswer]]+ reasons = [why | Rejected why <- delivered]+ if+ | handed > 0 -> do+ p.note LevelDebug ("Handed " <> name <> " to " <> T.pack (show handed) <> " peers")+ p.fetch txid+ return (ToPeers handed)+ | why : _ <- reasons -> do+ p.note LevelInfo ("Every peer refused " <> name <> ", the first: " <> why)+ return (Refused why)+ | Closed `elem` delivered -> do+ p.note LevelWarn ("A peer closed the connection on receiving " <> name)+ return (Refused "a peer closed the connection on receiving it")+ | otherwise -> do+ p.note LevelWarn ("No peer to hand " <> name <> " to")+ return NoPeers where txid = txHash tx- key = doubleSHA256 (encode tx) name = txHashToHex txid- done v = do- now <- getCurrentTime- atomically (stateTVar p.outbox (\b -> ((), settle now key v b)))- joined =- timeout (seconds (p.limits.wait + 2 * p.limits.handover)) (atomically (slotDone p.outbox key)) >>= \case- Just (Just v) -> return v- -- forgotten already: it was accepted, or no node was reached- Just Nothing -> (\has -> if has then Accepted else Unreachable) <$> p.present txid- Nothing -> return Silent- -- Listen before sending, so that the echo cannot come unheard, and- -- look again for the transaction once listening: it may have come- -- since the request read the database.- listening send =- withSubscription p.events $ \sub ->- p.present txid >>= \case- True -> return Accepted- False ->- send >>= \case- Silent- | not p.mempool -> return Accepted- | otherwise ->- timeout (seconds p.limits.wait) (echo sub) >>= \case- Just () -> return Accepted- -- an event may have been missed, never a transaction- Nothing -> (\has -> if has then Accepted else Silent) <$> p.present txid- v -> return v- -- The store has it in its mempool, so no echo will come: what the node- -- took is accepted.- again unverified =- send unverified >>= \case- Silent -> return Accepted- v -> return v- echo sub =- receive sub >>= \case- StoreMempoolNew h | h == txid -> return ()- _ -> echo sub- -- Over the RPC of a node if the store has one that answers: the node- -- checks everything, says why it refuses, and nothing it refuses- -- costs the store its connections. Else to a peer, and then only what- -- the store verified itself.- send unverified =- p.direct tx >>= \case- Sent -> return Silent- Declined why -> return (Refused why)- Unavailable -> maybe handOver (return . Unverified) unverified- -- 'Silent' here: the node has it, and has not said that it refused it- handOver = p.nodes >>= go . take p.limits.tries- where- go [] = return Unreachable- go (sa : more) =- p.courier sa tx >>= \case- Took -> return Silent- NoAnswer -> return Silent- Rejected why -> return (Refused why)- Closed -> return Dropped- NoConnection -> go more- say Accepted = p.note LevelDebug ("Published " <> name)- say (Refused why) = p.note LevelInfo ("A node refused " <> name <> ": " <> why)- say Dropped = p.note LevelWarn ("A node closed the connection on receiving " <> name)- say Unreachable = p.note LevelWarn ("No node to publish " <> name <> " to")- say (Unverified i) =- p.note LevelInfo ("Not sent to a peer, input " <> T.pack (show i) <> " not verified: " <> name)- say Silent = p.note LevelInfo ("No node announced " <> name <> " after taking it") --- | Wait for a transaction that another request is publishing.-slotDone :: TVar Outbox -> Hash256 -> STM (Maybe Verdict)-slotDone var key =- HashMap.lookup key . (.slots) <$> readTVar var >>= \case- Just (_, Flying) -> retrySTM- Just (_, Done v) -> return (Just v)- Nothing -> return Nothing+-- | The transactions that were handed over and that the store asks for+-- until it has them, each with when to ask next and the delays after that+-- ('askAgain').+--+-- The store asks its peers as if they had announced the transaction, so+-- that it is requested and imported as any other. A peer gives it back+-- once it would announce it: Bitcoin Cash Node in a second or two, Bitcoin+-- Core once it has announced something to the store since it took the+-- transaction, which on a busy network is a matter of seconds.+--+-- One thread of the web server asks ('askLoop'), and stops with it: a+-- thread for each transaction could outlive the database that it reads.+newtype Asking = Asking (TVar (HashMap TxHash (UTCTime, [NominalDiffTime]))) -seconds :: NominalDiffTime -> Int-seconds t = max 0 (round (t * 1000000))+-- | Nothing to ask for.+newAsking :: IO Asking+newAsking = Asking <$> newTVarIO HashMap.empty +-- | The most transactions asked for at once. Past it a transaction is not+-- asked for, and comes to the store when a peer announces it or a block+-- confirms it.+askingLimit :: Int+askingLimit = 10000++-- | Ask for a transaction from now on, on the schedule of 'askAgain'.+askFor :: Asking -> TxHash -> IO ()+askFor (Asking var) h = do+ now <- getCurrentTime+ atomically . modifyTVar' var $ \m -> case askAgain of+ first : rest | HashMap.size m < askingLimit -> HashMap.insert h (addUTCTime first now, rest) m+ _ -> m++-- | The transactions whose time to be asked for has come, and what is left+-- to ask for after them: each at its next delay, or no more after its last.+due ::+ UTCTime ->+ HashMap TxHash (UTCTime, [NominalDiffTime]) ->+ ([TxHash], HashMap TxHash (UTCTime, [NominalDiffTime]))+due now m = ([h | (h, (t, _)) <- HashMap.toList m, t <= now], HashMap.mapMaybe next m)+ where+ next (t, ds)+ | t > now = Just (t, ds)+ | otherwise = case ds of+ d : rest -> Just (addUTCTime d now, rest)+ [] -> Nothing++-- | Every second, ask for each transaction whose time has come, unless the+-- store has it now. Runs until it is cancelled.+askLoop :: Asking -> (TxHash -> IO Bool) -> (TxHash -> IO ()) -> IO ()+askLoop (Asking var) present ask = forever $ do+ threadDelay 1000000+ now <- getCurrentTime+ ready <- atomically (stateTVar var (due now))+ forM_ ready $ \h ->+ present h >>= \case+ True -> atomically (modifyTVar' var (HashMap.delete h))+ False -> ask h++-- | When to ask for a transaction that was handed over: soon, for Bitcoin+-- Cash Node, and then every 25 seconds for two minutes and a half. The+-- delays are longer than the 20 seconds that the block store waits for a+-- transaction that it requested before it asks again.+askAgain :: [NominalDiffTime]+askAgain = [2, 25, 25, 25, 25, 25, 25]++-- | The tokens that an output holds: their category, a non-fungible token+-- with its capability and commitment, and an amount of fungible ones.+data Tokens = Tokens+ { category :: !ByteString,+ nft :: !(Maybe (Word8, ByteString)),+ amount :: !Word64+ }++-- | The tokens at the front of the script of an output, if it has any, or+-- 'Left' if they are not written as tokens are ('splitTokenPrefix').+readTokens :: ByteString -> Either () (Maybe Tokens)+readTokens script = case splitTokenPrefix script of+ Nothing -> Left ()+ Just (prefix, _)+ | B.null prefix -> Right Nothing+ | otherwise -> Right (Just (parse prefix))+ where+ -- the prefix is well formed: 'splitTokenPrefix' said so+ parse prefix =+ let flags = B.index prefix 33+ rest0 = B.drop 34 prefix+ (commitment, rest1)+ | flags .&. 0x40 /= 0 = let (n, r) = compact rest0 in B.splitAt (fromIntegral n) r+ | otherwise = (B.empty, rest0)+ held+ | flags .&. 0x10 /= 0 = fst (compact rest1)+ | otherwise = 0+ in Tokens+ { category = B.take 32 (B.drop 1 prefix),+ nft = if flags .&. 0x20 /= 0 then Just (flags .&. 0x0f, commitment) else Nothing,+ amount = held+ }+ compact :: ByteString -> (Word64, ByteString)+ compact b = case B.uncons b of+ Just (w, r)+ | w < 0xfd -> (fromIntegral w, r)+ | w == 0xfd -> sized 2 r+ | w == 0xfe -> sized 4 r+ | otherwise -> sized 8 r+ Nothing -> (0, B.empty)+ sized :: Int -> ByteString -> (Word64, ByteString)+ sized k r = (B.foldr (\w acc -> acc `shiftL` 8 .|. fromIntegral w) 0 (B.take k r), B.drop k r)++-- | The rules of the tokens of Bitcoin Cash (CashTokens, since May 2023),+-- given the outputs that the inputs spend, in order. A node drops every+-- connection of a peer that sends it a transaction that breaks them, so+-- the store checks them before it sends anything.+--+-- * The tokens of every output are written as tokens are.+-- * A category is new when it is the id of a transaction whose first+-- output an input spends: its tokens may be made at will.+-- * Of any other category, the outputs hold no more fungible tokens than+-- the inputs, and no more than the largest amount, in all.+-- * And its non-fungible tokens come from the inputs: any, when an input+-- holds one that can mint; else each output's equals one of an input+-- that cannot be changed, or takes the place of one that can be changed,+-- and none can mint.+tokenRules :: Tx -> [TxOut] -> Either Refusal ()+tokenRules tx spent = do+ outs <- mapM readOut (zip [0 ..] tx.outputs)+ let ins = [t | Right (Just t) <- map (readTokens . (.script)) spent]+ made = [t | Just t <- outs]+ new = [encode i.outpoint.hash | i <- tx.inputs, i.outpoint.index == 0]+ categories = HashMap.keys (HashMap.fromList [(t.category, ()) | t <- made])+ forM_ categories $ \c -> do+ let given = [t | t <- made, t.category == c]+ held = [t | t <- ins, t.category == c]+ total = sum (map (toInteger . (.amount)) given)+ when (total > 0x7fffffffffffffff) $ Left (TokensUnbacked (named c))+ unless (c `elem` new) $ do+ when (total > sum (map (toInteger . (.amount)) held)) $ Left (TokensUnbacked (named c))+ unless (nftsBacked [n | Just n <- map (.nft) given] [n | Just n <- map (.nft) held]) $+ Left (TokensUnbacked (named c))+ where+ readOut (i, o) = either (const (Left (BadTokens i))) Right (readTokens o.script)+ -- the id of a category as people write it: the bytes reversed+ named = concatMap hex . B.unpack . B.reverse+ hex w = [digits !! fromIntegral (w `div` 16), digits !! fromIntegral (w `mod` 16)]+ digits = "0123456789abcdef"+ -- capabilities: 0 none, 1 mutable, 2 minting+ nftsBacked given held+ | any ((== 2) . fst) held = True+ | any ((== 2) . fst) given = False+ | otherwise =+ let (fixed, changing) = partition ((== 0) . fst) given+ left = foldl matched ([n | n <- held, fst n == 0], []) fixed+ mutable = length (filter ((== 1) . fst) held)+ in length (snd left) + length changing <= mutable+ -- an immutable token given takes an equal one held, or waits for a+ -- mutable one+ matched (pool, unmatched) n = case break (== n) pool of+ (before, _ : after) -> (before <> after, unmatched)+ _ -> (pool, n : unmatched)+ -- | What a node did with a transaction handed to it. data Delivery = -- | it answered the ping that followed: it is done with the@@ -794,3 +800,6 @@ (Nothing, Just (Right ())) -> Took (Nothing, Just (Left ())) -> if was then Closed else NoConnection (Nothing, Nothing) -> if was then NoAnswer else NoConnection++seconds :: NominalDiffTime -> Int+seconds t = max 0 (round (t * 1000000))
swagger.yaml view
@@ -11,7 +11,7 @@ - url: './' info:- version: &version 1.12.1+ version: &version 1.13.0 title: Haskoin contact: name: JP Rupp@@ -130,6 +130,7 @@ | 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. |+ | 410 | `replaced` | A transaction asked for by its id was replaced by one that a block confirmed; `replaced-by` and `height` say which and where. | | 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. |@@ -160,8 +161,22 @@ 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+ spends its outputs. It is no longer in any answer that lists+ transactions.++ Fetched by its id, a transaction that a block replaced gives 410, Gone,+ for 2016 blocks, with the transaction of the block that replaced it, or+ that replaced the unconfirmed transaction whose outputs it spent:++ ```json+ {"error": "replaced", "message": "Transaction … was replaced by … in the block at height 977130",+ "replaced-by": "<txid>", "height": 977130}+ ```++ After that, and for one replaced in the mempool by a higher fee, the+ answer is 404, as for a transaction the server never saw. If the block is+ undone by a reorganisation, so is the 410. The servers of a fleet+ answer alike, from what the fleet keeps. Earlier versions answered with the transaction and `"deleted": true`, which clients that did not look at that field took for a pending payment. @@ -1763,6 +1778,12 @@ application/json: schema: $ref: '#/components/schemas/Error'+ '410':+ description: A block replaced the transaction; see Unconfirmed transactions.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error' '/transaction/{txid}/raw': get:@@ -1797,6 +1818,12 @@ application/json: schema: $ref: '#/components/schemas/Error'+ '410':+ description: A block replaced the transaction; see Unconfirmed transactions.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error' '/transaction/{txid}/after/{height}': get:@@ -1957,27 +1984,26 @@ 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 that a full node accepted the transaction and that this- server has it**: the next request for the transaction, or for a balance- it changes, shows it.+ **A 202 means that the transaction was handed over**: to a full node+ over RPC, or to the peers of this server. It does not say that the+ network took it. A node that takes it announces it, and this server+ has it from then on, usually within a second, and seconds with+ nodes that do not whitelist this server. - The server does not broadcast. It checks what it can by itself: that- the inputs exist and are not spent, the amounts, the lock time, the- size and dust rules of relay, and the signature of every input that- spends a coin locked to one key. It does not run other scripts: a- transaction with an input it cannot verify is published only by a- server that reaches a full node over RPC, and refused by others.- Then it hands the transaction to one full node, and answers- when a node has announced it back, which is how it enters the mempool of- this server. That takes milliseconds when the node whitelists the server,- and seconds otherwise.+ The server checks what it can by itself: that the inputs exist and+ are not spent, the amounts, the lock time, the version, that the+ input scripts only push data in its shortest form, the tokens of+ Bitcoin Cash, the size and dust rules of relay, and the signature of+ every input that spends a coin locked to one key. It does not run+ other scripts: a transaction with an input it cannot verify is+ published only by a server that reaches a full node over RPC, and+ refused by others. Then it hands the transaction to every peer, each+ on a connection of its own, and answers as soon as they took it. A transaction that this server has in a block is answered 200 and not- sent. One that it has unconfirmed is checked and handed to a full node- again, and answered 200 when the node took it: the nodes may have- forgotten a transaction that this server lists, after a reorganisation- or when a node lost its mempool, and posting it again gets it back to- them. Posting the same transaction again is always safe.+ sent. One that it has unconfirmed is handed over again, as the nodes+ may have forgotten it: after a reorganisation, or when a node lost+ its mempool. Posting the same transaction again is always safe. operationId: postTransaction requestBody: description: >-@@ -1999,8 +2025,19 @@ 04e444809abd438d85eb18e8d07c796e364afe88ac399b7c00000000001976a9\ 1486a7f1a2047ed6719fa1ad5484f15c37c104887c88ac00000000" responses:+ '202':+ description: |+ Handed over. `message` says to whom: a full node over RPC, or+ how many peers took it.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/TxId'+ example:+ txid: 0b7cb07d848959fbd02ad6e2ed21fac888b8e8011c6b8c5362d05c703066ae67+ message: Handed over to 8 peers. The network decides whether to take it. '200':- description: A full node accepted the transaction, and this server has it.+ description: This server has the transaction in a block, and sent nothing. content: application/json: schema:@@ -2014,16 +2051,22 @@ * The body is not one transaction in hexadecimal. * It spends an output that this server does not know, or that a confirmed transaction has spent, or a coinbase that is not a- hundred blocks old.+ hundred blocks old. The output of a transaction that was handed+ over is known once a node has announced it. * Its outputs are worth more than its inputs, its lock time has- not passed, it is larger than nodes relay, or one of its- outputs is dust.+ not passed, its version is not one that nodes take, it is+ larger than nodes relay, or one of its outputs is dust.+ * An input script does more than push data, or pushes it in a+ longer form than it needs.+ * On Bitcoin Cash, its outputs give tokens that its inputs do not+ hold, or tokens not written as tokens are. * The signature of an input that spends a coin locked to one key does not verify. * This server cannot verify the signature of an input, such as one of a script that is not a key or a multisig, and has no full node to give the transaction to over RPC.- * A node refused it and said why.+ * A node refused it over RPC and said why, or every peer that+ answered refused it and said why. content: application/json: schema:@@ -2039,16 +2082,8 @@ $ref: '#/components/schemas/Error' '500': description: |- The server could not find out whether the network took the- transaction. Post the same transaction again to ask again.-- * `server-timeout`: a node took the transaction and none announced- it within the time this server waits (30 seconds by default).- Most likely it was refused: Bitcoin Core does not say when it- refuses a transaction.- * `server-error`: no node could be reached; or a node closed the- connection on receiving the transaction, which a node does with- one it finds invalid.+ `server-error`: this server has no peer to hand the transaction to,+ or reached none. content: application/json: schema:
test/Haskoin/Store/Database/MigrateSpec.hs view
@@ -241,10 +241,11 @@ 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)))+ -- with the time of the revert, which the timeout of the mempool+ -- counts from+ reverted <- apply layeredRules db (Revert (headerHash (maybe (error "no block") (.header) best)) 7000) isRight reverted.result `shouldBe` True- fmap (.timestamp) <$> M.getMempoolTx db (txHash tx) `shouldReturn` Just time+ fmap (.timestamp) <$> M.getMempoolTx db (txHash tx) `shouldReturn` Just 7000 modifyMaxSuccess (const 20) $ it "is the time of its block, after any history" $ -- whatever a store saw in its mempool before, and through blocks@@ -345,7 +346,7 @@ -- 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)+ reverted <- apply layeredRules db (Revert best 0) isRight reverted.result `shouldBe` True keysNow `shouldReturn` [] Q.getChainSpenders db (txHash wide) `shouldReturn` IntMap.empty
test/Haskoin/Store/Fleet/MemberSpec.hs view
@@ -74,7 +74,7 @@ memberForget m out.removed when (isRight out.result) $ case op of Mine _ _ -> best >>= noteBest m- Revert _ -> 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))@@ -153,7 +153,7 @@ 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+ 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
test/Haskoin/Store/Fleet/RedisSpec.hs view
@@ -70,7 +70,7 @@ 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])+ foldM_ (pooled conn keys) (P.empty, Nothing, 0, (Map.empty, Map.empty)) (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@@ -82,7 +82,7 @@ -- 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]}+ let confirms = nothing {confirmed = [block 7], spent = [out 0 0 <> block 7 <> encode (2 :: Word32)]} 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@@ -102,7 +102,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]}+ let confirms = nothing {confirmed = [block 9], spent = [out 0 1 <> block 9 <> encode (3 :: Word32)]} 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@@ -239,9 +239,9 @@ fleetMove conn keys (Just now) seen `shouldReturn` expected same conn keys now model' return (model', now)- nothing = PoolChange {returned = [], gone = [], confirmed = [], spent = []}+ nothing = PoolChange {returned = [], gone = [], confirmed = [], spent = [], height = Nothing, lost = []} -- carry a step of the pool out in Redis and in the model, and compare- pooled conn keys (model, tip, n) = \case+ pooled conn keys (model, tip, n, kept) = \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@@ -255,30 +255,73 @@ 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)+ return (model', tip, n, kept) 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)+ return (model', tip, n, kept) Go back dead done used -> do- -- one store, which reports a block that no step has had+ -- one store, which reports a block that no step has had, 700+ -- blocks above the last, so that what was replaced is forgotten+ -- three steps later let target = block (1000 + n)+ height = 700 * (n + 1) 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]+ -- what spends each output in the blocks, one of the+ -- transactions of the tests, so that it may come back later+ labelled = [(out a i, block ((a * 7 + i * 3 + n) `mod` 12) <> encode height) | (a, i) <- used]+ change = P.Change returned' (map block dead) (map block done) (map fst labelled)+ -- the store names those of the abandoned blocks that it has as+ -- replaced: here one of those that are gone, by a transaction+ -- that the blocks may confirm now, later or never+ lost = [(block t, block w, block w <> encode height) | t <- take 1 dead, let w = (t * 5 + n) `mod` 12] sent = PoolChange [txOf (number t) os | (t, os) <- returned'] change.gone change.confirmed- change.spent+ [o <> w | (o, w) <- labelled]+ (Just height)+ [t <> w | (t, _, w) <- lost] 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+ let (records, waiting) = kept+ (model', replaced, waiting') = P.advanceReplacing maker change (`lookup` labelled) lost waiting model+ -- what a transaction that comes back had replaced is forgotten+ -- first, then the new, then what is 2016 blocks old+ back' = Set.fromList (map fst returned')+ unwon = Map.filter (\w -> not (Set.member (B.take 4 w) back')) records+ records' = Map.filter fresh (foldl' (\m (t, w) -> Map.insert t w m) unwon replaced)+ waiting'' = Map.filter (fresh . snd) waiting'+ fresh w = height < 2016 || either (const False) (> height - 2016) (decode (B.drop 4 w)) samePool conn keys model'- return (model', Just target, n + 1)+ sameReplaced conn keys records' waiting''+ return (model', Just target, n + 1, (records', waiting'')) maker = B.take 4+ -- what is kept of replaced transactions, with its two indices+ sameReplaced conn keys expected waiting = 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+ waits <- list (Redis.hgetall (key "pool:lost"))+ Map.fromList waits `shouldBe` Map.map snd waiting+ forM_ (Map.toList waiting) $ \(t, (by, _)) ->+ list (Redis.sismember (key ("pool:lostby:" <> by)) t) `shouldReturn` True+ byWinner <- list (Redis.keys (key "pool:lostby:*"))+ Set.fromList byWinner `shouldBe` Set.fromList [key ("pool:lostby:" <> by) | (by, _) <- Map.elems waiting]+ lostAt <- list (Redis.zcard (key "pool:lostat"))+ lostAt `shouldBe` fromIntegral (Map.size waiting)+ kept <- list (Redis.hgetall (key "pool:rep"))+ Map.fromList kept `shouldBe` expected+ at <- list (Redis.zrangeWithscores (key "pool:repat") 0 (-1))+ let heightOf w = either (const (-1)) fromIntegral (decode (B.drop 4 w) :: Either String Word32)+ Map.fromList at `shouldBe` Map.map heightOf expected+ forM_ (Map.toList expected) $ \(t, w) ->+ list (Redis.sismember (key ("pool:repby:" <> B.take 4 w)) t) `shouldReturn` True+ bys <- list (Redis.keys (key "pool:repby:*"))+ Set.fromList bys `shouldBe` Set.fromList [key ("pool:repby:" <> B.take 4 w) | w <- Map.elems expected] sorted = \case Conflict rivals -> Conflict (sort rivals) other -> other
test/Haskoin/Store/Histories.hs view
@@ -180,7 +180,7 @@ data Rules = Rules { initBest :: Writer -> IO (), importBlock :: Writer -> Block -> BlockNode -> IO (BlockData, [TxData]),- revertBlock :: Writer -> BlockHash -> IO (),+ revertBlock :: Writer -> UnixTime -> BlockHash -> IO (), newMempoolTx :: Writer -> Tx -> UnixTime -> IO Bool, deleteUnconfirmedTx :: Writer -> Bool -> TxHash -> IO () }@@ -191,7 +191,7 @@ Rules { initBest = Mixed.initBest, importBlock = Mixed.importBlock,- revertBlock = Mixed.revertBlock,+ revertBlock = Mixed.revertBlockAt, newMempoolTx = Mixed.newMempoolTx, deleteUnconfirmedTx = Mixed.deleteUnconfirmedTx }@@ -239,8 +239,8 @@ Mine !Block !BlockNode | -- | a mempool transaction is deleted, with its descendants Delete !TxHash- | -- | the best block is reverted- Revert !BlockHash+ | -- | the best block is reverted, at a time+ Revert !BlockHash !UnixTime deriving (Show) -- | What an operation did: what the rules returned or why they refused, and@@ -260,7 +260,7 @@ -- 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)+ Revert h time -> outcome <$> run db (\w -> rules.revertBlock w time h) where outcome :: (Show a) => Either ImportException (a, MempoolChanges) -> Outcome outcome (Left e) = Outcome {result = Left e, added = [], removed = []}@@ -275,7 +275,7 @@ | isLeft out.result -> noted block.txs | otherwise -> (noted block.txs) {chain = (block, node) : hist.chain} Delete _ -> hist- Revert _+ Revert _ _ | isLeft out.result -> hist | otherwise -> hist {chain = drop 1 hist.chain} where@@ -492,7 +492,7 @@ h : _ | even s -> h _ -> TxHash (sha256 (encode seed)) revert = return $ case hist.chain of- (b, _) : _ : _ -> Just (Revert (headerHash b.header))+ (b, _) : _ : _ -> Just (Revert (headerHash b.header) hist.clock) _ -> Nothing -- an earlier transaction, which may be confirmed, in the mempool, -- deleted, or missing what it spends
test/Haskoin/Store/IntegrationSpec.hs view
@@ -173,6 +173,9 @@ aroundAll (withEnvOf False kind ctx) $ describe (name kind <> " that does not whitelist the store") $ it "publishes what it verified, and keeps its node when sent what it cannot verify" strangerTest+ aroundAll (withEnvAt True (Just (30 * 24 * 3600)) BitcoinCore ctx) $+ describe "Bitcoin Core with blocks of a month ago" $+ it "keeps what a reorganisation of an old block put back in the mempool" oldBlockTest aroundAll (withEnv BitcoinCore ctx) $ describe "Bitcoin Core" (chainSpec BitcoinCore) aroundAll (withEnv BitcoinCashNode ctx) $@@ -203,8 +206,8 @@ it "evicts the least recently used xpubs" evictTest it "answers an error in JSON, whatever was asked for" errorFormatTest it "answers the Haskell client of haskoin-store-data" clientTest- it "publishes through a full node, and shows only what the node announced back" publishTest- it "publishes over the RPC of its node when given it, and through a peer when that fails" askTest+ it "hands what it verified to its peers, and shows only what a node announced back" publishTest+ it "publishes over the RPC of its node when given it, and through its peers when that fails" askTest when (kind == BitcoinCashNode) $ it "publishes transactions that make and spend tokens" tokenTest it "publishes spends of a multisig, signed the old way and with Schnorr signatures" multisigTest@@ -262,7 +265,12 @@ -- transactions seconds later, so the web servers wait longer for one they -- published, and it does not give its mempool when asked. withEnvOf :: Bool -> NodeKind -> Ctx -> (Env -> IO ()) -> IO ()-withEnvOf whitelisted kind ctx go = do+withEnvOf whitelisted = withEnvAt whitelisted Nothing++-- | The same, with the first blocks made a number of seconds in the past:+-- the node's clock is set back for them with @setmocktime@, and left so.+withEnvAt :: Bool -> Maybe Int -> NodeKind -> Ctx -> (Env -> IO ()) -> IO ()+withEnvAt whitelisted ago kind ctx go = do image <- testImage withContainer (redisContainer image) $ \redis -> do redisPort <- hostPort redis 6379@@ -279,6 +287,11 @@ p2p = p2pPort } waitRpc node+ -- before the wallet is made: a wallet does not look at blocks older+ -- than itself+ forM_ ago $ \secs -> do+ now <- round <$> getPOSIXTime+ void $ rpc node "setmocktime" [Number (fromIntegral (now - secs :: Int))] ensureWallet node miner <- getNewAddress node -- enough mature coinbases for the wallet to fund every test without@@ -289,8 +302,7 @@ cfg = (storeConfig net ctx logger dir redisURL p2pPort False) {syncMempool = whitelisted} withStore cfg $ \store -> do listening $ \(cachedSock, cachedPort) -> listening $ \(plainSock, plainPort) -> do- let patient c = c {limits = c.limits {publishTimeout = 45}} :: WebConfig- web s = runWebSocket ((if whitelisted then id else patient) (webConfig logger s))+ 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 ->@@ -376,8 +388,7 @@ maxLaggingBlocks = 100, maxPendingTxs = 10000, minPeers = 0,- -- a short wait, for the transactions that a node takes and says nothing of- limits = def {publishTimeout = 3},+ limits = def, version = "test", noMempool = False, stats = Nothing,@@ -1280,7 +1291,7 @@ -- | 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.+-- cache, and is answered 410 with the transaction that replaced it. -- -- Bitcoin Cash Node cannot mine a chosen transaction (@generateblock@), so -- the double spend is set up with coinbase maturity: transaction A spends a@@ -1314,6 +1325,13 @@ e <- expect <$> readIORef ref e.txids `shouldBe` [txB] checkXPub env x ref+ -- asked for by its id, the payment that lost says what replaced it, from+ -- the database and from the cache alike, where one that never was is 404+ forM_ [env.plain, env.cached] $ \url -> do+ (code, v) <- getJSON url ("/transaction/" <> cs txA)+ (code, textField "error" v, textField "replaced-by" v) `shouldBe` (410, Just "replaced", Just txB)+ fst <$> getJSON url ("/transaction/" <> cs txA <> "/raw") `shouldReturn` 410+ fst <$> getJSON env.plain ("/transaction/" <> replicate 64 '1') `shouldReturn` 404 -- | A coinbase that may be spent in the next block and not before, and a -- way to spend it to an address. A spend in that block is dropped by the@@ -1373,9 +1391,35 @@ (field "block" v >>= field "mempool") `shouldSatisfy` isJust (_, raw) <- getJSON env.plain ("/transaction/" <> cs txA <> "/raw") tx <- maybe (fail "No raw transaction") (either fail return . decodeTxBody env.net . cs) (textField "result" raw)- publishTx env tx >>= \(c, r) -> (c, textField "txid" r) `shouldBe` (200, Just txA)+ publishTx env tx >>= \(c, r) -> (c, textField "txid" r) `shouldBe` (202, Just txA) (txA `elem`) <$> rawMempool env.node `shouldReturn` True +-- | A payment confirmed in a block of a month ago that a reorganisation+-- reverts goes back to the mempool, and stays there while the node has+-- it. It went back with the time of its block, and the store deletes+-- mempool transactions older than its timeout of 14 days on every clock+-- tick, so it was gone in under a second. The node's clock is a month+-- back when the test starts ('withEnvAt'); the new blocks are made with+-- @generateblock@ and no transactions, so that the node keeps the payment.+oldBlockTest :: Env -> IO ()+oldBlockTest env = do+ to <- getNewAddress env.node+ txA <- sendTo env.node to 100000+ waitFor "the payment in the store" ((== 200) . fst <$> getJSON env.plain ("/transaction/" <> cs txA))+ [blockX] <- mine env 1+ -- the real time from now on: the new blocks are of today+ void $ rpc env.node "setmocktime" [Number 0]+ void $ rpc env.node "invalidateblock" [String blockX]+ (txA `elem`) <$> rawMempool env.node `shouldReturn` True+ replicateM_ 2 $ rpc env.node "generateblock" [String env.miner, toJSON ([] :: [Text])]+ waitSynced env+ (txA `elem`) <$> rawMempool env.node `shouldReturn` True+ -- longer than a few clock ticks+ threadDelay 3000000+ (code, v) <- getJSON env.plain ("/transaction/" <> cs txA)+ code `shouldBe` 200+ (field "block" v >>= field "mempool") `shouldSatisfy` isJust+ ------------------------------------------------------------------------------- -- Cache -------------------------------------------------------------------------------@@ -1825,24 +1869,22 @@ described `shouldSatisfy` C8.isInfixOf "servers:\n - url: './'\n" described `shouldNotSatisfy` C8.isInfixOf "api.haskoin.com" --- | Transactions posted to the store. A good one is handed to the node,--- which announces it back: the answer comes once the store has it, and--- posting it again sends nothing. What the store can tell is wrong is--- refused with the reason: an output it does not know or that is spent,--- and a signature of a key that does not verify. One that only a node can--- refuse, here for paying no fee, is not answered as published and never--- shows; after it the store still has its node, and publishes the next.+-- | Transactions posted to the store. A good one is handed to the node+-- and answered 202 at once; the store asks for it back, and has it a few+-- seconds later. Posted again, it is handed over again. What the store can+-- tell is wrong is refused with the reason: an output it does not know or+-- that is spent, and a signature of a key that does not verify. One that+-- only a node can refuse, here for paying no fee, is refused with the+-- node's reason on Bitcoin Cash, handed over on Bitcoin, and never shows;+-- after it the store still has its node, and publishes the next. publishTest :: Env -> IO () publishTest env = do first <- unsent env- let firstId = cs (txHashToHex (txHash first))- publishTx env first >>= \(code, v) -> (code, textField "txid" v) `shouldBe` (200, Just (cs firstId))- -- it is there for the request that follows- (seen, _) <- getJSON env.plain ("/transaction/" <> firstId)- seen `shouldBe` 200+ let firstId = txHashToHex (txHash first)+ handedOver env first inNode <- rpc env.node "getrawmempool" []- arr inNode `shouldSatisfy` elem (String (cs firstId))- publishTx env first >>= \(code, _) -> code `shouldBe` 200+ arr inNode `shouldSatisfy` elem (String firstId)+ publishTx env first >>= \(code, _) -> code `shouldBe` 202 -- an output that the store does not know let nowhere = OutPoint (TxHash (sha256 ("nowhere" :: C8.ByteString))) 0 orphan = first {inputs = [i {outpoint = nowhere} | i <- take 1 first.inputs], witness = []} :: Tx@@ -1858,19 +1900,18 @@ fmap (T.isInfixOf "does not verify") (textField "message" v) `shouldBe` Just True (unseen, _) <- getJSON env.plain ("/transaction/" <> cs (txHashToHex (txHash forged))) unseen `shouldBe` 404- -- one that pays no fee, which only a node refuses+ -- one that pays no fee, which only a node refuses: Bitcoin Cash Node+ -- says why, Bitcoin Core takes it in silence and does not keep it free <- unsentFree env- (code, v) <- publishTx env free- case env.node.kind of- -- Bitcoin Cash Node says why+ publishTx env free >>= \(code, v) -> case env.node.kind of BitcoinCashNode -> do code `shouldBe` 400- fmap (T.isInfixOf "A node refused") (textField "message" v) `shouldBe` Just True- -- Bitcoin Core says nothing- BitcoinCore -> code `shouldBe` 500+ fmap (T.isInfixOf "refused") (textField "message" v) `shouldBe` Just True+ BitcoinCore -> code `shouldBe` 202+ threadDelay 4000000 (unpaid, _) <- getJSON env.plain ("/transaction/" <> cs (txHashToHex (txHash free))) unpaid `shouldBe` 404- publishTx env second >>= \(c, _) -> c `shouldBe` 200+ handedOver env second -- more than a transaction can be long: refused before it is read whole let most = 2 * (if env.net.segWit then 4000000 else 1000000) + 4096 publishBody env (C8.replicate (most + 1) '0') >>= \(c, _) -> c `shouldBe` 413@@ -1879,7 +1920,20 @@ publishTx env (cheaper first) >>= \(c, e) -> do c `shouldBe` 400 fmap (T.isInfixOf "confirmed transaction has spent") (textField "message" e) `shouldBe` Just True+ -- and the transaction in that block is answered 200: nothing to send+ publishTx env first >>= \(code, v) -> (code, textField "txid" v) `shouldBe` (200, Just firstId) +-- | Post a good transaction: it is answered 202 with its id and a message,+-- and the store has it a few seconds later, when it has asked for it back.+handedOver :: Env -> Tx -> IO ()+handedOver env tx = do+ let txid = txHashToHex (txHash tx)+ publishTx env tx >>= \(code, v) -> do+ (code, textField "txid" v) `shouldBe` (202, Just txid)+ fmap (T.isInfixOf "Handed over") (textField "message" v) `shouldBe` Just True+ waitFor "the store to have the transaction" $+ (== 200) . fst <$> getJSON env.plain ("/transaction/" <> cs txid)+ -- | A transaction of the node's wallet, signed and not sent. unsent :: Env -> IO Tx unsent env = do@@ -1950,42 +2004,115 @@ else tx {inputs = [i {script = encode (H.Script [H.opPushData script])} | i <- tx.inputs]} -- | A node that does not whitelist the store, which is what a node makes--- of any peer it did not choose. It announces a transaction on a timer,--- so the answer takes seconds, and is still that the store has it.+-- of any peer it did not choose. -- -- Such a node may close every connection of a peer that sends it a--- transaction it finds invalid, those the store syncs from too, and--- Bitcoin Cash Node does. So the store sends a peer only what it has--- verified: a transaction whose signature it cannot judge is refused, and--- one made to fail at the node, which used to cost the store its--- connections, never reaches the node. The store keeps its node, and--- publishes the next good transaction.+-- transaction it finds invalid, and Bitcoin Cash Node does. So the store+-- sends a peer only what it has verified, and refuses what was measured to+-- cost it its connections: a transaction whose signature it cannot judge,+-- a push in a longer form than it needs, and on Bitcoin Cash more tokens+-- out than in. None reaches the node; the store keeps the connection it+-- had to it, and publishes the next good transaction. strangerTest :: Env -> IO () strangerTest env = do+ before <- peerIds unsent env >>= published -- a spend of a coin that a script locks: the store cannot tell a good -- one from one that the node would close its connections over forged <- unjudged env- publishTx env forged >>= \(code, v) -> do- code `shouldBe` 400- fmap (T.isInfixOf "cannot verify") (textField "message" v) `shouldBe` Just True- threadDelay 1000000- (length <$> getPeers env.store.peerMgr) >>= (`shouldSatisfy` (> 0))+ refused "cannot verify" forged+ -- a signature pushed in a longer form than it needs, which Bitcoin Cash+ -- Node closes every connection over, and Bitcoin Core does not relay+ legacy <- legacySpend env+ refused "longer form" (legacy {inputs = [i {script = longer i.script} | i <- legacy.inputs]} :: Tx)+ -- more tokens out than in, which Bitcoin Cash Node closes them over too+ when (env.kind == BitcoinCashNode) $ inflatedTokens env >>= refused "inputs do not hold" unsent env >>= published+ -- the store kept the connection that it had to the node all along+ threadDelay 1000000+ after <- peerIds+ all (`elem` after) before `shouldBe` True -- the store follows the chain void $ mine env 1 where+ peerIds = map (\p' -> numV (p' ! "id")) . arr <$> rpc env.node "getpeerinfo" []+ -- the node does not whitelist the store, and does not give back what+ -- the store sent it when it has no other peer: the node has it published tx = do- publishTx env tx >>= \(code, _) -> code `shouldBe` 200- (seen, _) <- getJSON env.plain ("/transaction/" <> cs (txHashToHex (txHash tx)))- seen `shouldBe` 200+ publishTx env tx >>= \(code, _) -> code `shouldBe` 202+ waitFor "the node to have the transaction" $+ elem (txHashToHex (txHash tx)) <$> rawMempool env.node+ refused why tx =+ publishTx env tx >>= \(code, v) ->+ (code, fmap (T.isInfixOf why) (textField "message" v)) `shouldBe` (400, Just True)+ -- every push with a byte of length, even where the opcode says it+ longer bytes = case decode bytes of+ Right (H.Script ops) -> encode (H.Script (map lengthened ops))+ Left _ -> bytes+ lengthened = \case+ H.OP_PUSHDATA d _ -> H.OP_PUSHDATA d H.OPDATA1+ o -> o +-- | A spend, signed by the wallet of the node, of a coin locked to the hash+-- of a key, whose input script holds the signature and the key.+legacySpend :: Env -> IO Tx+legacySpend env = do+ a <- case env.kind of+ BitcoinCore ->+ rpc env.node "getnewaddress" [String "", String "legacy"] >>= \case+ String t -> return t+ other -> fail ("No address: " <> show other)+ BitcoinCashNode -> getNewAddress env.node+ _ <- sendTo env.node a 1000000+ void $ mine env 1+ coins <- arr <$> rpc env.node "listunspent" [Number 1, Number 9999999, toJSON [a]]+ c <- case coins of+ c : _ -> return c+ [] -> fail "No coin of the address"+ to <- getNewAddress env.node+ let amount = case c ! "amount" of+ Number n -> Number (n - 0.0001)+ other -> other+ rpc env.node "createrawtransaction"+ [toJSON [object ["txid" .= (c ! "txid"), "vout" .= (c ! "vout")]], object [Key.fromText to .= amount]]+ >>= signed env Nothing++-- | Tokens of Bitcoin Cash made by the node, confirmed, and then a spend of+-- them, signed by the node, that gives more of them than it spends.+inflatedTokens :: Env -> IO Tx+inflatedTokens env = do+ coins <- arr <$> rpc env.node "listunspent" []+ to <- getNewAddress env.node+ c <- case [c | c <- coins, numV (c ! "vout") == 0] of+ c : _ -> return c+ [] -> fail "The wallet has no first output"+ let amount less = case c ! "amount" of+ Number n -> Number (n - less)+ other -> other+ holding tokens less =+ object+ [ Key.fromText to+ .= object+ [ "amount" .= amount less,+ "tokenData" .= object ["category" .= (c ! "txid"), "amount" .= (tokens :: Text)]+ ]+ ]+ spending txid = toJSON [object ["txid" .= txid, "vout" .= (0 :: Int)]]+ genesis <-+ rpc env.node "createrawtransaction" [spending (c ! "txid"), toJSON [holding "1000" 0.001]]+ >>= signed env Nothing+ void $ rpc env.node "sendrawtransaction" [String (encodeHex (encode genesis))]+ void $ mine env 1+ rpc env.node "createrawtransaction" [spending (txHashToHex (txHash genesis)), toJSON [holding "2000" 0.002]]+ >>= signed env Nothing+ -- | A store that was given the RPC of its node publishes through it. A -- transaction that the node does not take is refused with the node's -- reason, on Bitcoin Core too, which says nothing to a peer. A good -- transaction whose signature the store cannot judge is published, where -- a store without the RPC refuses it. And when the RPC does not answer,--- what the store verified goes to a peer.+-- what the store verified goes to the peers. Each that is published is+-- answered 202. askTest :: Env -> IO () askTest env = withTestLogger $ \logger -> listening $ \(sock, port) -> listening $ \(deaf, deafPort) -> do let rpcURL = "http://test:test@" <> drop (length ("http://" :: String)) env.node.url@@ -2003,14 +2130,18 @@ waitFor "the server that has the RPC of the node" (up asking) waitFor "the server whose node does not answer" (up unanswered) unsentFree env >>= publishTx asking >>= refused "min relay fee not met"- unsent env >>= publishTx asking >>= \(code, _) -> code `shouldBe` 200+ unsent env >>= publishTx asking >>= \(code, v) -> do+ code `shouldBe` 202+ fmap (T.isInfixOf "full node took") (textField "message" v) `shouldBe` Just True -- not judged by the store: over RPC only first <- unjudged env publishTx env first >>= refused "cannot verify" publishTx unanswered first >>= refused "cannot verify"- publishTx asking first >>= \(code, _) -> code `shouldBe` 200- -- verified by the store: to a peer when the RPC does not answer- unsent env >>= publishTx unanswered >>= \(code, _) -> code `shouldBe` 200+ publishTx asking first >>= \(code, _) -> code `shouldBe` 202+ -- verified by the store: to the peers when the RPC does not answer+ unsent env >>= publishTx unanswered >>= \(code, v) -> do+ code `shouldBe` 202+ fmap (T.isInfixOf "Handed over") (textField "message" v) `shouldBe` Just True -- | Pay a script from the wallet of the node, and give the coin: where it -- is, and the output that holds it.@@ -2091,9 +2222,7 @@ published tx = do publishTx env (cheaper tx) >>= \(code, v) -> (code, fmap (T.isInfixOf "cannot verify") (textField "message" v)) `shouldBe` (400, Just True)- publishTx env tx >>= \(code, v) -> (code, v) `shouldSatisfy` ((== 200) . fst)- (seen, _) <- getJSON env.plain ("/transaction/" <> cs (txHashToHex (txHash tx)))- seen `shouldBe` 200+ handedOver env tx -- signed by the first two keys, the old way old locked hashType = do (coin, out) <- payScript env locked@@ -2178,7 +2307,8 @@ ] ] spending txid = toJSON [object ["txid" .= txid, "vout" .= (0 :: Int)]]- published tx = publishTx env tx >>= \(code, v) -> (code, v) `shouldSatisfy` ((== 200) . fst)+ -- the spend needs the store to know what it spends+ published = handedOver env genesis <- rpc env.node "createrawtransaction" [spending (coin ! "txid"), toJSON [holding 0.001]] >>= signed env Nothing@@ -2440,6 +2570,39 @@ waitFor "the health check of a member to pass" ((== 200) . fst <$> getJSON urlA "/health") (_, health) <- getJSON urlA "/health" ((health ! "redis") ! "ok") `shouldBe` Bool True+ -- a payment that a reorganisation replaced, as in 'reorgTest': both+ -- stores answer 410 alike, from what the pool kept as the tip moved+ let y = newXPub env "fleet-replaced" DeriveNormal+ (coinbaseSpend, cbValue) <- youngCoinbase env (newXPub env "fleet-coinbase" DeriveNormal)+ txA <- coinbaseSpend (at env y [0, 0]) (cbValue - 100000)+ [blockX] <- mine env 1+ waitStore env a+ waitStore env b+ atTip+ header <- rpc env.node "getblockheader" [String blockX]+ parent <- maybe (fail "No parent") return (textField "previousblockhash" header)+ void $ rpc env.node "invalidateblock" [String parent]+ other <- getNewAddress env.node+ void $ generateTo env.node 1 other+ txB <- coinbaseSpend (at env y [0, 1]) (cbValue - 200000)+ void $ mine env 2+ waitStore env a+ waitStore env b+ atTip+ let path = "/transaction/" <> T.unpack txA+ answers = (,) <$> getJSON urlA path <*> getJSON urlB path+ agreed <- tryAny . waitFor "both stores to answer that the payment was replaced" $ do+ (fromA, fromB) <- answers+ return (fromA == fromB && fst fromA == 410)+ case agreed of+ Right () -> return ()+ Left _ -> do+ got <- answers+ kept <- redisRun env (Redis.hgetall (cachePrefix env.net <> "pool:rep"))+ local <- mapM (\st -> Q.getReplaced st.db (fromMaybe (error "txid") (hexToTxHash txA))) [a, b]+ expectationFailure ("Not replaced alike: " <> show got <> "; pool: " <> show kept <> "; stores: " <> show local)+ (_, gone) <- getJSON urlA ("/transaction/" <> T.unpack txA)+ textField "replaced-by" gone `shouldBe` Just txB where -- Both members and their web servers log through the logger of the -- tests, each with its name in front of its lines. A logger of their
test/Haskoin/Store/Logic/LayeredSpec.hs view
@@ -1,14 +1,15 @@ module Haskoin.Store.Logic.LayeredSpec (spec) where -import Control.Monad (foldM, forM_, void)+import Control.Monad (foldM, forM_, replicateM_, void) import Data.ByteString (ByteString) import Data.ByteString qualified as B-import Data.Either (isLeft)-import Data.List (sortOn)+import Data.Either (isLeft, isRight)+import Data.List (sort, sortOn) import Data.Maybe (isJust) import Data.Serialize (decode) import Haskoin ( Block (..),+ BlockHeight, BlockHash, Ctx, Network,@@ -23,8 +24,8 @@ prepareContext, txHash, )-import Haskoin.Store.Common (Start (..), StoreRead (..))-import Haskoin.Store.Data (TxData (..), Unspent (..))+import Haskoin.Store.Common (Replaced (..), Start (..), StoreRead (..), replacedKept)+import Haskoin.Store.Data (BlockData (..), TxData (..), Unspent (..)) import Haskoin.Store.Database.Mempool qualified as M import Haskoin.Store.Database.Migrate (upgradeDatabase) import Haskoin.Store.Database.Query qualified as Q@@ -75,7 +76,7 @@ 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)))+ (b, _) : _ -> (h, Just (Revert (headerHash b.header) h.clock)) [] -> (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]@@ -94,6 +95,26 @@ (.result) <$> apply layeredRules db block `shouldReturn` Left Orphan everything db `shouldReturn` untouched + it "keep what a block replaced, and forget it when the block is reverted" $+ withSystemTempDirectory "haskoin-store-layered-" $ \dir ->+ openLayered btcRegTest ctx dir $ \db -> do+ (gone, height) <- replacedByBlock db+ best <- Q.getBestBlock db >>= maybe (fail "no best block") return+ isRight . (.result) <$> apply layeredRules db (Revert best 2000) `shouldReturn` True+ mapM (Q.getReplaced db) gone `shouldReturn` [Nothing, Nothing]+ Q.getReplacedAt db height `shouldReturn` []++ it "forget what a block replaced 2016 blocks later" $+ withSystemTempDirectory "haskoin-store-layered-" $ \dir ->+ openLayered btcRegTest ctx dir $ \db -> do+ (gone, height) <- replacedByBlock db+ let mineEmpty = blockOn db [] >>= apply layeredRules db+ replicateM_ (fromIntegral replacedKept - 1) mineEmpty+ all isJust <$> mapM (Q.getReplaced db) gone `shouldReturn` True+ void mineEmpty+ mapM (Q.getReplaced db) gone `shouldReturn` [Nothing, Nothing]+ Q.getReplacedAt db height `shouldReturn` []+ 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@@ -238,8 +259,10 @@ everything :: DatabaseReader -> IO Everything everything db = do best <- Q.getBestBlock db- blocks <- dump db (blockCF db.db)- heights <- dump db (heightCF db.db)+ -- what is kept of replaced transactions is the layered layout's own,+ -- and tested on its own+ blocks <- filter (not . ("\x11" `B.isPrefixOf`) . fst) <$> dump db (blockCF db.db)+ heights <- filter (not . ("\x12" `B.isPrefixOf`) . fst) <$> dump db (heightCF db.db) chain <- chainOf db area <- dump db (mempoolCF db.db) mempoolByAddress <- dump db (mempoolAddrCF db.db)@@ -262,3 +285,26 @@ decoded :: ByteString -> IO TxData decoded = either fail return . decode span' p xs = (filter p xs, filter (not . p) xs)++-- | A payment and its child in the mempool, and a block whose transaction+-- spends what the payment spent: both are replaced. Gives their hashes and+-- the height of the block, after checking what is kept of them.+replacedByBlock :: DatabaseReader -> IO ([TxHash], BlockHeight)+replacedByBlock db = do+ hist <- start layeredRules db+ let paid = Tx 2 [TxIn (coinOf hist) "" maxBound] [TxOut 4000 (p2pkh 1)] [] 0+ child = Tx 2 [TxIn (OutPoint (txHash paid) 0) "" maxBound] [TxOut 3000 (p2pkh 2)] [] 0+ rival = Tx 2 [TxIn (coinOf hist) "" maxBound] [TxOut 4500 (p2pkh 3)] [] 0+ gone = map txHash [paid, child]+ forM_ [paid, child] $ \tx ->+ (.result) <$> apply layeredRules db (Pay tx 1000) `shouldReturn` Right "True"+ block <- blockOn db [rival]+ replaced <- apply layeredRules db block+ sort replaced.removed `shouldBe` sort gone+ best <- Q.getBestBlock db >>= maybe (fail "no best block") return+ height <- maybe (fail "no block") (return . (.height)) =<< Q.getBlock db best+ -- the payment, and its child, which spent what it paid+ let what = Replaced {by = txHash rival, height = height}+ mapM (Q.getReplaced db) gone `shouldReturn` [Just what, Just what]+ Q.getReplacedAt db height `shouldReturn` sort gone+ return (gone, height)
test/Haskoin/Store/Web/PublishSpec.hs view
@@ -3,20 +3,17 @@ module Haskoin.Store.Web.PublishSpec (spec) where -import Control.Concurrent (forkIO, threadDelay)-import UnliftIO.Async (concurrently) import Control.Concurrent.STM (atomically, modifyTVar', newTVarIO, readTVarIO)-import Control.Monad (replicateM_, void)+import Control.Monad (replicateM_)+import Data.ByteString (ByteString) import Data.ByteString qualified as B-import Data.Serialize (encode)-import Data.Time.Clock (UTCTime)+import Data.HashMap.Strict qualified as HashMap+import Data.IORef (modifyIORef', newIORef, readIORef) import Data.Time.Clock.POSIX (posixSecondsToUTCTime)-import Data.Word (Word32, Word64)+import Data.Serialize (encode)+import Data.Word (Word32, Word64, Word8) import Haskoin hiding (Coin (..))-import Haskoin.Store.Common (StoreEvent (..)) import Haskoin.Store.Web.Publish-import NQE (withPublisher)-import NQE qualified import Network.Socket (SockAddr (..), tupleToHostAddress) import Test.Hspec @@ -35,64 +32,86 @@ locktime = 0 } -limits :: Limits-limits =- Limits {wait = 0.3, handover = 1, tries = 2, capacity = 3, memory = 600}--at :: Integer -> UTCTime-at = posixSecondsToUTCTime . fromInteger--node :: Word32 -> SockAddr-node n = SockAddrInet 8333 (tupleToHostAddress (10, 0, 0, fromIntegral n))- -- | A coin of a value that nothing is known of: not in a block, and with -- a script that the store cannot judge the spending of. held :: Word64 -> Coin held v = Coin {output = TxOut v "", mined = Nothing, coinbase = False} +-- | An input script that pushes a word, which 'rulesOf' reads.+saying :: ByteString -> ByteString+saying word = encode (Script [opPushData word])+ -- | The chain at block 1000. -- -- The signatures are judged by the scripts of the test: an input script--- of @forged@ is forged, one of @script@ cannot be told, and any other--- verifies. The real judge is tested in haskoin-core, and against nodes--- in the integration tests.+-- that pushes @forged@ is forged, one that pushes @script@ cannot be told,+-- and any other verifies. The real judge is tested in haskoin-core, and+-- against nodes in the integration tests. rulesOf :: Rules rulesOf = Rules {net = btc, inputs = \tx _ -> map verdict tx.inputs, height = 1000, medianTime = 1600000000} where verdict i- | i.script == "forged" = InputForged- | i.script == "script" = InputUndecided+ | i.script == saying "forged" = InputForged+ | i.script == saying "script" = InputUndecided | otherwise = InputVerified +-- | A peer, told apart by a number.+node :: Int -> SockAddr+node n = SockAddrInet 8333 (tupleToHostAddress (10, 0, 0, fromIntegral n))+ -- | A desk to publish at: every output is a coin of 1000, the store has no--- transaction, and the courier does what it is told for each node. It--- counts the times it was sent.-desk :: Ctx -> (SockAddr -> Tx -> IO Delivery) -> (Publishing -> IO Int -> IO a) -> IO a-desk ctx courier go =- withPublisher $ \events -> do- sent <- newTVarIO (0 :: Int)- here <- newTVarIO ([] :: [TxHash])- outbox <- newTVarIO emptyOutbox- let p =- Publishing- { limits = limits,- outbox = outbox,- rules = return (rulesOf),- direct = \_ -> return Unavailable,- holds = \h -> (\hs -> if elem h hs then Pending else Absent) <$> readTVarIO here,- coin = \_ -> return (held 1000),- present = \h -> elem h <$> readTVarIO here,- events = events,- mempool = True,- nodes = return [node 1, node 2],- courier = \sa tx -> atomically (modifyTVar' sent (+ 1)) >> courier sa tx,- note = \_ _ -> return ()- }- go p (readTVarIO sent)+-- transaction, there is no node to ask over RPC, and there are as many+-- peers as said, each of which does with a transaction what it is told.+-- It counts what each peer was handed, and the transactions asked for+-- after the answer.+desk :: Int -> (Int -> Tx -> Delivery) -> (Publishing -> IO [Int] -> IO [TxHash] -> IO a) -> IO a+desk n does go = do+ sent <- newTVarIO (replicate n 0)+ asked <- newIORef []+ let count k = atomically (modifyTVar' sent (\cs' -> [if j == k then c + 1 else c | (j, c) <- zip [0 ..] cs']))+ p =+ Publishing+ { rules = return rulesOf,+ direct = \_ -> return Unavailable,+ holds = \_ -> return Absent,+ coin = \_ -> return (held 1000),+ peers = return [node k | k <- [0 .. n - 1]],+ courier = \sa tx -> case [k | k <- [0 .. n - 1], node k == sa] of+ k : _ -> count k >> return (does k tx)+ [] -> return NoConnection,+ fetch = \h -> modifyIORef' asked (h :),+ note = \_ _ -> return ()+ }+ go p (readTVarIO sent) (reverse <$> readIORef asked) +-- | Every peer takes what it is handed.+taking :: Int -> Tx -> Delivery+taking _ _ = Took++-- | The front of the script of an output that holds tokens: the category,+-- the flags, the commitment if there is one, and the amount if not zero.+withTokens :: ByteString -> Maybe (Word8, ByteString) -> Word64 -> ByteString+withTokens category nft amount =+ B.concat+ [ "\xef",+ category,+ B.singleton (nftFlags .|. (if amount > 0 then 0x10 else 0)),+ maybe "" (\(_, c) -> if B.null c then "" else B.singleton (fromIntegral (B.length c)) <> c) nft,+ if amount > 0 then compact amount else "",+ "\x76\xa9\x14" <> B.replicate 20 7 <> "\x88\xac"+ ]+ where+ nftFlags = case nft of+ Nothing -> 0+ Just (capability, c) -> 0x20 .|. capability .|. (if B.null c then 0 else 0x40)+ compact v+ | v < 0xfd = B.singleton (fromIntegral v)+ | otherwise = "\xfd" <> B.pack [fromIntegral v, fromIntegral (v `div` 256)]+ a .|. b = a + b+ spec :: Spec-spec = prepareContext $ \ctx -> do- let check = judge (rulesOf)+spec = do+ let check = judge rulesOf describe "what the store can check" $ do let tx = spending [output 1, output 2] 1500 it "lets through a transaction that spends what is there" $@@ -108,7 +127,6 @@ check (spending [nullOutPoint] 1) [Missing] `shouldBe` Left IsCoinbase check (spending [] 1) [] `shouldBe` Left NoInputs check (spending [output 1] 1) {outputs = []} [held 1] `shouldBe` Left NoOutputs- it "refuses a coinbase that is not a hundred blocks old" $ do let young h = Coin {output = TxOut 1000 "", mined = Just h, coinbase = True} one = spending [output 1] 900@@ -133,122 +151,148 @@ check (paying 0 "\x6a\x04data") [held 1000] `shouldBe` Right Nothing it "refuses a forged signature, and names the input it cannot judge" $ do let signedBy items = (spending [output 1, output 2] 900) {inputs = zipWith input [1 ..] items} :: Tx- input n item = TxIn (output n) item maxBound+ input n item = TxIn (output n) (saying item) maxBound check (signedBy ["good", "forged"]) [held 1000, held 1000] `shouldBe` Left (Forged 1) check (signedBy ["good", "script"]) [held 1000, held 1000] `shouldBe` Right (Just 1) check (signedBy ["good", "good"]) [held 1000, held 1000] `shouldBe` Right Nothing+ it "refuses a version that nodes do not take: 1 to 3 on Bitcoin, 1 and 2 on Bitcoin Cash" $ do+ let versioned v = (spending [output 1] 900) {version = v} :: Tx+ cash = judge rulesOf {net = bch}+ check (versioned 3) [held 1000] `shouldBe` Right Nothing+ check (versioned 0) [held 1000] `shouldBe` Left (BadVersion 0)+ check (versioned 4) [held 1000] `shouldBe` Left (BadVersion 4)+ cash (versioned 2) [held 1000] `shouldBe` Right Nothing+ cash (versioned 3) [held 1000] `shouldBe` Left (BadVersion 3)+ it "refuses an input script that does more than push, or pushes in a longer form than it needs" $ do+ let scripted ops = (spending [output 1] 900) {inputs = [TxIn (output 1) (encode (Script ops)) maxBound]} :: Tx+ signature = B.replicate 71 1+ check (scripted [opPushData signature, opPushData (B.replicate 33 2)]) [held 1000] `shouldBe` Right Nothing+ -- a push of 71 bytes takes one byte for its length, not two+ check (scripted [OP_PUSHDATA signature OPDATA1]) [held 1000] `shouldBe` Left (NotMinimal 0)+ -- the number 1 is OP_1, and the empty string OP_0+ check (scripted [OP_PUSHDATA "\x01" OPCODE]) [held 1000] `shouldBe` Left (NotMinimal 0)+ check (scripted [OP_0, OP_1, OP_1NEGATE]) [held 1000] `shouldBe` Right Nothing+ check (scripted [opPushData signature, OP_DUP]) [held 1000] `shouldBe` Left (NotPushOnly 0)+ -- bytes that are not a script at all+ check ((spending [output 1] 900) {inputs = [TxIn (output 1) "\x4c" maxBound]} :: Tx) [held 1000]+ `shouldBe` Left (NotPushOnly 0) - describe "outbox" $ do- let k n = doubleSHA256 (encode (n :: Word32))- come t n = enter limits (at t) (k n)- it "gives one request the transaction, and has the others join" $ do- let (first, box) = come 0 1 emptyOutbox- (second, _) = come 1 1 box- (first, second) `shouldBe` (Fresh, Joined)- it "remembers a refusal for a while, and not a success" $ do- let (_, box) = come 0 1 emptyOutbox- refused = settle (at 1) (k 1) (Refused "no") box- accepted = settle (at 1) (k 1) Accepted box- fst (come 2 1 refused) `shouldBe` Known (Refused "no")- fst (come 700 1 refused) `shouldBe` Fresh- fst (come 2 1 accepted) `shouldBe` Fresh- it "turns away what it has no room for" $ do- let (_, one) = come 0 1 emptyOutbox- (_, two) = come 0 2 one- (_, three) = come 0 3 two- fst (come 0 4 three) `shouldBe` Full- it "keeps taking others after nodes closed the courier on some" $ do- let (_, one) = come 0 1 emptyOutbox- (_, two) = come 0 2 (settle (at 1) (k 1) Dropped one)- box = settle (at 2) (k 2) Dropped two- fst (come 3 3 box) `shouldBe` Fresh- fst (come 3 1 box) `shouldBe` Known Dropped+ describe "the tokens of Bitcoin Cash" $ do+ let category = B.replicate 32 9+ -- a category made by this transaction: the id of what its first+ -- input spends, whose index is 0+ newCategory = encode (OutPoint (TxHash (doubleSHA256 ("genesis" :: ByteString))) 0).hash+ from n = OutPoint (output n).hash n+ tx ins outs = (spending ins 0) {outputs = [TxOut 1000 s | s <- outs]} :: Tx+ coin s = TxOut 1000 s+ plain = "\x76\xa9\x14" <> B.replicate 20 7 <> "\x88\xac"+ fungible c n = withTokens c Nothing n+ nft c capability commitment = withTokens c (Just (capability, commitment)) 0+ it "lets tokens through as the inputs hold them" $ do+ tokenRules (tx [from 1] [fungible category 400, fungible category 600]) [coin (fungible category 1000)]+ `shouldBe` Right ()+ -- burnt: fewer out than in+ tokenRules (tx [from 1] [fungible category 10]) [coin (fungible category 1000)] `shouldBe` Right ()+ tokenRules (tx [from 1] [plain]) [coin plain] `shouldBe` Right ()+ it "refuses more fungible tokens out than in" $+ tokenRules (tx [from 1, from 2] [fungible category 2000]) [coin (fungible category 1000), coin plain]+ `shouldBe` Left (TokensUnbacked (concatMap (const "09") [1 .. 32 :: Int]))+ it "makes tokens of a new category at will, from an input that spends an output 0" $ do+ let genesisId = TxHash (doubleSHA256 ("genesis" :: ByteString))+ genesis = (tx [] [fungible newCategory 5000, nft newCategory 2 ""])+ {inputs = [TxIn (OutPoint genesisId 0) "" maxBound]} :: Tx+ tokenRules genesis [coin plain] `shouldBe` Right ()+ -- the same category from an input that spends an output 1 is not new+ let notNew = genesis {inputs = [TxIn (OutPoint genesisId 1) "" maxBound]} :: Tx+ tokenRules notNew [coin plain] `shouldSatisfy` either (const True) (const False)+ it "refuses tokens not written as tokens are" $+ tokenRules (tx [from 1] ["\xef\x01"]) [coin plain] `shouldBe` Left (BadTokens 0)+ it "keeps non-fungible tokens to what the inputs allow" $ do+ let keep = tx [from 1] [nft category 0 "a"]+ -- the same immutable token passes on; another commitment does not+ tokenRules keep [coin (nft category 0 "a")] `shouldBe` Right ()+ tokenRules keep [coin (nft category 0 "b")] `shouldSatisfy` either (const True) (const False)+ -- a mutable one becomes any one token, not two+ tokenRules keep [coin (nft category 1 "x")] `shouldBe` Right ()+ tokenRules (tx [from 1] [nft category 0 "a", nft category 0 "b"]) [coin (nft category 1 "x")]+ `shouldSatisfy` either (const True) (const False)+ -- a minting one makes any, and only a minting one makes one that mints+ tokenRules (tx [from 1] [nft category 0 "a", nft category 2 "b"]) [coin (nft category 2 "")]+ `shouldBe` Right ()+ tokenRules (tx [from 1] [nft category 2 "b"]) [coin (nft category 1 "")]+ `shouldSatisfy` either (const True) (const False) describe "publishing" $ do let tx = spending [output 1] 900- echo events = void . forkIO $ threadDelay 50000 >> NQE.publish (StoreMempoolNew (txHash tx)) events- it "answers when the transaction comes back, having sent it once" $- desk ctx (\_ _ -> return Took) $ \p sent -> do- echo p.events- publish p tx `shouldReturn` Verdict Accepted- sent `shouldReturn` 1+ it "hands a transaction to every peer and answers at once, then asks for it" $+ desk 3 taking $ \p sent asked -> do+ publish p tx `shouldReturn` ToPeers 3+ sent `shouldReturn` [1, 1, 1]+ asked `shouldReturn` [txHash tx]+ it "sends again, each time, what the store has unconfirmed" $+ desk 2 taking $ \p sent _ -> do+ replicateM_ 2 $ publish p {holds = \_ -> return Pending} tx `shouldReturn` ToPeers 2+ sent `shouldReturn` [2, 2] it "sends nothing for what the store has in a block, or cannot be valid" $- desk ctx (\_ _ -> return Took) $ \p sent -> do- publish p {holds = \_ -> return Confirmed} tx `shouldReturn` Verdict Accepted- publish p {coin = \_ -> return Missing} tx- `shouldReturn` Invalid (UnknownOutput (output 1))- sent `shouldReturn` 0- it "sends again what the store has unconfirmed, each time, and waits for no echo" $- desk ctx (\_ _ -> return Took) $ \p sent -> do- let pending = p {holds = \_ -> return Pending}- replicateM_ 2 $ publish pending tx `shouldReturn` Verdict Accepted- sent `shouldReturn` 2- -- checked first, like any other: a coinbase too young is not sent- publish pending {coin = \_ -> return (Coin {output = TxOut 1000 "", mined = Just 950, coinbase = True})} tx+ desk 2 taking $ \p sent asked -> do+ publish p {holds = \_ -> return Confirmed} tx `shouldReturn` InBlock+ publish p {coin = \_ -> return Missing} tx `shouldReturn` Invalid (UnknownOutput (output 1))+ let young = Coin {output = TxOut 1000 "", mined = Just 950, coinbase = True}+ publish p {holds = \_ -> return Pending, coin = \_ -> return young} tx `shouldReturn` Invalid (Immature (output 1))- sent `shouldReturn` 2- it "says why a node refuses what the store has unconfirmed" $- desk ctx (\_ _ -> return (Rejected "missing inputs")) $ \p sent -> do- publish p {holds = \_ -> return Pending} tx `shouldReturn` Verdict (Refused "missing inputs")- sent `shouldReturn` 1- it "gives the reason of a node, again without asking it again" $- desk ctx (\_ _ -> return (Rejected "too cheap")) $ \p sent -> do- replicateM_ 2 $- publish p tx `shouldReturn` Verdict (Refused "too cheap")- sent `shouldReturn` 1- it "waits again, and sends nothing, for what a node took and did not announce" $- desk ctx (\_ _ -> return Took) $ \p sent -> do- publish p tx `shouldReturn` Verdict Silent- echo p.events- publish p tx `shouldReturn` Verdict Accepted- sent `shouldReturn` 1- it "goes to the next node when one cannot be reached, and tries anew when none can" $ do- desk ctx (\sa _ -> return (if sa == node 1 then NoConnection else Rejected "no")) $ \p sent -> do- publish p tx `shouldReturn` Verdict (Refused "no")- sent `shouldReturn` 2- desk ctx (\_ _ -> return NoConnection) $ \p sent -> do- replicateM_ 2 $ publish p tx `shouldReturn` Verdict Unreachable- sent `shouldReturn` 4- it "does not send again what a node closed the courier on, and goes on publishing the rest" $- desk ctx (\_ t -> return (if t == tx then Closed else Rejected "no")) $ \p sent -> do- replicateM_ 2 $ publish p tx `shouldReturn` Verdict Dropped- publish p (spending [output 2] 900) `shouldReturn` Verdict (Refused "no")- sent `shouldReturn` 2+ sent `shouldReturn` [0, 0]+ asked `shouldReturn` [] it "refuses a transaction larger than nodes relay" $- desk ctx (\_ _ -> return Took) $ \p sent -> do+ desk 1 taking $ \p sent _ -> do let long = (spending [output 1] 1) {outputs = [TxOut 1 (B.replicate 200000 1)]} :: Tx publish p long >>= \case Invalid (TooHeavy _) -> return () other -> expectationFailure (show other)- sent `shouldReturn` 0+ sent `shouldReturn` [0] it "gives it to a node over RPC where it can, and to no peer then" $- desk ctx (\_ _ -> return Took) $ \p sent -> do- echo p.events- publish p {direct = \_ -> return Sent} tx `shouldReturn` Verdict Accepted- publish p {direct = \_ -> return (Declined "min relay fee not met")} (spending [output 2] 900)- `shouldReturn` Verdict (Refused "min relay fee not met")- sent `shouldReturn` 0- it "sends to a peer only what it verified, and the rest over RPC only" $- desk ctx (\_ _ -> return (Rejected "sent")) $ \p sent -> do- let unjudged = (spending [output 1] 900) {inputs = [TxIn (output 1) "script" maxBound]} :: Tx- -- no node to ask: it is not sent, and is not held against it- replicateM_ 2 $ publish p unjudged `shouldReturn` Verdict (Unverified 0)- sent `shouldReturn` 0- -- a node that answers over RPC takes it- publish p {direct = \_ -> return (Declined "no")} unjudged `shouldReturn` Verdict (Refused "no")- -- one that was verified goes to a peer when RPC does not answer- publish p tx `shouldReturn` Verdict (Refused "sent")- sent `shouldReturn` 1- it "sends a transaction once for two requests at the same time" $- desk ctx (\_ _ -> threadDelay 100000 >> return (Rejected "no")) $ \p sent -> do- concurrently (publish p tx) (publish p tx)- `shouldReturn` (Verdict (Refused "no"), Verdict (Refused "no"))- sent `shouldReturn` 1- it "does not take the bytes of one transaction for those of another with its id" $ do- -- same id, other witness: other bytes, so another slot- let other = tx {witness = [["x"]]}- txHash other `shouldBe` txHash tx- desk ctx (\_ t -> return (if t == tx then Took else Rejected "bad witness")) $ \p _ -> do- publish p other `shouldReturn` Verdict (Refused "bad witness")- echo p.events- publish p tx `shouldReturn` Verdict Accepted+ desk 2 taking $ \p sent asked -> do+ publish p {direct = \_ -> return Sent} tx `shouldReturn` ToNode+ publish p {direct = \_ -> return (Declined "min relay fee not met")} tx+ `shouldReturn` Refused "min relay fee not met"+ sent `shouldReturn` [0, 0]+ asked `shouldReturn` [txHash tx]+ it "sends to the peers only what it verified, and the rest over RPC only" $+ desk 2 taking $ \p sent _ -> do+ let unjudged = (spending [output 1] 900) {inputs = [TxIn (output 1) (saying "script") maxBound]} :: Tx+ publish p unjudged `shouldReturn` Unverified 0+ sent `shouldReturn` [0, 0]+ publish p {direct = \_ -> return Sent} unjudged `shouldReturn` ToNode+ -- one that was verified goes to the peers when RPC does not answer+ publish p tx `shouldReturn` ToPeers 2+ sent `shouldReturn` [1, 1]+ it "says so when it has no peer, or reaches none" $ do+ desk 0 taking $ \p _ asked -> do+ publish p tx `shouldReturn` NoPeers+ asked `shouldReturn` []+ desk 2 (\_ _ -> NoConnection) $ \p sent _ -> do+ publish p tx `shouldReturn` NoPeers+ sent `shouldReturn` [1, 1]+ it "counts the peers that took it, and gives the reason when every one refused it" $ do+ desk 3 (\k _ -> if k == 1 then Rejected "too cheap" else NoAnswer) $ \p _ _ ->+ publish p tx `shouldReturn` ToPeers 2+ desk 2 (\_ _ -> Rejected "too cheap") $ \p _ asked -> do+ publish p tx `shouldReturn` Refused "too cheap"+ asked `shouldReturn` []+ desk 2 (\k _ -> if k == 0 then Closed else NoConnection) $ \p _ _ ->+ publish p tx >>= (`shouldSatisfy` \case Refused _ -> True; _ -> False)++ describe "asking for it back" $ do+ let at n = posixSecondsToUTCTime (fromInteger n)+ h n = txHash (spending [output n] 1)+ it "asks for what is due, and keeps it on the schedule for later" $ do+ let m = HashMap.fromList [(h 1, (at 10, [25])), (h 2, (at 20, []))]+ (ready, after) = due (at 10) m+ ready `shouldBe` [h 1]+ HashMap.lookup (h 1) after `shouldBe` Just (at 35, [])+ HashMap.lookup (h 2) after `shouldBe` Just (at 20, [])+ it "forgets a transaction after its last time" $ do+ let (ready, after) = due (at 30) (HashMap.fromList [(h 1, (at 30, []))])+ ready `shouldBe` [h 1]+ after `shouldBe` HashMap.empty+ it "asks past the two minutes in which nodes give back what they took" $+ sum askAgain `shouldSatisfy` (> 120)