diff --git a/borsh_utils/src/lib.rs b/borsh_utils/src/lib.rs index 9c787b5..a0cd69b 100644 --- a/borsh_utils/src/lib.rs +++ b/borsh_utils/src/lib.rs @@ -84,6 +84,34 @@ pub fn deserialize_outpoint(reader: &mut R) -> io::Result }) } +/// Serialize an `Option` +pub fn serialize_optional_outpoint( + outpoint: &Option, + writer: &mut W, +) -> io::Result<()> { + match outpoint { + None => writer.write_all(&[0u8]), + Some(outpoint) => { + writer.write_all(&[1u8])?; + serialize_outpoint(outpoint, writer) + } + } +} + +/// Deserialize an `Option` +pub fn deserialize_optional_outpoint(reader: &mut R) -> io::Result> { + let mut tag = [0u8; 1]; + reader.read_exact(&mut tag)?; + match tag[0] { + 0 => Ok(None), + 1 => Ok(Some(deserialize_outpoint(reader)?)), + _ => Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid Option tag for OutPoint", + )), + } +} + /// Serialize a BlockHash pub fn serialize_block_hash(hash: &BlockHash, writer: &mut W) -> io::Result<()> { writer.write_all(&hash.to_byte_array()) diff --git a/client/src/bin/space-cli.rs b/client/src/bin/space-cli.rs index 4899b67..d558473 100644 --- a/client/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -8,7 +8,7 @@ use jsonrpsee::{ http_client::HttpClient, }; use spaces_client::rpc::{ - CommitParams, CreateNumParams, DelegateParams, OperateParams, SetFallbackParams, + CommitParams, CreateNumParams, DelegateParams, OperateParams, SetFallbackParams, UnbindParams, }; use spaces_client::store::Sha256; use spaces_client::{ @@ -162,6 +162,26 @@ enum Commands { /// Space name, numeric, or num id subject: Subject, }, + /// Unbind nums: spend them with no successor so they go dormant. + /// A dormant num can be revived at its death spk with `createnum`. + #[command(name = "unbind")] + Unbind { + /// Nums to unbind (e.g., num1... or #800000-3-1) + subjects: Vec, + /// Read hex-encoded secret key from stdin for unbinding nums not owned by wallet + #[arg(long)] + secret_stdin: bool, + /// Fee rate to use in sat/vB + #[arg(long, short)] + fee_rate: Option, + }, + /// Get the rebind parked at a script pubkey (a num that died there, + /// revivable with `createnum --bind-spk `), if any + #[command(name = "getrebind")] + GetRebind { + /// Script public key as hex string + script_pubkey: String, + }, /// Transfer ownership of spaces and/or nums to the given name or address #[command( name = "transfer", @@ -607,7 +627,7 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client let response = cli.client.wallet_create(&cli.wallet).await?; println!("⚠️ Write down your recovery phrase NOW!"); println!("This is the ONLY time it will be shown:"); - println!("{}", &response); + println!("{}", response); } Commands::RecoverWallet => { print!("Enter mnemonic phrase: "); @@ -1019,6 +1039,43 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client .map_err(|e| ClientError::Custom(e.to_string()))?; println!("{}", serde_json::to_string(&num).expect("result")); } + Commands::Unbind { + subjects, + secret_stdin, + fee_rate, + } => { + let secret = if secret_stdin { + let mut input = String::new(); + io::stdin().read_line(&mut input).map_err(|e| { + ClientError::Custom(format!("failed to read secret from stdin: {}", e)) + })?; + Some(input.trim().to_string()) + } else { + None + }; + cli.send_request( + Some(RpcWalletRequest::Unbind(UnbindParams { subjects, secret })), + None, + fee_rate, + false, + ) + .await?; + println!( + "Num(s) go dormant once the tx confirms; revive with `createnum --bind-spk `" + ); + } + Commands::GetRebind { script_pubkey } => { + let spk = ScriptBuf::from( + hex::decode(script_pubkey) + .map_err(|_| ClientError::Custom("Invalid spk hex".to_string()))?, + ); + let rebind = cli + .client + .get_rebind(spk) + .await + .map_err(|e| ClientError::Custom(e.to_string()))?; + println!("{}", serde_json::to_string(&rebind).expect("result")); + } Commands::GetNumOut { outpoint } => { let numout = cli diff --git a/client/src/client.rs b/client/src/client.rs index 6b49fdd..4e97b87 100644 --- a/client/src/client.rs +++ b/client/src/client.rs @@ -7,7 +7,9 @@ use anyhow::{Result, anyhow}; use borsh::{BorshDeserialize, BorshSerialize}; use serde::de::Error as SerdeError; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use spaces_nums::{CommitmentKey, CommitmentTipKey, DelegatorKey, NumOutpointKey}; +use spaces_nums::{ + CommitmentKey, CommitmentTipKey, DelegatorKey, NumOutpointKey, RebindData, RebindKey, +}; use spaces_protocol::{ Bytes, Covenant, FullSpaceOut, RevokeReason, SpaceOut, bitcoin::{Amount, Block, BlockHash, OutPoint, Txid}, @@ -303,7 +305,7 @@ impl Client { }); } } - self.apply_ptrs_tx(chain, tx, ptrs_validated); + self.apply_nums_tx(chain, tx, ptrs_validated); } } @@ -315,7 +317,7 @@ impl Client { Ok((spaces_meta, num_meta)) } - fn apply_ptrs_tx( + fn apply_nums_tx( &self, state: &mut Chain, tx: &Transaction, @@ -367,21 +369,46 @@ impl Client { state.insert_commitment(commitment_key, commitment_info.commitment); } - // Create ptrs + // Rebinds (revivals): consume the parked rebind and delete the + // tombstone. The revived num itself is in `creates`, whose identity + // write repoints the genesis slot. + for rebind in changeset.rebinds.into_iter() { + state.remove_num_utxo(rebind.prev_outpoint); + state.remove_rebind(rebind.key); + } + + // Create nums for create in changeset.creates.into_iter() { let outpoint = OutPoint { txid: changeset.txid, vout: create.n as u32, }; - // Num => Outpoint + Numeric => NumId - state.insert_num_outpoint(create.num.id, outpoint.into()); + // Num => Outpoint + state.insert_num_outpoint(create.num.id, outpoint); + // Numeric => NumId state.insert_num(&create.num.name, create.num.id); // Outpoint => PtrOut let outpoint_key = NumOutpointKey::from_outpoint::(outpoint); state.insert_numout(outpoint_key, create); } + + // Unbind nums: overwrite the numout with the spent tombstone and park + // a rebind (derived from it) at the death spk's rebind slot. The + // identity slot is untouched. + for fno in changeset.unbinds.into_iter() { + let rebind_key = RebindKey::from_spk::(fno.numout.script_pubkey.clone()); + state.insert_rebind( + rebind_key, + RebindData { + prev_outpoint: fno.outpoint(), + prev: fno.numout.num.clone(), + }, + ); + let outpoint_key = NumOutpointKey::from_outpoint::(fno.outpoint()); + state.insert_numout(outpoint_key, fno.numout); + } } fn apply_space_tx(&self, state: &mut Chain, tx: &Transaction, changeset: TxChangeSet) { diff --git a/client/src/rpc.rs b/client/src/rpc.rs index 33447a5..06092ce 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -39,7 +39,7 @@ use spaces_nums::num_id::NumId; use spaces_nums::snumeric::SNumeric; use spaces_nums::{ ChainProofRequest, Commitment, CommitmentKey, CommitmentTipKey, DelegatorKey, FullNumOut, - NumKeyKind, NumOut, NumOutpointKey, NumSource, RootAnchor, + NumKeyKind, NumOut, NumOutpointKey, NumSource, RebindData, RebindKey, RootAnchor, }; use spaces_protocol::bitcoin::ScriptBuf; use spaces_protocol::hasher::Hash; @@ -157,6 +157,10 @@ pub enum ChainStateCommand { subject: Subject, resp: Responder>>, }, + GetRebind { + script_pubkey: ScriptBuf, + resp: Responder>>, + }, GetNum { subject: Subject, resp: Responder>>, @@ -261,6 +265,14 @@ pub trait Rpc { #[method(name = "getdelegator")] async fn get_delegator(&self, subject: Subject) -> Result, ErrorObjectOwned>; + /// Get the rebind parked at a script pubkey (a num that died there and + /// can be revived with a `…88` output), if any. + #[method(name = "getrebind")] + async fn get_rebind( + &self, + script_pubkey: ScriptBuf, + ) -> Result, ErrorObjectOwned>; + #[method(name = "checkpackage")] async fn check_package( &self, @@ -447,6 +459,29 @@ pub trait Rpc { space: &str, expire_height: u32, ) -> Result<(), ErrorObjectOwned>; + + /// Debug builder: construct, sign, and broadcast a raw unbind/revive tx + /// that bypasses the wallet's correctness invariants (single-output rule, + /// same-tx revive+die filtering). Intended for protocol-level edge-case + /// tests; regtest only. + #[method(name = "debugbuildunbindraw")] + async fn debug_build_unbind_raw( + &self, + wallet: &str, + num_outpoints: Vec, + extra_outputs: Vec, + locktime: Option, + fee_rate: FeeRate, + ) -> Result; +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DebugRawOutput { + #[cfg_attr(feature = "schema", schemars(with = "String"))] + pub script_pubkey: ScriptBuf, + #[cfg_attr(feature = "schema", schemars(with = "u64"))] + pub amount: Amount, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -489,6 +524,8 @@ pub enum RpcWalletRequest { Transfer(TransferSpacesParams), #[serde(rename = "createnum")] CreateNum(CreateNumParams), + #[serde(rename = "unbind")] + Unbind(UnbindParams), #[serde(rename = "operate")] Operate(OperateParams), #[serde(rename = "commit")] @@ -527,6 +564,18 @@ pub struct CreateNumParams { pub bind_spk: Option, } +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct UnbindParams { + /// List of nums to destroy (becomes dormant; revivable at the death spk). + #[cfg_attr(feature = "schema", schemars(with = "Vec"))] + pub subjects: Vec, + + /// Hex-encoded 32-byte secret key for unbinding nums not owned by the wallet + #[serde(skip_serializing_if = "Option::is_none")] + pub secret: Option, +} + #[derive(Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct OperateParams { @@ -1069,6 +1118,18 @@ impl RpcServer for RpcServerImpl { Ok(delegator) } + async fn get_rebind( + &self, + script_pubkey: ScriptBuf, + ) -> Result, ErrorObjectOwned> { + let rebind = self + .store + .get_rebind(script_pubkey) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::))?; + Ok(rebind) + } + async fn check_package( &self, txs: Vec, @@ -1489,6 +1550,39 @@ impl RpcServer for RpcServerImpl { .await .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } + + async fn debug_build_unbind_raw( + &self, + wallet: &str, + num_outpoints: Vec, + extra_outputs: Vec, + locktime: Option, + fee_rate: FeeRate, + ) -> Result { + let info = self + .store + .get_server_info() + .await + .map_err(|e| ErrorObjectOwned::owned(-1, e.to_string(), None::))?; + if info.network != ExtendedNetwork::Regtest { + return Err(ErrorObjectOwned::owned( + -1, + "debug_build_unbind_raw is only available on regtest", + None::, + )); + } + + let extras = extra_outputs + .into_iter() + .map(|o| (o.script_pubkey, o.amount)) + .collect(); + + self.wallet(wallet) + .await? + .send_debug_build_unbind_raw(num_outpoints, extras, locktime, fee_rate) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } } impl AsyncChainState { @@ -1692,6 +1786,15 @@ impl AsyncChainState { }); let _ = resp.send(result); } + ChainStateCommand::GetRebind { + script_pubkey, + resp, + } => { + let result = state + .get_num_rebind(&RebindKey::from_spk::(script_pubkey)) + .map_err(|e| anyhow!("could not get rebind: {}", e)); + let _ = resp.send(result); + } ChainStateCommand::GetNumOut { outpoint, resp } => { let result = state .get_numout(&outpoint) @@ -1939,6 +2042,11 @@ impl AsyncChainState { num_tree_keys.insert(id.into()); } } + NumKeyKind::Rebind(k) => { + // Inclusion proves a parked rebind; exclusion proves + // nothing is revivable at the spk. + num_tree_keys.insert(k.into()); + } NumKeyKind::Commitment(k) => { num_tree_keys.insert(k.into()); } @@ -2236,6 +2344,17 @@ impl AsyncChainState { resp_rx.await? } + pub async fn get_rebind(&self, script_pubkey: ScriptBuf) -> anyhow::Result> { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::GetRebind { + script_pubkey, + resp, + }) + .await?; + resp_rx.await? + } + pub async fn get_block_meta( &self, height_or_hash: HeightOrHash, @@ -2325,7 +2444,7 @@ async fn get_server_info( let network = info.chain; let network = ExtendedNetwork::from_core_arg(&network) - .map_err(|_| anyhow!("Unknown network ({})", &network))?; + .map_err(|_| anyhow!("Unknown network ({})", network))?; let start_block = match network { ExtendedNetwork::Mainnet => 871_222, diff --git a/client/src/store/chain.rs b/client/src/store/chain.rs index f5d355a..791cf22 100644 --- a/client/src/store/chain.rs +++ b/client/src/store/chain.rs @@ -13,7 +13,7 @@ use spaces_nums::num_id::NumId; use spaces_nums::snumeric::SNumeric; use spaces_nums::{ Commitment, CommitmentKey, CommitmentTipKey, DelegatorKey, FullNumOut, NumOut, NumOutpointKey, - NumSource, RootAnchor, + NumSource, RebindData, RebindKey, RootAnchor, }; use spaces_protocol::bitcoin::hashes::Hash as HashUtil; use spaces_protocol::bitcoin::{BlockHash, OutPoint}; @@ -137,6 +137,13 @@ impl NumSource for Chain { .get_snumeric(snum) .map_err(|e| spaces_protocol::errors::Error::IO(format!("get_num_id: {}", e))) } + + fn get_num_rebind( + &mut self, + key: &RebindKey, + ) -> spaces_protocol::errors::Result> { + self.db.num.state.get_num_rebind(key) + } } impl Chain { @@ -353,8 +360,16 @@ impl Chain { self.db.num.state.insert(key, ptrout) } - pub(crate) fn insert_num_outpoint(&self, key: NumId, outpoint: EncodableOutpoint) { - self.db.num.state.insert_num_outpoint(key, outpoint) + pub(crate) fn insert_num_outpoint(&self, key: NumId, outpoint: OutPoint) { + self.db.num.state.insert_num_outpoint(key, outpoint.into()) + } + + pub(crate) fn insert_rebind(&self, key: RebindKey, rebind: RebindData) { + self.db.num.state.insert_rebind(key, rebind) + } + + pub(crate) fn remove_rebind(&self, key: RebindKey) { + self.db.num.state.remove_rebind(key) } pub(crate) fn insert_num(&self, snum: &SNumeric, id: NumId) { @@ -382,7 +397,7 @@ impl Chain { } pub fn remove_num_utxo(&mut self, outpoint: OutPoint) { - let key = OutpointKey::from_outpoint::(outpoint); + let key = NumOutpointKey::from_outpoint::(outpoint); self.db.num.state.remove(key) } diff --git a/client/src/store/ptrs.rs b/client/src/store/ptrs.rs index 0268957..26b2ffc 100644 --- a/client/src/store/ptrs.rs +++ b/client/src/store/ptrs.rs @@ -17,7 +17,7 @@ use spaces_nums::num_id::NumId; use spaces_nums::snumeric::SNumeric; use spaces_nums::{ Commitment, CommitmentKey, CommitmentTipKey, DelegatorKey, FullNumOut, NumOut, NumOutpointKey, - NumSource, + NumSource, RebindData, RebindKey, }; use spaces_protocol::slabel::SLabel; use spaces_protocol::{ @@ -104,6 +104,8 @@ pub trait NumChainState { fn remove_commitment_tip(&self, key: CommitmentTipKey); fn insert_delegator(&self, key: DelegatorKey, space: SLabel); fn insert_num_outpoint(&self, key: NumId, outpoint: EncodableOutpoint); + fn insert_rebind(&self, key: RebindKey, rebind: RebindData); + fn remove_rebind(&self, key: RebindKey); #[allow(dead_code)] fn get_num_info(&mut self, id: &NumId) -> Result>; @@ -134,6 +136,14 @@ impl NumChainState for NumLiveSnapshot { self.insert(key, outpoint) } + fn insert_rebind(&self, key: RebindKey, rebind: RebindData) { + self.insert(key, rebind) + } + + fn remove_rebind(&self, key: RebindKey) { + self.remove(key) + } + fn get_num_info(&mut self, hash: &NumId) -> Result> { let outpoint = self.get_num_outpoint_by_id(hash)?; @@ -298,7 +308,7 @@ impl NumSource for NumLiveSnapshot { let result: Option = self.get(*id).map_err(|err| { spaces_protocol::errors::Error::IO(format!("getnumoutpoint: {}", err)) })?; - Ok(result.map(|out| out.into())) + Ok(result.map(|o| o.into())) } fn get_commitment( @@ -345,4 +355,14 @@ impl NumSource for NumLiveSnapshot { fn get_num_id(&mut self, _snum: &SNumeric) -> spaces_protocol::errors::Result> { panic!("not supported call chain.get_num_id") } + + fn get_num_rebind( + &mut self, + key: &RebindKey, + ) -> spaces_protocol::errors::Result> { + let result = self + .get(*key) + .map_err(|err| spaces_protocol::errors::Error::IO(format!("getrebind: {}", err)))?; + Ok(result) + } } diff --git a/client/src/wallets.rs b/client/src/wallets.rs index 7839cf9..6dbd4b6 100644 --- a/client/src/wallets.rs +++ b/client/src/wallets.rs @@ -23,7 +23,7 @@ use spaces_wallet::{ chain::{BlockId, ChainPosition, local_chain::CheckPoint}, }, bitcoin, - bitcoin::{Address, Amount, FeeRate, OutPoint, secp256k1::schnorr}, + bitcoin::{Address, Amount, FeeRate, OutPoint, absolute::LockTime, secp256k1::schnorr}, builder::{CoinTransfer, SpaceTransfer, SpacesAwareCoinSelection}, tx_event::{TxEvent, TxEventKind, TxRecord}, }; @@ -48,10 +48,12 @@ use crate::{ use spaces_nums::FullNumOut; use spaces_nums::num_id::{NUM_HRP, NumId, NumIdParseError}; use spaces_nums::snumeric::SNumeric; -use spaces_nums::{DelegatorKey, NumOut, NumSource}; +use spaces_nums::{DelegatorKey, NumOut, NumSource, RebindKey}; use spaces_protocol::bitcoin::address::ParseError; use spaces_protocol::bitcoin::{Network, ScriptBuf}; -use spaces_wallet::builder::{CommitmentRequest, NumDelegate, NumRequest, NumTransfer}; +use spaces_wallet::builder::{ + CommitmentRequest, NumDelegate, NumRequest, NumTransfer, NumUnbind, debug_create_unbind_raw_tx, +}; use tabled::Tabled; use tokio::{ select, @@ -343,6 +345,16 @@ pub enum WalletCommand { subject: Subject, resp: crate::rpc::Responder>, }, + /// Regtest-only debug builder for hand-crafted unbind/revive txs. + /// Skips the wallet's correctness invariants so tests can drive + /// protocol-level edge cases (multi-output destroys, same-tx revive+die). + DebugBuildUnbindRaw { + num_outpoints: Vec, + extra_outputs: Vec<(ScriptBuf, Amount)>, + locktime: Option, + fee_rate: FeeRate, + resp: crate::rpc::Responder>, + }, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, ValueEnum)] @@ -706,10 +718,73 @@ impl RpcWallet { let result = Self::can_operate(wallet, chain, &subject); _ = resp.send(result); } + WalletCommand::DebugBuildUnbindRaw { + num_outpoints, + extra_outputs, + locktime, + fee_rate, + resp, + } => { + let result = Self::handle_debug_build_unbind_raw( + source, + chain, + wallet, + num_outpoints, + extra_outputs, + locktime, + fee_rate, + ); + _ = resp.send(result); + } } Ok(()) } + /// Regtest-only: build, sign, and broadcast a hand-crafted unbind/revive + /// tx that bypasses the wallet's correctness invariants. Used by tests + /// to exercise protocol-level edge cases the high-level builder refuses + /// to construct. + fn handle_debug_build_unbind_raw( + source: &BitcoinBlockSource, + chain: &mut Chain, + wallet: &mut SpacesWallet, + num_outpoints: Vec, + extra_outputs: Vec<(ScriptBuf, Amount)>, + locktime: Option, + fee_rate: FeeRate, + ) -> anyhow::Result { + let unspendables = wallet.list_spaces_outpoints(chain)?; + let lock = locktime + .map(LockTime::from_height) + .transpose() + .map_err(|e| anyhow::anyhow!("invalid locktime height: {}", e))?; + + let tx = debug_create_unbind_raw_tx( + wallet, + fee_rate, + unspendables, + false, + num_outpoints, + extra_outputs, + lock, + )?; + + let txid = tx.compute_txid(); + let last_seen = source.rpc.broadcast_tx(&source.client, &tx)?; + + let tx_record = TxRecord::new(tx); + let events = tx_record.events.clone(); + wallet.apply_unconfirmed_tx_record(tx_record, last_seen)?; + wallet.commit()?; + + Ok(TxResponse { + txid, + events, + error: None, + raw: None, + }) + } + /// Check if wallet can operate on a subject by verifying it controls the operator num fn can_operate( wallet: &SpacesWallet, @@ -1650,14 +1725,87 @@ impl RpcWallet { Some(spk) => spk, None => advance_address_to_unique_num_spk(chain, wallet)?, }; - let snum = NumId::from_spk::(spk.clone()); - let snum = chain.get_num_info(&snum)?; - if snum.is_some() && !tx.force { - return Err(anyhow!("snum already exists")); + // A rebind parked at the spk means the caller is reviving + // a dormant num -> emit a revival (…88) output. Otherwise + // it's a fresh mint (…77), which consensus skips if an + // identity was ever minted at the spk — reject those + // up-front instead of wasting a tx. + let revive = chain + .get_num_rebind(&RebindKey::from_spk::(spk.clone()))? + .is_some(); + if !revive { + let id = NumId::from_spk::(spk.clone()); + if chain.get_num_info(&id)?.is_some() && !tx.force { + return Err(anyhow!("snum already exists")); + } } - builder = builder.add_num(NumRequest { bind_spk: spk }) + builder = builder.add_num(NumRequest { + bind_spk: spk, + revive, + }) + } + RpcWalletRequest::Unbind(params) => { + let secret: Option<[u8; 32]> = match ¶ms.secret { + Some(hex) => { + let bytes = + hex::decode(hex).map_err(|_| anyhow!("invalid hex secret key"))?; + if bytes.len() != 32 { + return Err(anyhow!("secret key must be 32 bytes")); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + Some(arr) + } + None => None, + }; + for subject in ¶ms.subjects { + let id = match subject { + Subject::NumId(id) => *id, + Subject::Label(label) if label.is_numeric() => { + let numeric: SNumeric = label.clone().try_into().unwrap(); + chain.get_num_id(&numeric)?.ok_or_else(|| { + anyhow!("unbind: numeric '{}' not found", label) + })? + } + Subject::Label(label) => { + return Err(anyhow!( + "unbind: expected a num, not space '{}'", + label + )); + } + }; + let num = match chain.get_num_info(&id)? { + None => return Err(anyhow!("unbind: num '{}' not found", id)), + Some(full) if full.numout.spent => { + return Err(anyhow!("unbind: num '{}' already dormant", id)); + } + Some(full) + if secret.is_none() + && !wallet.is_mine(full.numout.script_pubkey.clone()) => + { + return Err(anyhow!("unbind: you don't own num '{}'", id)); + } + Some(full) + if secret.is_none() + && wallet + .get_utxo(OutPoint::new(full.txid, full.numout.n as u32)) + .is_none() => + { + return Err(anyhow!( + "unbind '{}': wallet already has a pending tx", + id + )); + } + Some(full) => full, + }; + // Only attach the secret to nums the wallet doesn't own — + // owned nums sign through the wallet as usual. + let secret = + secret.filter(|_| !wallet.is_mine(num.numout.script_pubkey.clone())); + builder = builder.add_num_unbind(NumUnbind { num, secret }); + } } RpcWalletRequest::Commit(params) => { let reqs = commit_params_to_req(chain, wallet, params)?; @@ -2026,6 +2174,26 @@ impl RpcWallet { resp_rx.await? } + pub async fn send_debug_build_unbind_raw( + &self, + num_outpoints: Vec, + extra_outputs: Vec<(ScriptBuf, Amount)>, + locktime: Option, + fee_rate: FeeRate, + ) -> anyhow::Result { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(WalletCommand::DebugBuildUnbindRaw { + num_outpoints, + extra_outputs, + locktime, + fee_rate, + resp, + }) + .await?; + resp_rx.await? + } + pub async fn send_get_new_address(&self, kind: AddressKind) -> anyhow::Result { let (resp, resp_rx) = oneshot::channel(); self.sender diff --git a/client/tests/ptr_tests.rs b/client/tests/ptr_tests.rs index 67ae492..1142beb 100644 --- a/client/tests/ptr_tests.rs +++ b/client/tests/ptr_tests.rs @@ -1,6 +1,7 @@ use anyhow::anyhow; use spaces_client::rpc::{ CommitParams, CreateNumParams, OperateParams, SetFallbackParams, Subject, TransferSpacesParams, + UnbindParams, }; use spaces_client::store::Sha256; use spaces_client::{ @@ -1515,6 +1516,21 @@ async fn run_ptr_tests() -> anyhow::Result<()> { println!("\n=== Running Foreign Num Transfer Tests ==="); it_should_transfer_foreign_num_with_secret(&rig).await?; + println!("\n=== Running Unbind / Revive Round-Trip Tests ==="); + it_should_unbind_and_revive_num(&rig).await?; + + println!("\n=== Running Rotated-Away Genesis Guard Tests ==="); + it_should_guard_rotated_away_genesis(&rig).await?; + + println!("\n=== Running Rotated-Death Identity Isolation Tests ==="); + it_should_not_clobber_identity_on_rotated_death(&rig).await?; + + println!("\n=== Running Move-Preserves-Rebind Tests ==="); + it_should_not_clobber_rebind_on_subsequent_move(&rig).await?; + + println!("\n=== Running Foreign Unbind (secret) Tests ==="); + it_should_unbind_foreign_num_with_secret(&rig).await?; + println!("\n=== All tests passed! ==="); Ok(()) } @@ -2500,3 +2516,852 @@ async fn it_should_create_multiple_nums_same_tx(rig: &TestRig) -> anyhow::Result Ok(()) } + +// ============== Test: Unbind / Revive Round-Trip ============== +// +// CreateNum (genesis) → Unbind (dormancy) → CreateNum (revival at same spk). +// Asserts identity is stable across the cycle while the outpoint moves, and +// that a second Unbind on an already-dormant num is rejected. +async fn it_should_unbind_and_revive_num(rig: &TestRig) -> anyhow::Result<()> { + sync_all(rig).await?; + + // (1) Genesis at a fresh ALICE address. + println!("Test 1: Create a fresh num at a new ALICE address"); + let addr = rig + .spaced + .client + .wallet_get_new_address(ALICE, AddressKind::Coin) + .await?; + let spk = bitcoin::address::Address::from_str(&addr) + .expect("valid") + .assume_checked() + .script_pubkey(); + let id = NumId::from_spk::(spk.clone()); + + let create = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::CreateNum(CreateNumParams { + bind_spk: Some(spk.clone()), + })], + false, + ) + .await?; + wallet_res_err(&create)?; + mine_and_sync(rig, 1).await?; + + let live = rig + .spaced + .client + .get_num(Subject::NumId(id)) + .await? + .expect("num must exist after CreateNum"); + assert!(!live.numout.spent, "fresh num must not be marked spent"); + assert_eq!(live.numout.num.id, id, "num id matches H(spk)"); + let live_outpoint = bitcoin::OutPoint::new(live.txid, live.numout.n as u32); + println!("✓ live num at {} (id={})", live_outpoint, id); + + // (2) Unbind → tombstone (spent=true), identity preserved at the same slot. + println!("\nTest 2: Unbind the num (non-rotated death)"); + let unbind = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Unbind(UnbindParams { + subjects: vec![Subject::NumId(id)], + secret: None, + })], + false, + ) + .await?; + wallet_res_err(&unbind)?; + mine_and_sync(rig, 1).await?; + + let dormant = rig + .spaced + .client + .get_num(Subject::NumId(id)) + .await? + .expect("tombstone must remain at the identity slot for a non-rotated death"); + assert!(dormant.numout.spent, "unbind must set spent=true"); + assert_eq!(dormant.numout.num.id, id, "identity retained on tombstone"); + let dormant_outpoint = bitcoin::OutPoint::new(dormant.txid, dormant.numout.n as u32); + assert_eq!( + dormant_outpoint, live_outpoint, + "tombstone keeps pointing at the now-spent outpoint until revival" + ); + println!("✓ tombstone at {} retains id={}", dormant_outpoint, id); + + // (3) Revive: CreateNum at the death spk → same id, new outpoint. + println!("\nTest 3: Revive by CreateNum at the death spk"); + let revive = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::CreateNum(CreateNumParams { + bind_spk: Some(spk.clone()), + })], + false, + ) + .await?; + wallet_res_err(&revive)?; + mine_and_sync(rig, 1).await?; + + let revived = rig + .spaced + .client + .get_num(Subject::NumId(id)) + .await? + .expect("revival must produce a live num at the genesis id"); + assert!(!revived.numout.spent, "revived num must be live"); + assert_eq!( + revived.numout.num.id, id, + "identity stable across unbind→revive" + ); + let revived_outpoint = bitcoin::OutPoint::new(revived.txid, revived.numout.n as u32); + assert_ne!( + revived_outpoint, live_outpoint, + "outpoint must change on revival" + ); + assert_eq!( + revived.numout.script_pubkey, spk, + "revival binds back to the death spk" + ); + println!( + "✓ revived: id stable, outpoint moved {} → {}", + live_outpoint, revived_outpoint + ); + + // (4) Reject path: a second Unbind RPC against a dormant num must error. + println!("\nTest 4: Unbind once more, then assert a redundant unbind is rejected"); + let unbind2 = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Unbind(UnbindParams { + subjects: vec![Subject::NumId(id)], + secret: None, + })], + false, + ) + .await?; + wallet_res_err(&unbind2)?; + mine_and_sync(rig, 1).await?; + + let dormant_again = rig + .spaced + .client + .get_num(Subject::NumId(id)) + .await? + .expect("tombstone remains after the second unbind"); + assert!( + dormant_again.numout.spent, + "second unbind also yields a tombstone" + ); + + let again = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Unbind(UnbindParams { + subjects: vec![Subject::NumId(id)], + secret: None, + })], + false, + ) + .await; + let err_msg = match again { + Err(e) => format!("{e}"), + Ok(res) => match wallet_res_err(&res) { + Ok(()) => panic!("unbinding an already-dormant num must be rejected"), + Err(e) => format!("{e}"), + }, + }; + assert!( + err_msg.contains("dormant") || err_msg.contains("already"), + "expected dormant-rejection error, got: {err_msg}" + ); + println!("✓ second unbind on a dormant num rejected: {}", err_msg); + + Ok(()) +} + +// ============== Test: Rotated-Away Genesis Guard ============== +// +// Mint X at genesis spk_G, rotate to spk_R, kill at spk_R. The rebind slot +// lives at rebind(spk_R) — NOT at spk_G. A later CreateNum at spk_G must NOT +// revive X: spk_G's identity slot is occupied (append-forever), so the +// wallet rejects the mint up-front, and no rebind is parked there anyway. +// Real revival key is spk_R. +async fn it_should_guard_rotated_away_genesis(rig: &TestRig) -> anyhow::Result<()> { + sync_all(rig).await?; + + // (1) Mint X at the GENESIS spk under Alice. + println!("Test 1: Mint X at Alice's genesis spk_G"); + let g_addr = rig + .spaced + .client + .wallet_get_new_address(ALICE, AddressKind::Coin) + .await?; + let spk_g = bitcoin::address::Address::from_str(&g_addr) + .expect("valid") + .assume_checked() + .script_pubkey(); + let id_x = NumId::from_spk::(spk_g.clone()); + + wallet_res_err( + &wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::CreateNum(CreateNumParams { + bind_spk: Some(spk_g.clone()), + })], + false, + ) + .await?, + )?; + mine_and_sync(rig, 1).await?; + + // (2) Transfer X → spk_R (Bob), so X "rotates away" from its genesis spk. + println!("\nTest 2: Transfer X to Bob's spk_R (rotation away from genesis)"); + let r_addr = rig + .spaced + .client + .wallet_get_new_address(BOB, AddressKind::Space) + .await?; + let spk_r = SpaceAddress::from_str(&r_addr) + .expect("valid") + .script_pubkey(); + + wallet_res_err( + &wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Transfer(TransferSpacesParams { + secret: None, + spaces: vec![Subject::NumId(id_x)], + to: Some(r_addr.clone()), + data: None, + })], + false, + ) + .await?, + )?; + mine_and_sync(rig, 1).await?; + + let after_xfer = rig + .spaced + .client + .get_num(Subject::NumId(id_x)) + .await? + .expect("X still resolves after rotation"); + assert_eq!( + after_xfer.numout.script_pubkey, spk_r, + "X now lives at spk_R" + ); + assert!(!after_xfer.numout.spent, "X is still live before unbind"); + + // (3) Bob unbinds X at spk_R. Rotated death: rebind slot at H(spk_R), + // genesis identity at H(spk_G) is left as Minted{tombstone}. + println!("\nTest 3: Bob unbinds X at spk_R (rotated death)"); + wallet_res_err( + &wallet_do( + rig, + BOB, + vec![RpcWalletRequest::Unbind(UnbindParams { + subjects: vec![Subject::NumId(id_x)], + secret: None, + })], + false, + ) + .await?, + )?; + mine_and_sync(rig, 1).await?; + + let after_unbind = rig + .spaced + .client + .get_num(Subject::NumId(id_x)) + .await? + .expect("X's identity slot still resolves to a spent tombstone"); + assert!(after_unbind.numout.spent, "X is dormant after Bob's unbind"); + + // (4) Guard fires: CreateNum at the GENESIS spk_G must NOT revive X. + // No rebind is parked at spk_G (X died at spk_R), so this is a mint + // attempt — and spk_G's identity slot is occupied forever, so the wallet + // rejects it up-front instead of building a consensus no-op tx. + println!("\nTest 4: CreateNum at spk_G rejected (identity occupied; rebind lives at spk_R)"); + let genesis_try = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::CreateNum(CreateNumParams { + bind_spk: Some(spk_g.clone()), + })], + false, + ) + .await; + let err_msg = match genesis_try { + Err(e) => format!("{e}"), + Ok(res) => match wallet_res_err(&res) { + Ok(()) => panic!("mint at an occupied genesis spk must be rejected"), + Err(e) => format!("{e}"), + }, + }; + assert!( + err_msg.contains("exists"), + "expected already-exists rejection, got: {err_msg}" + ); + assert!( + rig.spaced.client.get_rebind(spk_g.clone()).await?.is_none(), + "no rebind parked at the genesis spk" + ); + let after_genesis_try = rig + .spaced + .client + .get_num(Subject::NumId(id_x)) + .await? + .expect("X's tombstone is still there"); + assert!( + after_genesis_try.numout.spent, + "X untouched — still dormant" + ); + println!("✓ revival at the wrong (genesis) spk rejected; X still dormant"); + + // (5) Sanity: revival AT the death spk (spk_R) does revive X. + println!("\nTest 5: CreateNum at spk_R (the actual rebind key) revives X"); + wallet_res_err( + &wallet_do( + rig, + BOB, + vec![RpcWalletRequest::CreateNum(CreateNumParams { + bind_spk: Some(spk_r.clone()), + })], + false, + ) + .await?, + )?; + mine_and_sync(rig, 1).await?; + + let revived = rig + .spaced + .client + .get_num(Subject::NumId(id_x)) + .await? + .expect("X resolves after revival"); + assert!(!revived.numout.spent, "X is live again"); + assert_eq!(revived.numout.num.id, id_x, "same identity restored"); + assert_eq!( + revived.numout.script_pubkey, spk_r, + "X bound at spk_R (the rebind key), not spk_G" + ); + println!("✓ revival at the correct spk brought X back"); + + Ok(()) +} + +// ============== Test: Rotated Death Does NOT Clobber Co-Parked Identity ============== +// +// Identity and rebind records live in separate key domains, so a rotated +// death at spk_A writes only rebind(spk_A) and can never damage N's +// identity minted there. +// +// Step 1 — mint N at spk_A: identity(spk_A) = N.outpoint +// Step 2 — mint Y at spk_B: identity(spk_B) = Y.outpoint +// Step 3 — transfer Y → spk_A: identity(spk_B) = Y.outpoint' (Y at spk_A) +// Step 4 — unbind Y at spk_A: rebind(spk_A) = Y.rebind; +// identity(spk_A) untouched — N unaffected +// Step 5 — revive Y at spk_A: rebind consumed; N and Y co-live at spk_A +async fn it_should_not_clobber_identity_on_rotated_death(rig: &TestRig) -> anyhow::Result<()> { + sync_all(rig).await?; + + // (1) Mint N at spk_A. + println!("Test 1: Mint N at spk_A (Alice)"); + let a_addr = rig + .spaced + .client + .wallet_get_new_address(ALICE, AddressKind::Space) + .await?; + let spk_a = SpaceAddress::from_str(&a_addr) + .expect("valid") + .script_pubkey(); + let id_n = NumId::from_spk::(spk_a.clone()); + + wallet_res_err( + &wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::CreateNum(CreateNumParams { + bind_spk: Some(spk_a.clone()), + })], + false, + ) + .await?, + )?; + mine_and_sync(rig, 1).await?; + + let n_live = rig + .spaced + .client + .get_num(Subject::NumId(id_n)) + .await? + .expect("N exists post mint"); + assert!(!n_live.numout.spent, "N is live"); + + // (2) Mint Y at a different spk_B. + println!("\nTest 2: Mint Y at spk_B (also Alice)"); + let b_addr = rig + .spaced + .client + .wallet_get_new_address(ALICE, AddressKind::Space) + .await?; + let spk_b = SpaceAddress::from_str(&b_addr) + .expect("valid") + .script_pubkey(); + let id_y = NumId::from_spk::(spk_b.clone()); + + wallet_res_err( + &wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::CreateNum(CreateNumParams { + bind_spk: Some(spk_b.clone()), + })], + false, + ) + .await?, + )?; + mine_and_sync(rig, 1).await?; + + // (3) Transfer Y → spk_A. The rotation writes Minted{Y.new} at Y.num.id = + // H(spk_B); H(spk_A) is NOT touched. + println!("\nTest 3: Transfer Y → spk_A"); + wallet_res_err( + &wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Transfer(TransferSpacesParams { + secret: None, + spaces: vec![Subject::NumId(id_y)], + to: Some(a_addr.clone()), + data: None, + })], + false, + ) + .await?, + )?; + mine_and_sync(rig, 1).await?; + + let y_post = rig + .spaced + .client + .get_num(Subject::NumId(id_y)) + .await? + .expect("Y still resolves after transfer"); + assert_eq!(y_post.numout.script_pubkey, spk_a, "Y now lives at spk_A"); + let n_post = rig + .spaced + .client + .get_num(Subject::NumId(id_n)) + .await? + .expect("N still resolves after Y's transfer (slot at H(spk_A) untouched)"); + assert!(!n_post.numout.spent, "N still live before Y's unbind"); + assert_eq!( + n_post.numout.script_pubkey, spk_a, + "N's identity still tracked via H(spk_A), pointing at original outpoint" + ); + + // (4) Unbind Y at spk_A. The rotated death parks rebind(spk_A) = Y and + // leaves N's identity completely untouched. + println!("\nTest 4: Unbind Y at spk_A — N's identity must be untouched"); + wallet_res_err( + &wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Unbind(UnbindParams { + subjects: vec![Subject::NumId(id_y)], + secret: None, + })], + false, + ) + .await?, + )?; + mine_and_sync(rig, 1).await?; + + let n_after = rig + .spaced + .client + .get_num(Subject::NumId(id_n)) + .await? + .expect("N's identity survives Y's rotated death at the same spk"); + assert!(!n_after.numout.spent, "N is still live"); + assert_eq!(n_after.numout.script_pubkey, spk_a, "N unchanged at spk_A"); + println!("✓ N unaffected by Y's death at the same spk"); + + // Y's own identity still resolves to a tombstone, and its rebind is + // parked at spk_A's rebind slot. + let y_after = rig + .spaced + .client + .get_num(Subject::NumId(id_y)) + .await? + .expect("Y still resolves via its own identity"); + assert!( + y_after.numout.spent, + "Y resolves to a spent tombstone after unbind" + ); + let parked = rig + .spaced + .client + .get_rebind(spk_a.clone()) + .await? + .expect("Y's rebind parked at rebind(spk_A)"); + assert_eq!(parked.prev.id, id_y, "the parked rebind is Y's"); + println!("✓ Y dormant, rebind parked at spk_A"); + + // (5) Revive Y at spk_A. N and Y co-live at spk_A afterwards. + println!("\nTest 5: Revive Y at spk_A — co-lives with N"); + wallet_res_err( + &wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::CreateNum(CreateNumParams { + bind_spk: Some(spk_a.clone()), + })], + false, + ) + .await?, + )?; + mine_and_sync(rig, 1).await?; + + let y_revived = rig + .spaced + .client + .get_num(Subject::NumId(id_y)) + .await? + .expect("Y resolves after revival"); + assert!(!y_revived.numout.spent, "Y is live again"); + assert_eq!(y_revived.numout.script_pubkey, spk_a, "Y revived at spk_A"); + assert!( + rig.spaced.client.get_rebind(spk_a.clone()).await?.is_none(), + "rebind consumed" + ); + let n_final = rig + .spaced + .client + .get_num(Subject::NumId(id_n)) + .await? + .expect("N still resolves"); + assert!( + !n_final.numout.spent, + "N still live — both co-exist at spk_A" + ); + println!("✓ Y revived; N and Y co-live at spk_A"); + + Ok(()) +} + +// ============== Test: Move Does NOT Clobber a Co-Parked Rebind ============== +// +// After Y's rotated death parks rebind(spk_A) = Y, moving N (whose genesis +// id is H(spk_A)) writes its rotation to identity(spk_A) — a different key +// domain — so Y's parked rebind survives and Y stays revivable. +async fn it_should_not_clobber_rebind_on_subsequent_move(rig: &TestRig) -> anyhow::Result<()> { + sync_all(rig).await?; + + // ---- Same setup as the rotated-death test so this stands alone. ---- + println!("Setup: mint N at spk_A, mint Y at spk_B, transfer Y → spk_A, unbind Y"); + + let a_addr = rig + .spaced + .client + .wallet_get_new_address(ALICE, AddressKind::Space) + .await?; + let spk_a = SpaceAddress::from_str(&a_addr) + .expect("valid") + .script_pubkey(); + let id_n = NumId::from_spk::(spk_a.clone()); + + wallet_res_err( + &wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::CreateNum(CreateNumParams { + bind_spk: Some(spk_a.clone()), + })], + false, + ) + .await?, + )?; + mine_and_sync(rig, 1).await?; + + let n_live = rig + .spaced + .client + .get_num(Subject::NumId(id_n)) + .await? + .expect("N exists post mint"); + let n_outpoint = bitcoin::OutPoint::new(n_live.txid, n_live.numout.n as u32); + + let b_addr = rig + .spaced + .client + .wallet_get_new_address(ALICE, AddressKind::Space) + .await?; + let spk_b = SpaceAddress::from_str(&b_addr) + .expect("valid") + .script_pubkey(); + let id_y = NumId::from_spk::(spk_b.clone()); + + wallet_res_err( + &wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::CreateNum(CreateNumParams { + bind_spk: Some(spk_b.clone()), + })], + false, + ) + .await?, + )?; + mine_and_sync(rig, 1).await?; + + wallet_res_err( + &wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Transfer(TransferSpacesParams { + secret: None, + spaces: vec![Subject::NumId(id_y)], + to: Some(a_addr.clone()), + data: None, + })], + false, + ) + .await?, + )?; + mine_and_sync(rig, 1).await?; + + wallet_res_err( + &wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Unbind(UnbindParams { + subjects: vec![Subject::NumId(id_y)], + secret: None, + })], + false, + ) + .await?, + )?; + mine_and_sync(rig, 1).await?; + + // Pre-condition: Y's rebind is parked at spk_A and N is unaffected. + assert!( + rig.spaced + .client + .get_num(Subject::NumId(id_n)) + .await? + .is_some_and(|n| !n.numout.spent), + "precondition: N stays live through Y's rotated death" + ); + assert!( + rig.spaced.client.get_rebind(spk_a.clone()).await?.is_some(), + "precondition: Y's rebind parked at rebind(spk_A)" + ); + println!("✓ pre-condition: rebind(spk_A) = Y, N live"); + + // ---- The actual test: move N to spk_C; the rebind must survive. ---- + println!("\nTest: transfer N → spk_C, then revive Y at spk_A"); + + let c_addr = rig + .spaced + .client + .wallet_get_new_address(ALICE, AddressKind::Space) + .await?; + let spk_c = SpaceAddress::from_str(&c_addr) + .expect("valid") + .script_pubkey(); + + wallet_res_err( + &wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Transfer(TransferSpacesParams { + secret: None, + spaces: vec![Subject::NumId(id_n)], + to: Some(c_addr.clone()), + data: None, + })], + false, + ) + .await?, + )?; + mine_and_sync(rig, 1).await?; + + let n_moved = rig + .spaced + .client + .get_num(Subject::NumId(id_n)) + .await? + .expect("N resolves after the move"); + assert!(!n_moved.numout.spent, "N is live at the new outpoint"); + assert_eq!(n_moved.numout.script_pubkey, spk_c, "N now lives at spk_C"); + assert_ne!( + bitcoin::OutPoint::new(n_moved.txid, n_moved.numout.n as u32), + n_outpoint, + "outpoint moved" + ); + + // The rotation wrote identity(spk_A) — a different domain than + // rebind(spk_A). Y's parked rebind survives, and Y can be revived. + let parked = rig + .spaced + .client + .get_rebind(spk_a.clone()) + .await? + .expect("Y's rebind survives N's move"); + assert_eq!(parked.prev.id, id_y, "still Y's rebind"); + println!("✓ N moved to spk_C; Y's rebind untouched at spk_A"); + + wallet_res_err( + &wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::CreateNum(CreateNumParams { + bind_spk: Some(spk_a.clone()), + })], + false, + ) + .await?, + )?; + mine_and_sync(rig, 1).await?; + + let y_revived = rig + .spaced + .client + .get_num(Subject::NumId(id_y)) + .await? + .expect("Y resolves after revival"); + assert!(!y_revived.numout.spent, "Y is revivable and live again"); + assert_eq!(y_revived.numout.script_pubkey, spk_a, "Y revived at spk_A"); + println!("✓ Y revived at spk_A after N's move — nothing was clobbered"); + + Ok(()) +} + +// ============== Test: Unbind a Foreign Num With a Secret ============== +// +// A num bound to a key outside any wallet can be unbound by whoever holds +// the raw secret: the wallet spends it as a foreign utxo signed with +// `sign_with_custom_secret` — the same machinery foreign transfers use. +// Without the secret the unbind is rejected up-front. Revival afterwards +// needs no secret at all, since binds are unsigned outputs. +async fn it_should_unbind_foreign_num_with_secret(rig: &TestRig) -> anyhow::Result<()> { + sync_all(rig).await?; + + let (spk, secret) = gen_p2tr_keypair(); + let num_id = NumId::from_spk::(spk.clone()); + println!("Test 1: Mint num at external spk (id={})", num_id); + + wallet_res_err( + &wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::CreateNum(CreateNumParams { + bind_spk: Some(spk.clone()), + })], + false, + ) + .await?, + )?; + mine_and_sync(rig, 1).await?; + + assert!( + rig.spaced + .client + .get_num(Subject::NumId(num_id)) + .await? + .is_some_and(|n| !n.numout.spent), + "num live at the external spk" + ); + + // (2) Unbind without the secret must be rejected. + println!("\nTest 2: Unbind without secret is rejected"); + let no_secret = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Unbind(UnbindParams { + subjects: vec![Subject::NumId(num_id)], + secret: None, + })], + false, + ) + .await; + let err_msg = match no_secret { + Err(e) => format!("{e}"), + Ok(res) => match wallet_res_err(&res) { + Ok(()) => panic!("unbinding a foreign num without a secret must be rejected"), + Err(e) => format!("{e}"), + }, + }; + assert!( + err_msg.contains("own"), + "expected ownership rejection, got: {err_msg}" + ); + println!("✓ rejected: {err_msg}"); + + // (3) Unbind with the secret: foreign utxo signed with the raw key. + println!("\nTest 3: Unbind with secret succeeds"); + wallet_res_err( + &wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Unbind(UnbindParams { + subjects: vec![Subject::NumId(num_id)], + secret: Some(hex::encode(secret)), + })], + false, + ) + .await?, + )?; + mine_and_sync(rig, 1).await?; + + let dormant = rig + .spaced + .client + .get_num(Subject::NumId(num_id)) + .await? + .expect("identity still resolves"); + assert!(dormant.numout.spent, "num dormant after secret unbind"); + let parked = rig + .spaced + .client + .get_rebind(spk.clone()) + .await? + .expect("rebind parked at the external spk"); + assert_eq!(parked.prev.id, num_id, "the parked rebind is this num's"); + println!("✓ dormant; rebind parked at the external spk"); + + // (4) Revival needs no secret — binds are unsigned outputs. + println!("\nTest 4: Revive at the external spk (no secret needed)"); + wallet_res_err( + &wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::CreateNum(CreateNumParams { + bind_spk: Some(spk.clone()), + })], + false, + ) + .await?, + )?; + mine_and_sync(rig, 1).await?; + + let revived = rig + .spaced + .client + .get_num(Subject::NumId(num_id)) + .await? + .expect("num resolves after revival"); + assert!(!revived.numout.spent, "num live again"); + assert_eq!(revived.numout.script_pubkey, spk, "revived at the same spk"); + println!("✓ revived with the same identity"); + + Ok(()) +} diff --git a/nums/src/lib.rs b/nums/src/lib.rs index 513c2f6..d092587 100644 --- a/nums/src/lib.rs +++ b/nums/src/lib.rs @@ -2,6 +2,8 @@ pub mod constants; pub mod num_id; pub mod snumeric; +use std::collections::{BTreeMap, BTreeSet}; + #[cfg(feature = "borsh")] use borsh::{BorshDeserialize, BorshSerialize}; @@ -27,6 +29,11 @@ pub trait NumSource { id: &NumId, ) -> spaces_protocol::errors::Result>; + fn get_num_rebind( + &mut self, + key: &RebindKey, + ) -> spaces_protocol::errors::Result>; + fn get_commitment( &mut self, key: &CommitmentKey, @@ -70,6 +77,17 @@ pub struct TxChangeSet { pub spends: Vec, /// List of transaction outputs creating numouts. pub creates: Vec, + /// Dormant deaths (spent, no valid successor). Each carries the spent + /// tombstone numout. Apply overwrites the numout in place and parks a + /// rebind — derived verbatim from the tombstone — at `rebind(death spk)`. + /// The identity slot is never touched: it keeps pointing at the (now + /// spent) outpoint so `num_id -> outpoint` resolution works through + /// dormancy. + pub unbinds: Vec, + /// Revivals: each consumes a parked rebind (deletes the slot) and deletes + /// the tombstone `outpoint -> numout` entry. The revived num itself is in + /// `creates`, whose identity write repoints the genesis slot. + pub rebinds: Vec, /// New commitments made pub commitments: Vec, pub revoked_commitments: Vec, @@ -138,6 +156,64 @@ pub struct NumOut { ) )] pub script_pubkey: ScriptBuf, + + /// Whether this num is spent. If so, it can be rebound. + pub spent: bool, +} + +/// A parked rebind, stored at `rebind(spk) = ns_hash(NumRebind, H(spk))` — +/// its own key domain, independent of the identity slot at +/// `ns_hash(NumId, H(spk))`. +/// +/// Written when a num dies at `spk` (spent with no valid successor); deleted +/// when an `is_revival_output` (`value % 100 == 88`) at `spk` revives it. +/// One rebind per spk: a second death at the same spk overwrites the parked +/// one (requires `spk`'s key — death means spending a utxo at that spk — so +/// no outsider can plant or grief it). +/// +/// The identity slot is a separate, append-forever record: minted once, +/// repointed on every rotation and revival, never deleted. Dormancy is read +/// from `NumOut.spent`, not from either slot. +#[derive(Clone, PartialEq, Debug)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct RebindData { + /// The num's last outpoint before it was destroyed (now a spent + /// tombstone). The num may be foreign (rotated in from another spk) + /// or native (died at its own genesis spk) — revival treats them + /// identically. + #[cfg_attr( + feature = "borsh", + borsh( + serialize_with = "borsh_utils::serialize_outpoint", + deserialize_with = "borsh_utils::deserialize_outpoint" + ) + )] + pub prev_outpoint: OutPoint, + /// The num to revive. + pub prev: Num, +} + +/// A revival: consumes the rebind parked at `key`. The revived num is +/// rebound to a new utxo, created in `creates`. +#[derive(Clone, PartialEq, Debug)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct RebindInfo { + /// The rebind slot to delete: `rebind(revival spk)`. + pub key: RebindKey, + + /// The num's last outpoint before destruction. This + /// `outpoint -> numout` tombstone mapping must be deleted, because the + /// num is rebound to a different utxo (created in `creates`). + #[cfg_attr( + feature = "borsh", + borsh( + serialize_with = "borsh_utils::serialize_outpoint", + deserialize_with = "borsh_utils::deserialize_outpoint" + ) + )] + pub prev_outpoint: OutPoint, } #[derive(Clone, PartialEq, Debug)] @@ -197,6 +273,7 @@ pub enum KeyKind { Delegator = 0x04, NumOutpoint = 0x05, SNumeric = 0x06, + NumRebind = 0x07, } impl KeyKind { @@ -231,6 +308,14 @@ pub struct CommitmentKey([u8; 32]); #[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] pub struct NumOutpointKey([u8; 32]); +/// Key of a parked rebind: `ns_hash(NumRebind, H(spk))`. A separate domain +/// from the identity key (`NumId`), so deaths and rotations at one spk can +/// never clobber each other's records. +#[derive(Debug, Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] +pub struct RebindKey([u8; 32]); + #[derive(Debug, Copy, Clone, Ord, PartialOrd, Eq, PartialEq)] #[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))] pub struct NumericKey([u8; 32]); @@ -285,6 +370,7 @@ pub struct ChainProofRequest { pub enum NumKeyKind { Id(NumId), Num(SNumeric), + Rebind(RebindKey), Commitment(CommitmentKey), CommitmentTip(CommitmentTipKey), } @@ -294,6 +380,7 @@ impl KeyHash for DelegatorKey {} impl KeyHash for CommitmentKey {} impl KeyHash for NumOutpointKey {} impl KeyHash for NumericKey {} +impl KeyHash for RebindKey {} impl Commitment { pub fn is_finalized(&self, height: u32) -> bool { @@ -341,6 +428,18 @@ impl From for Hash { } } +impl From for Hash { + fn from(value: RebindKey) -> Self { + value.0 + } +} + +impl RebindKey { + pub fn from_spk(spk: ScriptBuf) -> Self { + Self(ns_hash::(KeyKind::NumRebind, H::hash(spk.as_bytes()))) + } +} + impl NumOutpointKey { pub fn from_outpoint(outpoint: OutPoint) -> Self { let mut buffer = [0u8; 36]; @@ -397,7 +496,12 @@ pub struct DelegateContext { pub struct TxContext { pub inputs: Vec, + /// Mint-intent output spks (`…77`) whose identity slot is already + /// occupied (a num was minted there at some point — identity slots are + /// never deleted). pub existing_num_spks: Vec, + /// Revival-intent output spks (`…88`) with a parked rebind. + pub parked_rebinds: BTreeMap, // nums with existing delegations cannot be used multiple times pub nums_with_delegations: Vec, } @@ -428,7 +532,7 @@ impl TxContext { height: u32, ) -> spaces_protocol::errors::Result> { let has_num_outputs = is_num_minting_locktime(&tx.lock_time) - && tx.output.iter().any(|out| out.is_ptr_output()); + && tx.output.iter().any(|out| out.is_num_output()); let has_spaces = spends_spaces || !space_outputs.is_empty(); let relevant = has_spaces || has_num_outputs || Self::spending_nums(src, tx)?; @@ -507,22 +611,32 @@ impl TxContext { } } - // Output script pubkeys that already have a num (skip minting duplicates) - let existing_num_spks = tx - .output - .iter() - .filter(|out| out.is_ptr_output()) - .filter_map(|out| { + // One lookup per num output, keyed by intent: mint outputs check the + // identity slot (skip duplicates), revival outputs check the rebind + // slot (load the num to revive). + let mut existing_num_spks = Vec::new(); + let mut parked_rebinds = BTreeMap::new(); + for out in tx.output.iter() { + if out.is_mint_output() { + if existing_num_spks.contains(&out.script_pubkey) { + continue; + } let id = NumId::from_spk::(out.script_pubkey.clone()); - src.get_num_outpoint_by_id(&id) - .ok()? - .map(|_| out.script_pubkey.clone()) - }) - .collect(); + if src.get_num_outpoint_by_id(&id)?.is_some() { + existing_num_spks.push(out.script_pubkey.clone()); + } + } else if out.is_revival_output() && !parked_rebinds.contains_key(&out.script_pubkey) { + let key = RebindKey::from_spk::(out.script_pubkey.clone()); + if let Some(rebind) = src.get_num_rebind(&key)? { + parked_rebinds.insert(out.script_pubkey.clone(), rebind); + } + } + } Ok(Some(TxContext { inputs, existing_num_spks, + parked_rebinds, nums_with_delegations, })) } @@ -559,6 +673,8 @@ impl Validator { txid: tx.compute_txid(), spends: vec![], creates: vec![], + unbinds: vec![], + rebinds: vec![], commitments: vec![], revoked_commitments: vec![], revoked_delegations: vec![], @@ -637,6 +753,24 @@ impl Validator { _ => [].iter(), // Empty iterator for rollback or no-op }; + // Successor outputs claimed by a same-index value match. Only input N + // can value-match output N, so each claim is unique and independent of + // input ordering: it beats a neighboring input's N+1 fallback no + // matter how an (untrusted) assembler arranges the tx, and the losing + // fallback goes down the unbind path instead of dangling. Outputs + // minted into spaces never host num successors and are excluded. + let value_matched_outputs: BTreeSet = ctx + .inputs + .iter() + .filter(|input| { + tx.output + .get(input.n) + .is_some_and(|o| o.value == input.numout.value) + && !new_space_utxos.iter().any(|s| s.n == input.n) + }) + .map(|input| input.n) + .collect(); + for input_ctx in ctx.inputs.into_iter() { // Handle delegate commitments (only first delegate gets commitment_root) if let Some(delegate) = input_ctx.delegate { @@ -688,48 +822,86 @@ impl Validator { } } // Process spend - changeset.spends.push(input_ctx.n); self.process_spend( tx, input_ctx.n, input_ctx.numout, &new_space_utxos, + &value_matched_outputs, &mut changeset, height, &data_op, ); } - // Process new nums + // Dedup is per (spk, intent): one tx may both mint (77) and revive + // (88) at the same spk; duplicate outputs with the same spk AND + // intent act once. + let mut seen_spks: Vec<(ScriptBuf, bool)> = Vec::with_capacity(tx.output.len()); + // Process new nums (mints & revivals). Both require an opt-in signal: + // the minting locktime, or the tx being a spaces tx — space flows like + // operate (space transfer + create_num) can't carry the num locktime + // since spaces tracking claims it. A tx that is only relevant because + // it spends nums must not mint at incidental num-valued outputs. + // Successors created in process_spend are rotations, not mints, and + // are exempt (an output claimed as a successor is skipped here, so it + // never doubles as a mint or revival trigger). + let minting = is_num_minting_locktime(&tx.lock_time) || has_spaces; for (n, output) in tx.output.iter().enumerate() { - // Skip if not a PTR output or already processed - if !output.is_ptr_output() + // Skip if not a num output or already processed + if !minting + || !output.is_num_output() || changeset.creates.iter().any(|x| x.n == n) || new_space_utxos.iter().any(|x| x.n == n) + || seen_spks.contains(&(output.script_pubkey.clone(), output.is_mint_output())) { continue; } - - // Skip if num id already exists - if ctx - .existing_num_spks - .iter() - .any(|spk| output.script_pubkey.as_bytes() == spk.as_bytes()) - { - continue; + seen_spks.push((output.script_pubkey.clone(), output.is_mint_output())); + + if output.is_mint_output() { + // Fresh mint. Skip if an identity was ever minted at this spk: + // anti-dup for live nums, and the rotated-away/dormant genesis + // guard — identity slots are never deleted, so a compromised + // genesis key cannot re-mint over a num that lives (or died) + // elsewhere. Parked rebinds don't block minting. + if ctx.existing_num_spks.contains(&output.script_pubkey) { + continue; + } + changeset.creates.push(NumOut { + n, + num: Num { + id: NumId::from_spk::(output.script_pubkey.clone()), + name: SNumeric::new(height, tx_pos, n as u16), + data: data_op.clone(), + last_update: height, + }, + value: output.value, + script_pubkey: output.script_pubkey.clone(), + spent: false, + }); + } else { + // Revival. Consume the parked rebind if any (an `88` output + // at an empty slot is a no-op). The create's identity write + // repoints the revived num's genesis slot to the new utxo — + // uniform for native and foreign rebinds. + let Some(rebind) = ctx.parked_rebinds.get(&output.script_pubkey) else { + continue; + }; + let mut num = rebind.prev.clone(); + num.last_update = height; + changeset.rebinds.push(RebindInfo { + key: RebindKey::from_spk::(output.script_pubkey.clone()), + prev_outpoint: rebind.prev_outpoint, + }); + changeset.creates.push(NumOut { + n, + num, + value: output.value, + script_pubkey: output.script_pubkey.clone(), + spent: false, + }); } - - changeset.creates.push(NumOut { - n, - num: Num { - id: NumId::from_spk::(output.script_pubkey.clone()), - name: SNumeric::new(height, tx_pos, n as u16), - data: data_op.clone(), - last_update: height, - }, - value: output.value, - script_pubkey: output.script_pubkey.clone(), - }); } // Create delegations for nums that opt in via output @@ -766,37 +938,63 @@ impl Validator { input_index: usize, mut numout: NumOut, new_space_utxos: &[SpaceOut], + value_matched_outputs: &BTreeSet, changeset: &mut TxChangeSet, height: u32, data: &Option, ) { - let mut ptr = numout.num; - // if a corresponding output at the same index has the same value, - // that output becomes the num - let mut output_index = input_index; - let mut output = match tx.output.get(input_index) { - None => return, // cannot be rebound, if N doesn't exist, then we can skip n+1 rule check - Some(output) => output, + let output = match tx.output.get(input_index) { + // input N moves to Output N if value is the same. + Some(o) if o.value == numout.value => Some((o, input_index)), + // otherwise we assume it's a trading tx - new num should be at n+1, + // unless input n+1 value-matches output n+1: that claim wins and + // this num falls through to the unbind path (dormant, rebindable). + Some(_) => tx + .output + .get(input_index + 1) + .filter(|_| !value_matched_outputs.contains(&(input_index + 1))) + .map(|o| (o, input_index + 1)), + None => None, }; - // if the values don't match, then we assume it's a trading tx - ptr should be at n+1 - if output.value != numout.value { - output_index = input_index + 1; - output = match tx.output.get(output_index) { - None => return, // no rebounds - Some(output) => output, - }; - } + // A successor being minted into a space is not a valid num successor; + // fall through to the unbind (dormant) path so the spent numout isn't + // left dangling as an active entry at a now-spent outpoint. + let output = output.filter(|(_, idx)| !new_space_utxos.iter().any(|s| s.n == *idx)); + + let Some((output, output_index)) = output else { + // No valid num successor: either the output is missing, or it's + // being minted into a space (filtered out above). The num goes + // dormant and can be rebound later: apply overwrites the numout + // with this tombstone and parks a rebind (derived from it) at + // `rebind(death spk)`. The identity slot is never touched — it + // keeps pointing at this (now spent) outpoint, so resolution by + // id works through dormancy. No reads, no rotated/non-rotated + // distinction. + let input = tx + .input + .get(input_index) + .expect("spent numout should exist in tx inputs"); + assert_eq!( + input.previous_output.vout as usize, numout.n, + "numout vout to match the spent numout" + ); + numout.num.last_update = height; + numout.spent = true; - // if the output is already a space, then it can't be rebound - if new_space_utxos.iter().any(|s| s.n == output_index) { + changeset.unbinds.push(FullNumOut { + txid: input.previous_output.txid, + numout, + }); return; - } + }; + let mut ptr = numout.num; ptr.last_update = height; // Only update data if: // 1. A data OP_RETURN is present - // 2. PTR is P2TR and input uses SIGHASH_ALL (prevents malicious data injection) + // 2. PTR is P2TR and input uses SIGHASH_ALL + // (prevents malicious data injection for modular txs) if let Some(new_data) = data && numout.script_pubkey.is_p2tr() && is_p2tr_sighash_all(tx, input_index) @@ -808,6 +1006,9 @@ impl Validator { numout.script_pubkey = output.script_pubkey.clone(); numout.num = ptr; changeset.creates.push(numout); + + // only remove num output if it was re-created + changeset.spends.push(input_index); } } @@ -910,13 +1111,25 @@ pub fn is_num_minting_locktime(lock_time: &LockTime) -> bool { } pub trait PtrTrackableOutput { - fn is_ptr_output(&self) -> bool; + /// Mint intent: create a fresh num at this spk (if its identity slot is + /// free). + fn is_mint_output(&self) -> bool; + /// Revival intent: consume the rebind parked at this spk (no-op if none). + fn is_revival_output(&self) -> bool; + /// Any num-intent output (relevance / dispatch loop). + fn is_num_output(&self) -> bool { + self.is_mint_output() || self.is_revival_output() + } } impl PtrTrackableOutput for TxOut { - fn is_ptr_output(&self) -> bool { + fn is_mint_output(&self) -> bool { self.value.to_sat() % 100 == 77 } + + fn is_revival_output(&self) -> bool { + self.value.to_sat() % 100 == 88 + } } #[cfg(feature = "serde")] diff --git a/nums/tests/validator_tests.rs b/nums/tests/validator_tests.rs new file mode 100644 index 0000000..45fb0f3 --- /dev/null +++ b/nums/tests/validator_tests.rs @@ -0,0 +1,568 @@ +//! Consensus edge-case tests for the nums validator, run against +//! `Validator::process` directly with a mock `NumSource`. These pin behavior +//! that the regtest integration suite can't easily reach: successor-claim +//! collisions, the mint/revival opt-in gates, value-dispatched intent +//! (77 mint / 88 revive), and the independence of identity and rebind slots. + +use std::collections::HashMap; + +use bitcoin::hashes::{Hash as _, sha256}; +use bitcoin::{ + Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness, + absolute::LockTime, transaction::Version, +}; +use spaces_nums::num_id::NumId; +use spaces_nums::snumeric::SNumeric; +use spaces_nums::{ + Num, NumOut, NumSource, RebindData, RebindKey, TxChangeSet, TxContext, Validator, +}; +use spaces_protocol::SpaceOut; +use spaces_protocol::hasher::KeyHasher; + +const HEIGHT: u32 = 100; +const TX_POS: u16 = 3; + +struct TestHasher; + +impl KeyHasher for TestHasher { + fn hash(data: &[u8]) -> spaces_protocol::hasher::Hash { + sha256::Hash::hash(data).to_byte_array() + } +} + +#[derive(Default)] +struct MockSrc { + numouts: HashMap, + identities: HashMap, + rebinds: HashMap, +} + +impl NumSource for MockSrc { + fn get_num_outpoint_by_id( + &mut self, + id: &NumId, + ) -> spaces_protocol::errors::Result> { + Ok(self.identities.get(id).copied()) + } + + fn get_num_rebind( + &mut self, + key: &RebindKey, + ) -> spaces_protocol::errors::Result> { + Ok(self.rebinds.get(key).cloned()) + } + + fn get_commitment( + &mut self, + _key: &spaces_nums::CommitmentKey, + ) -> spaces_protocol::errors::Result> { + Ok(None) + } + + fn get_commitments_tip( + &mut self, + _key: &spaces_nums::CommitmentTipKey, + ) -> spaces_protocol::errors::Result> { + Ok(None) + } + + fn get_delegator( + &mut self, + _key: &spaces_nums::DelegatorKey, + ) -> spaces_protocol::errors::Result> { + Ok(None) + } + + fn get_numout( + &mut self, + outpoint: &OutPoint, + ) -> spaces_protocol::errors::Result> { + Ok(self.numouts.get(outpoint).cloned()) + } + + fn get_num_id(&mut self, _snum: &SNumeric) -> spaces_protocol::errors::Result> { + Ok(None) + } +} + +fn mint_locktime() -> LockTime { + let lt = LockTime::from_consensus(500_000_777); + assert!(spaces_nums::is_num_minting_locktime(<)); + lt +} + +fn spk(tag: u8) -> ScriptBuf { + // Arbitrary distinct scripts; the validator only hashes them. + ScriptBuf::from_bytes(vec![0x51, tag]) +} + +fn outpoint(tag: u8, vout: u32) -> OutPoint { + OutPoint { + txid: Txid::from_byte_array([tag; 32]), + vout, + } +} + +fn build_tx(lock_time: LockTime, inputs: &[OutPoint], outputs: &[(ScriptBuf, u64)]) -> Transaction { + Transaction { + version: Version::TWO, + lock_time, + input: inputs + .iter() + .map(|&previous_output| TxIn { + previous_output, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }) + .collect(), + output: outputs + .iter() + .map(|(script_pubkey, sats)| TxOut { + value: Amount::from_sat(*sats), + script_pubkey: script_pubkey.clone(), + }) + .collect(), + } +} + +/// Seed a live num whose current utxo is `current`. Passing `genesis_spk == +/// current_spk` models a never-rotated num; different spks model a rotated one. +fn seed_num( + src: &mut MockSrc, + genesis_spk: &ScriptBuf, + current: OutPoint, + current_spk: &ScriptBuf, + value: u64, +) -> Num { + let id = NumId::from_spk::(genesis_spk.clone()); + let num = Num { + id, + name: SNumeric::new(1, 0, current.vout as u16), + data: None, + last_update: 1, + }; + src.numouts.insert( + current, + NumOut { + n: current.vout as usize, + num: num.clone(), + value: Amount::from_sat(value), + script_pubkey: current_spk.clone(), + spent: false, + }, + ); + src.identities.insert(id, current); + num +} + +/// Park a rebind at `death_spk`, as if `prev` died there at `prev_outpoint`. +fn seed_rebind(src: &mut MockSrc, death_spk: &ScriptBuf, prev_outpoint: OutPoint, prev: Num) { + src.rebinds.insert( + RebindKey::from_spk::(death_spk.clone()), + RebindData { + prev_outpoint, + prev, + }, + ); +} + +fn foreign_num(genesis_spk: &ScriptBuf) -> Num { + Num { + id: NumId::from_spk::(genesis_spk.clone()), + name: SNumeric::new(2, 0, 0), + data: None, + last_update: 2, + } +} + +fn process(src: &mut MockSrc, tx: &Transaction, new_spaces: Vec) -> TxChangeSet { + let ctx = TxContext::from_tx::(src, tx, false, new_spaces.clone(), HEIGHT) + .expect("source never errors") + .expect("tx should be relevant"); + Validator::new().process::(HEIGHT, tx, TX_POS, ctx, vec![], new_spaces) +} + +#[test] +fn mint_requires_locktime_signal_for_relevance() { + let mut src = MockSrc::default(); + let tx = build_tx(LockTime::ZERO, &[outpoint(9, 0)], &[(spk(1), 1077)]); + let ctx = + TxContext::from_tx::(&mut src, &tx, false, vec![], HEIGHT).unwrap(); + assert!( + ctx.is_none(), + "num-valued outputs without the minting locktime must not make a tx relevant" + ); + + let tx = build_tx(mint_locktime(), &[outpoint(9, 0)], &[(spk(1), 1077)]); + let changeset = process(&mut src, &tx, vec![]); + assert_eq!(changeset.creates.len(), 1); + let created = &changeset.creates[0]; + assert_eq!(created.n, 0); + assert_eq!(created.num.id, NumId::from_spk::(spk(1))); + assert_eq!(created.num.name, SNumeric::new(HEIGHT, TX_POS, 0)); + assert_eq!(created.num.last_update, HEIGHT); + assert!(!created.spent); +} + +#[test] +fn num_spend_tx_does_not_mint_at_incidental_outputs() { + // A tx relevant only because it spends a num must not mint at outputs + // that merely carry a mint value (e.g. a 77-ending change output). + let mut src = MockSrc::default(); + let a = seed_num(&mut src, &spk(1), outpoint(1, 0), &spk(1), 1000); + + let rotation_and_change = &[(spk(2), 1000), (spk(3), 5077)]; + let tx = build_tx(LockTime::ZERO, &[outpoint(1, 0)], rotation_and_change); + let changeset = process(&mut src, &tx, vec![]); + + assert_eq!(changeset.creates.len(), 1, "only the rotation, no mint"); + assert_eq!(changeset.creates[0].num.id, a.id); + assert_eq!(changeset.creates[0].n, 0); + assert!(changeset.unbinds.is_empty()); + + // The same tx WITH the minting locktime also mints at the change output. + let tx = build_tx(mint_locktime(), &[outpoint(1, 0)], rotation_and_change); + let changeset = process(&mut src, &tx, vec![]); + assert_eq!(changeset.creates.len(), 2); + assert_eq!(changeset.creates[1].n, 1); + assert_eq!( + changeset.creates[1].num.id, + NumId::from_spk::(spk(3)) + ); +} + +#[test] +fn spaces_tx_mints_without_num_locktime() { + // Space flows (operate: space transfer + create_num) cannot carry the num + // locktime since spaces tracking claims it, so spaces txs mint without it. + let mut src = MockSrc::default(); + let space_out = SpaceOut { + n: 0, + space: None, + value: Amount::from_sat(662), + script_pubkey: spk(1), + }; + let tx = build_tx( + LockTime::ZERO, + &[outpoint(9, 0)], + &[(spk(1), 662), (spk(2), 1077)], + ); + let changeset = process(&mut src, &tx, vec![space_out]); + + assert_eq!(changeset.creates.len(), 1); + assert_eq!( + changeset.creates[0].n, 1, + "space-claimed output 0 must not mint" + ); + assert_eq!( + changeset.creates[0].num.id, + NumId::from_spk::(spk(2)) + ); +} + +#[test] +fn unbind_emits_tombstone_only() { + // A death emits only the spent tombstone — no identity write, no + // rotated/non-rotated distinction. Apply parks the rebind derived from + // the tombstone at rebind(death spk); the identity slot is untouched. + for rotated in [false, true] { + let mut src = MockSrc::default(); + let genesis = spk(1); + let current = if rotated { spk(2) } else { spk(1) }; + let a = seed_num(&mut src, &genesis, outpoint(1, 0), ¤t, 500); + + // Output 0 exists but value-mismatches and there is no output 1. + let tx = build_tx(LockTime::ZERO, &[outpoint(1, 0)], &[(spk(9), 600)]); + let changeset = process(&mut src, &tx, vec![]); + + assert!(changeset.creates.is_empty()); + assert!(changeset.spends.is_empty()); + assert!(changeset.rebinds.is_empty()); + assert_eq!(changeset.unbinds.len(), 1); + let fno = &changeset.unbinds[0]; + assert!(fno.numout.spent); + assert_eq!(fno.outpoint(), outpoint(1, 0)); + assert_eq!(fno.numout.num.id, a.id); + assert_eq!(fno.numout.num.last_update, HEIGHT); + assert_eq!( + fno.numout.script_pubkey, current, + "tombstone keeps the death spk" + ); + } +} + +#[test] +fn value_match_beats_fallback_and_loser_unbinds() { + // Input 0 mismatches output 0 and would fall back to output 1, but input 1 + // value-matches output 1. The value-match must win regardless of input + // order (untrusted assemblers control ordering), and the displaced num + // must go dormant — not dangle at an outpoint recorded as another num. + let mut src = MockSrc::default(); + let a = seed_num(&mut src, &spk(1), outpoint(1, 0), &spk(1), 500); + let b = seed_num(&mut src, &spk(2), outpoint(2, 1), &spk(2), 1000); + + let tx = build_tx( + LockTime::ZERO, + &[outpoint(1, 0), outpoint(2, 1)], + &[(spk(8), 600), (spk(9), 1000)], + ); + let changeset = process(&mut src, &tx, vec![]); + + assert_eq!( + changeset.creates.len(), + 1, + "output 1 hosts exactly one successor" + ); + assert_eq!(changeset.creates[0].n, 1); + assert_eq!( + changeset.creates[0].num.id, b.id, + "value-match wins the output" + ); + assert_eq!( + changeset.spends, + vec![1], + "only the winner's input is a plain spend" + ); + + assert_eq!(changeset.unbinds.len(), 1, "the displaced num goes dormant"); + let fno = &changeset.unbinds[0]; + assert_eq!(fno.numout.num.id, a.id); + assert!(fno.numout.spent); +} + +#[test] +fn fallback_claims_next_output_when_unclaimed() { + // Trading-tx shape: seller's payment at N, num successor at N+1 with an + // arbitrary buyer-chosen value (no num-value requirement — even 88). + let mut src = MockSrc::default(); + let a = seed_num(&mut src, &spk(1), outpoint(1, 0), &spk(1), 500); + + let tx = build_tx( + LockTime::ZERO, + &[outpoint(1, 0)], + &[(spk(8), 600), (spk(9), 12388)], + ); + let changeset = process(&mut src, &tx, vec![]); + + assert!(changeset.unbinds.is_empty()); + assert_eq!(changeset.spends, vec![0]); + assert_eq!(changeset.creates.len(), 1); + let created = &changeset.creates[0]; + assert_eq!(created.n, 1); + assert_eq!(created.num.id, a.id); + assert_eq!(created.value, Amount::from_sat(12388)); + assert_eq!(created.script_pubkey, spk(9)); + assert!( + changeset.rebinds.is_empty(), + "an output claimed as a successor never doubles as a revival trigger" + ); +} + +#[test] +fn revival_consumes_rebind_and_deletes_slot() { + // An 88 output at a spk with a parked rebind revives it — uniformly for + // foreign (died away from genesis) and native (died at genesis) nums. + for native in [false, true] { + let mut src = MockSrc::default(); + let revival_spk = spk(1); + let genesis_spk = if native { spk(1) } else { spk(7) }; + let dormant = foreign_num(&genesis_spk); + seed_rebind(&mut src, &revival_spk, outpoint(4, 0), dormant.clone()); + + let tx = build_tx( + mint_locktime(), + &[outpoint(9, 0)], + &[(revival_spk.clone(), 1088)], + ); + let changeset = process(&mut src, &tx, vec![]); + + assert_eq!(changeset.rebinds.len(), 1); + let rebind = &changeset.rebinds[0]; + assert_eq!( + rebind.key, + RebindKey::from_spk::(revival_spk.clone()), + "the parked rebind slot is deleted" + ); + assert_eq!( + rebind.prev_outpoint, + outpoint(4, 0), + "tombstone entry is deleted" + ); + + assert_eq!(changeset.creates.len(), 1); + let created = &changeset.creates[0]; + assert_eq!( + created.num.id, dormant.id, + "revival keeps the genesis identity" + ); + assert_eq!(created.num.name, dormant.name); + assert_eq!(created.num.last_update, HEIGHT); + assert!(!created.spent); + } +} + +#[test] +fn revival_requires_mint_signal() { + // A dormant slot must not be revived by a tx that is only relevant + // because it spends some unrelated num. + let mut src = MockSrc::default(); + seed_rebind(&mut src, &spk(1), outpoint(4, 0), foreign_num(&spk(7))); + let unrelated = seed_num(&mut src, &spk(5), outpoint(5, 0), &spk(5), 1000); + + let tx = build_tx( + LockTime::ZERO, + &[outpoint(5, 0)], + &[(spk(5), 1000), (spk(1), 1088)], + ); + let changeset = process(&mut src, &tx, vec![]); + + assert!( + changeset.rebinds.is_empty(), + "no revival without the mint signal" + ); + assert_eq!(changeset.creates.len(), 1); + assert_eq!( + changeset.creates[0].num.id, unrelated.id, + "only the rotation" + ); +} + +#[test] +fn mint_value_does_not_revive() { + // Explicit intent: a 77 output at a spk with a parked rebind and a FREE + // identity slot mints a fresh num; the rebind stays parked. They coexist. + let mut src = MockSrc::default(); + let dormant = foreign_num(&spk(7)); + seed_rebind(&mut src, &spk(1), outpoint(4, 0), dormant.clone()); + + let tx = build_tx(mint_locktime(), &[outpoint(9, 0)], &[(spk(1), 1077)]); + let changeset = process(&mut src, &tx, vec![]); + + assert!( + changeset.rebinds.is_empty(), + "the parked rebind is untouched" + ); + assert_eq!(changeset.creates.len(), 1); + assert_eq!( + changeset.creates[0].num.id, + NumId::from_spk::(spk(1)), + "a fresh num is minted, not the dormant one revived" + ); + assert_ne!(changeset.creates[0].num.id, dormant.id); +} + +#[test] +fn revival_value_at_empty_slot_is_noop() { + // An 88 output with nothing parked does nothing — no fresh mint either. + let mut src = MockSrc::default(); + let tx = build_tx(mint_locktime(), &[outpoint(9, 0)], &[(spk(1), 1088)]); + let changeset = process(&mut src, &tx, vec![]); + + assert!(changeset.creates.is_empty()); + assert!(changeset.rebinds.is_empty()); +} + +#[test] +fn same_tx_mint_and_revive_at_same_spk() { + // Dedup is per (spk, intent): one tx may both revive the dormant num and + // mint a fresh one at the same spk. Two nums with different ids coexist. + let mut src = MockSrc::default(); + let dormant = foreign_num(&spk(7)); + seed_rebind(&mut src, &spk(1), outpoint(4, 0), dormant.clone()); + + let tx = build_tx( + mint_locktime(), + &[outpoint(9, 0)], + &[(spk(1), 1077), (spk(1), 1088)], + ); + let changeset = process(&mut src, &tx, vec![]); + + assert_eq!(changeset.creates.len(), 2); + assert_eq!( + changeset.creates[0].num.id, + NumId::from_spk::(spk(1)) + ); + assert_eq!(changeset.creates[1].num.id, dormant.id); + assert_eq!(changeset.rebinds.len(), 1); +} + +#[test] +fn minted_slot_blocks_fresh_mint() { + // Anti-dup for live nums, and the rotated-away/dormant genesis guard: an + // occupied identity slot (never deleted) blocks minting forever. + let mut src = MockSrc::default(); + seed_num(&mut src, &spk(1), outpoint(1, 0), &spk(1), 1000); + + let tx = build_tx(mint_locktime(), &[outpoint(9, 0)], &[(spk(1), 1077)]); + let changeset = process(&mut src, &tx, vec![]); + + assert!(changeset.creates.is_empty()); + assert!(changeset.rebinds.is_empty()); +} + +#[test] +fn duplicate_spk_outputs_act_once() { + // Fresh slot: two mint outputs at the same spk mint exactly one num. + let mut src = MockSrc::default(); + let tx = build_tx( + mint_locktime(), + &[outpoint(9, 0)], + &[(spk(1), 1077), (spk(1), 2077)], + ); + let changeset = process(&mut src, &tx, vec![]); + assert_eq!(changeset.creates.len(), 1); + assert_eq!(changeset.creates[0].n, 0); + + // Dormant slot: two revival outputs consume the rebind exactly once. + let mut src = MockSrc::default(); + seed_rebind(&mut src, &spk(1), outpoint(4, 0), foreign_num(&spk(7))); + let tx = build_tx( + mint_locktime(), + &[outpoint(9, 0)], + &[(spk(1), 1088), (spk(1), 2088)], + ); + let changeset = process(&mut src, &tx, vec![]); + assert_eq!(changeset.rebinds.len(), 1); + assert_eq!(changeset.creates.len(), 1); + assert_eq!(changeset.creates[0].n, 0); +} + +#[test] +fn rotation_successor_not_double_minted() { + // An output claimed by a rotation must not also be treated as a mint, + // even when the tx carries the minting locktime. + let mut src = MockSrc::default(); + let a = seed_num(&mut src, &spk(1), outpoint(1, 0), &spk(1), 1077); + + let tx = build_tx(mint_locktime(), &[outpoint(1, 0)], &[(spk(2), 1077)]); + let changeset = process(&mut src, &tx, vec![]); + + assert_eq!(changeset.creates.len(), 1); + assert_eq!( + changeset.creates[0].num.id, a.id, + "rotation, not a fresh mint" + ); + assert_eq!(changeset.spends, vec![0]); +} + +#[test] +fn successor_claim_beats_revival_dispatch() { + // Resolved design question #2: a rotating num may land in an 88 output. + // If that output's spk also has a parked rebind, the successor claim wins + // and no revival fires — reviving needs a separate unclaimed 88 output. + let mut src = MockSrc::default(); + let a = seed_num(&mut src, &spk(1), outpoint(1, 0), &spk(2), 1088); + seed_rebind(&mut src, &spk(3), outpoint(4, 0), foreign_num(&spk(7))); + + // Value-match rotation into output 0 = (spk(3), 1088). + let tx = build_tx(mint_locktime(), &[outpoint(1, 0)], &[(spk(3), 1088)]); + let changeset = process(&mut src, &tx, vec![]); + + assert_eq!(changeset.creates.len(), 1); + assert_eq!(changeset.creates[0].num.id, a.id, "successor claim wins"); + assert!( + changeset.rebinds.is_empty(), + "revival does not fire on a claimed output" + ); +} diff --git a/wallet/src/builder.rs b/wallet/src/builder.rs index 58c3e90..ad6e111 100644 --- a/wallet/src/builder.rs +++ b/wallet/src/builder.rs @@ -90,6 +90,7 @@ pub enum StackRequest { Execute(ExecuteRequest), Num(NumRequest), NumTransfer(NumTransfer), + NumUnbind(NumUnbind), NumDelegate(NumDelegate), Commitment(CommitmentRequest), } @@ -99,6 +100,7 @@ pub enum StackOp { Open(OpenRevealParams), Bid(BidRequest), Num(NumParams), + NumUnbind { nums: Vec }, NumDelegate(NumDelegate), Commitment(Vec), } @@ -125,6 +127,9 @@ pub struct RegisterRequest { #[derive(Debug, Clone)] pub struct NumRequest { pub bind_spk: ScriptBuf, + /// Emit a revival output (`…88`, consumes the rebind parked at + /// `bind_spk`) instead of a mint output (`…77`). + pub revive: bool, } #[derive(Debug, Clone)] @@ -152,6 +157,13 @@ pub struct NumTransfer { pub secret: Option<[u8; 32]>, } +#[derive(Debug, Clone)] +pub struct NumUnbind { + pub num: FullNumOut, + /// Must be specified if num isn't owned by wallet + pub secret: Option<[u8; 32]>, +} + #[derive(Debug, Clone)] pub struct NumDelegate { pub num: FullNumOut, @@ -690,6 +702,33 @@ impl Iterator for BuilderIterator<'_> { detailed })) } + StackOp::NumUnbind { nums } => { + let event_info: Vec<_> = nums + .iter() + .map(|n| { + ( + n.num.numout.num.name.to_string(), + n.num.numout.num.id.to_string(), + // death spk doubles as the revival key + n.num.numout.script_pubkey.clone(), + ) + }) + .collect(); + let tx = create_unbind_nums_tx( + self.wallet, + self.fee_rate, + self.unspendables.clone(), + self.confirmed_only, + nums, + ); + Some(tx.map(|tx| { + let mut detailed = TxRecord::new(tx); + for (name, num_id, death_spk) in event_info { + detailed.add_unbind_num(name, num_id, death_spk); + } + detailed + })) + } StackOp::NumDelegate(d) => { let num_name = d.num.numout.num.name.to_string(); let delegate_spk = d.unique_num_spk.clone(); @@ -815,6 +854,11 @@ impl Builder { self } + pub fn add_num_unbind(mut self, request: NumUnbind) -> Self { + self.requests.push(StackRequest::NumUnbind(request)); + self + } + pub fn add_num_delegate(mut self, request: NumDelegate) -> Self { self.requests.push(StackRequest::NumDelegate(request)); self @@ -916,6 +960,7 @@ impl Builder { let mut executes = Vec::new(); let mut nums = Vec::new(); let mut num_transfers = Vec::new(); + let mut num_unbinds: Vec = Vec::new(); let mut num_delegates = Vec::new(); let mut commitments = Vec::new(); for req in self.requests { @@ -938,6 +983,7 @@ impl Builder { StackRequest::Execute(params) => executes.push(params), StackRequest::Num(params) => nums.push(params), StackRequest::NumTransfer(params) => num_transfers.push(params), + StackRequest::NumUnbind(params) => num_unbinds.push(params), StackRequest::NumDelegate(params) => num_delegates.push(params), StackRequest::Commitment(req) => commitments.push(req), } @@ -973,6 +1019,12 @@ impl Builder { stack.push(StackOp::Num(params)) } + if !num_unbinds.is_empty() { + // Batch every unbind into one destroy tx — matches the single-output + // correctness rule (the lone drain output is the entire output set). + stack.push(StackOp::NumUnbind { nums: num_unbinds }); + } + for d in num_delegates { stack.push(StackOp::NumDelegate(d)); } @@ -1173,6 +1225,12 @@ pub fn num_utxo_dust(amount: Amount) -> Amount { Amount::from_sat(amount - (amount % 100) + 77) } +/// Revival signal: consumes the rebind parked at the output's spk. +pub fn num_utxo_revive_dust(amount: Amount) -> Amount { + let amount = amount.to_sat(); + Amount::from_sat(amount - (amount % 100) + 88) +} + pub fn num_utxo_delegate_dust(amount: Amount) -> Amount { let amount = amount.to_sat(); Amount::from_sat(amount - (amount % 100) + 78) @@ -1306,6 +1364,131 @@ fn create_commitment_tx( Ok(signed) } +fn create_unbind_nums_tx( + w: &mut SpacesWallet, + fee_rate: FeeRate, + unspendables: Vec, + confirmed_only: bool, + nums: Vec, +) -> anyhow::Result { + let sink = w + .internal + .next_unused_address(KeychainKind::Internal) + .script_pubkey(); + + let mut builder = w.build_tx(unspendables, confirmed_only)?; + builder.fee_rate(fee_rate); + + // Spend every num to destroy. Input index is irrelevant with one output. + for unbind in &nums { + let outpoint = unbind.num.outpoint(); + if let Some(secret) = unbind.secret { + // destroy foreign num + let mut spend_input = Input { + witness_utxo: Some(TxOut { + value: unbind.num.numout.value, + script_pubkey: unbind.num.numout.script_pubkey.clone(), + }), + final_script_witness: Some(Witness::default()), + final_script_sig: Some(ScriptBuf::new()), + proprietary: BTreeMap::new(), + ..Default::default() + }; + spend_input.proprietary.insert( + SpacesWallet::spaces_signer("sign_with_custom_secret"), + secret.to_vec(), + ); + builder + .add_foreign_utxo_with_sequence( + outpoint, + spend_input, + tap_key_spend_weight(), + Sequence::ENABLE_RBF_NO_LOCKTIME, + ) + .map_err(|e| anyhow!("could not spend foreign num at {}: {}", outpoint, e))?; + } else { + builder + .add_utxo(outpoint) + .map_err(|e| anyhow!("could not spend num at {}: {}", outpoint, e))?; + } + } + + // EXACTLY ONE output. Drain everything (num dust + funding − fee) into the + // internal sink so bdk creates no separate change output. A change output + // here would land at n+1 and REBIND the num instead of destroying it. + builder.drain_to(sink); + + let psbt = builder.finish()?; + let tx = &psbt.unsigned_tx; + + // Destroy requires that NO num finds a positional successor. + // With a single drain output this holds, but coin selection controls the + // output set, so verify on the unsigned psbt rather than waste signing work. + if tx.output.len() != 1 { + return Err(anyhow!( + "unbind tx must have exactly one output, got {} — a second output \ + at n+1 would REBIND a num instead of destroying it", + tx.output.len() + )); + } + // Belt-and-suspenders: the lone output must not value-match any spent num + // (would be an n-successor = rotation, not a death). + let out_val = tx.output[0].value; + if nums.iter().any(|n| n.num.numout.value == out_val) { + return Err(anyhow!( + "drain output value-matches a num — could rotate, not destroy" + )); + } + + let signed = w.sign(psbt, None)?; + Ok(signed) +} + +/// Debug builder: construct a tx spending the given outpoints, drained to the +/// wallet's internal sink, with explicit extra outputs appended. NO invariants +/// are enforced — callers can produce txs that `create_unbind_nums_tx` would +/// reject (e.g. value-matching n+1, multi-output destroys, same-tx revive+die). +/// +/// Intended for regtest debug RPCs; do not expose to mainnet callers. +pub fn debug_create_unbind_raw_tx( + w: &mut SpacesWallet, + fee_rate: FeeRate, + unspendables: Vec, + confirmed_only: bool, + num_outpoints: Vec, + extra_outputs: Vec<(ScriptBuf, Amount)>, + locktime: Option, +) -> anyhow::Result { + let sink = w + .internal + .next_unused_address(KeychainKind::Internal) + .script_pubkey(); + + let mut builder = w.build_tx(unspendables, confirmed_only)?; + builder.ordering(TxOrdering::Untouched); + builder.fee_rate(fee_rate); + if let Some(lt) = locktime { + builder.nlocktime(lt); + } + + for outpoint in &num_outpoints { + builder + .add_utxo(*outpoint) + .map_err(|e| anyhow!("could not spend outpoint {}: {}", outpoint, e))?; + } + + // With Untouched ordering, explicit recipients appear in insertion order + // followed by the drain output. Callers can rely on these output indices. + for (spk, amount) in &extra_outputs { + builder.add_recipient(spk.clone(), *amount); + } + builder.drain_to(sink); + + let psbt = builder.finish()?; + let signed = w.sign(psbt, None)?; + Ok(signed) +} + fn create_num_tx( w: &mut SpacesWallet, median_time: u64, @@ -1369,7 +1552,12 @@ fn create_num_tx( // Handle binds: add any binds last to not mess with input/output order for transfers for num in params.binds { - builder.add_recipient(num.bind_spk, num_utxo_dust(Amount::from_sat(1000))); + let dust = if num.revive { + num_utxo_revive_dust(Amount::from_sat(1000)) + } else { + num_utxo_dust(Amount::from_sat(1000)) + }; + builder.add_recipient(num.bind_spk, dust); } // Add data OP_RETURN if present (only makes sense with transfers) diff --git a/wallet/src/nostr.rs b/wallet/src/nostr.rs index 62cd5cc..94c0ff0 100644 --- a/wallet/src/nostr.rs +++ b/wallet/src/nostr.rs @@ -57,10 +57,7 @@ impl NostrEvent { } pub fn serialize_for_signing(&self) -> Option { - let pubkey = match &self.pubkey { - None => return None, - Some(pubkey) => pubkey, - }; + let pubkey = self.pubkey.as_ref()?; // Nostr requires a specific serialization format for signing: // [0, , , , , ] let serialized = json!([ diff --git a/wallet/src/tx_event.rs b/wallet/src/tx_event.rs index 8d63a2f..887e23e 100644 --- a/wallet/src/tx_event.rs +++ b/wallet/src/tx_event.rs @@ -88,6 +88,13 @@ pub struct TransferNumEventDetails { pub to: ScriptBuf, } +#[derive(Debug, Serialize, Deserialize)] +pub struct UnbindNumEventDetails { + pub num_id: String, + /// The spk at which the num was destroyed; doubles as the revival key. + pub death_spk: ScriptBuf, +} + #[derive(Debug, Serialize, Deserialize)] pub struct DelegateEventDetails { pub script_pubkey: ScriptBuf, @@ -114,6 +121,7 @@ pub enum TxEventKind { Buy, CreateNum, TransferNum, + UnbindNum, Delegate, CommitRoot, RollbackRoot, @@ -487,6 +495,18 @@ impl TxRecord { }); } + pub fn add_unbind_num(&mut self, num: String, num_id: String, death_spk: ScriptBuf) { + self.events.push(TxEvent { + kind: TxEventKind::UnbindNum, + space: Some(num), + previous_spaceout: None, + details: Some( + serde_json::to_value(UnbindNumEventDetails { num_id, death_spk }) + .expect("json value"), + ), + }); + } + pub fn add_delegate(&mut self, num: String, to: ScriptBuf) { self.events.push(TxEvent { kind: TxEventKind::Delegate, @@ -571,6 +591,7 @@ impl Display for TxEventKind { TxEventKind::Renew => "renew", TxEventKind::CreateNum => "create-num", TxEventKind::TransferNum => "transfer-num", + TxEventKind::UnbindNum => "unbind-num", TxEventKind::Delegate => "delegate", TxEventKind::CommitRoot => "commit-root", TxEventKind::RollbackRoot => "rollback-root", @@ -596,6 +617,7 @@ impl FromStr for TxEventKind { "renew" => Ok(TxEventKind::Renew), "create-num" => Ok(TxEventKind::CreateNum), "transfer-num" => Ok(TxEventKind::TransferNum), + "unbind-num" => Ok(TxEventKind::UnbindNum), "delegate" => Ok(TxEventKind::Delegate), "commit-root" => Ok(TxEventKind::CommitRoot), "rollback-root" => Ok(TxEventKind::RollbackRoot),