diff --git a/README.md b/README.md index f980c63..379c1a4 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ and the machine-readable interface lives in | --- | --- | --- | | `create_vault` | Yes | Creates and funds a new vault, assigns a sequential vault id, and starts it in `Active`. | | `validate_milestone` | Yes | Marks an `Active` vault milestone as validated before the deadline. | -| `release_funds` | Yes | Sends funds to `success_destination` and moves the vault to `Completed`. | -| `redirect_funds` | Yes | Sends funds to `failure_destination` and moves the vault to `Failed`. | +| `release_funds` | Yes | Sends net funds to `success_destination`, optionally routes a protocol fee, and moves the vault to `Completed`. | +| `redirect_funds` | Yes | Sends net funds to `failure_destination`, optionally routes a protocol fee, and moves the vault to `Failed`. | | `cancel_vault` | Yes | Returns funds to the creator and moves the vault to `Cancelled`. | | `get_vault_state` | No | Reads a vault record, returning `None` for an unknown id. | | `vault_count` | No | Returns the number of vault ids assigned so far. | @@ -40,6 +40,18 @@ and the backend mapping in [`src/doc.md`](src/doc.md#error-handling). | `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). | +| `10` | `InvalidFee` | `create_vault`, settlement helpers | `fee_bps` exceeds `MAX_FEE_BPS` or checked fee math fails. | + +## Protocol Fees + +Vaults include a `ProtocolFeeConfig` at creation time. `fee_bps` is capped at +`MAX_FEE_BPS` (`1000`, or 10%) and `fee_recipient` receives the fee when +`release_funds` or `redirect_funds` settles the vault. The fee is rounded up in +the protocol's favor and deducted from the destination payout. `fee_bps == 0` +preserves the original behavior: the full vault amount goes to the success or +failure destination and no `fee_collected` event is emitted. + +See [`docs/FEES.md`](docs/FEES.md) for the formula and worked examples. ## Vault Lifecycle @@ -50,8 +62,8 @@ Vault records are never deleted by normal contract operation. A vault starts in stateDiagram-v2 [*] --> Active: create_vault Active --> Active: validate_milestone / milestone_validated = true - Active --> Completed: release_funds / success_destination receives funds - Active --> Failed: redirect_funds / failure_destination receives funds + Active --> Completed: release_funds / success_destination receives net funds + Active --> Failed: redirect_funds / failure_destination receives net funds Active --> Cancelled: cancel_vault / creator receives refund Completed --> [*] Failed --> [*] @@ -62,8 +74,8 @@ stateDiagram-v2 | --- | --- | --- | --- | --- | | None | `Active` | `create_vault` | Creator authorizes; amount and timestamps are valid; duration is 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` | `Completed` | `release_funds` | Creator authorizes; vault is active; milestone is validated or the deadline has been reached. | `fee_collected` when non-zero, then `funds_released` | +| `Active` | `Failed` | `redirect_funds` | Vault is active; ledger time is strictly greater than `end_timestamp`; milestone is not validated. | `fee_collected` when non-zero, then `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 diff --git a/contract-interface.json b/contract-interface.json index 6450206..16e2048 100644 --- a/contract-interface.json +++ b/contract-interface.json @@ -23,10 +23,19 @@ { "name": "verifier", "type": "Option
" }, { "name": "success_destination", "type": "Address" }, { "name": "failure_destination", "type": "Address" }, + { "name": "fee_bps", "type": "u32" }, + { "name": "fee_recipient", "type": "Address" }, { "name": "status", "type": "VaultStatus" }, { "name": "milestone_validated", "type": "bool" } ] }, + "ProtocolFeeConfig": { + "type": "struct", + "fields": [ + { "name": "fee_bps", "type": "u32" }, + { "name": "fee_recipient", "type": "Address" } + ] + }, "Error": { "type": "error", "variants": { @@ -38,7 +47,8 @@ "InvalidStatus": 6, "InvalidAmount": 7, "InvalidTimestamps": 8, - "DurationTooLong": 9 + "DurationTooLong": 9, + "InvalidFee": 10 } } }, @@ -54,7 +64,8 @@ { "name": "milestone_hash", "type": "BytesN<32>" }, { "name": "verifier", "type": "Option
" }, { "name": "success_destination", "type": "Address" }, - { "name": "failure_destination", "type": "Address" } + { "name": "failure_destination", "type": "Address" }, + { "name": "fee_config", "type": "ProtocolFeeConfig" } ], "outputs": { "type": "Result" } }, @@ -123,6 +134,11 @@ "topic": ["funds_redirected", "u32"], "data": "i128" }, + { + "name": "fee_collected", + "topic": ["fee_collected", "u32", "Address"], + "data": "i128" + }, { "name": "vault_cancelled", "topic": ["vault_cancelled", "u32"], diff --git a/docs/FEES.md b/docs/FEES.md new file mode 100644 index 0000000..9d5501a --- /dev/null +++ b/docs/FEES.md @@ -0,0 +1,32 @@ +# Protocol Fees + +Each vault stores an optional protocol fee configuration at creation time: + +- `fee_bps`: basis-point fee charged when `release_funds` or `redirect_funds` settles the vault. +- `fee_recipient`: address that receives the protocol fee. + +`fee_bps` must be between `0` and `MAX_FEE_BPS` (`1000`, or 10%). Values above the cap return `Error::InvalidFee` before the creator's funds are transferred into escrow. + +## Formula + +The settlement fee is rounded up in the protocol's favor: + +```text +fee = ceil(amount * fee_bps / 10_000) +net = amount - fee +``` + +When `fee_bps == 0`, the fee is `0` and settlement matches the original behavior exactly: the full vault amount goes to the success or failure destination and no `fee_collected` event is emitted. + +## Example + +For a vault with `amount = 1_000_000_000` stroops and `fee_bps = 250`: + +```text +fee = ceil(1_000_000_000 * 250 / 10_000) = 25_000_000 +net = 975_000_000 +``` + +On success, `release_funds` sends `25_000_000` to `fee_recipient`, sends `975_000_000` to `success_destination`, emits `fee_collected`, and then emits `funds_released` with the net amount. + +On failure, `redirect_funds` applies the same fee split but sends the net amount to `failure_destination`. diff --git a/src/doc.md b/src/doc.md index 4fb8ad2..4fbefc6 100644 --- a/src/doc.md +++ b/src/doc.md @@ -47,11 +47,15 @@ This guide provides comprehensive documentation for backend developers integrati "amount": "1000000000", "start_timestamp": 1704067200, "end_timestamp": 1706640000, - "milestone_hash": "4d696c6573746f6e655f726571756972656d656e74735f68617368", - "verifier": "GB7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - "success_destination": "GC7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - "failure_destination": "GD7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" -} + "milestone_hash": "4d696c6573746f6e655f726571756972656d656e74735f68617368", + "verifier": "GB7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "success_destination": "GC7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "failure_destination": "GD7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "fee_config": { + "fee_bps": 250, + "fee_recipient": "GE7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + } +} ``` **Field Descriptions:** @@ -64,15 +68,18 @@ This guide provides comprehensive documentation for backend developers integrati | `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 | -| `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 | +| `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 | +| `fee_config.fee_bps` | integer | Yes | Protocol fee in basis points, from 0 to 1000 | +| `fee_config.fee_recipient` | string | Yes | Address receiving protocol fees when fee_bps is non-zero | **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 +- Vault duration cannot exceed 1 year (365 days) +- `start_timestamp` must not be in the past +- `fee_config.fee_bps` must be between 0 and 1000 (inclusive) **Response (201 Created):** ```json @@ -91,9 +98,10 @@ This guide provides comprehensive documentation for backend developers integrati |-------------|------------|-------------| | 400 | `InvalidAmount` | Amount outside valid range | | 400 | `InvalidTimestamps` | Invalid timestamp ordering | -| 400 | `InvalidTimestamp` | Start timestamp in the past | -| 400 | `DurationTooLong` | Vault duration exceeds 1 year | -| 401 | `NotAuthorized` | Creator signature invalid or missing | +| 400 | `InvalidTimestamp` | Start timestamp in the past | +| 400 | `DurationTooLong` | Vault duration exceeds 1 year | +| 400 | `InvalidFee` | Protocol fee exceeds MAX_FEE_BPS | +| 401 | `NotAuthorized` | Creator signature invalid or missing | | 409 | `InsufficientBalance` | Creator has insufficient USDC balance | --- @@ -306,12 +314,14 @@ This guide provides comprehensive documentation for backend developers integrati "start_timestamp": 1704067200, "end_timestamp": 1706640000, "milestone_hash": "4d696c6573746f6e655f726571756972656d656e74735f68617368", - "verifier": "GB7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - "success_destination": "GC7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - "failure_destination": "GD7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - "status": "Active", - "milestone_validated": false - } + "verifier": "GB7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "success_destination": "GC7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "failure_destination": "GD7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "fee_bps": 250, + "fee_recipient": "GE7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "status": "Active", + "milestone_validated": false + } } ``` @@ -354,10 +364,14 @@ interface CreateVaultRequest { start_timestamp: number; end_timestamp: number; milestone_hash: string; - verifier?: string; - success_destination: string; - failure_destination: string; -} + verifier?: string; + success_destination: string; + failure_destination: string; + fee_config: { + fee_bps: number; + fee_recipient: string; + }; +} class VaultService { async createVault(request: CreateVaultRequest): Promise { @@ -493,10 +507,11 @@ class ReleaseService { const result = await this.submitTransaction(tx); return { - vault_id: request.vault_id, - status: 'Completed', - amount_released: vault.amount, - destination: vault.success_destination, + vault_id: request.vault_id, + status: 'Completed', + amount_released: vault.amount_minus_fee, + protocol_fee: vault.protocol_fee, + destination: vault.success_destination, transaction_hash: result.txHash, ledger_sequence: result.ledger, released_at: new Date().toISOString() @@ -524,9 +539,10 @@ Those sections are kept aligned with `src/lib.rs` and `contract-interface.json`. | `InvalidTimestamp` | 400 | INVALID_TIMESTAMP | No | | `MilestoneExpired` | 400 | MILESTONE_EXPIRED | No | | `InvalidStatus` | 400 | INVALID_STATUS | No | -| `InvalidAmount` | 400 | INVALID_AMOUNT | No | -| `InvalidTimestamps` | 400 | INVALID_TIMESTAMPS | No | -| `DurationTooLong` | 400 | DURATION_TOO_LONG | No | +| `InvalidAmount` | 400 | INVALID_AMOUNT | No | +| `InvalidTimestamps` | 400 | INVALID_TIMESTAMPS | No | +| `DurationTooLong` | 400 | DURATION_TOO_LONG | No | +| `InvalidFee` | 400 | INVALID_FEE | No | ### Standard Error Response Format diff --git a/src/lib.rs b/src/lib.rs index 9893393..d458424 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,8 @@ pub enum Error { InvalidTimestamps = 8, /// Vault duration (end − start) exceeds MAX_VAULT_DURATION. DurationTooLong = 9, + /// Protocol fee basis points exceed the supported range. + InvalidFee = 10, } // --------------------------------------------------------------------------- @@ -75,6 +77,10 @@ pub struct ProductivityVault { pub success_destination: Address, /// Funds go here on failure/redirect. pub failure_destination: Address, + /// Protocol fee in basis points, charged when funds are released or redirected. + pub fee_bps: u32, + /// Receives the protocol fee when `fee_bps` is non-zero. + pub fee_recipient: Address, /// Current lifecycle status. pub status: VaultStatus, /// Set to `true` once the verifier (or authorised party) calls `validate_milestone`. @@ -82,6 +88,16 @@ pub struct ProductivityVault { pub milestone_validated: bool, } +/// Per-vault protocol fee settings. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProtocolFeeConfig { + /// Fee in basis points. Must be between 0 and `MAX_FEE_BPS`. + pub fee_bps: u32, + /// Receives protocol fees when `fee_bps` is non-zero. + pub fee_recipient: Address, +} + // --------------------------------------------------------------------------- // Storage keys // --------------------------------------------------------------------------- @@ -108,6 +124,11 @@ pub const MIN_AMOUNT: i128 = 10_000_000; // 1 USDC /// otherwise returns `Error::InvalidAmount`. pub const MAX_AMOUNT: i128 = 10_000_000_000_000; // 10M USDC +/// Maximum protocol fee in basis points (10%). +pub const MAX_FEE_BPS: u32 = 1_000; + +const BPS_DENOMINATOR: i128 = 10_000; + #[contracttype] #[derive(Clone)] pub enum DataKey { @@ -115,6 +136,48 @@ pub enum DataKey { VaultCount, } +fn compute_fee(amount: i128, fee_bps: u32) -> Result { + if fee_bps > MAX_FEE_BPS { + return Err(Error::InvalidFee); + } + if fee_bps == 0 { + return Ok(0); + } + + amount + .checked_mul(i128::from(fee_bps)) + .and_then(|v| v.checked_add(BPS_DENOMINATOR - 1)) + .and_then(|v| v.checked_div(BPS_DENOMINATOR)) + .ok_or(Error::InvalidFee) +} + +fn transfer_settlement( + env: &Env, + token_client: &token::Client<'_>, + vault_id: u32, + vault: &ProductivityVault, + destination: &Address, +) -> Result { + let fee = compute_fee(vault.amount, vault.fee_bps)?; + let net_amount = vault.amount.checked_sub(fee).ok_or(Error::InvalidFee)?; + let contract = env.current_contract_address(); + + if fee > 0 { + token_client.transfer(&contract, &vault.fee_recipient, &fee); + env.events().publish( + ( + Symbol::new(env, "fee_collected"), + vault_id, + vault.fee_recipient.clone(), + ), + fee, + ); + } + + token_client.transfer(&contract, destination, &net_amount); + Ok(net_amount) +} + // --------------------------------------------------------------------------- // Contract // --------------------------------------------------------------------------- @@ -143,6 +206,7 @@ impl DisciplrVault { verifier: Option
, success_destination: Address, failure_destination: Address, + fee_config: ProtocolFeeConfig, ) -> Result { creator.require_auth(); @@ -168,6 +232,9 @@ impl DisciplrVault { if duration > MAX_VAULT_DURATION { return Err(Error::DurationTooLong); } + if fee_config.fee_bps > MAX_FEE_BPS { + return Err(Error::InvalidFee); + } // Pull USDC from creator into this contract. let token_client = token::Client::new(&env, &usdc_token); @@ -192,6 +259,8 @@ impl DisciplrVault { verifier, success_destination, failure_destination, + fee_bps: fee_config.fee_bps, + fee_recipient: fee_config.fee_recipient, status: VaultStatus::Active, milestone_validated: false, }; @@ -278,19 +347,19 @@ impl DisciplrVault { } let token_client = token::Client::new(&env, &usdc_token); - token_client.transfer( - &env.current_contract_address(), + let net_amount = transfer_settlement( + &env, + &token_client, + vault_id, + &vault, &vault.success_destination, - &vault.amount, - ); + )?; vault.status = VaultStatus::Completed; env.storage().instance().set(&vault_key, &vault); - env.events().publish( - (Symbol::new(&env, "funds_released"), vault_id), - vault.amount, - ); + env.events() + .publish((Symbol::new(&env, "funds_released"), vault_id), net_amount); Ok(true) } @@ -321,18 +390,20 @@ impl DisciplrVault { } let token_client = token::Client::new(&env, &usdc_token); - token_client.transfer( - &env.current_contract_address(), + let net_amount = transfer_settlement( + &env, + &token_client, + vault_id, + &vault, &vault.failure_destination, - &vault.amount, - ); + )?; vault.status = VaultStatus::Failed; env.storage().instance().set(&vault_key, &vault); env.events().publish( (Symbol::new(&env, "funds_redirected"), vault_id), - vault.amount, + net_amount, ); Ok(true) } @@ -493,6 +564,10 @@ mod tests { &Some(self.verifier.clone()), &self.success_dest, &self.failure_dest, + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: self.creator.clone(), + }, ) } @@ -508,6 +583,10 @@ mod tests { &None, &self.success_dest, &self.failure_dest, + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: self.creator.clone(), + }, ) } } @@ -596,6 +675,10 @@ mod tests { &Some(setup.verifier.clone()), &setup.success_dest, &setup.failure_dest, + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: setup.creator.clone(), + }, ); let vault = client.get_vault_state(&vault_id).unwrap(); @@ -617,6 +700,10 @@ mod tests { &None, &setup.success_dest, &setup.failure_dest, + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: setup.creator.clone(), + }, ); assert!( result.is_err(), @@ -639,6 +726,10 @@ mod tests { &None, &setup.success_dest, &setup.failure_dest, + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: setup.creator.clone(), + }, ); assert!( result.is_err(), @@ -760,6 +851,10 @@ mod tests { &None, &setup.success_dest, &setup.failure_dest, + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: setup.creator.clone(), + }, ); } @@ -779,6 +874,10 @@ mod tests { &None, &setup.success_dest, &setup.failure_dest, + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: setup.creator.clone(), + }, ); } @@ -1001,7 +1100,7 @@ mod tests { let _vault_id = DisciplrVault::create_vault( env, usdc_token, - creator, + creator.clone(), 1000, 100, 200, @@ -1009,6 +1108,10 @@ mod tests { Some(verifier), success_addr, failure_addr, + ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); } @@ -1028,6 +1131,10 @@ mod tests { &None, &setup.success_dest, &setup.failure_dest, + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: setup.creator.clone(), + }, ); } @@ -1048,7 +1155,7 @@ mod tests { let _vault_id = DisciplrVault::create_vault( env, usdc_token, - creator, // This address is NOT authorized + creator.clone(), // This address is NOT authorized 1000, 100, 200, @@ -1056,6 +1163,10 @@ mod tests { Some(verifier), success_addr, failure_addr, + ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); } @@ -1108,7 +1219,7 @@ mod tests { let _vault_id = DisciplrVault::create_vault( env, usdc_token, - creator, + creator.clone(), 5000, 100, 200, @@ -1116,6 +1227,10 @@ mod tests { None, success_addr, failure_addr, + ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); } @@ -1154,6 +1269,10 @@ mod tests { &Some(verifier.clone()), &success_destination, &failure_destination, + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); // Vault count starts at 0, first vault gets ID 0 @@ -1357,6 +1476,10 @@ mod test { &None, &success_dest, &failure_dest, + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); assert_eq!(vault_id, 0); @@ -1389,6 +1512,10 @@ mod test { &None, &Address::generate(&env), &Address::generate(&env), + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); } @@ -1417,6 +1544,10 @@ mod test { &None, &Address::generate(&env), &Address::generate(&env), + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); } @@ -1445,6 +1576,10 @@ mod test { &None, &Address::generate(&env), &Address::generate(&env), + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); } @@ -1473,6 +1608,10 @@ mod test { &None, &Address::generate(&env), &Address::generate(&env), + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); } @@ -1504,6 +1643,10 @@ mod test { &None, &Address::generate(&env), &Address::generate(&env), + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); } @@ -1534,6 +1677,10 @@ mod test { &Some(verifier), &Address::generate(&env), &Address::generate(&env), + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); assert_eq!(vault_id, 0); @@ -1567,6 +1714,10 @@ mod test { &None, &Address::generate(&env), &Address::generate(&env), + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); assert_eq!(vault_id, 0); 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..576f072 100644 --- a/test_snapshots/test/test_create_vault_exact_balance.1.json +++ b/test_snapshots/test/test_create_vault_exact_balance.1.json @@ -85,6 +85,26 @@ }, { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + }, + { + "map": [ + { + "key": { + "symbol": "fee_bps" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "fee_recipient" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + } + ] } ] } @@ -328,6 +348,22 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" } }, + { + "key": { + "symbol": "fee_bps" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "fee_recipient" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, { "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..595c17a 100644 --- a/test_snapshots/test/test_create_vault_success.1.json +++ b/test_snapshots/test/test_create_vault_success.1.json @@ -85,6 +85,26 @@ }, { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "map": [ + { + "key": { + "symbol": "fee_bps" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "fee_recipient" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + } + ] } ] } @@ -328,6 +348,22 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" } }, + { + "key": { + "symbol": "fee_bps" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "fee_recipient" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, { "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..8509786 100644 --- a/test_snapshots/test/test_create_vault_with_verifier.1.json +++ b/test_snapshots/test/test_create_vault_with_verifier.1.json @@ -87,6 +87,26 @@ }, { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" + }, + { + "map": [ + { + "key": { + "symbol": "fee_bps" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "fee_recipient" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + } + ] } ] } @@ -329,6 +349,22 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOLZM" } }, + { + "key": { + "symbol": "fee_bps" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "fee_recipient" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, { "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..8387a92 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 @@ -87,6 +87,26 @@ }, { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + }, + { + "map": [ + { + "key": { + "symbol": "fee_bps" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "fee_recipient" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + } + ] } ] } @@ -384,6 +404,22 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" } }, + { + "key": { + "symbol": "fee_bps" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "fee_recipient" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, { "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..d0ef18c 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 @@ -87,6 +87,26 @@ }, { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" + }, + { + "map": [ + { + "key": { + "symbol": "fee_bps" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "fee_recipient" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + } + ] } ] } @@ -330,6 +350,22 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" } }, + { + "key": { + "symbol": "fee_bps" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "fee_recipient" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, { "key": { "symbol": "milestone_hash" diff --git a/tests/fees.rs b/tests/fees.rs new file mode 100644 index 0000000..faab36d --- /dev/null +++ b/tests/fees.rs @@ -0,0 +1,153 @@ +#![cfg(test)] + +use disciplr_vault::{ + DisciplrVault, DisciplrVaultClient, Error, ProtocolFeeConfig, MAX_FEE_BPS, MIN_AMOUNT, +}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::{StellarAssetClient, TokenClient}, + Address, BytesN, Env, +}; + +struct Setup { + env: Env, + client: DisciplrVaultClient<'static>, + usdc: Address, + token: TokenClient<'static>, + asset: StellarAssetClient<'static>, + creator: Address, + verifier: Address, + success: Address, + failure: Address, + fee_recipient: Address, +} + +impl Setup { + fn new() -> Self { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_700_000_000); + + let contract_id = env.register(DisciplrVault, ()); + let client = DisciplrVaultClient::new(&env, &contract_id); + + let usdc_admin = Address::generate(&env); + let usdc_token = env.register_stellar_asset_contract_v2(usdc_admin); + let usdc = usdc_token.address(); + let asset = StellarAssetClient::new(&env, &usdc); + let token = TokenClient::new(&env, &usdc); + + Self { + creator: Address::generate(&env), + verifier: Address::generate(&env), + success: Address::generate(&env), + failure: Address::generate(&env), + fee_recipient: Address::generate(&env), + env, + client, + usdc, + token, + asset, + } + } + + fn create_vault(&self, amount: i128, fee_bps: u32) -> u32 { + self.asset.mint(&self.creator, &amount); + let now = self.env.ledger().timestamp(); + self.client.create_vault( + &self.usdc, + &self.creator, + &amount, + &now, + &(now + 86_400), + &BytesN::from_array(&self.env, &[9u8; 32]), + &Some(self.verifier.clone()), + &self.success, + &self.failure, + &ProtocolFeeConfig { + fee_bps, + fee_recipient: self.fee_recipient.clone(), + }, + ) + } +} + +#[test] +fn zero_fee_release_preserves_existing_destination_amount() { + let setup = Setup::new(); + let vault_id = setup.create_vault(MIN_AMOUNT, 0); + + setup.client.validate_milestone(&vault_id); + setup.client.release_funds(&vault_id, &setup.usdc); + + assert_eq!(setup.token.balance(&setup.success), MIN_AMOUNT); + assert_eq!(setup.token.balance(&setup.fee_recipient), 0); +} + +#[test] +fn max_fee_release_routes_fee_and_net_amount() { + let setup = Setup::new(); + let amount = MIN_AMOUNT * 10; + let vault_id = setup.create_vault(amount, MAX_FEE_BPS); + let fee = amount * i128::from(MAX_FEE_BPS) / 10_000; + + setup.client.validate_milestone(&vault_id); + setup.client.release_funds(&vault_id, &setup.usdc); + + assert_eq!(setup.token.balance(&setup.fee_recipient), fee); + assert_eq!(setup.token.balance(&setup.success), amount - fee); +} + +#[test] +fn redirect_uses_same_fee_split_as_release() { + let setup = Setup::new(); + let amount = MIN_AMOUNT * 5; + let vault_id = setup.create_vault(amount, 250); + let fee = amount * 250 / 10_000; + + setup.env.ledger().set_timestamp(1_700_086_401); + setup.client.redirect_funds(&vault_id, &setup.usdc); + + assert_eq!(setup.token.balance(&setup.fee_recipient), fee); + assert_eq!(setup.token.balance(&setup.failure), amount - fee); + assert_eq!(setup.token.balance(&setup.success), 0); +} + +#[test] +fn fee_rounds_up_in_protocol_favor() { + let setup = Setup::new(); + let amount = MIN_AMOUNT + 1; + let vault_id = setup.create_vault(amount, 1); + + setup.client.validate_milestone(&vault_id); + setup.client.release_funds(&vault_id, &setup.usdc); + + assert_eq!(setup.token.balance(&setup.fee_recipient), 1_001); + assert_eq!(setup.token.balance(&setup.success), amount - 1_001); +} + +#[test] +fn create_vault_rejects_fee_above_cap_before_transfer() { + let setup = Setup::new(); + setup.asset.mint(&setup.creator, &MIN_AMOUNT); + let now = setup.env.ledger().timestamp(); + + let result = setup.client.try_create_vault( + &setup.usdc, + &setup.creator, + &MIN_AMOUNT, + &now, + &(now + 86_400), + &BytesN::from_array(&setup.env, &[7u8; 32]), + &None, + &setup.success, + &setup.failure, + &ProtocolFeeConfig { + fee_bps: MAX_FEE_BPS + 1, + fee_recipient: setup.fee_recipient.clone(), + }, + ); + + assert_eq!(result, Err(Ok(Error::InvalidFee))); + assert_eq!(setup.token.balance(&setup.creator), MIN_AMOUNT); +} diff --git a/tests/lifecycle.rs b/tests/lifecycle.rs index 7079583..b9143a9 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, ProtocolFeeConfig, VaultStatus, MIN_AMOUNT, +}; fn setup() -> ( Env, @@ -56,6 +58,10 @@ fn test_full_lifecycle_success() { &Some(verifier.clone()), &success_dest, &failure_dest, + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); assert_eq!( @@ -105,6 +111,10 @@ fn test_full_lifecycle_failure_redirection() { &None, &success_dest, &failure_dest, + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); // 2. Wait until deadline passes without validation diff --git a/tests/proptest_amounts.rs b/tests/proptest_amounts.rs index fa0f5d0..ee3e4f9 100644 --- a/tests/proptest_amounts.rs +++ b/tests/proptest_amounts.rs @@ -2,7 +2,9 @@ extern crate std; -use disciplr_vault::{DisciplrVault, DisciplrVaultClient, MAX_AMOUNT, MIN_AMOUNT}; +use disciplr_vault::{ + DisciplrVault, DisciplrVaultClient, ProtocolFeeConfig, MAX_AMOUNT, MIN_AMOUNT, +}; use proptest::prelude::*; use soroban_sdk::{ testutils::{Address as _, Ledger}, @@ -56,6 +58,7 @@ proptest! { &None, &success, &failure, + &ProtocolFeeConfig { fee_bps: 0, fee_recipient: creator.clone() }, ); let vault = client.get_vault_state(&id).unwrap(); @@ -86,6 +89,7 @@ proptest! { &None, &success, &failure, + &ProtocolFeeConfig { fee_bps: 0, fee_recipient: creator.clone() }, ); prop_assert!(result.is_err()); @@ -110,6 +114,10 @@ fn edge_amount_min_succeeds() { &None, &Address::generate(&env), &Address::generate(&env), + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); let vault = client.get_vault_state(&id).unwrap(); @@ -134,6 +142,10 @@ fn edge_amount_max_succeeds() { &None, &Address::generate(&env), &Address::generate(&env), + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); let vault = client.get_vault_state(&id).unwrap(); @@ -158,6 +170,10 @@ fn edge_amount_max_underfunded_errors() { &None, &Address::generate(&env), &Address::generate(&env), + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); assert!(result.is_err()); diff --git a/tests/proptest_timestamps.rs b/tests/proptest_timestamps.rs index d8e37e8..92ad4f7 100644 --- a/tests/proptest_timestamps.rs +++ b/tests/proptest_timestamps.rs @@ -3,7 +3,8 @@ extern crate std; use disciplr_vault::{ - DisciplrVault, DisciplrVaultClient, Error, MAX_AMOUNT, MAX_VAULT_DURATION, MIN_AMOUNT, + DisciplrVault, DisciplrVaultClient, Error, ProtocolFeeConfig, MAX_AMOUNT, MAX_VAULT_DURATION, + MIN_AMOUNT, }; use proptest::prelude::*; use soroban_sdk::{ @@ -77,6 +78,7 @@ proptest! { &None, &success, &failure, + &ProtocolFeeConfig { fee_bps: 0, fee_recipient: creator.clone() }, ); let vault = client.get_vault_state(&vault_id).expect("vault should exist"); @@ -116,6 +118,7 @@ proptest! { &None, &success, &failure, + &ProtocolFeeConfig { fee_bps: 0, fee_recipient: creator.clone() }, ); assert_contract_error(result, Error::InvalidTimestamps); @@ -151,6 +154,7 @@ proptest! { &None, &success, &failure, + &ProtocolFeeConfig { fee_bps: 0, fee_recipient: creator.clone() }, ); assert_contract_error(result, Error::DurationTooLong); @@ -186,8 +190,9 @@ proptest! { &milestone, &None, &success, - &failure, - ); + &failure, + &ProtocolFeeConfig { fee_bps: 0, fee_recipient: creator.clone() }, + ); let vault = client.get_vault_state(&vault_id).expect("vault should exist"); prop_assert_eq!(vault.start_timestamp, start); prop_assert_eq!(vault.end_timestamp, end_valid); @@ -207,8 +212,9 @@ proptest! { &milestone, &None, &success, - &failure, - ); + &failure, + &ProtocolFeeConfig { fee_bps: 0, fee_recipient: creator.clone() }, + ); assert_contract_error(result, Error::DurationTooLong); } } @@ -244,6 +250,7 @@ proptest! { &None, &success, &failure, + &ProtocolFeeConfig { fee_bps: 0, fee_recipient: creator.clone() }, ); assert_contract_error(result, Error::InvalidTimestamp); @@ -272,6 +279,10 @@ fn edge_start_eq_now_succeeds() { &None, &Address::generate(&env), &Address::generate(&env), + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); let vault = client.get_vault_state(&id).unwrap(); @@ -279,7 +290,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(); @@ -299,6 +309,10 @@ fn edge_start_eq_end_rejected() { &None, &Address::generate(&env), &Address::generate(&env), + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); assert_contract_error(result, Error::InvalidTimestamps); @@ -322,6 +336,10 @@ fn edge_zero_start_with_current_zero_succeeds() { &None, &Address::generate(&env), &Address::generate(&env), + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); let vault = client.get_vault_state(&id).unwrap(); @@ -351,6 +369,10 @@ fn edge_max_duration_boundary_succeeds() { &None, &Address::generate(&env), &Address::generate(&env), + &ProtocolFeeConfig { + fee_bps: 0, + fee_recipient: creator.clone(), + }, ); let vault = client.get_vault_state(&id).unwrap();