Skip to main content

dash_sdk/core/
transaction.rs

1use crate::platform::fetch_current_no_parameters::FetchCurrent;
2use crate::platform::types::epoch::Epoch;
3use crate::{Error, Sdk};
4use bip37_bloom_filter::{BloomFilter, BloomFilterData};
5use dapi_grpc::core::v0::{
6    get_block_request, transactions_with_proofs_request, transactions_with_proofs_response,
7    GetBlockRequest, GetTransactionRequest, GetTransactionResponse, TransactionsWithProofsRequest,
8    TransactionsWithProofsResponse,
9};
10use dpp::dashcore::consensus::Decodable;
11use dpp::dashcore::hashes::Hash;
12use dpp::dashcore::{
13    Address, Block, BlockHash, InstantLock, MerkleBlock, OutPoint, Transaction, Txid,
14};
15use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof;
16use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof;
17use dpp::prelude::AssetLockProof;
18
19use dapi_grpc::tonic::Code;
20use rs_dapi_client::transport::TransportError;
21use rs_dapi_client::{DapiClientError, DapiRequestExecutor, IntoInner, RequestSettings};
22use std::time::Duration;
23use tokio::time::{sleep, timeout};
24
25/// A Core transaction fetched by id, plus the finality metadata needed to
26/// reconstruct an asset-lock proof from it (an InstantSend proof when the
27/// InstantLock is known, otherwise a ChainLock proof once chain-locked).
28#[derive(Clone, Debug)]
29pub struct FetchedCoreTransaction {
30    /// The decoded transaction.
31    pub transaction: Transaction,
32    /// Height of the block the transaction was mined in (0 if unconfirmed).
33    pub height: u32,
34    /// Whether the transaction's block is ChainLocked.
35    pub is_chain_locked: bool,
36    /// Whether the transaction is InstantSend-locked. Deliberately surfaced but
37    /// not required by the invitation claim: the proof carries the islock from
38    /// the link, and consensus re-verifies it — this flag is informational.
39    pub is_instant_locked: bool,
40}
41
42/// Where a Core transaction was mined, as the queried DAPI node reports it.
43///
44/// Everything here is self-reported by one node. In particular `block_hash`
45/// is not verified: a caller that builds anything on the placement must check
46/// the hash against a header chain it verified itself.
47#[derive(Clone, Debug)]
48#[non_exhaustive]
49pub struct CoreTransactionPlacement {
50    /// The decoded transaction.
51    pub transaction: Transaction,
52    /// Height of the block the transaction was mined in (0 if unconfirmed).
53    pub height: u32,
54    /// Hash of the block the transaction was mined in; `None` when unconfirmed
55    /// or when the reported bytes are not a 32-byte hash.
56    pub block_hash: Option<BlockHash>,
57    /// Whether the transaction's block is ChainLocked.
58    pub is_chain_locked: bool,
59    /// Whether the transaction is InstantSend-locked.
60    pub is_instant_locked: bool,
61}
62
63/// Whether an SDK error is a gRPC `NOT_FOUND` (the requested tx is unknown to
64/// the node), as opposed to a transient/transport failure. Used to distinguish
65/// "retry with a reversed txid" from "surface the error".
66fn error_is_not_found(err: &Error) -> bool {
67    match err {
68        Error::DapiClientError(DapiClientError::Transport(TransportError::Grpc(status))) => {
69            status.code() == Code::NotFound
70        }
71        Error::NoAvailableAddressesToRetry(inner) => error_is_not_found(inner),
72        _ => false,
73    }
74}
75
76/// DAPI fills `GetTransactionResponse.block_hash` by hex-decoding Core's
77/// display string, so the bytes arrive reversed relative to the hash's
78/// internal order.
79fn block_hash_from_display_bytes(bytes: &[u8]) -> Option<BlockHash> {
80    let mut hash: [u8; 32] = bytes.try_into().ok()?;
81    hash.reverse();
82    Some(BlockHash::from_byte_array(hash))
83}
84
85/// Decode the consensus-encoded transaction bytes of a `getTransaction` reply.
86fn decode_transaction(bytes: &[u8]) -> Result<Transaction, Error> {
87    Transaction::consensus_decode(&mut &bytes[..]).map_err(|e| Error::CoreError(e.into()))
88}
89
90/// Decode the consensus-encoded block bytes of a `getBlock` reply.
91fn decode_block(bytes: &[u8]) -> Result<Block, Error> {
92    Block::consensus_decode(&mut &bytes[..]).map_err(|e| Error::CoreError(e.into()))
93}
94
95impl Sdk {
96    /// Fetch a Core transaction by its id via DAPI `getTransaction`.
97    ///
98    /// `txid` is the transaction id as a hex string (big-endian display form).
99    /// Returns `Ok(Some(..))` with the decoded transaction plus its
100    /// confirmation/lock metadata; `Ok(None)` when the node does not know the tx
101    /// (empty response or gRPC `NOT_FOUND`) so the caller can retry with the id
102    /// byte-reversed; and `Err` for a transient/transport failure that must not
103    /// be masked by a doomed reversed-id retry.
104    pub async fn get_transaction(
105        &self,
106        txid: &str,
107    ) -> Result<Option<FetchedCoreTransaction>, Error> {
108        let Some(response) = self
109            .fetch_core_transaction(txid, RequestSettings::default())
110            .await?
111        else {
112            return Ok(None);
113        };
114
115        Ok(Some(FetchedCoreTransaction {
116            transaction: decode_transaction(&response.transaction)?,
117            height: response.height,
118            is_chain_locked: response.is_chain_locked,
119            is_instant_locked: response.is_instant_locked,
120        }))
121    }
122
123    /// Fetch where a Core transaction was mined via DAPI `getTransaction`,
124    /// including the block hash the node reports.
125    ///
126    /// Same `txid` form and `Ok(None)` / `Err` contract as
127    /// [`Sdk::get_transaction`]; `settings` override the SDK's request
128    /// settings for this call, so a caller on a deadline can bound it. The
129    /// placement is unverified — see [`CoreTransactionPlacement`].
130    pub async fn get_transaction_placement(
131        &self,
132        txid: &str,
133        settings: RequestSettings,
134    ) -> Result<Option<CoreTransactionPlacement>, Error> {
135        let Some(response) = self.fetch_core_transaction(txid, settings).await? else {
136            return Ok(None);
137        };
138
139        Ok(Some(CoreTransactionPlacement {
140            transaction: decode_transaction(&response.transaction)?,
141            height: response.height,
142            block_hash: block_hash_from_display_bytes(&response.block_hash),
143            is_chain_locked: response.is_chain_locked,
144            is_instant_locked: response.is_instant_locked,
145        }))
146    }
147
148    /// Fetch a Core block by hash via DAPI `getBlock`.
149    ///
150    /// Returns `Ok(None)` when the node does not serve the block (gRPC
151    /// `NOT_FOUND` or an empty reply) and `Err` for any other failure,
152    /// including bytes that do not decode as a block. The bytes are
153    /// self-reported by the queried node: check `block_hash()` against a
154    /// header chain you trust before relying on anything in the block.
155    pub async fn get_block_by_hash(
156        &self,
157        hash: &BlockHash,
158        settings: RequestSettings,
159    ) -> Result<Option<Block>, Error> {
160        let response = match self
161            .execute(
162                GetBlockRequest {
163                    // Core's `getblock` takes the display-order hex `BlockHash` formats to.
164                    block: Some(get_block_request::Block::Hash(hash.to_string())),
165                },
166                settings,
167            )
168            .await
169            .into_inner()
170        {
171            Ok(response) => response,
172            Err(e) => {
173                let err: Error = e.into();
174                return if error_is_not_found(&err) {
175                    Ok(None)
176                } else {
177                    Err(err)
178                };
179            }
180        };
181
182        if response.block.is_empty() {
183            return Ok(None);
184        }
185        decode_block(&response.block).map(Some)
186    }
187
188    /// Run `getTransaction`, mapping an unknown transaction (gRPC `NOT_FOUND`
189    /// or an empty reply) to `Ok(None)` and every other failure to `Err`.
190    async fn fetch_core_transaction(
191        &self,
192        txid: &str,
193        settings: RequestSettings,
194    ) -> Result<Option<GetTransactionResponse>, Error> {
195        let response = match self
196            .execute(
197                GetTransactionRequest {
198                    id: txid.to_string(),
199                },
200                settings,
201            )
202            .await
203            .into_inner()
204        {
205            Ok(response) => response,
206            Err(e) => {
207                let err: Error = e.into();
208                return if error_is_not_found(&err) {
209                    Ok(None)
210                } else {
211                    Err(err)
212                };
213            }
214        };
215
216        if response.transaction.is_empty() {
217            return Ok(None);
218        }
219        Ok(Some(response))
220    }
221
222    /// Starts the stream to listen for instant send lock messages
223    pub async fn start_instant_send_lock_stream(
224        &self,
225        from_block_hash: Vec<u8>,
226        address: &Address,
227    ) -> Result<dapi_grpc::tonic::Streaming<TransactionsWithProofsResponse>, Error> {
228        let address_bytes = address.as_unchecked().payload_to_vec();
229
230        // create the bloom filter
231        let bloom_filter = BloomFilter::builder(1, 0.001)
232            .expect("this FP rate allows up to 10000 items")
233            .add_element(&address_bytes)
234            .build();
235
236        let bloom_filter_proto = {
237            let BloomFilterData {
238                v_data,
239                n_hash_funcs,
240                n_tweak,
241                n_flags,
242            } = bloom_filter.into();
243            dapi_grpc::core::v0::BloomFilter {
244                v_data,
245                n_hash_funcs,
246                n_tweak,
247                n_flags,
248            }
249        };
250
251        let core_transactions_stream = TransactionsWithProofsRequest {
252            bloom_filter: Some(bloom_filter_proto),
253            count: 0, // Subscribing to new transactions as well
254            send_transaction_hashes: true,
255            from_block: Some(transactions_with_proofs_request::FromBlock::FromBlockHash(
256                from_block_hash,
257            )),
258        };
259        self.execute(core_transactions_stream, RequestSettings::default())
260            .await
261            .into_inner()
262            .map_err(|e| e.into())
263    }
264
265    /// Waits for a response for the asset lock proof
266    pub async fn wait_for_asset_lock_proof_for_transaction(
267        &self,
268        mut stream: dapi_grpc::tonic::Streaming<TransactionsWithProofsResponse>,
269        transaction: &Transaction,
270        time_out: Option<Duration>,
271    ) -> Result<AssetLockProof, Error> {
272        let transaction_id = transaction.txid();
273
274        let _span = tracing::debug_span!(
275            "wait_for_asset_lock_proof_for_transaction",
276            transaction_id = transaction_id.to_string(),
277        )
278        .entered();
279
280        tracing::debug!("waiting for messages from stream");
281
282        // Define an inner async block to handle the stream processing.
283        let stream_processing = async {
284            loop {
285                // TODO: We should retry if Err is returned
286                let message = stream
287                    .message()
288                    .await
289                    .map_err(|e| Error::Generic(format!("can't receive message: {e}")))?;
290
291                let Some(TransactionsWithProofsResponse { responses }) = message else {
292                    return Err(Error::Generic("stream closed unexpectedly".to_string()));
293                };
294
295                match responses {
296                    Some(
297                        transactions_with_proofs_response::Responses::InstantSendLockMessages(
298                            instant_send_lock_messages,
299                        ),
300                    ) => {
301                        tracing::debug!(
302                            "received {} instant lock message(s)",
303                            instant_send_lock_messages.messages.len()
304                        );
305
306                        for instant_lock_bytes in instant_send_lock_messages.messages {
307                            let instant_lock =
308                                InstantLock::consensus_decode(&mut instant_lock_bytes.as_slice())
309                                    .map_err(|e| {
310                                    tracing::error!("invalid asset lock: {}", e);
311
312                                    Error::CoreError(e.into())
313                                })?;
314
315                            if instant_lock.txid == transaction_id {
316                                let asset_lock_proof =
317                                    AssetLockProof::Instant(InstantAssetLockProof {
318                                        instant_lock,
319                                        transaction: transaction.clone(),
320                                        output_index: 0,
321                                    });
322
323                                tracing::debug!(
324                                    ?asset_lock_proof,
325                                    "instant lock is matching to the broadcasted transaction, returning instant asset lock proof"
326                                );
327
328                                return Ok(asset_lock_proof);
329                            } else {
330                                tracing::debug!(
331                                    "instant lock is not matching, waiting for the next message"
332                                );
333                            }
334                        }
335                    }
336                    Some(transactions_with_proofs_response::Responses::RawMerkleBlock(
337                        raw_merkle_block,
338                    )) => {
339                        tracing::debug!("received merkle block");
340
341                        let merkle_block =
342                            MerkleBlock::consensus_decode(&mut raw_merkle_block.as_slice())
343                                .map_err(|e| {
344                                    tracing::error!("can't decode merkle block: {}", e);
345
346                                    Error::CoreError(e.into())
347                                })?;
348
349                        let mut matches: Vec<Txid> = vec![];
350                        let mut index: Vec<u32> = vec![];
351
352                        merkle_block.extract_matches(&mut matches, &mut index)?;
353
354                        // Continue receiving messages until we find the transaction
355                        if !matches.contains(&transaction_id) {
356                            tracing::debug!(
357                                "merkle block doesn't contain the transaction, waiting for the next message"
358                            );
359
360                            continue;
361                        }
362
363                        tracing::debug!(
364                            "merkle block contains the transaction, obtaining core chain locked height"
365                        );
366
367                        // TODO: This a temporary implementation until we have headers stream running in background
368                        //  so we can always get actual height and chain locks
369
370                        // Wait until the block is chainlocked
371                        let mut core_chain_locked_height;
372                        loop {
373                            let GetTransactionResponse {
374                                height,
375                                is_chain_locked,
376                                ..
377                            } = self
378                                .execute(
379                                    GetTransactionRequest {
380                                        id: transaction_id.to_string(),
381                                    },
382                                    RequestSettings::default(),
383                                )
384                                .await // TODO: We need better way to handle execution errors
385                                .into_inner()?;
386
387                            core_chain_locked_height = height;
388
389                            if is_chain_locked {
390                                break;
391                            }
392
393                            tracing::trace!("the transaction is on height {} but not chainlocked. try again in 1 sec", height);
394
395                            sleep(Duration::from_secs(1)).await;
396                        }
397
398                        tracing::debug!(
399                            "the transaction is chainlocked on height {}, waiting platform for reaching the same core height",
400                            core_chain_locked_height
401                        );
402
403                        // Wait until platform chain is on the block's chain locked height
404                        loop {
405                            let (_epoch, metadata) =
406                                Epoch::fetch_current_with_metadata(self).await?;
407
408                            if metadata.core_chain_locked_height >= core_chain_locked_height {
409                                break;
410                            }
411
412                            tracing::trace!(
413                                "platform chain locked core height {} but we need {}. try again in 1 sec",
414                                metadata.core_chain_locked_height,
415                                core_chain_locked_height,
416                            );
417
418                            sleep(Duration::from_secs(1)).await;
419                        }
420
421                        let asset_lock_proof = AssetLockProof::Chain(ChainAssetLockProof {
422                            core_chain_locked_height,
423                            out_point: OutPoint {
424                                txid: transaction.txid(),
425                                vout: 0,
426                            },
427                        });
428
429                        tracing::debug!(
430                                ?asset_lock_proof,
431                                "merkle block contains the broadcasted transaction, returning chain asset lock proof"
432                            );
433
434                        return Ok(asset_lock_proof);
435                    }
436                    Some(transactions_with_proofs_response::Responses::RawTransactions(_)) => {
437                        tracing::trace!("received transaction(s), ignoring")
438                    }
439                    None => tracing::trace!(
440                        "received empty response as a workaround for the bug in tonic, ignoring"
441                    ),
442                }
443            }
444        };
445
446        // Apply the timeout if `time_out_ms` is Some, otherwise just await the processing.
447        match time_out {
448            Some(duration) => timeout(duration, stream_processing).await.map_err(|_| {
449                Error::TimeoutReached(duration, String::from("receiving asset lock proof"))
450            })?,
451            None => stream_processing.await,
452        }
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    /// DAPI's display-order bytes come back as the hash's internal order.
461    #[test]
462    fn block_hash_from_display_bytes_reverses_the_bytes() {
463        let mut display = [0u8; 32];
464        display[0] = 0xaa;
465        display[31] = 0x01;
466        let hash = block_hash_from_display_bytes(&display).expect("32 bytes");
467        let internal = hash.to_byte_array();
468        assert_eq!(internal[0], 0x01);
469        assert_eq!(internal[31], 0xaa);
470    }
471
472    /// Anything but a 32-byte hash, including an unconfirmed tx's empty field,
473    /// yields no hash.
474    #[test]
475    fn block_hash_from_display_bytes_rejects_wrong_lengths() {
476        assert!(block_hash_from_display_bytes(&[]).is_none());
477        assert!(block_hash_from_display_bytes(&[0u8; 31]).is_none());
478        assert!(block_hash_from_display_bytes(&[0u8; 33]).is_none());
479    }
480
481    /// A block Core v24 mined at height 15146 on a devnet, as DAPI `getBlock`
482    /// returns it: its coinbase is a version 4 CbTx and it holds a version 3
483    /// ProRegTx.
484    const CORE_V24_BLOCK: &str = "00000020624025135b0936675af8abbe364e7d21c8e1e42eab3f2b6f6528d6ea875dad51e2664e33e00b3bf6b05a67603cddadf9daa4149e9130c7e5180e38426d20a695d5a6c76affff7f20010000000203000500010000000000000000000000000000000000000000000000000000000000000000ffffffff016affffffff036de1f505000000001976a91456208a8adff9433262bffee3c4894d2a160c21bd88ac209db40600000000016a26072d0b000000001976a91456208a8adff9433262bffee3c4894d2a160c21bd88ac00000000cf04002a3b0000db318d0bad75d80a6e14088f71657af817056edee711360d25c729905f33ed502692b21d078be4bc3103156612ba6bdcf387efdd6320a50a97985f5cdce4ead8008df9d40906f1c7c36c545b06daec45a84ffd43b5caf4045c9c9a39a146a7771de6cbdd11f5d16b22e9e358f5c483e35b12a5f3cab190026c5445dec34e632c36a0ba36a1f87bb411d7d292c5fd55cd36128877c707747286e40abec1aee7e4812eb72a56c407000000000000000000000000000000000000000000000000000000000000000000000300010001d02a36181466f1f3ec0f61a73114bdf31fa7f137fa880441d7e5e7b672493c04010000006a473044022004f9bb946ee7fab7d823a812cfb27b576f2c0cda4e597a8c92be26d8af33f155022034d05190a9b7a9aaae89fc89148639af1eb7f0601d9c959232b7a159cb2ca991012102c861a156ca6c6abecdeea95eab3ec7c93c6a37d63a5a6f62368ac94a424af15ffeffffff0200e87648170000001976a914fc52fb4219926f353f5475da7885b213c384a24188ac4d4fbfb88b0000001976a914c39aa109a7f20cefa69079374c7324e01998a12488ac00000000d10300000000000000000000000000000000000000000000000000000000000000000000000000000000000100c1dad84fdab5738de910b64d3a9ae7baf6afbd69806b8889b4ed6ccecefe7861fc3d69b7598f6ab9621fe2568403f94a1c5df398891433cdf301c67fda82f80bfe7f321465e088b8b1360c62946afcbea403cf927d5bfe230000011976a91431389f57b2f32565dcf713d2b2420914885bedcd88ac1027000000000000000000000000004f8f8fe9fd4ad5565cd6c4887bc54e2910a340bb7583c0d8d6a92d52b324095700";
485
486    /// A Core v24 block decodes with every transaction, so its merkle root
487    /// matches the header.
488    #[test]
489    fn decode_block_reads_a_core_v24_block() {
490        let bytes = hex::decode(CORE_V24_BLOCK).expect("vector is hex");
491        let block = decode_block(&bytes).expect("a Core v24 block decodes");
492        assert!(block.check_merkle_root());
493        assert_eq!(block.txdata.len(), 2);
494    }
495}