Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions contracts/milestone_escrow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,7 @@ soroban contract invoke \
- **`vesting_escrow`**: Provides time-based vesting, whereas `milestone_escrow` provides performance-based verification.
- **`bulk_payment`**: Can trigger batch escrow creation for project teams.
- **`orgusd`**: Supported as escrow token asset.

## Timestamp-Manipulation Review (Issue #1615)

`created_at` is recorded via `e.ledger().timestamp()` at escrow creation for informational/audit purposes only. Milestone release is triggered by verification calls, not by comparing timestamps, so this contract is not exposed to the validator-timestamp regression risk addressed in `vesting_escrow`. No code changes required here.
19 changes: 19 additions & 0 deletions contracts/vesting_escrow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,25 @@ The contract includes comprehensive replay-attack tests:

---

## Timestamp Manipulation Resistance (Issue #1615)

The vesting schedule relies on `e.ledger().timestamp()` for cliff and linear-vesting calculations. Stellar consensus enforces a monotonically increasing ledger close time across the network, but this contract adds a defense-in-depth check rather than trusting that guarantee unconditionally.

### Protection Mechanism
- A `LastObservedTimestamp` key stores the highest ledger timestamp seen by any fund-moving call (`claim()`, `clawback()`).
- Before computing a vested amount for either call, `require_monotonic_timestamp` compares the current `e.ledger().timestamp()` against the stored value and returns `TimestampRegression` if it has moved backward.
- On success, the stored value is advanced to the new timestamp (only ever forward).

### Anomaly Scenario Prevented
If a manipulated or anomalous ledger timestamp were ever observed moving backward relative to a prior fund-moving call, the contract now rejects the call outright instead of silently recomputing a smaller (or otherwise incorrect) vested amount from it. This check is independent of, and in addition to, the existing `LedgerReplayDetected` same-ledger check (Issue #1600), which only guards duplicate calls within a single ledger sequence and does not by itself detect a backward-moving timestamp across different ledgers.

### Scope
- `preview_vested_amount` and `get_vesting_snapshot` are read-only views and are intentionally left unguarded: they cannot move funds, and `preview_vested_amount` is designed to accept an arbitrary timestamp for forward-looking previews.
- `partial_clawback` and `extend_vesting` do not read `e.ledger().timestamp()` for their accounting and are unaffected.

### Test Coverage
- **claim_rejects_timestamp_regression**: A legitimate claim succeeds; a subsequent claim at an anomalous backward timestamp is rejected with `TimestampRegression`; a further claim at a timestamp forward of the last observed value succeeds again.
- **clawback_rejects_timestamp_regression**: A clawback attempted after a backward-moving timestamp is rejected the same way, and the grant remains active.
## Race-Condition Test Coverage (Issue #1616)

Added tests specifically targeting concurrent (same-ledger) withdrawal
Expand Down
24 changes: 24 additions & 0 deletions contracts/vesting_escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ pub enum ContractError {
NoPendingAdminTransfer = 21,
/// Proposed admin does not match the caller.
NotProposedAdmin = 22,
/// Ledger timestamp observed by a fund-moving call is behind the last recorded value.
TimestampRegression = 23,
}

// ── Events ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -219,6 +221,8 @@ pub enum DataKey {
PendingAdmin,
/// State version for migration tracking.
StateVersion,
/// Last ledger timestamp observed before a fund-moving call (monotonicity guard).
LastObservedTimestamp,
}

