diff --git a/README.md b/README.md index f980c63..acc2dc3 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,12 @@ and the backend mapping in [`src/doc.md`](src/doc.md#error-handling). | `1` | `VaultNotFound` | `validate_milestone`, `release_funds`, `redirect_funds`, `cancel_vault` | No `ProductivityVault` exists for the supplied `vault_id`. `get_vault_state` returns `None` instead of raising this error. | | `2` | `NotAuthorized` | `release_funds`, `redirect_funds` | `release_funds` is called before the vault is validated and before the deadline, or `redirect_funds` is called after the milestone was already validated. Address-level auth failures from `require_auth` surface as Soroban auth failures rather than this contract error. | | `3` | `VaultNotActive` | `validate_milestone`, `release_funds`, `redirect_funds`, `cancel_vault` | The vault status is already terminal: `Completed`, `Failed`, or `Cancelled`. | -| `4` | `InvalidTimestamp` | `create_vault`, `redirect_funds` | `create_vault` receives a `start_timestamp` earlier than the current ledger timestamp, or `redirect_funds` is called at or before `end_timestamp`. The exact deadline case returns `#4`. | +| `4` | `InvalidTimestamp` | `create_vault`, `redirect_funds` | `create_vault` receives a `start_timestamp` earlier than the current ledger timestamp, or `redirect_funds` is called at or before `end_timestamp + grace_period`. Checked grace-deadline overflow also returns `#4`. | | `5` | `MilestoneExpired` | `validate_milestone` | The current ledger timestamp is greater than or equal to `end_timestamp`, so validation is no longer allowed. | | `6` | `InvalidStatus` | None in the current implementation | Reserved by the enum for invalid-status flows. Current state-changing entrypoints use `VaultNotActive` for non-`Active` vault states. | | `7` | `InvalidAmount` | `create_vault` | `amount` is below `MIN_AMOUNT` or above `MAX_AMOUNT`. This covers zero, negative, and over-maximum amounts. | | `8` | `InvalidTimestamps` | `create_vault` | `end_timestamp` is less than or equal to `start_timestamp`. | -| `9` | `DurationTooLong` | `create_vault` | `end_timestamp - start_timestamp` exceeds `MAX_VAULT_DURATION` (365 days). | +| `9` | `DurationTooLong` | `create_vault` | `end_timestamp - start_timestamp` exceeds `MAX_VAULT_DURATION` (365 days), or `grace_period` exceeds `MAX_VAULT_DURATION`. | ## Vault Lifecycle @@ -60,10 +60,10 @@ stateDiagram-v2 | From | To | Entrypoint | Preconditions | Resulting event | | --- | --- | --- | --- | --- | -| None | `Active` | `create_vault` | Creator authorizes; amount and timestamps are valid; duration is within the maximum; token transfer into the contract succeeds. | `vault_created` | +| None | `Active` | `create_vault` | Creator authorizes; amount and timestamps are valid; duration and grace period are within the maximum; token transfer into the contract succeeds. | `vault_created` | | `Active` | `Active` | `validate_milestone` | Vault exists, is active, caller is the configured verifier or creator fallback, and ledger time is before `end_timestamp`. | `milestone_validated` | | `Active` | `Completed` | `release_funds` | Creator authorizes; vault is active; milestone is validated or the deadline has been reached. | `funds_released` | -| `Active` | `Failed` | `redirect_funds` | Vault is active; ledger time is strictly greater than `end_timestamp`; milestone is not validated. | `funds_redirected` | +| `Active` | `Failed` | `redirect_funds` | Vault is active; ledger time is strictly greater than `end_timestamp + grace_period`; milestone is not validated. | `funds_redirected` | | `Active` | `Cancelled` | `cancel_vault` | Creator authorizes and vault is active. | `vault_cancelled` | Any attempt to call `validate_milestone`, `release_funds`, `redirect_funds`, or @@ -77,3 +77,5 @@ Any attempt to call `validate_milestone`, `release_funds`, `redirect_funds`, or for integrators and tooling. - [`src/doc.md`](src/doc.md) maps these contract semantics to backend API payloads and HTTP error responses. +- [`docs/GRACE_PERIOD.md`](docs/GRACE_PERIOD.md) documents redirect grace-period + timing and boundary behavior. diff --git a/README_CANCEL_VAULT.md b/README_CANCEL_VAULT.md index 9bbf181..eaf443c 100644 --- a/README_CANCEL_VAULT.md +++ b/README_CANCEL_VAULT.md @@ -8,6 +8,9 @@ Behavior & rules - Cancellation allowed only when `status == Active` (i.e., before validation/completion). - Caller must be the `creator` (enforced via `Address::require_auth`). - Refund is performed against a simulated per-vault balance stored in persistent storage under key `("vault_balance", vault_id)`. +- Cancellation is independent of redirect grace periods: an active vault can still be cancelled by + its creator before another terminal transition occurs. Once `redirect_funds` succeeds after + `end_timestamp + grace_period`, cancellation is rejected because the vault is `Failed`. Security notes - Real token transfer: the current implementation simulates token movement by updating contract-owned balance storage. For production, replace this with a real token transfer using the Soroban token client and ensure the contract has allowance/escrowed tokens before creating the vault. diff --git a/contract-interface.json b/contract-interface.json index 6450206..7bcdf59 100644 --- a/contract-interface.json +++ b/contract-interface.json @@ -19,6 +19,7 @@ { "name": "amount", "type": "i128" }, { "name": "start_timestamp", "type": "u64" }, { "name": "end_timestamp", "type": "u64" }, + { "name": "grace_period", "type": "u64" }, { "name": "milestone_hash", "type": "BytesN<32>" }, { "name": "verifier", "type": "Option
" }, { "name": "success_destination", "type": "Address" }, @@ -53,6 +54,7 @@ { "name": "end_timestamp", "type": "u64" }, { "name": "milestone_hash", "type": "BytesN<32>" }, { "name": "verifier", "type": "Option
" }, + { "name": "grace_period", "type": "u64" }, { "name": "success_destination", "type": "Address" }, { "name": "failure_destination", "type": "Address" } ], diff --git a/docs/GRACE_PERIOD.md b/docs/GRACE_PERIOD.md new file mode 100644 index 0000000..a7721eb --- /dev/null +++ b/docs/GRACE_PERIOD.md @@ -0,0 +1,30 @@ +# Redirect Grace Period + +Vault creators can configure a `grace_period` in seconds when calling +`create_vault`. The value gives late-but-valid milestone completions a buffer +after `end_timestamp` before funds can be redirected to `failure_destination`. + +## Rules + +| Rule | Behavior | +| --- | --- | +| `grace_period == 0` | Preserves the original behavior: redirect succeeds only when `now > end_timestamp`. | +| `0 < grace_period <= MAX_VAULT_DURATION` | Redirect succeeds only when `now > end_timestamp + grace_period`. | +| `grace_period > MAX_VAULT_DURATION` | `create_vault` rejects with `DurationTooLong`. | +| `end_timestamp + grace_period` overflow | `redirect_funds` rejects with `InvalidTimestamp`. | +| Milestone already validated | `redirect_funds` rejects with `NotAuthorized`, even after the grace period. | + +The redirect boundary is strict. At exactly `end_timestamp + grace_period`, +redirection is still too early. One second later, redirection is allowed if the +vault remains active and unvalidated. + +## Example + +```text +start_timestamp = 1_700_000_000 +end_timestamp = 1_700_086_400 +grace_period = 3_600 +redirect_after = 1_700_090_000 +``` + +`redirect_funds` rejects at `1_700_090_000` and succeeds at `1_700_090_001`. diff --git a/src/doc.md b/src/doc.md index 4fb8ad2..995644d 100644 --- a/src/doc.md +++ b/src/doc.md @@ -44,10 +44,11 @@ This guide provides comprehensive documentation for backend developers integrati { "usdc_token": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", "creator": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - "amount": "1000000000", - "start_timestamp": 1704067200, - "end_timestamp": 1706640000, - "milestone_hash": "4d696c6573746f6e655f726571756972656d656e74735f68617368", + "amount": "1000000000", + "start_timestamp": 1704067200, + "end_timestamp": 1706640000, + "grace_period": 3600, + "milestone_hash": "4d696c6573746f6e655f726571756972656d656e74735f68617368", "verifier": "GB7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "success_destination": "GC7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "failure_destination": "GD7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" @@ -60,19 +61,21 @@ This guide provides comprehensive documentation for backend developers integrati |-------|------|----------|-------------| | `usdc_token` | string | Yes | Contract address of USDC token (StrKey format) | | `creator` | string | Yes | Creator's Stellar address (G-address) | -| `amount` | string | Yes | Amount in stroops (7 decimals: 1 USDC = 10,000,000 stroops) | -| `start_timestamp` | integer | Yes | Unix timestamp when vault becomes active | -| `end_timestamp` | integer | Yes | Unix timestamp deadline for milestone validation | -| `milestone_hash` | string | Yes | Hex-encoded SHA-256 hash of milestone document | +| `amount` | string | Yes | Amount in stroops (7 decimals: 1 USDC = 10,000,000 stroops) | +| `start_timestamp` | integer | Yes | Unix timestamp when vault becomes active | +| `end_timestamp` | integer | Yes | Unix timestamp deadline for milestone validation | +| `grace_period` | integer | Yes | Additional seconds after `end_timestamp` before redirect is allowed; use `0` for legacy behavior | +| `milestone_hash` | string | Yes | Hex-encoded SHA-256 hash of milestone document | | `verifier` | string | Optional | Designated verifier address (null for creator-only validation) | | `success_destination` | string | Yes | Address to receive funds on successful milestone | | `failure_destination` | string | Yes | Address to receive funds on failure | **Constraints:** - `amount` must be between 1 USDC (10,000,000 stroops) and 10M USDC (10,000,000,000,000 stroops) -- `end_timestamp` must be greater than `start_timestamp` -- Vault duration cannot exceed 1 year (365 days) -- `start_timestamp` must not be in the past +- `end_timestamp` must be greater than `start_timestamp` +- Vault duration cannot exceed 1 year (365 days) +- `grace_period` cannot exceed 1 year (365 days) +- `start_timestamp` must not be in the past **Response (201 Created):** ```json @@ -215,9 +218,9 @@ This guide provides comprehensive documentation for backend developers integrati | `caller_signature` | string | Yes | Signed transaction from caller | **Constraints:** -- Vault must exist and be in `Active` status -- Current timestamp must be strictly greater than or equal to `end_timestamp` -- `milestone_validated` must be false +- Vault must exist and be in `Active` status +- Current timestamp must be strictly greater than `end_timestamp + grace_period` +- `milestone_validated` must be false **Response (200 OK):** ```json @@ -238,7 +241,7 @@ This guide provides comprehensive documentation for backend developers integrati |-------------|------------|-------------| | 404 | `VaultNotFound` | Vault does not exist | | 400 | `VaultNotActive` | Vault is not in Active status | -| 400 | `InvalidTimestamp` | Current time < end_timestamp (deadline not reached) | +| 400 | `InvalidTimestamp` | Current time <= end_timestamp + grace_period, or grace deadline overflow | | 401 | `NotAuthorized` | Milestone was validated - funds should be released, not redirected | --- @@ -302,10 +305,11 @@ This guide provides comprehensive documentation for backend developers integrati "exists": true, "vault": { "creator": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - "amount": "1000000000", - "start_timestamp": 1704067200, - "end_timestamp": 1706640000, - "milestone_hash": "4d696c6573746f6e655f726571756972656d656e74735f68617368", + "amount": "1000000000", + "start_timestamp": 1704067200, + "end_timestamp": 1706640000, + "grace_period": 3600, + "milestone_hash": "4d696c6573746f6e655f726571756972656d656e74735f68617368", "verifier": "GB7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "success_destination": "GC7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "failure_destination": "GD7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", @@ -350,10 +354,11 @@ This guide provides comprehensive documentation for backend developers integrati interface CreateVaultRequest { usdc_token: string; creator: string; - amount: string; - start_timestamp: number; - end_timestamp: number; - milestone_hash: string; + amount: string; + start_timestamp: number; + end_timestamp: number; + grace_period: number; + milestone_hash: string; verifier?: string; success_destination: string; failure_destination: string; @@ -397,10 +402,15 @@ class VaultService { const duration = request.end_timestamp - request.start_timestamp; const maxDuration = 365 * 24 * 60 * 60; // 1 year - if (duration > maxDuration) { - throw new ValidationError('DurationTooLong', - 'Vault duration cannot exceed 1 year'); - } + if (duration > maxDuration) { + throw new ValidationError('DurationTooLong', + 'Vault duration cannot exceed 1 year'); + } + + if (request.grace_period > maxDuration) { + throw new ValidationError('DurationTooLong', + 'Grace period cannot exceed 1 year'); + } const now = Math.floor(Date.now() / 1000); if (request.start_timestamp < now) { @@ -589,7 +599,7 @@ All transactions benefit from Stellar's built-in replay protection via sequence | `create_vault` | Creator | Must sign and authorize USDC transfer | | `validate_milestone` | Verifier (if set) or Creator | Must be before deadline | | `release_funds` | Anyone | Conditions: validated OR past deadline | -| `redirect_funds` | Anyone | Conditions: not validated AND past deadline | +| `redirect_funds` | Anyone | Conditions: not validated AND past deadline plus grace period | | `cancel_vault` | Creator only | Vault must be Active | --- @@ -706,7 +716,7 @@ This is the single enforcement point. Every state-changing function (`release_fu ### `redirect_funds` 1. Loads the vault and calls `require_active`. - 2. Checks `env.ledger().timestamp() > end_timestamp` — panics with `DeadlineNotReached` if too early. + 2. Checks `env.ledger().timestamp() > end_timestamp + grace_period` with checked addition — returns `InvalidTimestamp` if too early or if the grace deadline overflows. 3. **Sets `status = Failed` and persists the vault** (Checks & Effects). 4. Performs the transfer to `failure_destination` (Interactions). diff --git a/src/lib.rs b/src/lib.rs index 9893393..e4fc736 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -64,6 +64,8 @@ pub struct ProductivityVault { pub start_timestamp: u64, /// Ledger timestamp after which deadline-based release is allowed. pub end_timestamp: u64, + /// Additional seconds after `end_timestamp` before redirection is allowed. + pub grace_period: u64, /// Hash representing the milestone the creator commits to. pub milestone_hash: BytesN<32>, /// Optional designated verifier. When `Some(addr)`, only that address may call `validate_milestone`. @@ -129,6 +131,7 @@ impl DisciplrVault { /// # Validation Rules /// - `amount` must be positive; otherwise returns `Error::InvalidAmount`. /// - `start_timestamp` must be strictly less than `end_timestamp`; otherwise returns `Error::InvalidTimestamps`. + /// - `grace_period` must be no greater than `MAX_VAULT_DURATION`; otherwise returns `Error::DurationTooLong`. /// /// # Prerequisites /// Creator must have sufficient USDC balance and authorize the transaction. @@ -141,6 +144,7 @@ impl DisciplrVault { end_timestamp: u64, milestone_hash: BytesN<32>, verifier: Option
, + grace_period: u64, success_destination: Address, failure_destination: Address, ) -> Result { @@ -168,6 +172,9 @@ impl DisciplrVault { if duration > MAX_VAULT_DURATION { return Err(Error::DurationTooLong); } + if grace_period > MAX_VAULT_DURATION { + return Err(Error::DurationTooLong); + } // Pull USDC from creator into this contract. let token_client = token::Client::new(&env, &usdc_token); @@ -188,6 +195,7 @@ impl DisciplrVault { amount, start_timestamp, end_timestamp, + grace_period, milestone_hash, verifier, success_destination, @@ -298,7 +306,10 @@ impl DisciplrVault { // redirect_funds // ----------------------------------------------------------------------- - /// Redirect funds to `failure_destination` (e.g. after deadline without validation). + /// Redirect funds to `failure_destination` after the deadline and grace period. + /// + /// Redirection is allowed only when `now > end_timestamp + grace_period`. + /// The addition is checked so overflow rejects with `Error::InvalidTimestamp`. pub fn redirect_funds(env: Env, vault_id: u32, usdc_token: Address) -> Result { let vault_key = DataKey::Vault(vault_id); let mut vault: ProductivityVault = env @@ -311,7 +322,11 @@ impl DisciplrVault { return Err(Error::VaultNotActive); } - if env.ledger().timestamp() <= vault.end_timestamp { + let redirect_after = vault + .end_timestamp + .checked_add(vault.grace_period) + .ok_or(Error::InvalidTimestamp)?; + if env.ledger().timestamp() <= redirect_after { return Err(Error::InvalidTimestamp); // Too early to redirect } @@ -491,6 +506,7 @@ mod tests { &self.end_timestamp, &self.milestone_hash(), &Some(self.verifier.clone()), + &0, &self.success_dest, &self.failure_dest, ) @@ -506,6 +522,7 @@ mod tests { &self.end_timestamp, &self.milestone_hash(), &None, + &0, &self.success_dest, &self.failure_dest, ) @@ -594,6 +611,7 @@ mod tests { &setup.end_timestamp, &custom_hash, &Some(setup.verifier.clone()), + &0, &setup.success_dest, &setup.failure_dest, ); @@ -615,6 +633,7 @@ mod tests { &setup.end_timestamp, &setup.milestone_hash(), &None, + &0, &setup.success_dest, &setup.failure_dest, ); @@ -637,6 +656,7 @@ mod tests { &1000u64, &setup.milestone_hash(), &None, + &0, &setup.success_dest, &setup.failure_dest, ); @@ -758,6 +778,7 @@ mod tests { &1000, // start == end &setup.milestone_hash(), &None, + &0, &setup.success_dest, &setup.failure_dest, ); @@ -777,6 +798,7 @@ mod tests { &1000, // start > end &setup.milestone_hash(), &None, + &0, &setup.success_dest, &setup.failure_dest, ); @@ -1007,6 +1029,7 @@ mod tests { 200, milestone_hash, Some(verifier), + 0, success_addr, failure_addr, ); @@ -1026,6 +1049,7 @@ mod tests { &2000, &setup.milestone_hash(), &None, + &0, &setup.success_dest, &setup.failure_dest, ); @@ -1054,6 +1078,7 @@ mod tests { 200, milestone_hash, Some(verifier), + 0, success_addr, failure_addr, ); @@ -1114,6 +1139,7 @@ mod tests { 200, milestone_hash, None, + 0, success_addr, failure_addr, ); @@ -1152,6 +1178,7 @@ mod tests { &end_timestamp, &milestone_hash, &Some(verifier.clone()), + &0, &success_destination, &failure_destination, ); @@ -1355,6 +1382,7 @@ mod test { &200, &milestone_hash, &None, + &0, &success_dest, &failure_dest, ); @@ -1387,6 +1415,7 @@ mod test { &200, &milestone_hash, &None, + &0, &Address::generate(&env), &Address::generate(&env), ); @@ -1415,6 +1444,7 @@ mod test { &200, &milestone_hash, &None, + &0, &Address::generate(&env), &Address::generate(&env), ); @@ -1443,6 +1473,7 @@ mod test { &100, &milestone_hash, &None, + &0, &Address::generate(&env), &Address::generate(&env), ); @@ -1471,6 +1502,7 @@ mod test { &100, &milestone_hash, &None, + &0, &Address::generate(&env), &Address::generate(&env), ); @@ -1502,6 +1534,7 @@ mod test { &200, &milestone_hash, &None, + &0, &Address::generate(&env), &Address::generate(&env), ); @@ -1532,6 +1565,7 @@ mod test { &200, &milestone_hash, &Some(verifier), + &0, &Address::generate(&env), &Address::generate(&env), ); @@ -1565,6 +1599,7 @@ mod test { &200, &milestone_hash, &None, + &0, &Address::generate(&env), &Address::generate(&env), ); diff --git a/test_snapshots/test/test_create_vault_exact_balance.1.json b/test_snapshots/test/test_create_vault_exact_balance.1.json index 6ecff57..8bdb515 100644 --- a/test_snapshots/test/test_create_vault_exact_balance.1.json +++ b/test_snapshots/test/test_create_vault_exact_balance.1.json @@ -80,6 +80,9 @@ "bytes": "0101010101010101010101010101010101010101010101010101010101010101" }, "void", + { + "u64": 0 + }, { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" }, @@ -328,6 +331,14 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" } }, + { + "key": { + "symbol": "grace_period" + }, + "val": { + "u64": 0 + } + }, { "key": { "symbol": "milestone_hash" diff --git a/test_snapshots/test/test_create_vault_success.1.json b/test_snapshots/test/test_create_vault_success.1.json index baf60db..36ab08e 100644 --- a/test_snapshots/test/test_create_vault_success.1.json +++ b/test_snapshots/test/test_create_vault_success.1.json @@ -80,6 +80,9 @@ "bytes": "0101010101010101010101010101010101010101010101010101010101010101" }, "void", + { + "u64": 0 + }, { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" }, @@ -328,6 +331,14 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" } }, + { + "key": { + "symbol": "grace_period" + }, + "val": { + "u64": 0 + } + }, { "key": { "symbol": "milestone_hash" diff --git a/test_snapshots/test/test_create_vault_with_verifier.1.json b/test_snapshots/test/test_create_vault_with_verifier.1.json index b54b3aa..f8f2fb7 100644 --- a/test_snapshots/test/test_create_vault_with_verifier.1.json +++ b/test_snapshots/test/test_create_vault_with_verifier.1.json @@ -82,6 +82,9 @@ { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" }, + { + "u64": 0 + }, { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" }, @@ -329,6 +332,14 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" } }, + { + "key": { + "symbol": "grace_period" + }, + "val": { + "u64": 0 + } + }, { "key": { "symbol": "milestone_hash" diff --git a/test_snapshots/tests/test_get_vault_state_cancelled_vault_still_returns_some.1.json b/test_snapshots/tests/test_get_vault_state_cancelled_vault_still_returns_some.1.json index bef38d9..4add3e7 100644 --- a/test_snapshots/tests/test_get_vault_state_cancelled_vault_still_returns_some.1.json +++ b/test_snapshots/tests/test_get_vault_state_cancelled_vault_still_returns_some.1.json @@ -82,6 +82,9 @@ { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" }, + { + "u64": 0 + }, { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" }, @@ -384,6 +387,14 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" } }, + { + "key": { + "symbol": "grace_period" + }, + "val": { + "u64": 0 + } + }, { "key": { "symbol": "milestone_hash" diff --git a/test_snapshots/tests/test_get_vault_state_failed_vault_still_returns_some.1.json b/test_snapshots/tests/test_get_vault_state_failed_vault_still_returns_some.1.json index 56f4a68..30b43bb 100644 --- a/test_snapshots/tests/test_get_vault_state_failed_vault_still_returns_some.1.json +++ b/test_snapshots/tests/test_get_vault_state_failed_vault_still_returns_some.1.json @@ -82,6 +82,9 @@ { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" }, + { + "u64": 0 + }, { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM" }, @@ -330,6 +333,14 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" } }, + { + "key": { + "symbol": "grace_period" + }, + "val": { + "u64": 0 + } + }, { "key": { "symbol": "milestone_hash" diff --git a/tests/lifecycle.rs b/tests/lifecycle.rs index 7079583..1a8951e 100644 --- a/tests/lifecycle.rs +++ b/tests/lifecycle.rs @@ -6,7 +6,9 @@ use soroban_sdk::{ Address, BytesN, Env, }; -use disciplr_vault::{DisciplrVault, DisciplrVaultClient, VaultStatus, MIN_AMOUNT}; +use disciplr_vault::{ + DisciplrVault, DisciplrVaultClient, Error, VaultStatus, MAX_VAULT_DURATION, MIN_AMOUNT, +}; fn setup() -> ( Env, @@ -54,6 +56,7 @@ fn test_full_lifecycle_success() { &(now + 86_400), &milestone, &Some(verifier.clone()), + &0, &success_dest, &failure_dest, ); @@ -103,6 +106,7 @@ fn test_full_lifecycle_failure_redirection() { &(now + 86_400), &milestone, &None, + &0, &success_dest, &failure_dest, ); @@ -116,3 +120,117 @@ fn test_full_lifecycle_failure_redirection() { assert_eq!(final_state.status, VaultStatus::Failed); assert_eq!(usdc_token.balance(&failure_dest), MIN_AMOUNT); } + +#[test] +fn test_redirect_respects_creator_grace_period() { + let (env, client, usdc, usdc_asset, usdc_token) = setup(); + + let creator = Address::generate(&env); + let success_dest = Address::generate(&env); + let failure_dest = Address::generate(&env); + let now = 1_700_000_000u64; + let grace_period = 3_600u64; + env.ledger().set_timestamp(now); + + usdc_asset.mint(&creator, &MIN_AMOUNT); + let milestone = BytesN::from_array(&env, &[2u8; 32]); + + let vault_id = client.create_vault( + &usdc, + &creator, + &MIN_AMOUNT, + &now, + &(now + 86_400), + &milestone, + &None, + &grace_period, + &success_dest, + &failure_dest, + ); + + let vault = client.get_vault_state(&vault_id).unwrap(); + assert_eq!(vault.grace_period, grace_period); + + env.ledger().set_timestamp(now + 86_400 + grace_period); + let boundary_result = client.try_redirect_funds(&vault_id, &usdc); + assert!( + matches!(boundary_result, Err(Ok(Error::InvalidTimestamp))), + "redirect at exact grace boundary must fail: {:?}", + boundary_result + ); + + env.ledger().set_timestamp(now + 86_400 + grace_period + 1); + assert!(client.redirect_funds(&vault_id, &usdc)); + assert_eq!(usdc_token.balance(&failure_dest), MIN_AMOUNT); +} + +#[test] +fn test_create_vault_rejects_oversized_grace_period() { + let (env, client, usdc, usdc_asset, _usdc_token) = setup(); + + let creator = Address::generate(&env); + let success_dest = Address::generate(&env); + let failure_dest = Address::generate(&env); + let now = 1_700_000_000u64; + env.ledger().set_timestamp(now); + + usdc_asset.mint(&creator, &MIN_AMOUNT); + let milestone = BytesN::from_array(&env, &[3u8; 32]); + let oversized_grace = MAX_VAULT_DURATION + 1; + + let result = client.try_create_vault( + &usdc, + &creator, + &MIN_AMOUNT, + &now, + &(now + 86_400), + &milestone, + &None, + &oversized_grace, + &success_dest, + &failure_dest, + ); + + assert!( + matches!(result, Err(Ok(Error::DurationTooLong))), + "oversized grace period should be rejected: {:?}", + result + ); +} + +#[test] +fn test_redirect_rejects_grace_deadline_overflow() { + let (env, client, usdc, usdc_asset, _usdc_token) = setup(); + + let creator = Address::generate(&env); + let success_dest = Address::generate(&env); + let failure_dest = Address::generate(&env); + let start = u64::MAX - MAX_VAULT_DURATION + 1; + let end = start + 1; + let grace_period = MAX_VAULT_DURATION; + env.ledger().set_timestamp(start); + + usdc_asset.mint(&creator, &MIN_AMOUNT); + let milestone = BytesN::from_array(&env, &[4u8; 32]); + + let vault_id = client.create_vault( + &usdc, + &creator, + &MIN_AMOUNT, + &start, + &end, + &milestone, + &None, + &grace_period, + &success_dest, + &failure_dest, + ); + + env.ledger().set_timestamp(u64::MAX); + let result = client.try_redirect_funds(&vault_id, &usdc); + assert!( + matches!(result, Err(Ok(Error::InvalidTimestamp))), + "overflowing grace deadline should be rejected: {:?}", + result + ); +} diff --git a/tests/proptest_amounts.rs b/tests/proptest_amounts.rs index fa0f5d0..cf44c57 100644 --- a/tests/proptest_amounts.rs +++ b/tests/proptest_amounts.rs @@ -54,6 +54,7 @@ proptest! { &(now + 86_400), &BytesN::from_array(&env, &[7u8; 32]), &None, + &0, &success, &failure, ); @@ -84,6 +85,7 @@ proptest! { &(now + 86_400), &BytesN::from_array(&env, &[8u8; 32]), &None, + &0, &success, &failure, ); @@ -108,6 +110,7 @@ fn edge_amount_min_succeeds() { &(1_725_000_000u64 + 86_400), &BytesN::from_array(&env, &[9u8; 32]), &None, + &0, &Address::generate(&env), &Address::generate(&env), ); @@ -132,6 +135,7 @@ fn edge_amount_max_succeeds() { &(1_725_000_000u64 + 86_400), &BytesN::from_array(&env, &[10u8; 32]), &None, + &0, &Address::generate(&env), &Address::generate(&env), ); @@ -156,6 +160,7 @@ fn edge_amount_max_underfunded_errors() { &(1_725_000_000u64 + 86_400), &BytesN::from_array(&env, &[11u8; 32]), &None, + &0, &Address::generate(&env), &Address::generate(&env), ); diff --git a/tests/proptest_timestamps.rs b/tests/proptest_timestamps.rs index d8e37e8..c89e534 100644 --- a/tests/proptest_timestamps.rs +++ b/tests/proptest_timestamps.rs @@ -50,6 +50,7 @@ proptest! { now in 0u64..20_000_000_000u64, start_offset in 0u64..1_000_000_000, duration in 1u64..=MAX_VAULT_DURATION, + grace_period in 0u64..=MAX_VAULT_DURATION, amount in MIN_AMOUNT..=MAX_AMOUNT, ) { let (env, client, usdc, usdc_asset) = setup(); @@ -75,6 +76,7 @@ proptest! { &end, &milestone, &None, + &grace_period, &success, &failure, ); @@ -83,6 +85,7 @@ proptest! { prop_assert_eq!(vault.start_timestamp, start); prop_assert_eq!(vault.end_timestamp, end); prop_assert_eq!(vault.end_timestamp - vault.start_timestamp, duration); + prop_assert_eq!(vault.grace_period, grace_period); prop_assert_eq!(vault.amount, amount); } @@ -114,6 +117,7 @@ proptest! { &end, &BytesN::from_array(&env, &[1u8; 32]), &None, + &0, &success, &failure, ); @@ -149,6 +153,45 @@ proptest! { &end, &BytesN::from_array(&env, &[2u8; 32]), &None, + &0, + &success, + &failure, + ); + + assert_contract_error(result, Error::DurationTooLong); + } + + #[test] + fn prop_create_vault_rejects_grace_above_max( + now in 0u64..20_000_000_000u64, + start_offset in 0u64..10_000, + duration in 1u64..=MAX_VAULT_DURATION, + extra_grace in 1u64..10_000, + amount in MIN_AMOUNT..=MAX_AMOUNT, + ) { + let (env, client, usdc, usdc_asset) = setup(); + + let creator = Address::generate(&env); + let success = Address::generate(&env); + let failure = Address::generate(&env); + + env.ledger().set_timestamp(now); + + let start = now + start_offset; + let end = start + duration; + let grace_period = MAX_VAULT_DURATION + extra_grace; + + usdc_asset.mint(&creator, &amount); + + let result = client.try_create_vault( + &usdc, + &creator, + &amount, + &start, + &end, + &BytesN::from_array(&env, &[7u8; 32]), + &None, + &grace_period, &success, &failure, ); @@ -185,6 +228,7 @@ proptest! { &end_valid, &milestone, &None, + &0, &success, &failure, ); @@ -206,6 +250,7 @@ proptest! { &end_invalid, &milestone, &None, + &0, &success, &failure, ); @@ -242,6 +287,7 @@ proptest! { &end, &milestone, &None, + &0, &success, &failure, ); @@ -270,6 +316,7 @@ fn edge_start_eq_now_succeeds() { &end, &BytesN::from_array(&env, &[6u8; 32]), &None, + &0, &Address::generate(&env), &Address::generate(&env), ); @@ -279,7 +326,6 @@ fn edge_start_eq_now_succeeds() { assert_eq!(vault.end_timestamp, end); } - #[test] fn edge_start_eq_end_rejected() { let (env, client, usdc, usdc_asset) = setup(); @@ -297,6 +343,7 @@ fn edge_start_eq_end_rejected() { &now, &BytesN::from_array(&env, &[3u8; 32]), &None, + &0, &Address::generate(&env), &Address::generate(&env), ); @@ -320,6 +367,7 @@ fn edge_zero_start_with_current_zero_succeeds() { &1, &BytesN::from_array(&env, &[4u8; 32]), &None, + &0, &Address::generate(&env), &Address::generate(&env), ); @@ -349,6 +397,7 @@ fn edge_max_duration_boundary_succeeds() { &end, &BytesN::from_array(&env, &[5u8; 32]), &None, + &0, &Address::generate(&env), &Address::generate(&env), );