Skip to main content

sidestr_agent/
lib.rs

1//! `sidestr-agent`: an agent wallet for sidestr sidechains, where **a
2//! did:nostr key is the wallet**.
3//!
4//! A Nostr identity is a secp256k1 key whose x-only public key is the
5//! `did:nostr:<hex>` identifier and the `npub`. On a sidestr chain that same
6//! 32-byte x-only key is a taproot output key: its coins pay `OP_1
7//! <pubkey>` (`5120‖pubkey`, the key used untweaked, as siding's wallet does),
8//! and its address is that script in bech32m under the chain's
9//! `addressPrefix`. So an agent needs no second key. It reads its balance
10//! from a producer, pays another agent by `npub`, and signs the spend with
11//! the key its identity already holds. The kind-23500 event that carries the
12//! transaction to the producer's relays is signed with the same key, so the
13//! event names the agent that paid.
14//!
15//! This crate is the library behind the `sidestr-agent` binary. The binary is
16//! a generalisation of the tool that ran the first live loop on
17//! `sidestr:dreamlab`, beside Bitcoin testnet4. That loop was a peg-in, three
18//! trades between two agents as kind-23500 events each signed by its agent's
19//! own Nostr key, and a peg-out. Everything here is for **testnet4 and
20//! experimental sidechains**: coins on `sidestr:dreamlab` have no value.
21//!
22//! | item | what |
23//! |---|---|
24//! | [`AgentKey`] | the secret: a key file as 64 hex characters or an `nsec` (NIP-19) |
25//! | [`parse_pubkey`], [`npub`], [`Identity`] | `npub` / hex / `did:nostr:` ↔ x-only key ↔ script ↔ chain address |
26//! | [`destination`], [`refuse_secret`] | a pay-to: an `npub`, a `did:nostr:`, a chain address or a script hex; never secret-shaped text |
27//! | [`prepare`] | a spend or peg-out burn, signed by the key, and its kind-23500 event, signed by the same key |
28//! | [`ChainView`] | a mirror's block file replayed, with the SPEC 12 assets view: coins, plain coins, asset balances |
29//! | [`prepare_transfer`], [`prepare_issue`] | move or issue an asset (with memo records such as `tip:nostr:<event id>`), and the event |
30//! | [`pegin_plan`] | what a parent wallet pays to peg in: the peg address (and its refund descriptor), the marker |
31//!
32//! It is a port in the AGPL sense: it builds on `sidestr-core`,
33//! `sidestr-wallet` and `sidestr-nostr`, which port **siding**, the
34//! reference implementation by Melvin Carvalho
35//! (<https://github.com/sidestr/spec>). It carries the same licence,
36//! AGPL-3.0-only.
37//!
38//! # An agent pays another agent by npub
39//!
40//! ```
41//! use bitcoin::secp256k1::SecretKey;
42//! use sidestr_agent::{destination, identity, prepare, AgentKey, Payment};
43//! use sidestr_core::block::{challenge_for, pubkey_of};
44//! use sidestr_core::document::{ChainDocument, Peg};
45//! use sidestr_core::state::{NextBlock, State};
46//! use sidestr_wallet::coins::from_state;
47//!
48//! // two agents: the key file text is what an agent keeps (hex, or an nsec)
49//! let alice = AgentKey::parse(&"11".repeat(32)).unwrap();
50//! let bob = AgentKey::parse(&"22".repeat(32)).unwrap();
51//!
52//! // a throwaway chain whose genesis pegs alice's script; the producer's key seals blocks
53//! let producer = SecretKey::from_slice(&[7u8; 32]).unwrap();
54//! let json = format!(r#"{{"id":"sidestr:example","name":"example","parent":"tbtc4","challenge":"{}",
55//!   "powLimit":"7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff","addressPrefix":"ex",
56//!   "genesisTime":1790000000,"signer":"{}","pegs":[]}}"#, challenge_for(&pubkey_of(&producer)).to_hex_string(), pubkey_of(&producer));
57//! let mut doc = ChainDocument::from_json(&json).unwrap();
58//! doc.pegs.push(Peg { txid: "a".repeat(64), vout: 0, amount: 100_000, script: alice.script().to_hex_string(), extra: Default::default() });
59//! let mut chain = State::with_key(doc.clone(), &producer).unwrap();
60//! for i in 1..=100 { chain.produce(&producer, &NextBlock { time: 1790000000 + i, claims: vec![] }, None).unwrap(); }
61//!
62//! // alice's did:nostr key is her wallet: the same x-only key, the same script
63//! let me = identity(&alice.pubkey(), "ex").unwrap();
64//! assert_eq!(me.did, format!("did:nostr:{}", alice.pubkey()));
65//! assert_eq!(me.script, format!("5120{}", alice.pubkey()));
66//!
67//! // pay bob by his npub; the event carrying the transaction is signed by alice's key too
68//! let to = destination(&identity(&bob.pubkey(), "ex").unwrap().npub).unwrap();
69//! let coins = from_state(&chain, &alice.script());
70//! let p = prepare(&alice, &doc, &coins, chain.height(), Payment::Send, &to, 30_000, None, 1_790_000_200).unwrap();
71//! assert!(p.event.verify().is_ok() && p.event.pubkey == alice.pubkey().to_string());
72//! assert_eq!(p.event.kind, 23500);
73//! chain.submit(p.spend.tx.clone()).unwrap(); // the producer's mempool check
74//! ```
75
76#![forbid(unsafe_code)]
77#![deny(
78    missing_docs,
79    missing_debug_implementations,
80    rustdoc::broken_intra_doc_links
81)]
82
83use std::str::FromStr;
84
85use bech32::primitives::decode::CheckedHrpstring;
86use bech32::{Bech32, Hrp};
87use bitcoin::key::XOnlyPublicKey;
88use bitcoin::secp256k1::SecretKey;
89use bitcoin::Txid;
90use bitcoin::{Address, ScriptBuf};
91use serde::Serialize;
92use sidestr_core::address::script_to_address;
93use sidestr_core::assets::{AssetView, Issued};
94use sidestr_core::block::{key_from_hex, pubkey_of};
95use sidestr_core::document::ChainDocument;
96use sidestr_core::federation::Federation;
97use sidestr_core::parent::parent_network;
98use sidestr_core::state::State;
99use sidestr_nostr::event::{Event, SecretKeySigner};
100use sidestr_nostr::tx::sign_transaction_event;
101use sidestr_wallet::asset::{
102    balance_of, build_issue, build_transfer, plain_coins, IssueRequest, TransferRequest,
103};
104use sidestr_wallet::burn::{build_burn, BurnRequest};
105use sidestr_wallet::coins::from_state;
106use sidestr_wallet::coins::Coin;
107use sidestr_wallet::key::{script_for, PlainKey};
108use sidestr_wallet::pegin::build_pegin;
109use sidestr_wallet::spend::{build_spend, Spend, SpendRequest};
110use sidestr_wallet::Permissive;
111
112/// What can go wrong.
113#[derive(Debug, thiserror::Error)]
114pub enum Error {
115    /// A key file or key argument that is neither 64 hex characters, an
116    /// `nsec`, an `npub` nor a `did:nostr:` identifier. The text is never
117    /// echoed: it may be a secret.
118    #[error("not a key: {0}")]
119    Key(&'static str),
120    /// A destination that is none of the accepted forms.
121    #[error("not a destination: {0}")]
122    Destination(String),
123    /// The peg-in plan cannot be made as asked.
124    #[error("peg-in plan: {0}")]
125    Plan(String),
126    /// A consensus or document error.
127    #[error(transparent)]
128    Core(#[from] sidestr_core::Error),
129    /// The wallet refused to build.
130    #[error(transparent)]
131    Wallet(#[from] sidestr_wallet::Error),
132    /// The event could not be made.
133    #[error(transparent)]
134    Nostr(#[from] sidestr_nostr::Error),
135    /// Reading a key file.
136    #[error(transparent)]
137    Io(#[from] std::io::Error),
138}
139
140/// This crate's result.
141pub type Result<T> = core::result::Result<T, Error>;
142
143const NSEC: Hrp = Hrp::parse_unchecked("nsec");
144const NPUB: Hrp = Hrp::parse_unchecked("npub");
145
146/// A NIP-19 string's payload: Bech32 (not Bech32m, which NIP-19 does not
147/// use), under exactly `hrp`. The error is static: the text may be a secret.
148fn nip19(text: &str, hrp: Hrp, what: &'static str) -> Result<Vec<u8>> {
149    let c = CheckedHrpstring::new::<Bech32>(text).map_err(|_| Error::Key(what))?;
150    if c.hrp() != hrp {
151        return Err(Error::Key(what));
152    }
153    Ok(c.byte_iter().collect())
154}
155
156/// Whether `text` has the shape of a secret key: an `nsec`, or 32 bytes of
157/// bare hex (a key file's form). Such text is never a destination here; it
158/// is refused before it can reach an error message, a log, or an output
159/// script on the chain.
160fn looks_secret(text: &str) -> bool {
161    let t = text.trim();
162    t.get(..5).is_some_and(|p| p.eq_ignore_ascii_case("nsec1"))
163        || (t.len() == 64 && t.bytes().all(|b| b.is_ascii_hexdigit()))
164}
165
166/// Refuse secret-shaped text where a destination or an address is expected
167/// (see [`destination`]); the error names what to use and never echoes it.
168pub fn refuse_secret(text: &str) -> Result<&str> {
169    if looks_secret(text) {
170        return Err(Error::Destination(
171            "that looks like a secret key (an nsec, or 64 hex characters), which is never a \
172             destination: use an npub1…, a did:nostr:<hex>, an address, or a full script hex \
173             such as 5120…"
174                .into(),
175        ));
176    }
177    Ok(text)
178}
179
180/// An agent's secret key: its Nostr identity, and so its wallet. `Debug`
181/// prints the public key only.
182#[derive(Clone)]
183pub struct AgentKey {
184    secret: SecretKey,
185}
186
187impl core::fmt::Debug for AgentKey {
188    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
189        f.debug_struct("AgentKey")
190            .field("pubkey", &self.pubkey())
191            .finish_non_exhaustive()
192    }
193}
194
195impl AgentKey {
196    /// From a key file's text: 64 hex characters (siding's
197    /// `~/.sidestr/<name>.key`) or a NIP-19 `nsec1…`. Surrounding whitespace
198    /// is ignored.
199    ///
200    /// ```
201    /// use sidestr_agent::AgentKey;
202    /// // NIP-19's published vector: this nsec is this hex secret
203    /// let a = AgentKey::parse("nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5").unwrap();
204    /// let b = AgentKey::parse("67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa\n").unwrap();
205    /// assert_eq!(a.pubkey(), b.pubkey());
206    /// assert!(AgentKey::parse("npub1…").is_err());
207    /// ```
208    pub fn parse(text: &str) -> Result<Self> {
209        let t = text.trim();
210        let secret = if t.get(..5).is_some_and(|p| p.eq_ignore_ascii_case("nsec1")) {
211            let bytes = nip19(t, NSEC, "not a Bech32 nsec (NIP-19)")?;
212            SecretKey::from_slice(&bytes).map_err(|_| Error::Key("an nsec of the wrong length"))?
213        } else {
214            key_from_hex(t).map_err(|_| Error::Key("want 64 hex characters or an nsec1…"))?
215        };
216        Ok(Self { secret })
217    }
218
219    /// From the 32 secret bytes held in memory (a browser session's key).
220    /// The error does not echo the bytes.
221    pub fn from_secret_bytes(bytes: &[u8; 32]) -> Result<Self> {
222        let secret =
223            SecretKey::from_slice(bytes).map_err(|_| Error::Key("not a secp256k1 secret key"))?;
224        Ok(Self { secret })
225    }
226
227    /// Read and parse a key file.
228    pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
229        Self::parse(&std::fs::read_to_string(path)?)
230    }
231
232    /// The x-only public key: the `did:nostr` identifier, the `npub`, and
233    /// the taproot output key its coins pay.
234    pub fn pubkey(&self) -> XOnlyPublicKey {
235        pubkey_of(&self.secret)
236    }
237
238    /// The script the agent's coins pay: `OP_1 <pubkey>`.
239    pub fn script(&self) -> ScriptBuf {
240        script_for(&self.pubkey())
241    }
242
243    /// The key as the wallet's spend signer.
244    pub fn spend_signer(&self) -> PlainKey {
245        PlainKey::new(self.secret)
246    }
247
248    /// The key as a Nostr event signer.
249    pub fn event_signer(&self) -> SecretKeySigner {
250        SecretKeySigner::from_bytes(&self.secret.secret_bytes())
251            .expect("a valid secret key is a valid signer")
252    }
253}
254
255/// An x-only key from `npub1…`, `did:nostr:<hex>` or 64 hex characters.
256///
257/// ```
258/// use sidestr_agent::parse_pubkey;
259/// // NIP-19's published vector
260/// let k = parse_pubkey("npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg").unwrap();
261/// assert_eq!(k.to_string(), "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e");
262/// assert_eq!(parse_pubkey(&format!("did:nostr:{k}")).unwrap(), k);
263/// ```
264pub fn parse_pubkey(text: &str) -> Result<XOnlyPublicKey> {
265    let t = text.trim();
266    let bytes = if t.get(..5).is_some_and(|p| p.eq_ignore_ascii_case("npub1")) {
267        nip19(t, NPUB, "not a Bech32 npub (NIP-19)")?
268    } else {
269        let h = t.strip_prefix("did:nostr:").unwrap_or(t);
270        if h.len() != 64 {
271            return Err(Error::Key(
272                "want an npub1…, did:nostr:<hex> or 64 hex characters",
273            ));
274        }
275        hex::decode(h).map_err(|_| Error::Key("not hex"))?
276    };
277    XOnlyPublicKey::from_slice(&bytes).map_err(|_| Error::Key("not a point on secp256k1"))
278}
279
280/// The NIP-19 `npub` of a key.
281pub fn npub(key: &XOnlyPublicKey) -> String {
282    bech32::encode::<Bech32>(NPUB, &key.serialize()).expect("32 bytes fit an npub")
283}
284
285/// One key, every name it goes by.
286#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
287pub struct Identity {
288    /// NIP-19.
289    pub npub: String,
290    /// The x-only key, hex.
291    pub pubkey: String,
292    /// `did:nostr:<hex>`.
293    pub did: String,
294    /// The script its coins pay, hex: `5120‖pubkey`.
295    pub script: String,
296    /// That script as an address under the chain's prefix.
297    pub address: String,
298}
299
300/// Every name of `key` on a chain whose `addressPrefix` is `prefix`; `None`
301/// for a prefix bech32 cannot carry.
302pub fn identity(key: &XOnlyPublicKey, prefix: &str) -> Option<Identity> {
303    let script = script_for(key);
304    Some(Identity {
305        npub: npub(key),
306        pubkey: key.to_string(),
307        did: format!("did:nostr:{key}"),
308        address: script_to_address(&script, prefix)?,
309        script: script.to_hex_string(),
310    })
311}
312
313/// A destination in the form the wallet takes (a script hex or an address
314/// under any prefix): an `npub` or a `did:nostr:` becomes its key's `5120`
315/// script; anything else passes through for the wallet to judge — except
316/// secret-shaped text ([`refuse_secret`]). A bare 64-hex string is refused
317/// too: it may be a key file's secret, and the wallet would otherwise read
318/// it as a 32-byte script and publish it in an output. A key is named as an
319/// `npub` or a `did:nostr:`, a script by its full hex.
320///
321/// ```
322/// use sidestr_agent::destination;
323/// assert!(destination("nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5").is_err());
324/// assert!(destination(&"ab".repeat(32)).is_err());
325/// assert_eq!(destination(&format!("did:nostr:{}", "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e")).unwrap(),
326///            "51207e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e");
327/// ```
328pub fn destination(to: &str) -> Result<String> {
329    let t = refuse_secret(to)?.trim();
330    if t.is_empty() {
331        return Err(Error::Destination("empty".into()));
332    }
333    let lower = t.to_ascii_lowercase();
334    if lower.starts_with("npub1") || lower.starts_with("did:nostr:") {
335        return Ok(script_for(&parse_pubkey(t)?).to_hex_string());
336    }
337    Ok(t.to_string())
338}
339
340/// What a payment is.
341#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
342#[serde(rename_all = "lowercase")]
343pub enum Payment {
344    /// A spend to a sidechain destination.
345    Send,
346    /// A peg-out: a burn owed to a parent address (SPEC 7).
347    Burn,
348}
349
350/// A signed transaction and the signed event that carries it.
351#[derive(Debug, Clone)]
352pub struct Prepared {
353    /// The wallet's spend or burn.
354    pub spend: Spend,
355    /// Kind 23500, tagged with the chain, content the transaction hex,
356    /// signed by the agent's key.
357    pub event: Event,
358}
359
360/// Build and sign a payment with the agent's key, and the kind-23500 event
361/// that carries it, signed with the same key (pure: no network). `to` is a
362/// sidechain destination for [`Payment::Send`] (see [`destination`]) and a
363/// parent address or script for [`Payment::Burn`]; `created_at` is the
364/// event's time.
365///
366/// `coins` are spent as sats. On a chain where issued assets ride on coins,
367/// pass [`ChainView::plain_coins`], never every coin the key holds: a coin
368/// spent here carries nothing onward, so an asset on it would be destroyed.
369#[allow(clippy::too_many_arguments)]
370pub fn prepare(
371    key: &AgentKey,
372    chain: &ChainDocument,
373    coins: &[Coin],
374    tip_height: u32,
375    what: Payment,
376    to: &str,
377    amount: u64,
378    fee: Option<u64>,
379    created_at: u64,
380) -> Result<Prepared> {
381    let signer = key.spend_signer();
382    let spend = match what {
383        Payment::Send => build_spend(
384            &SpendRequest {
385                chain,
386                coins,
387                tip_height,
388                to,
389                amount,
390                fee,
391            },
392            &signer,
393            &Permissive,
394        )?,
395        Payment::Burn => build_burn(
396            &BurnRequest {
397                chain,
398                coins,
399                tip_height,
400                to,
401                amount,
402                fee,
403            },
404            &signer,
405            &Permissive,
406        )?,
407    };
408    let event = sign_transaction_event(&key.event_signer(), &chain.id, &spend.hex, created_at)?;
409    Ok(Prepared { spend, event })
410}
411
412/// A chain replayed from a block file and read under the `assets` rule:
413/// the UTXO set and what each unspent output carries (SPEC 12). What an
414/// agent needs before it moves an issued asset or pays plain sats beside
415/// coins that carry one.
416#[derive(Debug)]
417pub struct ChainView {
418    /// The validated chain at the file's last block.
419    pub state: State,
420    /// What each unspent output carries.
421    pub assets: AssetView,
422}
423
424impl ChainView {
425    /// Replay a block file's bytes (`GET <mirror>/blocks.dat`) against the
426    /// chain document the caller trusts. `now` is the clock for the
427    /// future-time rule, `None` to skip it. Stock-header chains.
428    pub fn replay(doc: ChainDocument, dat: &[u8], now: Option<u32>) -> Result<Self> {
429        let mut assets = AssetView::new();
430        let state = State::replay_with(doc, dat, now, |_, h, block| {
431            assets.apply_transactions(&block.txdata, h);
432        })?;
433        Ok(Self { state, assets })
434    }
435
436    /// The coins a script holds at the tip.
437    pub fn coins(&self, script: &bitcoin::Script) -> Vec<Coin> {
438        from_state(&self.state, script)
439    }
440
441    /// The coins a script holds that carry nothing: what a plain payment
442    /// may spend without destroying an asset.
443    pub fn plain_coins(&self, script: &bitcoin::Script) -> Vec<Coin> {
444        plain_coins(&self.coins(script), &self.assets)
445    }
446
447    /// How much of `asset` a script holds.
448    pub fn asset_balance(&self, script: &bitcoin::Script, asset: &Txid) -> u64 {
449        balance_of(&self.coins(script), &self.assets, asset)
450    }
451
452    /// An asset by id, or by ticker (the earliest issued under it).
453    pub fn find_asset(&self, text: &str) -> Option<(Txid, Issued)> {
454        if let Ok(id) = text.parse::<Txid>() {
455            return self.assets.issued().get(&id).map(|i| (id, i.clone()));
456        }
457        self.assets.by_ticker(text).map(|(id, i)| (*id, i.clone()))
458    }
459}
460
461/// An asset transfer signed with the agent's key, and the kind-23500 event
462/// that carries it: `amount` units of `asset` to `to` (a
463/// [`destination`]), with `memos` as records beside the tally. Plain coins
464/// pay the fee; no other asset is touched.
465#[allow(clippy::too_many_arguments)]
466pub fn prepare_transfer(
467    key: &AgentKey,
468    view: &ChainView,
469    asset: Txid,
470    to: &str,
471    amount: u64,
472    memos: &[String],
473    fee: Option<u64>,
474    created_at: u64,
475) -> Result<Prepared> {
476    let chain = view.state.document();
477    let coins = view.coins(&key.script());
478    let t = build_transfer(
479        &TransferRequest {
480            chain,
481            coins: &coins,
482            view: &view.assets,
483            tip_height: view.state.height(),
484            asset,
485            to,
486            amount,
487            memos,
488            fee,
489        },
490        &key.spend_signer(),
491        &Permissive,
492    )?;
493    let event = sign_transaction_event(&key.event_signer(), &chain.id, &t.spend.hex, created_at)?;
494    Ok(Prepared {
495        spend: t.spend,
496        event,
497    })
498}
499
500/// Issue an asset from the agent's plain coins, its whole supply on one
501/// carrier to `to` (the agent itself when `None`), and the kind-23500
502/// event. The asset's id is the spend's txid.
503#[allow(clippy::too_many_arguments)]
504pub fn prepare_issue(
505    key: &AgentKey,
506    view: &ChainView,
507    ticker: &str,
508    decimals: u8,
509    supply: u64,
510    to: Option<&str>,
511    fee: Option<u64>,
512    created_at: u64,
513) -> Result<Prepared> {
514    let chain = view.state.document();
515    let coins = view.coins(&key.script());
516    let spend = build_issue(
517        &IssueRequest {
518            chain,
519            coins: &coins,
520            view: &view.assets,
521            tip_height: view.state.height(),
522            ticker,
523            decimals,
524            supply,
525            to,
526            fee,
527        },
528        &key.spend_signer(),
529        &Permissive,
530    )?;
531    let event = sign_transaction_event(&key.event_signer(), &chain.id, &spend.hex, created_at)?;
532    Ok(Prepared { spend, event })
533}
534
535/// Whose output the peg is.
536#[derive(Debug, Clone, PartialEq, Eq)]
537pub enum PegTarget {
538    /// A taproot output with this key path and the refund leaf
539    /// `and_v(v:pk(refund), older(refundBlocks))` (SPEC 6, item 1). The peg
540    /// holders import the descriptor so their wallet owns the output.
541    Key(XOnlyPublicKey),
542    /// An address the peg holders' wallet already owns (level 1:
543    /// `getnewaddress` on the producer's peg wallet), paid as it is.
544    Address(String),
545}
546
547/// What a parent wallet pays to peg in.
548#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
549#[serde(rename_all = "camelCase")]
550pub struct PeginPlan {
551    /// The chain the coins appear on.
552    pub chain: String,
553    /// Its parent's alias.
554    pub parent: String,
555    /// Sats to peg.
556    pub amount: u64,
557    /// The peg output's address on the parent.
558    pub peg_address: String,
559    /// The output descriptor, with checksum, when the plan made the address:
560    /// what the peg holders import (`importdescriptors`) so that their
561    /// wallet owns it (SPEC 6, 0.0.3).
562    pub descriptor: Option<String>,
563    /// Blocks after which the refund key may sweep an unclaimed peg.
564    pub refund_blocks: u32,
565    /// The sidechain script the marker names, hex.
566    pub side_script: String,
567    /// The `OP_RETURN` payload, hex: `pegin:<chain id>:<script bytes>`.
568    pub marker: String,
569    /// Bitcoin Core's `send` outputs: `[{"<peg address>": "<btc>"}, {"data": "<marker>"}]`.
570    pub core_send: serde_json::Value,
571    /// What the plan means.
572    pub note: String,
573}
574
575/// The parent address of the peg script a chain's signer announces with
576/// every tip (SPEC 0.0.4, the `peg` tag; [`sidestr_nostr::tip::newest_peg_script`]):
577/// what a level-1 peg-in pays when no `--peg-address` is given, as the JS
578/// wallet's `pegInScript` does. The scanner counts only a taproot output
579/// paying it, so anything else is refused.
580///
581/// ```
582/// use sidestr_agent::announced_peg_address;
583/// use sidestr_core::document::ChainDocument;
584///
585/// let doc = ChainDocument::from_json(r#"{"id":"sidestr:x","name":"x","parent":"tbtc4",
586///   "challenge":"5120c95b519579bda3b5e29f5dca4a0b8f9f1d04d1979d2e4c3a33483a6b34b61d88",
587///   "powLimit":"7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff","addressPrefix":"ex",
588///   "genesisTime":1790000000,"refundBlocks":10000,"pegs":[]}"#).unwrap();
589/// let peg = format!("5120{}", "ab".repeat(32));
590/// assert!(announced_peg_address(&doc, &peg).unwrap().starts_with("tb1p"));
591/// assert!(announced_peg_address(&doc, "0014aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").is_err());
592/// ```
593pub fn announced_peg_address(chain: &ChainDocument, peg_script_hex: &str) -> Result<String> {
594    let parent = chain.parent()?;
595    let network = parent_network(parent).ok_or(sidestr_core::Error::ReservedParent {
596        alias: parent.alias,
597        label: parent.label,
598    })?;
599    let script = ScriptBuf::from_hex(peg_script_hex)
600        .map_err(|e| Error::Plan(format!("the announced peg script is not hex: {e}")))?;
601    if !script.is_p2tr() {
602        return Err(Error::Plan(
603            "the announced peg script is not a taproot output, which is all the scanner counts (SPEC 6)"
604                .into(),
605        ));
606    }
607    Address::from_script(&script, network)
608        .map(|a| a.to_string())
609        .map_err(|_| Error::Plan("the announced peg script has no parent address".into()))
610}
611
612/// The peg script `chain`'s signer announces with its newest tip, asked of
613/// `relays` once (`announce.mjs fetchLatestTip` then `pegScript`, as the JS
614/// wallet's `pegInScript` does): only the chain document's signer counts,
615/// and `None` when the newest announcement carries none. Feature `cli`.
616#[cfg(feature = "cli")]
617pub async fn fetch_announced_peg_script(
618    relays: &[String],
619    chain: &ChainDocument,
620    timeout: std::time::Duration,
621) -> Option<String> {
622    let events =
623        sidestr_round::relay::fetch(relays, sidestr_nostr::relay::tip_filter(&chain.id), timeout)
624            .await;
625    sidestr_nostr::tip::newest_peg_script(&events, &chain.id, chain.signer.as_deref())
626}
627
628/// The public explorer API a wallet with no node broadcasts a parent
629/// transaction to, per parent (the JS wallet's `parentApi`): mempool.guide
630/// beside a BLAKE2b parent, mempool.space beside stock Bitcoin, `/testnet4`
631/// off mainnet; `None` for a parent the table does not know.
632pub fn parent_explorer_api(chain: &ChainDocument) -> Option<String> {
633    let p = chain.parent().ok()?;
634    let host = match p.family {
635        sidestr_core::parents::Family::Blake2b => "https://mempool.guide",
636        sidestr_core::parents::Family::Stock => "https://mempool.space",
637    };
638    Some(if p.mainnet {
639        format!("{host}/api")
640    } else {
641        format!("{host}/testnet4/api")
642    })
643}
644
645/// The signing key a level-1 document names: its `signer`, else the key of a
646/// `5120‖key` challenge. `None` for a level-2 document. This is what
647/// [`PegTarget::Key`] takes when the peg holders choose to import a
648/// descriptor; [`pegin_plan`] never uses it on its own, because at level 1
649/// the peg is whatever the producer's parent wallet owns.
650pub fn level1_peg_key(chain: &ChainDocument) -> Result<Option<XOnlyPublicKey>> {
651    if Federation::for_document(chain)?.is_some() {
652        return Ok(None);
653    }
654    if let Some(s) = &chain.signer {
655        return Ok(Some(parse_pubkey(s)?));
656    }
657    let c = chain.challenge_script()?;
658    if c.is_p2tr() {
659        return Ok(Some(
660            XOnlyPublicKey::from_slice(&c.as_bytes()[2..34])
661                .map_err(|_| Error::Key("the challenge's key is not a point"))?,
662        ));
663    }
664    Ok(None)
665}
666
667/// Plan a peg-in (SPEC 6) for a parent wallet to pay: `amount` sats to the
668/// peg output and `OP_RETURN pegin:<chain id>:<side script>`, in one parent
669/// transaction, outputs in any order. Since 0.0.3 the producer takes the peg
670/// to be the output its peg wallet owns, wherever it sits.
671///
672/// Who owns the peg decides what to pay (SPEC 6):
673///
674/// - **Level 1:** the producer's parent wallet owns the peg output. Pass
675///   [`PegTarget::Address`] with an address that wallet gave
676///   (`getnewaddress`); it is paid as it is. With no target, a level-1 plan
677///   is refused rather than guessed.
678/// - **Level 2:** the peg is the chain's challenge script, which the
679///   federation's peg wallet owns. With no target, its address is paid.
680/// - [`PegTarget::Key`] is the explicit alternative at either level: the
681///   peg address is `tr(<key>, and_v(v:pk(<refund>), older(<refundBlocks>)))`,
682///   and the descriptor comes back. It is a peg-in only once the peg holders
683///   have imported it (watch-only is enough), so their wallet owns it. Then
684///   `refund` may sweep a peg left unclaimed for `refundBlocks`.
685///
686/// ```
687/// use sidestr_agent::{pegin_plan, parse_pubkey, PegTarget};
688/// use sidestr_core::document::ChainDocument;
689/// use sidestr_core::parent::find_pegin;
690///
691/// let doc = ChainDocument::from_json(r#"{"id":"sidestr:example","name":"example","parent":"tbtc4",
692///   "challenge":"5120c95b519579bda3b5e29f5dca4a0b8f9f1d04d1979d2e4c3a33483a6b34b61d88",
693///   "powLimit":"7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff","addressPrefix":"ex",
694///   "genesisTime":1790000000,"refundBlocks":10000,"pegs":[]}"#).unwrap();
695/// let refund = parse_pubkey("npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg").unwrap();
696/// let side = format!("5120{}", "ab".repeat(32));
697/// // level 1: the producer's peg wallet gave this address; it is paid as it is
698/// let peg = "tb1palk8spjn20q8fa3gu8p30tx4xl0mqyk7t3497540zjkmt4zdvesq07eq2k";
699/// let plan = pegin_plan(&doc, 50_000, &refund, &side, Some(PegTarget::Address(peg.into()))).unwrap();
700/// assert_eq!(plan.peg_address, peg);
701/// assert!(plan.descriptor.is_none());
702/// // with no target, a level-1 plan is refused rather than guessed
703/// assert!(pegin_plan(&doc, 50_000, &refund, &side, None).is_err());
704/// // the explicit descriptor alternative, for peg holders who import it
705/// let key = parse_pubkey("c95b519579bda3b5e29f5dca4a0b8f9f1d04d1979d2e4c3a33483a6b34b61d88").unwrap();
706/// let plan = pegin_plan(&doc, 50_000, &refund, &side, Some(PegTarget::Key(key))).unwrap();
707/// assert!(plan.descriptor.as_deref().unwrap().starts_with("tr(c95b5195"));
708/// assert!(plan.descriptor.as_deref().unwrap().contains("older(10000)"));
709/// assert_eq!(plan.core_send[1]["data"], plan.marker);
710/// ```
711pub fn pegin_plan(
712    chain: &ChainDocument,
713    amount: u64,
714    refund: &XOnlyPublicKey,
715    side: &str,
716    target: Option<PegTarget>,
717) -> Result<PeginPlan> {
718    // secret-shaped text is refused before anything else is judged, so no
719    // other error can come first and nothing can repeat it
720    refuse_secret(side)?;
721    if let Some(PegTarget::Address(a)) = &target {
722        refuse_secret(a)?;
723    }
724    let parent = chain.parent()?;
725    let network = parent_network(parent).ok_or(sidestr_core::Error::ReservedParent {
726        alias: parent.alias,
727        label: parent.label,
728    })?;
729    let side_script = destination(side)?;
730    if target.is_none() && Federation::for_document(chain)?.is_none() {
731        return Err(Error::Plan(
732            "level 1: the peg output is the one the producer's parent wallet owns (SPEC 6): \
733             pay the peg script the signer announces (announced_peg_address), an address \
734             that wallet gave (--peg-address), or pass --peg-key for a descriptor the peg \
735             holders import"
736                .into(),
737        ));
738    }
739    let (address, descriptor, note) = match target {
740        Some(PegTarget::Key(k)) => {
741            let text = format!(
742                "tr({k},and_v(v:pk({refund}),older({})))",
743                chain.refund_blocks
744            );
745            let d = miniscript::Descriptor::<XOnlyPublicKey>::from_str(&text)
746                .map_err(|e| Error::Plan(format!("descriptor {text}: {e}")))?;
747            d.sanity_check()
748                .map_err(|e| Error::Plan(format!("descriptor {text}: {e}")))?;
749            let a = d
750                .address(network)
751                .map_err(|e| Error::Plan(format!("descriptor {text}: {e}")))?;
752            (
753                a.to_string(),
754                Some(d.to_string()),
755                format!(
756                    "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",
757                    chain.refund_blocks
758                ),
759            )
760        }
761        Some(PegTarget::Address(a)) => (
762            refuse_secret(&a)?.to_string(),
763            None,
764            "paid to an address the peg holders' wallet owns; the refund is theirs to honour"
765                .into(),
766        ),
767        None => {
768            let c = chain.challenge_script()?;
769            let a = Address::from_script(&c, network)
770                .map_err(|_| Error::Plan("the challenge has no parent address".into()))?;
771            (
772                a.to_string(),
773                None,
774                "level 2: the peg is the chain's challenge, which the federation's peg wallet owns (SPEC 6)".into(),
775            )
776        }
777    };
778    let p = build_pegin(chain, &address, amount, &side_script)?;
779    let core_send = p.core_send_outputs();
780    let marker = core_send[1]["data"]
781        .as_str()
782        .expect("core_send_outputs carries the marker")
783        .to_string();
784    Ok(PeginPlan {
785        chain: chain.id.clone(),
786        parent: parent.alias.to_string(),
787        amount,
788        peg_address: p.peg_address.to_string(),
789        descriptor,
790        refund_blocks: chain.refund_blocks,
791        side_script: p.side_script.to_hex_string(),
792        marker,
793        core_send,
794        note,
795    })
796}