Skip to content

Instantly share code, notes, and snippets.

@biryukovmaxim
Last active June 22, 2025 14:26
Show Gist options
  • Save biryukovmaxim/6c6f4efbf4d05940ea9190c49673c297 to your computer and use it in GitHub Desktop.
Save biryukovmaxim/6c6f4efbf4d05940ea9190c49673c297 to your computer and use it in GitHub Desktop.
Tracking sender address
[package]
name = "tx-tracking"
version = "0.1.0"
edition = "2024"
[dependencies]
kaspa-addresses = { git = "https://github.com/kaspanet/rusty-kaspa.git" }
kaspa-consensus-core = { git = "https://github.com/kaspanet/rusty-kaspa.git" }
kaspa-txscript = { git = "https://github.com/kaspanet/rusty-kaspa.git" }
kaspa-wrpc-client = { git = "https://github.com/kaspanet/rusty-kaspa.git" }
anyhow = "1.0.98"
async-channel = "2.3.1"
faster-hex = "0.10.0"
hex-literal = "1.0.0"
rand = "0.8.5"
secp256k1 = { version = "0.29.0", features = ["rand"] }
tokio = { version = "1.45.1", features = ["rt-multi-thread", "macros"] }
use anyhow::{Result, bail};
use hex_literal::hex;
use kaspa_addresses::{Address, Prefix, Version};
use kaspa_consensus_core::config::params::TESTNET_PARAMS;
use kaspa_consensus_core::hashing::sighash::{
SigHashReusedValuesUnsync, calc_schnorr_signature_hash,
};
use kaspa_consensus_core::hashing::sighash_type::SIG_HASH_ALL;
use kaspa_consensus_core::mass::{MassCalculator, NonContextualMasses};
use kaspa_consensus_core::tx::{
MutableTransaction, Transaction, TransactionInput, TransactionOutpoint, TransactionOutput,
UtxoEntry,
};
use kaspa_txscript::opcodes::codes::*;
use kaspa_txscript::pay_to_script_hash_script;
use kaspa_txscript::script_builder::{ScriptBuilder, ScriptBuilderResult};
use kaspa_wrpc_client::prelude::{
BlockAddedScope, ChannelConnection, ChannelType, NetworkId, NetworkType, Notification,
RpcAddress, RpcTransaction, RpcUtxosByAddressesEntry, Scope,
};
use kaspa_wrpc_client::{KaspaRpcClient, Resolver, WrpcEncoding};
use secp256k1::{Keypair, Secp256k1, SecretKey, XOnlyPublicKey};
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<()> {
// generate a random keypair
// let kp = generate_new_kp();
let kp = kp_from_hex();
let r_script = redeem_script(kp.public_key().x_only_public_key().0)?;
let spk = pay_to_script_hash_script(&r_script);
let address = Address::new(Prefix::Testnet, Version::ScriptHash, &spk.script()[2..34]);
println!("address: {}", address);
let client = Arc::new(rpc_client().await?);
let r_script: Arc<[u8]> = r_script.into();
let found_tx = listen_blocks(client.clone(), r_script.clone()).await?;
let utxos = client
.rpc_api()
.get_utxos_by_addresses(vec![RpcAddress::from(address)])
.await?;
// println!("utxos: {:?}", utxos.iter().map(|utxo| {
// (utxo.utxo_entry.amount, utxo.outpoint)
// }).collect::<Vec<_>>());
if utxos.is_empty() {
bail!("no utxos found for address")
}
let utxo = utxos.first().unwrap();
let tx = build_simple_tx(&kp, utxo, r_script.as_ref())?;
println!("trying to submit tx: {:?}", tx.id());
client
.rpc_api()
.submit_transaction(RpcTransaction::from(&tx), false)
.await?;
found_tx.await?;
Ok(())
}
#[allow(dead_code)]
fn generate_new_kp() -> Keypair {
let secp = Secp256k1::new();
let kp = Keypair::new(&secp, &mut rand::thread_rng());
println!(
"private key: {}",
faster_hex::hex_string(&kp.secret_bytes())
);
kp
}
fn kp_from_hex() -> Keypair {
let secp = Secp256k1::new();
// idc, it's an ephemeral key that I generated for test only and mined against it
let priv_bytes = hex!("b69a234f4453ddd584f882b2d13c2ec656eb61ebc59c5505a06509c9d24b1509");
Keypair::from_secret_key(&secp, &SecretKey::from_slice(&priv_bytes).unwrap())
}
fn redeem_script(xpub_key: XOnlyPublicKey) -> ScriptBuilderResult<Vec<u8>> {
let mut builder = ScriptBuilder::new();
let script = builder
.add_data(xpub_key.serialize().as_slice())?
.add_op(OpCheckSig)?
.drain();
Ok(script)
}
async fn rpc_client() -> Result<KaspaRpcClient> {
let client = KaspaRpcClient::new(
WrpcEncoding::Borsh,
None,
Some(Resolver::default()),
Some(NetworkId::with_suffix(NetworkType::Testnet, 10)),
None,
)?;
// Connect to Kaspa node
client.connect(None).await?;
Ok(client)
}
async fn listen_blocks(
rpc_client: Arc<KaspaRpcClient>,
redeem_script: Arc<[u8]>,
) -> Result<tokio::sync::oneshot::Receiver<RpcTransaction>> {
let (oneshot_tx, oneshot_rx) = tokio::sync::oneshot::channel();
let (sender, receiver) = async_channel::unbounded();
let listener_id = rpc_client
.rpc_api()
.register_new_listener(ChannelConnection::new(
"wrpc-example-subscriber",
sender,
ChannelType::Persistent,
));
rpc_client
.rpc_api()
.start_notify(listener_id, Scope::BlockAdded(BlockAddedScope {}))
.await?;
tokio::spawn({
let redeem_script = redeem_script.clone();
async move {
let tx = loop {
let Notification::BlockAdded(b) = receiver.recv().await? else {
continue;
};
let Some(tx) = b.block.transactions.iter().find_map(|tx| {
tx.inputs
.iter()
.find(|input| {
let Some((_signature_data, redeem_script_data)) = input.signature_script.split_at_checked(66) else {
return false;
};
const OP_DATA_34: u8 = OpData34; // to avoid clippy warning
matches!(redeem_script_data, [OP_DATA_34, actual_redeem_script @ ..] if actual_redeem_script == redeem_script.as_ref())
})
.map(|_| tx.clone())
}) else {
continue;
};
break tx;
};
println!("found expected tx: {:?}", tx);
println!("payload: {}", String::from_utf8_lossy(&tx.payload));
oneshot_tx.send(tx).unwrap();
Ok::<_, anyhow::Error>(())
}
});
Ok(oneshot_rx)
}
fn build_simple_tx(
keypair: &Keypair,
RpcUtxosByAddressesEntry {
outpoint,
utxo_entry,
..
}: &RpcUtxosByAddressesEntry,
redeem_script: &[u8],
) -> Result<Transaction> {
// Create a transaction input
let input = TransactionInput {
previous_outpoint: TransactionOutpoint {
transaction_id: outpoint.transaction_id,
index: outpoint.index,
},
signature_script: vec![0; 65 + 2 + redeem_script.len()], // todo replace signature
sequence: 0,
sig_op_count: 1,
};
let output = TransactionOutput {
value: utxo_entry.amount, // todo decrease me
script_public_key: utxo_entry.script_public_key.clone(),
};
// Create a transaction with the input and output
let tx = Transaction::new_non_finalized(
0,
vec![input],
vec![output],
0,
Default::default(),
0,
b"hello world".to_vec(),
);
let utxo = UtxoEntry {
amount: utxo_entry.amount,
script_public_key: utxo_entry.script_public_key.clone(),
block_daa_score: utxo_entry.block_daa_score,
is_coinbase: utxo_entry.is_coinbase,
};
let mut mutable_tx = MutableTransaction::with_entries(tx, vec![utxo]);
let calculator = MassCalculator::new_with_consensus_params(&TESTNET_PARAMS);
let storage_mass = calculator
.calc_contextual_masses(&mutable_tx.as_verifiable())
.map(|mass| mass.storage_mass)
.unwrap_or_default();
let NonContextualMasses {
compute_mass,
transient_mass,
} = calculator.calc_non_contextual_masses(&mutable_tx.tx);
let mass = storage_mass.max(compute_mass).max(transient_mass);
mutable_tx.tx.set_mass(mass);
mutable_tx.tx.outputs[0].value = utxo_entry.amount - mass;
mutable_tx.tx.finalize();
let reused_values = SigHashReusedValuesUnsync::new();
let sig_hash =
calc_schnorr_signature_hash(&mutable_tx.as_verifiable(), 0, SIG_HASH_ALL, &reused_values);
let msg = secp256k1::Message::from_digest_slice(sig_hash.as_bytes().as_slice())?;
let sig = keypair.sign_schnorr(msg);
let mut signature = Vec::new();
signature.extend_from_slice(sig.as_ref().as_slice());
signature.push(SIG_HASH_ALL.to_u8());
let mut builder = ScriptBuilder::new();
builder.add_data(&signature)?;
builder.add_data(redeem_script)?;
mutable_tx.tx.inputs[0].signature_script = builder.drain();
Ok(mutable_tx.tx)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_script() {
let script_signature = hex!(
"41f9431ea0783fc628e8e510a50a0ae54b954c42172bcc3affe6617c1a1cff755b9f82349f86c6d267380e81bb6f0895f71d1a196e32843a71896fff7f5b5712fe012220798fbf8c935fd45ea8e3154493d4cceed48bd8a30f03ae819e25300ee2588e92ac"
);
let kp = kp_from_hex();
let pubkey = kp.x_only_public_key().0;
let r_script = redeem_script(pubkey).unwrap();
dbg!(r_script.len());
let (signature_data, redeem_script_data) = script_signature.split_at_checked(66).unwrap();
assert_eq!(signature_data[0], OpData65);
assert_eq!(signature_data[65], SIG_HASH_ALL.to_u8());
const OP_DATA_34: u8 = OpData34; // to avoid clippy warning
assert!(
matches!(redeem_script_data, [OP_DATA_34, actual_redeem_script @ ..] if actual_redeem_script == r_script)
)
}
}
@biryukovmaxim
Copy link
Author

biryukovmaxim commented Jun 22, 2025

https://gist.github.com/biryukovmaxim/6c6f4efbf4d05940ea9190c49673c297#file-main-rs-L135-L136

that means 1: we found the exactly our redeem script + enough bytes for signature. inclusion of such tx to block = signature is valid according to node that validated the block. if you dont trust the rpc - verify signature against pubkey manually: signature - first 64 bytes of script signature. pubkey is first 32 bytes of redeem script. But you need to track utxos of the address to validate that. You can ask multiple rpc to see if they know output (utxo) which means there is a consensus against the tx

but u can simply run ur node and trust it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment