Skip to content
 
 

Latest commit

 

History

164 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

HireSettle — Recruitment Fee Settlement Contract

A Soroban smart contract deployed on Stellar for managing recruitment fee settlements through milestone-based escrow payments. Built with #![no_std] Rust and the Soroban SDK.


Setup & Installation

Prerequisites

Before getting started, ensure you have:

  • Rust (latest stable version)
  • Cargo
  • Soroban CLI
  • Stellar CLI
  • Git

Clone the Repository

git clone https://github.com/TrustHire/hiresettle-contract.git
cd hiresettle-contract

Build the Contract

cd contracts/hiresettle
cargo build

Run the Tests

cargo test

Overview

HireSettle governs the relationship between a hiring company and a recruiter by locking the total agreed fee in an escrow wallet at engagement creation. As the recruiter delivers on each milestone (placement, 30-day retention, 90-day retention, etc.), the company confirms the deliverable, releasing a proportional payment from escrow. If disputes arise, an M-of-N arbiter panel votes to resolve them. The contract handles the entire lifecycle: creation, proof submission, confirmation, dispute, replacement, early exit, cancellation, and expiry.

Core Concepts

  • Engagement — A single recruitment contract identified by a unique string ID. Stores the company, recruiter, arbiters, token, total fee, milestone list, and lifecycle status.
  • Milestone — A discrete payment trigger with a name, payment percentage, type (Placement or Retention), time-gate ledger, proof hash, and status (LockedPendingProofSubmittedConfirmed / DisputedResolved).
  • Escrow — Funds are transferred from the company to the contract at creation. Milestone payments release proportional amounts to the recruiter (minus platform fees). Remaining escrow is refunded on cancellation or expiry.
  • Dispute Resolution — The company raises a dispute on a proof-submitted milestone. Arbiters vote approve/reject; if approve_votes >= quorum, payment is released; if reject_votes > arbiters.len() - quorum, the proof is cleared and the milestone returns to Pending.

Key Features

