lexe/
wallet.rs

1use std::{marker::PhantomData, path::PathBuf};
2
3use anyhow::{Context, anyhow, ensure};
4use common::{
5    api::{
6        auth::{
7            UserSignupRequestWire, UserSignupRequestWireV1,
8            UserSignupRequestWireV2,
9        },
10        user::{NodePkProof, UserPk},
11    },
12    rng::Crng,
13    root_seed::RootSeed,
14};
15use lexe_api::{
16    def::{AppBackendApi, AppNodeRunApi},
17    models::command::{
18        EnclavesToProvisionRequest, LxPaymentIdStruct, PayInvoiceRequest,
19        UpdatePaymentNote,
20    },
21    types::payments::PaymentCreatedIndex,
22};
23use node_client::{
24    client::{GatewayClient, NodeClient},
25    credentials::CredentialsRef,
26};
27use payment_uri::{
28    bip353::{self, Bip353Client},
29    lnurl::LnurlClient,
30};
31use sdk_core::models::{
32    SdkCreateInvoiceRequest, SdkCreateInvoiceResponse, SdkGetPaymentRequest,
33    SdkGetPaymentResponse, SdkNodeInfo, SdkPayInvoiceRequest,
34    SdkPayInvoiceResponse,
35};
36use tracing::info;
37
38use crate::{
39    config::{
40        WalletEnvConfig, WalletEnvDbConfig, WalletUserConfig,
41        WalletUserDbConfig,
42    },
43    payments_db::PaymentsDb,
44    unstable::{ffs::DiskFs, provision, wallet_db::WalletDb},
45};
46
47/// Type state indicating the wallet has persistence enabled.
48pub struct WithDb;
49/// Type state indicating the wallet has no persistence.
50pub struct WithoutDb;
51
52/// Top-level handle to a Lexe wallet.
53///
54/// Exposes simple and ~stable APIs for easy management of a Lexe wallet.
55pub struct LexeWallet<Db> {
56    user_config: WalletUserConfig,
57
58    /// Database for persistent storage
59    /// Present iff `Db` = `WithDb`.
60    db: Option<WalletDb<DiskFs>>,
61
62    gateway_client: GatewayClient,
63    node_client: NodeClient,
64    #[allow(dead_code)] // TODO(max): Remove
65    bip353_client: Bip353Client,
66    #[allow(dead_code)] // TODO(max): Remove
67    lnurl_client: LnurlClient,
68
69    _marker: PhantomData<Db>,
70}
71
72// TODO(max): Consider what happens if someone provides *both* a client
73// credential and a root seed for the same user. Do we need locks for the dbs?
74
75impl LexeWallet<WithDb> {
76    /// Create a fresh [`LexeWallet`], deleting any existing database state for
77    /// this user. Data for other users and environments is not affected.
78    ///
79    /// It is recommended to always pass the same `lexe_data_dir`,
80    /// regardless of which environment we're in (dev/staging/prod) and which
81    /// user this [`LexeWallet`] is for. Users and environments will not
82    /// interfere with each other as all data is namespaced internally.
83    /// Defaults to `~/.lexe` if not specified.
84    pub fn fresh(
85        rng: &mut impl Crng,
86        env_config: WalletEnvConfig,
87        credentials: CredentialsRef<'_>,
88        lexe_data_dir: Option<PathBuf>,
89    ) -> anyhow::Result<Self> {
90        let lexe_data_dir =
91            lexe_data_dir.map_or_else(crate::default_lexe_data_dir, Ok)?;
92        let env_db_config =
93            WalletEnvDbConfig::new(env_config.wallet_env, lexe_data_dir);
94        let user_db_config =
95            WalletUserDbConfig::from_credentials(credentials, env_db_config)?;
96
97        let db = WalletDb::fresh(user_db_config)
98            .context("Failed to create fresh wallet db")?;
99
100        Self::with_db(rng, env_config, credentials, db)
101    }
102
103    /// Load an existing [`LexeWallet`] with persistence from `lexe_data_dir`.
104    /// Returns [`None`] if no local data exists, in which case you should use
105    /// [`fresh`] to create the wallet and local data cache.
106    ///
107    /// If you are authenticating with [`RootSeed`]s and this returns [`None`],
108    /// you should call [`signup`] after creating the wallet if you're not sure
109    /// whether the user has been signed up with Lexe.
110    ///
111    /// It is recommended to always pass the same `lexe_data_dir`,
112    /// regardless of which environment we're in (dev/staging/prod) and which
113    /// user this [`LexeWallet`] is for. Users and environments will not
114    /// interfere with each other as all data is namespaced internally.
115    /// Defaults to `~/.lexe` if not specified.
116    ///
117    /// [`fresh`]: LexeWallet::fresh
118    /// [`signup`]: LexeWallet::signup
119    pub fn load(
120        rng: &mut impl Crng,
121        env_config: WalletEnvConfig,
122        credentials: CredentialsRef<'_>,
123        lexe_data_dir: Option<PathBuf>,
124    ) -> anyhow::Result<Option<Self>> {
125        let lexe_data_dir =
126            lexe_data_dir.map_or_else(crate::default_lexe_data_dir, Ok)?;
127        let env_db_config =
128            WalletEnvDbConfig::new(env_config.wallet_env, lexe_data_dir);
129        let user_db_config =
130            WalletUserDbConfig::from_credentials(credentials, env_db_config)?;
131
132        let maybe_db = WalletDb::load(user_db_config)
133            .context("Failed to load wallet db")?;
134        let db = match maybe_db {
135            Some(d) => d,
136            None => return Ok(None),
137        };
138
139        Self::with_db(rng, env_config, credentials, db).map(Some)
140    }
141
142    /// Load an existing [`LexeWallet`] with persistence from `lexe_data_dir`,
143    /// or create a fresh one if no local data exists. If you are authenticating
144    /// with client credentials, this is generally what you want to use.
145    ///
146    /// It is recommended to always pass the same `lexe_data_dir`,
147    /// regardless of which environment we're in (dev/staging/prod) and which
148    /// user this [`LexeWallet`] is for. Users and environments will not
149    /// interfere with each other as all data is namespaced internally.
150    /// Defaults to `~/.lexe` if not specified.
151    pub fn load_or_fresh(
152        rng: &mut impl Crng,
153        env_config: WalletEnvConfig,
154        credentials: CredentialsRef<'_>,
155        lexe_data_dir: Option<PathBuf>,
156    ) -> anyhow::Result<Self> {
157        let lexe_data_dir =
158            lexe_data_dir.map_or_else(crate::default_lexe_data_dir, Ok)?;
159        let env_db_config =
160            WalletEnvDbConfig::new(env_config.wallet_env, lexe_data_dir);
161        let user_db_config =
162            WalletUserDbConfig::from_credentials(credentials, env_db_config)?;
163
164        let db = WalletDb::load_or_fresh(user_db_config)
165            .context("Failed to load or create wallet db")?;
166
167        Self::with_db(rng, env_config, credentials, db)
168    }
169
170    // Internal constructor for a wallet with `WalletDb` enabled.
171    fn with_db(
172        rng: &mut impl Crng,
173        env_config: WalletEnvConfig,
174        credentials: CredentialsRef<'_>,
175        db: WalletDb<DiskFs>,
176    ) -> anyhow::Result<Self> {
177        let user_pk = credentials.user_pk().context(
178            "Client credentials are out of date. \
179             Please create a new one from within the Lexe wallet app.",
180        )?;
181
182        let user_config = WalletUserConfig {
183            user_pk,
184            env_config: env_config.clone(),
185        };
186
187        let gateway_client = GatewayClient::new(
188            env_config.wallet_env.deploy_env,
189            env_config.gateway_url.clone(),
190            env_config.user_agent.clone(),
191        )
192        .context("Failed to build GatewayClient")?;
193
194        let node_client = NodeClient::new(
195            rng,
196            env_config.wallet_env.use_sgx,
197            env_config.wallet_env.deploy_env,
198            gateway_client.clone(),
199            credentials,
200        )
201        .context("Failed to build NodeClient")?;
202
203        let bip353_client = Bip353Client::new(bip353::GOOGLE_DOH_ENDPOINT)
204            .context("Failed to build BIP353 client")?;
205
206        let lnurl_client = LnurlClient::new(env_config.wallet_env.deploy_env)
207            .context("Failed to build LNURL client")?;
208
209        Ok(Self {
210            user_config,
211            db: Some(db),
212            gateway_client,
213            node_client,
214            bip353_client,
215            lnurl_client,
216            _marker: PhantomData,
217        })
218    }
219
220    /// Get a reference to the [`WalletDb`].
221    #[cfg(feature = "unstable")]
222    pub fn db(&self) -> &WalletDb<DiskFs> {
223        self.db.as_ref().expect("WithDb always has db")
224    }
225
226    /// Get a reference to the [`PaymentsDb`].
227    /// This is the primary data source for constructing a payments list UI.
228    pub fn payments_db(&self) -> &PaymentsDb<DiskFs> {
229        self.db
230            .as_ref()
231            .expect("WithDb always has db")
232            .payments_db()
233    }
234
235    /// Sync payments from the user node to the local database.
236    /// This fetches updated payments from the node and persists them locally.
237    ///
238    /// Only one sync can run at a time.
239    /// Errors if another sync is already in progress.
240    pub async fn sync_payments(
241        &self,
242    ) -> anyhow::Result<crate::payments_db::PaymentSyncSummary> {
243        self.db
244            .as_ref()
245            .expect("WithDb always has db")
246            .sync_payments(
247                &self.node_client,
248                common::constants::DEFAULT_PAYMENTS_BATCH_SIZE,
249            )
250            .await
251    }
252}
253
254impl LexeWallet<WithoutDb> {
255    /// Create a [`LexeWallet`] without any persistence. It is recommended to
256    /// use [`fresh`] or [`load`] instead, to initialize with persistence.
257    ///
258    /// [`fresh`]: LexeWallet::fresh
259    /// [`load`]: LexeWallet::load
260    pub fn without_db(
261        rng: &mut impl Crng,
262        env_config: WalletEnvConfig,
263        credentials: CredentialsRef<'_>,
264    ) -> anyhow::Result<Self> {
265        let user_pk = credentials.user_pk().context(
266            "Client credentials are out of date. \
267             Please create a new one from within the Lexe wallet app.",
268        )?;
269
270        let user_config = WalletUserConfig {
271            user_pk,
272            env_config: env_config.clone(),
273        };
274
275        let gateway_client = GatewayClient::new(
276            env_config.wallet_env.deploy_env,
277            env_config.gateway_url.clone(),
278            env_config.user_agent.clone(),
279        )
280        .context("Failed to build GatewayClient")?;
281
282        let node_client = NodeClient::new(
283            rng,
284            env_config.wallet_env.use_sgx,
285            env_config.wallet_env.deploy_env,
286            gateway_client.clone(),
287            credentials,
288        )
289        .context("Failed to build NodeClient")?;
290
291        let bip353_client = Bip353Client::new(bip353::GOOGLE_DOH_ENDPOINT)
292            .context("Failed to build BIP353 client")?;
293
294        let lnurl_client = LnurlClient::new(env_config.wallet_env.deploy_env)
295            .context("Failed to build LNURL client")?;
296
297        Ok(Self {
298            user_config,
299            db: None,
300            gateway_client,
301            node_client,
302            bip353_client,
303            lnurl_client,
304            _marker: PhantomData,
305        })
306    }
307}
308
309impl<D> LexeWallet<D> {
310    /// Registers this user with the Lexe backend, then provisions the node.
311    /// This function must be called after the user's [`LexeWallet`] has been
312    /// created for the first time, otherwise subsequent requests will fail.
313    ///
314    /// It is only necessary to call this function once, ever, per user, but it
315    /// is also okay to call this function even if the user has already been
316    /// signed up; in other words, this function is idempotent.
317    ///
318    /// After a successful signup, make sure the user's root seed has been
319    /// persisted somewhere! Without access to their root seed, your user will
320    /// lose their funds forever. If adding Lexe to a broader wallet, a good
321    /// strategy is to derive Lexe's [`RootSeed`] from your own root seed.
322    ///
323    /// - `partner_pk`: Set to your company's [`UserPk`] to earn a share of this
324    ///   wallet's fees.
325    ///
326    /// [`fresh()`]: LexeWallet::fresh
327    /// [`without_db()`]: LexeWallet::without_db
328    pub async fn signup(
329        &self,
330        rng: &mut impl Crng,
331        root_seed: &RootSeed,
332        partner_pk: Option<UserPk>,
333    ) -> anyhow::Result<()> {
334        self.signup_inner(
335            rng, root_seed, partner_pk, None,  // signup_code
336            false, // allow_gvfs_access
337            None,  // backup_password
338            None,  // google_auth_code
339        )
340        .await
341    }
342
343    /// [`signup`](Self::signup) but with extra parameters generally only used
344    /// by the Lexe App.
345    #[cfg(feature = "unstable")]
346    pub async fn signup_custom(
347        &self,
348        rng: &mut impl Crng,
349        root_seed: &RootSeed,
350        partner_pk: Option<UserPk>,
351        signup_code: Option<String>,
352        allow_gvfs_access: bool,
353        backup_password: Option<&str>,
354        google_auth_code: Option<String>,
355    ) -> anyhow::Result<()> {
356        self.signup_inner(
357            rng,
358            root_seed,
359            partner_pk,
360            signup_code,
361            allow_gvfs_access,
362            backup_password,
363            google_auth_code,
364        )
365        .await
366    }
367
368    // Inner implementation shared by both stable and unstable APIs.
369    #[cfg_attr(not(feature = "unstable"), allow(dead_code))]
370    async fn signup_inner(
371        &self,
372        rng: &mut impl Crng,
373        root_seed: &RootSeed,
374        partner_pk: Option<UserPk>,
375        signup_code: Option<String>,
376        allow_gvfs_access: bool,
377        backup_password: Option<&str>,
378        google_auth_code: Option<String>,
379    ) -> anyhow::Result<()> {
380        // Derive keys and build signup request
381        let user_key_pair = root_seed.derive_user_key_pair();
382        let node_key_pair = root_seed.derive_node_key_pair(rng);
383        let node_pk_proof = NodePkProof::sign(rng, &node_key_pair);
384
385        let signup_req = UserSignupRequestWire::V2(UserSignupRequestWireV2 {
386            v1: UserSignupRequestWireV1 {
387                node_pk_proof,
388                signup_code,
389            },
390            partner: partner_pk,
391        });
392        let signed_signup_req = user_key_pair
393            .sign_struct(&signup_req)
394            .map(|(_buf, signed)| signed)
395            .expect("Should never fail to serialize UserSignupRequestWire");
396
397        // Register with backend
398        self.gateway_client
399            .signup_v2(&signed_signup_req)
400            .await
401            .context("Failed to signup user")?;
402
403        // Encrypt seed if backup password provided.
404        // NOTE: This is very slow; 600K HMAC iterations!
405        let encrypted_seed = backup_password
406            .map(|password| root_seed.password_encrypt(rng, password))
407            .transpose()
408            .context("Could not encrypt root seed under password")?;
409
410        // Initial provisioning
411        let credentials = CredentialsRef::from(root_seed);
412        self.provision_inner(
413            credentials,
414            allow_gvfs_access,
415            encrypted_seed,
416            google_auth_code,
417        )
418        .await
419        .context("Initial provision failed")?;
420
421        Ok(())
422    }
423
424    /// Ensures the wallet is provisioned to all recent trusted releases.
425    /// This should be called every time the wallet is loaded, to ensure the
426    /// user is running the most up-to-date enclave software.
427    ///
428    /// This fetches the current enclaves from the gateway, computes which
429    /// releases need to be provisioned, and provisions them.
430    pub async fn provision(
431        &self,
432        credentials: CredentialsRef<'_>,
433    ) -> anyhow::Result<()> {
434        self.provision_inner(
435            credentials,
436            false, // allow_gvfs_access
437            None,  // encrypted_seed
438            None,  // google_auth_code
439        )
440        .await
441    }
442
443    /// [`provision`](Self::provision) but with extra parameters generally only
444    /// used by the Lexe App.
445    #[cfg(feature = "unstable")]
446    pub async fn provision_custom(
447        &self,
448        credentials: CredentialsRef<'_>,
449        allow_gvfs_access: bool,
450        encrypted_seed: Option<Vec<u8>>,
451        google_auth_code: Option<String>,
452    ) -> anyhow::Result<()> {
453        self.provision_inner(
454            credentials,
455            allow_gvfs_access,
456            encrypted_seed,
457            google_auth_code,
458        )
459        .await
460    }
461
462    // Inner implementation shared by both stable and unstable APIs.
463    #[cfg_attr(not(feature = "unstable"), allow(dead_code))]
464    async fn provision_inner(
465        &self,
466        credentials: CredentialsRef<'_>,
467        allow_gvfs_access: bool,
468        encrypted_seed: Option<Vec<u8>>,
469        google_auth_code: Option<String>,
470    ) -> anyhow::Result<()> {
471        // Only RootSeed can sign; delegated provisioning not implemented yet.
472        let CredentialsRef::RootSeed(_root_seed_ref) = credentials else {
473            return Err(anyhow!(
474                "Delegated provisioning is not implemented yet"
475            ));
476        };
477
478        let wallet_env = self.user_config.env_config.wallet_env;
479
480        // Get a bearer token for authentication.
481        let token = self
482            .node_client
483            .request_provision_token()
484            .await
485            .context("Could not get bearer token")?;
486
487        // Build request with our trusted measurements.
488        let req = EnclavesToProvisionRequest {
489            trusted_measurements: provision::LATEST_TRUSTED_MEASUREMENTS
490                .clone(),
491        };
492
493        let enclaves_to_provision = self
494            .gateway_client
495            .enclaves_to_provision(&req, token)
496            .await
497            .context("Could not fetch enclaves to provision")?;
498
499        // Client-side verification: ensure backend only returned enclaves we
500        // trust. Skip in dev mode since measurements are mocked.
501        if wallet_env.deploy_env.is_staging_or_prod() {
502            let all_trusted =
503                enclaves_to_provision.enclaves.iter().all(|enclave| {
504                    provision::LATEST_TRUSTED_MEASUREMENTS
505                        .contains(&enclave.measurement)
506                });
507            ensure!(all_trusted, "Backend returned untrusted enclaves:");
508        }
509
510        if enclaves_to_provision.enclaves.is_empty() {
511            info!("Already provisioned to all recent releases");
512            return Ok(());
513        }
514
515        info!(
516            "Provisioning enclaves: {:?}",
517            enclaves_to_provision.enclaves
518        );
519
520        match credentials {
521            CredentialsRef::RootSeed(root_seed_ref) => {
522                let root_seed = provision::clone_root_seed(root_seed_ref);
523
524                provision::provision_all(
525                    self.node_client.clone(),
526                    enclaves_to_provision.enclaves,
527                    root_seed,
528                    wallet_env,
529                    google_auth_code,
530                    allow_gvfs_access,
531                    encrypted_seed,
532                )
533                .await
534                .context("Root seed provision_all failed")?;
535            }
536            // TODO(max): Implement delegated provisioning
537            CredentialsRef::ClientCredentials(_) =>
538                return Err(anyhow!(
539                    "Delegated provisioning is not implemented yet"
540                )),
541        }
542
543        Ok(())
544    }
545
546    /// Get a reference to the user's wallet configuration.
547    pub fn user_config(&self) -> &WalletUserConfig {
548        &self.user_config
549    }
550
551    /// Get a reference to the inner [`GatewayClient`].
552    #[cfg(feature = "unstable")]
553    pub fn gateway_client(&self) -> &GatewayClient {
554        &self.gateway_client
555    }
556
557    /// Get a reference to the inner [`NodeClient`].
558    #[cfg(feature = "unstable")]
559    pub fn node_client(&self) -> &NodeClient {
560        &self.node_client
561    }
562
563    /// Get a reference to the inner [`Bip353Client`].
564    #[cfg(feature = "unstable")]
565    pub fn bip353_client(&self) -> &Bip353Client {
566        &self.bip353_client
567    }
568
569    /// Get a reference to the inner [`LnurlClient`].
570    #[cfg(feature = "unstable")]
571    pub fn lnurl_client(&self) -> &LnurlClient {
572        &self.lnurl_client
573    }
574
575    // --- Command API --- //
576
577    /// Get information about this Lexe node.
578    pub async fn node_info(&self) -> anyhow::Result<SdkNodeInfo> {
579        self.node_client
580            .node_info()
581            .await
582            .map(SdkNodeInfo::from)
583            .context("Failed to get node info")
584    }
585
586    /// Create a BOLT 11 invoice.
587    pub async fn create_invoice(
588        &self,
589        req: SdkCreateInvoiceRequest,
590    ) -> anyhow::Result<SdkCreateInvoiceResponse> {
591        let resp = self
592            .node_client
593            .create_invoice(req.into())
594            .await
595            .context("Failed to create invoice")?;
596
597        let index = resp.created_index.context("Node is out of date")?;
598
599        Ok(SdkCreateInvoiceResponse::new(index, resp.invoice))
600    }
601
602    /// Pay a BOLT 11 invoice.
603    pub async fn pay_invoice(
604        &self,
605        req: SdkPayInvoiceRequest,
606    ) -> anyhow::Result<SdkPayInvoiceResponse> {
607        let id = req.invoice.payment_id();
608        let resp = self
609            .node_client
610            .pay_invoice(PayInvoiceRequest::from(req))
611            .await
612            .context("Failed to pay invoice")?;
613
614        let index = PaymentCreatedIndex {
615            created_at: resp.created_at,
616            id,
617        };
618
619        Ok(SdkPayInvoiceResponse {
620            index,
621            created_at: resp.created_at,
622        })
623    }
624
625    /// Get information about a payment by its index.
626    pub async fn get_payment(
627        &self,
628        req: SdkGetPaymentRequest,
629    ) -> anyhow::Result<SdkGetPaymentResponse> {
630        let id = req.index.id;
631        let payment = self
632            .node_client
633            .get_payment_by_id(LxPaymentIdStruct { id })
634            .await
635            .context("Failed to get payment")?
636            .maybe_payment
637            .map(Into::into);
638
639        Ok(SdkGetPaymentResponse { payment })
640    }
641
642    /// Update the note on an existing payment.
643    pub async fn update_payment_note(
644        &self,
645        req: UpdatePaymentNote,
646    ) -> anyhow::Result<()> {
647        // Update remote store first
648        self.node_client
649            .update_payment_note(req.clone())
650            .await
651            .context("Failed to update payment note on user node")?;
652
653        // Success. If persistence is enabled, update the local payments store.
654        if let Some(db) = &self.db {
655            db.payments_db().update_payment_note(req)?;
656        }
657
658        Ok(())
659    }
660}