Skip to main content

hypercall_db_diesel/
faucet.rs

1//! FaucetWriter trait implementation for DieselDb.
2//!
3//! Implements atomic faucet credit operations: upsert credit total with limit
4//! check and insert a deposit audit event in one transaction.
5
6use anyhow::{Context, Result};
7use diesel::prelude::*;
8use diesel::sql_types::*;
9use diesel_async::{AsyncConnection, RunQueryDsl};
10use rust_decimal::Decimal;
11
12use crate::diesel_db::DieselDb;
13use hypercall_db::{FaucetCreditResult, FaucetWriter};
14use hypercall_types::WalletAddress;
15
16// ---- Private QueryableByName structs ----
17
18#[derive(QueryableByName)]
19struct TotalRow {
20    #[diesel(sql_type = Numeric)]
21    total_amount: Decimal,
22}
23
24#[derive(QueryableByName)]
25struct IdRow {
26    #[diesel(sql_type = BigInt)]
27    id: i64,
28}
29
30// =============================================================================
31// FaucetWriter
32// =============================================================================
33
34#[async_trait::async_trait]
35impl FaucetWriter for DieselDb {
36    async fn persist_faucet_credit(
37        &self,
38        wallet: &WalletAddress,
39        amount: Decimal,
40        limit: Decimal,
41        window_start_ms: i64,
42    ) -> Result<FaucetCreditResult> {
43        let wallet = *wallet;
44        let mut conn = self.get_conn().await?;
45
46        conn.transaction(async |conn| {
47            // Step 1: Upsert faucet_credits, checking limit.
48            // The WHERE clause prevents the update if it would exceed the limit.
49            let new_total: Option<TotalRow> = diesel::sql_query(
50                r#"
51                INSERT INTO faucet_credits (wallet, total_amount, last_request_ts_ms)
52                SELECT $1, $2, $3
53                WHERE $2 <= $4
54                ON CONFLICT (wallet)
55                DO UPDATE SET
56                    total_amount = faucet_credits.total_amount + EXCLUDED.total_amount,
57                    last_request_ts_ms = EXCLUDED.last_request_ts_ms,
58                    updated_at = NOW()
59                WHERE faucet_credits.total_amount + EXCLUDED.total_amount <= $4
60                RETURNING total_amount
61                "#,
62            )
63            .bind::<Binary, _>(&wallet)
64            .bind::<Numeric, _>(amount)
65            .bind::<BigInt, _>(window_start_ms)
66            .bind::<Numeric, _>(limit)
67            .get_result(&mut *conn)
68            .await
69            .optional()
70            .context("Failed to upsert faucet credit total")?;
71
72            let new_total = match new_total {
73                Some(row) => row.total_amount,
74                None => {
75                    anyhow::bail!("Faucet credit would exceed per-window limit of {}", limit);
76                }
77            };
78
79            // Step 2: Insert ledger event.
80            let id_row: IdRow = diesel::sql_query(
81                "INSERT INTO ledger_events (wallet, event_ts_ms, delta, event_type)
82                 VALUES ($1, $2, $3, 'deposit')
83                 RETURNING id",
84            )
85            .bind::<Binary, _>(&wallet)
86            .bind::<BigInt, _>(window_start_ms)
87            .bind::<Numeric, _>(amount)
88            .get_result(&mut *conn)
89            .await
90            .context("Failed to insert faucet ledger event")?;
91
92            Ok(FaucetCreditResult {
93                new_total,
94                ledger_event_id: id_row.id,
95            })
96        })
97        .await
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use crate::test_helpers::TestDb;
104    use hypercall_db::FaucetWriter;
105    use hypercall_types::wallet_address::test_wallet;
106    use rust_decimal_macros::dec;
107
108    #[tokio::test]
109    async fn persist_faucet_credit_first_credit_succeeds() {
110        let test_db = TestDb::new().await.unwrap();
111        let db = test_db.diesel_db().await;
112
113        let wallet = test_wallet(90);
114        let amount = dec!(100);
115        let limit = dec!(500);
116        let window_start_ms = 1_000_000;
117
118        let result = db
119            .persist_faucet_credit(&wallet, amount, limit, window_start_ms)
120            .await
121            .unwrap();
122
123        assert_eq!(result.new_total, dec!(100));
124        assert!(result.ledger_event_id > 0);
125    }
126
127    #[tokio::test]
128    async fn persist_faucet_credit_accumulates_total() {
129        let test_db = TestDb::new().await.unwrap();
130        let db = test_db.diesel_db().await;
131
132        let wallet = test_wallet(91);
133        let limit = dec!(500);
134
135        // First credit
136        let r1 = db
137            .persist_faucet_credit(&wallet, dec!(100), limit, 1_000_000)
138            .await
139            .unwrap();
140        assert_eq!(r1.new_total, dec!(100));
141
142        // Second credit
143        let r2 = db
144            .persist_faucet_credit(&wallet, dec!(200), limit, 2_000_000)
145            .await
146            .unwrap();
147        assert_eq!(r2.new_total, dec!(300));
148    }
149
150    #[tokio::test]
151    async fn persist_faucet_credit_exceeding_limit_fails() {
152        let test_db = TestDb::new().await.unwrap();
153        let db = test_db.diesel_db().await;
154
155        let wallet = test_wallet(92);
156        let limit = dec!(500);
157
158        // First credit of 400 should succeed
159        let r1 = db
160            .persist_faucet_credit(&wallet, dec!(400), limit, 1_000_000)
161            .await
162            .unwrap();
163        assert_eq!(r1.new_total, dec!(400));
164
165        // Second credit of 200 would bring total to 600 > 500 limit
166        let r2 = db
167            .persist_faucet_credit(&wallet, dec!(200), limit, 2_000_000)
168            .await;
169        assert!(r2.is_err());
170        let err_msg = r2.unwrap_err().to_string();
171        assert!(
172            err_msg.contains("limit"),
173            "Error should mention limit: {err_msg}"
174        );
175    }
176
177    #[tokio::test]
178    async fn persist_faucet_credit_at_exact_limit_succeeds() {
179        let test_db = TestDb::new().await.unwrap();
180        let db = test_db.diesel_db().await;
181
182        let wallet = test_wallet(93);
183        let limit = dec!(500);
184
185        // Credit exactly at the limit should succeed
186        let result = db
187            .persist_faucet_credit(&wallet, dec!(500), limit, 1_000_000)
188            .await
189            .unwrap();
190        assert_eq!(result.new_total, dec!(500));
191    }
192
193    #[tokio::test]
194    async fn persist_faucet_credit_over_limit_on_first_call_fails() {
195        let test_db = TestDb::new().await.unwrap();
196        let db = test_db.diesel_db().await;
197
198        let wallet = test_wallet(94);
199        let limit = dec!(500);
200
201        // Single credit exceeding limit on the very first call
202        let result = db
203            .persist_faucet_credit(&wallet, dec!(600), limit, 1_000_000)
204            .await;
205        assert!(result.is_err());
206    }
207}