diff --git a/client/src/bin/space-cli.rs b/client/src/bin/space-cli.rs index 5129821..829981a 100644 --- a/client/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -144,6 +144,9 @@ enum Commands { /// The space name space: String, }, + /// Generate a random p2tr keypair and print the secret key, script pubkey, and num id + #[command(name = "generatekey")] + GenerateKey, /// Create a new num #[command(name = "createnum")] CreateNum { @@ -173,6 +176,9 @@ enum Commands { /// Recipient space name or address #[arg(long, display_order = 1)] to: String, + /// Read hex-encoded secret key from stdin for transferring nums not owned by wallet + #[arg(long)] + secret_stdin: bool, /// Fee rate to use in sat/vB #[arg(long, short)] fee_rate: Option, @@ -399,9 +405,12 @@ enum Commands { /// still in auction with a winning bid #[command(name = "listspaces")] ListSpaces, - /// List nums owned by wallet + /// List nums. Defaults to owned, use --kind external for nums created but not owned. #[command(name = "listnums")] - ListNums, + ListNums { + #[arg(long, default_value = "owned")] + kind: String, + }, /// List unspent auction outputs i.e. outputs that can be /// auctioned off in the bidding process #[command(name = "listbidouts")] @@ -694,7 +703,7 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client Subject::Label(SLabel::from_str(&normalized).expect("valid space")) }).collect(); cli.send_request( - Some(RpcWalletRequest::Transfer(TransferSpacesParams { + Some(RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces, to: None, data: None, @@ -708,10 +717,20 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client Commands::Transfer { spaces, to, + 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::Transfer(TransferSpacesParams { + secret, spaces, to: Some(to), data: None, @@ -817,8 +836,9 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client let spaces = cli.client.wallet_list_spaces(&cli.wallet).await?; print_list_spaces_response(tip.tip.height, spaces, cli.format); } - Commands::ListNums => { - let nums = cli.client.wallet_list_nums(&cli.wallet).await?; + Commands::ListNums { kind } => { + let kind = if kind == "owned" { None } else { Some(kind) }; + let nums = cli.client.wallet_list_nums(&cli.wallet, kind).await?; print_list_nums_response(nums, cli.format); } Commands::Balance => { @@ -923,6 +943,30 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client cli.client.verify_listing(listing).await?; println!("{} Listing verified", "✓".color(Color::Green)); } + Commands::GenerateKey => { + use spaces_wallet::bitcoin::secp256k1::{Secp256k1, Keypair}; + use spaces_wallet::bitcoin::key::TapTweak; + use spaces_wallet::bitcoin::script::Builder; + use spaces_wallet::bitcoin::opcodes::all::OP_PUSHNUM_1; + + let secp = Secp256k1::new(); + let (secret_key, _) = secp.generate_keypair(&mut rand::thread_rng()); + let keypair = Keypair::from_secret_key(&secp, &secret_key); + let tweaked = keypair.tap_tweak(&secp, None); + let (xonly, _) = tweaked.to_keypair().x_only_public_key(); + + let spk = Builder::new() + .push_opcode(OP_PUSHNUM_1) + .push_slice(xonly.serialize()) + .into_script(); + + let num_id = NumId::from_spk::(spk.clone()); + let tweaked_secret = tweaked.to_keypair().secret_key().secret_bytes(); + + println!("secret: {}", hex::encode(tweaked_secret)); + println!("spk: {}", hex::encode(spk.as_bytes())); + println!("num_id: {}", num_id); + } Commands::CreateNum { bind_spk, fee_rate } => { let spk = match bind_spk { Some(hex) => { diff --git a/client/src/rpc.rs b/client/src/rpc.rs index 2cd2dd1..3cb64fa 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -424,6 +424,7 @@ pub trait Rpc { async fn wallet_list_nums( &self, wallet: &str, + kind: Option, ) -> Result; #[method(name = "walletlistunspent")] @@ -513,6 +514,10 @@ pub struct TransferSpacesParams { #[serde(skip_serializing_if = "Option::is_none")] pub data: Option>, + + /// Hex-encoded 32-byte secret key for transferring nums not owned by the wallet + #[serde(skip_serializing_if = "Option::is_none")] + pub secret: Option, } #[derive(Clone, Serialize, Deserialize)] @@ -1361,10 +1366,12 @@ impl RpcServer for RpcServerImpl { async fn wallet_list_nums( &self, wallet: &str, + kind: Option, ) -> Result { + let external = kind.as_deref() == Some("external"); self.wallet(&wallet) .await? - .send_list_nums() + .send_list_nums(external) .await .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } diff --git a/client/src/wallets.rs b/client/src/wallets.rs index 06200ed..cb927dd 100644 --- a/client/src/wallets.rs +++ b/client/src/wallets.rs @@ -313,6 +313,7 @@ pub enum WalletCommand { resp: crate::rpc::Responder>, }, ListPtrs { + external: bool, resp: crate::rpc::Responder>, }, Buy { @@ -671,8 +672,12 @@ impl RpcWallet { let result = Self::list_spaces(wallet, chain); _ = resp.send(result); } - WalletCommand::ListPtrs { resp } => { - let result = Self::list_nums(wallet, chain); + WalletCommand::ListPtrs { external, resp } => { + let result = if external { + Self::list_external_nums(wallet, chain) + } else { + Self::list_nums(wallet, chain) + }; _ = resp.send(result); } WalletCommand::ListBidouts { resp } => { @@ -1069,23 +1074,57 @@ impl RpcWallet { fn list_nums(wallet: &mut SpacesWallet, chain: &mut Chain) -> anyhow::Result { let mut nums: Vec = Vec::new(); for unspent in wallet.list_unspent() { - let snum = NumId::from_spk::(unspent.txout.script_pubkey); - let Some(fpo) = chain.get_num_info(&snum)? else { + let Some(numout) = chain.get_numout(&unspent.outpoint)? else { continue; }; - if fpo.outpoint() != unspent.outpoint { + let rsk = DelegatorKey::from_id::(numout.num.id); + let delegating_for = chain.get_delegator(&rsk)?; + nums.push(NumEntry { + txid: unspent.outpoint.txid, + numout, + delegating_for, + }) + } + + Ok(ListNumsResponse { nums }) + } + + fn list_external_nums(wallet: &mut SpacesWallet, chain: &mut Chain) -> anyhow::Result { + use spaces_wallet::tx_event::CreateNumEventDetails; + + let mut nums: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for event in wallet.list_create_num_events()? { + let Some(details) = event.details else { continue }; + let Ok(create) = serde_json::from_value::(details) else { + continue; + }; + + let num_id = NumId::from_spk::(create.genesis_spk.clone()); + if !seen.insert(num_id) { + continue; + } + + let Some(fpo) = chain.get_num_info(&num_id)? else { + continue; + }; + + // Only include if the wallet doesn't own the current output + if wallet.is_mine(fpo.numout.script_pubkey.clone()) { continue; } - let rsk = DelegatorKey::from_id::(snum); + + let rsk = DelegatorKey::from_id::(num_id); let delegating_for = chain.get_delegator(&rsk)?; nums.push(NumEntry { txid: fpo.txid, numout: fpo.numout, delegating_for, - }) + }); } - Ok(ListNumsResponse { nums: nums }) + Ok(ListNumsResponse { nums }) } fn list_spaces( @@ -1341,6 +1380,19 @@ impl RpcWallet { }); } RpcWalletRequest::Transfer(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, + }; let recipient = if let Some(to) = params.to { match Self::resolve(network, chain, &to, true)? { None => return Err(anyhow!("transfer: could not resolve '{}'", to)), @@ -1355,11 +1407,11 @@ impl RpcWallet { match item { Subject::NumId(id) => { let num = match chain.get_num_info(id)? { - None => return Err(anyhow!("transfer: num '{}' not found or not owned", id)), - Some(full) if !wallet.is_mine(full.numout.script_pubkey.clone()) => { + None => return Err(anyhow!("transfer: num '{}' not found", id)), + Some(full) if secret.is_none() && !wallet.is_mine(full.numout.script_pubkey.clone()) => { return Err(anyhow!("transfer: you don't own num '{}'", id)) } - Some(full) if wallet.get_utxo(OutPoint::new(full.txid, full.numout.n as u32)).is_none() => { + Some(full) if secret.is_none() && wallet.get_utxo(OutPoint::new(full.txid, full.numout.n as u32)).is_none() => { return Err(anyhow!( "transfer '{}': wallet already has a pending tx for this num", id @@ -1377,6 +1429,7 @@ impl RpcWallet { num, recipient: recipient_addr, is_delegate: false, + secret, }); } Subject::Label(label) if label.is_numeric() => { @@ -1385,11 +1438,11 @@ impl RpcWallet { anyhow!("transfer: numeric '{}' not found", numeric) })?; let num = match chain.get_num_info(&id)? { - None => return Err(anyhow!("transfer: num '{}' not found or not owned", id)), - Some(full) if !wallet.is_mine(full.numout.script_pubkey.clone()) => { + None => return Err(anyhow!("transfer: num '{}' not found", id)), + Some(full) if secret.is_none() && !wallet.is_mine(full.numout.script_pubkey.clone()) => { return Err(anyhow!("transfer: you don't own num '{}'", id)) } - Some(full) if wallet.get_utxo(OutPoint::new(full.txid, full.numout.n as u32)).is_none() => { + Some(full) if secret.is_none() && wallet.get_utxo(OutPoint::new(full.txid, full.numout.n as u32)).is_none() => { return Err(anyhow!( "transfer '{}': wallet already has a pending tx for this num", id @@ -1407,6 +1460,7 @@ impl RpcWallet { num, recipient: recipient_addr, is_delegate: false, + secret, }); } Subject::Label(space) => { @@ -1696,6 +1750,7 @@ impl RpcWallet { num: delegate_utxo, recipient: SpaceAddress::from(r), is_delegate: true, + secret: None, }) } RpcWalletRequest::SetFallback(params) => match params.subject { @@ -1753,6 +1808,7 @@ impl RpcWallet { num: num_info, recipient, is_delegate: false, + secret: None, }) .add_data(params.data); } @@ -2046,9 +2102,9 @@ impl RpcWallet { resp_rx.await? } - pub async fn send_list_nums(&self) -> anyhow::Result { + pub async fn send_list_nums(&self, external: bool) -> anyhow::Result { let (resp, resp_rx) = oneshot::channel(); - self.sender.send(WalletCommand::ListPtrs { resp }).await?; + self.sender.send(WalletCommand::ListPtrs { external, resp }).await?; resp_rx.await? } diff --git a/client/tests/integration_tests.rs b/client/tests/integration_tests.rs index 48f8fae..7cfe71d 100644 --- a/client/tests/integration_tests.rs +++ b/client/tests/integration_tests.rs @@ -390,7 +390,7 @@ async fn it_should_allow_batch_transfers_refreshing_expire_height( let result = wallet_do( rig, ALICE, - vec![RpcWalletRequest::Transfer(TransferSpacesParams { + vec![RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: registered_spaces.clone(), to: Some(space_address), data: None, @@ -457,7 +457,7 @@ async fn it_should_allow_applying_script_in_batch(rig: &TestRig) -> anyhow::Resu ALICE, vec![ // Transfer spaces to self with data (replaces Execute) - RpcWalletRequest::Transfer(TransferSpacesParams { + RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: registered_spaces.clone(), to: None, // None = renew to self data: Some(vec![0xDE, 0xAD, 0xBE, 0xEF]), @@ -868,7 +868,7 @@ async fn it_should_not_allow_register_or_transfer_to_same_space_multiple_times( let response = wallet_do( rig, ALICE, - vec![RpcWalletRequest::Transfer(TransferSpacesParams { + vec![RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::Label(SLabel::from_str(&transfer).expect("valid"))], to: Some(bob_address.clone()), data: None, @@ -886,7 +886,7 @@ async fn it_should_not_allow_register_or_transfer_to_same_space_multiple_times( wallet_do( rig, ALICE, - vec![RpcWalletRequest::Transfer(TransferSpacesParams { + vec![RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::Label(SLabel::from_str(&transfer).expect("valid"))], to: Some(bob_address), data: None, @@ -900,7 +900,7 @@ async fn it_should_not_allow_register_or_transfer_to_same_space_multiple_times( let response = wallet_do( rig, ALICE, - vec![RpcWalletRequest::Transfer(TransferSpacesParams { + vec![RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::Label(SLabel::from_str(&setdata).expect("valid"))], to: None, data: Some(vec![0xAA, 0xAA]), @@ -917,7 +917,7 @@ async fn it_should_not_allow_register_or_transfer_to_same_space_multiple_times( wallet_do( rig, ALICE, - vec![RpcWalletRequest::Transfer(TransferSpacesParams { + vec![RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::Label(SLabel::from_str(&setdata).expect("valid"))], to: None, data: Some(vec![0xDE, 0xAD]), @@ -968,7 +968,7 @@ async fn it_can_batch_txs(rig: &TestRig) -> anyhow::Result<()> { rig, ALICE, vec![ - RpcWalletRequest::Transfer(TransferSpacesParams { + RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::Label(SLabel::from_str("@test9996").expect("valid"))], to: Some(bob_address), data: None, @@ -986,7 +986,7 @@ async fn it_can_batch_txs(rig: &TestRig) -> anyhow::Result<()> { amount: 1000, }), // Transfer spaces to self with data (replaces Execute) - RpcWalletRequest::Transfer(TransferSpacesParams { + RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![ Subject::Label(SLabel::from_str("@test10000").expect("valid")), Subject::Label(SLabel::from_str("@test9999").expect("valid")), @@ -1250,7 +1250,7 @@ async fn it_should_handle_expired_spaces(rig: &TestRig) -> anyhow::Result<()> { let renew_result = wallet_do( rig, ALICE, - vec![RpcWalletRequest::Transfer(TransferSpacesParams { + vec![RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::Label( SLabel::from_str(&space_name).expect("valid") )], @@ -1327,7 +1327,7 @@ async fn it_should_handle_expired_spaces(rig: &TestRig) -> anyhow::Result<()> { let force_renew_result = wallet_do( rig, ALICE, - vec![RpcWalletRequest::Transfer(TransferSpacesParams { + vec![RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::Label( SLabel::from_str(&space_name_2).expect("valid") )], diff --git a/client/tests/ptr_tests.rs b/client/tests/ptr_tests.rs index 7c444d9..6cda94f 100644 --- a/client/tests/ptr_tests.rs +++ b/client/tests/ptr_tests.rs @@ -115,7 +115,7 @@ async fn it_should_create_nums(rig: &TestRig) -> anyhow::Result<()> { let xfer = wallet_do( rig, ALICE, - vec![RpcWalletRequest::Transfer(TransferSpacesParams { + vec![RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::NumId(id0)], to: Some(addr1.clone()), data: None, @@ -561,7 +561,7 @@ async fn it_should_reject_duplicate_num_id_delegations(rig: &TestRig) -> anyhow: let transfer1 = wallet_do( rig, ALICE, - vec![RpcWalletRequest::Transfer(TransferSpacesParams { + vec![RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::Label(space1_name.clone())], to: Some(common_addr.clone()), data: None, @@ -584,7 +584,7 @@ async fn it_should_reject_duplicate_num_id_delegations(rig: &TestRig) -> anyhow: let transfer2 = wallet_do( rig, ALICE, - vec![RpcWalletRequest::Transfer(TransferSpacesParams { + vec![RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::Label(space2_name.clone())], to: Some(common_addr.clone()), data: None, @@ -613,7 +613,7 @@ async fn it_should_reject_duplicate_num_id_delegations(rig: &TestRig) -> anyhow: let transfer_away = wallet_do( rig, ALICE, - vec![RpcWalletRequest::Transfer(TransferSpacesParams { + vec![RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::Label(space1_name.clone())], to: Some(new_addr), data: None, @@ -634,7 +634,7 @@ async fn it_should_reject_duplicate_num_id_delegations(rig: &TestRig) -> anyhow: let transfer2_retry = wallet_do( rig, ALICE, - vec![RpcWalletRequest::Transfer(TransferSpacesParams { + vec![RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::Label(space2_name.clone())], to: Some(common_addr), data: None, @@ -685,7 +685,7 @@ async fn it_should_restore_delegation_when_transferring_back(rig: &TestRig) -> a let setup_transfer = wallet_do( rig, ALICE, - vec![RpcWalletRequest::Transfer(TransferSpacesParams { + vec![RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::Label(space_name.clone())], to: Some(original_addr.clone()), data: None, @@ -714,7 +714,7 @@ async fn it_should_restore_delegation_when_transferring_back(rig: &TestRig) -> a let transfer1 = wallet_do( rig, ALICE, - vec![RpcWalletRequest::Transfer(TransferSpacesParams { + vec![RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::Label(space_name.clone())], to: Some(new_addr.clone()), data: None, @@ -744,7 +744,7 @@ async fn it_should_restore_delegation_when_transferring_back(rig: &TestRig) -> a let transfer2 = wallet_do( rig, ALICE, - vec![RpcWalletRequest::Transfer(TransferSpacesParams { + vec![RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::Label(space_name.clone())], to: Some(original_addr.clone()), data: None, @@ -832,7 +832,7 @@ async fn it_should_set_and_persist_ptr_data(rig: &TestRig) -> anyhow::Result<()> let transfer = wallet_do( rig, ALICE, - vec![RpcWalletRequest::Transfer(TransferSpacesParams { + vec![RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::NumId(id)], to: Some(bob_addr.clone()), data: None, @@ -967,7 +967,7 @@ async fn it_should_set_and_get_space_fallback(rig: &TestRig) -> anyhow::Result<( let transfer = wallet_do( rig, ALICE, - vec![RpcWalletRequest::Transfer(TransferSpacesParams { + vec![RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::Label(space_name.clone())], to: Some(bob_addr), data: None, @@ -1062,10 +1062,126 @@ async fn run_ptr_tests() -> anyhow::Result<()> { println!("\n=== Running Multiple Nums Same Tx Tests ==="); it_should_create_multiple_nums_same_tx(&rig).await?; + println!("\n=== Running Foreign Num Transfer Tests ==="); + it_should_transfer_foreign_num_with_secret(&rig).await?; + println!("\n=== All tests passed! ==="); Ok(()) } +fn gen_p2tr_keypair() -> (bitcoin::ScriptBuf, [u8; 32]) { + use bitcoin::secp256k1::{Secp256k1, Keypair}; + use bitcoin::key::TapTweak; + use bitcoin::script::Builder; + use bitcoin::opcodes::all::OP_PUSHNUM_1; + + let secp = Secp256k1::new(); + let (secret_key, _) = secp.generate_keypair(&mut rand::thread_rng()); + let keypair = Keypair::from_secret_key(&secp, &secret_key); + let tweaked = keypair.tap_tweak(&secp, None); + let (xonly, _) = tweaked.to_keypair().x_only_public_key(); + + let spk = Builder::new() + .push_opcode(OP_PUSHNUM_1) + .push_slice(xonly.serialize()) + .into_script(); + + let tweaked_secret = tweaked.to_keypair().secret_key().secret_bytes(); + (spk, tweaked_secret) +} + +async fn it_should_transfer_foreign_num_with_secret(rig: &TestRig) -> anyhow::Result<()> { + sync_all(rig).await?; + + // Generate a keypair not owned by any wallet + let (spk, secret) = gen_p2tr_keypair(); + let num_id = NumId::from_spk::(spk.clone()); + println!("Test 1: Create num bound to external key (num_id={})", num_id); + + // Create a num bound to the external spk + wallet_do(rig, ALICE, vec![ + RpcWalletRequest::CreateNum(CreateNumParams { + bind_spk: Some(spk.clone()), + }), + ], false).await?; + mine_and_sync(rig, 1).await?; + + // Verify the num exists + let num_info = rig.spaced.client.get_num(Subject::NumId(num_id)).await? + .expect("num should exist"); + assert_eq!(num_info.numout.script_pubkey, spk, "num should be bound to external spk"); + println!(" Num created: {} (id={})", num_info.numout.num.name, num_id); + + // Before transfer: num should appear in external list, not owned list + rig.wait_until_wallet_synced(ALICE).await?; + let owned_before = rig.spaced.client.wallet_list_nums(ALICE, None).await?; + let external_before = rig.spaced.client.wallet_list_nums(ALICE, Some("external".to_string())).await?; + assert!(!owned_before.nums.iter().any(|n| n.numout.num.id == num_id), + "num should NOT be in owned list before transfer"); + assert!(external_before.nums.iter().any(|n| n.numout.num.id == num_id), + "num should be in external list before transfer"); + println!("✓ Num correctly listed as external before transfer"); + + // Test 1: Transfer the foreign num to ALICE's wallet using the secret + println!("\nTest 2: Transfer foreign num to wallet using secret key"); + let secret_hex = hex::encode(secret); + let result = wallet_do(rig, ALICE, vec![ + RpcWalletRequest::Transfer(TransferSpacesParams { + secret: Some(secret_hex.clone()), + spaces: vec![Subject::NumId(num_id)], + to: None, // transfer to self (wallet's own address) + data: None, + }), + ], false).await?; + assert!(wallet_res_err(&result).is_ok(), "transfer with secret should succeed"); + mine_and_sync(rig, 1).await?; + rig.wait_until_wallet_synced(ALICE).await?; + + // Verify ALICE's wallet now lists the num as owned, not external + let owned_after = rig.spaced.client.wallet_list_nums(ALICE, None).await?; + let external_after = rig.spaced.client.wallet_list_nums(ALICE, Some("external".to_string())).await?; + assert!(owned_after.nums.iter().any(|n| n.numout.num.id == num_id), + "num should be in owned list after transfer"); + assert!(!external_after.nums.iter().any(|n| n.numout.num.id == num_id), + "num should NOT be in external list after transfer"); + println!("✓ Foreign num transferred to wallet, correctly moved from external to owned"); + + // Test 2: Generate a second keypair, transfer using secret to that external address + println!("\nTest 3: Transfer num from wallet to a new external key using wallet ownership"); + let (spk2, _secret2) = gen_p2tr_keypair(); + let addr2 = SpaceAddress(bitcoin::Address::from_script(&spk2, bitcoin::Network::Regtest) + .expect("valid address")); + + let result2 = wallet_do(rig, ALICE, vec![ + RpcWalletRequest::Transfer(TransferSpacesParams { + secret: None, // ALICE owns it now, no secret needed + spaces: vec![Subject::NumId(num_id)], + to: Some(addr2.to_string()), + data: None, + }), + ], false).await?; + assert!(wallet_res_err(&result2).is_ok(), "transfer to external address should succeed"); + mine_and_sync(rig, 1).await?; + + // Verify the num is now at the new spk + let num_after = rig.spaced.client.get_num(Subject::NumId(num_id)).await? + .expect("num should still exist"); + assert_eq!(num_after.numout.script_pubkey, spk2, "num should be at new external spk"); + println!("✓ Num transferred to new external address"); + + // After transferring out: should be back in external list, not owned + rig.wait_until_wallet_synced(ALICE).await?; + let owned_final = rig.spaced.client.wallet_list_nums(ALICE, None).await?; + let external_final = rig.spaced.client.wallet_list_nums(ALICE, Some("external".to_string())).await?; + assert!(!owned_final.nums.iter().any(|n| n.numout.num.id == num_id), + "num should NOT be in owned list after transferring out"); + assert!(external_final.nums.iter().any(|n| n.numout.num.id == num_id), + "num should be back in external list after transferring out"); + println!("✓ Num correctly back in external list after transferring out"); + + Ok(()) +} + // ============== Test: PTR n→n Transfer Rule ============== async fn it_should_transfer_ptr_with_n_to_n_rule(rig: &TestRig) -> anyhow::Result<()> { @@ -1089,7 +1205,7 @@ async fn it_should_transfer_ptr_with_n_to_n_rule(rig: &TestRig) -> anyhow::Resul // Transfer to addr1 with SAME value (should use n→n rule) let addr1 = rig.spaced.client.wallet_get_new_address(BOB, AddressKind::Space).await?; wallet_do(rig, ALICE, vec![ - RpcWalletRequest::Transfer(TransferSpacesParams { + RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::NumId(id)], to: Some(addr1.clone()), data: None, @@ -1128,12 +1244,12 @@ async fn it_should_transfer_ptr_with_n_to_n_rule(rig: &TestRig) -> anyhow::Resul let dest_b = rig.spaced.client.wallet_get_new_address(BOB, AddressKind::Space).await?; wallet_do(rig, ALICE, vec![ - RpcWalletRequest::Transfer(TransferSpacesParams { + RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::NumId(id_a)], to: Some(dest_a.clone()), data: None, }), - RpcWalletRequest::Transfer(TransferSpacesParams { + RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::NumId(id_b)], to: Some(dest_b.clone()), data: None, @@ -1301,7 +1417,7 @@ async fn it_should_authorize_numeric_to_another_wallet(rig: &TestRig) -> anyhow: ], false).await?; mine_and_sync(rig, 1).await?; - let alice_nums = rig.spaced.client.wallet_list_nums(ALICE).await?; + let alice_nums = rig.spaced.client.wallet_list_nums(ALICE, None).await?; let num_entry = alice_nums.nums.last().expect("Alice should have a num"); let numeric_label = num_entry.numout.num.name.to_slabel(); let num_id = num_entry.numout.num.id; @@ -1336,7 +1452,7 @@ async fn it_should_authorize_numeric_to_another_wallet(rig: &TestRig) -> anyhow: let bob_addr = rig.spaced.client .wallet_get_new_address(BOB, AddressKind::Space).await?; let authorize_res = wallet_do(rig, ALICE, vec![ - RpcWalletRequest::Transfer(TransferSpacesParams { + RpcWalletRequest::Transfer(TransferSpacesParams { secret: None, spaces: vec![Subject::NumId(delegation_id)], to: Some(bob_addr), data: None, @@ -1447,7 +1563,7 @@ async fn it_should_create_multiple_nums_same_tx(rig: &TestRig) -> anyhow::Result sync_all(rig).await?; println!("Test 1: Create two nums in a single transaction (auto-generated addresses)"); - let before = rig.spaced.client.wallet_list_nums(ALICE).await?; + let before = rig.spaced.client.wallet_list_nums(ALICE, None).await?; let before_count = before.nums.len(); wallet_do(rig, ALICE, vec![ @@ -1456,7 +1572,7 @@ async fn it_should_create_multiple_nums_same_tx(rig: &TestRig) -> anyhow::Result ], false).await?; mine_and_sync(rig, 1).await?; - let after = rig.spaced.client.wallet_list_nums(ALICE).await?; + let after = rig.spaced.client.wallet_list_nums(ALICE, None).await?; assert_eq!(after.nums.len(), before_count + 2, "two new nums created"); let new_nums: Vec<_> = after.nums.iter() diff --git a/wallet/src/builder.rs b/wallet/src/builder.rs index 2c6aca0..2fbd17c 100644 --- a/wallet/src/builder.rs +++ b/wallet/src/builder.rs @@ -13,11 +13,7 @@ use bdk_wallet::{ tx_builder::TxOrdering, KeychainKind, TxBuilder, Utxo, WeightedUtxo, }; -use bitcoin::{ - absolute::LockTime, key::rand::RngCore, psbt::Input, script, script::PushBytesBuf, Address, - Amount, FeeRate, Network, OutPoint, Psbt, Script, ScriptBuf, Sequence, Transaction, TxOut, - Txid, Weight, Witness, -}; +use bitcoin::{absolute::LockTime, key::rand::RngCore, psbt::Input, script, script::PushBytesBuf, Address, Amount, FeeRate, Network, OutPoint, Psbt, Script, ScriptBuf, Sequence, Transaction, TxOut, Txid, Weight, Witness}; use spaces_nums::{create_commitment_script, CommitmentOp, FullNumOut}; use spaces_protocol::hasher::Hash; @@ -148,6 +144,8 @@ pub struct NumTransfer { pub recipient: SpaceAddress, /// Whether this transfer is a delegation (authorize) rather than a regular transfer pub is_delegate: bool, + /// Must be specified if num isn't owned by wallet + pub secret: Option<[u8;32]>, } #[derive(Debug, Clone)] @@ -656,7 +654,7 @@ impl Iterator for BuilderIterator<'_> { } StackOp::Num(params) => { let transfers: Vec<_> = params.transfers.iter().map(|t| { - (t.num.numout.num.name.to_string(), t.recipient.script_pubkey(), t.is_delegate) + (t.num.numout.num.name.to_string(), t.num.numout.num.id.to_string(), t.recipient.script_pubkey(), t.is_delegate) }).collect(); let binds: Vec<_> = params.binds.iter().map(|b| { b.bind_spk.clone() @@ -672,11 +670,11 @@ impl Iterator for BuilderIterator<'_> { ); Some(tx.map(|tx| { let mut detailed = TxRecord::new(tx); - for (name, spk, is_delegate) in transfers { + for (name, num_id, to_spk, is_delegate) in transfers { if is_delegate { - detailed.add_delegate(name, spk); + detailed.add_delegate(name, to_spk); } else { - detailed.add_transfer_num(name, spk); + detailed.add_transfer_num(name, num_id, to_spk); } } for spk in binds { @@ -1321,10 +1319,36 @@ fn create_num_tx( vout: transfer.num.numout.n as _, }; - // spend num - builder - .add_utxo(outpoint) - .map_err(|e| anyhow!("could not transfer num at {}:{}", outpoint, e))?; + if let Some(secret) = transfer.secret { + // spend foreign num + let mut spend_input = Input { + witness_utxo: Some(TxOut { + value: transfer.num.numout.value, + script_pubkey: transfer.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 { + // spend local num + builder + .add_utxo(outpoint) + .map_err(|e| anyhow!("could not transfer num at {}:{}", outpoint, e))?; + } + // add replacement output at the same index builder.add_recipient( transfer.recipient.script_pubkey(), diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index ee3ddd2..84e34eb 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -466,6 +466,11 @@ impl SpacesWallet { TxEvent::get_latest_events(&db_tx).context("could not read latest events") } + pub fn list_create_num_events(&mut self) -> anyhow::Result> { + let db_tx = self.connection.transaction().context("no db transaction")?; + TxEvent::get_create_num_events(&db_tx).context("could not read create num events") + } + pub fn sign_event( &mut self, src: &mut S, @@ -1361,6 +1366,7 @@ impl SpacesWallet { } let mut reveals: BTreeMap = BTreeMap::new(); + let mut custom_secrets: BTreeMap = BTreeMap::new(); for (idx, input) in psbt.inputs.iter_mut().enumerate() { let reveal_key = Self::spaces_signer("reveal_signing_info"); @@ -1370,10 +1376,16 @@ impl SpacesWallet { .context("expected reveal signing info")?; reveals.insert(idx as u32, signing_info); } + let secret_key = Self::spaces_signer("sign_with_custom_secret"); + if let Some(raw) = input.proprietary.get(&secret_key) { + let mut secret = [0u8; 32]; + secret.copy_from_slice(raw.as_slice()); + custom_secrets.insert(idx as u32, secret); + } } let mut tx = psbt.extract_tx()?; - if reveals.len() == 0 { + if reveals.is_empty() && custom_secrets.is_empty() { return Ok(tx); } @@ -1426,6 +1438,33 @@ impl SpacesWallet { witness.push(&signing_info.control_block.serialize()); } + // Sign inputs with externally-provided secret keys (taproot key-spend) + for (input_idx, secret) in custom_secrets { + let ctx = secp256k1::Secp256k1::new(); + let keypair = secp256k1::Keypair::from_seckey_slice(&ctx, &secret) + .context("invalid secret key")?; + + let sighash = sighash_cache.taproot_key_spend_signature_hash( + input_idx as usize, + &prevouts, + TapSighashType::Default, + )?; + + let msg = secp256k1::Message::from_digest_slice(sighash.as_ref())?; + let signature = ctx.sign_schnorr(&msg, &keypair); + + let witness = sighash_cache + .witness_mut(input_idx as usize) + .expect("witness should exist"); + witness.push( + taproot::Signature { + signature, + sighash_type: TapSighashType::Default, + } + .to_vec(), + ); + } + Ok(tx) } diff --git a/wallet/src/tx_event.rs b/wallet/src/tx_event.rs index bc17261..93985d4 100644 --- a/wallet/src/tx_event.rs +++ b/wallet/src/tx_event.rs @@ -79,12 +79,13 @@ pub struct ExecuteEventDetails { #[derive(Debug, Serialize, Deserialize)] pub struct CreateNumEventDetails { - pub script_pubkey: ScriptBuf, + pub genesis_spk: ScriptBuf, } #[derive(Debug, Serialize, Deserialize)] pub struct TransferNumEventDetails { - pub script_pubkey: ScriptBuf, + pub num_id: String, + pub to: ScriptBuf, } #[derive(Debug, Serialize, Deserialize)] @@ -231,6 +232,21 @@ impl TxEvent { Ok(None) } + /// Retrieve all CreateNum events + pub fn get_create_num_events( + db_tx: &rusqlite::Transaction, + ) -> rusqlite::Result> { + let query = format!( + "SELECT type, space, previous_spaceout, details + FROM {table} + WHERE type = 'create-num' + ORDER BY id DESC", + table = Self::TX_EVENTS_TABLE_NAME, + ); + let stmt = db_tx.prepare(&query)?; + Self::from_sqlite_statement(stmt, []) + } + /// Retrieve all spaces the wallet has done any operation with pub fn get_latest_events( db_tx: &rusqlite::Transaction, @@ -456,25 +472,25 @@ impl TxRecord { }); } - pub fn add_create_num(&mut self, to: ScriptBuf) { + pub fn add_create_num(&mut self, genesis_spk: ScriptBuf) { self.events.push(TxEvent { kind: TxEventKind::CreateNum, space: None, previous_spaceout: None, details: Some( - serde_json::to_value(CreateNumEventDetails { script_pubkey: to }) + serde_json::to_value(CreateNumEventDetails { genesis_spk }) .expect("json value"), ), }); } - pub fn add_transfer_num(&mut self, num: String, to: ScriptBuf) { + pub fn add_transfer_num(&mut self, num: String, num_id: String, to: ScriptBuf) { self.events.push(TxEvent { kind: TxEventKind::TransferNum, space: Some(num), previous_spaceout: None, details: Some( - serde_json::to_value(TransferNumEventDetails { script_pubkey: to }) + serde_json::to_value(TransferNumEventDetails { num_id, to }) .expect("json value"), ), });