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
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["borsh_utils", "client", "protocol", "testutil", "wallet", "ptr"]
members = ["borsh_utils", "client", "protocol", "testutil", "wallet", "ptr", "sip7"]

[workspace.dependencies]
anyhow = "1.0"
Expand Down
1 change: 1 addition & 0 deletions client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ path = "src/lib.rs"
spaces_wallet = { path = "../wallet" }
spaces_protocol = { path = "../protocol", features = ["std"] }
spaces_ptr = { path = "../ptr", features = ["std"] }
sip7 = { path = "../sip7", features = ["serde"] }
spacedb = { workspace = true }
borsh_utils = { path = "../borsh_utils" }

Expand Down
131 changes: 85 additions & 46 deletions client/src/bin/space-cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ use spaces_client::{
},
wallets::{AddressKind, WalletResponse},
};
use spaces_client::rpc::{CommitParams, CreatePtrParams, DelegateParams, SetPtrDataParams};
use spaces_client::rpc::{CommitParams, CreatePtrParams, DelegateParams, SetFallbackParams};
use spaces_client::store::Sha256;
use spaces_protocol::bitcoin::{Amount, FeeRate, OutPoint, Txid};
use spaces_protocol::slabel::SLabel;
Expand Down Expand Up @@ -163,7 +163,7 @@ enum Commands {
/// Transfer ownership of spaces and/or PTRs to the given name or address
#[command(
name = "transfer",
override_usage = "space-cli transfer [SPACES-OR-PTRS]... --to <SPACE-OR-ADDRESS> [--data <DATA>]"
override_usage = "space-cli transfer [SPACES-OR-PTRS]... --to <SPACE-OR-ADDRESS>"
)]
Transfer {
/// Spaces (e.g., @bitcoin) and/or PTRs (e.g., sptr1...) to send
Expand All @@ -172,9 +172,6 @@ enum Commands {
/// Recipient space name or address
#[arg(long, display_order = 1)]
to: String,
/// Optional data to set on all transferred spaces/PTRs (hex-encoded)
#[arg(long, display_order = 2)]
data: Option<String>,
/// Fee rate to use in sat/vB
#[arg(long, short)]
fee_rate: Option<u64>,
Expand Down Expand Up @@ -353,17 +350,40 @@ enum Commands {
#[arg(default_value = "0")]
target_interval: usize,
},
/// Associate on-chain record data with a space/sptr as a fallback to P2P options like Fabric.
#[command(name = "setrawfallback")]
SetRawFallback {
/// Set on-chain fallback record data for a space/sptr/numeric.
///
/// Records can be specified as key=value flags, raw base64, or JSON from stdin.
///
/// Examples:
/// space-cli setfallback @alice --txt btc=bc1q... --txt nostr=npub1...
/// space-cli setfallback @alice --raw SGVsbG8=
/// echo '[{"type":"txt","key":"btc","value":"bc1q..."}]' | space-cli setfallback @alice --stdin
#[command(name = "setfallback")]
SetFallback {
/// Space name, SPTR, or numeric identifier
subject: String,
/// Hex encoded data
data: String,
/// Add a TXT record (key=value, can be repeated)
#[arg(long = "txt", value_name = "KEY=VALUE")]
txt_records: Vec<String>,
/// Add a BLOB record (key=base64, can be repeated)
#[arg(long = "blob", value_name = "KEY=BASE64")]
blob_records: Vec<String>,
/// Set raw wire-format data as base64
#[arg(long, conflicts_with_all = ["txt_records", "blob_records", "stdin"])]
raw: Option<String>,
/// Read JSON records from stdin
#[arg(long, conflicts_with_all = ["txt_records", "blob_records", "raw"])]
stdin: bool,
/// Fee rate to use in sat/vB
#[arg(long, short)]
fee_rate: Option<u64>,
},
/// Get on-chain fallback record data for a space/sptr/numeric.
#[command(name = "getfallback")]
GetFallback {
/// Space name, SPTR, or numeric identifier
subject: String,
},
/// List last transactions
#[command(name = "listtransactions")]
ListTransactions {
Expand Down Expand Up @@ -694,7 +714,6 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client
Commands::Transfer {
spaces,
to,
data,
fee_rate,
} => {
// Parse spaces, PTRs, and numerics into Subject
Expand All @@ -713,22 +732,11 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client
}).collect();
let spaces = spaces?;

// Parse hex data if present
let data = match data {
Some(hex_str) => {
let data = hex::decode(hex_str).map_err(|e| {
ClientError::Custom(format!("Invalid hex data: {}", e))
})?;
Some(data)
}
None => None,
};

cli.send_request(
Some(RpcWalletRequest::Transfer(TransferSpacesParams {
spaces,
to: Some(to),
data,
data: None,
})),
None,
fee_rate,
Expand All @@ -752,37 +760,68 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client
)
.await?
}
Commands::SetRawFallback {
Commands::SetFallback {
subject: subject_str,
data,
txt_records,
blob_records,
raw,
stdin,
fee_rate,
} => {
let data = match hex::decode(data) {
Ok(data) => data,
Err(e) => {
return Err(ClientError::Custom(format!(
"Could not hex decode data: {}",
e
)))
use base64::Engine;
let data = if let Some(raw_b64) = raw {
// Raw base64-encoded wire-format bytes
base64::engine::general_purpose::STANDARD.decode(&raw_b64)
.map_err(|e| ClientError::Custom(format!("Could not base64 decode data: {}", e)))?
} else if stdin {
// Read JSON records from stdin
let mut input = String::new();
io::stdin().read_line(&mut input).map_err(|e|
ClientError::Custom(format!("Failed to read stdin: {}", e)))?;
let record_set: sip7::RecordSet = serde_json::from_str(input.trim())
.map_err(|e| ClientError::Custom(format!("Invalid SIP-7 JSON: {}", e)))?;
record_set.encode()
} else if !txt_records.is_empty() || !blob_records.is_empty() {
// Build from --txt and --blob flags
let mut record_set = sip7::RecordSet::new();
for txt in &txt_records {
let (key, value) = txt.split_once('=').ok_or_else(||
ClientError::Custom(format!("Invalid --txt format '{}': expected key=value", txt)))?;
record_set.push_txt(key, value).map_err(|e|
ClientError::Custom(format!("Invalid TXT record: {}", e)))?;
}
for blob in &blob_records {
let (key, b64_value) = blob.split_once('=').ok_or_else(||
ClientError::Custom(format!("Invalid --blob format '{}': expected key=base64", blob)))?;
let value = base64::engine::general_purpose::STANDARD.decode(b64_value)
.map_err(|e| ClientError::Custom(format!("Invalid base64 in --blob '{}': {}", key, e)))?;
record_set.push_blob(key, value).map_err(|e|
ClientError::Custom(format!("Invalid BLOB record: {}", e)))?;
}
record_set.encode()
} else {
return Err(ClientError::Custom(
"No data specified. Use --txt, --blob, --raw, or --stdin".to_string()
));
};

let subject = parse_subject(&subject_str)
.map_err(|e| ClientError::Custom(format!("Invalid subject: {}", e)))?;
match &subject {
Subject::Ptr(_) | Subject::Numeric(_) => {
cli.send_request(
Some(RpcWalletRequest::SetPtrData(SetPtrDataParams { subject, data })),
None,
fee_rate,
false,
)
.await?;
}
Subject::Space(_) => {
// TODO: support set data for spaces
}
}
cli.send_request(
Some(RpcWalletRequest::SetFallback(SetFallbackParams { subject, data })),
None,
fee_rate,
false,
)
.await?;
}
Commands::GetFallback {
subject: subject_str,
} => {
let subject = parse_subject(&subject_str)
.map_err(|e| ClientError::Custom(format!("Invalid subject: {}", e)))?;
let response = cli.client.get_fallback(subject).await?;
println!("{}", serde_json::to_string_pretty(&response)?);
}
Commands::ListUnspent => {
let utxos = cli.client.wallet_list_unspent(&cli.wallet).await?;
Expand Down
64 changes: 59 additions & 5 deletions client/src/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -416,11 +416,26 @@ pub trait Rpc {
#[method(name = "walletgetbalance")]
async fn wallet_get_balance(&self, wallet: &str) -> Result<Balance, ErrorObjectOwned>;

#[method(name = "getfallback")]
async fn get_fallback(
&self,
subject: Subject,
) -> Result<Option<FallbackResponse>, ErrorObjectOwned>;

/// Debug method to set a space's expire height (regtest only)
#[method(name = "debugsetexpireheight")]
async fn debug_set_expire_height(&self, space: &str, expire_height: u32) -> Result<(), ErrorObjectOwned>;
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FallbackResponse {
/// Raw data encoded as base64
pub data: String,
/// Parsed SIP-7 records, if data is valid
#[serde(skip_serializing_if = "Option::is_none")]
pub records: Option<sip7::RecordSet>,
}

#[derive(Clone, Serialize, Deserialize)]
pub struct RpcWalletTxBuilder {
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -450,8 +465,8 @@ pub enum RpcWalletRequest {
Delegate(DelegateParams),
#[serde(rename = "commit")]
Commit(CommitParams),
#[serde(rename = "setptrdata")]
SetPtrData(SetPtrDataParams),
#[serde(rename = "setfallback")]
SetFallback(SetFallbackParams),
#[serde(rename = "send")]
SendCoins(SendCoinsParams),
}
Expand Down Expand Up @@ -485,7 +500,7 @@ pub struct CommitParams {
}

#[derive(Clone, Serialize, Deserialize)]
pub struct SetPtrDataParams {
pub struct SetFallbackParams {
pub subject: Subject,
pub data: Vec<u8>,
}
Expand Down Expand Up @@ -1295,6 +1310,42 @@ impl RpcServer for RpcServerImpl {
.map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::<String>))
}

async fn get_fallback(&self, subject: Subject) -> Result<Option<FallbackResponse>, ErrorObjectOwned> {
let data = match &subject {
Subject::Space(label) => {
let space_hash = SpaceKey::from(Sha256::hash(label.as_ref()));
let fso = self.store.get_space(space_hash).await
.map_err(|e| ErrorObjectOwned::owned(-1, e.to_string(), None::<String>))?;
fso.and_then(|fso| {
if let Some(space) = &fso.spaceout.space {
if let Covenant::Transfer { data, .. } = &space.covenant {
return data.as_ref().map(|b| b.clone().to_vec());
}
}
None
})
}
Subject::Ptr(_) | Subject::Numeric(_) => {
let fpt = self.store.get_ptr(subject).await
.map_err(|e| ErrorObjectOwned::owned(-1, e.to_string(), None::<String>))?;
fpt.and_then(|fpt| fpt.ptrout.sptr.data.map(|b| b.to_vec()))
}
};

match data {
None => Ok(None),
Some(raw) => {
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&raw);
let records = sip7::RecordSet::decode(&raw).ok();
Ok(Some(FallbackResponse {
data: encoded,
records,
}))
}
}
}

async fn debug_set_expire_height(&self, space: &str, expire_height: u32) -> Result<(), ErrorObjectOwned> {
// Only allow on regtest
let info = self.store.get_server_info().await
Expand Down Expand Up @@ -1668,8 +1719,11 @@ impl AsyncChainState {
}

let space_key = SpaceKey::from(Sha256::hash(space.as_ref()));
let fso = state.get_space_info(&space_key)?
.ok_or_else(|| anyhow!("Space not found: {}", space))?;
let Some(fso) = state.get_space_info(&space_key)? else {
// non-existence proof
space_tree_keys.insert(space_key.into());
continue;
};

let outpoint_key = OutpointKey::from_outpoint::<Sha256>(fso.outpoint());
space_tree_keys.insert(outpoint_key.into());
Expand Down
Loading
Loading