#![forbid(unsafe_code)]
#![deny(
missing_docs,
missing_debug_implementations,
rustdoc::broken_intra_doc_links
)]
use std::str::FromStr;
use bech32::primitives::decode::CheckedHrpstring;
use bech32::{Bech32, Hrp};
use bitcoin::key::XOnlyPublicKey;
use bitcoin::secp256k1::SecretKey;
use bitcoin::{Address, ScriptBuf};
use serde::Serialize;
use sidestr_core::address::script_to_address;
use sidestr_core::block::{key_from_hex, pubkey_of};
use sidestr_core::document::ChainDocument;
use sidestr_core::federation::Federation;
use sidestr_core::parent::parent_network;
use sidestr_nostr::event::{Event, SecretKeySigner};
use sidestr_nostr::tx::sign_transaction_event;
use sidestr_wallet::burn::{build_burn, BurnRequest};
use sidestr_wallet::coins::Coin;
use sidestr_wallet::key::{script_for, PlainKey};
use sidestr_wallet::pegin::build_pegin;
use sidestr_wallet::spend::{build_spend, Spend, SpendRequest};
use sidestr_wallet::Permissive;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("not a key: {0}")]
Key(&'static str),
#[error("not a destination: {0}")]
Destination(String),
#[error("peg-in plan: {0}")]
Plan(String),
#[error(transparent)]
Core(#[from] sidestr_core::Error),
#[error(transparent)]
Wallet(#[from] sidestr_wallet::Error),
#[error(transparent)]
Nostr(#[from] sidestr_nostr::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
}
pub type Result<T> = core::result::Result<T, Error>;
const NSEC: Hrp = Hrp::parse_unchecked("nsec");
const NPUB: Hrp = Hrp::parse_unchecked("npub");
fn nip19(text: &str, hrp: Hrp, what: &'static str) -> Result<Vec<u8>> {
let c = CheckedHrpstring::new::<Bech32>(text).map_err(|_| Error::Key(what))?;
if c.hrp() != hrp {
return Err(Error::Key(what));
}
Ok(c.byte_iter().collect())
}
fn looks_secret(text: &str) -> bool {
let t = text.trim();
t.get(..5).is_some_and(|p| p.eq_ignore_ascii_case("nsec1"))
|| (t.len() == 64 && t.bytes().all(|b| b.is_ascii_hexdigit()))
}
pub fn refuse_secret(text: &str) -> Result<&str> {
if looks_secret(text) {
return Err(Error::Destination(
"that looks like a secret key (an nsec, or 64 hex characters), which is never a \
destination: use an npub1…, a did:nostr:<hex>, an address, or a full script hex \
such as 5120…"
.into(),
));
}
Ok(text)
}
#[derive(Clone)]
pub struct AgentKey {
secret: SecretKey,
}
impl core::fmt::Debug for AgentKey {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("AgentKey")
.field("pubkey", &self.pubkey())
.finish_non_exhaustive()
}
}
impl AgentKey {
pub fn parse(text: &str) -> Result<Self> {
let t = text.trim();
let secret = if t.get(..5).is_some_and(|p| p.eq_ignore_ascii_case("nsec1")) {
let bytes = nip19(t, NSEC, "not a Bech32 nsec (NIP-19)")?;
SecretKey::from_slice(&bytes).map_err(|_| Error::Key("an nsec of the wrong length"))?
} else {
key_from_hex(t).map_err(|_| Error::Key("want 64 hex characters or an nsec1…"))?
};
Ok(Self { secret })
}
pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
Self::parse(&std::fs::read_to_string(path)?)
}
pub fn pubkey(&self) -> XOnlyPublicKey {
pubkey_of(&self.secret)
}
pub fn script(&self) -> ScriptBuf {
script_for(&self.pubkey())
}
pub fn spend_signer(&self) -> PlainKey {
PlainKey::new(self.secret)
}
pub fn event_signer(&self) -> SecretKeySigner {
SecretKeySigner::from_bytes(&self.secret.secret_bytes())
.expect("a valid secret key is a valid signer")
}
}
pub fn parse_pubkey(text: &str) -> Result<XOnlyPublicKey> {
let t = text.trim();
let bytes = if t.get(..5).is_some_and(|p| p.eq_ignore_ascii_case("npub1")) {
nip19(t, NPUB, "not a Bech32 npub (NIP-19)")?
} else {
let h = t.strip_prefix("did:nostr:").unwrap_or(t);
if h.len() != 64 {
return Err(Error::Key(
"want an npub1…, did:nostr:<hex> or 64 hex characters",
));
}
hex::decode(h).map_err(|_| Error::Key("not hex"))?
};
XOnlyPublicKey::from_slice(&bytes).map_err(|_| Error::Key("not a point on secp256k1"))
}
pub fn npub(key: &XOnlyPublicKey) -> String {
bech32::encode::<Bech32>(NPUB, &key.serialize()).expect("32 bytes fit an npub")
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Identity {
pub npub: String,
pub pubkey: String,
pub did: String,
pub script: String,
pub address: String,
}
pub fn identity(key: &XOnlyPublicKey, prefix: &str) -> Option<Identity> {
let script = script_for(key);
Some(Identity {
npub: npub(key),
pubkey: key.to_string(),
did: format!("did:nostr:{key}"),
address: script_to_address(&script, prefix)?,
script: script.to_hex_string(),
})
}
pub fn destination(to: &str) -> Result<String> {
let t = refuse_secret(to)?.trim();
if t.is_empty() {
return Err(Error::Destination("empty".into()));
}
let lower = t.to_ascii_lowercase();
if lower.starts_with("npub1") || lower.starts_with("did:nostr:") {
return Ok(script_for(&parse_pubkey(t)?).to_hex_string());
}
Ok(t.to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Payment {
Send,
Burn,
}
#[derive(Debug, Clone)]
pub struct Prepared {
pub spend: Spend,
pub event: Event,
}
#[allow(clippy::too_many_arguments)]
pub fn prepare(
key: &AgentKey,
chain: &ChainDocument,
coins: &[Coin],
tip_height: u32,
what: Payment,
to: &str,
amount: u64,
fee: Option<u64>,
created_at: u64,
) -> Result<Prepared> {
let signer = key.spend_signer();
let spend = match what {
Payment::Send => build_spend(
&SpendRequest {
chain,
coins,
tip_height,
to,
amount,
fee,
},
&signer,
&Permissive,
)?,
Payment::Burn => build_burn(
&BurnRequest {
chain,
coins,
tip_height,
to,
amount,
fee,
},
&signer,
&Permissive,
)?,
};
let event = sign_transaction_event(&key.event_signer(), &chain.id, &spend.hex, created_at)?;
Ok(Prepared { spend, event })
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PegTarget {
Key(XOnlyPublicKey),
Address(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PeginPlan {
pub chain: String,
pub parent: String,
pub amount: u64,
pub peg_address: String,
pub descriptor: Option<String>,
pub refund_blocks: u32,
pub side_script: String,
pub marker: String,
pub core_send: serde_json::Value,
pub note: String,
}
pub fn level1_peg_key(chain: &ChainDocument) -> Result<Option<XOnlyPublicKey>> {
if Federation::for_document(chain)?.is_some() {
return Ok(None);
}
if let Some(s) = &chain.signer {
return Ok(Some(parse_pubkey(s)?));
}
let c = chain.challenge_script()?;
if c.is_p2tr() {
return Ok(Some(
XOnlyPublicKey::from_slice(&c.as_bytes()[2..34])
.map_err(|_| Error::Key("the challenge's key is not a point"))?,
));
}
Ok(None)
}
pub fn pegin_plan(
chain: &ChainDocument,
amount: u64,
refund: &XOnlyPublicKey,
side: &str,
target: Option<PegTarget>,
) -> Result<PeginPlan> {
refuse_secret(side)?;
if let Some(PegTarget::Address(a)) = &target {
refuse_secret(a)?;
}
let parent = chain.parent()?;
let network = parent_network(parent).ok_or(sidestr_core::Error::ReservedParent {
alias: parent.alias,
label: parent.label,
})?;
let side_script = destination(side)?;
if target.is_none() && Federation::for_document(chain)?.is_none() {
return Err(Error::Plan(
"level 1: the peg output is the one the producer's parent wallet owns (SPEC 6): \
pay an address that wallet gave (--peg-address), or pass --peg-key for a \
descriptor the peg holders import"
.into(),
));
}
let (address, descriptor, note) = match target {
Some(PegTarget::Key(k)) => {
let text = format!(
"tr({k},and_v(v:pk({refund}),older({})))",
chain.refund_blocks
);
let d = miniscript::Descriptor::<XOnlyPublicKey>::from_str(&text)
.map_err(|e| Error::Plan(format!("descriptor {text}: {e}")))?;
d.sanity_check()
.map_err(|e| Error::Plan(format!("descriptor {text}: {e}")))?;
let a = d
.address(network)
.map_err(|e| Error::Plan(format!("descriptor {text}: {e}")))?;
(
a.to_string(),
Some(d.to_string()),
format!(
"the peg holders import the descriptor (importdescriptors, watch-only is enough) so their wallet owns the peg (SPEC 6, 0.0.3); {refund} may sweep it after {} parent blocks unclaimed",
chain.refund_blocks
),
)
}
Some(PegTarget::Address(a)) => (
refuse_secret(&a)?.to_string(),
None,
"paid to an address the peg holders' wallet owns; the refund is theirs to honour"
.into(),
),
None => {
let c = chain.challenge_script()?;
let a = Address::from_script(&c, network)
.map_err(|_| Error::Plan("the challenge has no parent address".into()))?;
(
a.to_string(),
None,
"level 2: the peg is the chain's challenge, which the federation's peg wallet owns (SPEC 6)".into(),
)
}
};
let p = build_pegin(chain, &address, amount, &side_script)?;
let core_send = p.core_send_outputs();
let marker = core_send[1]["data"]
.as_str()
.expect("core_send_outputs carries the marker")
.to_string();
Ok(PeginPlan {
chain: chain.id.clone(),
parent: parent.alias.to_string(),
amount,
peg_address: p.peg_address.to_string(),
descriptor,
refund_blocks: chain.refund_blocks,
side_script: p.side_script.to_hex_string(),
marker,
core_send,
note,
})
}