use crate::intent;
use crate::script::arknote_script;
use crate::script::tr_script_pubkey;
use crate::Error;
use crate::UNSPENDABLE_KEY;
use bitcoin::hashes::sha256;
use bitcoin::hashes::Hash;
use bitcoin::key::Secp256k1;
use bitcoin::taproot::LeafVersion;
use bitcoin::taproot::TaprootBuilder;
use bitcoin::Amount;
use bitcoin::OutPoint;
use bitcoin::PublicKey;
use bitcoin::ScriptBuf;
use bitcoin::Sequence;
use bitcoin::TxOut;
use bitcoin::Txid;
use std::fmt;
pub const DEFAULT_HRP: &str = "arknote";
pub const PREIMAGE_LENGTH: usize = 32;
const VALUE_LENGTH: usize = 4;
const ARKNOTE_LENGTH: usize = PREIMAGE_LENGTH + VALUE_LENGTH;
pub const FAKE_VOUT: u32 = 0;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArkNote {
preimage: [u8; PREIMAGE_LENGTH],
value: Amount,
hrp: String,
}
impl ArkNote {
pub fn new(preimage: [u8; PREIMAGE_LENGTH], value: Amount) -> Result<Self, Error> {
Self::new_with_hrp(preimage, value, DEFAULT_HRP.to_string())
}
pub fn new_with_hrp(
preimage: [u8; PREIMAGE_LENGTH],
value: Amount,
hrp: String,
) -> Result<Self, Error> {
if value.to_sat() > u32::MAX as u64 {
return Err(Error::ad_hoc(format!(
"value {} exceeds maximum of {} sats",
value.to_sat(),
u32::MAX
)));
}
Ok(Self {
preimage,
value,
hrp,
})
}
pub fn from_string(s: &str) -> Result<Self, Error> {
Self::from_string_with_hrp(s, DEFAULT_HRP)
}
pub fn from_string_with_hrp(s: &str, hrp: &str) -> Result<Self, Error> {
let s = s.trim();
if !s.starts_with(hrp) {
return Err(Error::ad_hoc(format!(
"invalid prefix: expected '{}', got '{}'",
hrp,
&s[..hrp.len().min(s.len())]
)));
}
let encoded = &s[hrp.len()..];
let decoded = bs58::decode(encoded)
.into_vec()
.map_err(|e| Error::ad_hoc(format!("invalid base58: {e}")))?;
if decoded.len() != ARKNOTE_LENGTH {
return Err(Error::ad_hoc(format!(
"invalid payload length: expected {}, got {}",
ARKNOTE_LENGTH,
decoded.len()
)));
}
let mut preimage = [0u8; PREIMAGE_LENGTH];
preimage.copy_from_slice(&decoded[..PREIMAGE_LENGTH]);
let value_bytes: [u8; 4] = decoded[PREIMAGE_LENGTH..]
.try_into()
.map_err(|_| Error::ad_hoc("invalid value bytes"))?;
let value = Amount::from_sat(u32::from_be_bytes(value_bytes) as u64);
Self::new_with_hrp(preimage, value, hrp.to_string())
}
pub fn to_encoded_string(&self) -> String {
self.to_string()
}
pub fn preimage(&self) -> &[u8; PREIMAGE_LENGTH] {
&self.preimage
}
pub fn preimage_hash(&self) -> sha256::Hash {
sha256::Hash::hash(&self.preimage)
}
pub fn value(&self) -> Amount {
self.value
}
pub fn hrp(&self) -> &str {
&self.hrp
}
pub fn script(&self) -> ScriptBuf {
arknote_script(&self.preimage_hash())
}
pub fn txid(&self) -> Txid {
Txid::from_byte_array(*self.preimage_hash().as_byte_array())
}
pub fn outpoint(&self) -> OutPoint {
OutPoint::new(self.txid(), FAKE_VOUT)
}
pub fn to_intent_input(&self) -> Result<intent::Input, Error> {
let secp = Secp256k1::new();
let unspendable_key: PublicKey = UNSPENDABLE_KEY
.parse()
.map_err(|e| Error::ad_hoc(format!("invalid unspendable key: {e}")))?;
let (unspendable_xonly, _) = unspendable_key.inner.x_only_public_key();
let note_script = self.script();
let spend_info = TaprootBuilder::new()
.add_leaf(0, note_script.clone())
.map_err(|e| Error::ad_hoc(format!("failed to add leaf: {e:?}")))?
.finalize(&secp, unspendable_xonly)
.map_err(|e| Error::ad_hoc(format!("failed to finalize taproot: {e:?}")))?;
let control_block = spend_info
.control_block(&(note_script.clone(), LeafVersion::TapScript))
.ok_or_else(|| Error::ad_hoc("failed to get control block for note script"))?;
let script_pubkey = tr_script_pubkey(&spend_info);
Ok(intent::Input::new_with_extra_witness(
self.outpoint(),
Sequence::MAX,
None,
TxOut {
value: self.value,
script_pubkey,
},
vec![note_script.clone()],
(note_script, control_block),
false, false, Vec::new(),
vec![self.preimage.to_vec()],
))
}
}
impl fmt::Display for ArkNote {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut payload = Vec::with_capacity(ARKNOTE_LENGTH);
payload.extend_from_slice(&self.preimage);
payload.extend_from_slice(&(self.value.to_sat() as u32).to_be_bytes());
write!(f, "{}{}", self.hrp, bs58::encode(payload).into_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn hex_to_array32(hex: &str) -> [u8; 32] {
let bytes = hex::decode(hex).expect("valid hex");
bytes.try_into().expect("32 bytes")
}
#[test]
fn roundtrip_encoding() {
let preimage =
hex_to_array32("11d2a03264d0efd311d2a03264d0efd311d2a03264d0efd311d2a03264d0efd3");
let value = Amount::from_sat(900_000);
let note = ArkNote::new(preimage, value).unwrap();
let encoded = note.to_string();
let decoded = ArkNote::from_string(&encoded).unwrap();
assert_eq!(decoded.preimage(), &preimage);
assert_eq!(decoded.value(), value);
}
#[test]
fn test_vectors() {
let cases = [
(
"arknote",
"arknote8rFzGqZsG9RCLripA6ez8d2hQEzFKsqCeiSnXhQj56Ysw7ZQT",
"11d2a03264d0efd311d2a03264d0efd311d2a03264d0efd311d2a03264d0efd3",
900_000u64,
),
(
"arknote",
"arknoteSkB92YpWm4Q2ijQHH34cqbKkCZWszsiQgHVjtNeFF2Cwp59D",
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
1_828_932u64,
),
(
"noteark",
"noteark8rFzGqZsG9RCLripA6ez8d2hQEzFKsqCeiSnXhQj56Ysw7ZQT",
"11d2a03264d0efd311d2a03264d0efd311d2a03264d0efd311d2a03264d0efd3",
900_000u64,
),
];
for (hrp, note_str, preimage_hex, expected_sats) in cases {
let note = ArkNote::from_string_with_hrp(note_str, hrp).unwrap();
assert_eq!(note.preimage(), &hex_to_array32(preimage_hex));
assert_eq!(note.value(), Amount::from_sat(expected_sats));
assert_eq!(note.hrp(), hrp);
let reconstructed = ArkNote::new_with_hrp(
hex_to_array32(preimage_hex),
Amount::from_sat(expected_sats),
hrp.to_string(),
)
.unwrap();
assert_eq!(reconstructed.to_string(), note_str);
}
}
#[test]
fn invalid_prefix() {
let result = ArkNote::from_string("wrongprefix123456789");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("invalid prefix"));
}
#[test]
fn invalid_base58() {
let result = ArkNote::from_string("arknote!!!invalid!!!");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("base58"));
}
#[test]
fn value_overflow() {
let preimage = [0u8; 32];
let result = ArkNote::new(preimage, Amount::from_sat(u64::MAX));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("exceeds maximum"));
}
#[test]
fn script_is_hash_lock() {
let preimage = [0x42u8; 32];
let note = ArkNote::new(preimage, Amount::from_sat(1000)).unwrap();
let script = note.script();
let bytes = script.as_bytes();
assert_eq!(bytes[0], bitcoin::opcodes::all::OP_SHA256.to_u8());
assert_eq!(bytes[1], 0x20); assert_eq!(bytes[34], bitcoin::opcodes::all::OP_EQUAL.to_u8());
}
#[test]
fn whitespace_handling() {
let note_str = " arknote8rFzGqZsG9RCLripA6ez8d2hQEzFKsqCeiSnXhQj56Ysw7ZQT ";
let note = ArkNote::from_string(note_str).unwrap();
assert_eq!(note.value(), Amount::from_sat(900_000));
}
}