Skip to content

Commit 7aef424

Browse files
committed
Support setrawfallback for sptrs in space-cli
1 parent df63252 commit 7aef424

File tree

6 files changed

+209
-26
lines changed

6 files changed

+209
-26
lines changed

client/src/bin/space-cli.rs

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ use spaces_client::{
3535
serialize_base64,
3636
wallets::{AddressKind, WalletResponse},
3737
};
38-
use spaces_client::rpc::{CommitParams, CreatePtrParams, DelegateParams, TransferPtrParams};
38+
use spaces_client::rpc::{CommitParams, CreatePtrParams, DelegateParams, SetPtrDataParams, TransferPtrParams};
3939
use spaces_client::store::Sha256;
4040
use spaces_protocol::bitcoin::{Amount, FeeRate, OutPoint, Txid};
4141
use spaces_protocol::slabel::SLabel;
@@ -415,11 +415,11 @@ enum Commands {
415415
#[arg(default_value = "0")]
416416
target_interval: usize,
417417
},
418-
/// Associate on-chain record data with a space as a fallback to P2P options like Fabric.
418+
/// Associate on-chain record data with a space/sptr as a fallback to P2P options like Fabric.
419419
#[command(name = "setrawfallback")]
420420
SetRawFallback {
421-
/// Space name
422-
space: String,
421+
/// Space name or SPTR identifier
422+
space_or_sptr: String,
423423
/// Hex encoded data
424424
data: String,
425425
/// Fee rate to use in sat/vB
@@ -846,11 +846,10 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client
846846
.await?
847847
}
848848
Commands::SetRawFallback {
849-
mut space,
849+
space_or_sptr,
850850
data,
851851
fee_rate,
852852
} => {
853-
space = normalize_space(&space);
854853
let data = match hex::decode(data) {
855854
Ok(data) => data,
856855
Err(e) => {
@@ -861,19 +860,35 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client
861860
}
862861
};
863862

864-
let space_script =
865-
spaces_protocol::script::SpaceScript::create_set_fallback(data.as_slice());
863+
// Check if it's an SPTR (starts with "sptr1")
864+
if space_or_sptr.starts_with("sptr1") {
865+
let sptr = Sptr::from_str(&space_or_sptr).map_err(|e| {
866+
ClientError::Custom(format!("Invalid SPTR: {}", e))
867+
})?;
868+
cli.send_request(
869+
Some(RpcWalletRequest::SetPtrData(SetPtrDataParams { sptr, data })),
870+
None,
871+
fee_rate,
872+
false,
873+
)
874+
.await?;
875+
} else {
876+
// Space fallback: use existing space script
877+
let space = normalize_space(&space_or_sptr);
878+
let space_script =
879+
spaces_protocol::script::SpaceScript::create_set_fallback(data.as_slice());
866880

867-
cli.send_request(
868-
Some(RpcWalletRequest::Execute(ExecuteParams {
869-
context: vec![space],
870-
space_script,
871-
})),
872-
None,
873-
fee_rate,
874-
false,
875-
)
876-
.await?;
881+
cli.send_request(
882+
Some(RpcWalletRequest::Execute(ExecuteParams {
883+
context: vec![space],
884+
space_script,
885+
})),
886+
None,
887+
fee_rate,
888+
false,
889+
)
890+
.await?;
891+
}
877892
}
878893
Commands::ListUnspent => {
879894
let utxos = cli.client.wallet_list_unspent(&cli.wallet).await?;

client/src/rpc.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,8 @@ pub enum RpcWalletRequest {
489489
Delegate(DelegateParams),
490490
#[serde(rename = "commit")]
491491
Commit(CommitParams),
492+
#[serde(rename = "setptrdata")]
493+
SetPtrData(SetPtrDataParams),
492494
#[serde(rename = "send")]
493495
SendCoins(SendCoinsParams),
494496
}
@@ -523,6 +525,11 @@ pub struct CommitParams {
523525
pub root: Option<sha256::Hash>,
524526
}
525527

528+
#[derive(Clone, Serialize, Deserialize)]
529+
pub struct SetPtrDataParams {
530+
pub sptr: Sptr,
531+
pub data: Vec<u8>,
532+
}
526533

527534
#[derive(Clone, Serialize, Deserialize)]
528535
pub struct SendCoinsParams {

client/src/wallets.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1367,6 +1367,31 @@ impl RpcWallet {
13671367
create_ptr: true,
13681368
});
13691369
}
1370+
RpcWalletRequest::SetPtrData(params) => {
1371+
// Find the PTR UTXO
1372+
let ptr_info = match chain.get_ptr_info(&params.sptr)? {
1373+
None => return Err(anyhow!("setptrdata: PTR '{}' not found", params.sptr)),
1374+
Some(ptr) if !wallet.is_mine(ptr.ptrout.script_pubkey.clone()) => {
1375+
return Err(anyhow!("setptrdata: you don't own '{}'", params.sptr))
1376+
}
1377+
Some(ptr) if wallet.get_utxo(OutPoint::new(ptr.txid, ptr.ptrout.n as u32)).is_none() => {
1378+
return Err(anyhow!(
1379+
"setptrdata '{}': wallet already has a pending tx for this PTR",
1380+
params.sptr
1381+
))
1382+
}
1383+
Some(ptr) => ptr,
1384+
};
1385+
1386+
// Transfer PTR to self with data
1387+
let recipient = wallet.reveal_next_space_address();
1388+
builder = builder
1389+
.add_ptr_transfer(PtrTransfer {
1390+
ptr: ptr_info,
1391+
recipient,
1392+
})
1393+
.add_ptr_data(params.data);
1394+
}
13701395
}
13711396
}
13721397

