The Disciplr Vault is a Soroban smart contract deployed on the Stellar blockchain that enables programmable time-locked USDC vaults for productivity-based milestone funding. It allows creators to lock USDC tokens with specific milestones and conditions, ensuring funds are only released upon verified completion or redirected to a failure destination if milestones are not met.
- Vesting schedules: Lock tokens that vest over time based on milestone completion
- Grant funding: Enable grant providers to fund projects with accountability
- Team incentives: Align team compensation with deliverable completion
- Bug bounties: Create time-bound bounty programs with predefined payout conditions
Represents the current state of a vault:
#[contracttype]
pub enum VaultStatus {
Active = 0, // Vault created and funds locked
Completed = 1, // Milestone validated, funds released to success destination
Failed = 2, // Milestone not completed by deadline, funds redirected
Cancelled = 3, // Vault cancelled by creator, funds returned
}| Status | Description |
|---|---|
Active |
Vault is live, waiting for milestone validation or deadline |
Completed |
Milestone verified, funds released to success destination |
Failed |
Deadline passed without validation, funds redirected |
Cancelled |
Creator cancelled vault, funds returned |
The main data structure representing a vault:
#[contracttype]
pub struct ProductivityVault {
pub creator: Address, // Address that created the vault
pub amount: i128, // Amount of USDC locked (in stroops)
pub start_timestamp: u64, // Unix timestamp when vault becomes active
pub end_timestamp: u64, // Unix deadline for milestone validation
pub milestone_hash: BytesN<32>, // SHA-256 hash of milestone requirements
pub verifier: Option<Address>, // Optional trusted verifier address
pub success_destination: Address, // Address for fund release on success
pub failure_destination: Address, // Address for fund redirect on failure
pub status: VaultStatus, // Current vault status
}| Field | Type | Description |
|---|---|---|
creator |
Address |
Wallet address that created the vault |
amount |
i128 |
Total USDC amount locked (in stroops, 1 USDC = 10^7 stroops) |
start_timestamp |
u64 |
Unix timestamp (seconds) when vault becomes active |
end_timestamp |
u64 |
Unix timestamp (seconds) deadline for milestone validation |
milestone_hash |
BytesN<32> |
SHA-256 hash documenting milestone requirements |
verifier |
Option<Address> |
Optional trusted party who can validate milestones |
success_destination |
Address |
Recipient address on successful milestone completion |
failure_destination |
Address |
Recipient address when milestone is not completed |
status |
VaultStatus |
Current lifecycle state of the vault |
Creates a new productivity vault and locks USDC funds.
pub fn create_vault(
env: Env,
creator: Address,
amount: i128,
start_timestamp: u64,
end_timestamp: u64,
milestone_hash: BytesN<32>,
verifier: Option<Address>,
success_destination: Address,
failure_destination: Address,
) -> u32Parameters:
creator: Address of the vault creator (must authorize transaction)amount: USDC amount to lock (in stroops)start_timestamp: When vault becomes active (unix seconds)end_timestamp: Deadline for milestone validation (unix seconds)milestone_hash: SHA-256 hash of milestone documentverifier: Optional verifier address (None = creator validates)success_destination: Address to receive funds on successfailure_destination: Address to receive funds on failure
Returns: u32 - Unique vault identifier
Requirements:
- Caller must authorize the transaction (
creator.require_auth()) amountmust be within[MIN_AMOUNT, MAX_AMOUNT]; otherwise returnsError::InvalidAmountstart_timestampmust be strictly less thanend_timestamp; otherwise returnsError::InvalidTimestampsend_timestamp - start_timestampmust not exceedMAX_VAULT_DURATION; otherwise returnsError::DurationTooLongsuccess_destinationmust differ fromfailure_destination; otherwise returnsError::SameDestination(error code#10). Equal destinations make the success/failure outcome financially indistinguishable, removing the accountability incentive of the vault.creatormust differ fromsuccess_destinationandfailure_destination; otherwise returnsError::InvalidAddress(error code#11). A creator that is also a destination could trivially recover funds regardless of milestone outcome, defeating the vault's accountability mechanism.verifier(whenSome) must differ fromcreator; otherwise returnsError::InvalidAddress(error code#11). A verifier equal to the creator provides no independent validation.- USDC transfer must be approved by creator before calling
Emits: vault_created event
Allows the verifier (or authorized party) to validate milestone completion and release funds.
pub fn validate_milestone(env: Env, vault_id: u32) -> boolParameters:
vault_id: ID of the vault to validate
Returns: bool - True if validation successful
Requirements:
- Vault must exist and be in
Activestatus - Caller must be the designated verifier (if set), or creator (if verifier is None)
- Current timestamp must be before
end_timestamp
Emits: milestone_validated event
Releases locked funds to the success destination (after validation or deadline).
pub fn release_funds(env: Env, vault_id: u32, usdc_token: Address) -> boolParameters:
vault_id: ID of the vault to release funds fromusdc_token: Address of the USDC token contract
Returns: bool - True if release successful
Requirements:
- Vault status must be
Active - Milestone must be validated OR current time must be past
end_timestamp - Transfers USDC to
success_destination - Sets status to
Completed
Redirects funds to the failure destination when milestone is not completed by deadline.
pub fn redirect_funds(env: Env, vault_id: u32, usdc_token: Address) -> boolParameters:
vault_id: ID of the vault to redirect funds fromusdc_token: Address of the USDC token contract
Returns: bool - True if redirect successful
Requirements:
- Vault status must be
Active - Current timestamp must be past
end_timestamp - Milestone must NOT have been validated
- Transfers USDC to
failure_destination - Sets status to
Failed
Allows the creator to cancel the vault and retrieve locked funds.
pub fn cancel_vault(env: Env, vault_id: u32, usdc_token: Address) -> boolParameters:
vault_id: ID of the vault to cancelusdc_token: Address of the USDC token contract
Returns: bool - True if cancellation successful
Requirements:
- Caller must be the vault creator
- Vault status must be
Active - Returns USDC to creator
- Sets status to
Cancelled
Retrieves the current state of a vault.
pub fn get_vault_state(env: Env, vault_id: u32) -> Option<ProductivityVault>Parameters:
vault_id: ID of the vault to query
Returns: Option<ProductivityVault> - Stored vault data when a record exists for that ID.
Behavior: Created vault records are not deleted during normal contract execution. Completed, failed, and cancelled vaults still return Some(ProductivityVault) with their terminal status. None therefore means the ID was never assigned (vault_id >= vault_count()) or storage was cleared outside the contract's normal lifecycle.
Emitted when a new vault is created.
Topic:
("vault_created", vault_id)
Data:
ProductivityVault {
creator: Address,
amount: i128,
start_timestamp: u64,
end_timestamp: u64,
milestone_hash: BytesN<32>,
verifier: Option<Address>,
success_destination: Address,
failure_destination: Address,
status: VaultStatus::Active,
}Emitted when a milestone is successfully validated.
Topic:
("milestone_validated", vault_id)
Data: () (empty tuple)
┌──────────────┐
│ CREATED │
│ │
│ create_vault │
└──────┬───────┘
│
▼
┌──────────────┐
┌─────────│ ACTIVE │─────────┐
│ │ │ │
│ └──────────────┘ │
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────────┐
│ validate_ │ │ redirect_funds │
│ milestone() │ │ (deadline passed) │
└────────┬────────┘ └──────────┬──────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────────┐
│ COMPLETED │ │ FAILED │
│ │ │ │
└─────────────────┘ └─────────────────────┘
│
▼
┌─────────────────┐
│ cancel_vault() │
└────────┬────────┘
│
▼
┌─────────────────┐
│ CANCELLED │
│ │
└─────────────────┘
This section outlines the security properties, trust assumptions, and known limitations of the Disciplr Vault contract to assist auditors and users.
- Verifier Trust (Critical): When a
verifieris designated (viaSome(Address)), that address has absolute power to validate the milestone and cause funds to be released to thesuccess_destinationbefore the deadline. If the verifier is compromised or malicious, they can release funds prematurely or to a non-compliant recipient. - Creator Authority: The
creatoris the only address authorized tocreate_vaultorcancel_vault. They must authorize the initial USDC funding. If noverifieris set (None), only thecreatorcan validate the milestone. - No Administrative Overrides: There is no "admin" or "owner" role with the power to sweep funds or override the vault logic. Funds can only flow to the predefined
success_destination,failure_destination, or back to thecreatoron cancellation. - Immutable Destinations: Once a vault is created, the
success_destinationandfailure_destinationare immutable. This prevents redirection of funds after the vault is funded.
- Stellar Ledger Integrity: We assume the underlying Stellar blockchain and Soroban runtime correctly enforce authorization (
require_auth) and maintain state integrity. - Ledger Timestamp: The contract relies on
env.ledger().timestamp()for all time-based logic (start/end windows). We assume ledger timestamps are reasonably accurate and monotonic as per Stellar network consensus. - Token Contract Behavior: The contract interacts with a USDC token contract (standard Soroban token interface). We assume the token contract is honest and follows the expected transfer behavior.
- Per-Call Token Address: The
usdc_tokenaddress is passed as an argument to release/redirect functions rather than being pinned to the vault data at creation. This introduces a risk where a malicious caller could potentially pass a different token address (though they would still need the contract to hold a balance of that token). - Checks-Effects-Interactions (CEI): In
release_funds,redirect_funds, andcancel_vault, the USDC transfer is initiated before the internal status is updated toCompleted,Failed, orCancelled. While Soroban's atomicity safeguards against most reentrancy/partial-success risks, this is a deviation from the strict CEI pattern. - Lack of Emergency Stops: There is currently no circuit breaker or emergency pause mechanism.
- Precision: All amounts are handled as
i128in stroops (7 decimals for USDC); users must ensure they provide correct decimal-adjusted amounts. - Equal Destinations Rejected (Issue #124):
success_destinationandfailure_destinationmust be different addresses. If they were equal, the outcome of the vault (success vs. failure) would be financially indistinguishable for the creator: funds would arrive at the same address regardless of whether the milestone was completed. This eliminates the accountability incentive that is the core purpose of the vault, and could be used to disguise a vault with no real consequence for non-completion. The contract enforces this at creation time withError::SameDestination(code#10). - Invalid Address Roles Rejected (Issue #125): The contract rejects configurations where address roles overlap in ways that defeat the vault's accountability mechanism. Specifically: (a)
creator == success_destination— the creator would trivially recover funds on success regardless of milestone completion; (b)creator == failure_destination— the creator would recover funds on failure with no consequence for non-completion; (c)verifier == creator— the creator would be validating their own milestone, providing no independent oversight. All three cases returnError::InvalidAddress(code#11). Note: the Soroban SDK does not expose a way to detect the Stellar zero-address (GAAA...WHF) at the contract level inno_stdenvironments; these role-overlap checks are the detectable "obviously invalid placeholder" validations available at contract level.
- Use Soroban Token Interface: Implement standard token operations for USDC
- Add Access Control: Implement
Ownablepattern for admin functions - Circuit Breaker: Add emergency pause functionality
- Upgradeability: Consider proxy pattern for contract upgrades
- Comprehensive Tests: Achieve 95%+ test coverage
- External Audits: Have security experts review before mainnet deployment
- Multisig Verifiers: For high-value vaults, use a multisig address as the
verifier
A project owner wants to lock 1000 USDC for a bug bounty program with a 30-day deadline.
// Parameters
let creator: Address = Address::from_string("GA7..."); // Creator wallet
let amount: i128 = 1000 * 10_000_000; // 1000 USDC in stroops
let start_timestamp: u64 = 1704067200; // Jan 1, 2024 00:00:00 UTC
let end_timestamp: u64 = 1706640000; // Jan 30, 2024 00:00:00 UTC (30 days)
// Hash of milestone requirements (off-chain document)
let milestone_hash: BytesN<32> = BytesN::from_array(&env, &[
0x4d, 0x69, 0x6c, 0x65, 0x73, 0x74, 0x6f, 0x6e,
0x65, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72,
0x65, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x5f, 0x68,
0x61, 0x73, 0x68, 0x5f, 0x65, 0x78, 0x61, 0x6d
]);
let verifier: Option<Address> = Some(Address::from_string("GB7..."));
let success_destination: Address = Address::from_string("GC7..."); // Project wallet
let failure_destination: Address = Address::from_string("GD7..."); // Funder wallet
// Create vault
let vault_id = DisciplrVaultClient::new(&env, &contract_address)
.create_vault(
&creator,
&amount,
&start_timestamp,
&end_timestamp,
&milestone_hash,
&verifier,
&success_destination,
&failure_destination,
);
// vault_id = 0The verifier validates that milestone requirements were met and releases funds.
let verifier: Address = Address::from_string("GB7..."); // Designated verifier
let result = DisciplrVaultClient::new(&env, &contract_address)
.with_source_account(&verifier)
.validate_milestone(&vault_id);
// result = true
// Funds now transferred to success_destination
// Vault status changed to CompletedAfter the deadline passes without milestone validation, funds are redirected.
// Assume end_timestamp has passed and no validation occurred
let result = DisciplrVaultClient::new(&env, &contract_address)
.redirect_funds(&vault_id);
// result = true
// Funds transferred to failure_destination
// Vault status changed to FailedCreator decides to cancel the vault before the deadline.
let creator: Address = Address::from_string("GA7..."); // Original creator
let result = DisciplrVaultClient::new(&env, &contract_address)
.with_source_account(&creator)
.cancel_vault(&vault_id);
// result = true
// Funds returned to creator
// Vault status changed to CancelledCheck the current state of a vault.
let vault_state = DisciplrVaultClient::new(&env, &contract_address)
.get_vault_state(&vault_id);
// Returns Some(ProductivityVault) or None
match vault_state {
Some(vault) => {
// Access vault fields
let current_status = vault.status;
let amount_locked = vault.amount;
}
None => {
// Vault not found or not initialized
}
}Run the test suite to verify contract functionality:
cargo testExpected output should include tests for:
- Vault creation with valid parameters
- Vault creation authorization
- Event emission on vault creation
- Milestone validation logic
- Fund release and redirect logic
- Vault cancellation
- State retrieval
- Equal destination rejection (Issue #124)
- Invalid address role rejection (Issue #125)
The repository layout changes as tests, documentation, workflows, and tooling evolve. See the live repository tree for the current, complete file structure.
| Version | Changes |
|---|---|
| 0.1.0 | Initial release with basic vault structure, stubbed implementations |
| 0.2.0 | Issue #124: reject equal success/failure destinations (SameDestination error) |
| 0.3.0 | Issue #125: reject invalid address role overlaps (InvalidAddress error) |