Skip to main content

cdk_supabase/
wallet.rs

1use std::collections::HashMap;
2use std::fmt::Debug;
3use std::str::FromStr;
4use std::sync::Arc;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use aes_gcm::aead::{Aead, AeadCore, KeyInit};
8use aes_gcm::{Aes256Gcm, Key, Nonce};
9use async_trait::async_trait;
10use bitcoin::bip32::DerivationPath;
11use bitcoin::hashes::{sha256, Hash};
12use bitcoin::secp256k1::rand::rngs::OsRng;
13use cdk_common::auth::oidc::OidcClient;
14use cdk_common::common::ProofInfo;
15use cdk_common::database::wallet::Database;
16use cdk_common::database::{Error as DatabaseError, KVStoreDatabase};
17use cdk_common::mint_url::MintUrl;
18use cdk_common::nuts::{
19    CurrencyUnit, Id, KeySet, KeySetInfo, Keys, MintInfo, PublicKey, SpendingConditions, State,
20};
21use cdk_common::secret::Secret;
22use cdk_common::util::hex;
23use cdk_common::wallet::{
24    self, MintQuote, Transaction, TransactionDirection, TransactionId, WalletSaga,
25};
26use reqwest::{Client, StatusCode};
27use serde::{Deserialize, Serialize};
28use tokio::sync::RwLock;
29use url::Url;
30
31use crate::Error;
32
33#[rustfmt::skip]
34mod migrations {
35    include!(concat!(env!("OUT_DIR"), "/migrations_supabase.rs"));
36}
37
38/// Returns the concatenated SQL of all migration files.
39///
40/// Operators can use this to set up the database manually via the Supabase
41/// Dashboard SQL editor or `supabase db push`.
42pub(crate) fn get_schema_sql_inner() -> String {
43    migrations::MIGRATIONS
44        .iter()
45        .map(|(_, _, sql)| *sql)
46        .collect::<Vec<&str>>()
47        .join("\n\n")
48}
49
50/// URL-encode a value for use in query parameters
51fn url_encode(value: &str) -> String {
52    urlencoding::encode(value).into_owned()
53}
54
55/// Decode JWT expiration from token string (without verification)
56fn decode_jwt_expiry(token: &str) -> Option<u64> {
57    let parts: Vec<&str> = token.split('.').collect();
58    if parts.len() < 2 {
59        return None;
60    }
61    let payload_part = parts[1];
62
63    use bitcoin::base64::engine::general_purpose;
64    use bitcoin::base64::Engine as _;
65
66    let decoded = general_purpose::URL_SAFE_NO_PAD.decode(payload_part).ok()?;
67
68    #[derive(Deserialize)]
69    struct Claims {
70        exp: Option<u64>,
71    }
72
73    let claims: Claims = serde_json::from_slice(&decoded).ok()?;
74    claims.exp
75}
76
77/// Authentication provider for Supabase
78///
79/// This enum abstracts the token refresh logic for different authentication methods.
80#[derive(Debug, Clone)]
81pub enum AuthProvider {
82    /// No authentication provider - uses API key only, no automatic token refresh
83    None,
84    /// Supabase Auth (GoTrue) - uses Supabase's built-in authentication
85    ///
86    /// Token refresh uses `POST /auth/v1/token` with `grant_type=refresh_token`
87    SupabaseAuth,
88    /// External OIDC provider - uses standard OIDC discovery and token endpoint
89    Oidc(OidcClient),
90}
91
92/// Response from Supabase Auth token refresh
93#[derive(Debug, Deserialize)]
94struct SupabaseTokenResponse {
95    access_token: String,
96    refresh_token: Option<String>,
97    expires_in: Option<i64>,
98    #[serde(skip)]
99    _token_type: (),
100}
101
102/// Supabase wallet database implementation
103///
104/// This database uses two types of authentication:
105/// - `api_key`: The Supabase project API key (required, used in `apikey` header)
106/// - `jwt_token`: An optional JWT token for user authentication (used in `Authorization: Bearer` header)
107///
108/// When `jwt_token` is set, requests will include both headers:
109/// - `apikey: <api_key>`
110/// - `Authorization: Bearer <jwt_token>`
111///
112/// When `jwt_token` is not set, the `api_key` is used for both headers (legacy behavior).
113///
114/// ## Authentication Providers
115///
116/// The database supports multiple authentication providers via [`AuthProvider`]:
117/// - **None**: No automatic token refresh, use API key only
118/// - **SupabaseAuth**: Uses Supabase's GoTrue API for token refresh
119/// - **Oidc**: Uses an external OIDC provider for token refresh
120#[derive(Debug, Clone)]
121pub struct SupabaseWalletDatabase {
122    url: Url,
123    api_key: String,
124    jwt_token: Arc<RwLock<Option<String>>>,
125    refresh_token: Arc<RwLock<Option<String>>>,
126    token_expiration: Arc<RwLock<Option<u64>>>,
127    auth_provider: Arc<RwLock<AuthProvider>>,
128    client: Client,
129    encryption_key: Arc<RwLock<Option<Key<Aes256Gcm>>>>,
130}
131
132impl SupabaseWalletDatabase {
133    /// Create a new SupabaseWalletDatabase with API key only (legacy behavior)
134    ///
135    /// No automatic token refresh is configured.
136    ///
137    /// **Note**: This does NOT run or check migrations automatically. After
138    /// authentication, call [`check_schema_compatibility()`] to verify the
139    /// database schema is ready. Migrations must be run separately by an
140    /// administrator — see [`get_schema_sql()`] or use `supabase db push`.
141    pub async fn new(url: Url, api_key: String) -> Result<Self, Error> {
142        Ok(Self {
143            url,
144            api_key,
145            jwt_token: Arc::new(RwLock::new(None)),
146            refresh_token: Arc::new(RwLock::new(None)),
147            token_expiration: Arc::new(RwLock::new(None)),
148            auth_provider: Arc::new(RwLock::new(AuthProvider::None)),
149            client: Client::new(),
150            encryption_key: Arc::new(RwLock::new(None)),
151        })
152    }
153
154    /// Create a new SupabaseWalletDatabase with Supabase Auth for token refresh
155    ///
156    /// This uses Supabase's built-in GoTrue authentication system.
157    /// Token refresh uses `POST /auth/v1/token` with `grant_type=refresh_token`.
158    ///
159    /// **Note**: This does NOT run or check migrations automatically. After
160    /// authentication, call [`check_schema_compatibility()`] to verify the
161    /// database schema is ready. Migrations must be run separately by an
162    /// administrator — see [`get_schema_sql()`] or use `supabase db push`.
163    pub async fn with_supabase_auth(url: Url, api_key: String) -> Result<Self, Error> {
164        Ok(Self {
165            url,
166            api_key,
167            jwt_token: Arc::new(RwLock::new(None)),
168            refresh_token: Arc::new(RwLock::new(None)),
169            token_expiration: Arc::new(RwLock::new(None)),
170            auth_provider: Arc::new(RwLock::new(AuthProvider::SupabaseAuth)),
171            client: Client::new(),
172            encryption_key: Arc::new(RwLock::new(None)),
173        })
174    }
175
176    /// Create a new SupabaseWalletDatabase with external OIDC client for auth
177    ///
178    /// This uses an external OIDC provider (e.g., Keycloak, Auth0) for token refresh.
179    /// The OIDC provider must be configured in Supabase to validate the JWTs.
180    ///
181    /// **Note**: This does NOT run or check migrations automatically. After
182    /// authentication, call [`check_schema_compatibility()`] to verify the
183    /// database schema is ready. Migrations must be run separately by an
184    /// administrator — see [`get_schema_sql()`] or use `supabase db push`.
185    pub async fn with_oidc(
186        url: Url,
187        api_key: String,
188        oidc_client: OidcClient,
189    ) -> Result<Self, Error> {
190        Ok(Self {
191            url,
192            api_key,
193            jwt_token: Arc::new(RwLock::new(None)),
194            refresh_token: Arc::new(RwLock::new(None)),
195            token_expiration: Arc::new(RwLock::new(None)),
196            auth_provider: Arc::new(RwLock::new(AuthProvider::Oidc(oidc_client))),
197            client: Client::new(),
198            encryption_key: Arc::new(RwLock::new(None)),
199        })
200    }
201
202    /// The schema version required by this SDK version.
203    ///
204    /// This must match the latest `schema_version` value set in the migration files.
205    /// When adding new migrations, update this constant and set the same value
206    /// in the new migration's `INSERT INTO schema_info` statement.
207    pub const REQUIRED_SCHEMA_VERSION: u32 = 5;
208
209    /// Get the full database schema SQL
210    ///
211    /// Returns the concatenated SQL of all migration files.
212    ///
213    /// Use this to set up or update the database schema by running the output
214    /// through the Supabase Dashboard SQL editor or `supabase db push`.
215    /// This is an **admin-only operation** — never run this from a client app.
216    pub fn get_schema_sql() -> String {
217        get_schema_sql_inner()
218    }
219
220    /// Check that the database schema is compatible with this SDK version
221    ///
222    /// This is the **recommended client-side startup check**. It queries the
223    /// `schema_info` table (which is readable by all authenticated users) to
224    /// verify the database has the required schema version.
225    ///
226    /// # Errors
227    ///
228    /// - [`Error::SchemaNotInitialized`] if the `schema_info` table doesn't exist
229    ///   (database was never set up or is running a pre-v4 schema).
230    /// - [`Error::SchemaMismatch`] if the database schema version is older than
231    ///   what this SDK version requires.
232    ///
233    /// # Example
234    ///
235    /// ```rust,ignore
236    /// // Call after authentication, before using the database
237    /// db.check_schema_compatibility().await?;
238    /// // Database is ready for use
239    /// ```
240    pub async fn check_schema_compatibility(&self) -> Result<(), Error> {
241        let path = "rest/v1/schema_info?key=eq.schema_version&select=value";
242
243        let result = self.get_request(path).await;
244
245        match result {
246            Ok((status, text)) => {
247                if status == StatusCode::NOT_FOUND
248                    || text.contains("relation")
249                    || text.contains("does not exist")
250                {
251                    return Err(Error::SchemaNotInitialized);
252                }
253
254                if !status.is_success() {
255                    // If we get a 404-like error or permission error, schema_info
256                    // table likely doesn't exist
257                    return Err(Error::SchemaNotInitialized);
258                }
259
260                // Parse the response: [{"value": "4"}] or []
261                let items: Vec<serde_json::Value> =
262                    serde_json::from_str(&text).map_err(|_| Error::SchemaNotInitialized)?;
263
264                if items.is_empty() {
265                    return Err(Error::SchemaNotInitialized);
266                }
267
268                let version_str = items[0]
269                    .get("value")
270                    .and_then(|v| v.as_str())
271                    .ok_or(Error::SchemaNotInitialized)?;
272
273                let found_version: u32 = version_str
274                    .parse()
275                    .map_err(|_| Error::SchemaNotInitialized)?;
276
277                if found_version < Self::REQUIRED_SCHEMA_VERSION {
278                    return Err(Error::SchemaMismatch {
279                        required: Self::REQUIRED_SCHEMA_VERSION,
280                        found: found_version,
281                    });
282                }
283
284                tracing::info!(
285                    schema_version = found_version,
286                    required = Self::REQUIRED_SCHEMA_VERSION,
287                    "Database schema compatibility check passed"
288                );
289
290                Ok(())
291            }
292            Err(_) => Err(Error::SchemaNotInitialized),
293        }
294    }
295
296    /// Set or update the JWT token for authentication
297    pub async fn set_jwt_token(&self, token: Option<String>) {
298        let mut jwt = self.jwt_token.write().await;
299        *jwt = token.clone();
300
301        let mut expiration = self.token_expiration.write().await;
302
303        if let Some(t) = token {
304            *expiration = decode_jwt_expiry(&t);
305        } else {
306            *expiration = None;
307        }
308    }
309
310    /// Set refresh token
311    pub async fn set_refresh_token(&self, token: Option<String>) {
312        let mut refresh = self.refresh_token.write().await;
313        *refresh = token;
314    }
315
316    /// Derives an AES-256-GCM encryption key from `password` via SHA-256.
317    pub async fn set_encryption_password(&self, password: &str) {
318        let key = sha256::Hash::hash(password.as_bytes());
319
320        let mut encryption_key = self.encryption_key.write().await;
321        *encryption_key = Some(*Key::<Aes256Gcm>::from_slice(key.as_byte_array()));
322    }
323
324    async fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>, DatabaseError> {
325        let key_guard = self.encryption_key.read().await;
326        let key = key_guard
327            .as_ref()
328            .ok_or(DatabaseError::Internal("Encryption key not set".into()))?;
329        let cipher = Aes256Gcm::new(key);
330        let nonce = Aes256Gcm::generate_nonce(&mut OsRng); // 96-bits; unique per message
331        let ciphertext = cipher
332            .encrypt(&nonce, data)
333            .map_err(|_| DatabaseError::Internal("Encryption failed".into()))?;
334
335        // Prepend nonce to ciphertext
336        let mut result = nonce.to_vec();
337        result.extend_from_slice(&ciphertext);
338        Ok(result)
339    }
340
341    async fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>, DatabaseError> {
342        let key_guard = self.encryption_key.read().await;
343        let key = key_guard
344            .as_ref()
345            .ok_or(DatabaseError::Internal("Encryption key not set".into()))?;
346        let cipher = Aes256Gcm::new(key);
347
348        if data.len() < 12 {
349            return Err(DatabaseError::Internal("Invalid ciphertext length".into()));
350        }
351
352        let nonce = Nonce::from_slice(&data[0..12]);
353        let ciphertext = &data[12..];
354
355        cipher
356            .decrypt(nonce, ciphertext)
357            .map_err(|_| DatabaseError::Internal("Decryption failed".into()))
358    }
359
360    async fn decrypt_proof_table(&self, p: &mut ProofTable) {
361        // Decrypt secret
362        if let Ok(encrypted_bytes) = hex::decode(&p.secret) {
363            if let Ok(decrypted) = self.decrypt(&encrypted_bytes).await {
364                if let Ok(secret_str) = String::from_utf8(decrypted) {
365                    p.secret = secret_str;
366                }
367            }
368        }
369
370        // Decrypt C
371        if let Ok(encrypted_c) = hex::decode(&p.c) {
372            if let Ok(decrypted_c) = self.decrypt(&encrypted_c).await {
373                p.c = hex::encode(decrypted_c);
374            }
375        }
376    }
377
378    /// Refresh the access token using the stored refresh token
379    ///
380    /// This method handles different authentication providers:
381    /// - **SupabaseAuth**: Uses `POST /auth/v1/token` with `grant_type=refresh_token`
382    /// - **Oidc**: Uses the OIDC provider's token endpoint
383    /// - **None**: Returns an error (no provider configured)
384    pub async fn refresh_access_token(&self) -> Result<(), Error> {
385        let refresh_token = self.refresh_token.read().await.clone();
386        let refresh = refresh_token
387            .ok_or_else(|| Error::Supabase("No refresh token available".to_string()))?;
388
389        let auth_provider = self.auth_provider.read().await.clone();
390
391        match auth_provider {
392            AuthProvider::None => {
393                return Err(Error::Supabase(
394                    "No authentication provider configured".to_string(),
395                ));
396            }
397            AuthProvider::SupabaseAuth => {
398                // Use Supabase GoTrue API for token refresh
399                let auth_url = self
400                    .url
401                    .join("auth/v1/token?grant_type=refresh_token")
402                    .map_err(|e| Error::Supabase(format!("Invalid auth URL: {}", e)))?;
403
404                let body = serde_json::json!({
405                    "refresh_token": refresh
406                });
407
408                let response = self
409                    .client
410                    .post(auth_url)
411                    .header("apikey", &self.api_key)
412                    .header("Content-Type", "application/json")
413                    .json(&body)
414                    .send()
415                    .await
416                    .map_err(Error::Reqwest)?;
417
418                let status = response.status();
419                if !status.is_success() {
420                    let text = response.text().await.unwrap_or_default();
421                    return Err(Error::Supabase(format!(
422                        "Supabase token refresh failed: HTTP {} - {}",
423                        status, text
424                    )));
425                }
426
427                let token_response: SupabaseTokenResponse =
428                    response.json().await.map_err(Error::Reqwest)?;
429
430                self.set_jwt_token(Some(token_response.access_token)).await;
431
432                if let Some(new_refresh) = token_response.refresh_token {
433                    self.set_refresh_token(Some(new_refresh)).await;
434                }
435
436                if let Some(expires_in) = token_response.expires_in {
437                    let expiration = SystemTime::now()
438                        .duration_since(UNIX_EPOCH)
439                        .map_err(|e| Error::Supabase(format!("SystemTime error: {}", e)))?
440                        .as_secs()
441                        + expires_in as u64;
442                    let mut exp = self.token_expiration.write().await;
443                    *exp = Some(expiration);
444                }
445            }
446            AuthProvider::Oidc(oidc) => {
447                let client_id = oidc.client_id().ok_or_else(|| {
448                    Error::Supabase("Client ID not set in OIDC client".to_string())
449                })?;
450
451                let response = oidc
452                    .refresh_access_token(client_id, refresh)
453                    .await
454                    .map_err(|e| Error::Supabase(e.to_string()))?;
455
456                self.set_jwt_token(Some(response.access_token)).await;
457
458                if let Some(new_refresh) = response.refresh_token {
459                    self.set_refresh_token(Some(new_refresh)).await;
460                }
461
462                if let Some(expires_in) = response.expires_in {
463                    let expiration = SystemTime::now()
464                        .duration_since(UNIX_EPOCH)
465                        .map_err(|e| Error::Supabase(format!("SystemTime error: {}", e)))?
466                        .as_secs()
467                        + expires_in as u64;
468                    let mut exp = self.token_expiration.write().await;
469                    *exp = Some(expiration);
470                }
471            }
472        }
473
474        Ok(())
475    }
476
477    /// Sign up a new user and automatically set tokens if returned
478    pub async fn signup(&self, email: &str, password: &str) -> Result<SupabaseAuthResponse, Error> {
479        let response = SupabaseAuth::signup(&self.url, &self.api_key, email, password).await?;
480
481        // If signup returns valid tokens (e.g. auto-confirm enabled), set them
482        if !response.access_token.is_empty() {
483            self.set_jwt_token(Some(response.access_token.clone()))
484                .await;
485        }
486        if let Some(refresh) = &response.refresh_token {
487            self.set_refresh_token(Some(refresh.clone())).await;
488        }
489
490        Ok(response)
491    }
492
493    /// Sign in a user and automatically set tokens on the database instance
494    pub async fn signin(&self, email: &str, password: &str) -> Result<SupabaseAuthResponse, Error> {
495        let response = SupabaseAuth::signin(&self.url, &self.api_key, email, password).await?;
496
497        self.set_jwt_token(Some(response.access_token.clone()))
498            .await;
499        if let Some(refresh) = &response.refresh_token {
500            self.set_refresh_token(Some(refresh.clone())).await;
501        }
502        if let Some(expires_in) = response.expires_in {
503            let expiration = SystemTime::now()
504                .duration_since(UNIX_EPOCH)
505                .map_err(|e| Error::Supabase(format!("SystemTime error: {}", e)))?
506                .as_secs()
507                + expires_in as u64;
508            let mut exp = self.token_expiration.write().await;
509            *exp = Some(expiration);
510        }
511
512        Ok(response)
513    }
514
515    /// Get the current JWT token if set
516    pub async fn get_jwt_token(&self) -> Option<String> {
517        self.jwt_token.read().await.clone()
518    }
519
520    /// Call a Supabase RPC function with JSON parameters
521    pub async fn call_rpc(&self, function_name: &str, params_json: &str) -> Result<String, Error> {
522        // Parse the JSON to validate it and convert to Value for sending
523        // Treat empty string as empty object for convenience
524        let params: serde_json::Value = if params_json.trim().is_empty() {
525            serde_json::Value::Object(serde_json::Map::new())
526        } else {
527            serde_json::from_str(params_json).map_err(Error::Serde)?
528        };
529
530        let path = format!("rest/v1/rpc/{}", function_name);
531        let url = self.join_url(&path)?;
532        let auth_bearer = self.get_auth_bearer().await;
533
534        let res = self
535            .client
536            .post(url.clone())
537            .header("apikey", &self.api_key)
538            .header("Authorization", format!("Bearer {}", auth_bearer))
539            .header("Content-Type", "application/json")
540            .json(&params)
541            .send()
542            .await
543            .map_err(Error::Reqwest)?;
544
545        let status = res.status();
546        let text = res.text().await.map_err(Error::Reqwest)?;
547
548        if !status.is_success() {
549            return Err(Error::Supabase(format!(
550                "RPC '{}' failed: HTTP {} - {}",
551                function_name, status, text
552            )));
553        }
554
555        Ok(text)
556    }
557
558    /// Get the authorization token to use for requests
559    ///
560    /// Returns the JWT token if set, otherwise falls back to the API key.
561    async fn get_auth_bearer(&self) -> String {
562        // Check expiration
563        let expiration = *self.token_expiration.read().await;
564        if let Some(exp) = expiration {
565            let now = SystemTime::now()
566                .duration_since(UNIX_EPOCH)
567                .expect("SystemTime should be after UNIX_EPOCH")
568                .as_secs();
569            // Refresh if expired or expiring in 60 seconds
570            if now + 60 > exp {
571                if let Err(e) = self.refresh_access_token().await {
572                    tracing::warn!("Failed to refresh token: {}", e);
573                }
574            }
575        }
576
577        self.jwt_token
578            .read()
579            .await
580            .clone()
581            .unwrap_or_else(|| self.api_key.clone())
582    }
583
584    /// Join the base URL with a path
585    pub fn join_url(&self, path: &str) -> Result<Url, DatabaseError> {
586        self.url
587            .join(path)
588            .map_err(|e| DatabaseError::Internal(e.to_string()))
589    }
590
591    /// Make a GET request and return the response text
592    async fn get_request(&self, path: &str) -> Result<(StatusCode, String), Error> {
593        let url = self.join_url(path)?;
594        let auth_bearer = self.get_auth_bearer().await;
595
596        tracing::debug!(method = "GET", url = %url, "Supabase request");
597
598        let res = self
599            .client
600            .get(url.clone())
601            .header("apikey", &self.api_key)
602            .header("Authorization", format!("Bearer {}", auth_bearer))
603            .send()
604            .await
605            .map_err(Error::Reqwest)?;
606
607        let status = res.status();
608        let text = res.text().await.map_err(Error::Reqwest)?;
609
610        tracing::debug!(method = "GET", url = %url, status = %status, response_len = text.len(), "Supabase response");
611
612        Ok((status, text))
613    }
614
615    /// Make a POST request with JSON body
616    async fn post_request<T: Serialize + Debug>(
617        &self,
618        path: &str,
619        body: &T,
620    ) -> Result<(StatusCode, String), Error> {
621        let url = self.join_url(path)?;
622        let auth_bearer = self.get_auth_bearer().await;
623
624        tracing::debug!(method = "POST", url = %url, "Supabase request");
625
626        let res = self
627            .client
628            .post(url.clone())
629            .header("apikey", &self.api_key)
630            .header("Authorization", format!("Bearer {}", auth_bearer))
631            .header("Prefer", "resolution=merge-duplicates,missing=default")
632            .json(body)
633            .send()
634            .await
635            .map_err(Error::Reqwest)?;
636
637        let status = res.status();
638        let text = res.text().await.map_err(Error::Reqwest)?;
639
640        tracing::debug!(method = "POST", url = %url, status = %status, response_len = text.len(), "Supabase response");
641
642        Ok((status, text))
643    }
644
645    /// Make a PATCH request with JSON body
646    async fn patch_request<T: Serialize + Debug>(
647        &self,
648        path: &str,
649        body: &T,
650    ) -> Result<(StatusCode, String), Error> {
651        let url = self.join_url(path)?;
652        let auth_bearer = self.get_auth_bearer().await;
653
654        tracing::debug!(method = "PATCH", url = %url, "Supabase request");
655
656        let res = self
657            .client
658            .patch(url.clone())
659            .header("apikey", &self.api_key)
660            .header("Authorization", format!("Bearer {}", auth_bearer))
661            .json(body)
662            .send()
663            .await
664            .map_err(Error::Reqwest)?;
665
666        let status = res.status();
667        let text = res.text().await.map_err(Error::Reqwest)?;
668
669        tracing::debug!(method = "PATCH", url = %url, status = %status, response_len = text.len(), "Supabase response");
670
671        Ok((status, text))
672    }
673
674    /// Make a PATCH request and ask PostgREST to return the updated rows as JSON
675    /// (`Prefer: return=representation`).  Returns `(status, body)` where body is
676    /// an empty JSON array `[]` when the filter matched no rows.
677    async fn patch_request_returning<T: Serialize + Debug>(
678        &self,
679        path: &str,
680        body: &T,
681    ) -> Result<(StatusCode, String), Error> {
682        let url = self.join_url(path)?;
683        let auth_bearer = self.get_auth_bearer().await;
684
685        tracing::debug!(method = "PATCH", url = %url, "Supabase request (returning)");
686
687        let res = self
688            .client
689            .patch(url.clone())
690            .header("apikey", &self.api_key)
691            .header("Authorization", format!("Bearer {}", auth_bearer))
692            .header("Prefer", "return=representation")
693            .json(body)
694            .send()
695            .await
696            .map_err(Error::Reqwest)?;
697
698        let status = res.status();
699        let text = res.text().await.map_err(Error::Reqwest)?;
700
701        tracing::debug!(method = "PATCH", url = %url, status = %status, response_len = text.len(), "Supabase response (returning)");
702
703        Ok((status, text))
704    }
705
706    /// Make a DELETE request
707    async fn delete_request(&self, path: &str) -> Result<(StatusCode, String), Error> {
708        let url = self.join_url(path)?;
709        let auth_bearer = self.get_auth_bearer().await;
710
711        tracing::debug!(method = "DELETE", url = %url, "Supabase request");
712
713        let res = self
714            .client
715            .delete(url.clone())
716            .header("apikey", &self.api_key)
717            .header("Authorization", format!("Bearer {}", auth_bearer))
718            .send()
719            .await
720            .map_err(Error::Reqwest)?;
721
722        let status = res.status();
723        let text = res.text().await.map_err(Error::Reqwest)?;
724
725        tracing::debug!(method = "DELETE", url = %url, status = %status, response_len = text.len(), "Supabase response");
726
727        Ok((status, text))
728    }
729
730    /// Parse a JSON response, returning None for empty responses
731    fn parse_response<T: serde::de::DeserializeOwned>(text: &str) -> Result<Option<Vec<T>>, Error> {
732        if text.trim().is_empty() || text.trim() == "[]" {
733            return Ok(None);
734        }
735        let items: Vec<T> = serde_json::from_str(text).map_err(Error::Serde)?;
736        if items.is_empty() {
737            Ok(None)
738        } else {
739            Ok(Some(items))
740        }
741    }
742}
743
744#[async_trait]
745impl KVStoreDatabase for SupabaseWalletDatabase {
746    type Err = DatabaseError;
747
748    async fn kv_read(
749        &self,
750        primary_namespace: &str,
751        secondary_namespace: &str,
752        key: &str,
753    ) -> Result<Option<Vec<u8>>, Self::Err> {
754        let path = format!(
755            "rest/v1/kv_store?primary_namespace=eq.{}&secondary_namespace=eq.{}&key=eq.{}",
756            url_encode(primary_namespace),
757            url_encode(secondary_namespace),
758            url_encode(key)
759        );
760
761        let (status, text) = self.get_request(&path).await?;
762
763        if status == StatusCode::NO_CONTENT || !status.is_success() {
764            if !status.is_success() && status != StatusCode::NO_CONTENT {
765                return Err(DatabaseError::Internal(format!(
766                    "kv_read failed: HTTP {}",
767                    status
768                )));
769            }
770            return Ok(None);
771        }
772
773        if let Some(items) = Self::parse_response::<KVStoreTable>(&text)? {
774            if let Some(item) = items.into_iter().next() {
775                let bytes = hex::decode(item.value)
776                    .map_err(|_| DatabaseError::Internal("Invalid hex in kv_store".into()))?;
777
778                // Decrypt value
779                let decrypted = self.decrypt(&bytes).await?;
780                return Ok(Some(decrypted));
781            }
782        }
783        Ok(None)
784    }
785
786    async fn kv_list(
787        &self,
788        primary_namespace: &str,
789        secondary_namespace: &str,
790    ) -> Result<Vec<String>, Self::Err> {
791        let path = format!(
792            "rest/v1/kv_store?primary_namespace=eq.{}&secondary_namespace=eq.{}",
793            url_encode(primary_namespace),
794            url_encode(secondary_namespace)
795        );
796
797        let (status, text) = self.get_request(&path).await?;
798
799        if !status.is_success() {
800            return Err(DatabaseError::Internal(format!(
801                "kv_list failed: HTTP {}",
802                status
803            )));
804        }
805
806        if let Some(items) = Self::parse_response::<KVStoreTable>(&text)? {
807            Ok(items.into_iter().map(|i| i.key).collect())
808        } else {
809            Ok(Vec::new())
810        }
811    }
812}
813#[async_trait]
814impl Database<DatabaseError> for SupabaseWalletDatabase {
815    async fn get_mint(&self, mint_url: MintUrl) -> Result<Option<MintInfo>, DatabaseError> {
816        let path = format!(
817            "rest/v1/mint?mint_url=eq.{}",
818            url_encode(&mint_url.to_string())
819        );
820        let (status, text) = self.get_request(&path).await?;
821
822        // 404 or empty result means not found
823        if status == StatusCode::NOT_FOUND {
824            return Ok(None);
825        }
826        if !status.is_success() {
827            return Err(DatabaseError::Internal(format!(
828                "get_mint failed: HTTP {}",
829                status
830            )));
831        }
832
833        if let Some(mints) = Self::parse_response::<MintTable>(&text)? {
834            if let Some(mint) = mints.into_iter().next() {
835                return Ok(Some(mint.try_into()?));
836            }
837        }
838        Ok(None)
839    }
840
841    async fn get_mints(&self) -> Result<HashMap<MintUrl, Option<MintInfo>>, DatabaseError> {
842        let (status, text) = self.get_request("rest/v1/mint").await?;
843
844        if !status.is_success() {
845            return Err(DatabaseError::Internal(format!(
846                "get_mints failed: HTTP {}",
847                status
848            )));
849        }
850
851        let mut map = HashMap::new();
852        if let Some(mints) = Self::parse_response::<MintTable>(&text)? {
853            for mint in mints {
854                map.insert(
855                    MintUrl::from_str(&mint.mint_url)
856                        .map_err(|e| DatabaseError::Internal(e.to_string()))?,
857                    Some(mint.try_into()?),
858                );
859            }
860        }
861        Ok(map)
862    }
863
864    async fn get_mint_keysets(
865        &self,
866        mint_url: MintUrl,
867    ) -> Result<Option<Vec<KeySetInfo>>, DatabaseError> {
868        let path = format!(
869            "rest/v1/keyset?mint_url=eq.{}",
870            url_encode(&mint_url.to_string())
871        );
872        let (status, text) = self.get_request(&path).await?;
873
874        if !status.is_success() {
875            return Err(DatabaseError::Internal(format!(
876                "get_mint_keysets failed: HTTP {}",
877                status
878            )));
879        }
880
881        if let Some(keysets) = Self::parse_response::<KeySetTable>(&text)? {
882            let result: Result<Vec<KeySetInfo>, _> =
883                keysets.into_iter().map(|ks| ks.try_into()).collect();
884            Ok(Some(result?))
885        } else {
886            Ok(None)
887        }
888    }
889
890    async fn get_keyset_by_id(&self, keyset_id: &Id) -> Result<Option<KeySetInfo>, DatabaseError> {
891        let path = format!(
892            "rest/v1/keyset?id=eq.{}",
893            url_encode(&keyset_id.to_string())
894        );
895        let (status, text) = self.get_request(&path).await?;
896
897        if !status.is_success() {
898            return Err(DatabaseError::Internal(format!(
899                "get_keyset_by_id failed: HTTP {}",
900                status
901            )));
902        }
903
904        if let Some(items) = Self::parse_response::<KeySetTable>(&text)? {
905            if let Some(item) = items.into_iter().next() {
906                return Ok(Some(item.try_into()?));
907            }
908        }
909        Ok(None)
910    }
911
912    async fn get_mint_quote(&self, quote_id: &str) -> Result<Option<MintQuote>, DatabaseError> {
913        let path = format!("rest/v1/mint_quote?id=eq.{}", url_encode(quote_id));
914        let (status, text) = self.get_request(&path).await?;
915
916        if !status.is_success() {
917            return Err(DatabaseError::Internal(format!(
918                "get_mint_quote failed: HTTP {}",
919                status
920            )));
921        }
922
923        if let Some(items) = Self::parse_response::<MintQuoteTable>(&text)? {
924            if let Some(item) = items.into_iter().next() {
925                return Ok(Some(item.try_into()?));
926            }
927        }
928        Ok(None)
929    }
930
931    async fn get_mint_quotes(&self) -> Result<Vec<MintQuote>, DatabaseError> {
932        let (status, text) = self.get_request("rest/v1/mint_quote").await?;
933
934        if !status.is_success() {
935            return Err(DatabaseError::Internal(format!(
936                "get_mint_quotes failed: HTTP {}",
937                status
938            )));
939        }
940
941        if let Some(quotes) = Self::parse_response::<MintQuoteTable>(&text)? {
942            quotes.into_iter().map(|q| q.try_into()).collect()
943        } else {
944            Ok(Vec::new())
945        }
946    }
947
948    async fn get_unissued_mint_quotes(&self) -> Result<Vec<MintQuote>, DatabaseError> {
949        let (status, text) = self
950            .get_request("rest/v1/mint_quote?amount_issued=eq.0")
951            .await?;
952
953        if !status.is_success() {
954            return Err(DatabaseError::Internal(format!(
955                "get_unissued_mint_quotes failed: HTTP {}",
956                status
957            )));
958        }
959
960        if let Some(quotes) = Self::parse_response::<MintQuoteTable>(&text)? {
961            quotes.into_iter().map(|q| q.try_into()).collect()
962        } else {
963            Ok(Vec::new())
964        }
965    }
966
967    async fn get_melt_quote(
968        &self,
969        quote_id: &str,
970    ) -> Result<Option<wallet::MeltQuote>, DatabaseError> {
971        let path = format!("rest/v1/melt_quote?id=eq.{}", url_encode(quote_id));
972        let (status, text) = self.get_request(&path).await?;
973
974        if !status.is_success() {
975            return Err(DatabaseError::Internal(format!(
976                "get_melt_quote failed: HTTP {}",
977                status
978            )));
979        }
980
981        if let Some(items) = Self::parse_response::<MeltQuoteTable>(&text)? {
982            if let Some(item) = items.into_iter().next() {
983                return Ok(Some(item.try_into()?));
984            }
985        }
986        Ok(None)
987    }
988
989    async fn get_melt_quotes(&self) -> Result<Vec<wallet::MeltQuote>, DatabaseError> {
990        let (status, text) = self.get_request("rest/v1/melt_quote").await?;
991
992        if !status.is_success() {
993            return Err(DatabaseError::Internal(format!(
994                "get_melt_quotes failed: HTTP {}",
995                status
996            )));
997        }
998
999        if let Some(quotes) = Self::parse_response::<MeltQuoteTable>(&text)? {
1000            quotes.into_iter().map(|q| q.try_into()).collect()
1001        } else {
1002            Ok(Vec::new())
1003        }
1004    }
1005
1006    async fn get_keys(&self, id: &Id) -> Result<Option<Keys>, DatabaseError> {
1007        let path = format!("rest/v1/key?id=eq.{}", url_encode(&id.to_string()));
1008        let (status, text) = self.get_request(&path).await?;
1009
1010        if !status.is_success() {
1011            return Err(DatabaseError::Internal(format!(
1012                "get_keys failed: HTTP {}",
1013                status
1014            )));
1015        }
1016
1017        if let Some(items) = Self::parse_response::<KeyTable>(&text)? {
1018            if let Some(item) = items.into_iter().next() {
1019                return Ok(Some(item.try_into()?));
1020            }
1021        }
1022        Ok(None)
1023    }
1024
1025    async fn get_proofs(
1026        &self,
1027        mint_url: Option<MintUrl>,
1028        unit: Option<CurrencyUnit>,
1029        state: Option<Vec<State>>,
1030        spending_conditions: Option<Vec<SpendingConditions>>,
1031    ) -> Result<Vec<ProofInfo>, DatabaseError> {
1032        let mut query = String::from("rest/v1/proof?select=*");
1033        if let Some(url) = mint_url {
1034            query.push_str(&format!("&mint_url=eq.{}", url_encode(&url.to_string())));
1035        }
1036        if let Some(u) = unit {
1037            query.push_str(&format!("&unit=eq.{}", url_encode(&u.to_string())));
1038        }
1039        if let Some(states) = state {
1040            let s_str: Vec<String> = states.iter().map(|s| s.to_string()).collect();
1041            query.push_str(&format!("&state=in.({})", s_str.join(",")));
1042        }
1043
1044        let (status, text) = self.get_request(&query).await?;
1045
1046        if !status.is_success() {
1047            return Err(DatabaseError::Internal(format!(
1048                "get_proofs failed: HTTP {}",
1049                status
1050            )));
1051        }
1052
1053        let mut result = Vec::new();
1054        if let Some(proofs) = Self::parse_response::<ProofTable>(&text)? {
1055            for mut p in proofs {
1056                self.decrypt_proof_table(&mut p).await;
1057
1058                result.push(p.try_into()?);
1059            }
1060        }
1061
1062        // Filter by spending conditions in memory if specified
1063        if let Some(conds) = spending_conditions {
1064            result.retain(|p: &ProofInfo| {
1065                if let Some(sc) = &p.spending_condition {
1066                    conds.contains(sc)
1067                } else {
1068                    false
1069                }
1070            });
1071        }
1072
1073        Ok(result)
1074    }
1075
1076    async fn get_proofs_by_ys(&self, ys: Vec<PublicKey>) -> Result<Vec<ProofInfo>, DatabaseError> {
1077        if ys.is_empty() {
1078            return Ok(Vec::new());
1079        }
1080
1081        let ys_str: Vec<String> = ys.iter().map(|y| hex::encode(y.to_bytes())).collect();
1082        let filter = format!("({})", ys_str.join(","));
1083        let path = format!("rest/v1/proof?y=in.{}", filter);
1084
1085        let (status, text) = self.get_request(&path).await?;
1086
1087        if !status.is_success() {
1088            return Err(DatabaseError::Internal(format!(
1089                "get_proofs_by_ys failed: HTTP {}",
1090                status
1091            )));
1092        }
1093
1094        if let Some(proofs) = Self::parse_response::<ProofTable>(&text)? {
1095            let mut result = Vec::new();
1096            for mut p in proofs {
1097                self.decrypt_proof_table(&mut p).await;
1098
1099                result.push(p.try_into()?);
1100            }
1101            Ok(result)
1102        } else {
1103            Ok(Vec::new())
1104        }
1105    }
1106
1107    async fn get_balance(
1108        &self,
1109        mint_url: Option<MintUrl>,
1110        unit: Option<CurrencyUnit>,
1111        state: Option<Vec<State>>,
1112    ) -> Result<u64, DatabaseError> {
1113        // Note: Ideally this would use a server-side SUM aggregation, but PostgREST
1114        // doesn't support aggregate functions directly. We fetch all proofs and sum locally.
1115        let proofs = self.get_proofs(mint_url, unit, state, None).await?;
1116        Ok(proofs.iter().map(|p| p.proof.amount.to_u64()).sum())
1117    }
1118
1119    async fn get_transaction(
1120        &self,
1121        transaction_id: TransactionId,
1122    ) -> Result<Option<Transaction>, DatabaseError> {
1123        let id_hex = transaction_id.to_string();
1124        let path = format!("rest/v1/transactions?id=eq.\\x{}", id_hex);
1125
1126        let (status, text) = self.get_request(&path).await?;
1127
1128        // 404 or empty result means not found
1129        if status == StatusCode::NOT_FOUND {
1130            return Ok(None);
1131        }
1132        if !status.is_success() {
1133            return Err(DatabaseError::Internal(format!(
1134                "get_transaction failed: HTTP {}",
1135                status
1136            )));
1137        }
1138
1139        if let Some(txs) = Self::parse_response::<TransactionTable>(&text)? {
1140            if let Some(t) = txs.into_iter().next() {
1141                return Ok(Some(t.try_into()?));
1142            }
1143        }
1144        Ok(None)
1145    }
1146
1147    async fn list_transactions(
1148        &self,
1149        mint_url: Option<MintUrl>,
1150        direction: Option<TransactionDirection>,
1151        unit: Option<CurrencyUnit>,
1152    ) -> Result<Vec<Transaction>, DatabaseError> {
1153        let mut query = String::from("rest/v1/transactions?select=*");
1154        if let Some(url) = mint_url {
1155            query.push_str(&format!("&mint_url=eq.{}", url_encode(&url.to_string())));
1156        }
1157        if let Some(d) = direction {
1158            query.push_str(&format!("&direction=eq.{}", url_encode(&d.to_string())));
1159        }
1160        if let Some(u) = unit {
1161            query.push_str(&format!("&unit=eq.{}", url_encode(&u.to_string())));
1162        }
1163
1164        let (status, text) = self.get_request(&query).await?;
1165
1166        if !status.is_success() {
1167            return Err(DatabaseError::Internal(format!(
1168                "list_transactions failed: HTTP {}",
1169                status
1170            )));
1171        }
1172
1173        if let Some(txs) = Self::parse_response::<TransactionTable>(&text)? {
1174            txs.into_iter().map(|t| t.try_into()).collect()
1175        } else {
1176            Ok(Vec::new())
1177        }
1178    }
1179
1180    async fn update_proofs(
1181        &self,
1182        added: Vec<ProofInfo>,
1183        removed_ys: Vec<PublicKey>,
1184    ) -> Result<(), DatabaseError> {
1185        // If nothing to do, return early
1186        if added.is_empty() && removed_ys.is_empty() {
1187            return Ok(());
1188        }
1189
1190        // Convert proofs to table format for the RPC call
1191
1192        // Re-do serialization loop properly to allow await
1193        let mut proofs_json: Vec<serde_json::Value> = Vec::with_capacity(added.len());
1194        for p in added {
1195            let mut table: ProofTable = p.try_into()?;
1196
1197            // Encrypt secret
1198            let secret_bytes = table.secret.as_bytes();
1199            let encrypted = self.encrypt(secret_bytes).await?;
1200            table.secret = hex::encode(encrypted);
1201
1202            // Encrypt C
1203            if let Ok(c_bytes) = hex::decode(&table.c) {
1204                let encrypted_c = self.encrypt(&c_bytes).await?;
1205                table.c = hex::encode(encrypted_c);
1206            }
1207
1208            proofs_json.push(serde_json::to_value(&table).map_err(DatabaseError::from)?);
1209        }
1210
1211        // Convert Y values to hex strings
1212        let ys_json: Vec<String> = removed_ys
1213            .iter()
1214            .map(|y| hex::encode(y.to_bytes()))
1215            .collect();
1216
1217        // Try atomic RPC first
1218        let rpc_body = serde_json::json!({
1219            "p_proofs_to_add": proofs_json,
1220            "p_ys_to_remove": ys_json
1221        });
1222
1223        let url = self.join_url("rest/v1/rpc/update_proofs_atomic")?;
1224        let auth_bearer = self.get_auth_bearer().await;
1225
1226        tracing::debug!(
1227            method = "POST",
1228            url = %url,
1229            proofs_count = proofs_json.len(),
1230            remove_count = ys_json.len(),
1231            "Supabase atomic update_proofs RPC"
1232        );
1233
1234        let res = self
1235            .client
1236            .post(url.clone())
1237            .header("apikey", &self.api_key)
1238            .header("Authorization", format!("Bearer {}", auth_bearer))
1239            .header("Content-Type", "application/json")
1240            .json(&rpc_body)
1241            .send()
1242            .await
1243            .map_err(Error::Reqwest)?;
1244
1245        let status = res.status();
1246        let text = res.text().await.map_err(Error::Reqwest)?;
1247
1248        tracing::debug!(
1249            method = "POST",
1250            url = %url,
1251            status = %status,
1252            response_len = text.len(),
1253            "Supabase atomic update_proofs response"
1254        );
1255
1256        if status.is_success() {
1257            return Ok(());
1258        }
1259
1260        Err(DatabaseError::Internal(format!(
1261            "update_proofs_atomic RPC failed: HTTP {}. Ensure migrations have been run.",
1262            status
1263        )))
1264    }
1265
1266    async fn update_proofs_state(
1267        &self,
1268        ys: Vec<PublicKey>,
1269        state: State,
1270    ) -> Result<(), DatabaseError> {
1271        if ys.is_empty() {
1272            return Ok(());
1273        }
1274
1275        let ys_str: Vec<String> = ys.iter().map(|y| hex::encode(y.to_bytes())).collect();
1276        let filter = format!("({})", ys_str.join(","));
1277        let path = format!("rest/v1/proof?y=in.{}", filter);
1278
1279        let (status, response_text) = self
1280            .patch_request(&path, &serde_json::json!({ "state": state.to_string() }))
1281            .await?;
1282
1283        if !status.is_success() {
1284            return Err(DatabaseError::Internal(format!(
1285                "update_proofs_state failed: HTTP {} - {}",
1286                status, response_text
1287            )));
1288        }
1289
1290        Ok(())
1291    }
1292
1293    async fn add_transaction(&self, transaction: Transaction) -> Result<(), DatabaseError> {
1294        let item: TransactionTable = transaction.try_into()?;
1295        let (status, response_text) = self
1296            .post_request("rest/v1/transactions?on_conflict=id,wallet_id", &item)
1297            .await?;
1298
1299        if !status.is_success() {
1300            return Err(DatabaseError::Internal(format!(
1301                "add_transaction failed: HTTP {} - {}",
1302                status, response_text
1303            )));
1304        }
1305        Ok(())
1306    }
1307
1308    async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), DatabaseError> {
1309        let id_hex = transaction_id.to_string();
1310        let path = format!("rest/v1/transactions?id=eq.\\x{}", id_hex);
1311
1312        let (status, response_text) = self.delete_request(&path).await?;
1313
1314        if !status.is_success() {
1315            return Err(DatabaseError::Internal(format!(
1316                "remove_transaction failed: HTTP {} - {}",
1317                status, response_text
1318            )));
1319        }
1320        Ok(())
1321    }
1322
1323    async fn update_mint_url(
1324        &self,
1325        old_mint_url: MintUrl,
1326        new_mint_url: MintUrl,
1327    ) -> Result<(), DatabaseError> {
1328        let old_encoded = url_encode(&old_mint_url.to_string());
1329        let update_body = serde_json::json!({ "mint_url": new_mint_url.to_string() });
1330
1331        // Update mint table first (parent table)
1332        let path = format!("rest/v1/mint?mint_url=eq.{}", old_encoded);
1333        let (status, response_text) = self.patch_request(&path, &update_body).await?;
1334        if !status.is_success() {
1335            return Err(DatabaseError::Internal(format!(
1336                "update_mint_url (mint) failed: HTTP {} - {}",
1337                status, response_text
1338            )));
1339        }
1340
1341        // Update keyset table
1342        let path = format!("rest/v1/keyset?mint_url=eq.{}", old_encoded);
1343        let (status, response_text) = self.patch_request(&path, &update_body).await?;
1344        if !status.is_success() {
1345            return Err(DatabaseError::Internal(format!(
1346                "update_mint_url (keyset) failed: HTTP {} - {}",
1347                status, response_text
1348            )));
1349        }
1350
1351        // Update mint_quote table
1352        let path = format!("rest/v1/mint_quote?mint_url=eq.{}", old_encoded);
1353        let (status, response_text) = self.patch_request(&path, &update_body).await?;
1354        if !status.is_success() {
1355            return Err(DatabaseError::Internal(format!(
1356                "update_mint_url (mint_quote) failed: HTTP {} - {}",
1357                status, response_text
1358            )));
1359        }
1360
1361        // Update proof table
1362        let path = format!("rest/v1/proof?mint_url=eq.{}", old_encoded);
1363        let (status, response_text) = self.patch_request(&path, &update_body).await?;
1364        if !status.is_success() {
1365            return Err(DatabaseError::Internal(format!(
1366                "update_mint_url (proof) failed: HTTP {} - {}",
1367                status, response_text
1368            )));
1369        }
1370
1371        // Update transactions table
1372        let path = format!("rest/v1/transactions?mint_url=eq.{}", old_encoded);
1373        let (status, response_text) = self.patch_request(&path, &update_body).await?;
1374        if !status.is_success() {
1375            return Err(DatabaseError::Internal(format!(
1376                "update_mint_url (transactions) failed: HTTP {} - {}",
1377                status, response_text
1378            )));
1379        }
1380
1381        Ok(())
1382    }
1383
1384    async fn increment_keyset_counter(
1385        &self,
1386        keyset_id: &Id,
1387        count: u32,
1388    ) -> Result<u32, DatabaseError> {
1389        // Use Supabase RPC for atomic increment
1390        // This calls the increment_keyset_counter PostgreSQL function
1391        let rpc_body = serde_json::json!({
1392            "p_keyset_id": keyset_id.to_string(),
1393            "p_increment": count as i32
1394        });
1395
1396        let url = self.join_url("rest/v1/rpc/increment_keyset_counter")?;
1397        let auth_bearer = self.get_auth_bearer().await;
1398
1399        tracing::debug!(method = "POST", url = %url, keyset_id = %keyset_id, increment = count, "Supabase RPC request");
1400
1401        let res = self
1402            .client
1403            .post(url.clone())
1404            .header("apikey", &self.api_key)
1405            .header("Authorization", format!("Bearer {}", auth_bearer))
1406            .header("Content-Type", "application/json")
1407            .header("Prefer", "return=representation")
1408            .json(&rpc_body)
1409            .send()
1410            .await
1411            .map_err(Error::Reqwest)?;
1412
1413        let status = res.status();
1414        let text = res.text().await.map_err(Error::Reqwest)?;
1415
1416        tracing::debug!(method = "POST", url = %url, status = %status, response_len = text.len(), "Supabase RPC response");
1417
1418        if status.is_success() {
1419            // RPC returns the new counter value directly
1420            let new_counter: i32 = serde_json::from_str(&text).map_err(|e| {
1421                DatabaseError::Internal(format!("Failed to parse counter response: {}", e))
1422            })?;
1423            return Ok(new_counter as u32);
1424        }
1425
1426        Err(DatabaseError::Internal(format!(
1427            "increment_keyset_counter RPC failed: HTTP {}. Ensure migrations have been run.",
1428            status
1429        )))
1430    }
1431
1432    async fn add_mint(
1433        &self,
1434        mint_url: MintUrl,
1435        mint_info: Option<MintInfo>,
1436    ) -> Result<(), DatabaseError> {
1437        let info_table: MintTable = match mint_info {
1438            Some(info) => MintTable::from_info(mint_url.clone(), info)?,
1439            None => MintTable {
1440                mint_url: mint_url.to_string(),
1441                ..Default::default()
1442            },
1443        };
1444
1445        let (status, response_text) = self
1446            .post_request("rest/v1/mint?on_conflict=mint_url,wallet_id", &info_table)
1447            .await?;
1448
1449        if !status.is_success() {
1450            return Err(DatabaseError::Internal(format!(
1451                "add_mint failed: HTTP {} - {}",
1452                status, response_text
1453            )));
1454        }
1455        Ok(())
1456    }
1457
1458    async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), DatabaseError> {
1459        let path = format!(
1460            "rest/v1/mint?mint_url=eq.{}",
1461            url_encode(&mint_url.to_string())
1462        );
1463        let (status, response_text) = self.delete_request(&path).await?;
1464
1465        if !status.is_success() {
1466            return Err(DatabaseError::Internal(format!(
1467                "remove_mint failed: HTTP {} - {}",
1468                status, response_text
1469            )));
1470        }
1471        Ok(())
1472    }
1473
1474    async fn add_mint_keysets(
1475        &self,
1476        mint_url: MintUrl,
1477        keysets: Vec<KeySetInfo>,
1478    ) -> Result<(), DatabaseError> {
1479        if keysets.is_empty() {
1480            return Ok(());
1481        }
1482
1483        let items: Result<Vec<KeySetTable>, DatabaseError> = keysets
1484            .into_iter()
1485            .map(|k| KeySetTable::from_info(mint_url.clone(), k))
1486            .collect();
1487        let items = items?;
1488
1489        let (status, response_text) = self
1490            .post_request("rest/v1/keyset?on_conflict=id,wallet_id", &items)
1491            .await?;
1492
1493        if !status.is_success() {
1494            return Err(DatabaseError::Internal(format!(
1495                "add_mint_keysets failed: HTTP {} - {}",
1496                status, response_text
1497            )));
1498        }
1499        Ok(())
1500    }
1501
1502    async fn add_mint_quote(&self, quote: MintQuote) -> Result<(), DatabaseError> {
1503        let expected_version = quote.version;
1504        let mut item: MintQuoteTable = quote.try_into()?;
1505        item.version = Some(expected_version.wrapping_add(1) as i32);
1506
1507        let path = format!(
1508            "rest/v1/mint_quote?id=eq.{}&version=eq.{}",
1509            url_encode(&item.id),
1510            expected_version
1511        );
1512
1513        // Use `return=representation` so PostgREST returns the updated rows as JSON.
1514        // An empty array `[]` means the version filter matched nothing — the row either
1515        // doesn't exist yet or was concurrently modified.
1516        let (status, response_text) = self.patch_request_returning(&path, &item).await?;
1517
1518        if status.is_success() {
1519            let updated: serde_json::Value =
1520                serde_json::from_str(&response_text).unwrap_or(serde_json::Value::Null);
1521            let row_count = updated.as_array().map(|a| a.len()).unwrap_or(0);
1522
1523            if row_count > 0 {
1524                // PATCH updated an existing row — done.
1525                return Ok(());
1526            }
1527
1528            // No rows updated: the row doesn't exist yet — fall through to INSERT.
1529            let (status, response_text) = self
1530                .post_request("rest/v1/mint_quote?on_conflict=id,wallet_id", &item)
1531                .await?;
1532
1533            if status.is_success() {
1534                return Ok(());
1535            }
1536
1537            return Err(DatabaseError::Internal(format!(
1538                "add_mint_quote insert failed: HTTP {} - {}",
1539                status, response_text
1540            )));
1541        }
1542
1543        Err(DatabaseError::Internal(format!(
1544            "add_mint_quote failed: HTTP {} - {}",
1545            status, response_text
1546        )))
1547    }
1548
1549    async fn remove_mint_quote(&self, quote_id: &str) -> Result<(), DatabaseError> {
1550        let path = format!("rest/v1/mint_quote?id=eq.{}", url_encode(quote_id));
1551        let (status, response_text) = self.delete_request(&path).await?;
1552
1553        if !status.is_success() {
1554            return Err(DatabaseError::Internal(format!(
1555                "remove_mint_quote failed: HTTP {} - {}",
1556                status, response_text
1557            )));
1558        }
1559        Ok(())
1560    }
1561
1562    async fn add_melt_quote(&self, quote: wallet::MeltQuote) -> Result<(), DatabaseError> {
1563        let expected_version = quote.version;
1564        let mut item: MeltQuoteTable = quote.try_into()?;
1565        item.version = Some(expected_version.wrapping_add(1) as i32);
1566
1567        let path = format!(
1568            "rest/v1/melt_quote?id=eq.{}&version=eq.{}",
1569            url_encode(&item.id),
1570            expected_version
1571        );
1572
1573        // Use `return=representation` so PostgREST returns the updated rows as JSON.
1574        // An empty array `[]` means the version filter matched nothing — the row either
1575        // doesn't exist yet or was concurrently modified.
1576        let (status, response_text) = self.patch_request_returning(&path, &item).await?;
1577
1578        if status.is_success() {
1579            let updated: serde_json::Value =
1580                serde_json::from_str(&response_text).unwrap_or(serde_json::Value::Null);
1581            let row_count = updated.as_array().map(|a| a.len()).unwrap_or(0);
1582
1583            if row_count > 0 {
1584                // PATCH updated an existing row — done.
1585                return Ok(());
1586            }
1587
1588            // No rows updated: the row doesn't exist yet — fall through to INSERT.
1589            let (status, response_text) = self
1590                .post_request("rest/v1/melt_quote?on_conflict=id,wallet_id", &item)
1591                .await?;
1592
1593            if status.is_success() {
1594                return Ok(());
1595            }
1596
1597            return Err(DatabaseError::Internal(format!(
1598                "add_melt_quote insert failed: HTTP {} - {}",
1599                status, response_text
1600            )));
1601        }
1602
1603        Err(DatabaseError::Internal(format!(
1604            "add_melt_quote failed: HTTP {} - {}",
1605            status, response_text
1606        )))
1607    }
1608
1609    async fn remove_melt_quote(&self, quote_id: &str) -> Result<(), DatabaseError> {
1610        let path = format!("rest/v1/melt_quote?id=eq.{}", url_encode(quote_id));
1611        let (status, response_text) = self.delete_request(&path).await?;
1612
1613        if !status.is_success() {
1614            return Err(DatabaseError::Internal(format!(
1615                "remove_melt_quote failed: HTTP {} - {}",
1616                status, response_text
1617            )));
1618        }
1619        Ok(())
1620    }
1621
1622    async fn add_keys(&self, keyset: KeySet) -> Result<(), DatabaseError> {
1623        keyset.verify_id().map_err(DatabaseError::from)?;
1624        let item = KeyTable::from_keyset(&keyset)?;
1625
1626        let (status, response_text) = self
1627            .post_request("rest/v1/key?on_conflict=id,wallet_id", &item)
1628            .await?;
1629
1630        if !status.is_success() {
1631            return Err(DatabaseError::Internal(format!(
1632                "add_keys failed: HTTP {} - {}",
1633                status, response_text
1634            )));
1635        }
1636        Ok(())
1637    }
1638
1639    async fn remove_keys(&self, id: &Id) -> Result<(), DatabaseError> {
1640        let path = format!("rest/v1/key?id=eq.{}", url_encode(&id.to_string()));
1641        let (status, response_text) = self.delete_request(&path).await?;
1642
1643        if !status.is_success() {
1644            return Err(DatabaseError::Internal(format!(
1645                "remove_keys failed: HTTP {} - {}",
1646                status, response_text
1647            )));
1648        }
1649        Ok(())
1650    }
1651
1652    async fn kv_write(
1653        &self,
1654        primary_namespace: &str,
1655        secondary_namespace: &str,
1656        key: &str,
1657        value: &[u8],
1658    ) -> Result<(), DatabaseError> {
1659        // Encrypt value
1660        let encrypted = self.encrypt(value).await?;
1661
1662        let item = KVStoreTable {
1663            primary_namespace: primary_namespace.to_string(),
1664            secondary_namespace: secondary_namespace.to_string(),
1665            key: key.to_string(),
1666            value: hex::encode(encrypted),
1667            _extra: Default::default(),
1668        };
1669
1670        let (status, response_text) = self
1671            .post_request(
1672                "rest/v1/kv_store?on_conflict=primary_namespace,secondary_namespace,key,wallet_id",
1673                &item,
1674            )
1675            .await?;
1676
1677        if !status.is_success() {
1678            return Err(DatabaseError::Internal(format!(
1679                "kv_write failed: HTTP {} - {}",
1680                status, response_text
1681            )));
1682        }
1683        Ok(())
1684    }
1685
1686    async fn kv_remove(
1687        &self,
1688        primary_namespace: &str,
1689        secondary_namespace: &str,
1690        key: &str,
1691    ) -> Result<(), DatabaseError> {
1692        let path = format!(
1693            "rest/v1/kv_store?primary_namespace=eq.{}&secondary_namespace=eq.{}&key=eq.{}",
1694            url_encode(primary_namespace),
1695            url_encode(secondary_namespace),
1696            url_encode(key)
1697        );
1698        let (status, response_text) = self.delete_request(&path).await?;
1699
1700        if !status.is_success() {
1701            return Err(DatabaseError::Internal(format!(
1702                "kv_remove failed: HTTP {} - {}",
1703                status, response_text
1704            )));
1705        }
1706        Ok(())
1707    }
1708
1709    async fn kv_read(
1710        &self,
1711        primary_namespace: &str,
1712        secondary_namespace: &str,
1713        key: &str,
1714    ) -> Result<Option<Vec<u8>>, DatabaseError> {
1715        // Delegate to the KVStoreDatabase impl
1716        KVStoreDatabase::kv_read(self, primary_namespace, secondary_namespace, key).await
1717    }
1718
1719    async fn kv_list(
1720        &self,
1721        primary_namespace: &str,
1722        secondary_namespace: &str,
1723    ) -> Result<Vec<String>, DatabaseError> {
1724        // Delegate to the KVStoreDatabase impl
1725        KVStoreDatabase::kv_list(self, primary_namespace, secondary_namespace).await
1726    }
1727
1728    // ========== Saga methods ==========
1729
1730    async fn add_saga(&self, saga: WalletSaga) -> Result<(), DatabaseError> {
1731        let saga_json = serde_json::to_string(&saga)
1732            .map_err(|e| DatabaseError::Internal(format!("Serialize saga: {e}")))?;
1733
1734        let item = SagaTable {
1735            id: saga.id.to_string(),
1736            data: saga_json,
1737            version: saga.version as i32,
1738            completed: false,
1739            created_at: saga.created_at as i64,
1740            updated_at: saga.updated_at as i64,
1741            _extra: Default::default(),
1742        };
1743
1744        let (status, response_text) = self
1745            .post_request("rest/v1/saga?on_conflict=id,wallet_id", &item)
1746            .await?;
1747
1748        if !status.is_success() {
1749            return Err(DatabaseError::Internal(format!(
1750                "add_saga failed: HTTP {} - {}",
1751                status, response_text
1752            )));
1753        }
1754        Ok(())
1755    }
1756
1757    async fn get_saga(&self, id: &uuid::Uuid) -> Result<Option<WalletSaga>, DatabaseError> {
1758        let path = format!("rest/v1/saga?id=eq.{}", url_encode(&id.to_string()));
1759        let (status, text) = self.get_request(&path).await?;
1760
1761        if !status.is_success() {
1762            return Err(DatabaseError::Internal(format!(
1763                "get_saga failed: HTTP {}",
1764                status
1765            )));
1766        }
1767
1768        if let Some(items) = Self::parse_response::<SagaTable>(&text)? {
1769            if let Some(item) = items.into_iter().next() {
1770                let saga: WalletSaga = serde_json::from_str(&item.data)
1771                    .map_err(|e| DatabaseError::Internal(format!("Deserialize saga: {e}")))?;
1772                return Ok(Some(saga));
1773            }
1774        }
1775        Ok(None)
1776    }
1777
1778    async fn update_saga(&self, saga: WalletSaga) -> Result<bool, DatabaseError> {
1779        let expected_version = saga.version.saturating_sub(1);
1780        let saga_json = serde_json::to_string(&saga)
1781            .map_err(|e| DatabaseError::Internal(format!("Serialize saga: {e}")))?;
1782
1783        let item = SagaTable {
1784            id: saga.id.to_string(),
1785            data: saga_json,
1786            version: saga.version as i32,
1787            completed: false,
1788            created_at: saga.created_at as i64,
1789            updated_at: saga.updated_at as i64,
1790            _extra: Default::default(),
1791        };
1792
1793        // Use PostgREST filtering to only update if version matches (optimistic locking)
1794        let path = format!(
1795            "rest/v1/saga?id=eq.{}&version=eq.{}",
1796            url_encode(&saga.id.to_string()),
1797            expected_version
1798        );
1799
1800        let (status, response_text) = self.patch_request(&path, &item).await?;
1801
1802        if !status.is_success() {
1803            return Err(DatabaseError::Internal(format!(
1804                "update_saga failed: HTTP {} - {}",
1805                status, response_text
1806            )));
1807        }
1808
1809        // PostgREST PATCH returns empty body for 0 rows updated. Check via GET.
1810        // Alternatively, we check if the response indicates changes were made.
1811        // A simpler approach: re-read and verify version was updated.
1812        let current = self.get_saga(&saga.id).await?;
1813        match current {
1814            Some(s) => Ok(s.version == saga.version),
1815            None => Ok(false),
1816        }
1817    }
1818
1819    async fn delete_saga(&self, id: &uuid::Uuid) -> Result<(), DatabaseError> {
1820        let path = format!("rest/v1/saga?id=eq.{}", url_encode(&id.to_string()));
1821        let (status, response_text) = self.delete_request(&path).await?;
1822
1823        if !status.is_success() {
1824            return Err(DatabaseError::Internal(format!(
1825                "delete_saga failed: HTTP {} - {}",
1826                status, response_text
1827            )));
1828        }
1829        Ok(())
1830    }
1831
1832    async fn get_incomplete_sagas(&self) -> Result<Vec<WalletSaga>, DatabaseError> {
1833        let path = "rest/v1/saga?completed=eq.false&order=created_at.asc";
1834        let (status, text) = self.get_request(path).await?;
1835
1836        if !status.is_success() {
1837            return Err(DatabaseError::Internal(format!(
1838                "get_incomplete_sagas failed: HTTP {}",
1839                status
1840            )));
1841        }
1842
1843        if let Some(items) = Self::parse_response::<SagaTable>(&text)? {
1844            let mut sagas = Vec::new();
1845            for item in items {
1846                let saga: WalletSaga = serde_json::from_str(&item.data)
1847                    .map_err(|e| DatabaseError::Internal(format!("Deserialize saga: {e}")))?;
1848                sagas.push(saga);
1849            }
1850            Ok(sagas)
1851        } else {
1852            Ok(Vec::new())
1853        }
1854    }
1855
1856    // ========== Proof reservation methods ==========
1857
1858    async fn reserve_proofs(
1859        &self,
1860        ys: Vec<PublicKey>,
1861        operation_id: &uuid::Uuid,
1862    ) -> Result<(), DatabaseError> {
1863        let op_id_str = operation_id.to_string();
1864        for y in &ys {
1865            let y_hex = hex::encode(y.to_bytes());
1866
1867            // Update proof state to Reserved with operation_id atomically by filtering on state=Unspent
1868            let update = serde_json::json!({
1869                "state": State::Reserved.to_string(),
1870                "used_by_operation": op_id_str,
1871            });
1872
1873            // We filter on state=Unspent to ensure we only reserve proofs that are currently available.
1874            // This prevents race conditions where two operations try to reserve the same proof.
1875            let patch_path = format!(
1876                "rest/v1/proof?y=eq.{}&state=eq.{}",
1877                url_encode(&y_hex),
1878                url_encode(&State::Unspent.to_string())
1879            );
1880
1881            let (status, response_text) = self.patch_request(&patch_path, &update).await?;
1882
1883            if !status.is_success() {
1884                return Err(DatabaseError::Internal(format!(
1885                    "reserve_proofs: update failed: HTTP {} - {}",
1886                    status, response_text
1887                )));
1888            }
1889
1890            // PostgREST returns 204 No Content for success.
1891            // If the proof was already reserved or spent, the PATCH will succeed (HTTP 204)
1892            // but no rows will be updated. We check if the proof is actually reserved.
1893            let reserved_proofs = self.get_reserved_proofs(operation_id).await?;
1894            if !reserved_proofs.iter().any(|p| p.y == *y) {
1895                return Err(DatabaseError::ProofNotUnspent);
1896            }
1897        }
1898        Ok(())
1899    }
1900
1901    async fn release_proofs(&self, operation_id: &uuid::Uuid) -> Result<(), DatabaseError> {
1902        let op_id_str = operation_id.to_string();
1903
1904        // Update all proofs reserved by this operation back to Unspent
1905        let update = serde_json::json!({
1906            "state": State::Unspent.to_string(),
1907            "used_by_operation": null,
1908        });
1909        let path = format!(
1910            "rest/v1/proof?used_by_operation=eq.{}",
1911            url_encode(&op_id_str)
1912        );
1913        let (status, response_text) = self.patch_request(&path, &update).await?;
1914
1915        if !status.is_success() {
1916            return Err(DatabaseError::Internal(format!(
1917                "release_proofs failed: HTTP {} - {}",
1918                status, response_text
1919            )));
1920        }
1921        Ok(())
1922    }
1923
1924    async fn get_reserved_proofs(
1925        &self,
1926        operation_id: &uuid::Uuid,
1927    ) -> Result<Vec<ProofInfo>, DatabaseError> {
1928        let op_id_str = operation_id.to_string();
1929        let path = format!(
1930            "rest/v1/proof?used_by_operation=eq.{}",
1931            url_encode(&op_id_str)
1932        );
1933        let (status, text) = self.get_request(&path).await?;
1934
1935        if !status.is_success() {
1936            return Err(DatabaseError::Internal(format!(
1937                "get_reserved_proofs failed: HTTP {}",
1938                status
1939            )));
1940        }
1941
1942        if let Some(items) = Self::parse_response::<ProofTable>(&text)? {
1943            let mut proofs = Vec::with_capacity(items.len());
1944            for mut p in items {
1945                self.decrypt_proof_table(&mut p).await;
1946                proofs.push(p.try_into()?);
1947            }
1948            Ok(proofs)
1949        } else {
1950            Ok(Vec::new())
1951        }
1952    }
1953
1954    // ========== Quote reservation methods ==========
1955
1956    async fn reserve_melt_quote(
1957        &self,
1958        quote_id: &str,
1959        operation_id: &uuid::Uuid,
1960    ) -> Result<(), DatabaseError> {
1961        let op_id_str = operation_id.to_string();
1962
1963        let update = serde_json::json!({
1964            "used_by_operation": op_id_str,
1965        });
1966
1967        // Use PostgREST filters on PATCH for atomic reservation.
1968        // We filter for both the quote ID and ensuring it is currently not reserved (used_by_operation IS NULL).
1969        let patch_path = format!(
1970            "rest/v1/melt_quote?id=eq.{}&used_by_operation=is.null",
1971            url_encode(quote_id)
1972        );
1973
1974        let (status, response_text) = self.patch_request(&patch_path, &update).await?;
1975
1976        if !status.is_success() {
1977            return Err(DatabaseError::Internal(format!(
1978                "reserve_melt_quote failed: HTTP {} - {}",
1979                status, response_text
1980            )));
1981        }
1982
1983        // Verify that the quote was actually updated by checking if it's reserved for this operation.
1984        let quote = self.get_melt_quote(quote_id).await?;
1985        match quote {
1986            Some(q) => {
1987                if q.used_by_operation.as_deref() == Some(&op_id_str) {
1988                    Ok(())
1989                } else {
1990                    // Quote exists but was not reserved for us (already reserved by another operation).
1991                    Err(DatabaseError::QuoteAlreadyInUse)
1992                }
1993            }
1994            None => Err(DatabaseError::UnknownQuote),
1995        }
1996    }
1997
1998    async fn release_melt_quote(&self, operation_id: &uuid::Uuid) -> Result<(), DatabaseError> {
1999        let op_id_str = operation_id.to_string();
2000
2001        let update = serde_json::json!({
2002            "used_by_operation": null,
2003        });
2004        let path = format!(
2005            "rest/v1/melt_quote?used_by_operation=eq.{}",
2006            url_encode(&op_id_str)
2007        );
2008        let (status, response_text) = self.patch_request(&path, &update).await?;
2009
2010        if !status.is_success() {
2011            return Err(DatabaseError::Internal(format!(
2012                "release_melt_quote failed: HTTP {} - {}",
2013                status, response_text
2014            )));
2015        }
2016        Ok(())
2017    }
2018
2019    async fn reserve_mint_quote(
2020        &self,
2021        quote_id: &str,
2022        operation_id: &uuid::Uuid,
2023    ) -> Result<(), DatabaseError> {
2024        let op_id_str = operation_id.to_string();
2025
2026        let update = serde_json::json!({
2027            "used_by_operation": op_id_str,
2028        });
2029
2030        // Use PostgREST filters on PATCH for atomic reservation.
2031        // We filter for both the quote ID and ensuring it is currently not reserved (used_by_operation IS NULL).
2032        let patch_path = format!(
2033            "rest/v1/mint_quote?id=eq.{}&used_by_operation=is.null",
2034            url_encode(quote_id)
2035        );
2036
2037        let (status, response_text) = self.patch_request(&patch_path, &update).await?;
2038
2039        if !status.is_success() {
2040            return Err(DatabaseError::Internal(format!(
2041                "reserve_mint_quote failed: HTTP {} - {}",
2042                status, response_text
2043            )));
2044        }
2045
2046        // Verify that the quote was actually updated by checking if it's reserved for this operation.
2047        let quote = self.get_mint_quote(quote_id).await?;
2048        match quote {
2049            Some(q) => {
2050                if q.used_by_operation.as_deref() == Some(&op_id_str) {
2051                    Ok(())
2052                } else {
2053                    // Quote exists but was not reserved for us (already reserved by another operation).
2054                    Err(DatabaseError::QuoteAlreadyInUse)
2055                }
2056            }
2057            None => Err(DatabaseError::UnknownQuote),
2058        }
2059    }
2060
2061    async fn release_mint_quote(&self, operation_id: &uuid::Uuid) -> Result<(), DatabaseError> {
2062        let op_id_str = operation_id.to_string();
2063
2064        let update = serde_json::json!({
2065            "used_by_operation": null,
2066        });
2067        let path = format!(
2068            "rest/v1/mint_quote?used_by_operation=eq.{}",
2069            url_encode(&op_id_str)
2070        );
2071        let (status, response_text) = self.patch_request(&path, &update).await?;
2072
2073        if !status.is_success() {
2074            return Err(DatabaseError::Internal(format!(
2075                "release_mint_quote failed: HTTP {} - {}",
2076                status, response_text
2077            )));
2078        }
2079        Ok(())
2080    }
2081
2082    async fn add_p2pk_key(
2083        &self,
2084        pubkey: &PublicKey,
2085        derivation_path: DerivationPath,
2086        derivation_index: u32,
2087    ) -> Result<(), DatabaseError> {
2088        let created_time = SystemTime::now()
2089            .duration_since(UNIX_EPOCH)
2090            .map_err(|e| DatabaseError::Internal(format!("SystemTime error: {}", e)))?
2091            .as_secs();
2092
2093        let item = P2PKSigningKeyTable {
2094            pubkey: hex::encode(pubkey.to_bytes()),
2095            derivation_index: derivation_index as i64,
2096            derivation_path: derivation_path.to_string(),
2097            created_time: created_time as i64,
2098            _extra: Default::default(),
2099        };
2100
2101        let (status, response_text) = self
2102            .post_request(
2103                "rest/v1/p2pk_signing_key?on_conflict=pubkey,wallet_id",
2104                &item,
2105            )
2106            .await?;
2107
2108        if !status.is_success() {
2109            return Err(DatabaseError::Internal(format!(
2110                "add_p2pk_key failed: HTTP {} - {}",
2111                status, response_text
2112            )));
2113        }
2114        Ok(())
2115    }
2116
2117    async fn get_p2pk_key(
2118        &self,
2119        pubkey: &PublicKey,
2120    ) -> Result<Option<wallet::P2PKSigningKey>, DatabaseError> {
2121        let path = format!(
2122            "rest/v1/p2pk_signing_key?pubkey=eq.{}",
2123            url_encode(&hex::encode(pubkey.to_bytes()))
2124        );
2125        let (status, text) = self.get_request(&path).await?;
2126
2127        if status == StatusCode::NOT_FOUND {
2128            return Ok(None);
2129        }
2130        if !status.is_success() {
2131            return Err(DatabaseError::Internal(format!(
2132                "get_p2pk_key failed: HTTP {}",
2133                status
2134            )));
2135        }
2136
2137        if let Some(rows) = Self::parse_response::<P2PKSigningKeyTable>(&text)? {
2138            if let Some(row) = rows.into_iter().next() {
2139                return Ok(Some(row.try_into()?));
2140            }
2141        }
2142        Ok(None)
2143    }
2144
2145    async fn list_p2pk_keys(&self) -> Result<Vec<wallet::P2PKSigningKey>, DatabaseError> {
2146        let path = "rest/v1/p2pk_signing_key?order=derivation_index.desc";
2147        let (status, text) = self.get_request(path).await?;
2148
2149        if !status.is_success() {
2150            return Err(DatabaseError::Internal(format!(
2151                "list_p2pk_keys failed: HTTP {}",
2152                status
2153            )));
2154        }
2155
2156        if let Some(rows) = Self::parse_response::<P2PKSigningKeyTable>(&text)? {
2157            rows.into_iter()
2158                .map(|row| row.try_into())
2159                .collect::<Result<Vec<_>, _>>()
2160        } else {
2161            Ok(Vec::new())
2162        }
2163    }
2164
2165    async fn latest_p2pk(&self) -> Result<Option<wallet::P2PKSigningKey>, DatabaseError> {
2166        let path = "rest/v1/p2pk_signing_key?order=derivation_index.desc&limit=1";
2167        let (status, text) = self.get_request(path).await?;
2168
2169        if !status.is_success() {
2170            return Err(DatabaseError::Internal(format!(
2171                "latest_p2pk failed: HTTP {}",
2172                status
2173            )));
2174        }
2175
2176        if let Some(rows) = Self::parse_response::<P2PKSigningKeyTable>(&text)? {
2177            if let Some(row) = rows.into_iter().next() {
2178                return Ok(Some(row.try_into()?));
2179            }
2180        }
2181        Ok(None)
2182    }
2183}
2184
2185// Data Structures for Supabase Tables (Serde)
2186
2187// Note: All table structs use `deny_unknown_fields = false` (serde default) to allow
2188// extra columns added by other applications (e.g., user_id, opt_version) without breaking.
2189
2190#[derive(Debug, Serialize, Deserialize)]
2191struct KVStoreTable {
2192    primary_namespace: String,
2193    secondary_namespace: String,
2194    key: String,
2195    value: String, // hex encoded bytea
2196    /// Extra fields from other applications (captured during deserialization, ignored during serialization)
2197    #[serde(default, skip_serializing, flatten)]
2198    _extra: serde_json::Map<String, serde_json::Value>,
2199}
2200
2201#[derive(Debug, Serialize, Deserialize, Default)]
2202struct MintTable {
2203    mint_url: String,
2204    name: Option<String>,
2205    pubkey: Option<String>,
2206    version: Option<String>,
2207    description: Option<String>,
2208    description_long: Option<String>,
2209    contact: Option<String>,
2210    nuts: Option<String>,
2211    icon_url: Option<String>,
2212    urls: Option<String>,
2213    motd: Option<String>,
2214    mint_time: Option<i64>,
2215    tos_url: Option<String>,
2216    /// Extra fields from other applications (captured during deserialization, ignored during serialization)
2217    #[serde(default, skip_serializing, flatten)]
2218    _extra: serde_json::Map<String, serde_json::Value>,
2219}
2220
2221impl MintTable {
2222    fn from_info(mint_url: MintUrl, info: MintInfo) -> Result<Self, DatabaseError> {
2223        Ok(Self {
2224            mint_url: mint_url.to_string(),
2225            name: info.name,
2226            pubkey: info.pubkey.map(|p| hex::encode(p.to_bytes())),
2227            version: info
2228                .version
2229                .map(|v| serde_json::to_string(&v))
2230                .transpose()?,
2231            description: info.description,
2232            description_long: info.description_long,
2233            contact: info
2234                .contact
2235                .map(|c| serde_json::to_string(&c))
2236                .transpose()?,
2237            nuts: Some(serde_json::to_string(&info.nuts)?),
2238            icon_url: info.icon_url,
2239            urls: info.urls.map(|u| serde_json::to_string(&u)).transpose()?,
2240            motd: info.motd,
2241            mint_time: info.time.map(|t| t as i64),
2242            tos_url: info.tos_url,
2243            _extra: Default::default(),
2244        })
2245    }
2246}
2247
2248impl TryInto<MintInfo> for MintTable {
2249    type Error = DatabaseError;
2250    fn try_into(self) -> Result<MintInfo, Self::Error> {
2251        // Helper to filter empty strings before JSON parsing
2252        fn parse_json_field<T: serde::de::DeserializeOwned>(
2253            field: Option<String>,
2254        ) -> Result<Option<T>, serde_json::Error> {
2255            match field {
2256                Some(s) if !s.trim().is_empty() => {
2257                    let s = s.trim();
2258                    match serde_json::from_str::<T>(s) {
2259                        Ok(v) => Ok(Some(v)),
2260                        Err(e) => {
2261                            // If it fails to parse, try wrapping it in quotes in case it's a bare string
2262                            // but only if it doesn't already look like a JSON object or array
2263                            if !s.starts_with('{') && !s.starts_with('[') && !s.starts_with('"') {
2264                                let quoted = format!("\"{}\"", s);
2265                                if let Ok(v) = serde_json::from_str::<T>(&quoted) {
2266                                    return Ok(Some(v));
2267                                }
2268                            }
2269                            Err(e)
2270                        }
2271                    }
2272                }
2273                _ => Ok(None),
2274            }
2275        }
2276
2277        Ok(MintInfo {
2278            name: self.name,
2279            pubkey: self
2280                .pubkey
2281                .map(|p| {
2282                    PublicKey::from_hex(&p)
2283                        .map_err(|_| DatabaseError::Internal("Invalid pubkey hex".into()))
2284                })
2285                .transpose()?,
2286            version: parse_json_field(self.version)?,
2287            description: self.description,
2288            description_long: self.description_long,
2289            contact: parse_json_field(self.contact)?,
2290            nuts: parse_json_field(self.nuts)?.unwrap_or_default(),
2291            icon_url: self.icon_url,
2292            urls: parse_json_field(self.urls)?,
2293            motd: self.motd,
2294            time: self.mint_time.map(|t| t as u64),
2295            tos_url: self.tos_url,
2296        })
2297    }
2298}
2299
2300#[derive(Debug, Serialize, Deserialize)]
2301struct KeySetTable {
2302    mint_url: String,
2303    id: String,
2304    unit: String,
2305    active: bool,
2306    input_fee_ppk: i64,
2307    final_expiry: Option<i64>,
2308    keyset_u32: Option<i64>,
2309    /// Extra fields from other applications (captured during deserialization, ignored during serialization)
2310    #[serde(default, skip_serializing, flatten)]
2311    _extra: serde_json::Map<String, serde_json::Value>,
2312}
2313
2314impl KeySetTable {
2315    fn from_info(mint_url: MintUrl, info: KeySetInfo) -> Result<Self, DatabaseError> {
2316        Ok(Self {
2317            mint_url: mint_url.to_string(),
2318            id: info.id.to_string(),
2319            unit: info.unit.to_string(),
2320            active: info.active,
2321            input_fee_ppk: info.input_fee_ppk as i64,
2322            final_expiry: info.final_expiry.map(|v| v as i64),
2323            keyset_u32: Some(u32::from(info.id) as i64),
2324            _extra: Default::default(),
2325        })
2326    }
2327}
2328
2329impl TryInto<KeySetInfo> for KeySetTable {
2330    type Error = DatabaseError;
2331    fn try_into(self) -> Result<KeySetInfo, Self::Error> {
2332        Ok(KeySetInfo {
2333            id: Id::from_str(&self.id).map_err(|_| DatabaseError::InvalidKeysetId)?,
2334            unit: CurrencyUnit::from_str(&self.unit)
2335                .map_err(|_| DatabaseError::Internal("Invalid unit".into()))?,
2336            active: self.active,
2337            input_fee_ppk: self.input_fee_ppk as u64,
2338            final_expiry: self.final_expiry.map(|v| v as u64),
2339        })
2340    }
2341}
2342
2343#[derive(Debug, Serialize, Deserialize)]
2344struct P2PKSigningKeyTable {
2345    pubkey: String,
2346    derivation_index: i64,
2347    derivation_path: String,
2348    created_time: i64,
2349    /// Extra fields from other applications (captured during deserialization, ignored during serialization)
2350    #[serde(default, skip_serializing, flatten)]
2351    _extra: serde_json::Map<String, serde_json::Value>,
2352}
2353
2354impl TryInto<wallet::P2PKSigningKey> for P2PKSigningKeyTable {
2355    type Error = DatabaseError;
2356    fn try_into(self) -> Result<wallet::P2PKSigningKey, Self::Error> {
2357        Ok(wallet::P2PKSigningKey {
2358            pubkey: PublicKey::from_hex(&self.pubkey)
2359                .map_err(|_| DatabaseError::Internal("Invalid pubkey hex".into()))?,
2360            derivation_path: DerivationPath::from_str(&self.derivation_path)
2361                .map_err(|_| DatabaseError::Internal("Invalid derivation path".into()))?,
2362            derivation_index: self.derivation_index as u32,
2363            created_time: self.created_time as u64,
2364        })
2365    }
2366}
2367
2368#[derive(Debug, Serialize, Deserialize)]
2369struct KeyTable {
2370    id: String,
2371    keys: String, // json string
2372    keyset_u32: Option<i64>,
2373    /// Extra fields from other applications (captured during deserialization, ignored during serialization)
2374    #[serde(default, skip_serializing, flatten)]
2375    _extra: serde_json::Map<String, serde_json::Value>,
2376}
2377
2378impl KeyTable {
2379    fn from_keyset(keyset: &KeySet) -> Result<Self, DatabaseError> {
2380        Ok(Self {
2381            id: keyset.id.to_string(),
2382            keys: serde_json::to_string(&keyset.keys)?,
2383            keyset_u32: Some(u32::from(keyset.id) as i64),
2384            _extra: Default::default(),
2385        })
2386    }
2387}
2388
2389impl TryInto<Keys> for KeyTable {
2390    type Error = DatabaseError;
2391    fn try_into(self) -> Result<Keys, Self::Error> {
2392        Ok(serde_json::from_str(&self.keys)?)
2393    }
2394}
2395
2396#[derive(Debug, Serialize, Deserialize)]
2397struct MintQuoteTable {
2398    id: String,
2399    mint_url: String,
2400    amount: i64,
2401    unit: String,
2402    request: Option<String>,
2403    state: String,
2404    expiry: i64,
2405    secret_key: Option<String>,
2406    payment_method: String,
2407    amount_issued: i64,
2408    amount_paid: i64,
2409    #[serde(default)]
2410    used_by_operation: Option<String>,
2411    #[serde(default)]
2412    version: Option<i32>,
2413    /// Extra fields from other applications (captured during deserialization, ignored during serialization)
2414    #[serde(default, skip_serializing, flatten)]
2415    _extra: serde_json::Map<String, serde_json::Value>,
2416}
2417
2418impl TryInto<MintQuote> for MintQuoteTable {
2419    type Error = DatabaseError;
2420    fn try_into(self) -> Result<MintQuote, Self::Error> {
2421        Ok(MintQuote {
2422            id: self.id,
2423            mint_url: MintUrl::from_str(&self.mint_url)
2424                .map_err(|e| DatabaseError::Internal(e.to_string()))?,
2425            amount: Some(cdk_common::Amount::from(self.amount as u64)),
2426            unit: CurrencyUnit::from_str(&self.unit)
2427                .map_err(|_| DatabaseError::Internal("Invalid unit".into()))?,
2428            request: self
2429                .request
2430                .ok_or(DatabaseError::Internal("Missing request".into()))?,
2431            state: cdk_common::nuts::MintQuoteState::from_str(&self.state)
2432                .map_err(|_| DatabaseError::Internal("Invalid state".into()))?,
2433            expiry: self.expiry as u64,
2434            secret_key: self
2435                .secret_key
2436                .map(|k| cdk_common::nuts::SecretKey::from_str(&k))
2437                .transpose()
2438                .map_err(|_| DatabaseError::Internal("Invalid secret key".into()))?,
2439            payment_method: cdk_common::PaymentMethod::from_str(&self.payment_method)
2440                .map_err(|_| DatabaseError::Internal("Invalid payment method".into()))?,
2441            amount_issued: cdk_common::Amount::from(self.amount_issued as u64),
2442            amount_paid: cdk_common::Amount::from(self.amount_paid as u64),
2443            used_by_operation: self.used_by_operation,
2444            version: self.version.unwrap_or(0) as u32,
2445        })
2446    }
2447}
2448
2449impl TryFrom<MintQuote> for MintQuoteTable {
2450    type Error = DatabaseError;
2451    fn try_from(q: MintQuote) -> Result<Self, Self::Error> {
2452        Ok(Self {
2453            id: q.id,
2454            mint_url: q.mint_url.to_string(),
2455            amount: q.amount.map(|a| a.to_u64() as i64).unwrap_or(0),
2456            unit: q.unit.to_string(),
2457            request: Some(q.request),
2458            state: q.state.to_string(),
2459            expiry: q.expiry as i64,
2460            secret_key: q.secret_key.map(|k| k.to_string()),
2461            payment_method: q.payment_method.to_string(),
2462            amount_issued: q.amount_issued.to_u64() as i64,
2463            amount_paid: q.amount_paid.to_u64() as i64,
2464            used_by_operation: q.used_by_operation,
2465            version: Some(q.version as i32),
2466            _extra: Default::default(),
2467        })
2468    }
2469}
2470
2471#[derive(Debug, Serialize, Deserialize)]
2472struct MeltQuoteTable {
2473    id: String,
2474    unit: String,
2475    amount: i64,
2476    request: String,
2477    fee_reserve: i64,
2478    state: String,
2479    expiry: i64,
2480    payment_preimage: Option<String>,
2481    payment_method: String,
2482    #[serde(default)]
2483    mint_url: Option<String>,
2484    #[serde(default)]
2485    used_by_operation: Option<String>,
2486    #[serde(default)]
2487    version: Option<i32>,
2488    /// Extra fields from other applications (captured during deserialization, ignored during serialization)
2489    #[serde(default, skip_serializing, flatten)]
2490    _extra: serde_json::Map<String, serde_json::Value>,
2491}
2492
2493impl TryInto<wallet::MeltQuote> for MeltQuoteTable {
2494    type Error = DatabaseError;
2495    fn try_into(self) -> Result<wallet::MeltQuote, Self::Error> {
2496        Ok(wallet::MeltQuote {
2497            id: self.id,
2498            mint_url: self
2499                .mint_url
2500                .as_deref()
2501                .map(cdk_common::mint_url::MintUrl::from_str)
2502                .transpose()
2503                .map_err(|_| DatabaseError::Internal("Invalid mint URL".into()))?,
2504            unit: CurrencyUnit::from_str(&self.unit)
2505                .map_err(|_| DatabaseError::Internal("Invalid unit".into()))?,
2506            amount: cdk_common::Amount::from(self.amount as u64),
2507            request: self.request,
2508            fee_reserve: cdk_common::Amount::from(self.fee_reserve as u64),
2509            state: cdk_common::nuts::MeltQuoteState::from_str(&self.state)
2510                .map_err(|_| DatabaseError::Internal("Invalid state".into()))?,
2511            expiry: self.expiry as u64,
2512            payment_preimage: self.payment_preimage,
2513            payment_method: cdk_common::PaymentMethod::from_str(&self.payment_method)
2514                .map_err(|_| DatabaseError::Internal("Invalid payment method".into()))?,
2515            used_by_operation: self.used_by_operation,
2516            version: self.version.unwrap_or(0) as u32,
2517        })
2518    }
2519}
2520
2521impl TryFrom<wallet::MeltQuote> for MeltQuoteTable {
2522    type Error = DatabaseError;
2523    fn try_from(q: wallet::MeltQuote) -> Result<Self, Self::Error> {
2524        Ok(Self {
2525            id: q.id,
2526            mint_url: q.mint_url.map(|u| u.to_string()),
2527            unit: q.unit.to_string(),
2528            amount: q.amount.to_u64() as i64,
2529            request: q.request,
2530            fee_reserve: q.fee_reserve.to_u64() as i64,
2531            state: q.state.to_string(),
2532            expiry: q.expiry as i64,
2533            payment_preimage: q.payment_preimage,
2534            payment_method: q.payment_method.to_string(),
2535            used_by_operation: q.used_by_operation,
2536            version: Some(q.version as i32),
2537            _extra: Default::default(),
2538        })
2539    }
2540}
2541
2542#[derive(Debug, Serialize, Deserialize)]
2543struct ProofTable {
2544    y: String,
2545    mint_url: String,
2546    state: String,
2547    spending_condition: Option<String>,
2548    unit: String,
2549    amount: i64,
2550    keyset_id: String,
2551    secret: String,
2552    c: String,
2553    witness: Option<String>,
2554    dleq_e: Option<String>,
2555    dleq_s: Option<String>,
2556    dleq_r: Option<String>,
2557    #[serde(default)]
2558    used_by_operation: Option<String>,
2559    #[serde(default)]
2560    created_by_operation: Option<String>,
2561    #[serde(default)]
2562    p2pk_e: Option<String>,
2563    /// Extra fields from other applications (captured during deserialization, ignored during serialization)
2564    #[serde(default, skip_serializing, flatten)]
2565    _extra: serde_json::Map<String, serde_json::Value>,
2566}
2567
2568impl TryInto<ProofInfo> for ProofTable {
2569    type Error = DatabaseError;
2570    fn try_into(self) -> Result<ProofInfo, Self::Error> {
2571        let y = PublicKey::from_hex(&self.y)
2572            .map_err(|_| DatabaseError::Internal("Invalid y".into()))?;
2573        let c = PublicKey::from_hex(&self.c)
2574            .map_err(|_| DatabaseError::Internal("Invalid c".into()))?;
2575        Ok(ProofInfo {
2576            y,
2577            mint_url: MintUrl::from_str(&self.mint_url)
2578                .map_err(|e| DatabaseError::Internal(e.to_string()))?,
2579            state: cdk_common::nuts::State::from_str(&self.state)
2580                .map_err(|_| DatabaseError::Internal("Invalid state".into()))?,
2581            spending_condition: self
2582                .spending_condition
2583                .filter(|s| !s.trim().is_empty())
2584                .map(|s| serde_json::from_str(&s))
2585                .transpose()?,
2586            unit: CurrencyUnit::from_str(&self.unit)
2587                .map_err(|_| DatabaseError::Internal("Invalid unit".into()))?,
2588            proof: cdk_common::Proof {
2589                amount: cdk_common::Amount::from(self.amount as u64),
2590                keyset_id: Id::from_str(&self.keyset_id)
2591                    .map_err(|_| DatabaseError::InvalidKeysetId)?,
2592                secret: Secret::from_str(&self.secret)
2593                    .map_err(|_| DatabaseError::Internal("Invalid secret".into()))?,
2594                c,
2595                witness: self
2596                    .witness
2597                    .filter(|w| !w.trim().is_empty())
2598                    .map(|w| serde_json::from_str(&w))
2599                    .transpose()?,
2600                dleq: match (self.dleq_e, self.dleq_s, self.dleq_r) {
2601                    (Some(e), Some(s), Some(r)) => Some(cdk_common::ProofDleq {
2602                        e: cdk_common::SecretKey::from_hex(&e)
2603                            .map_err(|_| DatabaseError::Internal("Invalid dleq_e".into()))?,
2604                        s: cdk_common::SecretKey::from_hex(&s)
2605                            .map_err(|_| DatabaseError::Internal("Invalid dleq_s".into()))?,
2606                        r: cdk_common::SecretKey::from_hex(&r)
2607                            .map_err(|_| DatabaseError::Internal("Invalid dleq_r".into()))?,
2608                    }),
2609                    _ => None,
2610                },
2611                p2pk_e: self
2612                    .p2pk_e
2613                    .map(|s| PublicKey::from_hex(&s))
2614                    .transpose()
2615                    .map_err(|_| DatabaseError::Internal("Invalid p2pk_e".into()))?,
2616            },
2617            used_by_operation: self
2618                .used_by_operation
2619                .map(|s| uuid::Uuid::parse_str(&s))
2620                .transpose()
2621                .map_err(|_| DatabaseError::Internal("Invalid used_by_operation uuid".into()))?,
2622            created_by_operation: self
2623                .created_by_operation
2624                .map(|s| uuid::Uuid::parse_str(&s))
2625                .transpose()
2626                .map_err(|_| DatabaseError::Internal("Invalid created_by_operation uuid".into()))?,
2627        })
2628    }
2629}
2630
2631impl TryFrom<ProofInfo> for ProofTable {
2632    type Error = DatabaseError;
2633    fn try_from(p: ProofInfo) -> Result<Self, Self::Error> {
2634        Ok(Self {
2635            y: hex::encode(p.y.to_bytes()),
2636            mint_url: p.mint_url.to_string(),
2637            state: p.state.to_string(),
2638            spending_condition: p
2639                .spending_condition
2640                .map(|s| serde_json::to_string(&s))
2641                .transpose()?,
2642            unit: p.unit.to_string(),
2643            amount: p.proof.amount.to_u64() as i64,
2644            keyset_id: p.proof.keyset_id.to_string(),
2645            secret: p.proof.secret.to_string(),
2646            c: hex::encode(p.proof.c.to_bytes()),
2647            witness: p
2648                .proof
2649                .witness
2650                .map(|w| serde_json::to_string(&w))
2651                .transpose()?,
2652            dleq_e: p
2653                .proof
2654                .dleq
2655                .as_ref()
2656                .map(|d| hex::encode(d.e.to_secret_bytes())),
2657            dleq_s: p
2658                .proof
2659                .dleq
2660                .as_ref()
2661                .map(|d| hex::encode(d.s.to_secret_bytes())),
2662            dleq_r: p
2663                .proof
2664                .dleq
2665                .as_ref()
2666                .map(|d| hex::encode(d.r.to_secret_bytes())),
2667            used_by_operation: p.used_by_operation.map(|u| u.to_string()),
2668            created_by_operation: p.created_by_operation.map(|u| u.to_string()),
2669            p2pk_e: p.proof.p2pk_e.map(|e| hex::encode(e.to_bytes())),
2670            _extra: Default::default(),
2671        })
2672    }
2673}
2674
2675#[derive(Debug, Serialize, Deserialize)]
2676struct TransactionTable {
2677    id: String,
2678    mint_url: String,
2679    direction: String,
2680    unit: String,
2681    amount: i64,
2682    fee: i64,
2683    ys: Option<Vec<String>>,
2684    timestamp: i64,
2685    memo: Option<String>,
2686    metadata: Option<String>,
2687    quote_id: Option<String>,
2688    payment_request: Option<String>,
2689    payment_proof: Option<String>,
2690    payment_method: Option<String>,
2691    #[serde(default)]
2692    saga_id: Option<String>,
2693    /// Extra fields from other applications (captured during deserialization, ignored during serialization)
2694    #[serde(default, skip_serializing, flatten)]
2695    _extra: serde_json::Map<String, serde_json::Value>,
2696}
2697
2698impl TryInto<Transaction> for TransactionTable {
2699    type Error = DatabaseError;
2700    fn try_into(self) -> Result<Transaction, Self::Error> {
2701        let id_bytes = hex::decode(&self.id)
2702            .map_err(|_| DatabaseError::Internal("Invalid transaction id hex".into()))?;
2703        let _id_arr: [u8; 32] = id_bytes
2704            .try_into()
2705            .map_err(|_| DatabaseError::Internal("Invalid transaction id len".into()))?;
2706
2707        let ys = match self.ys {
2708            Some(strs) => strs
2709                .into_iter()
2710                .map(|s| {
2711                    PublicKey::from_hex(&s)
2712                        .map_err(|_| DatabaseError::Internal("Invalid y hex".into()))
2713                })
2714                .collect::<Result<Vec<_>, _>>()?,
2715            None => vec![],
2716        };
2717
2718        Ok(Transaction {
2719            mint_url: MintUrl::from_str(&self.mint_url)
2720                .map_err(|e| DatabaseError::Internal(e.to_string()))?,
2721            direction: TransactionDirection::from_str(&self.direction)
2722                .map_err(|_| DatabaseError::Internal("Invalid direction".into()))?,
2723            unit: CurrencyUnit::from_str(&self.unit)
2724                .map_err(|_| DatabaseError::Internal("Invalid unit".into()))?,
2725            amount: cdk_common::Amount::from(self.amount as u64),
2726            fee: cdk_common::Amount::from(self.fee as u64),
2727            ys,
2728            timestamp: self.timestamp as u64,
2729            memo: self.memo,
2730            metadata: self
2731                .metadata
2732                .filter(|m| !m.trim().is_empty())
2733                .map(|m| serde_json::from_str(&m))
2734                .transpose()?
2735                .unwrap_or_default(),
2736            quote_id: self.quote_id,
2737            payment_request: self.payment_request,
2738            payment_proof: self.payment_proof,
2739            payment_method: self
2740                .payment_method
2741                .map(|p| cdk_common::PaymentMethod::from_str(&p))
2742                .transpose()
2743                .map_err(|_| DatabaseError::Internal("Invalid payment method".into()))?,
2744            saga_id: self
2745                .saga_id
2746                .map(|s| uuid::Uuid::parse_str(&s))
2747                .transpose()
2748                .map_err(|_| DatabaseError::Internal("Invalid saga_id uuid".into()))?,
2749        })
2750    }
2751}
2752
2753impl TryFrom<Transaction> for TransactionTable {
2754    type Error = DatabaseError;
2755    fn try_from(t: Transaction) -> Result<Self, Self::Error> {
2756        Ok(Self {
2757            id: t.id().to_string(),
2758            mint_url: t.mint_url.to_string(),
2759            direction: t.direction.to_string(),
2760            unit: t.unit.to_string(),
2761            amount: t.amount.to_u64() as i64,
2762            fee: t.fee.to_u64() as i64,
2763            ys: Some(t.ys.iter().map(|y| hex::encode(y.to_bytes())).collect()),
2764            timestamp: t.timestamp as i64,
2765            memo: t.memo,
2766            metadata: if t.metadata.is_empty() {
2767                None
2768            } else {
2769                Some(serde_json::to_string(&t.metadata)?)
2770            },
2771            quote_id: t.quote_id,
2772            payment_request: t.payment_request,
2773            payment_proof: t.payment_proof,
2774            payment_method: t.payment_method.map(|p| p.to_string()),
2775            saga_id: t.saga_id.map(|u| u.to_string()),
2776            _extra: Default::default(),
2777        })
2778    }
2779}
2780
2781#[derive(Debug, Serialize, Deserialize)]
2782struct SagaTable {
2783    id: String,
2784    data: String, // JSON-serialized WalletSaga
2785    version: i32,
2786    completed: bool,
2787    created_at: i64,
2788    updated_at: i64,
2789    /// Extra fields from other applications
2790    #[serde(default, skip_serializing, flatten)]
2791    _extra: serde_json::Map<String, serde_json::Value>,
2792}
2793
2794/// Response from Supabase Auth sign-up/sign-in
2795#[derive(Debug, Deserialize, Serialize, Clone)]
2796pub struct SupabaseAuthResponse {
2797    /// Access token
2798    pub access_token: String,
2799    /// Token type
2800    pub token_type: String,
2801    /// Expires in
2802    pub expires_in: Option<i64>,
2803    /// Refresh token
2804    pub refresh_token: Option<String>,
2805    /// User
2806    pub user: serde_json::Value,
2807}
2808
2809/// Helper for Supabase Authentication
2810#[derive(Debug)]
2811pub struct SupabaseAuth;
2812
2813impl SupabaseAuth {
2814    /// Sign up a new user with email and password
2815    pub async fn signup(
2816        url: &Url,
2817        api_key: &str,
2818        email: &str,
2819        password: &str,
2820    ) -> Result<SupabaseAuthResponse, Error> {
2821        let auth_url = url
2822            .join("auth/v1/signup")
2823            .map_err(|e| Error::Supabase(format!("Invalid auth URL: {}", e)))?;
2824
2825        let client = Client::new();
2826        let body = serde_json::json!({
2827            "email": email,
2828            "password": password
2829        });
2830
2831        let response = client
2832            .post(auth_url)
2833            .header("apikey", api_key)
2834            .header("Content-Type", "application/json")
2835            .json(&body)
2836            .send()
2837            .await
2838            .map_err(Error::Reqwest)?;
2839
2840        let status = response.status();
2841        if !status.is_success() {
2842            let text = response.text().await.unwrap_or_default();
2843            return Err(Error::Supabase(format!(
2844                "Supabase signup failed: HTTP {} - {}",
2845                status, text
2846            )));
2847        }
2848
2849        response.json().await.map_err(Error::Reqwest)
2850    }
2851
2852    /// Sign in a user with email and password
2853    pub async fn signin(
2854        url: &Url,
2855        api_key: &str,
2856        email: &str,
2857        password: &str,
2858    ) -> Result<SupabaseAuthResponse, Error> {
2859        let auth_url = url
2860            .join("auth/v1/token?grant_type=password")
2861            .map_err(|e| Error::Supabase(format!("Invalid auth URL: {}", e)))?;
2862
2863        let client = Client::new();
2864        let body = serde_json::json!({
2865            "email": email,
2866            "password": password
2867        });
2868
2869        let response = client
2870            .post(auth_url)
2871            .header("apikey", api_key)
2872            .header("Content-Type", "application/json")
2873            .json(&body)
2874            .send()
2875            .await
2876            .map_err(Error::Reqwest)?;
2877
2878        let status = response.status();
2879        if !status.is_success() {
2880            let text = response.text().await.unwrap_or_default();
2881            return Err(Error::Supabase(format!(
2882                "Supabase signin failed: HTTP {} - {}",
2883                status, text
2884            )));
2885        }
2886
2887        response.json().await.map_err(Error::Reqwest)
2888    }
2889}
2890
2891#[cfg(test)]
2892mod tests {
2893    use super::*;
2894
2895    #[test]
2896    fn test_set_encryption_password_key_derivation() {
2897        // SHA-256("password") == 5e884898...
2898        let key = sha256::Hash::hash(b"password");
2899        assert_eq!(
2900            hex::encode(key.as_byte_array()),
2901            "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
2902        );
2903    }
2904}