client/tests/ptr_tests.rs

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use spaces_client::{
77
},
88
wallets::{AddressKind, WalletResponse},
99
};
10-
use spaces_client::rpc::{CommitParams, CreatePtrParams, DelegateParams, TransferPtrParams, TransferSpacesParams};
10+
use spaces_client::rpc::{CommitParams, CreatePtrParams, DelegateParams, SetPtrDataParams, TransferPtrParams, TransferSpacesParams};
1111
use spaces_client::store::Sha256;
1212
use spaces_protocol::{bitcoin, bitcoin::{FeeRate}};
1313
use spaces_protocol::bitcoin::hashes::{sha256, Hash};
@@ -184,7 +184,7 @@ async fn it_should_commit_and_rollback(rig: &TestRig) -> anyhow::Result<()> {
184184
mine_and_sync(rig, 1).await?;
185185

186186
// Verify delegation is set up
187-
let sptr = rig.spaced.client.get_delegation(space_name.clone()).await?
187+
rig.spaced.client.get_delegation(space_name.clone()).await?
188188
.expect("delegation should be established");
189189

190190
// Test 1: Make initial commitment [1u8;32]
@@ -633,6 +633,122 @@ async fn it_should_reject_duplicate_sptr_delegations(rig: &TestRig) -> anyhow::R
633633
Ok(())
634634
}
635635