Feature Description
Milestone-based escrow Funds locked at creation; released per-milestone on confirmation. Percentages sum to 100%.
Placement & Retention types Placement starts Pending; retention starts Locked and requires a ledger time-gate to elapse.
Multi-arbiter disputes M-of-N arbiter voting to resolve disputes. Approve releases payment; reject resets milestone.
Co-recruiter split Optional co_recruiter address with configurable basis-point split for shared-fee engagements.
Proof cooldown Configurable minimum ledger gap between proof resubmissions (default ~4 hours).
Admin configuration Platform fee (up to 5%), token allowlist, max milestones, max retention days, inactivity timeout, confirm/dispute windows, arbiter fee (up to 2%), proof hash length, ledgers-per-day, storage TTL, upgrade lock duration.
Auto / force confirm If the company does not act within the confirm window (~5 days), any address can force-confirm.
Batch confirmation Confirm multiple milestones atomically in a single transaction.
Recruiter early exit Recruiter requests exit; company accepts (refunds remaining escrow) or rejects (returns to Active).
Candidate replacement Company requests replacement, resetting milestones and adjusting retention timers.
Engagement expiry Permissionless keeper function — expires after an inactivity timeout (~60 days), refunds company.
Amendment proposals Company or recruiter proposes a payment_percent change; the other party must accept within a TTL. Amendment history is logged (capped at 20 entries).
Arbiter succession Arbiters can nominate and claim successors for their slots.
Contract upgrade Admin proposes a WASM upgrade with a mandatory time-lock (~1 day); execution is permissionless after the lock.
Admin renouncement Admin can permanently renounce, making the contract immutable. All admin-gated functions fail after renouncement.
Contract PDF attestation Optional contract_pdf_hash (e.g. SHA-256 of the signed PDF) stored at creation for audit trail.
Engagement listing Per-company paginated engagement ID list (issue #35) and global engagement counter (issue #34).
Unlock progress query get_unlock_progress() returns (unlocked_count, total) — how many milestones are past Locked status.

Data Types

Engagement

The full on-chain record:

  • id, company, recruiter, arbiters, quorum, token
  • total_amount, released_amount, job_title
  • metadata_hash (optional IPFS CID), contract_pdf_hash (optional attestation hash)
  • created_at_ledger, last_activity_ledger
  • milestones (Vec), status, co_recruiter, recruiter_split_bps

Milestone

  • name, payment_percent, kind (Placement | Retention), valid_after_ledger
  • proof_hash, status (Locked | Pending | ProofSubmitted | Confirmed | Disputed | Resolved), proof_submitted_at

EngagementConfig

Passed at creation to stay within Soroban's 10-parameter limit:

  • metadata_hash (Option), contract_pdf_hash (Option)
  • co_recruiter (Option), recruiter_split_bps (u32)

EngagementStatus

ActiveCompleted | Cancelled | Expired | ReplacementRequested | ExitRequested

EngagementSummary

pub struct EngagementSummary {
    pub id: String,                  // unique engagement identifier
    pub job_title: String,           // short title set at creation
    pub company: Address,
    pub recruiter: Address,
    pub total_amount: i128,          // total fee locked (stroops)
    pub released_amount: i128,       // amount paid out so far
    pub status: EngagementStatus,
    pub milestone_count: u32,        // total milestones (does not change)
    pub created_at_ledger: u32,
}

Lightweight read-only view returned by get_engagement_summary, omitting the milestone vector for efficient dashboard listing.

AmendmentEntry

pub struct AmendmentEntry {
    pub proposer: Address,           // company or recruiter who proposed
    pub old_payment_percent: u32,
    pub new_payment_percent: u32,
    pub ledger: u32,                 // ledger when the amendment was accepted
}

History entry recorded when a milestone payment-percent amendment is accepted.

AmendmentProposal

pub struct AmendmentProposal {
    pub proposer: Address,
    pub new_payment_percent: u32,
    pub proposed_at_ledger: u32,
    pub expires_at_ledger: u32,      // proposal TTL; expires if not accepted in time
}

A pending milestone amendment proposal; either party may propose and the other must accept before expiry.

ArbiterSetup

pub struct ArbiterSetup {
    pub arbiters: Vec<Address>,      // ordered list eligible to vote on disputes
    pub quorum: u32,                 // M-of-N votes required to resolve
}

Bundled argument passed to create_engagement to configure the arbitration panel (keeps parameter count within Soroban's 10-arg limit).

ArbiterVoteRecord

pub struct ArbiterVoteRecord {
    pub approve_votes: u32,
    pub reject_votes: u32,
    pub voted: Vec<Address>,         // prevents double-voting
}

Per-dispute vote tally stored on-chain until the dispute resolves (approve or reject quorum is reached).

ArbiterVoteCounts

pub struct ArbiterVoteCounts {
    pub approve_votes: u32,
    pub reject_votes: u32,
}

Returned by `get_arbiter_votes` — lightweight view of the current tally without the voter list.

### `ArbiterNomination`

```rust
pub struct ArbiterNomination {
    pub current: Address,            // nominating arbiter
    pub nominee: Address,            // successor
}

Stored under DataKey::PendingArbiter during arbiter succession; the nominee calls claim_arbiter to finalise.

UpgradeProposal

pub struct UpgradeProposal {
    pub new_wasm_hash: BytesN<32>,   // new contract WASM hash
    pub execute_after_ledger: u32,   // earliest ledger at which execution is allowed
}

Pending contract WASM upgrade proposal; subject to an admin-configurable time-lock (default 17,280 ledgers ≈ 1 day).

PlatformFee

pub struct PlatformFee {
    pub bps: u32,                    // fee in basis points (max 500 = 5%)
    pub treasury: Address,           // fee recipient
}

Platform fee deducted from each milestone payment before release to the recruiter.

DataKey

pub enum DataKey {
    Engagement(String),              // full engagement record by ID (persistent)
    Admin,                           // current admin address (instance)
    PendingArbiter(String),          // pending arbiter succession nomination
    PlatformFee,                     // bps + treasury config (persistent)
    Paused,                          // pause-guard bool (persistent)
    PendingAdmin,                    // nominated admin successor (persistent)
    ProofCooldown,                   // min ledgers between resubmissions (instance)
    LastProofAt(String, u32),        // ledger of last proof submission
    ArbiterVotes(String, u32),       // running vote tally for a dispute
    AmendmentProposal(String, u32),  // active amendment proposal
    AmendmentLog(String, u32),       // amendment history entries
    AmendmentTTL,                    // proposal expiry duration (persistent)
    // … additional keys for counts, allowlist, timeouts, etc.
}

Contract storage key space enumerating all persistent and instance-stored values. Instance keys reset between transactions; persistent keys survive across ledgers.


Amendments

Either the company or the recruiter may propose a change to a milestone's payment_percent. Amendments are scoped to a single milestone per proposal and require explicit acceptance from the counterparty before taking effect.

Propose → Accept / Reject Flow

  1. Proposepropose_amendment(proposer, engagement_id, milestone_index, new_payment_percent)

    • Caller must be the engagement's company or recruiter; must sign the transaction.
    • new_payment_percent is validated to be within 0–100 (inclusive).
    • A new proposal overwrites any existing pending proposal for the same milestone (only one pending per milestone at a time).
    • Emits an amendment_proposed event.
    • The pending state is stored as an AmendmentProposal struct under DataKey::AmendmentProposal(engagement_id, milestone_index).
  2. Acceptaccept_amendment(acceptor, engagement_id, milestone_index)

    • Caller must be the other party (the one who did not propose). A proposer cannot accept their own proposal.
    • The milestone's payment_percent is updated to the proposed value immediately.
    • An AmendmentEntry is appended to the milestone's amendment log (see below) recording the old/new percentages, the proposer, and the acceptance ledger.
    • The pending proposal is cleared from storage.
    • Emits an amendment_accepted event.
  3. Rejectreject_amendment(rejector, engagement_id, milestone_index)

    • Caller must be the other party (the one who did not propose). A proposer cannot reject their own proposal.
    • The pending proposal is cleared from storage without any change to the milestone.
    • Emits an amendment_rejected event with reason declined.

TTL and Expiry

Every proposal carries an expiry ledger computed as proposed_at_ledger + amendment_ttl (see AmendmentProposal.expires_at_ledger).

  • The default TTL is 17,280 ledgers (≈ 1 day at 5 s/ledger).
  • Admin can change the default globally via set_amendment_ttl(ledgers); the current value is queried with get_amendment_ttl().
  • If the current ledger exceeds expires_at_ledger, the proposal is considered expired:
    • Calling accept_amendment on an expired proposal clears it, emits an amendment_rejected event with reason expired, and panics with amendment_expired.
    • get_pending_amendment automatically treats expired proposals as non-existent and returns None.
  • Expired proposals do not auto-clean from storage on ledger tick; they are lazily cleared on the next accept_amendment, reject_amendment, or overwritten by the next propose_amendment for the same milestone.

What an Amendment Can Change

Only one field is mutable via the amendment system:

Field Type Description
milestone.payment_percent u32 Percentage of total_amount released when the milestone confirms. Must be 0–100 inclusive.

An amendment does not change the total escrow, milestone status, proof hash, retention time-gates, arbiter configuration, or any other engagement field. Percentage-sum validation across all milestones is not re-enforced at amendment time; integrators are expected to ensure the combined set across all milestones still sums to 100 after applying accepted amendments.

Amendment History (Log)

Each time an amendment is accepted, an AmendmentEntry is appended to the per-milestone log:

  • Accessible via get_amendment_log(engagement_id, milestone_index) which returns entries in chronological order (oldest first).
  • The log is FIFO-capped at 20 entries per milestone — once the cap is reached, the oldest entry is evicted on the next accepted amendment.
  • The pending proposal itself is not part of the log until it is accepted; use get_pending_amendment to inspect a live proposal.

Token Allowlist

The contract supports an optional allowlist to restrict which tokens can be used for escrow.

Configuration

  • Toggle Allowlist: The allowlist is toggled on or off using set_token_allowlist_enabled(admin, enabled).
  • Add Token: New tokens are added to the allowlist with add_allowed_token(admin, token_address).
  • Remove Token: Tokens are removed with remove_allowed_token(admin, token_address).

Engagement Creation

When the token allowlist is enabled, create_engagement will panic with TokenNotAllowed if the token passed is not in the allowed tokens list. If the allowlist is disabled, any valid SAC token is accepted.

Public Function Reference

Admin

init, set_platform_fee, set_version, set_min_amount, pause, unpause, nominate_admin, claim_admin, renounce_admin, set_proof_cooldown, set_ledgers_per_day, set_max_retention_days, set_max_milestones, set_inactivity_timeout_ledgers, set_storage_ttl_extend_to, set_confirm_window, set_dispute_window, set_max_proof_hash_length, set_arbiter_fee, set_amendment_ttl, set_upgrade_lock_duration, propose_upgrade, add_allowed_token, remove_allowed_token, set_token_allowlist_enabled

Engagement Lifecycle

create_engagement, unlock_milestone, submit_proof, confirm_milestone, batch_confirm_milestones, force_confirm_milestone, raise_dispute, cast_arbiter_vote, request_replacement, cancel_engagement, top_up_escrow, request_early_exit, accept_early_exit, reject_early_exit, expire_engagement

Amendments

propose_amendment, accept_amendment, reject_amendment

Arbiter Succession

nominate_arbiter_successor, claim_arbiter

Arbiter Voting & Succession

Arbiter Voting

HireSettle uses a multi-arbiter M-of-N voting model to resolve disputes. When a company raises a dispute on a milestone, each assigned arbiter may cast a single vote using cast_arbiter_vote().

Votes are tracked until one of the following conditions is met:

  • Approval quorum reached (approve_votes >= quorum)
    • The dispute is resolved in favour of the recruiter.
    • The milestone payment is released from escrow.
  • Rejection quorum reached (reject_votes > arbiters.len() - quorum)
    • The submitted proof is rejected.
    • The milestone returns to the Pending state, allowing the recruiter to submit new proof.

Each arbiter may vote only once for a dispute. Duplicate votes are rejected by the contract.

Viewing Vote Progress

Applications can retrieve the current dispute vote tally using:

get_arbiter_votes()

This read-only function returns the current approval and rejection vote counts without exposing the voter list, allowing dashboards to display dispute progress while preserving voter privacy.

Arbiter Succession

To support long-running engagements, HireSettle allows arbiters to transfer their responsibilities to a successor without modifying the engagement itself.

The succession process consists of two steps:

  1. The current arbiter nominates a successor using:
nominate_arbiter_successor()
  1. The nominated address accepts the role by calling:
claim_arbiter()

Only the nominated address can complete the claim. Once claimed, the successor assumes the arbiter's position for future dispute voting while preserving the integrity of the arbitration panel.

Read-Only Queries

All read-only functions are permissionless and require no authentication.

Engagement Queries

Function Arguments Return Type
get_engagement engagement_id: String Engagement
get_engagement_summary engagement_id: String EngagementSummary
get_is_engagement_complete engagement_id: String bool
get_active_dispute_count engagement_id: String u32
get_unlock_progress engagement_id: String (u32, u32)
get_metadata_hash engagement_id: String Option<String>
get_contract_pdf_hash engagement_id: String Option<String>
get_total_released engagement_id: String i128
get_escrow_balance engagement_id: String i128

Milestone Queries

Function Arguments Return Type
get_milestone engagement_id: String, milestone_index: u32 Milestone
get_all_milestone_statuses engagement_id: String Vec<MilestoneStatus>
is_milestone_unlockable engagement_id: String, milestone_index: u32 bool
ledgers_until_unlock engagement_id: String, milestone_index: u32 u32
get_estimated_unlock_seconds engagement_id: String, milestone_index: u32 u64
get_arbiter_votes engagement_id: String, milestone_index: u32 ArbiterVoteCounts
get_dispute_reason engagement_id: String, milestone_index: u32 Option<String>

Engagement Listing & Counting

Function Arguments Return Type
get_engagement_count u64
get_company_engagement_count company: Address u32
get_engagements_by_company company: Address, page: u32, page_size: u32 Vec<String>
get_company_active_count company: Address u32

Amendment Queries

Function Arguments Return Type
get_amendment_log engagement_id: String, milestone_index: u32 Vec<AmendmentEntry>
get_pending_amendment engagement_id: String, milestone_index: u32 Option<AmendmentProposal>
get_amendment_ttl u32

Replacement Queries

Function Arguments Return Type
get_replacement_reason engagement_id: String, replacement_index: u32 Option<String>
get_replacement_count engagement_id: String u32

Contract Config Getters

Function Arguments Return Type
get_version String
get_min_amount i128
get_platform_fee (u32, Address)
get_ledgers_per_day u32
get_max_retention_days u32
get_max_milestones u32
get_max_replacements u32
get_max_active_per_company u32
get_inactivity_timeout_ledgers u32
get_storage_ttl_extend_to u32
get_confirm_window u32
get_dispute_window u32
get_max_proof_hash_length u32
get_arbiter_fee u32
get_upgrade_lock_duration u32
get_allowed_tokens Vec<Address>

Admin & Contract State

Function Arguments Return Type
is_paused bool
get_admin Address
get_pending_admin Option<Address>

Milestone Confirmation Functions

HireSettle provides three milestone confirmation paths with different authorization, preconditions, and semantics. The canonical single-milestone confirm_milestone is documented here as a reference point so the two batch / force variants can be contrasted against it.


confirm_milestone — Single Milestone (Reference)

Contract function reference: confirm_milestone

Caller: The engagement's company address. Requires authentication (company.require_auth()).

Preconditions:

  • Engagement status is Active.
  • Milestone status is ProofSubmitted (recruiter has already submitted a proof hash).
  • Sequential confirmation (Issue #67): every milestone with a lower index must already be in Confirmed or Resolved status. A later milestone cannot leapfrog an earlier unfinished one.
  • For Retention milestones: env.ledger().sequence() >= milestone.valid_after_ledger. The retention time-gate is re-verified at confirmation time even if unlock_milestone was already called, preventing a company from accidentally confirming before the window truly elapses.
  • Contract is not paused.

Payment / Side effects:

  • The gross share is engagement.total_amount × milestone.payment_percent ÷ 100. From this, milestone.replacement_paid_out is subtracted (Issue #183) so escrow topped up after a replacement reset still reaches the recruiter rather than getting permanently stuck in the contract.
  • Platform fee (bps × gross share ÷ 10 000) is transferred to the treasury.
  • The net remainder is distributed to the recruiter (and co-recruiter, if configured, per recruiter_split_bps).
  • Milestone moves to Confirmed; engagement moves to Completed if this was the last outstanding milestone.
  • Emits milestone_confirmed with (milestone_index, payment).

batch_confirm_milestones — Atomic Multi-Milestone Confirmation

Contract function reference: batch_confirm_milestones

Confirms several milestones in one transaction with a single company signature and all-or-nothing semantics. Useful when a batch of milestones (e.g. placement + 30-day retention) have proof submitted and the company wants to release them together.

Caller: The engagement's company address. Requires authentication (company.require_auth()).

Arguments:

  • milestone_indices: Vec<u32> — ordered list of milestone indices to confirm. Must be non-empty (panics with EmptyIndices otherwise).

Preconditions (validated for every index in the batch before any state mutation or transfer):

  • Engagement status is Active.
  • Each target milestone is in ProofSubmitted status.
  • For each Retention milestone in the batch: current_ledger >= valid_after_ledger.
  • Sequential confirmation (Issue #67 / #184): For every index idx in the batch, all milestones with index < idx must be either (a) already Confirmed or Resolved on-chain, or (b) present somewhere in milestone_indices itself. This allows a single batch to close a contiguous gap of unfinished milestones — e.g. confirming indices [0, 1, 2] atomically even if none of them were previously confirmed — while still forbidding index [2] without index [1].

Differences from single confirm_milestone:

Aspect confirm_milestone batch_confirm_milestones
Scope / signature One milestone_index per tx. milestone_indices: Vec<u32> — arbitrary count per tx.
Atomicity Single call per milestone. tx failure leaves prior milestones confirmed. Validate-all first, then mutate-all. Any failing precondition in the batch rejects the entire transaction — no partial confirmations and no stuck half-paid batches.
Sequential rule Only prior on-chain Confirmed/Resolved milestones count. Prior milestones may be either on-chain done or another entry in the same batch.
Replacement paid-out handling Deducts milestone.replacement_paid_out from the gross share (Issue #183). Does not deduct replacement_paid_out — always pays the full total_amount × payment_percent ÷ 100 gross share.
Completion check Checked once per call. Checked once after the loop, so a batch that closes the final milestone(s) transitions the engagement to Completed and emits exactly one engagement_completed.
Events per milestone One milestone_confirmed event. One milestone_confirmed event per index in the batch.

Payment / Side effects:

  • Identical per-milestone payout path (platform fee → treasury, net payout → recruiter(s), released_amount += gross) but without the replacement_paid_out subtraction (see above).
  • Milestones move to Confirmed in batch order; if the batch exhausts the engagement, engagement → Completed.

force_confirm_milestone — Confirm-Window Timeout Override

Contract function reference: force_confirm_milestone

A permissionless keeper function that force-confirms a milestone whose proof was submitted but the company never acted on it within the configured confirm window. This is the "auto-confirm" safety net advertised in the feature table: it guarantees the recruiter eventually gets paid if the deliverable is not disputed, even if the company goes silent.

Caller: Any address. caller.require_auth() is checked (so a real signature is required) but no role-based restriction is applied. The company, recruiter, an arbiter, or an unaffiliated keeper bot may all invoke it.

Preconditions:

  • Engagement status is Active.
  • Milestone status is exactly ProofSubmitted.
  • Confirm window has elapsed — the timeout gate. See "Confirm Window" subsection below.
  • Contract is not paused.

Preconditions that confirm_milestone enforces but force_confirm_milestone intentionally skips:

  • No sequential-confirmation check: A later milestone whose own confirm window has elapsed can be force-confirmed even if earlier milestones are still open. This keeps the timeout path usable even if an earlier milestone is stuck in a dispute.
  • No retention time-gate re-check: force_confirm_milestone does not re-verify valid_after_ledger. A Retention milestone in ProofSubmitted status has, by definition, already been unlocked (either by unlock_milestone or because it was a Placement), so the time-gate is not re-tested.
  • No replacement paid-out deduction: Always pays the full gross share, matching batch_confirm_milestones and contrasting with single confirm_milestone.

Confirm Window (Timeout Condition)

The confirm window is a global admin-configured ledger delta stored under DataKey::ConfirmWindow. It is managed by two admin/read functions:

  • set_confirm_window — set_confirm_window(env, admin, ledgers). Admin only. Sets the confirm-window ledger delta and emits confirm_window_set.
  • get_confirm_window — get_confirm_window(env) -> u32. Read-only. Returns the active window; defaults to DEFAULT_CONFIRM_WINDOW_LEDGERS = 86_400 (≈ 5 calendar days at 5 s / ledger) if admin has never set one.

The force-confirm gate inside force_confirm_milestone is the strict greater-than inequality:

current_ledger  >  milestone.proof_submitted_at  +  confirm_window

If this does not hold, the call panics with ConfirmWindowNotElapsed. In particular, equality (==) is not sufficient: callers must wait one additional ledger past the deadline.

proof_submitted_at is populated by submit_proof at the moment the recruiter's proof hash is accepted, and is part of the on-chain Milestone struct.

Differences from single confirm_milestone (summary):

Aspect confirm_milestone force_confirm_milestone
Caller restriction company only. Any authenticated address (permissionless).
Gate Company signature. Confirm-window elapsed since proof_submitted_at.
Sequential rule Enforced. Skipped.
Retention time-gate re-checked Yes. No.
Replacement paid-out handling Deducted from gross share. Not deducted.
Authorization path company.require_auth(). caller.require_auth() with no role check.
Emitted event milestone_confirmed. milestone_force_confirmed (distinct symbol for off-chain indexers to distinguish timeout-driven payouts from genuine company approvals).

Payment / Side effects:

  • Identical transfer mechanics to confirm_milestone (gross share, platform fee deduction, recruiter / co-recruiter distribution) except without the replacement_paid_out subtraction.
  • Milestone → Confirmed; engagement → Completed if this closes the last outstanding milestone.
  • Emits milestone_force_confirmed with (milestone_index, payment).

Events

The contract emits Soroban events for all state transitions: engagement_created, milestone_unlocked, proof_submitted, proof_resubmitted, milestone_confirmed, engagement_completed, dispute_raised, arbiter_voted, dispute_resolved, replacement_requested, engagement_cancelled, early_exit_requested, early_exit_accepted, early_exit_rejected, engagement_expired, escrow_topped_up, amendment_proposed, amendment_accepted, amendment_rejected, platform_fee_collected, upgrade_proposed, upgrade_executed, and admin configuration events.


Project Structure

hiresettle-contract-1/
├── Cargo.toml              # Workspace root
├── contracts/
│   └── hiresettle/
│       ├── Cargo.toml      # Contract crate config
│       ├── Makefile        # Build helpers
│       └── src/
│           ├── lib.rs      # Main contract logic (~3500 lines)
│           └── test.rs     # Unit tests (~2000 lines)
├── TODO.md                 # Task tracking
└── README.md               # This file

Testing

Run the full test suite from the contract directory:

cd contracts/hiresettle && cargo test

Tests cover creation, proof submission, confirmation, disputes, arbiter voting, replacement flow, early exit, amendments, batch confirmations, auto-confirm, expiry, admin configuration, and edge cases for all validation rules.


Usage Example

// 1. Init contract
HireSettleContract::init(env, admin);

// 2. Create engagement
let config = EngagementConfig {
    metadata_hash: Some(String::from_str(&env, "Qm...")),
    co_recruiter: None,
    recruiter_split_bps: 10_000,
    contract_pdf_hash: Some(String::from_str(&env, "sha256:abc123...")),
};
HireSettleContract::create_engagement(
    env, "engagement-1", company, recruiter,
    arbiter_setup, token, 100_000_000, "Senior Engineer",
    milestones, retention_days, config
);

// 3. Recruiter unlocks & submits proof
HireSettleContract::unlock_milestone(env, "engagement-1", 1);
HireSettleContract::submit_proof(env, recruiter, "engagement-1", 1, "ipfs://QmProof...");

// 4. Company confirms → payment released
HireSettleContract::confirm_milestone(env, company, "engagement-1", 1);

Create a test engagement via CLI

stellar contract invoke \
  --id <CONTRACT_ID> \
  --source my-account \
  --network testnet \
  -- create_engagement \
  --engagement_id "ENG-TEST-001" \
  --company <COMPANY_ADDRESS> \
  --recruiter <RECRUITER_ADDRESS> \
  --arbiter_setup '{"arbiters":["<ARBITER_ADDRESS>"],"quorum":1}' \
  --token <USDC_SAC_ADDRESS> \
  --total_amount 5000000000 \
  --job_title "Senior Engineer" \
  --milestones '[...]' \
  --retention_days '[30, 90]'

USDC SAC on Testnet: CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA


Security Considerations

  • Authorization: Every state-changing function calls require_auth(). Recruiters cannot confirm their own milestones. Companies cannot cast arbiter votes.
  • No party/arbiter address overlap: create_engagement rejects company == recruiter, company appearing in the arbiter set, or recruiter appearing in the arbiter set. Without this check a company could name itself (or a colluding address) as arbiter and vote on its own disputes, or name itself as recruiter to self-confirm milestones.
  • Multi-arbiter quorum: Disputes require M-of-N arbiter votes to resolve. A single arbiter cannot unilaterally release or withhold payment — both approval and rejection require a configurable quorum. Duplicate votes from the same arbiter are rejected on-chain.
  • Token amounts are raw integer units, not decimal-aware: The token allowlist accepts any allowlisted SAC, not just USDC. total_amount, MinEngagementAmount, and all milestone payout math (amount * payment_percent / 100) operate on raw integer units of whichever token is used — the contract never reads a token's decimals(). Percentage splits are exact regardless of precision, but a single admin-wide minimum amount (set_min_amount) will represent a different real-world value across tokens of different precision (e.g. 7-decimal vs. 18-decimal tokens). Integrators are responsible for only allowlisting tokens of comparable precision, or adjusting the minimum accordingly, when using a token other than the reference 7-decimal USDC used throughout this README's examples.
  • Arbiter fee cap: The arbiter fee is capped at 200 bps (2%) to prevent excessive deduction from recruiter payouts on dispute approval.
  • Retention double-check: confirm_milestone() re-verifies valid_after_ledger even if unlock_milestone() was called, preventing a company from confirming a retention milestone before the window truly ends.
  • Replacement fee fairness: The Placement tranche paid to the recruiter is non-refundable. Only unreleased amounts are frozen. This is explicit in the contract and documented clearly so both parties understand the terms at engagement creation.
  • Ledger drift: The 5s/ledger assumption is approximate. Stellar's actual ledger time may vary slightly. The contract uses ledger sequence numbers — not timestamps — so the unlock is purely count-based. Production deployments should account for ~±5% drift in real-world retention windows.
  • Upgrade time-lock: Contract upgrades require a configurable time-lock (default 17,280 ledgers ≈ 1 day) between proposal and execution, giving stakeholders time to review before changes take effect.

Roadmap

  • Core escrow + milestone logic
  • Time-gated retention milestones (ledger-based unlock)
  • Replacement clause with clock reset (request_replacement: company-only, requires confirmed Placement; resets the Placement milestone, restarts retention clocks, panics on bad preconditions, and emits the replacement_requested event)
  • Dispute resolution via arbiter
  • Flexible milestone structure (2-milestone 50/50, 3-milestone, custom)
  • 11 unit tests
  • Multi-candidate engagements (multiple positions, one company-recruiter pair)
  • Partial payout on replacement (configurable replacement fee)
  • Contract upgrade mechanism
  • Mainnet deployment

License

MIT


Function: confirm_milestone

Called by the hiring company to confirm a ProofSubmitted milestone. Releases the milestone payment from escrow, splits off the platform fee, and re-checks retention timing. Panics on unauthorized caller or invalid milestone state. Emits milestone_confirmed, platform_fee_collected, and (on final milestone) engagement_completed.

About

No description, website, or topics provided.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages