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();