1use spark_wallet::{NewTokenAllowance, PublicKey, SparkAddress, TokenAllowanceQuery};
2use tracing::error;
3
4use crate::{
5 CreateTokenAllowanceRequest, CreateTokenAllowanceResponse, ListTokenAllowancesRequest,
6 ListTokenAllowancesResponse, Network, PaymentStatus, PreparePullPaymentRequest,
7 PreparePullPaymentResponse, PullPaymentRequest, PullPaymentResponse, PullReceiver,
8 RevokeTokenAllowanceRequest, TokenAllowance, error::SdkError, persist::ObjectCacheRepository,
9 utils::token::token_transaction_to_payments,
10};
11
12use super::BreezSdk;
13
14const DEFAULT_LIST_LIMIT: u32 = 100;
15
16#[macros::instance_span]
17#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
18#[allow(clippy::needless_pass_by_value)]
19impl BreezSdk {
20 pub async fn create_token_allowance(
22 &self,
23 request: CreateTokenAllowanceRequest,
24 ) -> Result<CreateTokenAllowanceResponse, SdkError> {
25 let network = self.config.network;
26 let allowed_recipients = request
27 .allowed_recipients
28 .iter()
29 .map(|address| parse_address(address, network))
30 .collect::<Result<Vec<_>, _>>()?;
31 let allowance = self
32 .spark_wallet
33 .create_token_allowance(NewTokenAllowance {
34 spender_public_key: parse_address(&request.spender_address, network)?,
35 token_identifier: request.token_identifier,
36 max_per_payment: request.max_per_payment.into(),
37 max_total: request.max_total.into(),
38 expiry_time: request.expiry_time,
39 allowed_recipients,
40 })
41 .await?;
42 Ok(CreateTokenAllowanceResponse {
43 allowance: allowance_from(allowance, network)?,
44 })
45 }
46
47 pub async fn revoke_token_allowance(
49 &self,
50 request: RevokeTokenAllowanceRequest,
51 ) -> Result<(), SdkError> {
52 self.spark_wallet
53 .revoke_token_allowance(&request.allowance_id)
54 .await?;
55 Ok(())
56 }
57
58 pub async fn list_token_allowances(
60 &self,
61 request: ListTokenAllowancesRequest,
62 ) -> Result<ListTokenAllowancesResponse, SdkError> {
63 let network = self.config.network;
64 let allowances = self
65 .spark_wallet
66 .query_token_allowances(TokenAllowanceQuery {
67 role: request.role.into(),
68 counterparty_public_key: request
69 .counterparty_address
70 .as_deref()
71 .map(|address| parse_address(address, network))
72 .transpose()?,
73 token_identifier: request.token_identifier,
74 include_inactive: request.include_inactive.unwrap_or(false),
75 offset: request.offset.unwrap_or(0),
76 limit: request.limit.unwrap_or(DEFAULT_LIST_LIMIT),
77 })
78 .await?;
79 Ok(ListTokenAllowancesResponse {
80 allowances: allowances
81 .into_iter()
82 .map(|allowance| allowance_from(allowance, network))
83 .collect::<Result<_, _>>()?,
84 })
85 }
86
87 pub async fn prepare_pull_payment(
89 &self,
90 request: PreparePullPaymentRequest,
91 ) -> Result<PreparePullPaymentResponse, SdkError> {
92 let network = self.config.network;
93 let own_key = self.spark_wallet.get_identity_public_key();
94 let receivers = request
95 .receivers
96 .iter()
97 .map(|receiver| {
98 Ok(spark_wallet::PullReceiver {
99 receiver_public_key: match &receiver.receiver_address {
100 Some(address) => parse_address(address, network)?,
101 None => own_key,
102 },
103 amount: receiver.amount,
104 })
105 })
106 .collect::<Result<Vec<_>, SdkError>>()?;
107 let prepared = self
108 .spark_wallet
109 .prepare_token_pull(
110 parse_address(&request.payer_address, network)?,
111 &request.token_identifier,
112 receivers,
113 )
114 .await?;
115 prepare_response_from(&prepared, network)
116 }
117
118 pub async fn pull_payment(
120 &self,
121 request: PullPaymentRequest,
122 ) -> Result<PullPaymentResponse, SdkError> {
123 let prepared = decode_prepare_response(&request.prepare_response, self.config.network)?;
124 self.maybe_ensure_spark_private_mode_initialized().await?;
125 let transaction = self
126 .spark_wallet
127 .sign_and_broadcast_token_pull(&prepared)
128 .await?;
129 Ok(self.complete_pull(transaction).await)
130 }
131}
132
133impl BreezSdk {
134 pub(in crate::sdk) async fn complete_pull(
135 &self,
136 transaction: spark_wallet::TokenTransaction,
137 ) -> PullPaymentResponse {
138 if let Some(pending) = pending_pull(&transaction) {
139 return pending;
140 }
141 let object_repository = ObjectCacheRepository::new(self.storage.clone());
142 let payments = match token_transaction_to_payments(
143 &self.spark_wallet,
144 &object_repository,
145 &transaction,
146 false,
147 )
148 .await
149 {
150 Ok(payments) => payments,
151 Err(e) => {
152 error!("Failed to map settled pull {}: {e:?}", transaction.hash);
153 return PullPaymentResponse {
154 tx_hash: transaction.hash,
155 status: PaymentStatus::Completed,
156 payment: None,
157 };
158 }
159 };
160 let mut new_payments = Vec::new();
161 for payment in &payments {
162 match self.storage.apply_payment_update(payment.clone()).await {
163 Ok(true) => new_payments.push(payment.clone()),
164 Ok(false) => {}
165 Err(e) => error!("Failed to store pulled payment {}: {e:?}", payment.id),
166 }
167 }
168 if !new_payments.is_empty()
169 && let Err(e) = self
170 .spark_wallet
171 .process_token_transaction(&transaction)
172 .await
173 {
174 error!(
175 "Failed to add the outputs of pull {} to the wallet: {e:?}",
176 transaction.hash
177 );
178 }
179 super::payments::send::emit_payments(self, &new_payments).await;
180 PullPaymentResponse {
181 tx_hash: transaction.hash,
182 status: PaymentStatus::Completed,
183 payment: payments.into_iter().next(),
184 }
185 }
186}
187
188fn pending_pull(transaction: &spark_wallet::TokenTransaction) -> Option<PullPaymentResponse> {
189 (!matches!(
190 transaction.status,
191 spark_wallet::TokenTransactionStatus::Finalized
192 ))
193 .then(|| PullPaymentResponse {
194 tx_hash: transaction.hash.clone(),
195 status: PaymentStatus::Pending,
196 payment: None,
197 })
198}
199
200fn parse_address(address: &str, network: Network) -> Result<PublicKey, SdkError> {
201 let parsed = address
202 .parse::<SparkAddress>()
203 .map_err(|_| SdkError::InvalidInput(format!("Invalid spark address: {address}")))?;
204 if parsed.spark_invoice_fields.is_some() {
205 return Err(SdkError::InvalidInput(format!(
206 "Expected a spark address, not an invoice: {address}"
207 )));
208 }
209 if parsed.network != network.into() {
210 return Err(SdkError::InvalidInput(format!(
211 "The spark address is for another network: {address}"
212 )));
213 }
214 Ok(parsed.identity_public_key)
215}
216
217pub(in crate::sdk) fn address_of(key: PublicKey, network: Network) -> Result<String, SdkError> {
218 SparkAddress::new(key, network.into(), None)
219 .to_address_string()
220 .map_err(|e| SdkError::Generic(format!("Failed to encode a spark address: {e}")))
221}
222
223fn allowance_from(
224 allowance: spark_wallet::TokenAllowance,
225 network: Network,
226) -> Result<TokenAllowance, SdkError> {
227 Ok(TokenAllowance {
228 id: allowance.id,
229 owner_address: address_of(allowance.owner_public_key, network)?,
230 spender_address: address_of(allowance.spender_public_key, network)?,
231 token_identifier: allowance.token_identifier,
232 max_per_payment: allowance.max_per_payment.into(),
233 max_total: allowance.max_total.into(),
234 spent_amount: allowance.spent_amount,
235 allowed_recipients: allowance
236 .allowed_recipients
237 .into_iter()
238 .map(|key| address_of(key, network))
239 .collect::<Result<_, _>>()?,
240 expiry_time: allowance.expiry_time,
241 created_at: allowance.created_at,
242 revoked_at: allowance.revoked_at,
243 status: allowance.status.into(),
244 })
245}
246
247pub(in crate::sdk) fn encode_pull_context(
248 prepared: &spark_wallet::PreparedTokenPull,
249) -> Result<Vec<u8>, SdkError> {
250 serde_json::to_vec(prepared)
251 .map_err(|e| SdkError::Generic(format!("Failed to serialize the pull: {e}")))
252}
253
254pub(in crate::sdk) fn decode_pull_context(
255 bytes: &[u8],
256) -> Result<spark_wallet::PreparedTokenPull, SdkError> {
257 serde_json::from_slice(bytes)
258 .map_err(|e| SdkError::InvalidInput(format!("Invalid pull context: {e}")))
259}
260
261pub(in crate::sdk) fn decode_prepare_response(
262 response: &PreparePullPaymentResponse,
263 network: Network,
264) -> Result<spark_wallet::PreparedTokenPull, SdkError> {
265 let prepared = decode_pull_context(&response.pull_context)?;
266 if response.allowance_id != prepared.allowance_id
267 || response.expiry_time != prepared.expiry_time()
268 || !describes_pull(
269 &prepared,
270 network,
271 &response.payer_address,
272 &response.token_identifier,
273 &response.receivers,
274 response.amount,
275 )
276 {
277 return Err(SdkError::InvalidInput(
278 "The prepare response doesn't match its pull context".to_string(),
279 ));
280 }
281 Ok(prepared)
282}
283
284pub(in crate::sdk) fn describes_pull(
285 prepared: &spark_wallet::PreparedTokenPull,
286 network: Network,
287 payer_address: &str,
288 token_identifier: &str,
289 receivers: &[PullReceiver],
290 amount: u128,
291) -> bool {
292 address_of(prepared.payer_public_key, network).is_ok_and(|address| address == payer_address)
293 && token_identifier == prepared.token_identifier
294 && pull_receivers(prepared, network).is_ok_and(|expected| expected == receivers)
295 && amount == prepared.total()
296}
297
298pub(in crate::sdk) fn pull_receivers(
299 prepared: &spark_wallet::PreparedTokenPull,
300 network: Network,
301) -> Result<Vec<PullReceiver>, SdkError> {
302 prepared
303 .receivers
304 .iter()
305 .map(|receiver| {
306 Ok(PullReceiver {
307 amount: receiver.amount,
308 receiver_address: Some(address_of(receiver.receiver_public_key, network)?),
309 })
310 })
311 .collect()
312}
313
314fn prepare_response_from(
315 prepared: &spark_wallet::PreparedTokenPull,
316 network: Network,
317) -> Result<PreparePullPaymentResponse, SdkError> {
318 Ok(PreparePullPaymentResponse {
319 payer_address: address_of(prepared.payer_public_key, network)?,
320 token_identifier: prepared.token_identifier.clone(),
321 receivers: pull_receivers(prepared, network)?,
322 amount: prepared.total(),
323 allowance_id: prepared.allowance_id.clone(),
324 expiry_time: prepared.expiry_time(),
325 pull_context: encode_pull_context(prepared)?,
326 })
327}
328
329#[cfg(test)]
330pub(crate) fn prepare_response_for_tests(
331 prepared: &spark_wallet::PreparedTokenPull,
332) -> Result<PreparePullPaymentResponse, SdkError> {
333 prepare_response_from(prepared, Network::Regtest)
334}
335
336#[cfg(test)]
337pub(crate) mod tests {
338 use std::str::FromStr;
339
340 use platform_utils::time::SystemTime;
341 use spark_wallet::{
342 PreparedTokenPull, PublicKey, PullReceiver, TokenInputs, TokenTransaction,
343 TokenTransactionStatus, TokenTransferInput,
344 };
345
346 use super::{
347 address_of, decode_prepare_response, decode_pull_context, encode_pull_context,
348 pending_pull, prepare_response_from,
349 };
350 use crate::{Network, PaymentStatus, PreparePullPaymentResponse, error::SdkError};
351
352 pub(crate) const OTHER_KEY: &str =
353 "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
354
355 pub(crate) fn other_address() -> String {
356 address_of(PublicKey::from_str(OTHER_KEY).unwrap(), Network::Regtest).unwrap()
357 }
358
359 pub(crate) fn sample_pull() -> PreparedTokenPull {
360 let key = PublicKey::from_str(
361 "02ca75659458529755b77663f18282f4aa130313e098fac40deffb1208207a2ffe",
362 )
363 .unwrap();
364 PreparedTokenPull {
365 allowance_id: uuid::Uuid::now_v7().to_string(),
366 payer_public_key: key,
367 token_identifier: "btknrt1example".to_string(),
368 receivers: vec![PullReceiver {
369 receiver_public_key: key,
370 amount: 42,
371 }],
372 partial_token_transaction_bytes: vec![1, 2, 3],
373 partial_token_transaction_hash: vec![7; 32],
374 created_timestamp: SystemTime::now(),
375 allowance_expiry_time: 4_102_444_800,
376 }
377 }
378
379 #[test]
380 fn pull_context_round_trips() {
381 let pull = sample_pull();
382 let decoded = decode_pull_context(&encode_pull_context(&pull).unwrap()).unwrap();
383 assert_eq!(decoded.allowance_id, pull.allowance_id);
384 assert_eq!(
385 decoded.partial_token_transaction_hash,
386 pull.partial_token_transaction_hash
387 );
388 }
389
390 #[test]
391 fn prepare_response_reports_total_and_window() {
392 let pull = sample_pull();
393 let response = prepare_response_from(&pull, Network::Regtest).unwrap();
394 assert_eq!(response.amount, 42);
395 assert_eq!(response.allowance_id, pull.allowance_id);
396 assert_eq!(response.expiry_time, pull.expiry_time());
397 let address = address_of(pull.receivers[0].receiver_public_key, Network::Regtest).unwrap();
398 assert!(address.starts_with("sparkrt1"));
399 assert_eq!(response.receivers[0].receiver_address, Some(address));
400 }
401
402 #[test]
403 fn prepare_response_reports_an_earlier_allowance_expiry() {
404 let pull = PreparedTokenPull {
405 allowance_expiry_time: 1_000,
406 ..sample_pull()
407 };
408 assert_eq!(
409 prepare_response_from(&pull, Network::Regtest)
410 .unwrap()
411 .expiry_time,
412 1_000
413 );
414 }
415
416 #[test]
417 fn rejects_garbage_context() {
418 assert!(decode_pull_context(b"not json").is_err());
419 }
420
421 #[test]
422 fn a_pull_that_is_not_final_is_pending() {
423 let pull = |status| TokenTransaction {
424 hash: "pull".to_string(),
425 inputs: TokenInputs::Transfer(TokenTransferInput {
426 outputs_to_spend: vec![],
427 }),
428 outputs: vec![],
429 status,
430 created_timestamp: SystemTime::now(),
431 fulfilled_invoices: vec![],
432 };
433 for status in [
434 TokenTransactionStatus::Unknown,
435 TokenTransactionStatus::Signed,
436 TokenTransactionStatus::Revealed,
437 ] {
438 let response = pending_pull(&pull(status)).unwrap();
439 assert_eq!(response.tx_hash, "pull");
440 assert_eq!(response.status, PaymentStatus::Pending);
441 assert!(response.payment.is_none());
442 }
443 assert!(pending_pull(&pull(TokenTransactionStatus::Finalized)).is_none());
444 }
445
446 #[test]
447 fn accepts_an_unchanged_prepare_response() {
448 let pull = sample_pull();
449 let response = prepare_response_from(&pull, Network::Regtest).unwrap();
450 let decoded = decode_prepare_response(&response, Network::Regtest).unwrap();
451 assert_eq!(decoded.allowance_id, pull.allowance_id);
452 assert_eq!(decoded.receivers, pull.receivers);
453 assert!(matches!(
454 decode_prepare_response(&response, Network::Mainnet),
455 Err(SdkError::InvalidInput(_))
456 ));
457 }
458
459 #[test]
460 fn refuses_a_prepare_response_edited_after_prepare() {
461 let mut pull = sample_pull();
462 pull.receivers.push(PullReceiver {
463 receiver_public_key: PublicKey::from_str(OTHER_KEY).unwrap(),
464 amount: 8,
465 });
466 let original = prepare_response_from(&pull, Network::Regtest).unwrap();
467 let edits: [fn(&mut PreparePullPaymentResponse); 10] = [
468 |r| r.payer_address = other_address(),
469 |r| r.token_identifier = "btknrt1other".to_string(),
470 |r| r.receivers[0].receiver_address = Some(other_address()),
471 |r| r.receivers[0].receiver_address = None,
472 |r| r.receivers[0].amount += 1,
473 |r| r.receivers.swap(0, 1),
474 |r| r.receivers.truncate(1),
475 |r| r.amount += 1,
476 |r| r.allowance_id = uuid::Uuid::now_v7().to_string(),
477 |r| r.expiry_time += 1,
478 ];
479 for edit in edits {
480 let mut response = original.clone();
481 edit(&mut response);
482 assert!(matches!(
483 decode_prepare_response(&response, Network::Regtest),
484 Err(SdkError::InvalidInput(_))
485 ));
486 }
487 }
488}