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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +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`. |
| `release_funds` | Yes | Sends all remaining funds to `success_destination` and moves the vault to `Completed`. |
| `release_partial` | Yes | Sends a tranche to `success_destination`; keeps the vault `Active` until `remaining` reaches zero. |
| `redirect_funds` | Yes | Sends funds to `failure_destination` 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. |
Expand Down Expand Up @@ -77,3 +78,8 @@ 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.


## Partial Releases

See [docs/PARTIAL_RELEASE.md](docs/PARTIAL_RELEASE.md) for tranche release behavior, examples, and safety rules.
42 changes: 42 additions & 0 deletions docs/PARTIAL_RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Partial Milestone Release

`release_partial` lets a vault pay a tranche of its escrowed USDC balance to the
`success_destination` while keeping the vault `Active` for the unreleased
balance.

## Balance Model

- `amount`: original escrowed amount.
- `remaining`: unreleased balance still held by the vault.

A partial release must satisfy:

```text
0 < release_amount <= remaining
new_remaining = remaining - release_amount
```

The contract uses checked subtraction and rejects invalid release amounts with
`Error::InvalidAmount`.

## Lifecycle

1. `create_vault` sets `amount` and `remaining` to the initial escrow amount.
2. `release_partial` transfers `release_amount` to `success_destination`.
3. The vault remains `Active` while `remaining > 0`.
4. The final tranche sets `remaining = 0` and moves the vault to `Completed`.
5. `release_funds` preserves legacy behavior by releasing all `remaining` funds.

## Worked Example

A creator escrows 10,000 USDC:

| Step | Action | Paid | Remaining | Status |
| --- | --- | ---: | ---: | --- |
| 1 | create vault | 0 | 10,000 | Active |
| 2 | release_partial(2,500) | 2,500 | 7,500 | Active |
| 3 | release_partial(3,000) | 3,000 | 4,500 | Active |
| 4 | release_funds() | 4,500 | 0 | Completed |

`cancel_vault` and `redirect_funds` only transfer `remaining`, so cumulative
payouts can never exceed the original `amount`.
209 changes: 197 additions & 12 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ pub enum VaultStatus {
pub struct ProductivityVault {
/// Address that created (and funded) the vault.
pub creator: Address,
/// USDC amount locked in the vault (in stroops / smallest unit).
/// Original USDC amount locked in the vault (in stroops / smallest unit).
pub amount: i128,
/// Remaining unreleased balance still held by the vault.
pub remaining: i128,
/// Ledger timestamp when the commitment period starts.
pub start_timestamp: u64,
/// Ledger timestamp after which deadline-based release is allowed.
Expand Down Expand Up @@ -186,6 +188,7 @@ impl DisciplrVault {
let vault = ProductivityVault {
creator,
amount,
remaining: amount,
start_timestamp,
end_timestamp,
milestone_hash,
Expand Down Expand Up @@ -253,8 +256,42 @@ impl DisciplrVault {
// release_funds
// -----------------------------------------------------------------------

/// Release vault funds to `success_destination`.
/// Release all remaining vault funds to `success_destination`.
///
/// Preserves the legacy all-or-nothing behavior for callers that do not
/// need tranche releases: the full remaining balance is paid and the vault
/// moves to `Completed`.
pub fn release_funds(env: Env, vault_id: u32, usdc_token: Address) -> Result<bool, Error> {
let vault_key = DataKey::Vault(vault_id);
let vault: ProductivityVault = env
.storage()
.instance()
.get(&vault_key)
.ok_or(Error::VaultNotFound)?;

Self::release_success_amount(env, vault_id, usdc_token, vault.remaining, false)
}

/// Release a partial tranche of vault funds to `success_destination`.
///
/// The vault remains `Active` while `remaining > 0` and transitions to
/// `Completed` only when the final tranche drains the balance.
pub fn release_partial(
env: Env,
vault_id: u32,
usdc_token: Address,
release_amount: i128,
) -> Result<bool, Error> {
Self::release_success_amount(env, vault_id, usdc_token, release_amount, true)
}

fn release_success_amount(
env: Env,
vault_id: u32,
usdc_token: Address,
release_amount: i128,
partial_event: bool,
) -> Result<bool, Error> {
let vault_key = DataKey::Vault(vault_id);
let mut vault: ProductivityVault = env
.storage()
Expand All @@ -265,10 +302,9 @@ impl DisciplrVault {
vault.creator.require_auth();

if vault.status != VaultStatus::Active {
return Err(Error::VaultNotActive); // Or InvalidStatus as appropriate
return Err(Error::VaultNotActive);
}

// Check release conditions.
let now = env.ledger().timestamp();
let deadline_reached = now >= vault.end_timestamp;
let validated = vault.milestone_validated;
Expand All @@ -277,20 +313,39 @@ impl DisciplrVault {
return Err(Error::NotAuthorized);
}

if release_amount <= 0 || release_amount > vault.remaining {
return Err(Error::InvalidAmount);
}

let remaining = vault
.remaining
.checked_sub(release_amount)
.ok_or(Error::InvalidAmount)?;

let token_client = token::Client::new(&env, &usdc_token);
token_client.transfer(
&env.current_contract_address(),
&vault.success_destination,
&vault.amount,
&release_amount,
);

vault.status = VaultStatus::Completed;
vault.remaining = remaining;
if vault.remaining == 0 {
vault.status = VaultStatus::Completed;
}
env.storage().instance().set(&vault_key, &vault);

env.events().publish(
(Symbol::new(&env, "funds_released"), vault_id),
vault.amount,
);
if partial_event {
env.events().publish(
(Symbol::new(&env, "funds_released_partial"), vault_id),
(release_amount, vault.remaining),
);
} else {
env.events().publish(
(Symbol::new(&env, "funds_released"), vault_id),
release_amount,
);
}
Ok(true)
}

Expand Down Expand Up @@ -324,9 +379,10 @@ impl DisciplrVault {
token_client.transfer(
&env.current_contract_address(),
&vault.failure_destination,
&vault.amount,
&vault.remaining,
);

vault.remaining = 0;
vault.status = VaultStatus::Failed;
env.storage().instance().set(&vault_key, &vault);

Expand Down Expand Up @@ -360,9 +416,10 @@ impl DisciplrVault {
token_client.transfer(
&env.current_contract_address(),
&vault.creator,
&vault.amount,
&vault.remaining,
);

vault.remaining = 0;
vault.status = VaultStatus::Cancelled;
env.storage().instance().set(&vault_key, &vault);

Expand Down Expand Up @@ -529,6 +586,7 @@ mod tests {
let vault = vault_state.unwrap();
assert_eq!(vault.creator, setup.creator);
assert_eq!(vault.amount, setup.amount);
assert_eq!(vault.remaining, setup.amount);
assert_eq!(vault.start_timestamp, setup.start_timestamp);
assert_eq!(vault.end_timestamp, setup.end_timestamp);
assert_eq!(vault.milestone_hash, setup.milestone_hash());
Expand Down Expand Up @@ -850,6 +908,133 @@ mod tests {
assert_eq!(vault.status, VaultStatus::Completed);
}

#[test]
fn test_release_partial_after_validation_keeps_vault_active() {
let setup = TestSetup::new();
let client = setup.client();

setup.env.ledger().set_timestamp(setup.start_timestamp);
let vault_id = setup.create_default_vault();
client.validate_milestone(&vault_id);

let usdc = setup.usdc_client();
let success_before = usdc.balance(&setup.success_dest);
let partial = setup.amount / 2;

let result = client.release_partial(&vault_id, &setup.usdc_token, &partial);
assert!(result);

let success_after = usdc.balance(&setup.success_dest);
assert_eq!(success_after - success_before, partial);

let vault = client.get_vault_state(&vault_id).unwrap();
assert_eq!(vault.status, VaultStatus::Active);
assert_eq!(vault.amount, setup.amount);
assert_eq!(vault.remaining, setup.amount - partial);
}

#[test]
fn test_release_partial_final_tranche_completes_vault() {
let setup = TestSetup::new();
let client = setup.client();

setup.env.ledger().set_timestamp(setup.start_timestamp);
let vault_id = setup.create_default_vault();
client.validate_milestone(&vault_id);

let usdc = setup.usdc_client();
let success_before = usdc.balance(&setup.success_dest);
let first = setup.amount / 2;
let second = setup.amount - first;

assert!(client.release_partial(&vault_id, &setup.usdc_token, &first));
assert!(client.release_partial(&vault_id, &setup.usdc_token, &second));

assert_eq!(
usdc.balance(&setup.success_dest) - success_before,
setup.amount
);
let vault = client.get_vault_state(&vault_id).unwrap();
assert_eq!(vault.status, VaultStatus::Completed);
assert_eq!(vault.remaining, 0);
}

#[test]
fn test_release_funds_after_partial_releases_remaining() {
let setup = TestSetup::new();
let client = setup.client();

setup.env.ledger().set_timestamp(setup.start_timestamp);
let vault_id = setup.create_default_vault();
client.validate_milestone(&vault_id);

let usdc = setup.usdc_client();
let success_before = usdc.balance(&setup.success_dest);
let partial = setup.amount / 4;

assert!(client.release_partial(&vault_id, &setup.usdc_token, &partial));
assert!(client.release_funds(&vault_id, &setup.usdc_token));

assert_eq!(
usdc.balance(&setup.success_dest) - success_before,
setup.amount
);
let vault = client.get_vault_state(&vault_id).unwrap();
assert_eq!(vault.status, VaultStatus::Completed);
assert_eq!(vault.remaining, 0);
}

#[test]
#[should_panic(expected = "Error(Contract, #7)")]
fn test_release_partial_zero_amount_rejected() {
let setup = TestSetup::new();
let client = setup.client();

setup.env.ledger().set_timestamp(setup.start_timestamp);
let vault_id = setup.create_default_vault();
client.validate_milestone(&vault_id);

client.release_partial(&vault_id, &setup.usdc_token, &0);
}

#[test]
#[should_panic(expected = "Error(Contract, #7)")]
fn test_release_partial_over_remaining_rejected() {
let setup = TestSetup::new();
let client = setup.client();

setup.env.ledger().set_timestamp(setup.start_timestamp);
let vault_id = setup.create_default_vault();
client.validate_milestone(&vault_id);

client.release_partial(&vault_id, &setup.usdc_token, &(setup.amount + 1));
}

#[test]
fn test_cancel_after_partial_returns_only_remaining() {
let setup = TestSetup::new();
let client = setup.client();

setup.env.ledger().set_timestamp(setup.start_timestamp);
let vault_id = setup.create_default_vault();
client.validate_milestone(&vault_id);

let partial = setup.amount / 2;
assert!(client.release_partial(&vault_id, &setup.usdc_token, &partial));

let usdc = setup.usdc_client();
let creator_before = usdc.balance(&setup.creator);
assert!(client.cancel_vault(&vault_id, &setup.usdc_token));
assert_eq!(
usdc.balance(&setup.creator) - creator_before,
setup.amount - partial
);

let vault = client.get_vault_state(&vault_id).unwrap();
assert_eq!(vault.status, VaultStatus::Cancelled);
assert_eq!(vault.remaining, 0);
}

#[test]
fn test_double_release_rejected() {
let setup = TestSetup::new();
Expand Down
11 changes: 11 additions & 0 deletions test_snapshots/test/test_create_vault_exact_balance.1.json
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,17 @@
"bool": false
}
},
{
"key": {
"symbol": "remaining"
},
"val": {
"i128": {
"hi": 0,
"lo": 10000000
}
}
},
{
"key": {
"symbol": "start_timestamp"
Expand Down
11 changes: 11 additions & 0 deletions test_snapshots/test/test_create_vault_success.1.json
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,17 @@
"bool": false
}
},
{
"key": {
"symbol": "remaining"
},
"val": {
"i128": {
"hi": 0,
"lo": 10000000
}
}
},
{
"key": {
"symbol": "start_timestamp"
Expand Down
Loading