Repository: ordinals/ord (rust)

Issue:
The /output/ HTML page shows "spent: true" for unconfirmed tx outputs
I made a tx, then looked up one of its outputs on ordinals.com before it had confirmed

The result showed me "spent: true" but the output isn't spent 

Ord presumably knows nothing of the unconfirmed transaction outputs because it doesn't index unconfirmed transactions, but in that case maybe it shouldn't show a value for "spent" at all

Pull request description:
Add confirmations and fix spent in output page and API
Fixes #4236

@gmart7t2 

Patch:
diff --git a/crates/mockcore/src/server.rs b/crates/mockcore/src/server.rs
index bd7d0a2b..e394c9c4 100644
--- a/crates/mockcore/src/server.rs
+++ b/crates/mockcore/src/server.rs
@@ -258,17 +258,25 @@ impl Api for Server {
       }
     }
 
+    let Some(tx) = state.transactions.get(&txid) else {
+      return Ok(None);
+    };
+
+    let script_pubkey = &tx.output[usize::try_from(vout).unwrap()].script_pubkey;
+
     Ok(Some(GetTxOutResult {
       bestblock: BlockHash::all_zeros(),
       coinbase: false,
       confirmations: confirmations.unwrap().try_into().unwrap(),
       script_pub_key: GetRawTransactionResultVoutScriptPubKey {
         asm: String::new(),
-        hex: Vec::new(),
+        hex: script_pubkey.to_bytes(),
         req_sigs: None,
         type_: None,
         addresses: Vec::new(),
-        address: None,
+        address: Address::from_script(script_pubkey, state.network)
+          .ok()
+          .map(|addr| addr.into_unchecked()),
       },
       value: *value,
     }))
