Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 49 additions & 5 deletions client/src/bin/space-cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<u64>,
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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::<Sha256>(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) => {
Expand Down
9 changes: 8 additions & 1 deletion client/src/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ pub trait Rpc {
async fn wallet_list_nums(
&self,
wallet: &str,
kind: Option<String>,
) -> Result<ListNumsResponse, ErrorObjectOwned>;

#[method(name = "walletlistunspent")]
Expand Down Expand Up @@ -513,6 +514,10 @@ pub struct TransferSpacesParams {

#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Vec<u8>>,

/// Hex-encoded 32-byte secret key for transferring nums not owned by the wallet
#[serde(skip_serializing_if = "Option::is_none")]
pub secret: Option<String>,
}

#[derive(Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -1361,10 +1366,12 @@ impl RpcServer for RpcServerImpl {
async fn wallet_list_nums(
&self,
wallet: &str,
kind: Option<String>,
) -> Result<ListNumsResponse, ErrorObjectOwned> {
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::<String>))
}
Expand Down
88 changes: 72 additions & 16 deletions client/src/wallets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ pub enum WalletCommand {
resp: crate::rpc::Responder<anyhow::Result<ListSpacesResponse>>,
},
ListPtrs {
external: bool,
resp: crate::rpc::Responder<anyhow::Result<ListNumsResponse>>,
},
Buy {
Expand Down Expand Up @@ -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 } => {
Expand Down Expand Up @@ -1069,23 +1074,57 @@ impl RpcWallet {
fn list_nums(wallet: &mut SpacesWallet, chain: &mut Chain) -> anyhow::Result<ListNumsResponse> {
let mut nums: Vec<NumEntry> = Vec::new();
for unspent in wallet.list_unspent() {
let snum = NumId::from_spk::<Sha256>(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::<Sha256>(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<ListNumsResponse> {
use spaces_wallet::tx_event::CreateNumEventDetails;

let mut nums: Vec<NumEntry> = 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::<CreateNumEventDetails>(details) else {
continue;
};

let num_id = NumId::from_spk::<Sha256>(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::<Sha256>(snum);

let rsk = DelegatorKey::from_id::<Sha256>(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(
Expand Down Expand Up @@ -1341,6 +1380,19 @@ impl RpcWallet {
});
}
RpcWalletRequest::Transfer(params) => {
let secret: Option<[u8; 32]> = match &params.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)),
Expand All @@ -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
Expand All @@ -1377,6 +1429,7 @@ impl RpcWallet {
num,
recipient: recipient_addr,
is_delegate: false,
secret,
});
}
Subject::Label(label) if label.is_numeric() => {
Expand All @@ -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
Expand All @@ -1407,6 +1460,7 @@ impl RpcWallet {
num,
recipient: recipient_addr,
is_delegate: false,
secret,
});
}
Subject::Label(space) => {
Expand Down Expand Up @@ -1696,6 +1750,7 @@ impl RpcWallet {
num: delegate_utxo,
recipient: SpaceAddress::from(r),
is_delegate: true,
secret: None,
})
}
RpcWalletRequest::SetFallback(params) => match params.subject {
Expand Down Expand Up @@ -1753,6 +1808,7 @@ impl RpcWallet {
num: num_info,
recipient,
is_delegate: false,
secret: None,
})
.add_data(params.data);
}
Expand Down Expand Up @@ -2046,9 +2102,9 @@ impl RpcWallet {
resp_rx.await?
}

pub async fn send_list_nums(&self) -> anyhow::Result<ListNumsResponse> {
pub async fn send_list_nums(&self, external: bool) -> anyhow::Result<ListNumsResponse> {
let (resp, resp_rx) = oneshot::channel();
self.sender.send(WalletCommand::ListPtrs { resp }).await?;
self.sender.send(WalletCommand::ListPtrs { external, resp }).await?;
resp_rx.await?
}

Expand Down
Loading
Loading