Skip to content

Commit 9b864c3

Browse files
authored
Merge pull request #195 from Tyler7x/fix/102-single-inbound-payment
fix(#102): enforce single inbound payment restriction
2 parents 07cae76 + 3a4832b commit 9b864c3

2 files changed

Lines changed: 53 additions & 270 deletions

File tree

contracts/ephemeral_account/src/lib.rs

Lines changed: 29 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,18 @@ mod storage;
77
#[cfg(test)]
88
mod test;
99

10-
use soroban_sdk::{contract, contractimpl, xdr::ToXdr, Address, BytesN, Env, Vec};
11-
1210
pub use bridgelet_shared::{AccountInfo, AccountStatus, Payment};
1311
pub use errors::Error;
1412
pub use events::{
1513
AccountCreated, AccountExpired, MultiPaymentReceived, PaymentReceived, ReserveReclaimed,
1614
SweepExecutedMulti,
1715
};
16+
use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec};
1817
pub use storage::DataKey;
19-
2018
const BASE_RESERVE_STROOPS: i128 = 1_000_000_000;
2119
const CONTRACT_VERSION: u32 = 1;
22-
2320
#[contract]
2421
pub struct EphemeralAccountContract;
25-
2622
#[contractimpl]
2723
impl EphemeralAccountContract {
2824
/// Initialize the ephemeral account with restrictions
@@ -49,10 +45,8 @@ impl EphemeralAccountContract {
4945
if storage::is_initialized(&env) {
5046
return Err(Error::AlreadyInitialized);
5147
}
52-
5348
// Verify creator authorization
5449
creator.require_auth();
55-
5650
// Validate expiry is in future
5751
let current_ledger = env.ledger().sequence();
5852
if expiry_ledger <= current_ledger {
@@ -76,39 +70,37 @@ impl EphemeralAccountContract {
7670
storage::set_min_payment_amount(&env, min_amount);
7771
storage::init_reserve_tracking(&env, BASE_RESERVE_STROOPS);
7872
storage::set_contract_version(&env, CONTRACT_VERSION);
79-
8073
// Emit event
8174
events::emit_account_created(&env, creator, expiry_ledger);
82-
8375
Ok(())
8476
}
8577

86-
/// Record an inbound payment to this ephemeral account.
87-
/// Only the registered relayer address may call this function.
88-
/// Multiple payments with different assets are supported.
78+
/// Record a single inbound payment to this ephemeral account.
79+
///
80+
/// Each account is restricted to **one** payment. A second call will revert
81+
/// with [`Error::PaymentAlreadyReceived`] regardless of the asset.
8982
///
9083
/// # Arguments
91-
/// * `amount` - Payment amount
92-
/// * `asset` - Asset address
84+
/// * `amount` - Payment amount (must be positive)
85+
/// * `asset` - Asset contract address
9386
///
9487
/// # Errors
95-
/// Returns Error::Unauthorized if caller is not the registered relayer
96-
/// Returns Error::InvalidAmount if amount is not positive
97-
/// Returns Error::DuplicateAsset if asset already has a payment
88+
/// * [`Error::NotInitialized`] – account not yet initialised
89+
/// * [`Error::InvalidAmount`] – amount is not positive
90+
/// * [`Error::PaymentAlreadyReceived`] – a payment was already recorded
9891
pub fn record_payment(env: Env, amount: i128, asset: Address) -> Result<(), Error> {
9992
// Check initialized
10093
if !storage::is_initialized(&env) {
10194
return Err(Error::NotInitialized);
10295
}
103-
104-
// Only the registered relayer may record payments
105-
let relayer = storage::get_relayer(&env).ok_or(Error::Unauthorized)?;
106-
relayer.require_auth();
107-
10896
// Validate amount
10997
if amount <= 0 {
11098
return Err(Error::InvalidAmount);
11199
}
100+
// Enforce single inbound payment restriction
101+
if storage::has_payment_received(&env) {
102+
return Err(Error::PaymentAlreadyReceived);
103+
}
112104

113105
// Check minimum payment amount
114106
let min_amount = storage::get_min_payment_amount(&env);
@@ -120,35 +112,18 @@ impl EphemeralAccountContract {
120112
if storage::get_payment(&env, &asset).is_some() {
121113
return Err(Error::DuplicateAsset);
122114
}
123-
124-
// Check payment limit to prevent gas issues (max 10 assets)
125-
let payment_count = storage::get_total_payments(&env);
126-
if payment_count >= 10 {
127-
return Err(Error::TooManyPayments);
128-
}
129-
130115
// Create payment with current timestamp
131116
let payment = Payment {
132117
asset: asset.clone(),
133118
amount,
134119
timestamp: env.ledger().timestamp(),
135120
};
136-
137121
// Add payment
138122
storage::add_payment(&env, payment);
139-
140-
// Update status only on first payment
141-
if payment_count == 0 {
142-
storage::set_status(&env, AccountStatus::PaymentReceived);
143-
}
144-
145-
// Emit appropriate event
146-
if payment_count == 0 {
147-
events::emit_payment_received(&env, amount, asset);
148-
} else {
149-
events::emit_multi_payment_received(&env, asset, amount);
150-
}
151-
123+
// Update status
124+
storage::set_status(&env, AccountStatus::PaymentReceived);
125+
// Emit event
126+
events::emit_payment_received(&env, amount, asset);
152127
Ok(())
153128
}
154129

@@ -167,32 +142,26 @@ impl EphemeralAccountContract {
167142
if !storage::is_initialized(&env) {
168143
return Err(Error::NotInitialized);
169144
}
170-
171145
// Check not already swept
172146
if storage::get_status(&env) == AccountStatus::Swept {
173147
return Err(Error::AlreadySwept);
174148
}
175-
176149
// Check payment received
177150
if !storage::has_payment_received(&env) {
178151
return Err(Error::NoPaymentReceived);
179152
}
180-
181153
// Check not expired
182154
if Self::is_expired(env.clone()) {
183155
return Err(Error::AccountExpired);
184156
}
185-
186157
// Verify authorization signature
187158
Self::verify_sweep_authorization(&env, &destination, &auth_signature)?;
188-
189159
// Get all payments
190160
let payments = storage::get_all_payments(&env);
191161
let mut payments_vec = Vec::new(&env);
192162
for payment in payments.values() {
193163
payments_vec.push_back(payment);
194164
}
195-
196165
// Update status before transfer to prevent reentrancy
197166
storage::set_status(&env, AccountStatus::Swept);
198167
storage::set_swept_to(&env, &destination);
@@ -201,16 +170,24 @@ impl EphemeralAccountContract {
201170
// This contract enforces authorization/state transitions and reserve lifecycle.
202171
let sweep_id = env.ledger().sequence() as u64;
203172
storage::set_last_sweep_id(&env, sweep_id);
204-
205173
// Emit sweep event once transfer authorization/state update succeeds.
206174
events::emit_sweep_executed_multi(&env, destination.clone(), &payments_vec);
207-
208175
// Reclaim base reserve only after successful sweep state transition.
209176
Self::reclaim_reserve_to(&env, &destination, sweep_id)?;
210-
211177
Ok(())
212178
}
213179

180+
/// Check if the account has expired.
181+
///
182+
/// Expiry is determined by comparing the current **ledger sequence number**
183+
/// (`env.ledger().sequence()`) against the `expiry_ledger` set at
184+
/// initialization. Soroban contracts do not have access to wall-clock time
185+
/// (UNIX timestamps) for consensus-safe comparisons; ledger sequence is the
186+
/// canonical on-chain time source. Each ledger closes roughly every 5 s on
187+
/// Stellar mainnet, so `expiry_ledger` effectively encodes a duration in
188+
/// ledger ticks rather than seconds.
189+
///
190+
/// Returns `false` if the account has not been initialized.
214191
/// Check whether this ephemeral account has expired.
215192
///
216193
/// ## Ledger time vs wall-clock time
@@ -236,10 +213,8 @@ impl EphemeralAccountContract {
236213
if !storage::is_initialized(&env) {
237214
return false;
238215
}
239-
240216
let expiry_ledger = storage::get_expiry_ledger(&env);
241217
let current_ledger = env.ledger().sequence();
242-
243218
current_ledger >= expiry_ledger
244219
}
245220

@@ -253,7 +228,6 @@ impl EphemeralAccountContract {
253228
if !storage::is_initialized(&env) {
254229
return AccountStatus::Active;
255230
}
256-
257231
storage::get_status(&env)
258232
}
259233

@@ -267,25 +241,20 @@ impl EphemeralAccountContract {
267241
if !storage::is_initialized(&env) {
268242
return Err(Error::NotInitialized);
269243
}
270-
271244
// Check not already swept or expired
272245
let status = storage::get_status(&env);
273246
if status == AccountStatus::Swept || status == AccountStatus::Expired {
274247
return Err(Error::InvalidStatus);
275248
}
276-
277249
// Check if expired
278250
if !Self::is_expired(env.clone()) {
279251
return Err(Error::NotExpired);
280252
}
281-
282253
// Get recovery address
283254
let recovery_address = storage::get_recovery_address(&env);
284-
285255
// Update status
286256
storage::set_status(&env, AccountStatus::Expired);
287257
storage::set_swept_to(&env, &recovery_address);
288-
289258
// Get total amount from all payments if any payments were received
290259
let total_amount = if storage::has_payment_received(&env) {
291260
let payments = storage::get_all_payments(&env);
@@ -299,16 +268,12 @@ impl EphemeralAccountContract {
299268
} else {
300269
0
301270
};
302-
303271
let sweep_id = env.ledger().sequence() as u64;
304272
storage::set_last_sweep_id(&env, sweep_id);
305-
306273
// Reclaim reserve to recovery destination.
307274
let reclaimed_reserve = Self::reclaim_reserve_to(&env, &recovery_address, sweep_id)?;
308-
309275
// Emit expiration event with reserve amount reclaimed in this call.
310276
events::emit_account_expired(&env, recovery_address, total_amount, reclaimed_reserve);
311-
312277
Ok(())
313278
}
314279

@@ -318,15 +283,12 @@ impl EphemeralAccountContract {
318283
if !storage::is_initialized(&env) {
319284
return Err(Error::NotInitialized);
320285
}
321-
322286
let status = storage::get_status(&env);
323287
if status != AccountStatus::Swept && status != AccountStatus::Expired {
324288
return Err(Error::InvalidStatus);
325289
}
326-
327290
let destination = storage::get_swept_to(&env).ok_or(Error::InvalidStatus)?;
328291
let sweep_id = storage::get_last_sweep_id(&env);
329-
330292
Self::reclaim_reserve_to(&env, &destination, sweep_id)
331293
}
332294

@@ -335,7 +297,6 @@ impl EphemeralAccountContract {
335297
if !storage::is_initialized(&env) {
336298
return 0;
337299
}
338-
339300
storage::get_base_reserve_remaining(&env)
340301
}
341302

@@ -344,7 +305,6 @@ impl EphemeralAccountContract {
344305
if !storage::is_initialized(&env) {
345306
return 0;
346307
}
347-
348308
storage::get_available_reserve(&env)
349309
}
350310

@@ -353,7 +313,6 @@ impl EphemeralAccountContract {
353313
if !storage::is_initialized(&env) {
354314
return false;
355315
}
356-
357316
storage::is_reserve_reclaimed(&env)
358317
}
359318

@@ -362,7 +321,6 @@ impl EphemeralAccountContract {
362321
if !storage::is_initialized(&env) {
363322
return None;
364323
}
365-
366324
storage::get_last_reserve_event(&env)
367325
}
368326

@@ -371,7 +329,6 @@ impl EphemeralAccountContract {
371329
if !storage::is_initialized(&env) {
372330
return 0;
373331
}
374-
375332
storage::get_reserve_event_count(&env)
376333
}
377334

@@ -380,10 +337,8 @@ impl EphemeralAccountContract {
380337
if !storage::is_initialized(&env) {
381338
return Err(Error::NotInitialized);
382339
}
383-
384340
let payments = storage::get_all_payments(&env);
385341
let payment_count = payments.len();
386-
387342
Ok(AccountInfo {
388343
creator: storage::get_creator(&env),
389344
status: storage::get_status(&env),
@@ -403,13 +358,6 @@ impl EphemeralAccountContract {
403358
}
404359

405360
// Private helper functions
406-
407-
/// Verify sweep authorization via Ed25519 signature.
408-
///
409-
/// Message = SHA-256(destination_xdr || contract_id_xdr)
410-
///
411-
/// The off-chain signer signs this message with the private key
412-
/// corresponding to the `authorized_signer` stored at initialization.
413361
fn verify_sweep_authorization(
414362
env: &Env,
415363
destination: &Address,
@@ -434,11 +382,9 @@ impl EphemeralAccountContract {
434382
fn reclaim_reserve_to(env: &Env, destination: &Address, sweep_id: u64) -> Result<i128, Error> {
435383
let reserve_remaining = storage::get_base_reserve_remaining(env);
436384
let reserve_available = storage::get_available_reserve(env);
437-
438385
if reserve_remaining < 0 || reserve_available < 0 {
439386
return Err(Error::InvalidAmount);
440387
}
441-
442388
if reserve_remaining == 0 {
443389
storage::set_reserve_reclaimed(env, true);
444390
let event = ReserveReclaimed {
@@ -451,24 +397,20 @@ impl EphemeralAccountContract {
451397
Self::emit_and_store_reserve_event(env, event)?;
452398
return Ok(0);
453399
}
454-
455400
let reclaim_amount = if reserve_available < reserve_remaining {
456401
reserve_available
457402
} else {
458403
reserve_remaining
459404
};
460-
461405
let new_available = reserve_available
462406
.checked_sub(reclaim_amount)
463407
.ok_or(Error::InvalidAmount)?;
464408
let new_remaining = reserve_remaining
465409
.checked_sub(reclaim_amount)
466410
.ok_or(Error::InvalidAmount)?;
467-
468411
storage::set_available_reserve(env, new_available);
469412
storage::set_base_reserve_remaining(env, new_remaining);
470413
storage::set_reserve_reclaimed(env, new_remaining == 0);
471-
472414
let event = ReserveReclaimed {
473415
destination: destination.clone(),
474416
amount: reclaim_amount,
@@ -477,7 +419,6 @@ impl EphemeralAccountContract {
477419
remaining_reserve: new_remaining,
478420
};
479421
Self::emit_and_store_reserve_event(env, event)?;
480-
481422
Ok(reclaim_amount)
482423
}
483424

@@ -490,12 +431,10 @@ impl EphemeralAccountContract {
490431
event.fully_reclaimed,
491432
event.remaining_reserve,
492433
);
493-
494434
let event_count = storage::get_reserve_event_count(env);
495435
let next_count = event_count.checked_add(1).ok_or(Error::InvalidAmount)?;
496436
storage::set_last_reserve_event(env, &event);
497437
storage::set_reserve_event_count(env, next_count);
498-
499438
Ok(())
500439
}
501440
}

0 commit comments

Comments
 (0)