#![cfg(all(feature = "mrc20", not(target_arch = "wasm32")))]
#![warn(missing_docs)]
use std::str::FromStr;
use bitcoin::consensus::encode::serialize_hex;
use bitcoin::hashes::Hash;
use bitcoin::key::{Keypair, TapTweak, TweakedPublicKey};
use bitcoin::secp256k1::{rand, schnorr, Message, Secp256k1, SecretKey, XOnlyPublicKey};
use bitcoin::sighash::{Prevouts, SighashCache, TapSighashType};
use bitcoin::{
absolute, transaction, Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid,
Witness,
};
use serde_json::json;
use crate::blocktrail::{
is_gitmark_commit, network_for_txo_chain, txo_chain_for_network, Blocktrail, BlocktrailTxo,
};
use crate::mrc20::{
bt_address, bt_base_point, bt_derive_chained_privkey, bt_derive_chained_pubkey, jcs,
sha256_hex, MempoolLookup, Mrc20Op, Mrc20State, Mrc20Trail, TxIn as TxInView, TxInfo,
TxOut as TxOutView, MRC20_PROFILE, TRANSFER_OP,
};
use crate::payments::PaymentError;
pub const DEFAULT_FEE_SATS: u64 = 300;
pub const DUST_LIMIT_SATS: u64 = 546;
const INPUT_SEQUENCE: Sequence = Sequence::ENABLE_RBF_NO_LOCKTIME;
fn invalid(msg: impl std::fmt::Display) -> PaymentError {
PaymentError::InvalidState(msg.to_string())
}
fn parse_xonly(xonly: &[u8]) -> Result<XOnlyPublicKey, PaymentError> {
if xonly.len() != 32 {
return Err(invalid(format!(
"x-only pubkey must be 32 bytes, got {}",
xonly.len()
)));
}
XOnlyPublicKey::from_slice(xonly).map_err(|e| invalid(format!("bad x-only pubkey: {e}")))
}
fn parse_secret(privkey: &[u8]) -> Result<SecretKey, PaymentError> {
if privkey.len() != 32 {
return Err(invalid(format!(
"privkey must be 32 bytes, got {}",
privkey.len()
)));
}
SecretKey::from_slice(privkey).map_err(|e| invalid(format!("bad privkey: {e}")))
}
pub fn p2tr_script(xonly: &[u8]) -> Result<Vec<u8>, PaymentError> {
let key = parse_xonly(xonly)?;
Ok(ScriptBuf::new_p2tr_tweaked(TweakedPublicKey::dangerous_assume_tweaked(key)).into_bytes())
}
fn xonly_of(privkey: &[u8]) -> Result<[u8; 32], PaymentError> {
let secp = Secp256k1::signing_only();
let sk = parse_secret(privkey)?;
Ok(sk.x_only_public_key(&secp).0.serialize())
}
#[derive(Debug, Clone)]
pub struct TxInput {
pub txid: String,
pub vout: u32,
pub amount: u64,
pub script_pubkey: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct TxOutput {
pub amount: u64,
pub script_pubkey: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct BuiltTx {
pub txid: String,
pub raw_hex: String,
pub unsigned_hex: String,
pub sighashes: Vec<String>,
pub signatures: Vec<String>,
pub signing_xonly: String,
}
fn keypath_signing_secret(privkey: &[u8], needs_tweak: bool) -> Result<[u8; 32], PaymentError> {
let secp = Secp256k1::new();
let keypair = Keypair::from_secret_key(&secp, &parse_secret(privkey)?);
if !needs_tweak {
return Ok(keypair.secret_bytes());
}
let (internal, _parity) = keypair.x_only_public_key();
let tweak = bitcoin::taproot::TapTweakHash::from_key_and_tweak(internal, None).to_scalar();
let tweaked = keypair
.add_xonly_tweak(&secp, &tweak)
.map_err(|e| invalid(format!("taproot tweak failed: {e}")))?;
debug_assert_eq!(
tweaked.x_only_public_key().0,
internal.tap_tweak(&secp, None).0.to_x_only_public_key(),
"secret and public tweak must agree"
);
Ok(tweaked.secret_bytes())
}
fn schnorr_sign_zero_aux(secret: &[u8; 32], msg: &[u8; 32]) -> Result<[u8; 64], PaymentError> {
let secp = Secp256k1::signing_only();
let keypair = Keypair::from_seckey_slice(&secp, secret)
.map_err(|e| invalid(format!("invalid signing scalar: {e}")))?;
let sig = secp.sign_schnorr_with_aux_rand(&Message::from_digest(*msg), &keypair, &[0u8; 32]);
Ok(*sig.as_ref())
}
pub fn build_transaction(
inputs: &[TxInput],
outputs: &[TxOutput],
privkey: &[u8],
) -> Result<BuiltTx, PaymentError> {
if inputs.is_empty() {
return Err(invalid("transaction has no inputs"));
}
let untweaked_spk = p2tr_script(&xonly_of(privkey)?)?; let needs_tweak = inputs[0].script_pubkey != untweaked_spk;
let signing_secret = keypath_signing_secret(privkey, needs_tweak)?;
let secp = Secp256k1::signing_only();
let signing_xonly = Keypair::from_seckey_slice(&secp, &signing_secret)
.map_err(|e| invalid(format!("invalid signing scalar: {e}")))?
.x_only_public_key()
.0
.serialize();
let mut tx_inputs = Vec::with_capacity(inputs.len());
let mut spent = Vec::with_capacity(inputs.len());
for i in inputs {
let txid = Txid::from_str(&i.txid).map_err(|e| invalid(format!("bad txid hex: {e}")))?;
tx_inputs.push(TxIn {
previous_output: OutPoint::new(txid, i.vout),
script_sig: ScriptBuf::new(),
sequence: INPUT_SEQUENCE,
witness: Witness::new(),
});
spent.push(TxOut {
value: amount(i.amount)?,
script_pubkey: ScriptBuf::from_bytes(i.script_pubkey.clone()),
});
}
let tx_outputs = outputs
.iter()
.map(|o| {
Ok(TxOut {
value: amount(o.amount)?,
script_pubkey: ScriptBuf::from_bytes(o.script_pubkey.clone()),
})
})
.collect::<Result<Vec<_>, PaymentError>>()?;
let mut tx = Transaction {
version: transaction::Version::TWO,
lock_time: absolute::LockTime::ZERO,
input: tx_inputs,
output: tx_outputs,
};
let unsigned_hex = serialize_hex(&tx);
let prevouts = Prevouts::All(&spent);
let mut sighashes = Vec::with_capacity(inputs.len());
let mut signatures = Vec::with_capacity(inputs.len());
{
let mut cache = SighashCache::new(&tx);
for index in 0..inputs.len() {
let sighash = cache
.taproot_key_spend_signature_hash(index, &prevouts, TapSighashType::Default)
.map_err(|e| invalid(format!("taproot sighash: {e}")))?
.to_byte_array();
signatures.push(schnorr_sign_zero_aux(&signing_secret, &sighash)?);
sighashes.push(sighash);
}
}
for (txin, sig) in tx.input.iter_mut().zip(&signatures) {
let signature = schnorr::Signature::from_slice(sig)
.map_err(|e| invalid(format!("bad signature: {e}")))?;
txin.witness = Witness::p2tr_key_spend(&bitcoin::taproot::Signature {
signature,
sighash_type: TapSighashType::Default,
});
}
Ok(BuiltTx {
txid: tx.compute_txid().to_string(),
raw_hex: serialize_hex(&tx),
unsigned_hex,
sighashes: sighashes.iter().map(hex::encode).collect(),
signatures: signatures.iter().map(hex::encode).collect(),
signing_xonly: hex::encode(signing_xonly),
})
}
fn amount(sats: u64) -> Result<Amount, PaymentError> {
let a = Amount::from_sat(sats);
if a > Amount::MAX_MONEY {
return Err(invalid(format!("{sats} sats exceeds the money range")));
}
Ok(a)
}
pub fn verify_keypath_signature(
xonly_hex: &str,
sighash_hex: &str,
sig_hex: &str,
) -> Result<bool, PaymentError> {
let xonly = hex::decode(xonly_hex).map_err(|e| invalid(format!("bad xonly hex: {e}")))?;
let sighash = hex::decode(sighash_hex).map_err(|e| invalid(format!("bad sighash hex: {e}")))?;
let sig_bytes = hex::decode(sig_hex).map_err(|e| invalid(format!("bad sig hex: {e}")))?;
let key = parse_xonly(&xonly)?;
let msg = Message::from_digest_slice(&sighash)
.map_err(|e| invalid(format!("sighash must be 32 bytes: {e}")))?;
let sig = schnorr::Signature::from_slice(&sig_bytes)
.map_err(|e| invalid(format!("bad signature: {e}")))?;
Ok(Secp256k1::verification_only()
.verify_schnorr(&sig, &msg, &key)
.is_ok())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TxoVoucher {
pub txid: String,
pub vout: u32,
pub amount: u64,
pub privkey: String,
}
pub fn parse_txo_voucher(uri: &str) -> Result<TxoVoucher, PaymentError> {
let s = uri.trim();
let body = if let Some(rest) = s.strip_prefix("txo:") {
match rest.split_once(':') {
Some((_chain, after)) => after,
None => rest,
}
} else {
s
};
let (txid, tail) = body
.split_once(':')
.ok_or_else(|| PaymentError::InvalidTxo("voucher: missing ':vout'".into()))?;
let (vout_str, query) = tail
.split_once('?')
.ok_or_else(|| PaymentError::InvalidTxo("voucher: missing '?amount='".into()))?;
if txid.len() != 64 || !txid.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(PaymentError::InvalidTxo(
"voucher: txid must be 64 hex chars".into(),
));
}
let vout: u32 = vout_str
.parse()
.map_err(|_| PaymentError::InvalidTxo("voucher: bad vout".into()))?;
let mut amount: Option<u64> = None;
let mut privkey: Option<String> = None;
for kv in query.split('&') {
if let Some(v) = kv.strip_prefix("amount=") {
amount = Some(
v.parse()
.map_err(|_| PaymentError::InvalidTxo("voucher: bad amount".into()))?,
);
} else if let Some(v) = kv.strip_prefix("key=") {
if v.len() != 64 || !v.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(PaymentError::InvalidTxo(
"voucher: key must be 64 hex chars".into(),
));
}
privkey = Some(v.to_string());
}
}
Ok(TxoVoucher {
txid: txid.to_string(),
vout,
amount: amount.ok_or_else(|| PaymentError::InvalidTxo("voucher: missing amount".into()))?,
privkey: privkey.ok_or_else(|| PaymentError::InvalidTxo("voucher: missing key".into()))?,
})
}
#[async_trait::async_trait(?Send)]
pub trait MempoolBroadcast {
async fn broadcast_tx(&self, raw_hex: &str) -> Result<String, PaymentError>;
}
#[derive(Debug, Clone)]
pub struct TrailUpdate {
pub tx: BuiltTx,
pub trail: Mrc20Trail,
pub state: Mrc20State,
pub state_jcs: String,
pub address: String,
pub output_amount: u64,
}
pub async fn mint_token(
ticker: &str,
name: Option<&str>,
supply: u64,
voucher: &TxoVoucher,
network: &str,
fee_sats: u64,
mempool: &dyn MempoolLookup,
) -> Result<TrailUpdate, PaymentError> {
let privkey = hex::decode(&voucher.privkey)
.map_err(|e| PaymentError::InvalidState(format!("bad voucher key: {e}")))?;
let sk = parse_secret(&privkey)?;
let pubkey_base_hex = hex::encode(sk.public_key(&Secp256k1::signing_only()).serialize());
let mut balances = std::collections::BTreeMap::new();
balances.insert(pubkey_base_hex.clone(), supply);
let genesis = Mrc20State {
profile: MRC20_PROFILE.into(),
prev: "0".repeat(64),
seq: 0,
ticker: Some(ticker.to_string()),
name: Some(name.unwrap_or(ticker).to_string()),
decimals: Some(0),
supply: Some(supply),
balances: Some(balances),
ops: vec![],
anchor: None,
};
let genesis_jcs = jcs(&serde_json::to_value(&genesis).map_err(serde_err)?);
let genesis_strings = std::slice::from_ref(&genesis_jcs);
let genesis_xonly = chained_xonly(&pubkey_base_hex, genesis_strings)?;
let genesis_script = p2tr_script(&genesis_xonly)?;
let genesis_addr = bt_address(&pubkey_base_hex, genesis_strings, network)?;
let script_pubkey = fetch_output_spk(mempool, &voucher.txid, voucher.vout).await?;
let output_amount = checked_output(voucher.amount, fee_sats)?;
let tx = build_transaction(
&[TxInput {
txid: voucher.txid.clone(),
vout: voucher.vout,
amount: voucher.amount,
script_pubkey,
}],
&[TxOutput {
amount: output_amount,
script_pubkey: genesis_script,
}],
&privkey,
)?;
let trail = Mrc20Trail {
ticker: ticker.to_string(),
name: name.unwrap_or(ticker).to_string(),
supply,
pubkey_base: pubkey_base_hex,
states: vec![genesis.clone()],
state_strings: vec![genesis_jcs.clone()],
current_txid: String::new(), current_vout: 0,
current_amount: output_amount,
network: network.to_string(),
date_created: String::new(), };
Ok(TrailUpdate {
tx,
trail,
state: genesis,
state_jcs: genesis_jcs,
address: genesis_addr,
output_amount,
})
}
pub async fn transfer_token_with_key(
trail: &Mrc20Trail,
issuer_privkey_hex: &str,
from: Option<&str>,
to: &str,
amount: u64,
fee_sats: u64,
mempool: &dyn MempoolLookup,
) -> Result<TrailUpdate, PaymentError> {
let current_state = trail
.states
.last()
.ok_or_else(|| PaymentError::InvalidState("trail has no states".into()))?;
let mut balances = current_state.balances.clone().unwrap_or_default();
let sender = from.unwrap_or(&trail.pubkey_base).to_string();
let sender_balance = balances.get(&sender).copied().unwrap_or(0);
if sender_balance < amount {
return Err(PaymentError::InsufficientBalance {
balance: sender_balance,
cost: amount,
});
}
balances.insert(sender.clone(), sender_balance - amount);
let to_balance = balances.get(to).copied().unwrap_or(0);
balances.insert(to.to_string(), to_balance + amount);
balances.retain(|_, v| *v != 0);
let prev_jcs = trail
.state_strings
.last()
.ok_or_else(|| PaymentError::InvalidState("trail has no state strings".into()))?;
let new_state = Mrc20State {
profile: MRC20_PROFILE.into(),
prev: sha256_hex(prev_jcs),
seq: current_state.seq + 1,
ticker: trail.states.first().and_then(|s| s.ticker.clone()),
name: Some(trail.name.clone()),
decimals: Some(0),
supply: Some(trail.supply),
balances: Some(balances),
ops: vec![Mrc20Op {
op: TRANSFER_OP.into(),
from: Some(sender),
to: Some(to.to_string()),
amt: Some(amount),
}],
anchor: None,
};
let new_jcs = jcs(&serde_json::to_value(&new_state).map_err(serde_err)?);
let mut all_strings = trail.state_strings.clone();
all_strings.push(new_jcs.clone());
let new_xonly = chained_xonly(&trail.pubkey_base, &all_strings)?;
let new_script = p2tr_script(&new_xonly)?;
let new_addr = bt_address(&trail.pubkey_base, &all_strings, &trail.network)?;
let script_pubkey = fetch_output_spk(mempool, &trail.current_txid, trail.current_vout).await?;
let chained_priv = bt_derive_chained_privkey(issuer_privkey_hex, &trail.state_strings)?;
let output_amount = checked_output(trail.current_amount, fee_sats)?;
let tx = build_transaction(
&[TxInput {
txid: trail.current_txid.clone(),
vout: trail.current_vout,
amount: trail.current_amount,
script_pubkey,
}],
&[TxOutput {
amount: output_amount,
script_pubkey: new_script,
}],
&chained_priv,
)?;
let mut updated = trail.clone();
updated.states.push(new_state.clone());
updated.state_strings.push(new_jcs.clone());
updated.current_txid = String::new();
updated.current_vout = 0;
updated.current_amount = output_amount;
Ok(TrailUpdate {
tx,
trail: updated,
state: new_state,
state_jcs: new_jcs,
address: new_addr,
output_amount,
})
}
pub async fn anchor_state(
trail: &Mrc20Trail,
issuer_privkey_hex: &str,
state_hash: &str,
fee_sats: u64,
mempool: &dyn MempoolLookup,
) -> Result<TrailUpdate, PaymentError> {
let current_state = trail
.states
.last()
.ok_or_else(|| PaymentError::InvalidState("trail has no states".into()))?;
let prev_jcs = trail
.state_strings
.last()
.ok_or_else(|| PaymentError::InvalidState("trail has no state strings".into()))?;
let new_state = Mrc20State {
profile: MRC20_PROFILE.into(),
prev: sha256_hex(prev_jcs),
seq: current_state.seq + 1,
ticker: trail.states.first().and_then(|s| s.ticker.clone()),
name: Some(trail.name.clone()),
decimals: Some(0),
supply: Some(trail.supply),
balances: current_state.balances.clone(),
ops: vec![Mrc20Op {
op: "urn:mono:op:anchor".into(),
from: None,
to: None,
amt: None,
}],
anchor: Some(state_hash.to_string()),
};
let new_jcs = jcs(&serde_json::to_value(&new_state).map_err(serde_err)?);
let mut all_strings = trail.state_strings.clone();
all_strings.push(new_jcs.clone());
let new_xonly = chained_xonly(&trail.pubkey_base, &all_strings)?;
let new_script = p2tr_script(&new_xonly)?;
let new_addr = bt_address(&trail.pubkey_base, &all_strings, &trail.network)?;
let script_pubkey = fetch_output_spk(mempool, &trail.current_txid, trail.current_vout).await?;
let chained_priv = bt_derive_chained_privkey(issuer_privkey_hex, &trail.state_strings)?;
let output_amount = checked_output(trail.current_amount, fee_sats)?;
let tx = build_transaction(
&[TxInput {
txid: trail.current_txid.clone(),
vout: trail.current_vout,
amount: trail.current_amount,
script_pubkey,
}],
&[TxOutput {
amount: output_amount,
script_pubkey: new_script,
}],
&chained_priv,
)?;
let mut updated = trail.clone();
updated.states.push(new_state.clone());
updated.state_strings.push(new_jcs.clone());
updated.current_txid = String::new();
updated.current_vout = 0;
updated.current_amount = output_amount;
Ok(TrailUpdate {
tx,
trail: updated,
state: new_state,
state_jcs: new_jcs,
address: new_addr,
output_amount,
})
}
#[derive(Debug, Clone)]
pub struct GitmarkUpdate {
pub tx: BuiltTx,
pub trail: Blocktrail,
pub txo: BlocktrailTxo,
pub address: String,
pub output_amount: u64,
}
fn check_commit(commit: &str) -> Result<(), PaymentError> {
if is_gitmark_commit(commit) {
Ok(())
} else {
Err(invalid(
"a git-mark state is a commit hash: 40 (or 64) lowercase hex characters",
))
}
}
pub async fn gitmark_genesis(
voucher: &TxoVoucher,
commit: &str,
network: &str,
fee_sats: u64,
mempool: &dyn MempoolLookup,
) -> Result<GitmarkUpdate, PaymentError> {
check_commit(commit)?;
let privkey = hex::decode(&voucher.privkey)
.map_err(|e| PaymentError::InvalidState(format!("bad voucher key: {e}")))?;
let sk = parse_secret(&privkey)?;
let base = hex::encode(sk.public_key(&Secp256k1::signing_only()).serialize());
let commits = vec![commit.to_string()];
let script_pubkey = fetch_output_spk(mempool, &voucher.txid, voucher.vout).await?;
let output_amount = checked_output(voucher.amount, fee_sats)?;
let tx = build_transaction(
&[TxInput {
txid: voucher.txid.clone(),
vout: voucher.vout,
amount: voucher.amount,
script_pubkey,
}],
&[TxOutput {
amount: output_amount,
script_pubkey: p2tr_script(&chained_xonly(&base, &commits)?)?,
}],
&privkey,
)?;
let chain = txo_chain_for_network(network);
let txo = BlocktrailTxo::new(chain.clone(), tx.txid.clone(), 0)
.with_amount(output_amount)
.with_commit(commit);
Ok(GitmarkUpdate {
address: bt_address(&base, &commits, network)?,
trail: Blocktrail::gitmark(base, chain, commits, vec![txo.clone()]),
txo,
tx,
output_amount,
})
}
pub async fn gitmark_advance(
trail: &Blocktrail,
privkey_hex: &str,
commit: &str,
fee_sats: u64,
mempool: &dyn MempoolLookup,
) -> Result<GitmarkUpdate, PaymentError> {
check_commit(commit)?;
let base = trail
.pubkey_base
.as_deref()
.ok_or_else(|| invalid("the trail has no base key (pubkeyBase)"))?;
let base_point = bt_base_point(base)?;
let privkey = hex::decode(privkey_hex).map_err(|e| invalid(format!("bad privkey hex: {e}")))?;
let own = parse_secret(&privkey)?
.public_key(&Secp256k1::signing_only())
.serialize();
if own != base_point {
return Err(invalid("the secret is not the trail's base key"));
}
let head = trail
.txo
.last()
.ok_or_else(|| invalid("the trail has no marks: start it with gitmark_genesis"))?;
let commits = trail.state_strings().map_err(invalid)?;
let chain = trail
.chain
.clone()
.or_else(|| head.chain.clone())
.ok_or_else(|| invalid("the trail names no chain"))?;
let network = network_for_txo_chain(&chain);
let script_pubkey = fetch_output_spk(mempool, &head.txid, head.vout).await?;
if script_pubkey != p2tr_script(&chained_xonly(base, &commits)?)? {
return Err(invalid(format!(
"mark {} ({}) is not the key the trail's states derive: refusing to extend it",
commits.len() - 1,
head.outpoint()
)));
}
let input_amount = match head.amount {
Some(a) => a,
None => mempool
.tx(&head.txid)
.await?
.vout
.get(head.vout as usize)
.map(|o| o.value)
.ok_or_else(|| invalid(format!("output {} not found", head.outpoint())))?,
};
let mut next = commits.clone();
next.push(commit.to_string());
let output_amount = checked_output(input_amount, fee_sats)?;
let tx = build_transaction(
&[TxInput {
txid: head.txid.clone(),
vout: head.vout,
amount: input_amount,
script_pubkey,
}],
&[TxOutput {
amount: output_amount,
script_pubkey: p2tr_script(&chained_xonly(base, &next)?)?,
}],
&bt_derive_chained_privkey(privkey_hex, &commits)?,
)?;
let txo = BlocktrailTxo::new(chain, tx.txid.clone(), 0)
.with_amount(output_amount)
.with_commit(commit);
let mut updated = trail.clone();
updated.states = next
.iter()
.cloned()
.map(serde_json::Value::String)
.collect();
updated.txo.push(txo.clone());
Ok(GitmarkUpdate {
address: bt_address(base, &next, &network)?,
trail: updated,
txo,
tx,
output_amount,
})
}
pub fn decode_tx_info(raw_hex: &str) -> Result<TxInfo, PaymentError> {
let bytes = hex::decode(raw_hex).map_err(|e| invalid(format!("bad tx hex: {e}")))?;
let tx: Transaction = bitcoin::consensus::deserialize(&bytes)
.map_err(|e| invalid(format!("not a transaction: {e}")))?;
Ok(TxInfo {
txid: tx.compute_txid().to_string(),
vin: tx
.input
.iter()
.map(|i| TxInView {
txid: i.previous_output.txid.to_string(),
vout: i.previous_output.vout,
})
.collect(),
vout: tx
.output
.iter()
.map(|o| TxOutView {
value: o.value.to_sat(),
scriptpubkey: Some(hex::encode(o.script_pubkey.as_bytes())),
scriptpubkey_address: None,
})
.collect(),
confirmed: false,
block_height: None,
})
}
fn serde_err(e: serde_json::Error) -> PaymentError {
PaymentError::InvalidState(format!("serialize: {e}"))
}
fn chained_xonly(
pubkey_base_hex: &str,
state_strings: &[String],
) -> Result<[u8; 32], PaymentError> {
let chained = bt_derive_chained_pubkey(pubkey_base_hex, state_strings)?;
if chained.len() != 33 {
return Err(PaymentError::InvalidState(format!(
"expected 33-byte compressed chained key, got {}",
chained.len()
)));
}
let mut out = [0u8; 32];
out.copy_from_slice(&chained[1..]);
Ok(out)
}
fn checked_output(input_amount: u64, fee_sats: u64) -> Result<u64, PaymentError> {
let out = input_amount
.checked_sub(fee_sats)
.ok_or_else(|| PaymentError::InvalidState("input too small for fee".into()))?;
if out <= DUST_LIMIT_SATS {
return Err(PaymentError::InvalidState(format!(
"output {out} at/below dust limit {DUST_LIMIT_SATS}"
)));
}
Ok(out)
}
async fn fetch_output_spk(
mempool: &dyn MempoolLookup,
txid: &str,
vout: u32,
) -> Result<Vec<u8>, PaymentError> {
let tx = mempool.tx(txid).await?;
let out = tx
.vout
.get(vout as usize)
.ok_or_else(|| PaymentError::InvalidState(format!("output {vout} not found in {txid}")))?;
let spk_hex = out
.scriptpubkey
.as_ref()
.ok_or_else(|| PaymentError::InvalidState("output missing scriptpubkey".into()))?;
hex::decode(spk_hex)
.map_err(|e| PaymentError::InvalidState(format!("bad scriptpubkey hex: {e}")))
}
#[derive(Debug, Clone)]
pub struct WithdrawVoucher {
pub tx: BuiltTx,
pub voucher_privkey_hex: String,
pub amount: u64,
pub change: u64,
}
pub fn build_withdraw_voucher(
funding: &TxoVoucher,
funding_spk_hex: &str,
amount: u64,
fee_sats: u64,
) -> Result<WithdrawVoucher, PaymentError> {
let funding_spk = hex::decode(funding_spk_hex)
.map_err(|e| PaymentError::InvalidState(format!("bad funding scriptPubKey hex: {e}")))?;
let needed = amount
.checked_add(fee_sats)
.ok_or_else(|| PaymentError::InvalidState("amount + fee overflow".into()))?;
if funding.amount < needed {
return Err(PaymentError::InvalidState(format!(
"funding {} < needed {needed}",
funding.amount
)));
}
let voucher_sk = SecretKey::new(&mut rand::thread_rng());
let voucher_priv_hex = hex::encode(voucher_sk.secret_bytes());
let voucher_xonly = xonly_of(&voucher_sk.secret_bytes())?;
let voucher_script = p2tr_script(&voucher_xonly)?;
let funding_privkey = hex::decode(&funding.privkey)
.map_err(|e| PaymentError::InvalidTxo(format!("bad funding key: {e}")))?;
let funding_xonly = xonly_of(&funding_privkey)?;
let mut outputs = vec![TxOutput {
amount,
script_pubkey: voucher_script,
}];
let change = funding.amount - amount - fee_sats;
if change > DUST_LIMIT_SATS {
outputs.push(TxOutput {
amount: change,
script_pubkey: p2tr_script(&funding_xonly)?,
});
}
let tx = build_transaction(
&[TxInput {
txid: funding.txid.clone(),
vout: funding.vout,
amount: funding.amount,
script_pubkey: funding_spk,
}],
&outputs,
&funding_privkey,
)?;
Ok(WithdrawVoucher {
tx,
voucher_privkey_hex: voucher_priv_hex,
amount,
change: if change > DUST_LIMIT_SATS { change } else { 0 },
})
}
pub fn anchor_proof_json(trail: &Mrc20Trail) -> serde_json::Value {
json!({
"pubkey": trail.pubkey_base,
"stateStrings": trail.state_strings,
"network": trail.network,
})
}
#[cfg(test)]
mod tests {
use super::*;
const GOLDEN: &str = include_str!("../tests/fixtures/bitcoin/golden_tx.json");
fn golden() -> serde_json::Value {
serde_json::from_str(GOLDEN).unwrap()
}
#[test]
fn p2tr_script_shape() {
let xonly: [u8; 32] =
hex::decode("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798")
.unwrap()
.try_into()
.unwrap();
let s = p2tr_script(&xonly).unwrap();
assert_eq!(s.len(), 34);
assert_eq!(s[0], 0x51);
assert_eq!(s[1], 0x20);
assert_eq!(&s[2..], &xonly);
}
#[test]
fn p2tr_script_rejects_wrong_length() {
assert!(p2tr_script(&[0u8; 31]).is_err());
assert!(p2tr_script(&[0u8; 33]).is_err());
}
#[test]
fn golden_case_a_full_hex_matches_jss() {
let g = golden();
let a = &g["caseA"];
let input = TxInput {
txid: a["input"]["txid"].as_str().unwrap().into(),
vout: a["input"]["vout"].as_u64().unwrap() as u32,
amount: a["input"]["amount"].as_u64().unwrap(),
script_pubkey: hex::decode(a["input"]["scriptPubKey"].as_str().unwrap()).unwrap(),
};
let output = TxOutput {
amount: a["output"]["amount"].as_u64().unwrap(),
script_pubkey: hex::decode(a["output"]["scriptPubKey"].as_str().unwrap()).unwrap(),
};
let privkey = hex::decode(a["priv"].as_str().unwrap()).unwrap();
let built = build_transaction(&[input], &[output], &privkey).unwrap();
assert_eq!(
built.raw_hex,
a["raw"].as_str().unwrap(),
"case A raw tx hex must match JSS token.js (aux_rand=0)"
);
assert_eq!(built.sighashes[0], a["sighashes"][0].as_str().unwrap());
assert_eq!(built.signatures[0], a["sigs"][0].as_str().unwrap());
assert_eq!(built.unsigned_hex, a["unsigned"].as_str().unwrap());
}
#[test]
fn golden_case_b_tweaked_full_hex_matches_jss() {
let g = golden();
let b = &g["caseB"];
let input = TxInput {
txid: b["input"]["txid"].as_str().unwrap().into(),
vout: b["input"]["vout"].as_u64().unwrap() as u32,
amount: b["input"]["amount"].as_u64().unwrap(),
script_pubkey: hex::decode(b["input"]["scriptPubKey"].as_str().unwrap()).unwrap(),
};
let output = TxOutput {
amount: b["output"]["amount"].as_u64().unwrap(),
script_pubkey: hex::decode(b["output"]["scriptPubKey"].as_str().unwrap()).unwrap(),
};
let privkey = hex::decode(b["priv"].as_str().unwrap()).unwrap();
let built = build_transaction(&[input], &[output], &privkey).unwrap();
assert_eq!(
built.raw_hex,
b["raw"].as_str().unwrap(),
"case B (needsTweak) raw tx hex must match JSS"
);
assert_eq!(built.sighashes[0], b["sighashes"][0].as_str().unwrap());
assert_eq!(built.signatures[0], b["sigs"][0].as_str().unwrap());
}
#[test]
fn golden_case_c_multi_input_matches_jss() {
let g = golden();
let c = &g["caseC"];
let inputs: Vec<TxInput> = c["inputs"]
.as_array()
.unwrap()
.iter()
.map(|i| TxInput {
txid: i["txid"].as_str().unwrap().into(),
vout: i["vout"].as_u64().unwrap() as u32,
amount: i["amount"].as_u64().unwrap(),
script_pubkey: hex::decode(i["scriptPubKey"].as_str().unwrap()).unwrap(),
})
.collect();
let outputs: Vec<TxOutput> = c["outputs"]
.as_array()
.unwrap()
.iter()
.map(|o| TxOutput {
amount: o["amount"].as_u64().unwrap(),
script_pubkey: hex::decode(o["scriptPubKey"].as_str().unwrap()).unwrap(),
})
.collect();
let privkey = hex::decode(c["priv"].as_str().unwrap()).unwrap();
let built = build_transaction(&inputs, &outputs, &privkey).unwrap();
assert_eq!(
built.raw_hex,
c["raw"].as_str().unwrap(),
"case C raw tx hex must match JSS"
);
assert_eq!(
built.sighashes,
vec![
c["sighashes"][0].as_str().unwrap().to_string(),
c["sighashes"][1].as_str().unwrap().to_string(),
]
);
}
#[test]
fn every_golden_signature_verifies() {
let g = golden();
for case in ["caseA", "caseB", "caseC"] {
let c = &g[case];
let (inputs, outputs, privkey) = case_inputs(c);
let built = build_transaction(&inputs, &outputs, &privkey).unwrap();
for (i, (sighash, sig)) in built
.sighashes
.iter()
.zip(built.signatures.iter())
.enumerate()
{
assert!(
verify_keypath_signature(&built.signing_xonly, sighash, sig).unwrap(),
"{case} input {i}: signature must verify against signing_xonly"
);
}
}
}
fn case_inputs(c: &serde_json::Value) -> (Vec<TxInput>, Vec<TxOutput>, Vec<u8>) {
let privkey = hex::decode(c["priv"].as_str().unwrap()).unwrap();
let inputs = if let Some(arr) = c["inputs"].as_array() {
arr.iter()
.map(|i| TxInput {
txid: i["txid"].as_str().unwrap().into(),
vout: i["vout"].as_u64().unwrap() as u32,
amount: i["amount"].as_u64().unwrap(),
script_pubkey: hex::decode(i["scriptPubKey"].as_str().unwrap()).unwrap(),
})
.collect()
} else {
vec![TxInput {
txid: c["input"]["txid"].as_str().unwrap().into(),
vout: c["input"]["vout"].as_u64().unwrap() as u32,
amount: c["input"]["amount"].as_u64().unwrap(),
script_pubkey: hex::decode(c["input"]["scriptPubKey"].as_str().unwrap()).unwrap(),
}]
};
let outputs = if let Some(arr) = c["outputs"].as_array() {
arr.iter()
.map(|o| TxOutput {
amount: o["amount"].as_u64().unwrap(),
script_pubkey: hex::decode(o["scriptPubKey"].as_str().unwrap()).unwrap(),
})
.collect()
} else {
vec![TxOutput {
amount: c["output"]["amount"].as_u64().unwrap(),
script_pubkey: hex::decode(c["output"]["scriptPubKey"].as_str().unwrap()).unwrap(),
}]
};
(inputs, outputs, privkey)
}
const BIP340_CSV: &str = include_str!("../tests/fixtures/bitcoin/bip340_test_vectors.csv");
#[test]
fn bip340_official_vectors_sign_and_verify() {
let mut signed = 0;
let mut checked = 0;
for line in BIP340_CSV.lines().skip(1) {
let f: Vec<&str> = line.split(',').collect();
let (idx, sk, pk, aux, msg, sig, ok) = (f[0], f[1], f[2], f[3], f[4], f[5], f[6]);
if msg.len() != 64 {
continue;
}
let expected = ok == "TRUE";
if !sk.is_empty() {
let secret: [u8; 32] = hex::decode(sk).unwrap().try_into().unwrap();
let aux: [u8; 32] = hex::decode(aux).unwrap().try_into().unwrap();
let msg_b: [u8; 32] = hex::decode(msg).unwrap().try_into().unwrap();
let secp = Secp256k1::new();
let key = Keypair::from_seckey_slice(&secp, &secret).unwrap();
assert_eq!(
hex::encode_upper(key.x_only_public_key().0.serialize()),
pk,
"vector {idx}: public key"
);
let ours = *secp
.sign_schnorr_with_aux_rand(&Message::from_digest(msg_b), &key, &aux)
.as_ref();
assert_eq!(hex::encode_upper(ours), sig, "vector {idx}: signature");
signed += 1;
}
let verdict = verify_keypath_signature(
&pk.to_lowercase(),
&msg.to_lowercase(),
&sig.to_lowercase(),
)
.unwrap_or(false);
assert_eq!(verdict, expected, "vector {idx}: verification result");
checked += 1;
}
assert_eq!((signed, checked), (4, 15), "vector coverage changed");
}
const BIP341_JSON: &str =
include_str!("../tests/fixtures/bitcoin/bip341_wallet_test_vectors.json");
fn bip341() -> serde_json::Value {
serde_json::from_str(BIP341_JSON).unwrap()
}
#[test]
fn bip341_keypath_tweaked_privkey_vector() {
let v = bip341();
let inputs = v["keyPathSpending"][0]["inputSpending"].as_array().unwrap();
let mut seen = 0;
for inp in inputs {
if !inp["given"]["merkleRoot"].is_null() {
continue;
}
let privkey = hex::decode(inp["given"]["internalPrivkey"].as_str().unwrap()).unwrap();
let tweaked = keypath_signing_secret(&privkey, true).unwrap();
assert_eq!(
hex::encode(tweaked),
inp["intermediary"]["tweakedPrivkey"].as_str().unwrap(),
"BIP-341 tweakedPrivkey"
);
seen += 1;
}
assert_eq!(seen, 1, "expected exactly one key-path-only input");
}
#[test]
fn bip341_keypath_default_signature_vector() {
let v = bip341();
let inputs = v["keyPathSpending"][0]["inputSpending"].as_array().unwrap();
let mut seen = 0;
for inp in inputs {
if inp["given"]["hashType"].as_u64().unwrap() != 0 {
continue;
}
let secret: [u8; 32] =
hex::decode(inp["intermediary"]["tweakedPrivkey"].as_str().unwrap())
.unwrap()
.try_into()
.unwrap();
let sighash: [u8; 32] = hex::decode(inp["intermediary"]["sigHash"].as_str().unwrap())
.unwrap()
.try_into()
.unwrap();
let sig = schnorr_sign_zero_aux(&secret, &sighash).unwrap();
assert_eq!(
hex::encode(sig),
inp["expected"]["witness"][0].as_str().unwrap(),
"BIP-341 SIGHASH_DEFAULT witness"
);
seen += 1;
}
assert_eq!(seen, 1, "expected exactly one SIGHASH_DEFAULT input");
}
#[test]
fn bip341_scriptpubkey_vectors() {
let v = bip341();
let cases = v["scriptPubKey"].as_array().unwrap();
assert_eq!(cases.len(), 7);
for (i, c) in cases.iter().enumerate() {
let q = hex::decode(c["intermediary"]["tweakedPubkey"].as_str().unwrap()).unwrap();
assert_eq!(
hex::encode(p2tr_script(&q).unwrap()),
c["expected"]["scriptPubKey"].as_str().unwrap(),
"scriptPubKey vector {i}"
);
}
}
#[test]
fn golden_signing_key_matches_jss() {
let g = golden();
for case in ["caseA", "caseB", "caseC"] {
let c = &g[case];
let (inputs, _, privkey) = case_inputs(c);
let internal = p2tr_script(&xonly_of(&privkey).unwrap()).unwrap();
let needs_tweak = inputs[0].script_pubkey != internal;
let secret = keypath_signing_secret(&privkey, needs_tweak).unwrap();
assert_eq!(
hex::encode(secret),
c["signingKey"].as_str().unwrap(),
"{case} signingKey"
);
}
}
#[test]
fn bip341_official_output_key_vector() {
let internal = parse_xonly(
&hex::decode("d6889cb081036e0faefa3a35157ad71086b123b2b144b649798b494c300a961d")
.unwrap(),
)
.unwrap();
let (output, _parity) = internal.tap_tweak(&Secp256k1::verification_only(), None);
assert_eq!(
hex::encode(output.to_x_only_public_key().serialize()),
"53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343",
"BIP-341 official output-key vector"
);
}
#[test]
fn bip341_keypath_default_sighash_vector() {
use bitcoin::consensus::encode::deserialize;
let v = bip341();
let kps = &v["keyPathSpending"][0];
let tx: Transaction =
deserialize(&hex::decode(kps["given"]["rawUnsignedTx"].as_str().unwrap()).unwrap())
.unwrap();
let spent: Vec<TxOut> = kps["given"]["utxosSpent"]
.as_array()
.unwrap()
.iter()
.map(|u| TxOut {
value: Amount::from_sat(u["amountSats"].as_u64().unwrap()),
script_pubkey: ScriptBuf::from_bytes(
hex::decode(u["scriptPubKey"].as_str().unwrap()).unwrap(),
),
})
.collect();
let mut cache = SighashCache::new(&tx);
let mut seen = 0;
for inp in kps["inputSpending"].as_array().unwrap() {
if inp["given"]["hashType"].as_u64().unwrap() != 0 {
continue;
}
let index = inp["given"]["txinIndex"].as_u64().unwrap() as usize;
let sighash = cache
.taproot_key_spend_signature_hash(
index,
&Prevouts::All(&spent),
TapSighashType::Default,
)
.unwrap();
assert_eq!(
hex::encode(sighash.to_byte_array()),
inp["intermediary"]["sigHash"].as_str().unwrap(),
"BIP-341 sigHash for input {index}"
);
seen += 1;
}
assert_eq!(seen, 1);
}
#[test]
fn build_rejects_short_privkey_without_panicking() {
let input = TxInput {
txid: "aa".repeat(32),
vout: 0,
amount: 10_000,
script_pubkey: vec![0x51, 0x20],
};
let mut short = vec![0u8; 31];
short[30] = 1;
assert!(build_transaction(&[input], &[], &short).is_err());
}
#[test]
fn p2tr_script_rejects_non_point() {
let mut x = [0u8; 32];
x[31] = 5;
assert!(p2tr_script(&x).is_err());
}
#[test]
fn txid_hex_case_is_insignificant() {
let g = golden();
let (mut inputs, outputs, privkey) = case_inputs(&g["caseA"]);
let lower = build_transaction(&inputs, &outputs, &privkey).unwrap();
inputs[0].txid = inputs[0].txid.to_uppercase();
let upper = build_transaction(&inputs, &outputs, &privkey).unwrap();
assert_eq!(lower.raw_hex, upper.raw_hex);
}
#[test]
fn verify_rejects_non_32_byte_sighash() {
for line in BIP340_CSV.lines().skip(1) {
let f: Vec<&str> = line.split(',').collect();
if f[4].len() == 64 {
continue;
}
assert!(verify_keypath_signature(
&f[2].to_lowercase(),
&f[4].to_lowercase(),
&f[5].to_lowercase()
)
.is_err());
}
}
#[test]
fn golden_raw_decodes_and_txid_matches_unsigned_hash() {
use bitcoin::consensus::encode::deserialize;
let g = golden();
for case in ["caseA", "caseB", "caseC"] {
let c = &g[case];
let raw = hex::decode(c["raw"].as_str().unwrap()).unwrap();
let tx: Transaction = deserialize(&raw).unwrap();
let unsigned = hex::decode(c["unsigned"].as_str().unwrap()).unwrap();
let double = bitcoin::hashes::sha256d::Hash::hash(&unsigned);
assert_eq!(
tx.compute_txid().to_byte_array(),
double.to_byte_array(),
"{case}"
);
assert!(tx
.input
.iter()
.all(|i| i.witness.len() == 1 && i.witness[0].len() == 64));
}
}
#[test]
fn parse_voucher_full_form() {
let txid = "a".repeat(64);
let key = "1".repeat(64);
let uri = format!("txo:tbtc4:{txid}:2?amount=9700&key={key}");
let v = parse_txo_voucher(&uri).unwrap();
assert_eq!(v.txid, txid);
assert_eq!(v.vout, 2);
assert_eq!(v.amount, 9700);
assert_eq!(v.privkey, key);
}
#[test]
fn parse_voucher_no_chain_prefix() {
let txid = "b".repeat(64);
let key = "2".repeat(64);
let uri = format!("{txid}:0?amount=5000&key={key}");
let v = parse_txo_voucher(&uri).unwrap();
assert_eq!(v.txid, txid);
assert_eq!(v.amount, 5000);
}
#[test]
fn parse_voucher_rejects_bad_key() {
let txid = "c".repeat(64);
let uri = format!("txo:btc:{txid}:0?amount=5000&key=deadbeef");
assert!(parse_txo_voucher(&uri).is_err());
}
#[test]
fn parse_voucher_rejects_missing_amount() {
let txid = "d".repeat(64);
let key = "3".repeat(64);
let uri = format!("txo:btc:{txid}:0?key={key}");
assert!(parse_txo_voucher(&uri).is_err());
}
#[test]
fn build_rejects_empty_inputs() {
assert!(build_transaction(&[], &[], &[1u8; 32]).is_err());
}
#[test]
fn checked_output_rejects_dust() {
assert!(checked_output(800, 300).is_err()); assert!(checked_output(200, 300).is_err()); assert_eq!(checked_output(10_000, 300).unwrap(), 9_700);
}
}