haskoin-store 1.10.1 → 1.11.0
raw patch · 11 files changed
+1834/−69 lines, 11 filesdep ~haskoin-coredep ~haskoin-store-dataPVP ok
version bump matches the API change (PVP)
Dependency ranges changed: haskoin-core, haskoin-store-data
API changes (from Hackage documentation)
+ Haskoin.Store.Web: [nodeRpc] :: WebConfig -> !Maybe String
+ Haskoin.Store.Web: [publishTimeout] :: WebLimits -> !Word32
+ Haskoin.Store.Web.Publish: Accepted :: Verdict
+ Haskoin.Store.Web.Publish: Busy :: Answer
+ Haskoin.Store.Web.Publish: Closed :: Delivery
+ Haskoin.Store.Web.Publish: Coin :: !TxOut -> !Maybe BlockHeight -> !Bool -> Coin
+ Haskoin.Store.Web.Publish: Courier :: !Network -> !SockAddr -> WithConnection -> !NominalDiffTime -> !Logger -> Courier
+ Haskoin.Store.Web.Publish: Declined :: !Text -> Direct
+ Haskoin.Store.Web.Publish: Done :: !Verdict -> Slot
+ Haskoin.Store.Web.Publish: Dropped :: Verdict
+ Haskoin.Store.Web.Publish: DuplicateInput :: !OutPoint -> Refusal
+ Haskoin.Store.Web.Publish: Dust :: !Int -> Refusal
+ Haskoin.Store.Web.Publish: Flying :: Slot
+ Haskoin.Store.Web.Publish: Forged :: !Int -> Refusal
+ Haskoin.Store.Web.Publish: Fresh :: Ticket
+ Haskoin.Store.Web.Publish: Full :: Ticket
+ Haskoin.Store.Web.Publish: Immature :: !OutPoint -> Refusal
+ Haskoin.Store.Web.Publish: Invalid :: !Refusal -> Answer
+ Haskoin.Store.Web.Publish: IsCoinbase :: Refusal
+ 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: Missing :: Coin
+ Haskoin.Store.Web.Publish: NoAnswer :: Delivery
+ Haskoin.Store.Web.Publish: NoConnection :: Delivery
+ Haskoin.Store.Web.Publish: NoInputs :: Refusal
+ Haskoin.Store.Web.Publish: NoOutputs :: Refusal
+ Haskoin.Store.Web.Publish: NotFinal :: !Word32 -> Refusal
+ Haskoin.Store.Web.Publish: Overspent :: Refusal
+ Haskoin.Store.Web.Publish: Publishing :: !Limits -> !TVar Outbox -> !IO Rules -> !Tx -> IO Direct -> !TxHash -> IO Bool -> !OutPoint -> IO Coin -> !TxHash -> IO Bool -> !Publisher StoreEvent -> !Bool -> !IO [SockAddr] -> !SockAddr -> Tx -> IO Delivery -> !LogLevel -> Text -> IO () -> Publishing
+ Haskoin.Store.Web.Publish: Refused :: !Text -> Verdict
+ Haskoin.Store.Web.Publish: Rejected :: !Text -> Delivery
+ Haskoin.Store.Web.Publish: Rules :: !Network -> !Tx -> [TxOut] -> [InputVerdict] -> !BlockHeight -> !Word64 -> Rules
+ Haskoin.Store.Web.Publish: Sent :: Direct
+ Haskoin.Store.Web.Publish: Silent :: Verdict
+ Haskoin.Store.Web.Publish: Spent :: !TxHash -> Coin
+ Haskoin.Store.Web.Publish: SpentOutput :: !OutPoint -> !TxHash -> Refusal
+ Haskoin.Store.Web.Publish: TooHeavy :: !Int -> Refusal
+ Haskoin.Store.Web.Publish: TooLarge :: !Int -> Refusal
+ Haskoin.Store.Web.Publish: Took :: Delivery
+ Haskoin.Store.Web.Publish: Unavailable :: Direct
+ Haskoin.Store.Web.Publish: UnknownOutput :: !OutPoint -> Refusal
+ Haskoin.Store.Web.Publish: Unreachable :: Verdict
+ Haskoin.Store.Web.Publish: Unverified :: !Int -> Verdict
+ Haskoin.Store.Web.Publish: Verdict :: !Verdict -> Answer
+ Haskoin.Store.Web.Publish: [capacity] :: Limits -> !Int
+ Haskoin.Store.Web.Publish: [coin] :: Publishing -> !OutPoint -> IO Coin
+ Haskoin.Store.Web.Publish: [coinbase] :: Coin -> !Bool
+ Haskoin.Store.Web.Publish: [connect] :: Courier -> !SockAddr -> WithConnection
+ Haskoin.Store.Web.Publish: [courier] :: Publishing -> !SockAddr -> Tx -> IO Delivery
+ Haskoin.Store.Web.Publish: [direct] :: Publishing -> !Tx -> IO Direct
+ Haskoin.Store.Web.Publish: [events] :: Publishing -> !Publisher StoreEvent
+ Haskoin.Store.Web.Publish: [handover] :: Limits -> !NominalDiffTime
+ Haskoin.Store.Web.Publish: [has] :: Publishing -> !TxHash -> IO Bool
+ Haskoin.Store.Web.Publish: [height] :: Rules -> !BlockHeight
+ Haskoin.Store.Web.Publish: [inputs] :: Rules -> !Tx -> [TxOut] -> [InputVerdict]
+ Haskoin.Store.Web.Publish: [limits] :: Publishing -> !Limits
+ Haskoin.Store.Web.Publish: [logger] :: Courier -> !Logger
+ Haskoin.Store.Web.Publish: [medianTime] :: Rules -> !Word64
+ Haskoin.Store.Web.Publish: [memory] :: Limits -> !NominalDiffTime
+ Haskoin.Store.Web.Publish: [mempool] :: Publishing -> !Bool
+ Haskoin.Store.Web.Publish: [mined] :: Coin -> !Maybe BlockHeight
+ Haskoin.Store.Web.Publish: [net] :: Courier -> !Network
+ Haskoin.Store.Web.Publish: [nodes] :: Publishing -> !IO [SockAddr]
+ Haskoin.Store.Web.Publish: [note] :: Publishing -> !LogLevel -> Text -> IO ()
+ Haskoin.Store.Web.Publish: [outbox] :: Publishing -> !TVar Outbox
+ Haskoin.Store.Web.Publish: [output] :: Coin -> !TxOut
+ Haskoin.Store.Web.Publish: [patience] :: Courier -> !NominalDiffTime
+ Haskoin.Store.Web.Publish: [present] :: Publishing -> !TxHash -> IO Bool
+ Haskoin.Store.Web.Publish: [rules] :: Publishing -> !IO Rules
+ Haskoin.Store.Web.Publish: [tries] :: Limits -> !Int
+ Haskoin.Store.Web.Publish: [wait] :: Limits -> !NominalDiffTime
+ Haskoin.Store.Web.Publish: coinOf :: StoreRead -> OutPoint -> IO Coin
+ Haskoin.Store.Web.Publish: data Answer
+ Haskoin.Store.Web.Publish: data Coin
+ Haskoin.Store.Web.Publish: data Courier
+ Haskoin.Store.Web.Publish: data Delivery
+ Haskoin.Store.Web.Publish: data Direct
+ Haskoin.Store.Web.Publish: data Limits
+ Haskoin.Store.Web.Publish: data Outbox
+ Haskoin.Store.Web.Publish: data Publishing
+ Haskoin.Store.Web.Publish: data Refusal
+ Haskoin.Store.Web.Publish: data Rules
+ Haskoin.Store.Web.Publish: data Slot
+ Haskoin.Store.Web.Publish: data Ticket
+ Haskoin.Store.Web.Publish: data Verdict
+ Haskoin.Store.Web.Publish: deliver :: Courier -> SockAddr -> Tx -> IO Delivery
+ 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.Answer
+ Haskoin.Store.Web.Publish: instance GHC.Classes.Eq Haskoin.Store.Web.Publish.Coin
+ Haskoin.Store.Web.Publish: instance GHC.Classes.Eq Haskoin.Store.Web.Publish.Delivery
+ Haskoin.Store.Web.Publish: instance GHC.Classes.Eq Haskoin.Store.Web.Publish.Direct
+ 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.Refusal
+ 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.Answer
+ Haskoin.Store.Web.Publish: instance GHC.Internal.Show.Show Haskoin.Store.Web.Publish.Coin
+ Haskoin.Store.Web.Publish: instance GHC.Internal.Show.Show Haskoin.Store.Web.Publish.Delivery
+ Haskoin.Store.Web.Publish: instance GHC.Internal.Show.Show Haskoin.Store.Web.Publish.Direct
+ 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.Refusal
+ 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: isDust :: Network -> TxOut -> Bool
+ Haskoin.Store.Web.Publish: judge :: Rules -> Tx -> [Coin] -> Either Refusal (Maybe Int)
+ Haskoin.Store.Web.Publish: maxStandardSize :: Network -> Int
+ Haskoin.Store.Web.Publish: maxTxSize :: Network -> Int
+ Haskoin.Store.Web.Publish: publish :: Publishing -> Tx -> IO Answer
+ Haskoin.Store.Web.Publish: settle :: UTCTime -> Hash256 -> Verdict -> Outbox -> Outbox
- Haskoin.Store: Store :: !PeerMgr -> !Chain -> !BlockStore -> !DatabaseReader -> !Maybe Cache -> !Maybe Member -> !Publisher StoreEvent -> !Publisher StoreEvent -> !Network -> !Ctx -> Store
+ Haskoin.Store: Store :: !PeerMgr -> !Chain -> !BlockStore -> !DatabaseReader -> !Maybe Cache -> !Maybe Member -> !Publisher StoreEvent -> !Publisher StoreEvent -> !Network -> !Ctx -> !SockAddr -> WithConnection -> Store
- Haskoin.Store.Manager: Store :: !PeerMgr -> !Chain -> !BlockStore -> !DatabaseReader -> !Maybe Cache -> !Maybe Member -> !Publisher StoreEvent -> !Publisher StoreEvent -> !Network -> !Ctx -> Store
+ Haskoin.Store.Manager: Store :: !PeerMgr -> !Chain -> !BlockStore -> !DatabaseReader -> !Maybe Cache -> !Maybe Member -> !Publisher StoreEvent -> !Publisher StoreEvent -> !Network -> !Ctx -> !SockAddr -> WithConnection -> Store
- Haskoin.Store.Manager: [connect] :: StoreConfig -> !SockAddr -> WithConnection
+ Haskoin.Store.Manager: [connect] :: Store -> !SockAddr -> WithConnection
- Haskoin.Store.Web: WebConfig :: !String -> !Int -> !Store -> !Int -> !Int -> !Int -> !WebLimits -> !String -> !Bool -> !Maybe Stats -> !Int -> !String -> !String -> !Bool -> !Bool -> !Int -> !Bool -> !Maybe (FilePath, FilePath) -> !Logger -> WebConfig
+ Haskoin.Store.Web: WebConfig :: !String -> !Int -> !Store -> !Int -> !Int -> !Int -> !WebLimits -> !String -> !Bool -> !Maybe Stats -> !Int -> !String -> !String -> !Bool -> !Bool -> !Int -> !Bool -> !Maybe String -> !Maybe (FilePath, FilePath) -> !Logger -> WebConfig
- Haskoin.Store.Web: WebLimits :: !Word32 -> !Word32 -> !Word32 -> !Word32 -> !Word32 -> !Word32 -> !Word64 -> !Word64 -> !Word32 -> WebLimits
+ Haskoin.Store.Web: WebLimits :: !Word32 -> !Word32 -> !Word32 -> !Word32 -> !Word32 -> !Word32 -> !Word64 -> !Word64 -> !Word32 -> !Word32 -> WebLimits
Files
- CHANGELOG.md +74/−0
- README.md +55/−3
- app/Main.hs +19/−0
- haskoin-store.cabal +15/−13
- integration/Haskoin/Store/Integration/Node.hs +11/−0
- src/Haskoin/Store/Manager.hs +7/−2
- src/Haskoin/Store/Web.hs +192/−28
- src/Haskoin/Store/Web/Publish.hs +764/−0
- swagger.yaml +52/−16
- test/Haskoin/Store/IntegrationSpec.hs +404/−7
- test/Haskoin/Store/Web/PublishSpec.hs +241/−0
CHANGELOG.md view
@@ -6,6 +6,80 @@ ## [Unreleased] +## [1.11.0] - 2026-11-12++`POST /transactions` is done another way, and now says what came of the+transaction. `docs/publishing.md` has the design and the measurements.++### Changed+- **A 200 means that a full node accepted the transaction and that the+ server has it.** It used to mean that the transaction was sent, and came+ at once whatever happened. The request now waits for a node to announce+ the transaction back, for `--publish-timeout` (`PUBLISH_TIMEOUT`)+ seconds at most, 30 by default. With a node that whitelists the store+ that takes milliseconds.+- The transaction is handed to one node, on a connection made for it. It+ used to go to half the peers, on the connections the store syncs from.+- The transaction comes into the mempool when a node announces it on a+ connection that never sent it. The store used to ask the peers it had+ sent it to, once, five seconds later.+- The same transaction posted again is sent once, and one that the store+ has is not sent at all.++### Added+- The store refuses, with 400 and the reason, a transaction that it can+ tell no node will take:+ - one that spends an output it does not know, one that a confirmed+ transaction has spent, or a coinbase less than a hundred blocks old;+ - one whose outputs are worth more than its inputs, or that is a+ coinbase;+ - one whose lock time has not passed;+ - one larger than nodes relay, or with an output that is dust;+ - **one with a forged signature**, for coins locked to one key: key+ hash, key, witness key hash plain or nested, and Taproot by its key;+ on Bitcoin Cash with ECDSA and Schnorr signatures, and for coins that+ hold tokens. This is `judgeInputs` of haskoin-core 1.6.2, which also+ verifies multisig: with ECDSA signatures on both networks, with+ Schnorr signatures on Bitcoin Cash, and the multisig of Taproot.+- `--node-rpc` (`NODE_RPC`): the JSON-RPC URL of a full node. The store+ publishes through it (`sendrawtransaction`): the node checks+ everything, says why it refuses, which Bitcoin Core does not tell a+ peer, and does not close the store's connections over what it refuses.+ When the node does not answer, the transaction goes to a peer.+- **A peer is sent only what the store verified.** A transaction with an+ input that the store cannot verify, a script that is not a key or a+ multisig, is refused with 400 unless the store can send it over RPC. A+ peer that finds a transaction invalid may close every connection of+ the store, so sending on what it could not judge let anyone cut the+ store off from its peers.+- The reason of a node that refuses a transaction and says why, as Bitcoin+ Cash Node does, is the message of the 400.+- 500 when no node could be reached, when a node closed the connection on+ receiving the transaction, and when no node announced it in time. The+ reference always listed these errors; the code gave none of them.+- A transaction that a node refused, closed the connection on, or did not+ announce is remembered for ten minutes and not sent again. Other+ transactions are published as before.+- The body of `POST /transactions` is read up to the length of the longest+ transaction of the network in hexadecimal, and a longer one is answered+ 413. The server used to read a body of any size into memory.+- `Store` has `connect`, the function that opens a connection to a node.+- The integration tests run a store against a Bitcoin Core and a Bitcoin+ Cash Node that do not whitelist it.++### Fixed+- **A published transaction was often not seen until a block confirmed+ it.** Bitcoin Core does not give a transaction back on the connection+ that sent it until its timer for announcements has run, five seconds on+ average: the one request the store made at five seconds got `notfound`+ three times in four against Bitcoin Core 32.1. A store whose only node+ did not whitelist it then never took the transaction in.+- **One request could cut a store off from half its peers.** A node that+ finds a transaction invalid may close every connection from the address+ that sent it, and Bitcoin Cash Node 29.2 does. A forged transaction went+ to half the peers on the connections the store syncs from.+- With no peer connected the answer was 200 and nothing was sent.+ ## [1.10.1] - 2026-11-12 ### Changed
README.md view
@@ -227,10 +227,15 @@ curl -X POST --data-binary @tx.hex localhost:3000/transactions ``` -The body is the signed transaction in hex. The store sends it to its peers and-returns the transaction id. It appears in the mempool, and in the wallet's-history, as soon as a peer relays it back.+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. +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 sends nothing twice.+[Publishing](#publishing) has the rest.+ ### 6. Stay up to date without polling ```sh@@ -338,6 +343,8 @@ | `--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 | | `--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 |@@ -507,6 +514,10 @@ as a form (`application/x-www-form-urlencoded`), so that it does not have to fit in a URL. `/transactions` does not: `POST` there publishes a transaction.+- **A transaction to publish has a largest size**: the body of+ `POST /transactions` may be as long as the longest transaction of the+ network in hexadecimal, 8 MB with SegWit and 2 MB on Bitcoin Cash. More+ is answered 413 without being read whole. - **A 503 says when to try again**, in `Retry-After`. - **HTTPS is optional and off by default.** With `--tls-cert` and `--tls-key` the server speaks HTTPS on its port, and nothing else. A@@ -547,6 +558,46 @@ - The **web server** answers from RocksDB, and from Redis for what is cached there. +### Publishing++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.++- **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.+- **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.+- **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.+- **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.++`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.+ ### The database The chain and the mempool are kept apart.@@ -675,6 +726,7 @@ | `Haskoin.Store.Cache` | The Redis cache of a server alone | | `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.Publish` | Publishing a transaction through a full node | 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
@@ -129,6 +129,7 @@ noXPub :: !Bool, healthCheckInterval :: !Int, accessLog :: !Bool,+ nodeRpc :: !String, tlsCert :: !String, tlsKey :: !String, bloom :: !Bool,@@ -224,6 +225,8 @@ env "HEALTH_CHECK_INTERVAL" 1 readMaybe accessLog <- env "ACCESS_LOG" False parseBool+ nodeRpc <-+ env "NODE_RPC" "" pure bloom <- env "BLOOM" True parseBool noWeb <-@@ -269,6 +272,8 @@ 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.@@ -437,6 +442,13 @@ <> 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 $@@ -599,6 +611,12 @@ flag c.accessLog True $ long "access-log" <> helpEnv "ACCESS_LOG" "Log every request, with the address of the client and the time it took"+ nodeRpc <-+ 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"+ <> value c.nodeRpc tlsCert <- strOption $ metavar "FILE"@@ -766,6 +784,7 @@ noBlockchainInfo = cfg.noBlockchainInfo, healthCheckInterval = cfg.healthCheckInterval, accessLog = cfg.accessLog,+ nodeRpc = if null cfg.nodeRpc then Nothing else Just cfg.nodeRpc, tls = case (cfg.tlsCert, cfg.tlsKey) of ("", "") -> Nothing ("", _) -> error "--tls-key needs --tls-cert"
haskoin-store.cabal view
@@ -5,7 +5,7 @@ -- see: https://github.com/sol/hpack name: haskoin-store-version: 1.10.1+version: 1.11.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@@ -78,6 +78,7 @@ Haskoin.Store.Web Haskoin.Store.Web.Docs Haskoin.Store.Web.Events+ Haskoin.Store.Web.Publish Haskoin.Store.Web.Router other-modules: Paths_haskoin_store@@ -107,9 +108,9 @@ , file-embed >=0.0.16.0 && <0.1 , hashable >=1.5.1.0 && <1.6 , hashtables >=1.4.2 && <1.5- , haskoin-core >=1.6.1 && <1.7+ , haskoin-core >=1.6.2 && <1.7 , haskoin-node >=2.5.1 && <2.6- , haskoin-store-data ==2.0.*+ , haskoin-store-data >=2.0.1 && <2.1 , hedis >=0.16.4 && <0.17 , http-types >=0.12.6 && <0.13 , lens >=5.3.6 && <5.4@@ -172,9 +173,9 @@ , file-embed >=0.0.16.0 && <0.1 , hashable >=1.5.1.0 && <1.6 , hashtables >=1.4.2 && <1.5- , haskoin-core >=1.6.1 && <1.7+ , haskoin-core >=1.6.2 && <1.7 , haskoin-node >=2.5.1 && <2.6- , haskoin-store-data ==2.0.*+ , haskoin-store-data >=2.0.1 && <2.1 , hedis >=0.16.4 && <0.17 , http-types >=0.12.6 && <0.13 , lens >=5.3.6 && <5.4@@ -235,10 +236,10 @@ , filepath >=1.5.5.0 && <1.6 , hashable >=1.5.1.0 && <1.6 , hashtables >=1.4.2 && <1.5- , haskoin-core >=1.6.1 && <1.7+ , haskoin-core >=1.6.2 && <1.7 , haskoin-node >=2.5.1 && <2.6 , haskoin-store- , haskoin-store-data ==2.0.*+ , haskoin-store-data >=2.0.1 && <2.1 , hedis >=0.16.4 && <0.17 , http-types >=0.12.6 && <0.13 , lens >=5.3.6 && <5.4@@ -292,6 +293,7 @@ Haskoin.Store.OutageSpec Haskoin.Store.TestBlocks Haskoin.Store.Web.EventsSpec+ Haskoin.Store.Web.PublishSpec Haskoin.Store.Web.RouterSpec Haskoin.Store.WebSpec Haskoin.StoreSpec@@ -325,10 +327,10 @@ , file-embed >=0.0.16.0 && <0.1 , hashable >=1.5.1.0 && <1.6 , hashtables >=1.4.2 && <1.5- , haskoin-core >=1.6.1 && <1.7+ , haskoin-core >=1.6.2 && <1.7 , haskoin-node >=2.5.1 && <2.6 , haskoin-store- , haskoin-store-data ==2.0.*+ , haskoin-store-data >=2.0.1 && <2.1 , hedis >=0.16.4 && <0.17 , hspec >=2.11.18 && <2.12 , http-types >=0.12.6 && <0.13@@ -392,10 +394,10 @@ , file-embed >=0.0.16.0 && <0.1 , hashable >=1.5.1.0 && <1.6 , hashtables >=1.4.2 && <1.5- , haskoin-core >=1.6.1 && <1.7+ , haskoin-core >=1.6.2 && <1.7 , haskoin-node >=2.5.1 && <2.6 , haskoin-store- , haskoin-store-data ==2.0.*+ , haskoin-store-data >=2.0.1 && <2.1 , hedis >=0.16.4 && <0.17 , http-types >=0.12.6 && <0.13 , lens >=5.3.6 && <5.4@@ -456,10 +458,10 @@ , file-embed >=0.0.16.0 && <0.1 , hashable >=1.5.1.0 && <1.6 , hashtables >=1.4.2 && <1.5- , haskoin-core >=1.6.1 && <1.7+ , haskoin-core >=1.6.2 && <1.7 , haskoin-node >=2.5.1 && <2.6 , haskoin-store- , haskoin-store-data ==2.0.*+ , haskoin-store-data >=2.0.1 && <2.1 , hedis >=0.16.4 && <0.17 , http-types >=0.12.6 && <0.13 , integration
integration/Haskoin/Store/Integration/Node.hs view
@@ -23,6 +23,7 @@ ( NodeKind (..), Node (..), nodeContainer,+ strangerContainer, nodeNetwork, RpcError (..), rpc,@@ -123,6 +124,16 @@ BitcoinCore -> [], ports = [18443, 18444] }++-- | The same node without the whitelist: it treats the store as it treats+-- any peer that connected to it. It announces transactions on its timer+-- and not at once, does not answer a request for its mempool, and may+-- close the connections of a peer that sends it an invalid transaction.+strangerContainer :: NodeKind -> String -> ContainerSpec+strangerContainer kind image =+ spec {name = spec.name <> "-stranger", command = filter (/= "-whitelist=0.0.0.0/0") spec.command}+ where+ spec = nodeContainer kind image -- | Error returned by a node. data RpcError = RpcError !Text !Value
src/Haskoin/Store/Manager.hs view
@@ -142,7 +142,11 @@ -- | network constants net :: !Network, -- | secp256k1 context- ctx :: !Ctx+ ctx :: !Ctx,+ -- | how the store opens a connection to a node, for those it makes+ -- besides the ones of the peer manager+ -- ("Haskoin.Store.Web.Publish")+ connect :: !(SockAddr -> WithConnection) } -- | Configuration for a 'Store'.@@ -251,7 +255,8 @@ events = events, pub = pub, net = cfg.net,- ctx = cfg.ctx+ ctx = cfg.ctx,+ connect = cfg.connect } -- | The store as a member of a fleet, if the configuration says that it is
src/Haskoin/Store/Web.hs view
@@ -97,7 +97,7 @@ (.|), ) import Control.Applicative ((<|>))-import Control.Lens ((.~), (^.))+import Control.Lens ((&), (.~), (?~), (^.)) import Control.Monad ( filterM, forM_,@@ -114,7 +114,9 @@ ( Encoding, ToJSON (..), Value,+ (.=), )+import Data.Aeson.KeyMap qualified as KeyMap import Data.Aeson qualified as A import Data.Aeson.Encode.Pretty ( Config (..),@@ -140,7 +142,10 @@ import Data.HashMap.Strict qualified as HashMap import Data.HashSet (HashSet) import Data.HashSet qualified as HashSet+import Data.Foldable qualified import Data.Int (Int64)+import Data.List qualified+import Data.Maybe qualified import Data.List (nub) import Data.Maybe ( catMaybes,@@ -177,11 +182,11 @@ chainGetAncestor, chainGetBest, getPeers,+ logAt, logDebug, logError, logInfo, logWarn,- sendMessage, withSource, ) import Haskoin.Script@@ -215,6 +220,7 @@ import Haskoin.Store.Manager import Haskoin.Store.Web.Docs (swaggerPage, swaggerYaml) import Haskoin.Store.Web.Events+import Haskoin.Store.Web.Publish qualified as Publish import Haskoin.Store.Web.Router hiding (header) import Haskoin.Store.WebCommon import Haskoin.Transaction@@ -247,12 +253,15 @@ ( Application, Middleware, Request (..),+ RequestBodyLength (..), Response,+ getRequestBodyChunk, mapResponseHeaders, modifyResponse, responseLBS, responseStatus, responseStream,+ setRequestBodyChunks, ) import Network.Wai.Handler.Warp ( defaultSettings,@@ -289,7 +298,6 @@ import Text.Read (readMaybe) import UnliftIO ( TVar,- async, atomically, bracket, bracket_,@@ -299,6 +307,7 @@ fromException, handleAny, link,+ atomicModifyIORef', newIORef, newTQueueIO, newTVarIO,@@ -312,6 +321,7 @@ throwIO, timeout, try,+ tryAny, withAsync, writeIORef, writeTQueue,@@ -340,13 +350,17 @@ blockTimeout :: !Word64, -- | seconds that a request may take before the log has a warning -- about it ('logIt')- slowRequest :: !Word32+ slowRequest :: !Word32,+ -- | seconds that @POST \/transactions@ waits for a node to announce+ -- the transaction back ("Haskoin.Store.Web.Publish")+ publishTimeout :: !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, and a warning for requests of five seconds.+-- block timeout of four hours, a warning for requests of five seconds, and+-- thirty seconds for a published transaction to come back. instance Default WebLimits where def = WebLimits@@ -358,7 +372,8 @@ xpubGapInit = 20, txTimeout = 3600 `div` 2, blockTimeout = 4 * 3600,- slowRequest = 5+ slowRequest = 5,+ publishTimeout = 30 } -- | Configuration of the web server.@@ -400,6 +415,10 @@ healthCheckInterval :: !Int, -- | log every request, at the level of information ('logIt') accessLog :: !Bool,+ -- | URL of the JSON-RPC interface of a full node, with its user and+ -- password, to publish transactions through ('sendNode'); 'Nothing'+ -- to publish through peers only+ nodeRpc :: !(Maybe String), -- | a certificate and its private key, in files in PEM format, to -- serve HTTPS instead of HTTP; 'Nothing' for HTTP, which is the -- default@@ -429,7 +448,10 @@ -- for a store without a cache cacheUp :: !(TVar Bool), -- | the events that the clients of @\/events@ read ('feedEvents')- eventLog :: !EventLog+ eventLog :: !EventLog,+ -- | the transactions being published, and those that failed not long+ -- ago ("Haskoin.Store.Web.Publish")+ outbox :: !(TVar Publish.Outbox) } -- | StatsD metrics of the web server: a timing for each kind of request,@@ -663,6 +685,7 @@ forM_ config.store.fleet $ \member -> atomically (writeTVar member.healthy (isOK first)) cacheUp <- cacheCheck >>= newTVarIO eventLog <- newEventLog+ outbox <- newTVarIO Publish.emptyOutbox 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))@@ -739,7 +762,7 @@ 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 $ app+ serve . webSocketEvents st . logged . anyOrigin . preflight . autohead . compressed . cappedBody st $ app where priceUpdater session = unless config.noBlockchainInfo@@ -934,6 +957,40 @@ | net == bch = Just "bch" | otherwise = Nothing +-- | Read no more of the body of a transaction to publish than a+-- transaction can be long: twice 'Publish.maxTxSize' in hexadecimal, and+-- some room for white space. More is answered 413, by its length when the+-- client gave it, and else as soon as that much was read.+--+-- The router reads the body whole before it knows what to do with it.+-- Without this, anyone could have the server hold a body of any size.+-- Other requests with a body are forms of parameters, and are not capped+-- here.+cappedBody :: WebState -> Middleware+cappedBody st app request respond+ | requestMethod request == methodPost,+ filter (not . T.null) (pathInfo request) == ["transactions"] =+ case requestBodyLength request of+ KnownLength n | n > fromIntegral most -> refuse+ _ -> do+ left <- newIORef most+ let chunk = do+ bytes <- getRequestBodyChunk request+ now <- atomicModifyIORef' left (\l -> let l' = l - C.length bytes in (l', l'))+ when (now < 0) (throwIO RequestTooLarge)+ return bytes+ app (setRequestBodyChunks chunk request) respond `catch` \case+ RequestTooLarge -> refuse+ e -> throwIO e+ | otherwise = app request respond+ where+ most = maxPublishBody st.config.store.net+ refuse = errorResponse st RequestTooLarge >>= respond++-- | The most bytes of the body of @POST \/transactions@.+maxPublishBody :: Network -> Int+maxPublishBody net = 2 * Publish.maxTxSize net + 4096+ -- | Stop handling the request, and answer with an error ('guarded'). raise :: WebState -> Except -> IO a raise _ = throwIO@@ -1499,29 +1556,136 @@ -- POST Transaction -- --- | Handler for @POST \/transactions@: broadcast a transaction to half of the peers and return its hash.+-- | 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"). 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 handlePostTx st (PostTx tx) =- withMetrics st (.txPost) $ do- let cfg = st.config- (publishTx cfg tx)- return (TxId (txHash tx))+ withMetrics st (.txPost) $+ Publish.publish (publishing st) tx >>= \case+ Publish.Verdict Publish.Accepted -> return (TxId (txHash tx))+ Publish.Invalid why -> raise st (UserError (show why))+ Publish.Verdict (Publish.Refused why) ->+ raise st (UserError ("A node refused the transaction: " <> T.unpack why))+ Publish.Verdict (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 --- | Send transaction to all connected peers.-publishTx ::- WebConfig ->- Tx ->- IO ()-publishTx cfg tx = do- ps <- getPeers cfg.store.peerMgr- let c = max 1 (length ps `div` 2)- forM_ (take c ps) $ \p -> do- sendMessage (MTx tx) p.mailbox- void . async $ do- threadDelay (5 * 1000 * 1000)- let v = if cfg.store.net.segWit then InvWitnessTx else InvTx- g = MGetData (GetData [InvVector v (txHash tx).get])- sendMessage g p.mailbox+-- | 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+-- the last eleven blocks.+chainRules :: WebState -> IO Publish.Rules+chainRules st = do+ best <- st.query.getBestBlock >>= maybe (return Nothing) st.query.getBlock+ times <- maybe (return []) (back (11 :: Int)) best+ return+ Publish.Rules+ { net = own.net,+ inputs = \tx -> judgeInputs own.net own.ctx tx . map Just,+ height = maybe 0 (.height) best,+ medianTime = case Data.List.sort times of+ [] -> 0+ sorted -> fromIntegral (sorted !! (length sorted `div` 2))+ }+ where+ own = st.config.store+ back n b+ | n <= 1 = return [b.header.timestamp]+ | otherwise =+ (b.header.timestamp :) <$> (st.query.getBlock b.header.prev >>= maybe (return []) (back (n - 1)))++-- | Give a transaction to a full node over its JSON-RPC interface+-- (@sendrawtransaction@).+--+-- The node runs every script and holds the transaction to its own rules+-- and its own mempool, says why it refuses, and does not close the+-- connections of the store over what it refuses, as it may over a+-- transaction that a peer sends it. A transaction that the node takes+-- comes back to the store as any other: the node announces it on the+-- connection that the store syncs from.+--+-- 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 that the node has already was sent.+sendNode :: WebState -> String -> Tx -> IO Publish.Direct+sendNode st url tx =+ tryAny (Wreq.Session.postWith opts st.session url body) >>= \case+ Left e -> unheard (cs (show e))+ Right r -> case A.decode (r ^. Wreq.responseBody) >>= answer of+ Nothing -> unheard ("HTTP " <> cs (show (r ^. Wreq.responseStatus . Wreq.statusCode)))+ Just Nothing -> return Publish.Sent+ Just (Just why)+ | "already" `T.isInfixOf` why -> return Publish.Sent+ | otherwise -> return (Publish.Declined why)+ where+ opts = Wreq.defaults & Wreq.checkResponse ?~ (\_ _ -> return ())+ body =+ A.object+ [ "jsonrpc" .= ("1.0" :: Text),+ "id" .= ("haskoin-store" :: Text),+ "method" .= ("sendrawtransaction" :: Text),+ "params" .= [encodeHex (runPutS (serialize tx))]+ ]+ -- the error of the answer if it has one, nothing if it has a result+ answer v = do+ A.Object top <- Just v+ case KeyMap.lookup "error" top of+ Just (A.Object e) | Just (A.String why) <- KeyMap.lookup "message" e -> Just (Just why)+ Just A.Null | KeyMap.member "result" top -> Just Nothing+ Nothing | KeyMap.member "result" top -> Just Nothing+ _ -> Nothing+ unheard why = do+ logWarn+ (withSource "Publish" st.config.logger)+ ("The node did not take a transaction over RPC, which goes to a peer if it is verified: " <> why)+ return Publish.Unavailable++-- | 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.+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,+ direct = maybe (\_ -> return Publish.Unavailable) (sendNode st) st.config.nodeRpc,+ has = fmap (maybe False (not . (.deleted))) . 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,+ courier =+ Publish.deliver+ Publish.Courier+ { net = own.net,+ connect = own.connect,+ patience = 10,+ logger = withSource "Courier" st.config.logger+ },+ note = \level -> logAt level (withSource "Publish" st.config.logger)+ }+ where+ own = st.config.store -- GET Mempool / Events --
+ src/Haskoin/Store/Web/Publish.hs view
@@ -0,0 +1,764 @@+{-# LANGUAGE DuplicateRecordFields #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# OPTIONS_GHC -Wno-ambiguous-fields #-}++-- |+-- Module : Haskoin.Store.Web.Publish+-- Copyright : (c) Haskoin Developers+-- License : MIT+-- Maintainer : jprupp@protonmail.ch+-- Stability : experimental+-- Portability : POSIX+--+-- 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 already is answered at once.+--+-- 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.+--+-- 5. __Answer__ when the transaction is in the mempool, or when the node+-- said that it refused it, or after 'Limits'.@wait@ seconds.+--+-- 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.+--+-- = 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.+--+-- * __One node.__ What is sent to one node reaches the others if it is+-- good. Sending it to many spreads what is bad.+--+-- * __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.+--+-- 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.+--+-- = 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.+module Haskoin.Store.Web.Publish+ ( -- * Publishing+ Publishing (..),+ Limits (..),+ Answer (..),+ Verdict (..),+ Direct (..),+ publish,++ -- * What the store can check+ Coin (..),+ Refusal (..),+ Rules (..),+ judge,+ 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 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.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.POSIX (utcTimeToPOSIXSeconds)+import Data.Word (Word32, Word64)+import Haskoin+ ( Hash256,+ Message (..),+ MessageCommand (..),+ Network (..),+ NetworkAddress (..),+ OutPoint (..),+ Ping (..),+ Pong (..),+ Reject (..),+ Tx (..),+ TxHash,+ BlockHeight,+ InputVerdict (..),+ TxIn (..),+ TxOut (..),+ VarString (..),+ doubleSHA256,+ nullOutPoint,+ sockToHostAddress,+ txHash,+ txHashToHex,+ )+import Haskoin.Node+ ( LogLevel (..),+ Logger,+ PeerConfig (..),+ PeerEvent (..),+ WithConnection,+ buildVersion,+ peer,+ peerMessageDone,+ sendMessage,+ wrapPeer,+ )+import Haskoin.Node.Peer.Lock (Lock (..))+import Haskoin.Store.Common (StoreEvent (..), StoreRead (..))+import Haskoin.Store.Data (BlockRef (..), Spender (..), TxData (..), isCoinbaseTx)+import NQE (Publisher, newMailbox, receive, withPublisher, withSubscription)+import Network.Socket (SockAddr (..))+import System.Random (randomIO)+import UnliftIO+ ( STM,+ TVar,+ atomically,+ newTVarIO,+ onException,+ race,+ readTVar,+ readTVarIO,+ retrySTM,+ stateTVar,+ timeout,+ waitCatch,+ withAsync,+ writeTVar,+ )+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)++-- | What came of giving a transaction to a full node over its RPC.+data Direct+ = -- | the node took it into its mempool, or had it+ Sent+ | -- | the node refused it, with this reason+ Declined !Text+ | -- | there is no such node, or it did not answer+ Unavailable+ deriving (Eq, Show)++-- | The answer to a request to publish.+data Answer+ = -- | what came of handing it to a node+ Verdict !Verdict+ | -- | refused by the store, before anything was sent+ Invalid !Refusal+ | -- | the outbox is full+ Busy+ deriving (Eq, Show)++-- | An output that a transaction spends, as the store knows it.+data Coin+ = -- | it is there to spend, or only an unconfirmed transaction spends+ -- it+ Coin+ { -- | the output, as it is in the transaction that made it+ output :: !TxOut,+ -- | the height of the block that confirmed it, if one did+ mined :: !(Maybe BlockHeight),+ -- | whether a coinbase made it+ coinbase :: !Bool+ }+ | -- | a confirmed transaction has spent it+ Spent !TxHash+ | -- | the store does not know it+ Missing+ deriving (Eq, Show)++-- | Why the store refuses a transaction without sending it.+data Refusal+ = TooLarge !Int+ | NoInputs+ | NoOutputs+ | IsCoinbase+ | DuplicateInput !OutPoint+ | UnknownOutput !OutPoint+ | SpentOutput !OutPoint !TxHash+ | Overspent+ | Immature !OutPoint+ | NotFinal !Word32+ | TooHeavy !Int+ | Dust !Int+ | Forged !Int+ deriving (Eq)++instance Show Refusal where+ show (TooLarge n) = "The transaction is too large: " <> show n <> " bytes"+ show NoInputs = "The transaction has no inputs"+ show NoOutputs = "The transaction has no outputs"+ show IsCoinbase = "A coinbase transaction cannot be published"+ show (DuplicateInput o) = "The transaction spends the same output twice: " <> showOutPoint o+ show (UnknownOutput o) = "The transaction spends an output that this server does not know: " <> showOutPoint o+ show (SpentOutput o by) =+ "The transaction spends an output that a confirmed transaction has spent: "+ <> showOutPoint o+ <> " by "+ <> cs (txHashToHex by)+ show Overspent = "The outputs of the transaction are worth more than its inputs"+ show (Immature o) = "The transaction spends a coinbase that is not a hundred blocks old: " <> showOutPoint o+ show (NotFinal lock) = "The lock time of the transaction has not passed: " <> show lock+ 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"++showOutPoint :: OutPoint -> String+showOutPoint o = cs (txHashToHex o.hash) <> ":" <> show o.index++-- | The largest transaction that a block of the network can hold, in+-- bytes. With SegWit a block weighs four million units at most, and a byte+-- of witness weighs one, so a transaction that is nearly all witness comes+-- close to four million bytes. Without it, the rule of Bitcoin Cash is a+-- million bytes for a transaction.+--+-- Nodes relay far smaller ones only, a hundred thousand bytes or so. The+-- store does not hold their rule, which is theirs to change: this is the+-- size past which no node can take the transaction.+maxTxSize :: Network -> Int+maxTxSize net+ | net.segWit = 4000000+ | otherwise = 1000000++-- | What the store knows of the chain and the network when it looks at a+-- transaction.+data Rules = Rules+ { net :: !Network,+ -- | what can be said of the signature of each input, given the+ -- outputs they spend: 'judgeInputs' of haskoin-core+ inputs :: !(Tx -> [TxOut] -> [InputVerdict]),+ -- | height of the best block+ height :: !BlockHeight,+ -- | median of the times of the last eleven blocks, which is the time+ -- that lock times are held to (BIP 113)+ medianTime :: !Word64+ }++-- | What the store can say of a transaction from its own index, given the+-- coin of each input in order ('coinOf').+--+-- Every check here is one whose answer a node would give too, and that+-- does not depend on what else is in a mempool:+--+-- * __What makes it no transaction__: no inputs or outputs, an input+-- twice, a coinbase, longer than a block can hold ('maxTxSize').+-- * __The coins__: each input spends an output that the store knows, that+-- no confirmed transaction has spent, and a coinbase only after a+-- hundred blocks. The outputs are not worth more than the inputs.+-- * __The lock time__ has passed, by height or by the median time, unless+-- every input waives it.+-- * __What nodes do not relay__: more than 'maxStandardSize', and an+-- 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.+-- * __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+-- the store cannot read, is left to the node.+--+-- A conflict with an unconfirmed transaction is not a reason to refuse:+-- whether one may replace the other is for the node to say, and the+-- mempool of the store may hold what the network has dropped. The fee is+-- not checked either: what a node asks for changes with its mempool.+--+-- The answer for a transaction that passes says whether every signature+-- was verified, or names the first input that the store could not judge:+-- a script, a hash type other than all. Such a transaction may be good,+-- and only a full node can say. See 'publish' for what becomes of it.+judge :: Rules -> Tx -> [Coin] -> Either Refusal (Maybe Int)+judge rules tx coins = do+ when (size > maxTxSize rules.net) $ Left (TooLarge size)+ when (null tx.inputs) $ Left NoInputs+ when (null tx.outputs) $ Left NoOutputs+ when (any ((== nullOutPoint) . (.outpoint)) tx.inputs) $ Left IsCoinbase+ mapM_ (Left . DuplicateInput) (firstDuplicate (map (.outpoint) tx.inputs))+ spent <- mapM known (zip (map (.outpoint) tx.inputs) coins)+ -- 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)+ unless final $ Left (NotFinal tx.locktime)+ when (heavy > maxStandardSize rules.net) $ Left (TooHeavy heavy)+ mapM_ (Left . Dust) (take 1 [i | (i, o) <- zip [0 ..] tx.outputs, isDust rules.net o])+ let verdicts = zip [0 ..] (rules.inputs tx spent)+ mapM_ (Left . Forged) (take 1 [i | (i, InputForged) <- verdicts])+ return (listToMaybe [i | (i, InputUndecided) <- verdicts])+ where+ size = B.length (encode tx)+ -- what the limit of relay counts: weight with SegWit, bytes without+ heavy+ | rules.net.segWit = 3 * B.length (encode (tx {witness = []} :: Tx)) + size+ | otherwise = size+ known (_, Coin {output}) = Right output+ known (o, Spent by) = Left (SpentOutput o by)+ known (o, Missing) = Left (UnknownOutput o)+ -- a coinbase can be spent in the hundredth block after its own+ mature (o, Coin {coinbase = True, mined = Just h})+ | rules.height + 1 < h + 100 = Left (Immature o)+ mature _ = Right ()+ -- IsFinalTx of Bitcoin Core, for the block after the best one+ final+ | tx.locktime == 0 = True+ | tx.locktime < 500000000 = tx.locktime < rules.height + 1 || waived+ | otherwise = fromIntegral tx.locktime < rules.medianTime || waived+ waived = all ((== maxBound) . (.sequence)) tx.inputs+ firstDuplicate = go HashMap.empty+ where+ go _ [] = Nothing+ go seen (o : os)+ | HashMap.member o seen = Just o+ | otherwise = go (HashMap.insert o () seen) os++-- | The most that nodes relay: 400,000 units of weight with SegWit, which+-- is 100,000 bytes of a transaction without witness, and 100,000 bytes on+-- Bitcoin Cash.+maxStandardSize :: Network -> Int+maxStandardSize net+ | net.segWit = 400000+ | otherwise = 100000++-- | Whether an output is dust: worth less than three times what it costs,+-- at the lowest fee nodes relay, to make it and to spend it. Nodes do not+-- relay a transaction with one. This is @IsDust@ of Bitcoin Core at its+-- default of 3,000 satoshi a kilobyte, and of Bitcoin Cash Node: 546+-- satoshi for an output to a key hash, 294 to a witness key hash.+--+-- An output that cannot be spent, which starts with @OP_RETURN@, holds+-- data and may hold no value.+isDust :: Network -> TxOut -> Bool+isDust net o+ | unspendable = False+ | otherwise = toInteger o.value < 3 * toInteger (made + spent)+ where+ s = o.script+ unspendable = (not (B.null s) && B.head s == 0x6a) || B.length s > 10000+ made = 8 + lengthOfLength + B.length s+ lengthOfLength+ | B.length s < 0xfd = 1+ | B.length s <= 0xffff = 3+ | otherwise = 5+ -- what an input that spends it adds: less when its signature is in+ -- the witness+ spent+ | net.segWit && witnessProgram = 67+ | otherwise = 148+ witnessProgram =+ B.length s >= 4+ && B.length s <= 42+ && (B.head s == 0 || (B.head s >= 0x51 && B.head s <= 0x60))+ && fromIntegral (B.index s 1) == B.length s - 2++-- | The coin of an output, from the queries of a request.+coinOf :: StoreRead -> OutPoint -> IO Coin+coinOf q o =+ q.getTxData o.hash >>= \case+ Just td+ | not td.deleted,+ out : _ <- drop (fromIntegral o.index) td.tx.outputs ->+ q.getSpender o >>= \case+ Nothing -> return (coin td out)+ Just s ->+ q.getTxData s.txid >>= \case+ Just by | not by.deleted, Just _ <- confirmed by.block -> return (Spent s.txid)+ _ -> return (coin td out)+ _ -> return Missing+ where+ coin td out = Coin {output = out, mined = confirmed td.block, coinbase = isCoinbaseTx td.tx}+ 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+-- 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+ rules :: !(IO Rules),+ -- | give the transaction to a full node over its RPC, where the store+ -- was given the RPC of one+ direct :: !(Tx -> IO Direct),+ -- | whether the store has the transaction, from the queries of the+ -- request+ has :: !(TxHash -> IO Bool),+ -- | 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+ courier :: !(SockAddr -> Tx -> IO Delivery),+ note :: !(LogLevel -> Text -> IO ())+ }++-- | Publish a transaction, and say what came of it. See the module header.+publish :: Publishing -> Tx -> IO Answer+publish p tx = do+ known <- p.has txid+ if known+ then return (Verdict Accepted)+ else+ (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+ v <- listening (send unverified) `onException` done Unreachable+ done v+ say v+ return (Verdict v)+ 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+ 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++seconds :: NominalDiffTime -> Int+seconds t = max 0 (round (t * 1000000))++-- | What a node did with a transaction handed to it.+data Delivery+ = -- | it answered the ping that followed: it is done with the+ -- transaction, and said nothing against it+ Took+ | -- | it sent a @reject@ message for the transaction, with this reason+ Rejected !Text+ | -- | it closed the connection after getting the transaction and+ -- before answering the ping+ Closed+ | -- | it kept the connection and did not answer in time+ NoAnswer+ | -- | no connection, or it ended before the transaction was sent+ NoConnection+ deriving (Eq, Show)++-- | What the courier needs.+data Courier = Courier+ { net :: !Network,+ connect :: !(SockAddr -> WithConnection),+ -- | seconds for all of it+ patience :: !NominalDiffTime,+ logger :: !Logger+ }++-- | Hand a transaction to a node on a connection made for it, and closed+-- after: the version handshake, the transaction, a @ping@, and what the+-- node answers until its @pong@.+--+-- The connection is a peer of "Haskoin.Node.Peer" with a publisher of its+-- own, so nothing of it reaches the peer manager or the block store.+deliver :: Courier -> SockAddr -> Tx -> IO Delivery+deliver c sa tx =+ withPublisher $ \pub -> withSubscription pub $ \sub -> do+ (inbox, mailbox) <- newMailbox+ busy <- newTVarIO Free+ pending <- newTVarIO 0+ sent <- newTVarIO False+ refused <- newTVarIO Nothing+ nonce <- randomIO+ token <- randomIO+ now <- getCurrentTime+ let config =+ PeerConfig+ { pub = pub,+ net = c.net,+ label = "courier " <> cs (show sa),+ connect = c.connect sa,+ logger = c.logger,+ lookupBlock = const (return Nothing)+ }+ node = wrapPeer config busy pending mailbox+ nowhere = NetworkAddress 0 (sockToHostAddress (SockAddrInet 0 0))+ there = NetworkAddress 0 (sockToHostAddress sa)+ version = buildVersion c.net nonce 0 nowhere there (floor (utcTimeToPOSIXSeconds now))+ next =+ receive sub >>= \case+ PeerMessage _ m -> peerMessageDone node >> return m+ _ -> next+ handshake theirs ours+ | theirs && ours = return ()+ | otherwise =+ next >>= \case+ MVersion _ -> MVerAck `sendMessage` node >> handshake True ours+ MVerAck -> handshake theirs True+ _ -> handshake theirs ours+ answer =+ next >>= \case+ MPong (Pong n) | n == token -> return ()+ MPing (Ping n) -> MPong (Pong n) `sendMessage` node >> answer+ MReject Reject {message = MCTx, reason} -> do+ atomically (writeTVar refused (Just (T.strip (T.decodeUtf8Lenient reason.get))))+ answer+ _ -> answer+ talk = do+ MVersion version `sendMessage` node+ handshake False False+ atomically (writeTVar sent True)+ MTx tx `sendMessage` node+ MPing (Ping token) `sendMessage` node+ answer+ withAsync (peer config node inbox) $ \a -> do+ -- when the connection ends, what the node sent before it did may+ -- still be on its way to 'answer': give it a moment+ r <- timeout (seconds c.patience) (race (waitCatch a >> threadDelay 200000) talk)+ was <- readTVarIO sent+ why <- readTVarIO refused+ return $ case (why, r) of+ (Just reason, _) -> Rejected reason+ (Nothing, Just (Right ())) -> Took+ (Nothing, Just (Left ())) -> if was then Closed else NoConnection+ (Nothing, Nothing) -> if was then NoAnswer else NoConnection
swagger.yaml view
@@ -11,7 +11,7 @@ - url: './' info:- version: &version 1.10.1+ version: &version 1.11.0 title: Haskoin contact: name: JP Rupp@@ -1967,16 +1967,23 @@ must be in the format that nodes of the network read: on BCH, which has no SegWit, the format without witness data. - **A 200 means the transaction was sent, not that the network accepted it.**- The server does not validate transactions; it gives the transaction to half- of its peers, which check it as they would any other. If they accept it, they- relay it back, and it appears in the mempool and in balances within seconds.- To know whether it was accepted, look for it with `/transaction/{txid}` or- watch `/events`.+ **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. - Five seconds later the server asks those peers for the transaction back. A- peer that accepted it sends it, and the server imports it like any other- transaction it hears about; a peer that refused it has nothing to send.+ 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.++ A transaction that this server has already is answered 200 and not sent.+ Posting the same transaction again is always safe: it is sent once. operationId: postTransaction parameters: - $ref: '#/components/parameters/prettyParam'@@ -2001,7 +2008,7 @@ 1486a7f1a2047ed6719fa1ad5484f15c37c104887c88ac00000000" responses: '200':- description: Transaction accepted by network peer.+ description: A full node accepted the transaction, and this server has it. content: application/json: schema:@@ -2009,18 +2016,47 @@ example: txid: 0b7cb07d848959fbd02ad6e2ed21fac888b8e8011c6b8c5362d05c703066ae67 '400':- description: Transaction rejected.+ description: |+ The transaction was refused, and `message` says why:++ * 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.+ * 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.+ * 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. content: application/json: schema: $ref: '#/components/schemas/Error'+ '413':+ description: |+ The body is longer than a transaction of the network can be in+ hexadecimal: 8,000,000 characters on networks with SegWit and+ 2,000,000 on Bitcoin Cash, and some white space.+ content:+ application/json:+ schema:+ $ref: '#/components/schemas/Error' '500': description: |- Publishing transaction failed for one of these reasons:+ The server could not find out whether the network took the+ transaction. Post the same transaction again to ask again. - * No network peers available.- * Timeout while publishing, most likely silent rejection.- * Network peer disconnected while publishing transaction.+ * `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. content: application/json: schema:
test/Haskoin/Store/IntegrationSpec.hs view
@@ -34,7 +34,7 @@ module Haskoin.Store.IntegrationSpec (spec) where import Control.Lens ((&), (.~), (?~), (^.))-import Control.Monad (forM_, forever, unless, void, when)+import Control.Monad (forM_, forever, replicateM_, unless, void, when) import Data.Aeson (Value (..), eitherDecode, object, toJSON, (.=)) import Data.Aeson.Key qualified as Key import Data.Aeson.KeyMap qualified as KeyMap@@ -66,13 +66,17 @@ Ctx, Network (..), OutPoint (..),+ Tx (..), TxHash (..),+ TxIn (..),+ TxOut (..), XPrvKey, addrToText, addressToScriptBS, blockHashToHex, bloomCreate, bloomInsert,+ decodeHex, deriveXPubKey, encodeHex, headerHash,@@ -92,13 +96,16 @@ xPubExport, xPubWitnessAddr, )-import Haskoin.Node (LogLevel (..), Logger (..), withConnection, withLogger)+import Haskoin qualified as H+import Haskoin.Node (LogLevel (..), Logger (..), getPeers, withConnection, withLogger) import Haskoin.Store ( DeriveType (..), Store (..), StoreConfig (..), WebConfig (..),+ WebLimits (..), XPubSpec (..),+ decodeTxBody, runWebSocket, withStore, )@@ -162,11 +169,20 @@ it "check the store against Bitcoin Core and Bitcoin Cash Node" $ pendingWith why Right () -> prepareContext $ \ctx -> do+ forM_ [BitcoinCore, BitcoinCashNode] $ \kind ->+ 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 (withEnv BitcoinCore ctx) $ describe "Bitcoin Core" (chainSpec BitcoinCore) aroundAll (withEnv BitcoinCashNode ctx) $ describe "Bitcoin Cash Node" (chainSpec BitcoinCashNode) +-- | The name of a node, as people write it.+name :: NodeKind -> String+name BitcoinCore = "Bitcoin Core"+name BitcoinCashNode = "Bitcoin Cash Node"+ chainSpec :: NodeKind -> SpecWith Env chainSpec kind = do it "sees payments up to exactly the gap, and those the gap reaches later" gapTest@@ -187,6 +203,11 @@ 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+ 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 it "speaks HTTP as browsers, proxies and long requests need it" httpTest it "serves HTTPS, and nothing else, when given a certificate" tlsTest it "tells each client of the transactions it chose, and takes up where it left" eventsTest@@ -232,14 +253,21 @@ gap = 32 withEnv :: NodeKind -> Ctx -> (Env -> IO ()) -> IO ()-withEnv kind ctx go = do+withEnv = withEnvOf True++-- | The same with a node that whitelists the store, as an operator's own+-- node should, or with one that does not. The second announces+-- 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 image <- testImage withContainer (redisContainer image) $ \redis -> do redisPort <- hostPort redis 6379 let redisURL = "redis://127.0.0.1:" <> show redisPort waitRedis redisURL writeOldKeys redisURL- withContainer (nodeContainer kind image) $ \c -> do+ withContainer ((if whitelisted then nodeContainer else strangerContainer) kind image) $ \c -> do rpcPort <- hostPort c 18443 p2pPort <- hostPort c 18444 let node =@@ -256,10 +284,11 @@ void $ generateTo node 150 miner withRamDir $ \dir -> withTestLogger $ \logger -> do let net = nodeNetwork kind- cfg = storeConfig net ctx logger dir redisURL p2pPort False+ cfg = (storeConfig net ctx logger dir redisURL p2pPort False) {syncMempool = whitelisted} withStore cfg $ \store -> do listening $ \(cachedSock, cachedPort) -> listening $ \(plainSock, plainPort) -> do- let web s = runWebSocket (webConfig logger s)+ let patient c = c {limits = c.limits {publishTimeout = 45}} :: WebConfig+ web s = runWebSocket ((if whitelisted then id else patient) (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 ->@@ -345,7 +374,8 @@ maxLaggingBlocks = 100, maxPendingTxs = 10000, minPeers = 0,- limits = def,+ -- a short wait, for the transactions that a node takes and says nothing of+ limits = def {publishTimeout = 3}, version = "test", noMempool = False, stats = Nothing,@@ -356,6 +386,7 @@ noBlockchainInfo = False, healthCheckInterval = 5, accessLog = False,+ nodeRpc = Nothing, tls = Nothing, logger = logger }@@ -1697,6 +1728,372 @@ let described = BL.toStrict (reference ^. Wreq.responseBody) 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.+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+ inNode <- rpc env.node "getrawmempool" []+ arr inNode `shouldSatisfy` elem (String (cs firstId))+ publishTx env first >>= \(code, _) -> code `shouldBe` 200+ -- 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+ publishTx env orphan >>= \(code, v) -> do+ code `shouldBe` 400+ fmap (T.isInfixOf "does not know") (textField "message" v) `shouldBe` Just True+ -- a forged one: the outputs are not those that were signed. The wallets+ -- of both nodes pay to one key, so the store can tell by itself+ second <- unsent env+ let forged = cheaper second+ publishTx env forged >>= \(code, v) -> do+ code `shouldBe` 400+ 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+ free <- unsentFree env+ (code, v) <- publishTx env free+ case env.node.kind of+ -- Bitcoin Cash Node says why+ 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+ (unpaid, _) <- getJSON env.plain ("/transaction/" <> cs (txHashToHex (txHash free)))+ unpaid `shouldBe` 404+ publishTx env second >>= \(c, _) -> c `shouldBe` 200+ -- 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+ -- an output that a block has spent+ void $ mine env 1+ publishTx env (cheaper first) >>= \(c, e) -> do+ c `shouldBe` 400+ fmap (T.isInfixOf "confirmed transaction has spent") (textField "message" e) `shouldBe` Just True++-- | A transaction of the node's wallet, signed and not sent.+unsent :: Env -> IO Tx+unsent env = do+ to <- getNewAddress env.node+ raw <-+ rpc env.node "createrawtransaction" [toJSON ([] :: [Value]), object [Key.fromText to .= btc 300000]]+ funded <- rpc env.node "fundrawtransaction" [raw]+ signed <- rpc env.node "signrawtransactionwithwallet" [funded ! "hex"]+ case signed ! "hex" of+ String h -> either fail return (decodeTxBody env.net (cs h))+ other -> fail ("No transaction: " <> show other)++-- | Sign a raw transaction with the wallet of the node, with a hash type+-- if one is given.+signed :: Env -> Maybe Text -> Value -> IO Tx+signed env hashType raw = do+ let how = maybe [] (\t -> [toJSON ([] :: [Value]), String t]) hashType+ answer <- rpc env.node "signrawtransactionwithwallet" ([raw] <> how)+ case answer ! "hex" of+ String h -> either fail return (decodeTxBody env.net (cs h))+ other -> fail ("No transaction: " <> show other)++-- | A transaction of the node's wallet that pays no fee: one coin, sent+-- whole to one address.+unsentFree :: Env -> IO Tx+unsentFree env = do+ coins <- arr <$> rpc env.node "listunspent" []+ to <- getNewAddress env.node+ case coins of+ c : _ -> do+ let spent = object ["txid" .= (c ! "txid"), "vout" .= (c ! "vout")]+ rpc env.node "createrawtransaction" [toJSON [spent], object [Key.fromText to .= (c ! "amount")]]+ >>= signed env Nothing+ [] -> fail "The wallet has no coin"++-- | The same transaction paying one satoshi less: another transaction,+-- with signatures that are not of it.+cheaper :: Tx -> Tx+cheaper tx = tx {outputs = [o {value = o.value - 1} | o <- tx.outputs]}++-- | Post a transaction to publish.+publishTx :: Env -> Tx -> IO (Int, Value)+publishTx env tx = publishBody env (cs (encodeHex (encode tx)))++publishBody :: Env -> C8.ByteString -> IO (Int, Value)+publishBody env bytes = do+ r <- Wreq.postWith opts (env.plain <> "/transactions") bytes+ return (r ^. Wreq.responseStatus . Wreq.statusCode, body (r ^. Wreq.responseBody))+ where+ opts = Wreq.defaults & Wreq.checkResponse ?~ (\_ _ -> return ())++-- | A good transaction whose input the store cannot judge, because it+-- would have to run a script: it spends a coin locked by a script of its+-- own, here one that anyone can spend, which the wallet of the node pays+-- first. The script is in a witness script hash on Bitcoin, and in a+-- script hash on Bitcoin Cash.+unjudged :: Env -> IO Tx+unjudged env = do+ let script = "\x51" :: C8.ByteString+ locked+ | env.net.segWit = H.PayWitnessScriptHash (sha256 script)+ | otherwise = H.PayScriptHash (H.addressHash script)+ (coin, _) <- payScript env locked+ tx <- spendingTo env coin+ return $+ if env.net.segWit+ then tx {witness = [[script]]}+ 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.+--+-- 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.+strangerTest :: Env -> IO ()+strangerTest env = do+ 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))+ unsent env >>= published+ -- the store follows the chain+ void $ mine env 1+ where+ published tx = do+ publishTx env tx >>= \(code, _) -> code `shouldBe` 200+ (seen, _) <- getJSON env.plain ("/transaction/" <> cs (txHashToHex (txHash tx)))+ seen `shouldBe` 200++-- | 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.+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+ config url = (webConfig logger env.store) {nodeRpc = Just url} :: WebConfig+ asking = env {plain = "http://127.0.0.1:" <> show port} :: Env+ unanswered = env {plain = "http://127.0.0.1:" <> show deafPort} :: Env+ up e = (== 200) . fst <$> getJSON e.plain "/block/best?notx=true"+ refused why (code, v) = (code, fmap (T.isInfixOf why) (textField "message" v)) `shouldBe` (400, Just True)+ -- nothing listens on port 1+ let withBoth go =+ withAsync (runWebSocket (config rpcURL) sock) $ \a ->+ withAsync (runWebSocket (config "http://127.0.0.1:1/") deaf) $ \b ->+ link a >> link b >> go+ withBoth $ do+ 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+ -- 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++-- | Pay a script from the wallet of the node, and give the coin: where it+-- is, and the output that holds it.+payScript :: Env -> H.ScriptOutput -> IO (OutPoint, TxOut)+payScript env so = do+ address <- maybe (fail "No address for the script") return (H.outputAddress env.net env.ctx so)+ txid <- sendMany env.node [(addrText env address, 300000)]+ waitSynced env+ funding <- getRawTx env.node txid+ vout <- case [ numV n+ | o <- arr (funding ! "vout"),+ Just n <- [field "n" o],+ (textField "hex" =<< field "scriptPubKey" o) == Just (scriptHex env address)+ ] of+ v : _ -> return v+ [] -> fail "No output to the script"+ spent <- maybe (fail "Not a transaction id") return (hexToTxHash txid)+ return (OutPoint spent (fromIntegral vout), TxOut 300000 (H.marshal env.ctx so))++-- | A transaction that spends a coin of 300,000 to the wallet of the+-- node, with nothing in its input yet.+spendingTo :: Env -> OutPoint -> IO Tx+spendingTo env coin = do+ to <- getNewAddress env.node+ info <- rpc env.node "getaddressinfo" [String to]+ back <- maybe (fail "No script for the address of the wallet") return (textField "scriptPubKey" info >>= decodeHex)+ return+ Tx+ { version = 2,+ inputs = [TxIn {outpoint = coin, script = "", sequence = maxBound}],+ outputs = [TxOut {value = 299000, script = back}],+ witness = [],+ locktime = 0+ }++-- | A Schnorr signature of Bitcoin Cash, made the plain way: this is a+-- test, and the nonce is no secret worth keeping. haskoin-core verifies+-- these and does not make them.+cashSchnorr :: Ctx -> H.SecKey -> H.Hash256 -> C8.ByteString+cashSchnorr ctx k h = case [sig | Just sig <- map attempt [1 ..]] of+ sig : _ -> sig+ [] -> error "No nonce"+ where+ n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141 :: Integer+ p = 2 ^ (256 :: Int) - 2 ^ (32 :: Int) - 977 :: Integer+ word256 i = let b = H.integerToBS i in C8.replicate (32 - C8.length b) '\0' <> b+ public = H.exportPubKey ctx True (H.derivePubKey ctx k)+ attempt nonce = do+ r <- H.derivePubKey ctx <$> H.secKey (word256 nonce)+ let (x, y) = C8.splitAt 32 (C8.drop 1 (H.exportPubKey ctx False r))+ square = powMod (H.bsToInteger y) ((p - 1) `div` 2) == 1+ e = H.bsToInteger (encode (H.sha256 (x <> public <> encode h))) `mod` n+ if square then Just (x <> word256 ((nonce + e * H.bsToInteger k.get) `mod` n)) else Nothing+ powMod _ 0 = 1 :: Integer+ powMod b i+ | even i = let half = powMod b (i `div` 2) in half * half `mod` p+ | otherwise = b * powMod b (i - 1) `mod` p++-- | Spends of a coin that two of three keys open, published through a+-- peer: the store has no RPC here, and sends a peer only what it+-- verified. Each is made with keys of the test, not by the wallet of the+-- node, and the node takes it.+--+-- On both networks, the multisig of old, with ECDSA signatures. On+-- Bitcoin, the multisig of Taproot, a script with a Schnorr signature for+-- each key that signs. On Bitcoin Cash, the same multisig as of old+-- signed with Schnorr signatures, where a field of bits says which keys+-- signed. The same spend paying one satoshi less, which its signatures+-- are not of, is refused.+multisigTest :: Env -> IO ()+multisigTest env = do+ let ctx = env.ctx+ secret n = fromMaybe (error "Not a secret key") (H.secKey (C8.replicate 31 '\0' <> C8.singleton n))+ secrets = map secret "\x0b\x0c\x0d"+ points = map (H.derivePubKey ctx) secrets+ twoOfThree = H.PayMulSig (map (H.wrapPubKey True) points) 2+ right = either (fail . ("Could not make the transaction: " <>)) return+ 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+ -- signed by the first two keys, the old way+ old locked hashType = do+ (coin, out) <- payScript env locked+ unsigned <- spendingTo env coin+ let input =+ H.SigInput+ { script = locked,+ value = out.value,+ outpoint = coin,+ sighash = hashType,+ redeem = Just twoOfThree+ }+ right (H.signTx env.net ctx unsigned [input] (take 2 secrets)) >>= published+ case env.kind of+ BitcoinCore -> do+ inWitness <- right (H.payToWitnessScriptAddress ctx twoOfThree)+ old (fromMaybe (error "No script") (H.addressToOutput inWitness)) H.sigHashAll+ -- Taproot: signed by the first and the third key+ let xOnly = C8.drop 1 . H.exportPubKey ctx True+ leaf =+ H.Script $+ [H.opPushData (xOnly (head points)), H.OP_CHECKSIG]+ <> concat [[H.opPushData (xOnly k), H.OP_CHECKSIGADD] | k <- drop 1 points]+ <> [H.OP_2, H.OP_NUMEQUAL]+ internal = H.derivePubKey ctx (secret '\x0e')+ tree = H.TaprootOutput {internalKey = internal, mast = Just (H.MASTLeaf H.tapscriptLeafVersion leaf)}+ (coin, out) <- payScript env (H.taprootScriptOutput ctx tree)+ unsigned <- spendingTo env coin+ let leafOf = Just (H.leafHash H.tapscriptLeafVersion leaf, 0xffffffff)+ digest <- right (H.taprootSigHash unsigned [out] 0 H.sigHashDefault Nothing leafOf)+ let sign k = (H.signSchnorr ctx k (H.hashToMsg digest)).get+ spend =+ H.ScriptPathSpend+ H.ScriptPathData+ { annex = Nothing,+ -- an item for each key, that of the last key first+ stack = [sign (secrets !! 2), "", sign (head secrets)],+ script = leaf,+ extIsOdd = odd (H.keyParity ctx (H.taprootOutputKey ctx tree)),+ leafVersion = H.tapscriptLeafVersion,+ internalKey = internal,+ control = [],+ opaqueScript = Nothing+ }+ witness <- right (H.encodeTaprootWitness ctx spend)+ published (unsigned {witness = [witness]} :: Tx)+ BitcoinCashNode -> do+ inScript <- right (H.payToScriptAddress ctx twoOfThree)+ let locked = fromMaybe (error "No script") (H.addressToOutput inScript)+ old locked (H.setForkIdFlag H.sigHashAll)+ -- Schnorr signatures of the first and the third key: bits 0 and 2+ (coin, out) <- payScript env locked+ unsigned <- spendingTo env coin+ code <- right (H.encodeOutput ctx twoOfThree)+ digest <- right (H.txSigHashForkId env.net unsigned code out.value 0 (H.setForkIdFlag H.sigHashAll))+ let sign k = cashSchnorr ctx k digest <> "\x41"+ pushed = [sign (head secrets), sign (secrets !! 2), H.marshal ctx twoOfThree]+ items = [H.OP_5] <> map H.opPushData pushed+ published (unsigned {inputs = [i {script = encode (H.Script items)} | i <- unsigned.inputs]} :: Tx)++-- | Tokens of Bitcoin Cash. A transaction of the node's wallet that makes+-- tokens, and one that spends the output that holds them, are published:+-- the store verifies their signatures, which cover the tokens, and does+-- not take them for forged. A forged spend of the tokens is refused.+tokenTest :: Env -> IO ()+tokenTest env = do+ coins <- arr <$> rpc env.node "listunspent" []+ to <- getNewAddress env.node+ -- the category of new tokens is the transaction of a first output spent+ coin <- case [c | c <- coins, numV (c ! "vout") == 0] of+ c : _ -> return c+ [] -> fail "The wallet has no first output"+ let amount less = case coin ! "amount" of+ Number n -> Number (n - less)+ other -> other+ holding less =+ object+ [ Key.fromText to+ .= object+ [ "amount" .= amount less,+ "tokenData" .= object ["category" .= (coin ! "txid"), "amount" .= ("1000" :: Text)]+ ]+ ]+ spending txid = toJSON [object ["txid" .= txid, "vout" .= (0 :: Int)]]+ published tx = publishTx env tx >>= \(code, v) -> (code, v) `shouldSatisfy` ((== 200) . fst)+ genesis <-+ rpc env.node "createrawtransaction" [spending (coin ! "txid"), toJSON [holding 0.001]]+ >>= signed env Nothing+ published genesis+ spend <-+ rpc env.node "createrawtransaction" [spending (txHashToHex (txHash genesis)), toJSON [holding 0.002]]+ >>= signed env Nothing+ publishTx env (cheaper spend) >>= \(code, v) -> do+ code `shouldBe` 400+ fmap (T.isInfixOf "does not verify") (textField "message" v) `shouldBe` Just True+ published spend -- | The events of the server over a WebSocket. A client that says nothing -- hears of every transaction; one that names an extended public key, an
+ test/Haskoin/Store/Web/PublishSpec.hs view
@@ -0,0 +1,241 @@+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE OverloadedStrings #-}++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 Data.ByteString qualified as B+import Data.Serialize (encode)+import Data.Time.Clock (UTCTime)+import Data.Time.Clock.POSIX (posixSecondsToUTCTime)+import Data.Word (Word32, Word64)+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++-- | An output, told apart by a number.+output :: Word32 -> OutPoint+output n = OutPoint (TxHash (doubleSHA256 (encode n))) n++-- | A transaction that spends outputs and pays a value.+spending :: [OutPoint] -> Word64 -> Tx+spending ins v =+ Tx+ { version = 2,+ inputs = [TxIn {outpoint = o, script = "", sequence = maxBound} | o <- ins],+ outputs = [TxOut {value = v, script = B.replicate 25 1}],+ witness = [],+ 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}++-- | 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.+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+ | otherwise = InputVerified++-- | 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,+ has = \h -> elem h <$> 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)++spec :: Spec+spec = prepareContext $ \ctx -> 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" $+ check tx [held 1000, held 500] `shouldBe` Right Nothing+ it "refuses an output it does not know, and one spent in a block" $ do+ check tx [held 1000, Missing] `shouldBe` Left (UnknownOutput (output 2))+ let by = (output 9).hash+ check tx [Spent by, held 1000] `shouldBe` Left (SpentOutput (output 1) by)+ it "refuses outputs worth more than the inputs" $+ check tx [held 1000, held 499] `shouldBe` Left Overspent+ it "refuses the same output twice, a coinbase, and nothing to spend or pay" $ do+ check (spending [output 1, output 1] 1) [held 1, held 1] `shouldBe` Left (DuplicateInput (output 1))+ 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+ check one [young 902] `shouldBe` Left (Immature (output 1))+ check one [young 901] `shouldBe` Right Nothing+ check one [(young 902) {coinbase = False}] `shouldBe` Right Nothing+ it "refuses a lock time that has not passed, unless every input waives it" $ do+ let locked n = (spending [output 1] 900) {locktime = n, inputs = [TxIn (output 1) "" 0]} :: Tx+ check (locked 1000) [held 1000] `shouldBe` Right Nothing+ check (locked 1001) [held 1000] `shouldBe` Left (NotFinal 1001)+ check (locked 1599999999) [held 1000] `shouldBe` Right Nothing+ check (locked 1600000000) [held 1000] `shouldBe` Left (NotFinal 1600000000)+ check ((locked 1001) {inputs = [TxIn (output 1) "" maxBound]} :: Tx) [held 1000] `shouldBe` Right Nothing+ it "refuses an output worth less than it costs to spend, and not one that holds data" $ do+ let paying v script = (spending [output 1] 0) {outputs = [TxOut v script]} :: Tx+ keyHash = "\x76\xa9\x14" <> B.replicate 20 7 <> "\x88\xac"+ witnessKeyHash = "\x00\x14" <> B.replicate 20 7+ check (paying 545 keyHash) [held 1000] `shouldBe` Left (Dust 0)+ check (paying 546 keyHash) [held 1000] `shouldBe` Right Nothing+ check (paying 293 witnessKeyHash) [held 1000] `shouldBe` Left (Dust 0)+ check (paying 294 witnessKeyHash) [held 1000] `shouldBe` Right Nothing+ 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+ 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++ 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 "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 "sends nothing for what the store has, or cannot be valid" $+ desk ctx (\_ _ -> return Took) $ \p sent -> do+ publish p {has = \_ -> return True} tx `shouldReturn` Verdict Accepted+ publish p {coin = \_ -> return Missing} tx+ `shouldReturn` Invalid (UnknownOutput (output 1))+ sent `shouldReturn` 0+ 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+ it "refuses a transaction larger than nodes relay" $+ desk ctx (\_ _ -> return Took) $ \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+ 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