const PERSISTENT_TTL_THRESHOLD: u32 = 20_000;
Expand Down Expand Up @@ -562,6 +566,7 @@ impl VestingContract {
config.beneficiary.require_auth();

Self::require_unique_ledger(&e, &DataKey::LastClaimLedger)?;
Self::require_monotonic_timestamp(&e)?;

let vested = Self::calc_vested(&e, &config);
let claimable = Self::calc_claimable(vested, config.claimed_amount);
Expand Down Expand Up @@ -612,6 +617,7 @@ impl VestingContract {
if !config.is_active {
return Err(ContractError::AlreadyRevoked);
}
Self::require_monotonic_timestamp(&e)?;

let vested = Self::calc_vested(&e, &config);
let vested_floor = Self::max_i128(vested, config.claimed_amount);
Expand Down Expand Up @@ -1126,6 +1132,24 @@ impl VestingContract {
Ok(())
}

fn require_monotonic_timestamp(e: &Env) -> Result<u64, ContractError> {
let now = e.ledger().timestamp();
let last: u64 = e
.storage()
.instance()
.get(&DataKey::LastObservedTimestamp)
.unwrap_or(0);
if now < last {
return Err(ContractError::TimestampRegression);
}
if now > last {
e.storage()
.instance()
.set(&DataKey::LastObservedTimestamp, &now);
}
Ok(now)
}

fn require_not_paused(env: &Env) -> Result<(), ContractError> {
let paused: bool = env
.storage()
Expand Down
70 changes: 70 additions & 0 deletions contracts/vesting_escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,14 @@ mod cliff_properties {
}
}


// -- TIMESTAMP MANIPULATION RESISTANCE TESTS (Issue #1615) --------------------

#[test]
fn claim_rejects_timestamp_regression() {
let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup();

let start_time = 1_000u64;
// ─────────────────────────────────────────────────────────────────────────────
// Issue #1595: Cliff/Duration Edge-Case Tests
// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1197,13 +1205,54 @@ fn test_same_second_claim_after_cliff() {
&beneficiary,
&token_contract,
&start_time,
&100u64,
&1000u64,
&10_000i128,
&cliff_seconds,
&duration_seconds,
&amount,
&clawback_admin,
&admin,
);

// Legitimate claim at a forward-moving timestamp.
e.ledger().set_timestamp(start_time + 200);
e.ledger().set_sequence_number(101);
client.claim();
let claimed_before = client.get_config().claimed_amount;
assert!(claimed_before > 0, "first claim should succeed and record a vested amount");

// Simulate an anomalous/manipulated ledger timestamp moving backward on a
// later ledger. This must be rejected rather than silently recomputing a
// smaller (or stale) vested amount.
e.ledger().set_timestamp(start_time + 50);
e.ledger().set_sequence_number(102);
let result = client.try_claim();
assert_eq!(result, Err(Ok(ContractError::TimestampRegression)),
"a backward-moving ledger timestamp must be rejected");

// State must be unchanged after the rejected call.
let claimed_after = client.get_config().claimed_amount;
assert_eq!(claimed_before, claimed_after,
"rejected timestamp-regression claim must not alter claimed amount");

// A subsequent claim at a timestamp that is forward of the last observed
// value (not just forward of the manipulated one) must still succeed.
e.ledger().set_timestamp(start_time + 400);
e.ledger().set_sequence_number(103);
client.claim();
let claimed_final = client.get_config().claimed_amount;
assert!(claimed_final > claimed_before,
"claim should succeed again once the timestamp legitimately moves forward");
}

#[test]
fn clawback_rejects_timestamp_regression() {
let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup();

let start_time = 1_000u64;
e.ledger().set_timestamp(start_time);
e.ledger().set_sequence_number(200);
let initial_balance = token_client.balance(&beneficiary);

// Advance to exactly at cliff boundary and claim in same second
Expand Down Expand Up @@ -1294,6 +1343,27 @@ fn test_invariant_vesting_balance_equals_unresolved_liabilities() {
&beneficiary,
&token_contract,
&start_time,
&100u64,
&1000u64,
&10_000i128,
&clawback_admin,
&admin,
);

e.ledger().set_timestamp(start_time + 200);
e.ledger().set_sequence_number(201);
client.claim();

// Move the clock backward before the clawback call.
e.ledger().set_timestamp(start_time + 50);
e.ledger().set_sequence_number(202);
let result = client.try_clawback();
assert_eq!(result, Err(Ok(ContractError::TimestampRegression)),
"clawback must also reject a backward-moving ledger timestamp");

let config = client.get_config();
assert!(config.is_active, "rejected clawback must not deactivate the grant");
}
&cliff_seconds,
&duration_seconds,
&total_amount,
Expand Down
Loading