Skip to main content

breez_sdk_spark/sdk/
token_allowances.rs

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    /// Grants a spender an allowance over this wallet's tokens.
21    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    /// Revokes an allowance this wallet granted.
48    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    /// Lists allowances this wallet granted or received.
59    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    /// Prepares a pull from a wallet that granted this wallet an allowance.
88    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    /// Signs and broadcasts a prepared pull, and can be retried with the same prepare response.
119    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}