636+
// ============== Test: PTR Data ==============
637+
638+
async fn it_should_set_and_persist_ptr_data(rig: &TestRig) -> anyhow::Result<()> {
639+
sync_all(rig).await?;
640+
641+
// Test 1: Create a PTR
642+
println!("Test 1: Create PTR");
643+
let addr0 = rig.spaced.client.wallet_get_new_address(ALICE, AddressKind::Coin).await?;
644+
let addr0_spk = bitcoin::address::Address::from_str(&addr0)?
645+
.assume_checked()
646+
.script_pubkey();
647+
let addr0_spk_string = hex::encode(addr0_spk.as_bytes());
648+
649+
wallet_do(
650+
rig,
651+
ALICE,
652+
vec![RpcWalletRequest::CreatePtr(CreatePtrParams {
653+
spk: addr0_spk_string,
654+
})],
655+
false,
656+
).await?;
657+
mine_and_sync(rig, 1).await?;
658+
659+
let sptr = Sptr::from_spk::<Sha256>(addr0_spk.clone());
660+
println!("SPTR created: {}", sptr);
661+
662+
// Verify PTR exists with no data
663+
let ptr_initial = rig.spaced.client.get_ptr(sptr).await?
664+
.expect("ptr should exist");
665+
assert_eq!(ptr_initial.ptrout.sptr.as_ref().unwrap().data, None, "PTR should have no data initially");
666+
667+
// Test 2: Set data on the PTR
668+
println!("\nTest 2: Set data on PTR");
669+
let test_data = b"Hello, PTR data!".to_vec();
670+
let set_data = wallet_do(
671+
rig,
672+
ALICE,
673+
vec![RpcWalletRequest::SetPtrData(SetPtrDataParams {
674+
sptr,
675+
data: test_data.clone(),
676+
})],
677+
false,
678+
).await?;
679+
assert!(wallet_res_err(&set_data).is_ok(), "SetPtrData should succeed");
680+
mine_and_sync(rig, 1).await?;
681+
682+
// Verify data was set
683+
let ptr_with_data = rig.spaced.client.get_ptr(sptr).await?
684+
.expect("ptr should exist");
685+
assert_eq!(ptr_with_data.ptrout.sptr.as_ref().unwrap().data, Some(test_data.clone()), "PTR data should be set");
686+
println!("✓ PTR data set successfully: {:?}", String::from_utf8_lossy(&test_data));
687+
688+
// Test 3: Transfer PTR without data - data should persist
689+
println!("\nTest 3: Transfer PTR without setting new data - data should persist");
690+
let bob_addr = rig.spaced.client.wallet_get_new_address(BOB, AddressKind::Space).await?;
691+
let transfer = wallet_do(
692+
rig,
693+
ALICE,
694+
vec![RpcWalletRequest::TransferPtr(TransferPtrParams {
695+
ptrs: vec![sptr],
696+
to: bob_addr.clone(),
697+
})],
698+
false,
699+
).await?;
700+
assert!(wallet_res_err(&transfer).is_ok(), "TransferPtr should succeed");
701+
mine_and_sync(rig, 1).await?;
702+
703+
let ptr_after_transfer = rig.spaced.client.get_ptr(sptr).await?
704+
.expect("ptr should exist after transfer");
705+
assert_eq!(ptr_after_transfer.ptrout.sptr.as_ref().unwrap().data, Some(test_data.clone()),
706+
"PTR data should persist after transfer without new data");
707+
println!("✓ PTR data persisted after transfer");
708+
709+
// Test 4: Update data with new value
710+
println!("\nTest 4: Update PTR data with new value");
711+
let new_data = b"Updated data!".to_vec();
712+
let update_data = wallet_do(
713+
rig,
714+
BOB,
715+
vec![RpcWalletRequest::SetPtrData(SetPtrDataParams {
716+
sptr,
717+
data: new_data.clone(),
718+
})],
719+
false,
720+
).await?;
721+
assert!(wallet_res_err(&update_data).is_ok(), "SetPtrData should succeed");
722+
mine_and_sync(rig, 1).await?;
723+
724+
let ptr_updated = rig.spaced.client.get_ptr(sptr).await?
725+
.expect("ptr should exist");
726+
assert_eq!(ptr_updated.ptrout.sptr.as_ref().unwrap().data, Some(new_data.clone()), "PTR data should be updated");
727+
println!("✓ PTR data updated successfully: {:?}", String::from_utf8_lossy(&new_data));
728+
729+
// Test 5: Set empty data
730+
println!("\nTest 5: Set empty data");
731+
let empty_data = Vec::new();
732+
let set_empty = wallet_do(
733+
rig,
734+
BOB,
735+
vec![RpcWalletRequest::SetPtrData(SetPtrDataParams {
736+
sptr,
737+
data: empty_data.clone(),
738+
})],
739+
false,
740+
).await?;
741+
assert!(wallet_res_err(&set_empty).is_ok(), "SetPtrData with empty data should succeed");
742+
mine_and_sync(rig, 1).await?;
743+
744+
let ptr_empty = rig.spaced.client.get_ptr(sptr).await?
745+
.expect("ptr should exist");
746+
assert_eq!(ptr_empty.ptrout.sptr.as_ref().unwrap().data, Some(empty_data), "PTR data should be set to empty");
747+
println!("✓ PTR data set to empty successfully");
748+
749+
Ok(())
750+
}
751+
636752
// ============== Main Test Runner ==============
637753

