diff --git a/Cargo.lock b/Cargo.lock index 53b7b67..a31612e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2627,9 +2627,9 @@ dependencies = [ [[package]] name = "spacedb" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a48cda82e951391df9d0a54c96f8b04e117ff39abad5359b181cb71c80798d77" +checksum = "cf14a3d14c3dcc6b87d837e246d9de73ef24b0d83796a294ceaa82e8acf4e8c9" dependencies = [ "bincode", "borsh", diff --git a/Cargo.toml b/Cargo.toml index 0e23373..bc88fac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ anyhow = "1.0" hex = "0.4" serde_json = "1.0" tokio = { version = "1", features = ["full"] } -spacedb = { version = "0.1.2", features = ["hash-idx"] } +spacedb = { version = "0.1.4", features = ["hash-idx"] } base64 = "0.22" rand = "0.8" log = "0.4" diff --git a/client/src/bin/space-cli.rs b/client/src/bin/space-cli.rs index d558473..e820489 100644 --- a/client/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -320,16 +320,16 @@ enum Commands { /// List a space you own for sale #[command(name = "sell")] Sell { - /// The space to sell - space: String, + /// The space (@bitcoin), numeric (#800000-3-1), or num id (num1...) to sell + subject: String, /// Amount in satoshis price: u64, }, - /// Buy a space from the specified listing + /// Buy a space or num from the specified listing #[command(name = "buy")] Buy { - /// The space to buy - space: String, + /// The space (@bitcoin), numeric (#800000-3-1), or num id (num1...) to buy + subject: String, /// The listing price price: u64, /// The seller's signature @@ -338,6 +338,23 @@ enum Commands { /// The seller's address #[arg(long)] seller: String, + /// Deliver the space/num to this address instead of the wallet's own + /// (e.g. an external keystore) + #[arg(long)] + to: Option, + /// Fee rate to use in sat/vB + #[arg(long, short)] + fee_rate: Option, + }, + /// Fund and broadcast one or more externally-signed transfer PSBTs. + /// Each PSBT must be a single input/output pair signed with + /// SIGHASH_SINGLE|ANYONECANPAY whose input value equals its output value; + /// the wallet adds fee inputs and change and broadcasts one transaction. + #[command(name = "fundtransfer")] + FundTransfer { + /// Base64-encoded PSBT(s), one per transfer to batch into the tx + #[arg(required = true)] + psbts: Vec, /// Fee rate to use in sat/vB #[arg(long, short)] fee_rate: Option, @@ -345,8 +362,8 @@ enum Commands { /// Verify a listing #[command(name = "verifylisting")] VerifyListing { - /// The space to buy - space: String, + /// The space (@bitcoin), numeric (#800000-3-1), or num id (num1...) + subject: String, /// The listing price price: u64, /// The seller's signature @@ -546,6 +563,14 @@ fn normalize_space(space: &str) -> String { } } +/// Normalize a listing subject: @space, #numeric, or num1... — validated and +/// canonicalized via [`Subject`]. +fn normalize_subject(subject: &str) -> Result { + spaces_wallet::Subject::from_str(subject) + .map(|s| s.to_string()) + .map_err(ClientError::Custom) +} + #[tokio::main] async fn main() -> anyhow::Result<()> { let (cli, args) = SpaceCli::configure().await?; @@ -917,14 +942,15 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client ); } Commands::Buy { - space, + subject, price, signature, seller, + to, fee_rate, } => { let listing = Listing { - space: normalize_space(&space), + subject: normalize_subject(&subject)?, price, seller, signature: Signature::from_slice( @@ -941,6 +967,7 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client .wallet_buy( &cli.wallet, listing, + to, fee_rate.map(|rate| FeeRate::from_sat_per_vb(rate).expect("valid fee rate")), cli.skip_tx_check, ) @@ -953,19 +980,36 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client cli.format, ); } - Commands::Sell { mut space, price } => { - space = normalize_space(&space); - let result = cli.client.wallet_sell(&cli.wallet, space, price).await?; + Commands::FundTransfer { psbts, fee_rate } => { + let result = cli + .client + .wallet_fund_transfer( + &cli.wallet, + psbts, + fee_rate.map(|rate| FeeRate::from_sat_per_vb(rate).expect("valid fee rate")), + ) + .await?; + print_wallet_response( + cli.network.fallback_network(), + WalletResponse { + result: vec![result], + }, + cli.format, + ); + } + Commands::Sell { subject, price } => { + let subject = normalize_subject(&subject)?; + let result = cli.client.wallet_sell(&cli.wallet, subject, price).await?; println!("{}", serde_json::to_string_pretty(&result).expect("result")); } Commands::VerifyListing { - space, + subject, price, signature, seller, } => { let listing = Listing { - space: normalize_space(&space), + subject: normalize_subject(&subject)?, price, seller, signature: Signature::from_slice( diff --git a/client/src/rpc.rs b/client/src/rpc.rs index 06092ce..272cc74 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -375,11 +375,15 @@ pub trait Rpc { skip_tx_check: bool, ) -> Result, ErrorObjectOwned>; + /// Buy a listed space or num. `recipient` optionally delivers the subject + /// to an external address (e.g. another keystore); when omitted it goes to + /// a fresh address of the funding wallet. #[method(name = "walletbuy")] async fn wallet_buy( &self, wallet: &str, listing: Listing, + recipient: Option, fee_rate: Option, skip_tx_check: bool, ) -> Result; @@ -392,6 +396,17 @@ pub trait Rpc { amount: u64, ) -> Result; + /// Fund and broadcast one or more externally-signed transfer PSBTs + /// (single input/output, SIGHASH_SINGLE|ANYONECANPAY, value in == value + /// out) as a single transaction. The wallet supplies fee + change. + #[method(name = "walletfundtransfer")] + async fn wallet_fund_transfer( + &self, + wallet: &str, + psbts: Vec, + fee_rate: Option, + ) -> Result; + #[method(name = "verifylisting")] async fn verify_listing(&self, listing: Listing) -> Result<(), ErrorObjectOwned>; @@ -473,6 +488,17 @@ pub trait Rpc { locktime: Option, fee_rate: FeeRate, ) -> Result; + + /// Debug: produce a value-preserving num transfer PSBT (single input/output, + /// SIGHASH_SINGLE|ANYONECANPAY) for one of the wallet's nums, to be funded + /// via `walletfundtransfer`. Returns the base64 PSBT. Regtest only. + #[method(name = "debugsigntransfer")] + async fn debug_sign_transfer( + &self, + wallet: &str, + subject: String, + recipient: ScriptBuf, + ) -> Result; } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -1352,12 +1378,13 @@ impl RpcServer for RpcServerImpl { &self, wallet: &str, listing: Listing, + recipient: Option, fee_rate: Option, skip_tx_check: bool, ) -> Result { self.wallet(wallet) .await? - .send_buy(listing, fee_rate, skip_tx_check) + .send_buy(listing, recipient, fee_rate, skip_tx_check) .await .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } @@ -1375,6 +1402,19 @@ impl RpcServer for RpcServerImpl { .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } + async fn wallet_fund_transfer( + &self, + wallet: &str, + psbts: Vec, + fee_rate: Option, + ) -> Result { + self.wallet(wallet) + .await? + .send_fund_transfer(psbts, fee_rate) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + async fn verify_listing(&self, listing: Listing) -> Result<(), ErrorObjectOwned> { self.store .verify_listing(listing) @@ -1583,6 +1623,31 @@ impl RpcServer for RpcServerImpl { .await .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } + + async fn debug_sign_transfer( + &self, + wallet: &str, + subject: String, + recipient: ScriptBuf, + ) -> 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_sign_transfer is only available on regtest", + None::, + )); + } + self.wallet(wallet) + .await? + .send_debug_sign_transfer(subject, recipient) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } } impl AsyncChainState { diff --git a/client/src/rpc_schema.rs b/client/src/rpc_schema.rs index e9dd7a9..0c3e6c3 100644 --- a/client/src/rpc_schema.rs +++ b/client/src/rpc_schema.rs @@ -157,6 +157,20 @@ pub fn build_schema() -> Vec { result_schema: None, extra_examples: vec![], }, + MethodSchema { + name: "getrebind", + description: "Get the rebind parked at a script pubkey (a num that died \ + there and can be revived with a `…88` output), if any", + params: vec![param( + "script_pubkey", + "string", + "Script pubkey (hex)", + json!("5120..."), + )], + result_type: "Option", + result_schema: None, + extra_examples: vec![], + }, // Commitment/delegation queries MethodSchema { name: "getcommitment", @@ -496,6 +510,21 @@ pub fn build_schema() -> Vec { }] }), ), + ( + "Unbind a num (make it dormant / revivable at its death spk). \ + Pass an optional hex `secret` to unbind a num not owned by the wallet.", + json!({ + "jsonrpc": "2.0", "id": 1, + "method": "walletsendrequest", + "params": ["default", { + "requests": [{"request": "unbind", "subjects": ["num1..."]}], + "fee_rate": 1.0, + "force": false, + "confirmed_only": false, + "skip_tx_check": false, + }] + }), + ), ( "Set up an operator for a space", json!({ @@ -625,14 +654,21 @@ pub fn build_schema() -> Vec { }, MethodSchema { name: "walletbuy", - description: "Buy a space from a listing", + description: "Buy a listed space or num. Optionally deliver it to an \ + external recipient address instead of this wallet.", params: vec![ param("wallet", "string", "Wallet name", json!("default")), param( "listing", "Listing", "The listing to buy", - json!({"space": "@example", "price": 100000, "seller_psbt": "..."}), + json!({"subject": "@example", "price": 100000, "seller": "bcrt1p...", "signature": "hex..."}), + ), + opt_param( + "recipient", + "string", + "Address to deliver the space/num to (defaults to a fresh wallet address)", + json!("bcrt1p..."), ), opt_param("fee_rate", "number", "Fee rate in sat/vB", json!(1.0)), param( @@ -648,10 +684,15 @@ pub fn build_schema() -> Vec { }, MethodSchema { name: "walletsell", - description: "Create a listing to sell a space", + description: "Create a listing to sell a space or num", params: vec![ param("wallet", "string", "Wallet name", json!("default")), - param("space", "string", "Space name", json!("@example")), + param( + "subject", + "string", + "Space (@bitcoin), numeric (#800000-3-1), or num id (num1...)", + json!("@example"), + ), param( "amount", "integer", @@ -663,6 +704,26 @@ pub fn build_schema() -> Vec { result_schema: None, extra_examples: vec![], }, + MethodSchema { + name: "walletfundtransfer", + description: "Fund and broadcast one or more externally-signed transfer \ + PSBTs (single input/output, SIGHASH_SINGLE|ANYONECANPAY, \ + input value == output value) as a single transaction. The \ + wallet supplies fee inputs and change.", + params: vec![ + param("wallet", "string", "Wallet name", json!("default")), + param( + "psbts", + "array", + "Base64-encoded transfer PSBT(s) to batch into one tx", + json!(["cHNidP8B..."]), + ), + opt_param("fee_rate", "number", "Fee rate in sat/vB", json!(1.0)), + ], + result_type: "TxResponse", + result_schema: Some(serde_json::to_value(schema_for!(TxResponse)).unwrap()), + extra_examples: vec![], + }, MethodSchema { name: "verifylisting", description: "Verify that a listing is valid", @@ -670,7 +731,7 @@ pub fn build_schema() -> Vec { "listing", "Listing", "The listing to verify", - json!({"space": "@example", "price": 100000, "seller_psbt": "..."}), + json!({"subject": "@example", "price": 100000, "seller": "bcrt1p...", "signature": "hex..."}), )], result_type: "()", result_schema: None, @@ -813,6 +874,55 @@ pub fn build_schema() -> Vec { result_schema: None, extra_examples: vec![], }, + MethodSchema { + name: "debugbuildunbindraw", + description: "Debug method to build a raw num-unbind transaction with \ + positional inputs/outputs (regtest only)", + params: vec![ + param("wallet", "string", "Wallet name", json!("default")), + param( + "num_outpoints", + "array", + "Num outpoints to spend", + json!(["txid:0"]), + ), + param( + "extra_outputs", + "array", + "Extra outputs (script_pubkey + amount) to append", + json!([{"script_pubkey": "5120...", "amount": 662}]), + ), + opt_param("locktime", "integer", "Optional locktime height", json!(0)), + param("fee_rate", "number", "Fee rate in sat/vB", json!(1.0)), + ], + result_type: "TxResponse", + result_schema: Some(serde_json::to_value(schema_for!(TxResponse)).unwrap()), + extra_examples: vec![], + }, + MethodSchema { + name: "debugsigntransfer", + description: "Debug method to produce a value-preserving num transfer \ + PSBT for one of the wallet's nums, to be funded via \ + walletfundtransfer (regtest only)", + params: vec![ + param("wallet", "string", "Wallet name", json!("default")), + param( + "subject", + "string", + "Space, numeric, or num id to transfer", + json!("num1..."), + ), + param( + "recipient", + "string", + "Recipient script pubkey (hex)", + json!("5120..."), + ), + ], + result_type: "string", + result_schema: None, + extra_examples: vec![], + }, ] } diff --git a/client/src/wallets.rs b/client/src/wallets.rs index 6dbd4b6..45ea28f 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, absolute::LockTime, secp256k1::schnorr}, + bitcoin::{Address, Amount, FeeRate, OutPoint, Psbt, absolute::LockTime, secp256k1::schnorr}, builder::{CoinTransfer, SpaceTransfer, SpacesAwareCoinSelection}, tx_event::{TxEvent, TxEventKind, TxRecord}, }; @@ -312,10 +312,16 @@ pub enum WalletCommand { }, Buy { listing: Listing, + recipient: Option, skip_tx_check: bool, fee_rate: Option, resp: crate::rpc::Responder>, }, + FundTransfer { + psbts: Vec, + fee_rate: Option, + resp: crate::rpc::Responder>, + }, Sell { space: String, price: u64, @@ -355,6 +361,11 @@ pub enum WalletCommand { fee_rate: FeeRate, resp: crate::rpc::Responder>, }, + DebugSignTransfer { + subject: String, + recipient: ScriptBuf, + resp: crate::rpc::Responder>, + }, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, ValueEnum)] @@ -466,6 +477,7 @@ impl RpcWallet { chain: &mut Chain, wallet: &mut SpacesWallet, listing: Listing, + recipient: Option, skip_tx_check: bool, fee_rate: Option, ) -> anyhow::Result { @@ -478,17 +490,24 @@ impl RpcWallet { }; info!("Using fee rate: {} sat/vB", fee_rate.to_sat_per_vb_ceil()); - let (_, fullspaceout) = SpacesWallet::verify_listing::(chain, &listing)?; + // Optional external delivery address (e.g. an outside keystore). If + // absent, the subject goes to a fresh address of this wallet. + let recipient_spk = match recipient { + None => None, + Some(s) => Some(match SpaceAddress::from_str(&s) { + Ok(addr) => addr.script_pubkey(), + Err(_) => Address::from_str(&s) + .map_err(|e| anyhow!("invalid recipient address: {}", e))? + .assume_checked() + .script_pubkey(), + }), + }; + + let verified = SpacesWallet::verify_listing::(chain, &listing)?; - let space = fullspaceout - .spaceout - .space - .as_ref() - .expect("space") - .name - .to_string(); - let previous_spaceout = fullspaceout.outpoint(); - let tx = wallet.buy::(chain, &listing, fee_rate)?; + let subject = listing.subject.clone(); + let previous_spaceout = verified.outpoint; + let tx = wallet.buy::(chain, &listing, fee_rate, recipient_spk)?; if !skip_tx_check { let tip = wallet.local_chain().tip().height(); @@ -503,7 +522,7 @@ impl RpcWallet { tx, vec![TxEvent { kind: TxEventKind::Buy, - space: Some(space), + space: Some(subject), previous_spaceout: Some(previous_spaceout), details: None, }], @@ -524,6 +543,64 @@ impl RpcWallet { }) } + fn handle_fund_transfer( + source: &BitcoinBlockSource, + chain: &mut Chain, + wallet: &mut SpacesWallet, + psbts: Vec, + fee_rate: Option, + ) -> anyhow::Result { + let fee_rate = match fee_rate.as_ref() { + None => match Self::estimate_fee_rate(source) { + None => return Err(anyhow!("could not estimate fee rate")), + Some(r) => r, + }, + Some(r) => *r, + }; + info!("Using fee rate: {} sat/vB", fee_rate.to_sat_per_vb_ceil()); + + if psbts.is_empty() { + return Err(anyhow!("no transfer psbts provided")); + } + let parsed = psbts + .iter() + .enumerate() + .map(|(i, s)| { + Psbt::from_str(s.trim()).map_err(|e| anyhow!("transfer {i}: invalid psbt: {e}")) + }) + .collect::>>()?; + + // Exclude the funder's own space and num utxos from fee coin selection + // so we never spend one to pay the fee. + let mut unspendables = wallet.list_spaces_outpoints(chain)?; + for utxo in wallet.list_unspent() { + if chain.get_numout(&utxo.outpoint)?.is_some() { + unspendables.push(utxo.outpoint); + } + } + + let tx = wallet.fund_transfers(unspendables, parsed, fee_rate)?; + + // Validate the resulting protocol state transition before broadcast. + let tip = wallet.local_chain().tip().height(); + let mut checker = TxChecker::new(chain); + checker.check_apply_tx(tip + 1, &tx)?; + + let new_txid = tx.compute_txid(); + let last_seen = source.rpc.broadcast_tx(&source.client, &tx)?; + + let tx_record = TxRecord::new(tx); + wallet.apply_unconfirmed_tx_record(tx_record, last_seen + 1)?; + wallet.commit()?; + + Ok(TxResponse { + txid: new_txid, + events: vec![], + error: None, + raw: None, + }) + } + fn handle_fee_bump( source: &BitcoinBlockSource, chain: &mut Chain, @@ -691,6 +768,7 @@ impl RpcWallet { } WalletCommand::Buy { listing, + recipient, resp, skip_tx_check, fee_rate, @@ -700,6 +778,7 @@ impl RpcWallet { chain, wallet, listing, + recipient, skip_tx_check, fee_rate, )); @@ -707,6 +786,15 @@ impl RpcWallet { WalletCommand::Sell { space, price, resp } => { _ = resp.send(wallet.sell::(chain, &space, Amount::from_sat(price))); } + WalletCommand::FundTransfer { + psbts, + fee_rate, + resp, + } => { + _ = resp.send(Self::handle_fund_transfer( + source, chain, wallet, psbts, fee_rate, + )); + } WalletCommand::SignSchnorr { subject, message, @@ -736,6 +824,16 @@ impl RpcWallet { ); _ = resp.send(result); } + WalletCommand::DebugSignTransfer { + subject, + recipient, + resp, + } => { + let result = wallet + .sign_transfer::(chain, &subject, recipient) + .map(|psbt| psbt.to_string()); + _ = resp.send(result); + } } Ok(()) } @@ -2194,6 +2292,22 @@ impl RpcWallet { resp_rx.await? } + pub async fn send_debug_sign_transfer( + &self, + subject: String, + recipient: ScriptBuf, + ) -> anyhow::Result { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(WalletCommand::DebugSignTransfer { + subject, + recipient, + 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 @@ -2231,6 +2345,7 @@ impl RpcWallet { pub async fn send_buy( &self, listing: Listing, + recipient: Option, fee_rate: Option, skip_tx_check: bool, ) -> anyhow::Result { @@ -2238,6 +2353,7 @@ impl RpcWallet { self.sender .send(WalletCommand::Buy { listing, + recipient, fee_rate, skip_tx_check, resp, @@ -2254,6 +2370,22 @@ impl RpcWallet { resp_rx.await? } + pub async fn send_fund_transfer( + &self, + psbts: Vec, + fee_rate: Option, + ) -> anyhow::Result { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(WalletCommand::FundTransfer { + psbts, + fee_rate, + resp, + }) + .await?; + resp_rx.await? + } + pub async fn send_list_transactions( &self, count: usize, diff --git a/client/tests/integration_tests.rs b/client/tests/integration_tests.rs index 5d5172d..829101b 100644 --- a/client/tests/integration_tests.rs +++ b/client/tests/integration_tests.rs @@ -1185,6 +1185,7 @@ async fn it_should_allow_buy_sell(rig: &TestRig) -> anyhow::Result<()> { .wallet_buy( BOB, listing.clone(), + None, Some(FeeRate::from_sat_per_vb(1).expect("rate")), false, ) diff --git a/client/tests/ptr_tests.rs b/client/tests/ptr_tests.rs index 1142beb..19350d3 100644 --- a/client/tests/ptr_tests.rs +++ b/client/tests/ptr_tests.rs @@ -1531,10 +1531,242 @@ async fn run_ptr_tests() -> anyhow::Result<()> { println!("\n=== Running Foreign Unbind (secret) Tests ==="); it_should_unbind_foreign_num_with_secret(&rig).await?; + println!("\n=== Running Fund Transfer (PSBT) Tests ==="); + it_should_fund_and_broadcast_transfers(&rig).await?; + + println!("\n=== Running Num Sell/Buy Tests ==="); + it_should_sell_and_buy_num(&rig).await?; + println!("\n=== All tests passed! ==="); Ok(()) } +/// Mint a fresh num at a new address of `wallet`; returns (id, spk). +async fn mint_num_at_new_address( + rig: &TestRig, + wallet: &str, +) -> anyhow::Result<(NumId, bitcoin::ScriptBuf)> { + let addr = rig + .spaced + .client + .wallet_get_new_address(wallet, AddressKind::Coin) + .await?; + let spk = bitcoin::address::Address::from_str(&addr) + .expect("valid") + .assume_checked() + .script_pubkey(); + let id = NumId::from_spk::(spk.clone()); + wallet_res_err( + &wallet_do( + rig, + wallet, + vec![RpcWalletRequest::CreateNum(CreateNumParams { + bind_spk: Some(spk.clone()), + })], + false, + ) + .await?, + )?; + mine_and_sync(rig, 1).await?; + Ok((id, spk)) +} + +// ============== Test: Fund + broadcast transfer PSBTs (single & batch) ============== +// +// A maker signs a value-preserving single-in/single-out transfer with +// SIGHASH_SINGLE|ANYONECANPAY; a different wallet funds the fee and broadcasts. +// Batching two such transfers in one tx exercises the index-alignment +// invariant (input k rotates to output k). +async fn it_should_fund_and_broadcast_transfers(rig: &TestRig) -> anyhow::Result<()> { + sync_all(rig).await?; + + // (1) Single transfer: ALICE signs, BOB funds. + println!("Test 1: ALICE signs a num transfer, BOB funds + broadcasts"); + let (id_a, _spk_a) = mint_num_at_new_address(rig, ALICE).await?; + let value_a = rig + .spaced + .client + .get_num(Subject::NumId(id_a)) + .await? + .expect("num A exists") + .numout + .value; + let (recip_a, _) = gen_p2tr_keypair(); + + let psbt_a = rig + .spaced + .client + .debug_sign_transfer(ALICE, id_a.to_string(), recip_a.clone()) + .await?; + let resp = rig + .spaced + .client + .wallet_fund_transfer(BOB, vec![psbt_a], None) + .await?; + assert!( + resp.error.is_none(), + "fund transfer errored: {:?}", + resp.error + ); + mine_and_sync(rig, 1).await?; + + let moved = rig + .spaced + .client + .get_num(Subject::NumId(id_a)) + .await? + .expect("num A resolves after transfer"); + assert!(!moved.numout.spent, "transferred num must be live"); + assert_eq!( + moved.numout.script_pubkey, recip_a, + "num A landed at the recipient" + ); + assert_eq!(moved.numout.value, value_a, "transfer preserves value"); + println!("✓ single transfer funded and broadcast"); + + // (2) Batch: two transfers in one funded tx (index alignment). + println!("\nTest 2: batch two transfers into one tx"); + let (id_b, _spk_b) = mint_num_at_new_address(rig, ALICE).await?; + let (id_c, _spk_c) = mint_num_at_new_address(rig, ALICE).await?; + let (recip_b, _) = gen_p2tr_keypair(); + let (recip_c, _) = gen_p2tr_keypair(); + + let psbt_b = rig + .spaced + .client + .debug_sign_transfer(ALICE, id_b.to_string(), recip_b.clone()) + .await?; + let psbt_c = rig + .spaced + .client + .debug_sign_transfer(ALICE, id_c.to_string(), recip_c.clone()) + .await?; + + let resp = rig + .spaced + .client + .wallet_fund_transfer(BOB, vec![psbt_b, psbt_c], None) + .await?; + assert!( + resp.error.is_none(), + "batch fund transfer errored: {:?}", + resp.error + ); + mine_and_sync(rig, 1).await?; + + let moved_b = rig + .spaced + .client + .get_num(Subject::NumId(id_b)) + .await? + .expect("num B resolves"); + let moved_c = rig + .spaced + .client + .get_num(Subject::NumId(id_c)) + .await? + .expect("num C resolves"); + assert_eq!( + moved_b.numout.script_pubkey, recip_b, + "num B landed at its recipient (index 0)" + ); + assert_eq!( + moved_c.numout.script_pubkey, recip_c, + "num C landed at its recipient (index 1) — alignment holds" + ); + assert!(!moved_b.numout.spent && !moved_c.numout.spent); + println!("✓ batch of two transfers funded in one tx, alignment correct"); + + Ok(()) +} + +// ============== Test: Sell + buy a num (numeric and num id subjects) ============== +async fn it_should_sell_and_buy_num(rig: &TestRig) -> anyhow::Result<()> { + sync_all(rig).await?; + + for use_numeric in [true, false] { + let (id, _spk) = mint_num_at_new_address(rig, ALICE).await?; + let info = rig + .spaced + .client + .get_num(Subject::NumId(id)) + .await? + .expect("minted num exists"); + + // Sell by numeric (#b-t-v) on one pass, by num id (num1...) on the other. + let subject = if use_numeric { + info.numout.num.name.to_string() + } else { + id.to_string() + }; + println!("Selling num via subject {}", subject); + + let listing = rig + .spaced + .client + .wallet_sell(ALICE, subject.clone(), 5000) + .await?; + assert_eq!(listing.price, 5000); + + // A third party can verify the listing before buying. + rig.spaced.client.verify_listing(listing.clone()).await?; + + // On the num-id pass, deliver to an EXTERNAL recipient (EVE) while BOB + // funds — the Nacho case. On the numeric pass, deliver to BOB itself. + let deliver_external = !use_numeric; + let recipient = if deliver_external { + Some( + rig.spaced + .client + .wallet_get_new_address(EVE, AddressKind::Coin) + .await?, + ) + } else { + None + }; + + let resp = rig + .spaced + .client + .wallet_buy(BOB, listing, recipient, None, false) + .await?; + assert!(resp.error.is_none(), "buy errored: {:?}", resp.error); + mine_and_sync(rig, 1).await?; + + let bought = rig + .spaced + .client + .get_num(Subject::NumId(id)) + .await? + .expect("num resolves after sale"); + assert!(!bought.numout.spent, "sold num must be live"); + + // The num rotated to the intended owner's N+1 receiving output: EVE + // when delivered externally (BOB only funded), else BOB. + let owner = if deliver_external { EVE } else { BOB }; + let owner_nums = rig.spaced.client.wallet_list_nums(owner, None).await?; + assert!( + owner_nums.nums.iter().any(|n| n.numout.num.id == id), + "{} owns the num after buying (subject {})", + owner, + subject + ); + if deliver_external { + let buyer_nums = rig.spaced.client.wallet_list_nums(BOB, None).await?; + assert!( + !buyer_nums.nums.iter().any(|n| n.numout.num.id == id), + "funder BOB must NOT receive an externally-delivered num" + ); + } + println!( + "✓ num sold and bought via {} (delivered to {})", + subject, owner + ); + } + + Ok(()) +} + fn gen_p2tr_keypair() -> (bitcoin::ScriptBuf, [u8; 32]) { use bitcoin::key::TapTweak; use bitcoin::opcodes::all::OP_PUSHNUM_1; diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index e3dd24c..03bfd59 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -36,7 +36,7 @@ use spaces_nums::{ num_id::{NUM_HRP, NumId}, }; use spaces_protocol::{ - Covenant, FullSpaceOut, Space, + Covenant, Space, bitcoin::{ Address, ScriptBuf, XOnlyPublicKey, constants::genesis_block, @@ -156,12 +156,108 @@ impl<'de> Deserialize<'de> for Subject { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Listing { - pub space: String, + /// The space (@bitcoin), numeric (#800000-3-1), or num id (num1...) for + /// sale. `space` is a deprecated alias accepted on input for older clients. + #[serde(alias = "space")] + pub subject: String, pub price: u64, pub seller: String, pub signature: schnorr::Signature, } +/// What a [`Listing`] refers to, resolved on-chain. +#[derive(Debug, Clone)] +pub enum ListingKind { + Space(SLabel), + Num(NumId), +} + +/// A verified listing: the seller's committed input/output and what it sells. +#[derive(Debug, Clone)] +pub struct VerifiedListing { + /// The seller's proceeds address (output 0 of the signed pair). + pub recipient: SpaceAddress, + /// The utxo being sold. + pub outpoint: OutPoint, + /// Its value + script pubkey (committed by the seller's signature). + pub prevout: TxOut, + pub kind: ListingKind, +} + +/// A validated single-input/single-output transfer pair extracted from a PSBT. +#[derive(Debug)] +struct TransferPair { + outpoint: OutPoint, + prevout: TxOut, + sequence: Sequence, + output: TxOut, + witness: Witness, +} + +/// Parse and validate one externally-signed transfer PSBT: exactly one input +/// and one output, tx version 2 / locktime 0, a present witness_utxo, a +/// `SIGHASH_SINGLE|ANYONECANPAY` key-path signature, and input value == output +/// value. Returns the finalized foreign-input material for funding. +fn parse_transfer_pair(psbt: &Psbt) -> anyhow::Result { + let tx = &psbt.unsigned_tx; + if tx.input.len() != 1 || tx.output.len() != 1 { + return Err(anyhow!("expected exactly 1 input and 1 output")); + } + // The taproot sighash commits to version and locktime, so every maker must + // agree with the tx we build (and each other). + if tx.version != Version::TWO { + return Err(anyhow!("expected tx version 2")); + } + if tx.lock_time != LockTime::ZERO { + return Err(anyhow!("expected locktime 0")); + } + + let prevout = psbt.inputs[0] + .witness_utxo + .clone() + .ok_or_else(|| anyhow!("missing witness_utxo"))?; + let output = tx.output[0].clone(); + + // Safety invariant: value in == value out. + if prevout.value != output.value { + return Err(anyhow!( + "input value {} does not match output value {}", + prevout.value, + output.value + )); + } + + let single_acp = TapSighashType::SinglePlusAnyoneCanPay as u8; + let witness = if let Some(w) = psbt.inputs[0].final_script_witness.as_ref() { + let sig = w + .iter() + .next() + .filter(|_| w.len() == 1) + .ok_or_else(|| anyhow!("expected single key-path witness"))?; + if sig.len() != 65 || sig[64] != single_acp { + return Err(anyhow!("sighash must be SINGLE|ANYONECANPAY")); + } + w.clone() + } else if let Some(sig) = psbt.inputs[0].tap_key_sig.as_ref() { + if sig.sighash_type != TapSighashType::SinglePlusAnyoneCanPay { + return Err(anyhow!("sighash must be SINGLE|ANYONECANPAY")); + } + let mut w = Witness::new(); + w.push(sig.to_vec()); + w + } else { + return Err(anyhow!("input is not signed (no tap key signature)")); + }; + + Ok(TransferPair { + outpoint: tx.input[0].previous_output, + prevout, + sequence: tx.input[0].sequence, + output, + witness, + }) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BalanceDetails { #[serde(flatten)] @@ -980,13 +1076,27 @@ impl SpacesWallet { Ok(not_auctioned) } + /// Buy a listed space or num. The subject is delivered to `recipient` if + /// given (e.g. an external keystore's script pubkey), otherwise to a fresh + /// address of this wallet. The funding wallet always pays the price + fee. pub fn buy( &mut self, - src: &mut impl SpacesSource, + src: &mut (impl SpacesSource + NumSource), listing: &Listing, fee_rate: FeeRate, + recipient: Option, ) -> anyhow::Result { - let (seller, spaceout) = Self::verify_listing::(src, listing)?; + let verified = Self::verify_listing::(src, listing)?; + + // The seller signed input 0 -> output 0 (their proceeds). The subject + // (space or num) rotates to the *N+1* output the buyer adds, which the + // seller's SINGLE signature does not commit to. For that N+1 routing to + // happen the seller's output 0 must not value-match the input, so a num + // sale requires a non-zero price (a zero-price num sale would rotate the + // num straight to the seller — use a transfer instead). + if matches!(verified.kind, ListingKind::Num(_)) && listing.price == 0 { + return Err(anyhow!("a num sale requires a non-zero price")); + } let mut witness = Witness::new(); witness.push( @@ -999,8 +1109,11 @@ impl SpacesWallet { let funded_psbt = { let unspendables = self.list_spaces_outpoints(src)?; - let space_address = self.next_unused_space_address(); - let dust_amount = space_dust(space_address.script_pubkey().minimal_non_dust().mul(2)); + let recipient_spk = match recipient { + Some(spk) => spk, + None => self.next_unused_space_address().script_pubkey(), + }; + let dust_amount = space_dust(recipient_spk.minimal_non_dust().mul(2)); let mut builder = self.build_tx(unspendables, false)?; builder @@ -1010,12 +1123,9 @@ impl SpacesWallet { .nlocktime(LockTime::Blocks(Height::ZERO)) .set_exact_sequence(Sequence::ENABLE_RBF_NO_LOCKTIME) .add_foreign_utxo_with_sequence( - spaceout.outpoint(), + verified.outpoint, psbt::Input { - witness_utxo: Some(TxOut { - value: spaceout.spaceout.value, - script_pubkey: spaceout.spaceout.script_pubkey.clone(), - }), + witness_utxo: Some(verified.prevout.clone()), final_script_witness: Some(witness), ..Default::default() }, @@ -1023,10 +1133,10 @@ impl SpacesWallet { BID_PSBT_INPUT_SEQUENCE, )? .add_recipient( - seller.script_pubkey(), - spaceout.spaceout.value + Amount::from_sat(listing.price), + verified.recipient.script_pubkey(), + verified.prevout.value + Amount::from_sat(listing.price), ) - .add_recipient(space_address.script_pubkey(), dust_amount); + .add_recipient(recipient_spk, dust_amount); builder.finish()? }; @@ -1034,53 +1144,150 @@ impl SpacesWallet { Ok(tx) } - pub fn verify_listing( - src: &mut impl SpacesSource, - listing: &Listing, - ) -> anyhow::Result<(SpaceAddress, FullSpaceOut)> { - let label = SLabel::from_str(&listing.space)?; - let space_key = SpaceKey::from(H::hash(label.as_ref())); - let outpoint = match src.get_space_outpoint(&space_key)? { - None => { - return Err(anyhow::anyhow!( - "Unknown space {} - no outpoint found", - listing.space - )); + /// Fund and finalize one or more externally-signed transfer PSBTs into a + /// single transaction. Each PSBT must be a single-input/single-output pair + /// signed with `SIGHASH_SINGLE | ANYONECANPAY` whose input value equals its + /// output value. Those two properties make funding *blind and safe*: the + /// maker's output is fully covered by the maker's own input, so the funder + /// only ever contributes the fee — it can neither be tricked into covering + /// the recipient's amount nor redirect the maker's output. + /// + /// Layout is index-aligned: transfer `k`'s input sits at `vin[k]` and its + /// output at `vout[k]` (funder inputs and change are appended after). + /// Because the taproot SINGLE message commits to the *content* of the + /// output at the input's position (not the position number), each maker + /// signature stays valid at any index as long as its paired output keeps + /// the same content — which the alignment guarantees. The same alignment + /// satisfies the num successor rule (input `k` value-matches output `k`, so + /// the num rotates to its recipient). + /// + /// `unspendables` must include the funder's own space/num utxos so coin + /// selection never spends one to pay the fee. + pub fn fund_transfers( + &mut self, + unspendables: Vec, + psbts: Vec, + fee_rate: FeeRate, + ) -> anyhow::Result { + if psbts.is_empty() { + return Err(anyhow!("no transfer psbts provided")); + } + + let mut pairs: Vec = Vec::with_capacity(psbts.len()); + let mut seen = std::collections::HashSet::new(); + for (i, psbt) in psbts.iter().enumerate() { + let pair = parse_transfer_pair(psbt).map_err(|e| anyhow!("transfer {i}: {e}"))?; + if !seen.insert(pair.outpoint) { + return Err(anyhow!("transfer {i}: duplicate input {}", pair.outpoint)); } - Some(outpoint) => outpoint, - }; + pairs.push(pair); + } - let spaceout = match src.get_spaceout(&outpoint)? { - None => return Err(anyhow!("Unknown or spent spaces utxo: {}", outpoint)), - Some(outpoint) => outpoint, + let funded_psbt = { + let mut builder = self.build_tx(unspendables, false)?; + builder + .version(2) + .ordering(TxOrdering::Untouched) + .nlocktime(LockTime::ZERO) + .fee_rate(fee_rate); + + // Foreign inputs first, in order -> vin[0..N]. Preserve each + // maker's own sequence (committed by its sighash). + for pair in &pairs { + builder.add_foreign_utxo_with_sequence( + pair.outpoint, + psbt::Input { + witness_utxo: Some(pair.prevout.clone()), + final_script_witness: Some(pair.witness.clone()), + ..Default::default() + }, + tap_key_spend_weight(), + pair.sequence, + )?; + } + // Recipient outputs in the same order -> vout[0..N], aligning + // input k with output k. Funder fee inputs + change are appended + // after by coin selection (TxOrdering::Untouched). + for pair in &pairs { + builder.add_recipient(pair.output.script_pubkey.clone(), pair.output.value); + } + builder.finish()? }; - if spaceout.space.is_none() { - return Err(anyhow!("No associated space")); - } - if !matches!( - spaceout.space.as_ref().unwrap().covenant, - Covenant::Transfer { .. } - ) { - return Err(anyhow::anyhow!("Space not registered")); - } + let tx = self.sign(funded_psbt, None)?; + Ok(tx) + } - let recipient = Self::verify_listing_signature( - listing, - outpoint, - TxOut { - value: spaceout.value, - script_pubkey: spaceout.script_pubkey.clone(), - }, - )?; + /// Resolve a listing subject (@space, #numeric, or num1...) to the on-chain + /// utxo it sells, validating that the utxo is transferable. + fn resolve_listing_subject( + src: &mut (impl SpacesSource + NumSource), + subject: &str, + ) -> anyhow::Result<(OutPoint, TxOut, ListingKind)> { + let parsed = Subject::from_str(subject).map_err(|e| anyhow!(e))?; + match parsed { + Subject::Label(label) if !label.is_numeric() => { + let space_key = SpaceKey::from(H::hash(label.as_ref())); + let outpoint = src + .get_space_outpoint(&space_key)? + .ok_or_else(|| anyhow!("Unknown space {} - no outpoint found", subject))?; + let spaceout = src + .get_spaceout(&outpoint)? + .ok_or_else(|| anyhow!("Unknown or spent spaces utxo: {}", outpoint))?; + let space = spaceout + .space + .as_ref() + .ok_or_else(|| anyhow!("No associated space"))?; + if !matches!(space.covenant, Covenant::Transfer { .. }) { + return Err(anyhow!("Space not registered")); + } + let name = space.name.clone(); + let prevout = TxOut { + value: spaceout.value, + script_pubkey: spaceout.script_pubkey, + }; + Ok((outpoint, prevout, ListingKind::Space(name))) + } + parsed => { + let id = match parsed { + Subject::NumId(id) => id, + Subject::Label(numeric_label) => { + let numeric = SNumeric::try_from(numeric_label) + .map_err(|_| anyhow!("invalid numeric: {}", subject))?; + src.get_num_id(&numeric)? + .ok_or_else(|| anyhow!("Unknown numeric {}", subject))? + } + }; + let outpoint = src + .get_num_outpoint_by_id(&id)? + .ok_or_else(|| anyhow!("Unknown num {}", subject))?; + let numout = src + .get_numout(&outpoint)? + .ok_or_else(|| anyhow!("Unknown or spent num utxo: {}", outpoint))?; + if numout.spent { + return Err(anyhow!("Num {} is dormant", subject)); + } + let prevout = TxOut { + value: numout.value, + script_pubkey: numout.script_pubkey, + }; + Ok((outpoint, prevout, ListingKind::Num(id))) + } + } + } - Ok(( + pub fn verify_listing( + src: &mut (impl SpacesSource + NumSource), + listing: &Listing, + ) -> anyhow::Result { + let (outpoint, prevout, kind) = Self::resolve_listing_subject::(src, &listing.subject)?; + let recipient = Self::verify_listing_signature(listing, outpoint, prevout.clone())?; + Ok(VerifiedListing { recipient, - FullSpaceOut { - txid: outpoint.txid, - spaceout, - }, - )) + outpoint, + prevout, + kind, + }) } fn verify_listing_signature( @@ -1126,32 +1333,18 @@ impl SpacesWallet { pub fn sell( &mut self, - src: &mut impl SpacesSource, - space: &str, + src: &mut (impl SpacesSource + NumSource), + subject: &str, asking_price: Amount, ) -> anyhow::Result { - let label = SLabel::from_str(space)?; - let spacehash = SpaceKey::from(H::hash(label.as_ref())); - let space_outpoint = match src.get_space_outpoint(&spacehash)? { - None => return Err(anyhow::anyhow!("Space not found")), - Some(outpoint) => outpoint, - }; - let spaceout = match src.get_spaceout(&space_outpoint)? { - None => return Err(anyhow::anyhow!("Space not found")), - Some(spaceout) => spaceout, - }; - if !matches!( - spaceout.space.as_ref().unwrap().covenant, - Covenant::Transfer { .. } - ) { - return Err(anyhow::anyhow!("Space not registered")); - } + let (outpoint, _prevout, _kind) = Self::resolve_listing_subject::(src, subject)?; - let utxo = match self.internal.get_utxo(space_outpoint) { + let utxo = match self.internal.get_utxo(outpoint) { None => { return Err(anyhow::anyhow!( - "Wallet does not own a space with outpoint {}", - space_outpoint + "Wallet does not own {} (outpoint {})", + subject, + outpoint )); } Some(utxo) => utxo, @@ -1201,7 +1394,7 @@ impl SpacesWallet { .expect("signed listing must have a single witness item"); Ok(Listing { - space: space.to_string(), + subject: subject.to_string(), price: asking_price.to_sat(), seller: recipient.to_string(), signature: Signature::from_slice(&signature[..64]) @@ -1209,6 +1402,54 @@ impl SpacesWallet { }) } + /// Produce a value-preserving transfer PSBT for one of the wallet's own + /// nums: a single input (the num utxo) -> single output (`recipient`, same + /// value) signed with `SIGHASH_SINGLE|ANYONECANPAY`. The result can be + /// funded and broadcast by any wallet via [`Self::fund_transfers`]. + pub fn sign_transfer( + &mut self, + src: &mut (impl SpacesSource + NumSource), + subject: &str, + recipient: ScriptBuf, + ) -> anyhow::Result { + let (outpoint, prevout, _kind) = Self::resolve_listing_subject::(src, subject)?; + let utxo = self + .internal + .get_utxo(outpoint) + .ok_or_else(|| anyhow!("Wallet does not own {} (outpoint {})", subject, outpoint))?; + + let mut psbt = { + let mut builder = self + .internal + .build_tx() + .coin_selection(RequiredUtxosOnlyCoinSelectionAlgorithm); + builder + .version(2) + .allow_dust(true) + .ordering(TxOrdering::Untouched) + .nlocktime(LockTime::Blocks(Height::ZERO)) + .set_exact_sequence(Sequence::ENABLE_RBF_NO_LOCKTIME) + .manually_selected_only() + .sighash(TapSighashType::SinglePlusAnyoneCanPay.into()) + .add_utxo(utxo.outpoint)? + // Value-preserving: output value == input value. + .add_recipient(recipient, prevout.value); + builder.finish()? + }; + + let finalized = self.internal.sign( + &mut psbt, + SignOptions { + allow_all_sighashes: true, + ..Default::default() + }, + )?; + if !finalized { + return Err(anyhow!("signing transfer psbt failed")); + } + Ok(psbt) + } + pub fn new_bid_psbt( &mut self, total_burned: Amount, @@ -1749,3 +1990,75 @@ impl<'de> Deserialize<'de> for SpaceScriptSigningInfo { deserializer.deserialize_seq(OpenSigningInfoVisitor) } } + +#[cfg(test)] +mod transfer_tests { + use super::*; + + // A real single-input/single-output num transfer PSBT produced by the + // nacho app: P2TR num input (2000 sats) -> P2PKH recipient (2000 sats), + // signed SIGHASH_SINGLE|ANYONECANPAY (0x83). + const NACHO_PSBT: &str = "cHNidP8BAFUCAAAAAUmIZzfEOyHTqdRTVljO3sK4Vp4C2rKsXzG+Nvjw1RVaAgAAAAD/////AdAHAAAAAAAAGXapFMbmtRt9wz0wsaJKPjjcUkQyOgmfiKwAAAAAAAEBK9AHAAAAAAAAIlEgt9mEd9UzAwXAZNpkSMxuU1SM42Y7BCqgRRvaZHj9nawBAwSDAAAAARNB4BASbMDLf7MkxOjEGPgttqQPynbrKa+GtyLuN7sQkOzaP5HP8mVtfKXsxYugKVePWea5rS5vKyrtj3ii4tK87YMAAA=="; + + fn nacho() -> Psbt { + Psbt::from_str(NACHO_PSBT).expect("valid base64 psbt") + } + + #[test] + fn accepts_value_preserving_single_acp() { + let pair = parse_transfer_pair(&nacho()).expect("valid transfer"); + assert_eq!(pair.prevout.value, pair.output.value); + assert_eq!(pair.prevout.value, Amount::from_sat(2000)); + assert_eq!(pair.sequence, Sequence::MAX); + assert_eq!(pair.witness.len(), 1, "single key-path witness element"); + let sig = pair.witness.iter().next().unwrap(); + assert_eq!(sig.len(), 65); + assert_eq!(sig[64], TapSighashType::SinglePlusAnyoneCanPay as u8); + } + + #[test] + fn rejects_value_mismatch() { + let mut psbt = nacho(); + psbt.unsigned_tx.output[0].value = Amount::from_sat(1999); + let err = parse_transfer_pair(&psbt).unwrap_err().to_string(); + assert!(err.contains("does not match"), "got: {err}"); + } + + #[test] + fn rejects_multi_output() { + let mut psbt = nacho(); + let extra = psbt.unsigned_tx.output[0].clone(); + psbt.unsigned_tx.output.push(extra); + let err = parse_transfer_pair(&psbt).unwrap_err().to_string(); + assert!(err.contains("1 input and 1 output"), "got: {err}"); + } + + #[test] + fn rejects_wrong_sighash() { + let mut psbt = nacho(); + let sig = psbt.inputs[0].tap_key_sig.unwrap(); + psbt.inputs[0].tap_key_sig = Some(taproot::Signature { + signature: sig.signature, + sighash_type: TapSighashType::All, + }); + let err = parse_transfer_pair(&psbt).unwrap_err().to_string(); + assert!(err.contains("SINGLE|ANYONECANPAY"), "got: {err}"); + } + + #[test] + fn rejects_missing_witness_utxo() { + let mut psbt = nacho(); + psbt.inputs[0].witness_utxo = None; + let err = parse_transfer_pair(&psbt).unwrap_err().to_string(); + assert!(err.contains("witness_utxo"), "got: {err}"); + } + + #[test] + fn rejects_unsigned() { + let mut psbt = nacho(); + psbt.inputs[0].tap_key_sig = None; + psbt.inputs[0].final_script_witness = None; + let err = parse_transfer_pair(&psbt).unwrap_err().to_string(); + assert!(err.contains("not signed"), "got: {err}"); + } +}