forked from StellarSend/backend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbatch.rs
More file actions
312 lines (285 loc) · 11.7 KB
/
Copy pathbatch.rs
File metadata and controls
312 lines (285 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
use crate::{
error::{AppError, AppResult},
models::{
batch_payment::{BatchPaymentResult, SendBatchPaymentRequest},
transaction::TransactionStatus,
},
services::{stellar::StellarService, tx_hash::compute_transaction_hash},
};
use sqlx::PgPool;
use uuid::Uuid;
/// Business logic for split/batch payments: one client-signed transaction,
/// many recipients, relayed once and recorded as one `transactions` row per
/// leg (same non-custodial pattern as a normal send — the client already
/// built and signed the whole batch).
pub struct BatchPaymentService {
pool: PgPool,
}
/// True if `error` is a Postgres unique/primary-key violation — used to
/// detect losing the race to claim a `batch_submissions` row.
fn is_unique_violation(error: &sqlx::Error) -> bool {
matches!(error, sqlx::Error::Database(db_err) if db_err.is_unique_violation())
}
/// Decides what a failed submission attempt means for transaction status
/// (#30). The distinction is "did we get a definitive answer from Horizon
/// at all":
///
/// - `HorizonError` means Horizon received the request and rejected it —
/// that's a real, final answer, safe to record as `Failed`.
/// - Anything else (`HttpClient`, i.e. a connection failure or timeout —
/// the only other variant `StellarService::submit_transaction` can
/// return) means we never got Horizon's answer. The transaction may
/// still have landed, so this is `SubmittedUnconfirmed`, not `Failed` —
/// a false-negative failure here is what leads a caller to safely (but
/// wrongly) retry into a double payment. `ReconciliationService`
/// resolves it later by asking Horizon directly.
fn classify_submission_error(error: &AppError) -> TransactionStatus {
match error {
AppError::HorizonError(_) => TransactionStatus::Failed,
_ => TransactionStatus::SubmittedUnconfirmed,
}
}
impl BatchPaymentService {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
pub async fn execute_batch(
&self,
user_id: Uuid,
stellar: &StellarService,
network_passphrase: &str,
req: &SendBatchPaymentRequest,
) -> AppResult<BatchPaymentResult> {
if req.legs.is_empty() {
return Err(AppError::Validation(
"A batch payment must include at least one recipient".into(),
));
}
if req.legs.len() > 100 {
// Stellar caps operations per transaction at 100.
return Err(AppError::Validation(
"A batch payment cannot include more than 100 recipients".into(),
));
}
for leg in &req.legs {
let amount: f64 = leg
.amount
.parse()
.map_err(|_| AppError::Validation(format!("Invalid amount for {}", leg.destination)))?;
if amount <= 0.0 {
return Err(AppError::Validation(format!(
"Amount for {} must be positive",
leg.destination
)));
}
}
if req.signed_xdr.trim().is_empty() {
return Err(AppError::BadRequest("signed_xdr must not be empty".into()));
}
// Compute the transaction hash locally, before ever talking to
// Horizon, so a crash between here and the post-submission UPDATE
// still leaves a durable link between our rows and the actual
// on-chain transaction — reconciliation can look it up by this
// hash even if the submission response itself is lost (#30).
let tx_hash = compute_transaction_hash(&req.signed_xdr, network_passphrase)?;
// Claim the hash before doing anything else. A primary-key
// violation here means this exact signed transaction was already
// submitted (by this caller retrying, or a genuinely concurrent
// request) — reject outright rather than risk a double payment.
// The atomic INSERT is what actually closes this race; checking
// "does a row for this hash exist?" first and inserting after
// would leave the identical TOCTOU gap the underlying bug is made
// of, just moved one level up.
let batch_id = Uuid::new_v4();
if let Err(e) =
sqlx::query("INSERT INTO batch_submissions (stellar_tx_hash, batch_id) VALUES ($1, $2)")
.bind(&tx_hash)
.bind(batch_id)
.execute(&self.pool)
.await
{
if is_unique_violation(&e) {
return Err(AppError::Conflict(
"A batch payment with this signed transaction has already been submitted"
.into(),
));
}
return Err(e.into());
}
// All-or-nothing: insert every leg row in a single DB transaction
// so a crash mid-loop can never leave a partial batch (gaps in
// batch_index) that was never actually submitted.
let mut leg_ids = Vec::with_capacity(req.legs.len());
let mut db_tx = self.pool.begin().await?;
for (index, leg) in req.legs.iter().enumerate() {
let asset = match &leg.asset_issuer {
Some(issuer) => format!("{}:{}", leg.asset_code, issuer),
None => leg.asset_code.clone(),
};
let tx_id = Uuid::new_v4();
sqlx::query(
r#"
INSERT INTO transactions (
id, user_id, from_asset, to_asset, send_amount, receive_amount,
source_account, destination_account, status, stellar_tx_hash,
batch_id, batch_index
)
VALUES ($1, $2, $3, $3, $4, NULL, $5, $6, 'pending', $7, $8, $9)
"#,
)
.bind(tx_id)
.bind(user_id)
.bind(&asset)
.bind(&leg.amount)
.bind(&req.source_account)
.bind(&leg.destination)
.bind(&tx_hash)
.bind(batch_id)
.bind(index as i32)
.execute(&mut *db_tx)
.await?;
leg_ids.push(tx_id);
}
db_tx.commit().await?;
// Submit the single, already-signed batch transaction once.
let submission = stellar.submit_transaction(&req.signed_xdr).await;
match submission {
Ok(result) => {
if result.hash != tx_hash {
// Should never happen — would mean our local hash
// computation disagrees with Horizon's, which breaks
// the reconciliation link for this batch. Surface it
// loudly without failing the (already-successful)
// request.
tracing::error!(
computed_hash = %tx_hash,
horizon_hash = %result.hash,
batch_id = %batch_id,
"Locally computed transaction hash does not match Horizon's reported hash"
);
}
sqlx::query(
r#"
UPDATE transactions
SET status = $2, stellar_tx_hash = $3, updated_at = NOW()
WHERE batch_id = $1
"#,
)
.bind(batch_id)
.bind(TransactionStatus::Completed)
.bind(&result.hash)
.execute(&self.pool)
.await?;
Ok(BatchPaymentResult {
batch_id,
tx_hash: result.hash,
success: result.successful,
ledger: result.ledger,
fee_charged: result.fee_charged,
leg_transaction_ids: leg_ids,
})
}
Err(e) => {
let status = classify_submission_error(&e);
sqlx::query(
r#"
UPDATE transactions
SET status = $2, error_message = $3, updated_at = NOW()
WHERE batch_id = $1
"#,
)
.bind(batch_id)
.bind(status)
.bind(e.to_string())
.execute(&self.pool)
.await?;
Err(e)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::batch_payment::BatchPaymentLeg;
const NETWORK: &str = "Test SDF Network ; September 2015";
fn dummy_pool() -> PgPool {
PgPool::connect_lazy("postgres://user:pass@localhost/db").unwrap()
}
#[tokio::test]
async fn rejects_empty_batch() {
let svc = BatchPaymentService::new(dummy_pool());
let stellar = StellarService::new("https://horizon-testnet.stellar.org");
let req = SendBatchPaymentRequest {
source_account: "GSOURCE".into(),
signed_xdr: "AAAA".into(),
legs: vec![],
};
let err = svc
.execute_batch(Uuid::new_v4(), &stellar, NETWORK, &req)
.await
.unwrap_err();
assert!(matches!(err, AppError::Validation(_)));
}
#[tokio::test]
async fn rejects_non_positive_leg_amount() {
let svc = BatchPaymentService::new(dummy_pool());
let stellar = StellarService::new("https://horizon-testnet.stellar.org");
let req = SendBatchPaymentRequest {
source_account: "GSOURCE".into(),
signed_xdr: "AAAA".into(),
legs: vec![BatchPaymentLeg {
destination: "GDEST".into(),
amount: "0".into(),
asset_code: "XLM".into(),
asset_issuer: None,
}],
};
let err = svc
.execute_batch(Uuid::new_v4(), &stellar, NETWORK, &req)
.await
.unwrap_err();
assert!(matches!(err, AppError::Validation(_)));
}
#[tokio::test]
async fn rejects_invalid_signed_xdr_before_touching_the_database() {
// A batch that passes leg validation but carries un-parseable
// signed_xdr must fail at hash computation, before any DB call —
// exercised here via a lazy (never-connects) pool to prove it
// never tries to touch the database.
let svc = BatchPaymentService::new(dummy_pool());
let stellar = StellarService::new("https://horizon-testnet.stellar.org");
let req = SendBatchPaymentRequest {
source_account: "GSOURCE".into(),
signed_xdr: "not-valid-xdr".into(),
legs: vec![BatchPaymentLeg {
destination: "GDEST".into(),
amount: "10".into(),
asset_code: "XLM".into(),
asset_issuer: None,
}],
};
let err = svc
.execute_batch(Uuid::new_v4(), &stellar, NETWORK, &req)
.await
.unwrap_err();
assert!(matches!(err, AppError::BadRequest(_)));
}
#[test]
fn classifies_a_horizon_rejection_as_failed() {
let err = AppError::HorizonError("tx_bad_seq".into());
assert_eq!(classify_submission_error(&err), TransactionStatus::Failed);
}
#[test]
fn classifies_anything_else_as_submitted_unconfirmed() {
// AppError::HttpClient wraps a reqwest::Error, which has no public
// constructor; Internal(anyhow) exercises the same "not a
// HorizonError" branch classify_submission_error actually
// switches on, without needing a real network error to build one.
let err = AppError::Internal(anyhow::anyhow!("connection reset"));
assert_eq!(
classify_submission_error(&err),
TransactionStatus::SubmittedUnconfirmed
);
}
}