638754
#[tokio::test]
@@ -666,6 +782,9 @@ async fn run_ptr_tests() -> anyhow::Result<()> {
666782
println!("\n=== Running PTR n→n Transfer Rule Tests ===");
667783
it_should_transfer_ptr_with_n_to_n_rule(&rig).await?;
668784

785+
println!("\n=== Running PTR Data Tests ===");
786+
it_should_set_and_persist_ptr_data(&rig).await?;
787+
669788
println!("\n=== All tests passed! ===");
670789
Ok(())
671790
}

ptr/src/lib.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -679,12 +679,8 @@ pub fn parse_ptr_op(tx: &Transaction) -> Option<PtrOp> {
679679
Some(PtrOp::Commitment(CommitmentOp::Commit(commitments)))
680680
}
681681
x if x == PtrOpType::Data as u8 => {
682-
// Data: marker + payload
682+
// Data: marker + payload (payload can be empty)
683683
let payload = &bytes[1..];
684-
if payload.is_empty() {
685-
return None;
686-
}
687-
688684
Some(PtrOp::Data(payload.as_bytes().to_vec()))
689685
}
690686
_ => None,

wallet/src/builder.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use spaces_protocol::{
2727
Covenant, FullSpaceOut, Space,
2828
};
2929
use spaces_protocol::hasher::Hash;
30-
use spaces_ptr::{create_commitment_script, CommitmentOp, FullPtrOut};
30+
use spaces_ptr::{create_commitment_script, create_data_script, CommitmentOp, FullPtrOut};
3131
use crate::{
3232
address::SpaceAddress, tx_event::TxRecord, DoubleUtxo, FullTxOut, SpaceScriptSigningInfo,
3333
SpacesWallet,
@@ -50,6 +50,9 @@ pub struct Builder {
5050
/// e.g. opens for name that already exist ... etc.
5151
/// enable only for testing purposes!
5252
force: bool,
53+
54+
/// Optional data to attach to PTR transfers via OP_RETURN
55+
ptr_data: Option<Vec<u8>>,
5356
}
5457

5558
pub struct BuilderIterator<'a> {
@@ -168,6 +171,7 @@ pub struct PtrParams {
168171
transfers: Vec<PtrTransfer>,
169172
binds: Vec<PtrRequest>,
170173
commitments: Vec<CommitmentRequest>,
174+
data: Option<Vec<u8>>,
171175
}
172176

173177
#[derive(Clone, Debug)]
@@ -717,6 +721,7 @@ impl Builder {
717721
fee_rate: None,
718722
bidouts: None,
719723
force: false,
724+
ptr_data: None,
720725
}
721726
}
722727

@@ -771,6 +776,11 @@ impl Builder {
771776
self
772777
}
773778

779+
pub fn add_ptr_data(mut self, data: Vec<u8>) -> Self {
780+
self.ptr_data = Some(data);
781+
self
782+
}
783+
774784
pub fn add_ptr(mut self, request: PtrRequest) -> Self {
775785
self.requests.push(StackRequest::Ptr(request));
776786
self
@@ -913,6 +923,7 @@ impl Builder {
913923
transfers: ptr_transfers,
914924
binds: ptrs,
915925
commitments,
926+
data: self.ptr_data.clone(),
916927
};
917928
stack.push(StackOp::Ptr(params))
918929
}
@@ -981,6 +992,8 @@ impl Builder {
981992
return Ok(signed);
982993
}
983994

995+
let has_transfers = !params.transfers.is_empty();
996+
984997
// Handle transfers:
985998
for transfer in params.transfers {
986999
let outpoint = OutPoint {
@@ -1003,6 +1016,14 @@ impl Builder {
10031016
);
10041017
}
10051018

1019+
// Add data OP_RETURN if present (only makes sense with transfers)
1020+
if let Some(data) = params.data {
1021+
if has_transfers {
1022+
let script = create_data_script(&data);
1023+
builder.add_recipient(script, Amount::from_sat(0));
1024+
}
1025+
}
1026+
10061027
let psbt = builder.finish()?;
10071028
let signed = w.sign(psbt, None)?;
10081029
Ok(signed)

0 commit comments

Comments
 (0)