use anyhow::{anyhow, Result};
use bsv_sdk::primitives::bsv::sighash::parse_transaction;
use bsv_wallet_cli::gift::claim::build_claim_tx;
use bsv_wallet_cli::gift::covenant::parse_locking_script;
use bsv_wallet_toolbox::WalletServices;
use crate::commands::gift_chain;
use crate::context::WalletContext;
const MEDIAN_TIME_SPAN: u32 = 11;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ChainInfo {
blocks: u64,
mediantime: u64,
}
async fn chain_clock<V: WalletServices>(services: &V) -> Result<ChainInfo> {
let tip = services
.get_chain_tip_header()
.await
.map_err(|e| anyhow!("the header service gave no tip header (CHAINTRACKS_URL): {e}"))?;
let mut times = vec![tip.time];
let first = tip.height.saturating_sub(MEDIAN_TIME_SPAN - 1);
for height in first..tip.height {
let header = services
.get_header_for_height(height)
.await
.map_err(|e| anyhow!("the header service gave no header at height {height}: {e}"))?;
times.push(
header_time(&header)
.ok_or_else(|| anyhow!("the header at height {height} is not 80 bytes"))?,
);
}
Ok(ChainInfo {
blocks: tip.height as u64,
mediantime: median(times) as u64,
})
}
fn header_time(header: &[u8]) -> Option<u32> {
if header.len() != 80 {
return None;
}
Some(u32::from_le_bytes(header[68..72].try_into().ok()?))
}
fn median(mut times: Vec<u32>) -> u32 {
times.sort_unstable();
times[times.len() / 2]
}
fn fmt_ts(ts: u64) -> String {
chrono::DateTime::from_timestamp(ts as i64, 0)
.map(|d| d.to_rfc3339())
.unwrap_or_else(|| ts.to_string())
}
pub async fn run(ctx: &WalletContext, txid: &str) -> Result<()> {
let deposit_raw = gift_chain::deposit_bytes(ctx.wallet.services(), txid).await?;
let dep = parse_transaction(&deposit_raw).map_err(|e| anyhow!("parse deposit: {e}"))?;
let cov_out = dep
.outputs
.first()
.ok_or_else(|| anyhow!("{txid} has no vout 0"))?;
let params = parse_locking_script(&cov_out.script)
.map_err(|e| anyhow!("{txid} vout 0 is not a TimeLockedGift covenant: {e}"))?;
let (deposit_priv, deposit_pub) = crate::brc29::deposit_keypair(&ctx.root_key)?;
let locked_to_me = deposit_pub.to_compressed().as_slice() == params.recipient.as_slice();
let signable = locked_to_me
&& build_claim_tx(&deposit_raw, &deposit_priv, params.lock_until as u32).is_ok();
let info = chain_clock(ctx.wallet.services()).await?;
let now = chrono::Utc::now().timestamp() as u64;
let mtp_lag = now.saturating_sub(info.mediantime);
let claimable_now = info.mediantime >= params.lock_until;
let (est_blocks, est_height, est_wall) = if claimable_now {
(0u64, info.blocks, now)
} else {
let secs = params.lock_until - info.mediantime;
let blocks = secs.div_ceil(600);
(blocks, info.blocks + blocks, params.lock_until + mtp_lag)
};
if ctx.json_output {
println!(
"{}",
serde_json::json!({
"txid": txid,
"lockedToMe": locked_to_me,
"signable": signable,
"amount": params.amount,
"recipient": hex::encode(¶ms.recipient),
"lockUntil": params.lock_until,
"currentHeight": info.blocks,
"currentMtp": info.mediantime,
"claimableNow": claimable_now,
"estClaimableHeight": est_height,
"estClaimableTime": est_wall,
})
);
return Ok(());
}
println!("🎁 Time-locked gift — inspection (nothing claimed, nothing broadcast)");
println!(" gift txid: {txid}");
println!(" amount: {} sats", params.amount);
println!(" locked to key: {}", hex::encode(¶ms.recipient));
println!(
" that's YOU: {}",
if locked_to_me {
"✅ yes — this gift is yours"
} else {
"❌ no — locked to a different key"
}
);
println!(
" you can open it:{}",
if signable {
" ✅ your signature satisfies the covenant"
} else if !locked_to_me {
" ❌ not your key"
} else {
" ❌ could not produce a valid claim"
}
);
println!(" unlocks at: {}", fmt_ts(params.lock_until));
println!(
" chain now: block {} · median-time-past {} (~{}h behind real time)",
info.blocks,
fmt_ts(info.mediantime),
mtp_lag / 3600
);
if claimable_now {
println!(" status: 🔓 CLAIMABLE NOW");
println!("\n Claim it: bsv-wallet gift-claim {txid}");
println!(" …the claim records itself in your wallet; `bsv-wallet send` to spend.");
} else {
println!(
" status: 🔒 LOCKED — claimable ~{} (≈ block {}, ~{} blocks away)",
fmt_ts(est_wall),
est_height,
est_blocks
);
println!("\n Note: the network won't confirm a claim until its block's median-time-past");
println!(
" passes the unlock time, which lags real time by ~{}h.",
mtp_lag / 3600
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use bsv_wallet_toolbox::services::mock::MockWalletServices;
use bsv_wallet_toolbox::services::BlockHeader;
fn header(height: u32, time: u32) -> BlockHeader {
BlockHeader {
version: 536870912,
previous_hash: "0".repeat(64),
merkle_root: "a".repeat(64),
time,
bits: 402917821,
nonce: 7,
height,
hash: "b".repeat(64),
}
}
#[tokio::test]
async fn the_tip_and_the_median_time_past_come_from_the_header_service() {
let services = MockWalletServices::new();
let times = [100, 400, 200, 900, 300, 800, 500, 700, 600, 1000, 50];
for (i, time) in times.iter().enumerate() {
let h = header(900_000 + i as u32, *time);
if i + 1 == times.len() {
services.set_tip_header(h);
} else {
services.set_header_for_height(h);
}
}
let clock = chain_clock(&services).await.unwrap();
assert_eq!(
clock,
ChainInfo {
blocks: 900_010,
mediantime: 500
}
);
assert_eq!(services.call_count("get_chain_tip_header"), 1);
assert_eq!(services.call_count("get_header_for_height"), 10);
}
#[tokio::test]
async fn no_header_service_is_no_clock() {
let services = MockWalletServices::new();
services.set_tip_unavailable();
let err = chain_clock(&services).await.unwrap_err();
assert!(err.to_string().contains("CHAINTRACKS_URL"), "{err}");
}
#[test]
fn gift_inspect_asks_no_explorer_for_the_tip() {
let source = include_str!("gift_inspect.rs");
let code = source.split("#[cfg(test)]").next().unwrap();
assert!(!code.contains("chain/info"));
}
#[test]
fn the_median_is_the_middle_of_the_sorted_times() {
assert_eq!(median(vec![3, 1, 2]), 2);
assert_eq!(median(vec![7]), 7);
assert_eq!(median(vec![4, 1, 3, 2]), 3);
assert_eq!(header_time(&[0u8; 79]), None);
}
}