diff --git a/Cargo.lock b/Cargo.lock index b9fb86e..8f54390 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2513,6 +2513,15 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "sip7" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] + [[package]] name = "slab" version = "0.4.12" @@ -2596,6 +2605,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sip7", "spacedb", "spaces_protocol", "spaces_ptr", diff --git a/Cargo.toml b/Cargo.toml index 3afdcd7..ec5d9e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/client/Cargo.toml b/client/Cargo.toml index 2338659..545fd51 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -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" } diff --git a/client/src/bin/space-cli.rs b/client/src/bin/space-cli.rs index 20470ef..aee02c2 100644 --- a/client/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -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; @@ -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 [--data ]" + override_usage = "space-cli transfer [SPACES-OR-PTRS]... --to " )] Transfer { /// Spaces (e.g., @bitcoin) and/or PTRs (e.g., sptr1...) to send @@ -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, /// Fee rate to use in sat/vB #[arg(long, short)] fee_rate: Option, @@ -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, + /// Add a BLOB record (key=base64, can be repeated) + #[arg(long = "blob", value_name = "KEY=BASE64")] + blob_records: Vec, + /// Set raw wire-format data as base64 + #[arg(long, conflicts_with_all = ["txt_records", "blob_records", "stdin"])] + raw: Option, + /// 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, }, + /// 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 { @@ -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 @@ -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, @@ -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?; diff --git a/client/src/rpc.rs b/client/src/rpc.rs index 12311b6..0b21f34 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -416,11 +416,26 @@ pub trait Rpc { #[method(name = "walletgetbalance")] async fn wallet_get_balance(&self, wallet: &str) -> Result; + #[method(name = "getfallback")] + async fn get_fallback( + &self, + subject: Subject, + ) -> Result, 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, +} + #[derive(Clone, Serialize, Deserialize)] pub struct RpcWalletTxBuilder { #[serde(skip_serializing_if = "Option::is_none")] @@ -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), } @@ -485,7 +500,7 @@ pub struct CommitParams { } #[derive(Clone, Serialize, Deserialize)] -pub struct SetPtrDataParams { +pub struct SetFallbackParams { pub subject: Subject, pub data: Vec, } @@ -1295,6 +1310,42 @@ impl RpcServer for RpcServerImpl { .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } + async fn get_fallback(&self, subject: Subject) -> Result, 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::))?; + 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::))?; + 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 @@ -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::(fso.outpoint()); space_tree_keys.insert(outpoint_key.into()); diff --git a/client/src/wallets.rs b/client/src/wallets.rs index 585ccac..6852a91 100644 --- a/client/src/wallets.rs +++ b/client/src/wallets.rs @@ -1517,48 +1517,71 @@ impl RpcWallet { create_ptr: true, }); } - RpcWalletRequest::SetPtrData(params) => { - let sptr = match ¶ms.subject { - Subject::Ptr(s) => *s, - Subject::Numeric(numeric) => { - let key = NumericKey::from_numeric::(numeric); - chain.get_numeric(&key)?.ok_or_else(|| { - anyhow!("setptrdata: numeric '{}' not found", numeric) - })? - } - Subject::Space(_) => { - return Err(anyhow!( - "setptrdata: expected a ptr or numeric, not a space" - )) - } - }; - // Find the PTR UTXO - let ptr_info = match chain.get_ptr_info(&sptr)? { - None => return Err(anyhow!("setptrdata: PTR '{}' not found", sptr)), - Some(ptr) if !wallet.is_mine(ptr.ptrout.script_pubkey.clone()) => { - return Err(anyhow!("setptrdata: you don't own '{}'", sptr)) + RpcWalletRequest::SetFallback(params) => { + match params.subject { + Subject::Space(ref space) => { + let spacehash = SpaceKey::from(Sha256::hash(space.as_ref())); + let full = chain.get_space_info(&spacehash)? + .ok_or_else(|| anyhow!("setfallback: space '{}' not found", space))?; + if !wallet.is_mine(full.spaceout.script_pubkey.clone()) { + return Err(anyhow!("setfallback: you don't own '{}'", space)); + } + let recipient = SpaceAddress( + Address::from_script( + full.spaceout.script_pubkey.as_script(), + wallet.config.network, + ).expect("valid script"), + ); + builder = builder + .add_transfer(SpaceTransfer { + space: full, + recipient, + create_ptr: false, + }) + .add_data(params.data); } - Some(ptr) - if wallet - .get_utxo(OutPoint::new(ptr.txid, ptr.ptrout.n as u32)) - .is_none() => - { - return Err(anyhow!( - "setptrdata '{}': wallet already has a pending tx for this PTR", - sptr - )) + Subject::Ptr(_) | Subject::Numeric(_) => { + let sptr = match ¶ms.subject { + Subject::Ptr(s) => *s, + Subject::Numeric(numeric) => { + let key = NumericKey::from_numeric::(numeric); + chain.get_numeric(&key)?.ok_or_else(|| { + anyhow!("setfallback: numeric '{}' not found", numeric) + })? + } + _ => unreachable!(), + }; + let ptr_info = match chain.get_ptr_info(&sptr)? { + None => return Err(anyhow!("setfallback: PTR '{}' not found", sptr)), + Some(ptr) if !wallet.is_mine(ptr.ptrout.script_pubkey.clone()) => { + return Err(anyhow!("setfallback: you don't own '{}'", sptr)) + } + Some(ptr) + if wallet + .get_utxo(OutPoint::new(ptr.txid, ptr.ptrout.n as u32)) + .is_none() => + { + return Err(anyhow!( + "setfallback '{}': wallet already has a pending tx for this PTR", + sptr + )) + } + Some(ptr) => ptr, + }; + let recipient = SpaceAddress( + Address::from_script( + ptr_info.ptrout.script_pubkey.as_script(), + wallet.config.network, + ).expect("valid script"), + ); + builder = builder + .add_ptr_transfer(PtrTransfer { + ptr: ptr_info, + recipient, + }) + .add_data(params.data); } - Some(ptr) => ptr, - }; - - // Transfer PTR to self with data - let recipient = wallet.reveal_next_space_address(); - builder = builder - .add_ptr_transfer(PtrTransfer { - ptr: ptr_info, - recipient, - }) - .add_data(params.data); + } } } } diff --git a/client/tests/ptr_tests.rs b/client/tests/ptr_tests.rs index 370fe73..337fe4f 100644 --- a/client/tests/ptr_tests.rs +++ b/client/tests/ptr_tests.rs @@ -7,7 +7,7 @@ use spaces_client::{ }, wallets::{AddressKind, WalletResponse}, }; -use spaces_client::rpc::{CommitParams, CreatePtrParams, DelegateParams, SetPtrDataParams, Subject, TransferSpacesParams}; +use spaces_client::rpc::{CommitParams, CreatePtrParams, DelegateParams, SetFallbackParams, Subject, TransferSpacesParams}; use spaces_client::store::Sha256; use spaces_protocol::{bitcoin, bitcoin::{FeeRate}}; use spaces_protocol::bitcoin::hashes::{sha256, Hash}; @@ -814,13 +814,13 @@ async fn it_should_set_and_persist_ptr_data(rig: &TestRig) -> anyhow::Result<()> let set_data = wallet_do( rig, ALICE, - vec![RpcWalletRequest::SetPtrData(SetPtrDataParams { + vec![RpcWalletRequest::SetFallback(SetFallbackParams { subject: Subject::Ptr(sptr), data: test_data.clone(), })], false, ).await?; - assert!(wallet_res_err(&set_data).is_ok(), "SetPtrData should succeed"); + assert!(wallet_res_err(&set_data).is_ok(), "SetFallback should succeed"); mine_and_sync(rig, 1).await?; use spaces_protocol::Bytes; @@ -858,13 +858,13 @@ async fn it_should_set_and_persist_ptr_data(rig: &TestRig) -> anyhow::Result<()> let update_data = wallet_do( rig, BOB, - vec![RpcWalletRequest::SetPtrData(SetPtrDataParams { + vec![RpcWalletRequest::SetFallback(SetFallbackParams { subject: Subject::Ptr(sptr), data: new_data.clone(), })], false, ).await?; - assert!(wallet_res_err(&update_data).is_ok(), "SetPtrData should succeed"); + assert!(wallet_res_err(&update_data).is_ok(), "SetFallback should succeed"); mine_and_sync(rig, 1).await?; let ptr_updated = rig.spaced.client.get_ptr(Subject::Ptr(sptr)).await? @@ -878,13 +878,13 @@ async fn it_should_set_and_persist_ptr_data(rig: &TestRig) -> anyhow::Result<()> let set_empty = wallet_do( rig, BOB, - vec![RpcWalletRequest::SetPtrData(SetPtrDataParams { + vec![RpcWalletRequest::SetFallback(SetFallbackParams { subject: Subject::Ptr(sptr), data: empty_data.clone(), })], false, ).await?; - assert!(wallet_res_err(&set_empty).is_ok(), "SetPtrData with empty data should succeed"); + assert!(wallet_res_err(&set_empty).is_ok(), "SetFallback with empty data should succeed"); mine_and_sync(rig, 1).await?; let ptr_empty = rig.spaced.client.get_ptr(Subject::Ptr(sptr)).await? @@ -895,6 +895,124 @@ async fn it_should_set_and_persist_ptr_data(rig: &TestRig) -> anyhow::Result<()> Ok(()) } +// ============== Test: Space Fallback Data ============== + +async fn it_should_set_and_get_space_fallback(rig: &TestRig) -> anyhow::Result<()> { + sync_all(rig).await?; + + let alice_spaces = rig.spaced.client.wallet_list_spaces(ALICE).await?; + let owned = alice_spaces.owned.first().cloned() + .expect("Alice should own at least one space"); + let space_name = owned.spaceout.space.as_ref() + .expect("space must exist").name.clone(); + let space_str = space_name.to_string(); + + // Record the outpoint and script_pubkey before setfallback + let space_before = rig.spaced.client.get_space(&space_str).await? + .expect("space should exist"); + let spk_before = space_before.spaceout.script_pubkey.clone(); + + // Verify no fallback data initially via getfallback + let subject = Subject::Space(space_name.clone()); + let fallback_before = rig.spaced.client.get_fallback(subject.clone()).await?; + assert!(fallback_before.is_none(), "space should have no fallback data initially"); + println!("✓ No fallback data initially"); + + // Test 1: Set SIP-7 fallback data on the space + println!("\nTest 1: Set SIP-7 fallback data on space"); + let mut records = sip7::RecordSet::new(); + records.push_txt("btc", "bc1qtest").unwrap(); + records.push_txt("nostr", "npub1abc").unwrap(); + let wire_data = records.encode(); + + let set_result = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::SetFallback(SetFallbackParams { + subject: subject.clone(), + data: wire_data.clone(), + })], + false, + ).await?; + assert!(wallet_res_err(&set_result).is_ok(), "SetFallback on space should succeed"); + mine_and_sync(rig, 1).await?; + + // Verify space still exists and is still owned by Alice + let space_after = rig.spaced.client.get_space(&space_str).await? + .expect("space should still exist after setfallback"); + assert!(space_after.spaceout.space.as_ref().unwrap().is_owned(), + "space should still be owned after setfallback"); + assert_eq!(space_after.spaceout.script_pubkey, spk_before, + "space script_pubkey should not change after setfallback"); + println!("✓ Space still owned by Alice at same address"); + + // Verify data was set on the covenant + use spaces_protocol::Covenant; + match &space_after.spaceout.space.as_ref().unwrap().covenant { + Covenant::Transfer { data, .. } => { + assert_eq!(data.as_ref().map(|b| b.clone().to_vec()), Some(wire_data.clone()), + "covenant data should match"); + } + _ => panic!("space should be in Transfer covenant"), + } + println!("✓ Covenant data matches wire-encoded SIP-7 records"); + + // Verify via getfallback RPC + let fallback = rig.spaced.client.get_fallback(subject.clone()).await? + .expect("getfallback should return data"); + let parsed = fallback.records.expect("should parse as SIP-7 records"); + assert_eq!(parsed.records().len(), 2, "should have 2 records"); + println!("✓ getfallback returns parsed SIP-7 records"); + + // Test 2: Alice can still transfer the space after setfallback + println!("\nTest 2: Transfer space after setfallback"); + let bob_addr = rig.spaced.client.wallet_get_new_address(BOB, AddressKind::Space).await?; + let transfer = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Transfer(TransferSpacesParams { + spaces: vec![Subject::Space(space_name.clone())], + to: Some(bob_addr), + data: None, + })], + false, + ).await?; + assert!(wallet_res_err(&transfer).is_ok(), "should be able to transfer space after setfallback"); + mine_and_sync(rig, 1).await?; + + // Data should persist after transfer + let fallback_after_transfer = rig.spaced.client.get_fallback(subject.clone()).await? + .expect("fallback data should persist after transfer"); + assert!(fallback_after_transfer.records.is_some(), "SIP-7 records should still parse"); + println!("✓ Fallback data persists after transfer"); + + // Test 3: Bob can overwrite the fallback data + println!("\nTest 3: Bob overwrites fallback data"); + let mut new_records = sip7::RecordSet::new(); + new_records.push_txt("eth", "0xdeadbeef").unwrap(); + let new_wire = new_records.encode(); + + let bob_set = wallet_do( + rig, + BOB, + vec![RpcWalletRequest::SetFallback(SetFallbackParams { + subject: subject.clone(), + data: new_wire.clone(), + })], + false, + ).await?; + assert!(wallet_res_err(&bob_set).is_ok(), "Bob should be able to setfallback on his space"); + mine_and_sync(rig, 1).await?; + + let fallback_bob = rig.spaced.client.get_fallback(subject).await? + .expect("should have fallback data"); + let bob_parsed = fallback_bob.records.expect("should parse as SIP-7"); + assert_eq!(bob_parsed.records().len(), 1, "should have 1 record now"); + println!("✓ Bob successfully overwrote fallback data"); + + Ok(()) +} + // ============== Main Test Runner ============== #[tokio::test] @@ -934,6 +1052,9 @@ async fn run_ptr_tests() -> anyhow::Result<()> { println!("\n=== Running PTR Data Tests ==="); it_should_set_and_persist_ptr_data(&rig).await?; + println!("\n=== Running Space Fallback Data Tests ==="); + it_should_set_and_get_space_fallback(&rig).await?; + println!("\n=== All tests passed! ==="); Ok(()) } diff --git a/sip7/Cargo.toml b/sip7/Cargo.toml new file mode 100644 index 0000000..c7f6ea2 --- /dev/null +++ b/sip7/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "sip7" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive", "alloc"], default-features = false, optional = true } +base64 = { workspace = true, optional = true } + +[dev-dependencies] +serde_json = { workspace = true } + +[features] +default = [] +serde = ["dep:serde", "dep:base64"] +std = ["serde"] diff --git a/sip7/src/lib.rs b/sip7/src/lib.rs new file mode 100644 index 0000000..2e14c00 --- /dev/null +++ b/sip7/src/lib.rs @@ -0,0 +1,696 @@ +#![cfg_attr(all(not(feature = "std"), not(test)), no_std)] + +extern crate alloc; + +use alloc::{string::String, vec::Vec}; +use core::fmt; + +const TYPE_TXT: u8 = 0x00; +const TYPE_BLOB: u8 = 0x01; +const TYPE_RESERVED: u8 = 0xFF; + +/// A single record in a SIP-7 record set. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Record { + Txt { key: String, value: String }, + Blob { key: String, value: Vec }, + Unknown { rtype: u8, rdata: Vec }, +} + +/// An ordered collection of SIP-7 records. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RecordSet { + records: Vec, +} + +/// Errors that can occur during record parsing or construction. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Error { + ReservedType, + UnexpectedEof, + DataOverflow, + EmptyData, + KeyTooLong, + InvalidKey, + InvalidUtf8, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::ReservedType => write!(f, "reserved type 0xFF"), + Error::UnexpectedEof => write!(f, "unexpected end of data"), + Error::DataOverflow => write!(f, "data length exceeds available bytes"), + Error::EmptyData => write!(f, "empty record data"), + Error::KeyTooLong => write!(f, "key length exceeds record data"), + Error::InvalidKey => write!(f, "key must be lowercase ascii, digits, or hyphens"), + Error::InvalidUtf8 => write!(f, "invalid UTF-8 in text value"), +} + } +} + +fn validate_key(key: &str) -> Result<(), Error> { + if key.is_empty() { + return Err(Error::InvalidKey); + } + if key.len() > 255 { + return Err(Error::KeyTooLong); + } + if !key + .bytes() + .all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'-')) + { + return Err(Error::InvalidKey); + } + Ok(()) +} + +fn read_compact_size(data: &[u8], pos: &mut usize) -> Result { + if *pos >= data.len() { + return Err(Error::UnexpectedEof); + } + let first = data[*pos]; + *pos += 1; + match first { + 0x00..=0xFC => Ok(first as usize), + 0xFD => { + if *pos + 2 > data.len() { + return Err(Error::UnexpectedEof); + } + let v = u16::from_le_bytes([data[*pos], data[*pos + 1]]) as usize; + *pos += 2; + Ok(v) + } + 0xFE => { + if *pos + 4 > data.len() { + return Err(Error::UnexpectedEof); + } + let v = u32::from_le_bytes(data[*pos..*pos + 4].try_into().unwrap()) as usize; + *pos += 4; + Ok(v) + } + 0xFF => { + if *pos + 8 > data.len() { + return Err(Error::UnexpectedEof); + } + let v = u64::from_le_bytes(data[*pos..*pos + 8].try_into().unwrap()) as usize; + *pos += 8; + Ok(v) + } + } +} + +fn write_compact_size(buf: &mut Vec, value: usize) { + if value <= 0xFC { + buf.push(value as u8); + } else if value <= 0xFFFF { + buf.push(0xFD); + buf.extend_from_slice(&(value as u16).to_le_bytes()); + } else if value <= 0xFFFF_FFFF { + buf.push(0xFE); + buf.extend_from_slice(&(value as u32).to_le_bytes()); + } else { + buf.push(0xFF); + buf.extend_from_slice(&(value as u64).to_le_bytes()); + } +} + +fn parse_kv(data: &[u8]) -> Result<(String, &[u8]), Error> { + if data.is_empty() { + return Err(Error::EmptyData); + } + let kl = data[0] as usize; + if 1 + kl > data.len() { + return Err(Error::KeyTooLong); + } + let key = core::str::from_utf8(&data[1..1 + kl]).map_err(|_| Error::InvalidKey)?; + validate_key(key)?; + Ok((String::from(key), &data[1 + kl..])) +} + +impl Record { + fn encode(&self, buf: &mut Vec) { + match self { + Record::Txt { key, value } => { + buf.push(TYPE_TXT); + let data_len = 1 + key.len() + value.len(); + write_compact_size(buf, data_len); + buf.push(key.len() as u8); + buf.extend_from_slice(key.as_bytes()); + buf.extend_from_slice(value.as_bytes()); + } + Record::Blob { key, value } => { + buf.push(TYPE_BLOB); + let data_len = 1 + key.len() + value.len(); + write_compact_size(buf, data_len); + buf.push(key.len() as u8); + buf.extend_from_slice(key.as_bytes()); + buf.extend_from_slice(value); + } + Record::Unknown { rtype, rdata } => { + buf.push(*rtype); + write_compact_size(buf, rdata.len()); + buf.extend_from_slice(rdata); + } + } + } +} + +impl RecordSet { + /// Creates an empty record set. + pub fn new() -> Self { + Self { + records: Vec::new(), + } + } + + /// Decodes a record set from its wire format. + pub fn decode(data: &[u8]) -> Result { + let mut records = Vec::new(); + let mut pos = 0; + + while pos < data.len() { + let rtype = data[pos]; + pos += 1; + + if rtype == TYPE_RESERVED { + return Err(Error::ReservedType); + } + + let len = read_compact_size(data, &mut pos)?; + if pos + len > data.len() { + return Err(Error::DataOverflow); + } + let rdata = &data[pos..pos + len]; + pos += len; + + let record = match rtype { + TYPE_TXT => { + let (key, val_bytes) = parse_kv(rdata)?; + let value = + core::str::from_utf8(val_bytes).map_err(|_| Error::InvalidUtf8)?; + Record::Txt { + key, + value: String::from(value), + } + } + TYPE_BLOB => { + let (key, val_bytes) = parse_kv(rdata)?; + Record::Blob { + key, + value: val_bytes.to_vec(), + } + } + _ => Record::Unknown { + rtype, + rdata: rdata.to_vec(), + }, + }; + + records.push(record); + } + + Ok(Self { records }) + } + + /// Adds a TXT record. + pub fn push_txt(&mut self, key: &str, value: &str) -> Result<(), Error> { + validate_key(key)?; + self.records.push(Record::Txt { + key: String::from(key), + value: String::from(value), + }); + Ok(()) + } + + /// Adds a BLOB record. + pub fn push_blob(&mut self, key: &str, value: Vec) -> Result<(), Error> { + validate_key(key)?; + self.records.push(Record::Blob { + key: String::from(key), + value, + }); + Ok(()) + } + + /// Adds an unknown record type (preserved for round-tripping). + pub fn push_unknown(&mut self, rtype: u8, rdata: Vec) -> Result<(), Error> { + if rtype == TYPE_RESERVED { + return Err(Error::ReservedType); + } + self.records.push(Record::Unknown { rtype, rdata }); + Ok(()) + } + + /// Encodes the record set to its wire format. + pub fn encode(&self) -> Vec { + let mut buf = Vec::new(); + for record in &self.records { + record.encode(&mut buf); + } + buf + } + + /// Returns a slice of all records. + pub fn records(&self) -> &[Record] { + &self.records + } + + /// Returns true if the record set contains no records. + pub fn is_empty(&self) -> bool { + self.records.is_empty() + } + + /// Returns the number of records. + pub fn len(&self) -> usize { + self.records.len() + } +} + +impl Default for RecordSet { + fn default() -> Self { + Self::new() + } +} + +// --- Serde support --- + +#[cfg(feature = "serde")] +mod serde_impl { + use super::*; + use base64::prelude::{Engine, BASE64_STANDARD}; + use serde::de::{self, SeqAccess, Visitor}; + use serde::ser::SerializeSeq; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + #[derive(Serialize, Deserialize)] + struct TxtJson { + key: String, + value: String, + } + + #[derive(Serialize, Deserialize)] + struct BlobJson { + key: String, + value: String, + } + + #[derive(Serialize, Deserialize)] + struct UnknownJson { + rtype: u8, + rdata: String, + } + + #[derive(Serialize, Deserialize)] + #[serde(tag = "type")] + enum RecordJson { + #[serde(rename = "txt")] + Txt(TxtJson), + #[serde(rename = "blob")] + Blob(BlobJson), + #[serde(rename = "unknown")] + Unknown(UnknownJson), + } + + impl From<&Record> for RecordJson { + fn from(r: &Record) -> Self { + match r { + Record::Txt { key, value } => RecordJson::Txt(TxtJson { + key: key.clone(), + value: value.clone(), + }), + Record::Blob { key, value } => RecordJson::Blob(BlobJson { + key: key.clone(), + value: BASE64_STANDARD.encode(value), + }), + Record::Unknown { rtype, rdata } => RecordJson::Unknown(UnknownJson { + rtype: *rtype, + rdata: BASE64_STANDARD.encode(rdata), + }), + } + } + } + + impl TryFrom for Record { + type Error = &'static str; + + fn try_from(j: RecordJson) -> Result { + match j { + RecordJson::Txt(t) => Ok(Record::Txt { + key: t.key, + value: t.value, + }), + RecordJson::Blob(b) => { + let value = BASE64_STANDARD + .decode(&b.value) + .map_err(|_| "invalid base64 in blob value")?; + Ok(Record::Blob { + key: b.key, + value, + }) + } + RecordJson::Unknown(u) => { + let rdata = BASE64_STANDARD + .decode(&u.rdata) + .map_err(|_| "invalid base64 in unknown rdata")?; + Ok(Record::Unknown { + rtype: u.rtype, + rdata, + }) + } + } + } + } + + impl Serialize for Record { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + RecordJson::from(self).serialize(serializer) + } + } + + impl<'de> Deserialize<'de> for Record { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let j = RecordJson::deserialize(deserializer)?; + Record::try_from(j).map_err(de::Error::custom) + } + } + + impl Serialize for RecordSet { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut seq = serializer.serialize_seq(Some(self.records.len()))?; + for record in &self.records { + seq.serialize_element(record)?; + } + seq.end() + } + } + + impl<'de> Deserialize<'de> for RecordSet { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct RecordSetVisitor; + + impl<'de> Visitor<'de> for RecordSetVisitor { + type Value = RecordSet; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a sequence of records") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let mut records = Vec::new(); + while let Some(record) = seq.next_element::()? { + records.push(record); + } + Ok(RecordSet { records }) + } + } + + deserializer.deserialize_seq(RecordSetVisitor) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encode_decode_txt() { + let mut rs = RecordSet::new(); + rs.push_txt("btc", "bc1qtest").unwrap(); + rs.push_txt("nostr", "npub1abc").unwrap(); + + let encoded = rs.encode(); + let decoded = RecordSet::decode(&encoded).unwrap(); + + assert_eq!(rs, decoded); + assert_eq!(decoded.len(), 2); + } + + #[test] + fn encode_decode_blob() { + let mut rs = RecordSet::new(); + rs.push_blob("avatar", vec![0x89, 0x50, 0x4E, 0x47]).unwrap(); + + let encoded = rs.encode(); + let decoded = RecordSet::decode(&encoded).unwrap(); + + assert_eq!(rs, decoded); + match &decoded.records()[0] { + Record::Blob { key, value } => { + assert_eq!(key, "avatar"); + assert_eq!(value, &[0x89, 0x50, 0x4E, 0x47]); + } + _ => panic!("expected blob"), + } + } + + #[test] + fn encode_decode_unknown() { + let mut rs = RecordSet::new(); + rs.push_unknown(42, vec![1, 2, 3]).unwrap(); + + let encoded = rs.encode(); + let decoded = RecordSet::decode(&encoded).unwrap(); + + assert_eq!(rs, decoded); + match &decoded.records()[0] { + Record::Unknown { rtype, rdata } => { + assert_eq!(*rtype, 42); + assert_eq!(rdata, &[1, 2, 3]); + } + _ => panic!("expected unknown"), + } + } + + #[test] + fn encode_decode_mixed() { + let mut rs = RecordSet::new(); + rs.push_txt("btc", "bc1qtest").unwrap(); + rs.push_blob("data", vec![0xFF, 0x00]).unwrap(); + rs.push_unknown(0x10, vec![0xAB]).unwrap(); + rs.push_txt("email", "alice@example.com").unwrap(); + + let encoded = rs.encode(); + let decoded = RecordSet::decode(&encoded).unwrap(); + + assert_eq!(rs, decoded); + assert_eq!(decoded.len(), 4); + } + + #[test] + fn empty_record_set() { + let rs = RecordSet::new(); + assert!(rs.is_empty()); + let encoded = rs.encode(); + assert!(encoded.is_empty()); + let decoded = RecordSet::decode(&encoded).unwrap(); + assert!(decoded.is_empty()); + } + + #[test] + fn empty_txt_value() { + let mut rs = RecordSet::new(); + rs.push_txt("btc", "").unwrap(); + + let encoded = rs.encode(); + let decoded = RecordSet::decode(&encoded).unwrap(); + + match &decoded.records()[0] { + Record::Txt { key, value } => { + assert_eq!(key, "btc"); + assert_eq!(value, ""); + } + _ => panic!("expected txt"), + } + } + + #[test] + fn reject_reserved_type() { + let data = vec![0xFF, 0x00]; + assert_eq!(RecordSet::decode(&data), Err(Error::ReservedType)); + } + + #[test] + fn reject_reserved_type_on_push() { + let mut rs = RecordSet::new(); + assert_eq!( + rs.push_unknown(0xFF, vec![]), + Err(Error::ReservedType) + ); + } + + #[test] + fn reject_uppercase_key() { + let mut rs = RecordSet::new(); + assert_eq!(rs.push_txt("BTC", "bc1q"), Err(Error::InvalidKey)); + } + + #[test] + fn reject_invalid_key_chars() { + let mut rs = RecordSet::new(); + assert_eq!(rs.push_txt("my_key", "val"), Err(Error::InvalidKey)); + assert_eq!(rs.push_txt("my.key", "val"), Err(Error::InvalidKey)); + assert_eq!(rs.push_txt("my key", "val"), Err(Error::InvalidKey)); + } + + #[test] + fn reject_empty_key() { + let mut rs = RecordSet::new(); + assert_eq!(rs.push_txt("", "val"), Err(Error::InvalidKey)); + } + + #[test] + fn allow_valid_key_chars() { + let mut rs = RecordSet::new(); + rs.push_txt("my-key-123", "val").unwrap(); + rs.push_txt("a", "val").unwrap(); + rs.push_txt("abc-def", "val").unwrap(); + } + + #[test] + fn truncated_data() { + // RType present but no length + let data = vec![TYPE_TXT]; + assert_eq!(RecordSet::decode(&data), Err(Error::UnexpectedEof)); + } + + #[test] + fn data_overflow() { + // Claims 10 bytes of data but only has 3 + let data = vec![TYPE_TXT, 10, 3, b'b', b't', b'c']; + assert_eq!(RecordSet::decode(&data), Err(Error::DataOverflow)); + } + + #[test] + fn compact_size_boundary() { + // Test value at 0xFC boundary (252) + let mut rs = RecordSet::new(); + let long_value = "x".repeat(248); // key_len(1) + key(3) + value(248) = 252 + rs.push_txt("btc", &long_value).unwrap(); + + let encoded = rs.encode(); + let decoded = RecordSet::decode(&encoded).unwrap(); + assert_eq!(rs, decoded); + } + + #[test] + fn compact_size_multi_byte() { + // Test value requiring 0xFD prefix (> 252 bytes) + let mut rs = RecordSet::new(); + let long_value = "x".repeat(300); + rs.push_txt("btc", &long_value).unwrap(); + + let encoded = rs.encode(); + // Verify the compact size prefix is 0xFD + assert_eq!(encoded[1], 0xFD); + let decoded = RecordSet::decode(&encoded).unwrap(); + assert_eq!(rs, decoded); + } + + #[cfg(feature = "serde")] + mod serde_tests { + use super::*; + + #[test] + fn json_round_trip_txt() { + let mut rs = RecordSet::new(); + rs.push_txt("btc", "bc1qtest").unwrap(); + rs.push_txt("nostr", "npub1abc").unwrap(); + + let json = serde_json::to_string(&rs).unwrap(); + let decoded: RecordSet = serde_json::from_str(&json).unwrap(); + assert_eq!(rs, decoded); + } + + #[test] + fn json_round_trip_blob() { + let mut rs = RecordSet::new(); + rs.push_blob("avatar", vec![0x89, 0x50, 0x4E, 0x47]).unwrap(); + + let json = serde_json::to_string(&rs).unwrap(); + assert!(json.contains("\"type\":\"blob\"")); + // BLOB value should be base64 encoded + assert!(json.contains("\"value\":\"iVBORw==\"")); + + let decoded: RecordSet = serde_json::from_str(&json).unwrap(); + assert_eq!(rs, decoded); + } + + #[test] + fn json_round_trip_unknown() { + let mut rs = RecordSet::new(); + rs.push_unknown(42, vec![1, 2, 3]).unwrap(); + + let json = serde_json::to_string(&rs).unwrap(); + assert!(json.contains("\"type\":\"unknown\"")); + assert!(json.contains("\"rtype\":42")); + + let decoded: RecordSet = serde_json::from_str(&json).unwrap(); + assert_eq!(rs, decoded); + } + + #[test] + fn json_matches_spec_format() { + let json = r#"[ + {"type":"txt","key":"btc","value":"bc1q..."}, + {"type":"blob","key":"some-data","value":"aGVsbG8="}, + {"type":"unknown","rtype":42,"rdata":"AQID"} + ]"#; + + let rs: RecordSet = serde_json::from_str(json).unwrap(); + assert_eq!(rs.len(), 3); + + match &rs.records()[0] { + Record::Txt { key, value } => { + assert_eq!(key, "btc"); + assert_eq!(value, "bc1q..."); + } + _ => panic!("expected txt"), + } + + match &rs.records()[1] { + Record::Blob { key, value } => { + assert_eq!(key, "some-data"); + assert_eq!(value, b"hello"); + } + _ => panic!("expected blob"), + } + + match &rs.records()[2] { + Record::Unknown { rtype, rdata } => { + assert_eq!(*rtype, 42); + assert_eq!(rdata, &[1, 2, 3]); + } + _ => panic!("expected unknown"), + } + } + + #[test] + fn json_pretty_output() { + let mut rs = RecordSet::new(); + rs.push_txt("nostr", "npub1abc").unwrap(); + rs.push_txt("btc", "bc1q...").unwrap(); + rs.push_blob("some-data", b"hello".to_vec()).unwrap(); + + let json = serde_json::to_string_pretty(&rs).unwrap(); + let decoded: RecordSet = serde_json::from_str(&json).unwrap(); + assert_eq!(rs, decoded); + } + } +}