webledgers_teller/deposit.rs
1//! Deposit addresses: one taproot output per account (and nonce), derived
2//! from the operator's point, which anyone can recompute from public data and
3//! only the operator can spend (`teller.mjs depositAddress`, `depositSecret`,
4//! `watchList`, at `7c00cea`).
5//!
6//! ```text
7//! P = basePoint(did(basePoint(operator))) the operator's did: the 02 point of its x
8//! t = tagged("webledgers/deposit", ledgerHash32 ‖ x(account)32 ‖ nonce as 8 bytes, big-endian)
9//! Q = P + t·G added to the full point, never re-lifted
10//! output = OP_1 <x(Q)> x-only only here
11//! secret = normalize(d) + t the sign BIP 340 needs stays inside signing
12//! ```
13//!
14//! The operator is read as its did whatever parity the point it is given has
15//! (the `7c00cea` fix): an operator whose secret gives an odd-y point once got
16//! addresses from its `03` point while its secret was normalised to the `02`
17//! point, and the two disagreed. Reading both through the did makes the
18//! address from the point, from the did and from the normalised secret one
19//! and the same.
20//!
21//! This is a plain additive tweak, not BIP 341's script tree, so a hardware
22//! wallet cannot sign for it; and anyone who knows the operator's point can
23//! link a ledger's deposit addresses.
24//!
25//! ```
26//! use bitcoin::secp256k1::SecretKey;
27//! use webledgers_teller::{deposit_address, deposit_secret, pubkey_hex, DEFAULT_HRP};
28//!
29//! let operator = SecretKey::from_slice(&[0x11; 32]).unwrap();
30//! let did = format!("did:nostr:{}", pubkey_hex(&operator));
31//! let account = format!("did:nostr:{}", "aa".repeat(32));
32//! let ledger_hash = "cd".repeat(32);
33//!
34//! let a = deposit_address(&did, &ledger_hash, &account, 0, DEFAULT_HRP).unwrap();
35//! assert!(a.address.starts_with("tb1p"));
36//! assert_eq!(a.script, format!("5120{}", a.x_only));
37//!
38//! // only the operator can spend it: its secret plus the tweak is the output's key
39//! let d = deposit_secret(&operator, &a.tweak).unwrap();
40//! assert_eq!(pubkey_hex(&d), a.x_only);
41//! ```
42
43use bitcoin::secp256k1::SecretKey;
44use bitcoin::ScriptBuf;
45use serde::{Deserialize, Serialize};
46use sidestr_core::address::script_to_address;
47
48use crate::account::{account_of, x_of};
49use crate::error::{Error, Result};
50use crate::keys;
51use crate::ledger::Ledger;
52use crate::DEPOSIT_TAG;
53
54/// The address prefix the teller writes deposit addresses under: `tb`,
55/// txbt4's (testnet4's) segwit prefix.
56pub const DEFAULT_HRP: &str = "tb";
57
58/// A deposit address and how it was made (`depositAddress`'s result). With
59/// serde the field names are the teller's (`xOnly`).
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "camelCase")]
62pub struct DepositAddress {
63 /// The tweak `t`, 64 hex: what the operator adds to its secret to spend.
64 pub tweak: String,
65 /// The output's full point `Q`, compressed (66 hex).
66 pub point: String,
67 /// `x(Q)`, 64 hex: the taproot output key.
68 pub x_only: String,
69 /// The output script, `5120` + `x(Q)`, as hex.
70 pub script: String,
71 /// The bech32m address of the script under the prefix asked for.
72 pub address: String,
73 /// The account it is for, `did:nostr:<x>`.
74 pub account: String,
75 /// The nonce: 0 for an account's first address, another for another.
76 pub nonce: u64,
77}
78
79impl DepositAddress {
80 /// The output script as a script.
81 pub fn script_pubkey(&self) -> ScriptBuf {
82 ScriptBuf::from_hex(&self.script).expect("written by deposit_address")
83 }
84}
85
86pub(crate) fn is_x(s: &str) -> bool {
87 s.len() == 64 && s.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f'))
88}
89
90/// `depositAddress`: the deposit address of `account` on the ledger
91/// `ledger_hash` (64 lowercase hex), from `operator` in any form
92/// `keys.mjs basePoint` reads (`did:nostr:<x>`, a bare x, a did:nostr
93/// Multikey, a compressed point of either parity), for `nonce`, under the
94/// address prefix `hrp` ([`DEFAULT_HRP`] for txbt4).
95pub fn deposit_address(
96 operator: &str,
97 ledger_hash: &str,
98 account: &str,
99 nonce: u64,
100 hrp: &str,
101) -> Result<DepositAddress> {
102 if !is_x(ledger_hash) {
103 return Err(Error::DepositLedgerHash);
104 }
105 // the operator is its did: the 02 point of its x, whatever parity it was given as
106 let p = keys::base_point(&keys::did(&keys::base_point(operator)?)?)?;
107 let lh = hex::decode(ledger_hash).expect("checked hex");
108 let ax = hex::decode(x_of(account)?).expect("an account's x is hex");
109 let t = keys::tagged_scalar(DEPOSIT_TAG, &[&lh, &ax, &nonce.to_be_bytes()])?;
110 let q = keys::tweak_point(&p, &t)?;
111 let x = keys::x_only(&q)?;
112 let script = format!("5120{x}");
113 let spk = ScriptBuf::from_hex(&script).expect("hex");
114 let address = script_to_address(&spk, hrp).ok_or_else(|| Error::Address(hrp.to_owned()))?;
115 Ok(DepositAddress {
116 tweak: t,
117 point: q,
118 x_only: x,
119 script,
120 address,
121 account: account_of(account)?,
122 nonce,
123 })
124}
125
126/// `depositSecret`: the operator's secret for a deposit, `normalize(d) + t`.
127/// Its point is the deposit's full point `Q`; the sign a BIP 340 signature of
128/// `x(Q)` needs is applied inside the signing, never here.
129pub fn deposit_secret(operator_secret: &SecretKey, tweak: &str) -> Result<SecretKey> {
130 keys::tweaked_secret(&keys::normalized(operator_secret), &keys::scalar(tweak)?)
131}
132
133/// `watchList`: the nonce-0 addresses of every account the ledger knows (its
134/// entries, then its deposits) and of any extra account (one that joined but
135/// holds nothing yet), each once, in that order, under [`DEFAULT_HRP`].
136pub fn watch_list(
137 ledger: &Ledger,
138 operator: &str,
139 extra_accounts: &[&str],
140) -> Result<Vec<DepositAddress>> {
141 let extra: Vec<String> = extra_accounts
142 .iter()
143 .map(|a| account_of(a))
144 .collect::<Result<_>>()?;
145 let mut accounts: Vec<&str> = Vec::new();
146 let all = ledger
147 .entries
148 .iter()
149 .map(|e| e.url.as_str())
150 .chain(ledger.deposits.iter().map(|d| d.account.as_str()))
151 .chain(extra.iter().map(String::as_str));
152 for a in all {
153 if !accounts.contains(&a) {
154 accounts.push(a);
155 }
156 }
157 accounts
158 .into_iter()
159 .map(|a| deposit_address(operator, &ledger.hash, a, 0, DEFAULT_HRP))
160 .collect()
161}