diff --git a/node/src/bin/space-cli.rs b/node/src/bin/space-cli.rs index 2684319..8e42835 100644 --- a/node/src/bin/space-cli.rs +++ b/node/src/bin/space-cli.rs @@ -22,7 +22,9 @@ use spaced::{ store::Sha256, wallets::AddressKind, }; +use wallet::bitcoin::secp256k1::schnorr::Signature; use wallet::export::WalletExport; +use wallet::Listing; #[derive(Parser, Debug)] #[command(version, about, long_about = None)] @@ -174,6 +176,46 @@ enum Commands { #[arg(long, short)] fee_rate: u64, }, + /// Buy a space from the specified listing + #[command(name = "buy")] + Buy { + /// The space to buy + space: String, + /// The listing price + price: u64, + /// The seller's signature + #[arg(long)] + signature: String, + /// The seller's address + #[arg(long)] + seller: String, + /// Fee rate to use in sat/vB + #[arg(long, short)] + fee_rate: Option, + }, + /// List a space you own for sale + #[command(name = "sell")] + Sell { + /// The space to sell + space: String, + /// Amount in satoshis + price: u64, + }, + /// Verify a listing + #[command(name = "verifylisting")] + VerifyListing { + /// The space to buy + space: String, + /// The listing price + price: u64, + /// The seller's signature + #[arg(long)] + signature: String, + /// The seller's address + #[arg(long)] + seller: String, + }, + /// Get a spaceout - a Bitcoin output relevant to the Spaces protocol. #[command(name = "getspaceout")] GetSpaceOut { @@ -452,7 +494,7 @@ async fn handle_commands( fee_rate, false, ) - .await? + .await? } Commands::Bid { space, @@ -469,7 +511,7 @@ async fn handle_commands( fee_rate, confirmed_only, ) - .await? + .await? } Commands::CreateBidOuts { pairs, fee_rate } => { cli.send_request(None, Some(pairs), fee_rate, false).await? @@ -488,7 +530,7 @@ async fn handle_commands( fee_rate, false, ) - .await? + .await? } Commands::Transfer { spaces, @@ -505,7 +547,7 @@ async fn handle_commands( fee_rate, false, ) - .await? + .await? } Commands::SendCoins { amount, @@ -521,7 +563,7 @@ async fn handle_commands( fee_rate, false, ) - .await? + .await? } Commands::SetRawFallback { mut space, @@ -550,7 +592,7 @@ async fn handle_commands( fee_rate, false, ) - .await?; + .await?; } Commands::ListUnspent => { let spaces = cli.client.wallet_list_unspent(&cli.wallet).await?; @@ -614,6 +656,50 @@ async fn handle_commands( hash_space(&space).map_err(|e| ClientError::Custom(e.to_string()))? ); } + Commands::Buy { space, price, signature, seller, fee_rate } => { + let listing = Listing { + space: normalize_space(&space), + price, + seller, + signature: Signature::from_slice(hex::decode(signature) + .map_err(|_| ClientError::Custom("Signature must be in hex format".to_string()))?.as_slice()) + .map_err(|_| ClientError::Custom("Invalid signature".to_string()))?, + }; + let result = cli + .client + .wallet_buy( + &cli.wallet, + listing, + fee_rate.map(|rate| FeeRate::from_sat_per_vb(rate).expect("valid fee rate")), + cli.skip_tx_check, + ).await?; + println!("{}", serde_json::to_string_pretty(&result).expect("result")); + } + Commands::Sell { space, price, } => { + let result = cli + .client + .wallet_sell( + &cli.wallet, + space, + price, + ).await?; + println!("{}", serde_json::to_string_pretty(&result).expect("result")); + } + Commands::VerifyListing { space, price, signature, seller } => { + let listing = Listing { + space: normalize_space(&space), + price, + seller, + signature: Signature::from_slice(hex::decode(signature) + .map_err(|_| ClientError::Custom("Signature must be in hex format".to_string()))?.as_slice()) + .map_err(|_| ClientError::Custom("Invalid signature".to_string()))?, + }; + + let result = cli + .client + .verify_listing(listing).await?; + println!("{}", serde_json::to_string_pretty(&result).expect("result")); + } } Ok(()) diff --git a/node/src/rpc.rs b/node/src/rpc.rs index 449be50..e0a1fd3 100644 --- a/node/src/rpc.rs +++ b/node/src/rpc.rs @@ -35,7 +35,7 @@ use tokio::{ sync::{broadcast, mpsc, oneshot, RwLock}, task::JoinSet, }; -use wallet::{bdk_wallet as bdk, bdk_wallet::template::Bip86, bitcoin::hashes::Hash, export::WalletExport, Balance, DoubleUtxo, WalletConfig, WalletDescriptors, WalletInfo, WalletOutput}; +use wallet::{bdk_wallet as bdk, bdk_wallet::template::Bip86, bitcoin::hashes::Hash, export::WalletExport, Balance, DoubleUtxo, Listing, SpacesWallet, WalletConfig, WalletDescriptors, WalletInfo, WalletOutput}; use crate::{ checker::TxChecker, @@ -95,6 +95,10 @@ pub enum ChainStateCommand { target: usize, resp: Responder>>, }, + VerifyListing { + listing: Listing, + resp: Responder>, + }, } #[derive(Clone)] @@ -181,6 +185,29 @@ pub trait Rpc { skip_tx_check: bool, ) -> Result, ErrorObjectOwned>; + #[method(name = "walletbuy")] + async fn wallet_buy( + &self, + wallet: &str, + listing: Listing, + fee_rate: Option, + skip_tx_check: bool, + ) -> Result; + + #[method(name = "walletsell")] + async fn wallet_sell( + &self, + wallet: &str, + space: String, + amount: u64, + ) -> Result; + + #[method(name = "verifylisting")] + async fn verify_listing( + &self, + listing: Listing, + ) -> Result<(), ErrorObjectOwned>; + #[method(name = "walletlisttransactions")] async fn wallet_list_transactions( &self, @@ -199,7 +226,7 @@ pub trait Rpc { #[method(name = "walletlistspaces")] async fn wallet_list_spaces(&self, wallet: &str) - -> Result; + -> Result; #[method(name = "walletlistunspent")] async fn wallet_list_unspent( @@ -754,6 +781,29 @@ impl RpcServer for RpcServerImpl { .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } + async fn wallet_buy(&self, wallet: &str, listing: Listing, fee_rate: Option, skip_tx_check: bool) -> Result { + self.wallet(&wallet) + .await? + .send_buy(listing, fee_rate, skip_tx_check) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + + async fn wallet_sell(&self, wallet: &str, space: String, amount: u64) -> Result { + self.wallet(&wallet) + .await? + .send_sell(space, amount) + .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) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + async fn wallet_list_transactions( &self, wallet: &str, @@ -953,6 +1003,9 @@ impl AsyncChainState { let rollouts = chain_state.get_rollout(target); _ = resp.send(rollouts); } + ChainStateCommand::VerifyListing { listing, resp } => { + _ = resp.send(SpacesWallet::verify_listing::(chain_state, &listing).map(|_| ())); + } } } @@ -986,6 +1039,14 @@ impl AsyncChainState { resp_rx.await? } + pub async fn verify_listing(&self, listing: Listing) -> anyhow::Result<()> { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::VerifyListing { listing, resp }) + .await?; + resp_rx.await? + } + pub async fn get_rollout(&self, target: usize) -> anyhow::Result> { let (resp, resp_rx) = oneshot::channel(); self.sender diff --git a/node/src/wallets.rs b/node/src/wallets.rs index a7791ca..34f6978 100644 --- a/node/src/wallets.rs +++ b/node/src/wallets.rs @@ -18,7 +18,7 @@ use tokio::time::Instant; use wallet::{address::SpaceAddress, bdk_wallet::{ chain::{local_chain::CheckPoint, BlockId}, KeychainKind, -}, bitcoin, bitcoin::{Address, Amount, FeeRate, OutPoint}, builder::{CoinTransfer, SpaceTransfer, SpacesAwareCoinSelection}, tx_event::{TxRecord, TxEvent, TxEventKind}, Balance, DoubleUtxo, SpacesWallet, WalletInfo, WalletOutput}; +}, bitcoin, bitcoin::{Address, Amount, FeeRate, OutPoint}, builder::{CoinTransfer, SpaceTransfer, SpacesAwareCoinSelection}, tx_event::{TxRecord, TxEvent, TxEventKind}, Balance, DoubleUtxo, Listing, SpacesWallet, WalletInfo, WalletOutput}; use crate::{checker::TxChecker, config::ExtendedNetwork, node::BlockSource, rpc::{RpcWalletRequest, RpcWalletTxBuilder, WalletLoadRequest}, source::{ BitcoinBlockSource, BitcoinRpc, BitcoinRpcError, BlockEvent, BlockFetchError, BlockFetcher, }, std_wait, store::{ChainState, LiveSnapshot, Sha256}}; @@ -85,6 +85,17 @@ pub enum WalletCommand { ListSpaces { resp: crate::rpc::Responder>, }, + Buy { + listing: Listing, + skip_tx_check: bool, + fee_rate: Option, + resp: crate::rpc::Responder>, + }, + Sell { + space: String, + price: u64, + resp: crate::rpc::Responder>, + }, ListBidouts { resp: crate::rpc::Responder>>, }, @@ -145,6 +156,60 @@ impl RpcWallet { None } + fn handle_buy( + source: &BitcoinBlockSource, + state: &mut LiveSnapshot, + wallet: &mut SpacesWallet, + listing: Listing, + skip_tx_check: bool, + 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.clone(), + }; + info!("Using fee rate: {} sat/vB", fee_rate.to_sat_per_vb_ceil()); + + let (_, fullspaceout) = SpacesWallet::verify_listing::(state, &listing)?; + + let space = fullspaceout.spaceout.space.as_ref().expect("space").name.to_string(); + let foreign_input = fullspaceout.outpoint(); + let tx = wallet.buy::(state, &listing, fee_rate)?; + + if !skip_tx_check { + let tip = wallet.local_chain().tip().height(); + let mut checker = TxChecker::new(state); + 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_with_events(tx, vec![TxEvent { + kind: TxEventKind::Buy, + space: Some(space), + foreign_input: Some(foreign_input), + details: None, + }]); + + let events = tx_record.events.clone(); + + // Incrementing last_seen by 1 ensures eviction of older tx + // in cases with same-second/last seen replacement. + wallet.apply_unconfirmed_tx_record(tx_record, last_seen+1)?; + wallet.commit()?; + + Ok(TxResponse { + txid: new_txid, + events, + error: None, + raw: None, + }) + } + fn handle_fee_bump( source: &BitcoinBlockSource, state: &mut LiveSnapshot, @@ -304,6 +369,12 @@ impl RpcWallet { WalletCommand::UnloadWallet => { info!("Unloading wallet '{}' ...", wallet.name()); } + WalletCommand::Buy { listing, resp, skip_tx_check, fee_rate } => { + _ = resp.send(Self::handle_buy(source, state, wallet, listing, skip_tx_check, fee_rate)); + } + WalletCommand::Sell { space, price, resp } => { + _ = resp.send(wallet.sell::(state, &space, Amount::from_sat(price))); + } } Ok(()) } @@ -1070,6 +1141,40 @@ impl RpcWallet { resp_rx.await? } + pub async fn send_buy( + &self, + listing: Listing, + fee_rate: Option, + skip_tx_check: bool, + ) -> anyhow::Result { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(WalletCommand::Buy { + listing, + fee_rate, + skip_tx_check, + resp, + }) + .await?; + resp_rx.await? + } + + pub async fn send_sell( + &self, + space: String, + price: u64, + ) -> anyhow::Result { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(WalletCommand::Sell { + space, + resp, + price, + }) + .await?; + resp_rx.await? + } + pub async fn send_list_transactions( &self, count: usize, @@ -1082,6 +1187,8 @@ impl RpcWallet { resp_rx.await? } + + pub async fn send_force_spend( &self, outpoint: OutPoint, diff --git a/node/tests/integration_tests.rs b/node/tests/integration_tests.rs index 3d12343..6063e40 100644 --- a/node/tests/integration_tests.rs +++ b/node/tests/integration_tests.rs @@ -983,6 +983,47 @@ async fn it_can_use_reserved_op_codes(rig: &TestRig) -> anyhow::Result<()> { Ok(()) } +async fn it_should_allow_buy_sell(rig: &TestRig) -> anyhow::Result<()> { + rig.wait_until_wallet_synced(ALICE).await.expect("synced"); + rig.wait_until_wallet_synced(BOB).await.expect("synced"); + + let alice_spaces = rig.spaced.client.wallet_list_spaces(ALICE).await.expect("alice spaces"); + let space = alice_spaces.owned.first().expect("alice should have at least 1 space"); + + let space_name = space.spaceout.space.as_ref().unwrap().name.to_string(); + let listing = rig.spaced.client.wallet_sell(ALICE, space_name.clone(), 5000).await.expect("sell"); + + println!("listing\n{}", serde_json::to_string_pretty(&listing).unwrap()); + + rig.spaced.client.verify_listing(listing.clone()).await.expect("verify"); + + let alice_balance = rig.spaced.client.wallet_get_balance(ALICE).await.expect("balance"); + let buy = rig.spaced.client.wallet_buy( + BOB, + listing.clone(), + Some(FeeRate::from_sat_per_vb(1).expect("rate")), + false).await.expect("buy" + ); + + println!("{}", serde_json::to_string_pretty(&buy).unwrap()); + + rig.mine_blocks(1, None).await.expect("mine"); + rig.wait_until_synced().await.expect("synced"); + rig.wait_until_wallet_synced(BOB).await.expect("synced"); + rig.wait_until_wallet_synced(ALICE).await.expect("synced"); + + rig.spaced.client.verify_listing(listing) + .await.expect_err("should no longer be valid"); + + let bob_spaces = rig.spaced.client.wallet_list_spaces(BOB).await.expect("bob spaces"); + + assert!(bob_spaces.owned.iter().find(|s| s.spaceout.space.as_ref().unwrap().name.to_string() == space_name).is_some(), "bob should own it now"); + + let alice_balance_after = rig.spaced.client.wallet_get_balance(ALICE).await.expect("balance"); + assert_eq!(alice_balance.balance + Amount::from_sat(5666), alice_balance_after.balance); + + Ok(()) +} async fn it_should_handle_reorgs(rig: &TestRig) -> anyhow::Result<()> { rig.wait_until_wallet_synced(ALICE).await.expect("synced"); @@ -1025,6 +1066,7 @@ async fn run_auction_tests() -> anyhow::Result<()> { .expect("should not allow register/transfer multiple times"); it_can_batch_txs(&rig).await.expect("bump fee"); it_can_use_reserved_op_codes(&rig).await.expect("should use reserved opcodes"); + it_should_allow_buy_sell(&rig).await.expect("should use reserved opcodes"); // keep reorgs last as it can drop some txs from mempool and mess up wallet state it_should_handle_reorgs(&rig).await.expect("should make wallet"); diff --git a/wallet/src/builder.rs b/wallet/src/builder.rs index 1a6d18e..1ced069 100644 --- a/wallet/src/builder.rs +++ b/wallet/src/builder.rs @@ -176,7 +176,7 @@ trait TxBuilderSpacesUtils<'a, Cs: CoinSelectionAlgorithm> { fn add_send(&mut self, request: CoinTransfer) -> anyhow::Result<&mut Self>; } -fn tap_key_spend_weight() -> Weight { +pub fn tap_key_spend_weight() -> Weight { let tap_key_spend_weight: u64 = 66; Weight::from_vb(tap_key_spend_weight).expect("valid weight") } diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index c41063a..86ded3f 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -4,27 +4,36 @@ use std::{ fs, path::PathBuf, }; +use std::ops::Mul; +use std::str::FromStr; use anyhow::{anyhow, Context}; use bdk_wallet::{chain, chain::BlockId, coin_selection::{CoinSelectionAlgorithm, CoinSelectionResult, Excess, InsufficientFunds}, rusqlite::Connection, tx_builder::TxOrdering, AddressInfo, KeychainKind, LocalOutput, PersistedWallet, SignOptions, TxBuilder, Update, Wallet, WalletTx, WeightedUtxo}; use bdk_wallet::chain::{ChainPosition, Indexer}; use bdk_wallet::chain::local_chain::{CannotConnectError, LocalChain}; use bdk_wallet::chain::tx_graph::CalculateFeeError; use bincode::config; -use bitcoin::{absolute::{Height, LockTime}, key::rand::RngCore, psbt::raw::ProprietaryKey, script, sighash::{Prevouts, SighashCache}, taproot, taproot::LeafVersion, Amount, Block, BlockHash, FeeRate, Network, OutPoint, Psbt, Sequence, TapLeafHash, TapSighashType, Transaction, TxOut, Txid, Weight, Witness}; +use bitcoin::{absolute::{Height, LockTime}, key::rand::RngCore, psbt, psbt::raw::ProprietaryKey, script, sighash::{Prevouts, SighashCache}, taproot, taproot::LeafVersion, Amount, Block, BlockHash, FeeRate, Network, OutPoint, Psbt, Sequence, TapLeafHash, TapSighashType, Transaction, TxIn, TxOut, Txid, Weight, Witness}; +use bitcoin::transaction::Version; +use secp256k1::{schnorr, Message}; +use secp256k1::schnorr::Signature; use protocol::{bitcoin::{ constants::genesis_block, key::{rand, UntweakedKeypair}, opcodes, taproot::{ControlBlock, TaprootBuilder}, Address, ScriptBuf, XOnlyPublicKey, -}, prepare::{is_magic_lock_time, TrackableOutput}, Space}; +}, prepare::{is_magic_lock_time, TrackableOutput}, FullSpaceOut, Space}; use serde::{ser::SerializeSeq, Deserialize, Deserializer, Serialize, Serializer}; +use protocol::constants::{BID_PSBT_INPUT_SEQUENCE, BID_PSBT_TX_LOCK_TIME}; +use protocol::hasher::{KeyHasher, SpaceKey}; use protocol::prepare::DataSource; +use protocol::slabel::SLabel; use crate::{ address::SpaceAddress, builder::{is_connector_dust, is_space_dust, SpacesAwareCoinSelection}, tx_event::TxEvent, }; +use crate::builder::{space_dust, tap_key_spend_weight}; use crate::tx_event::{TxEventKind, TxRecord}; pub extern crate bdk_wallet; @@ -49,6 +58,14 @@ pub struct Balance { pub details: BalanceDetails, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Listing { + pub space: String, + pub price: u64, + pub seller: String, + pub signature: schnorr::Signature, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BalanceDetails { #[serde(flatten)] @@ -154,7 +171,7 @@ impl SpacesWallet { .descriptor(KeychainKind::Internal, Some(config.space_descriptors.internal.clone())) .lookahead(50) .extract_keys() - .load_wallet(&mut conn).context("could not load wallet")? { + .load_wallet(&mut conn).context("could not load wallet")? { wallet } else { Wallet::create( @@ -280,7 +297,7 @@ impl SpacesWallet { confirmed_only: bool, ) -> anyhow::Result> { let selection = SpacesAwareCoinSelection::new( - unspendables, confirmed_only + unspendables, confirmed_only, ); let mut builder = match replace { @@ -563,6 +580,189 @@ impl SpacesWallet { Ok(not_auctioned) } + pub fn buy(&mut self, src: &mut impl DataSource, listing: &Listing, fee_rate: FeeRate) -> anyhow::Result { + let (seller,spaceout) = Self::verify_listing::(src, &listing)?; + + let mut witness = Witness::new(); + witness.push( + taproot::Signature { + signature: listing.signature, + sighash_type: TapSighashType::SinglePlusAnyoneCanPay, + } + .to_vec(), + ); + + 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 mut builder = self.build_tx(unspendables, false)?; + builder + .version(2) + .ordering(TxOrdering::Untouched) + .fee_rate(fee_rate) + .nlocktime(LockTime::Blocks(Height::ZERO)) + .set_exact_sequence(Sequence::ENABLE_RBF_NO_LOCKTIME) + .add_foreign_utxo_with_sequence( + spaceout.outpoint(), + psbt::Input { + witness_utxo: Some(TxOut { + value: spaceout.spaceout.value, + script_pubkey: spaceout.spaceout.script_pubkey.clone(), + }), + final_script_witness: Some(witness), + ..Default::default() + }, + tap_key_spend_weight(), + BID_PSBT_INPUT_SEQUENCE, + )? + .add_recipient( + seller.script_pubkey(), + spaceout.spaceout.value + Amount::from_sat(listing.price), + ) + .add_recipient(space_address.script_pubkey(), dust_amount); + builder.finish()? + }; + + let tx = self.sign(funded_psbt, None)?; + Ok(tx) + } + + pub fn verify_listing(src: &mut impl DataSource, 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)), + Some(outpoint) => outpoint + }; + + let spaceout = match src.get_spaceout(&outpoint)? { + None => return Err(anyhow!("Unknown or spent spaces utxo: {}", outpoint)), + Some(outpoint) => outpoint, + }; + + if spaceout.space.is_none() { + return Err(anyhow!("No associated space")); + } + + let recipient = Self::verify_listing_signature(&listing, outpoint, TxOut { + value: spaceout.value, + script_pubkey: spaceout.script_pubkey.clone(), + })?; + + Ok((recipient, FullSpaceOut { + txid: outpoint.txid, + spaceout, + })) + } + + fn verify_listing_signature(listing: &Listing, outpoint: OutPoint, txout: TxOut) -> anyhow::Result { + let prevouts = Prevouts::One(0, txout.clone()); + let addr = SpaceAddress::from_str(&listing.seller)?; + + let total = Amount::from_sat(listing.price) + txout.value; + let mut tx = bitcoin::blockdata::transaction::Transaction { + version: Version(2), + lock_time: BID_PSBT_TX_LOCK_TIME, + input: vec![TxIn { + previous_output: outpoint, + script_sig: ScriptBuf::new(), + sequence: BID_PSBT_INPUT_SEQUENCE, + witness: Witness::new(), + }], + output: vec![TxOut { + value: total, + script_pubkey: addr.script_pubkey(), + }], + }; + + let mut sighash_cache = SighashCache::new(&mut tx); + let sighash = sighash_cache.taproot_key_spend_signature_hash( + 0, + &prevouts, + TapSighashType::SinglePlusAnyoneCanPay, + )?; + + let msg = Message::from_digest_slice(sighash.as_ref())?; + let ctx = bitcoin::secp256k1::Secp256k1::verification_only(); + let script_bytes = txout.script_pubkey.as_bytes(); + + let pubkey = XOnlyPublicKey::from_slice(&script_bytes[2..])?; + + ctx.verify_schnorr(&listing.signature, &msg, &pubkey)?; + Ok(addr) + } + + pub fn sell( + &mut self, + src: &mut impl DataSource, + space: &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 utxo = match self.internal.get_utxo(space_outpoint) { + None => return Err(anyhow::anyhow!("Wallet does not own a space with outpoint {}", space_outpoint)), + Some(utxo) => utxo + }; + + let recipient = self.next_unused_space_address(); + + + let mut sell_psbt = { + let mut builder = self + .internal + .build_tx() + .coin_selection(RequiredUtxosOnlyCoinSelectionAlgorithm); + + let total = utxo.txout.value + asking_price; + 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)? + .add_recipient( + recipient.script_pubkey(), + total, + ); + builder.finish()? + }; + + let finalized = self.internal.sign( + &mut sell_psbt, + SignOptions { + allow_all_sighashes: true, + ..Default::default() + }, + )?; + if !finalized { + return Err(anyhow::anyhow!("signing listing psbt failed")); + } + + let witness = sell_psbt.inputs[0].clone().final_script_witness + .expect("signed listing psbt has a witness"); + + let signature = witness.iter().next() + .expect("signed listing must have a single witness item"); + + Ok(Listing { + space: space.to_string(), + price: asking_price.to_sat(), + seller: recipient.to_string(), + signature: Signature::from_slice(&signature[..64]) + .expect("signed listing has a valid signature"), + }) + } + pub fn new_bid_psbt( &mut self, total_burned: Amount, diff --git a/wallet/src/tx_event.rs b/wallet/src/tx_event.rs index 8b55629..9204163 100644 --- a/wallet/src/tx_event.rs +++ b/wallet/src/tx_event.rs @@ -85,6 +85,7 @@ pub enum TxEventKind { Transfer, Send, FeeBump, + Buy, } impl TxEvent { @@ -505,7 +506,8 @@ impl Display for TxEventKind { TxEventKind::Transfer => "transfer", TxEventKind::Send => "send", TxEventKind::Script => "script", - TxEventKind::FeeBump => "fee-bump" + TxEventKind::FeeBump => "fee-bump", + TxEventKind::Buy => "buy" }) } }