diff --git a/docs/src/guides/api.md b/docs/src/guides/api.md
index 24240131..afc19a33 100644
--- a/docs/src/guides/api.md
+++ b/docs/src/guides/api.md
@@ -1803,6 +1803,7 @@ curl -s -H "Accept: application/json" \
 ```json
 {
   "address": "bc1pz4kvfpurqc2hwgrq0nwtfve2lfxvdpfcdpzc6ujchyr3ztj6gd9sfr6ayf",
+  "confirmations": 6,
   "indexed": false,
   "inscriptions": [],
   "outpoint": "bc4c30829a9564c0d58e6287195622b53ced54a25711d1b86be7cd3a70ef61ed:0",
diff --git a/src/api.rs b/src/api.rs
index 2bcc6a50..fee82adc 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -169,6 +169,7 @@ pub struct UtxoRecursive {
 #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]
 pub struct Output {
   pub address: Option<Address<NetworkUnchecked>>,
+  pub confirmations: u32,
   pub indexed: bool,
   pub inscriptions: Option<Vec<InscriptionId>>,
   pub outpoint: OutPoint,
@@ -183,6 +184,7 @@ pub struct Output {
 impl Output {
   pub fn new(
     chain: Chain,
+    confirmations: u32,
     inscriptions: Option<Vec<InscriptionId>>,
     outpoint: OutPoint,
     tx_out: TxOut,
@@ -196,6 +198,7 @@ impl Output {
         .address_from_script(&tx_out.script_pubkey)
         .ok()
         .map(|address| uncheck(&address)),
+      confirmations,
       indexed,
       inscriptions,
       outpoint,
diff --git a/src/index.rs b/src/index.rs
index 641ce908..91608dc2 100644
--- a/src/index.rs
+++ b/src/index.rs
@@ -18,7 +18,10 @@ use {
   },
   bitcoin::block::Header,
   bitcoincore_rpc::{
-    json::{GetBlockHeaderResult, GetBlockStatsResult},
+    json::{
+      GetBlockHeaderResult, GetBlockStatsResult, GetRawTransactionResult,
+      GetRawTransactionResultVout, GetRawTransactionResultVoutScriptPubKey, GetTxOutResult,
+    },
     Client,
   },
   chrono::SubsecRound,
@@ -1596,6 +1599,87 @@ impl Index {
     Ok(Some(result))
   }
 
+  pub fn get_unspent_or_unconfirmed_output(
+    &self,
+    txid: &Txid,
+    vout: u32,
+  ) -> Result<Option<GetTxOutResult>> {
+    if txid == &self.genesis_block_coinbase_txid {
+      let Some(output) = &self
+        .genesis_block_coinbase_transaction
+        .output
+        .get(vout.into_usize())
+      else {
+        return Ok(None);
+      };
+
+      return Ok(Some(GetTxOutResult {
+        bestblock: self.block_hash(None)?.unwrap(),
+        coinbase: true,
+        confirmations: self.block_count()?,
+        script_pub_key: GetRawTransactionResultVoutScriptPubKey {
+          address: None,
+          addresses: Vec::new(),
+          asm: output.script_pubkey.to_asm_string(),
+          hex: output.script_pubkey.to_bytes(),
+          req_sigs: Some(1),
+          type_: Some(bitcoincore_rpc::json::ScriptPubkeyType::Pubkey),
+        },
+        value: output.value,
+      }));
+    }
+
+    Ok(self.client.get_tx_out(txid, vout, Some(true))?)
+  }
+
+  pub fn get_transaction_info(&self, txid: &Txid) -> Result<Option<GetRawTransactionResult>> {
+    if txid == &self.genesis_block_coinbase_txid {
+      let tx = &self.genesis_block_coinbase_transaction;
+
+      let block = bitcoin::blockdata::constants::genesis_block(self.settings.chain().network());
+      let time = block.header.time.into_usize();
+
+      return Ok(Some(GetRawTransactionResult {
+        in_active_chain: Some(true),
+        hex: consensus::encode::serialize(tx),
+        txid: tx.compute_txid(),
+        hash: tx.compute_wtxid(),
+        size: tx.total_size(),
+        vsize: tx.vsize(),
+        #[allow(clippy::cast_sign_loss)]
+        version: tx.version.0 as u32,
+        locktime: 0,
+        vin: Vec::new(),
+        vout: tx
+          .output
+          .iter()
+          .enumerate()
+          .map(|(n, output)| GetRawTransactionResultVout {
+            n: n.try_into().unwrap(),
+            value: output.value,
+            script_pub_key: GetRawTransactionResultVoutScriptPubKey {
+              asm: output.script_pubkey.to_asm_string(),
+              hex: output.script_pubkey.clone().into(),
+              req_sigs: None,
+              type_: None,
+              addresses: Vec::new(),
+              address: None,
+            },
+          })
+          .collect(),
+        blockhash: Some(block.block_hash()),
+        confirmations: Some(self.block_count()?),
+        time: Some(time),
+        blocktime: Some(time),
+      }));
+    }
+
+    self
+      .client
+      .get_raw_transaction_info(txid, None)
+      .into_option()
+  }
+
   pub fn get_transaction(&self, txid: Txid) -> Result<Option<Transaction>> {
     if txid == self.genesis_block_coinbase_txid {
       return Ok(Some(self.genesis_block_coinbase_transaction.clone()));
@@ -2404,9 +2488,12 @@ impl Index {
   pub(crate) fn get_output_info(&self, outpoint: OutPoint) -> Result<Option<(api::Output, TxOut)>> {
     let sat_ranges = self.list(outpoint)?;
 
+    let confirmations;
     let indexed;
+    let spent;
+    let txout;
 
-    let txout = if outpoint == OutPoint::null() || outpoint == unbound_outpoint() {
+    if outpoint == OutPoint::null() || outpoint == unbound_outpoint() {
       let mut value = 0;
 
       if let Some(ranges) = &sat_ranges {
@@ -2415,35 +2502,49 @@ impl Index {
         }
       }
 
+      confirmations = 0;
       indexed = true;
-
-      TxOut {
+      spent = false;
+      txout = TxOut {
         value: Amount::from_sat(value),
         script_pubkey: ScriptBuf::new(),
-      }
+      };
     } else {
       indexed = self.contains_output(&outpoint)?;
 
-      let Some(tx) = self.get_transaction(outpoint.txid)? else {
-        return Ok(None);
-      };
+      if let Some(result) = self.get_unspent_or_unconfirmed_output(&outpoint.txid, outpoint.vout)? {
+        confirmations = result.confirmations;
+        spent = false;
+        txout = TxOut {
+          value: result.value,
+          script_pubkey: ScriptBuf::from_bytes(result.script_pub_key.hex),
+        };
+      } else {
+        let Some(result) = self.get_transaction_info(&outpoint.txid)? else {
+          return Ok(None);
+        };
 
-      let Some(txout) = tx.output.into_iter().nth(outpoint.vout as usize) else {
-        return Ok(None);
-      };
+        let Some(output) = result.vout.into_iter().nth(outpoint.vout.into_usize()) else {
+          return Ok(None);
+        };
 
-      txout
+        confirmations = result.confirmations.unwrap_or_default();
+        spent = true;
+        txout = TxOut {
+          value: output.value,
+          script_pubkey: ScriptBuf::from_bytes(output.script_pub_key.hex),
+        };
+      }
     };
 
     let inscriptions = self.get_inscriptions_for_output(outpoint)?;
 
     let runes = self.get_rune_balances_for_output(outpoint)?;
 
-    let spent = self.is_output_spent(outpoint)?;
-
     Ok(Some((
       api::Output::new(
         self.settings.chain(),
+        confirmations,
         inscriptions,
         outpoint,
         txout.clone(),
diff --git a/src/subcommand/server.rs b/src/subcommand/server.rs
index 0800adb0..02f2d3d7 100644
--- a/src/subcommand/server.rs
+++ b/src/subcommand/server.rs
@@ -769,6 +769,7 @@ impl Server {
       } else {
         OutputHtml {
           chain: server_config.chain,
+          confirmations: output_info.confirmations,
           inscriptions: output_info.inscriptions,
           outpoint,
           output: txout,
@@ -3432,6 +3433,7 @@ mod tests {
         value: 5000000000,
         script_pubkey: address.script_pubkey(),
         address: Some(uncheck(&address)),
+        confirmations: 1,
         transaction: txid,
         sat_ranges: None,
         indexed: true,
@@ -3720,6 +3722,7 @@ mod tests {
   <dt>value</dt><dd>5000000000</dd>
   <dt>script pubkey</dt><dd class=monospace>OP_PUSHBYTES_65 [[:xdigit:]]{{130}} OP_CHECKSIG</dd>
   <dt>transaction</dt><dd><a class=collapse href=/tx/{txid}>{txid}</a></dd>
+  <dt>confirmations</dt><dd>1</dd>
   <dt>spent</dt><dd>false</dd>
 </dl>
 <h2>1 Sat Range</h2>
@@ -3742,6 +3745,7 @@ mod tests {
   <dt>value</dt><dd>5000000000</dd>
   <dt>script pubkey</dt><dd class=monospace>OP_PUSHBYTES_65 [[:xdigit:]]{{130}} OP_CHECKSIG</dd>
   <dt>transaction</dt><dd><a class=collapse href=/tx/{txid}>{txid}</a></dd>
+  <dt>confirmations</dt><dd>1</dd>
   <dt>spent</dt><dd>false</dd>
 </dl>.*"
       ),
@@ -3765,6 +3769,7 @@ mod tests {
   <dt>value</dt><dd>5000000000</dd>
   <dt>script pubkey</dt><dd class=monospace></dd>
   <dt>transaction</dt><dd><a class=collapse href=/tx/{txid}>{txid}</a></dd>
+  <dt>confirmations</dt><dd>0</dd>
   <dt>spent</dt><dd>false</dd>
 </dl>
 <h2>1 Sat Range</h2>
diff --git a/src/templates/output.rs b/src/templates/output.rs
index 23da2b64..507cdc7b 100644
--- a/src/templates/output.rs
+++ b/src/templates/output.rs
@@ -3,6 +3,7 @@ use super::*;
 #[derive(Boilerplate)]
 pub(crate) struct OutputHtml {
   pub(crate) chain: Chain,
+  pub(crate) confirmations: u32,
   pub(crate) inscriptions: Option<Vec<InscriptionId>>,
   pub(crate) outpoint: OutPoint,
   pub(crate) output: TxOut,
@@ -29,6 +30,7 @@ mod tests {
     assert_regex_match!(
       OutputHtml {
         chain: Chain::Mainnet,
+        confirmations: 6,
         inscriptions: Some(Vec::new()),
         outpoint: outpoint(1),
         output: TxOut { value: Amount::from_sat(3), script_pubkey: ScriptBuf::new_p2pkh(&PubkeyHash::all_zeros()), },
@@ -43,6 +45,7 @@ mod tests {
           <dt>script pubkey</dt><dd class=monospace>OP_DUP OP_HASH160 OP_PUSHBYTES_20 0{40} OP_EQUALVERIFY OP_CHECKSIG</dd>
           <dt>address</dt><dd><a class=collapse href=/address/1111111111111111111114oLvT2>1111111111111111111114oLvT2</a></dd>
           <dt>transaction</dt><dd><a class=collapse href=/tx/1{64}>1{64}</a></dd>
+          <dt>confirmations</dt><dd>6</dd>
           <dt>spent</dt><dd>false</dd>
         </dl>
         <h2>2 Sat Ranges</h2>
@@ -60,6 +63,7 @@ mod tests {
     assert_regex_match!(
       OutputHtml {
         chain: Chain::Mainnet,
+        confirmations: 10,
         inscriptions: None,
         outpoint: outpoint(1),
         output: TxOut {
@@ -76,6 +80,7 @@ mod tests {
           <dt>value</dt><dd>1</dd>
           <dt>script pubkey</dt><dd class=monospace>OP_0</dd>
           <dt>transaction</dt><dd><a class=collapse href=/tx/1{64}>1{64}</a></dd>
+          <dt>confirmations</dt><dd>10</dd>
           <dt>spent</dt><dd>true</dd>
         </dl>
       "
@@ -88,6 +93,7 @@ mod tests {
     assert_regex_match!(
       OutputHtml {
         chain: Chain::Mainnet,
+        confirmations: 6,
         inscriptions: None,
         outpoint: outpoint(1),
         output: TxOut { value: Amount::from_sat(3), script_pubkey: ScriptBuf::new_p2pkh(&PubkeyHash::all_zeros()), },
@@ -102,6 +108,7 @@ mod tests {
           <dt>script pubkey</dt><dd class=monospace>OP_DUP OP_HASH160 OP_PUSHBYTES_20 0{40} OP_EQUALVERIFY OP_CHECKSIG</dd>
           <dt>address</dt><dd><a class=collapse href=/address/1111111111111111111114oLvT2>1111111111111111111114oLvT2</a></dd>
           <dt>transaction</dt><dd><a class=collapse href=/tx/1{64}>1{64}</a></dd>
+          <dt>confirmations</dt><dd>6</dd>
           <dt>spent</dt><dd>true</dd>
         </dl>
         <h2>2 Sat Ranges</h2>
@@ -119,6 +126,7 @@ mod tests {
     assert_regex_match!(
       OutputHtml {
         chain: Chain::Mainnet,
+        confirmations: 6,
         inscriptions: None,
         outpoint: outpoint(1),
         output: TxOut { value: Amount::from_sat(3), script_pubkey: ScriptBuf::new_p2pkh(&PubkeyHash::all_zeros()), },
@@ -134,6 +142,7 @@ mod tests {
           <dt>script pubkey</dt><dd class=monospace>OP_DUP OP_HASH160 OP_PUSHBYTES_20 0{40} OP_EQUALVERIFY OP_CHECKSIG</dd>
           <dt>address</dt><dd><a class=collapse href=/address/1111111111111111111114oLvT2>1111111111111111111114oLvT2</a></dd>
           <dt>transaction</dt><dd><a class=collapse href=/tx/1{64}>1{64}</a></dd>
+          <dt>confirmations</dt><dd>6</dd>
           <dt>spent</dt><dd>false</dd>
         </dl>
       "
@@ -146,6 +155,7 @@ mod tests {
     assert_regex_match!(
       OutputHtml {
         chain: Chain::Mainnet,
+        confirmations: 6,
         inscriptions: Some(vec![inscription_id(1)]),
         outpoint: outpoint(1),
         output: TxOut {
@@ -175,6 +185,7 @@ mod tests {
     assert_regex_match!(
       OutputHtml {
         chain: Chain::Mainnet,
+        confirmations: 6,
         inscriptions: None,
         outpoint: outpoint(1),
         output: TxOut {
diff --git a/templates/output.html b/templates/output.html
index 56a640b3..5bfd2d09 100644
--- a/templates/output.html
+++ b/templates/output.html
@@ -31,6 +31,7 @@
   <dt>address</dt><dd><a class=collapse href=/address/{{address}}>{{ address }}</a></dd>
 %% }
   <dt>transaction</dt><dd><a class=collapse href=/tx/{{ self.outpoint.txid }}>{{ self.outpoint.txid }}</a></dd>
+  <dt>confirmations</dt><dd>{{ self.confirmations }}</dd>
   <dt>spent</dt><dd>{{ self.spent }}</dd>
 </dl>
 %% if let Some(sat_ranges) = &self.sat_ranges {


Test patch:
diff --git a/tests/json_api.rs b/tests/json_api.rs
index 5606d792..78c350a9 100644
--- a/tests/json_api.rs
+++ b/tests/json_api.rs
@@ -405,6 +405,7 @@ fn get_output() {
           .parse()
           .unwrap()
       ),
+      confirmations: 1,
       outpoint: OutPoint { txid, vout: 0 },
       inscriptions: Some(vec![
         InscriptionId { txid, index: 0 },
@@ -809,6 +810,7 @@ fn outputs_address() {
     cardinals_json,
     vec![api::Output {
       address: Some(address.parse().unwrap()),
+      confirmations: 6,
       inscriptions: Some(vec![]),
       outpoint: OutPoint {
         txid: cardinal_send.txid,
@@ -853,6 +855,7 @@ fn outputs_address() {
     runes_json,
     vec![api::Output {
       address: Some(address.parse().unwrap()),
+      confirmations: 6,
       inscriptions: Some(vec![]),
       outpoint: OutPoint {
         txid: rune_send.txid,
@@ -884,6 +887,7 @@ fn outputs_address() {
     inscriptions_json,
     vec![api::Output {
       address: Some(address.parse().unwrap()),
+      confirmations: 14,
       inscriptions: Some(vec![InscriptionId {
         txid: reveal,
         index: 0
diff --git a/tests/wallet/send.rs b/tests/wallet/send.rs
index 6e2acf4d..0e8365d0 100644
--- a/tests/wallet/send.rs
+++ b/tests/wallet/send.rs
@@ -345,6 +345,7 @@ inscriptions:
     output_json,
     api::Output {
       address: Some(destination.clone()),
+      confirmations: 1,
       outpoint: OutPoint {
         txid: reveal_txid,
         vout: 0