hypercall_db_diesel/
faucet.rs1use 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#[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#[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 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 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 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 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 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 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 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 let result = db
203 .persist_faucet_credit(&wallet, dec!(600), limit, 1_000_000)
204 .await;
205 assert!(result.is_err());
206 }
207}