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}