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.
Before getting started, ensure you have:
- Rust (latest stable version)
- Cargo
- Soroban CLI
- Stellar CLI
- Git
git clone https://github.com/TrustHire/hiresettle-contract.git
cd hiresettle-contractcd contracts/hiresettle
cargo buildThe command above runs on your host target and is only useful for local development and editor tooling. To produce the artifact that actually gets deployed to Stellar, build for the wasm32v1-none target:
rustup target add wasm32v1-none # one-time setup
cargo build --target wasm32v1-none --releaseThis is equivalent to the Stellar CLI invocation used in CI and the Makefile:
stellar contract buildThe resulting WASM binary is emitted at target/wasm32v1-none/release/hiresettle.wasm. To optimize it for deployment:
stellar contract optimize --wasm target/wasm32v1-none/release/hiresettle.wasm
# or simply: make optimizeThe optimized artifact is target/wasm32v1-none/release/hiresettle.optimized.wasm.
Because Soroban runs contracts inside the Stellar WASM VM, the contract must compile to a freestanding WASM binary with no reliance on the host OS — hence the crate is annotated #![no_std] and built with the wasm32v1-none target, which ships no standard library and no OS interface. By contrast, cargo build and cargo test (the host-target commands above) compile natively so the tests can run in a normal process with the Soroban host emulated by the SDK.
cargo testThis walkthrough takes a single engagement from deployment to completion on Stellar testnet. A company escrows 100 XLM for a hire, the recruiter proves a placement and gets paid half, and after a one-day retention window, the recruiter gets paid the rest. Finally, the company rates the recruiter.
It uses four roles, each with its own key:
| Role | Does |
|---|---|
admin |
Deploys and initialises the contract |
company |
Funds escrow, confirms milestones, rates the recruiter |
recruiter |
Submits proof for each milestone and receives payouts |
arbiter |
Named on the engagement; only acts if a dispute is raised |
Want to see the whole flow in seconds instead of a day? The same lifecycle, with three milestones and simulated ledger time, runs as a unit test:
cd contracts/hiresettle && cargo test test_full_engagement_lifecycle -- --nocapture(seetest_full_engagement_lifecycleinsrc/test.rs).
Complete Setup & Installation first, then build the optimized artifact from contracts/hiresettle:
cd contracts/hiresettle
make optimizefor who in admin company recruiter arbiter; do
stellar keys generate "$who" --network testnet --fund
done
ADMIN=$(stellar keys address admin)
COMPANY=$(stellar keys address company)
RECRUITER=$(stellar keys address recruiter)
ARBITER=$(stellar keys address arbiter)Friendbot gives each account 10,000 XLM, which is plenty for the escrow and fees.
CONTRACT=$(stellar contract deploy \
--wasm target/wasm32v1-none/release/hiresettle.optimized.wasm \
--source admin --network testnet)
stellar contract invoke --id "$CONTRACT" --source admin --network testnet \
-- init --admin "$ADMIN"init sets the platform fee to 0 bps (treasury = admin) and the minimum engagement amount to 100_000 raw units. The token allowlist is off by default, so any token is accepted.
This walkthrough uses native XLM through its Stellar Asset Contract (7 decimals, so 1 XLM = 10_000_000). To use testnet USDC instead, see Create a test engagement via CLI.
TOKEN=$(stellar contract id asset --asset native --network testnet)The engagement has two milestones that must sum to 100%:
| Index | Name | Kind | Pays | Becomes provable |
|---|---|---|---|---|
| 0 | Candidate Placed |
Placement |
50% | Immediately (starts Pending) |
| 1 | 1-Day Retention |
Retention |
50% | After 1 day (starts Locked) |
retention_days holds one entry per Retention milestone, in order. The contract computes each retention unlock ledger as current_ledger + days × 17_280. For milestone 1, prerequisites: [0] means it cannot be confirmed before milestone 0.
stellar contract invoke --id "$CONTRACT" --source company --network testnet \
-- create_engagement \
--engagement_id "ENG-DEMO-001" \
--company "$COMPANY" \
--recruiter "$RECRUITER" \
--arbiter_setup "{\"arbiters\":[\"$ARBITER\"],\"quorum\":1,\"weights\":null}" \
--token "$TOKEN" \
--total_amount 1000000000 \
--job_title "Senior Engineer" \
--milestones '[
{"name":"Candidate Placed","payment_percent":50,"kind":"Placement",
"valid_after_ledger":0,"proof_hash":"","status":"Pending",
"proof_submitted_at":0,"replacement_paid_out":"0","prerequisites":[]},
{"name":"1-Day Retention","payment_percent":50,"kind":"Retention",
"valid_after_ledger":0,"proof_hash":"","status":"Locked",
"proof_submitted_at":0,"replacement_paid_out":"0","prerequisites":[0]}
]' \
--retention_days '[1]' \
--config '{"metadata_hash":null,"co_recruiter":null,"recruiter_split_bps":10000,
"contract_pdf_hash":null,"referrer":null,"tags":null,"is_public":false,
"stream_duration_ledgers":null,"recruiter_bond_amount":null,
"bundle_id":null,"fund_from_pool":false}'This call transfers 100 XLM from the company to the contract. The contract sets each milestone's valid_after_ledger and status itself, so pass 0, "" and the starting status shown in the command. recruiter_split_bps: 10000 sends the whole payout to the recruiter because there is no co-recruiter.
Check the result:
stellar contract invoke --id "$CONTRACT" --network testnet --source admin \
-- get_engagement_summary --engagement_id "ENG-DEMO-001" # status: Active, released_amount: 0
stellar contract invoke --id "$CONTRACT" --network testnet --source admin \
-- get_all_milestone_statuses --engagement_id "ENG-DEMO-001" # ["Pending","Locked"]A proof is any non-empty string of up to 200 characters, usually an IPFS CID or URI pointing to the evidence (e.g. a signed offer letter). A proof hash cannot be reused on another milestone in the same engagement.
stellar contract invoke --id "$CONTRACT" --source recruiter --network testnet \
-- submit_proof \
--recruiter "$RECRUITER" \
--engagement_id "ENG-DEMO-001" \
--milestone_index 0 \
--proof_hash "ipfs://QmOfferLetterDemo"Milestone 0 moves to ProofSubmitted.
stellar contract invoke --id "$CONTRACT" --source company --network testnet \
-- confirm_milestone \
--company "$COMPANY" \
--engagement_id "ENG-DEMO-001" \
--milestone_index 0The contract pays 1_000_000_000 × 50 / 100 = 500_000_000 (50 XLM) to the recruiter, minus the platform fee (0 here). Milestone 0 is now Confirmed, and get_escrow_balance returns 500000000.
If the company does not respond within the confirm window (~5 days), anyone can call force_confirm_milestone to pay the recruiter. If the company disagrees with the proof, it calls raise_dispute instead, and the arbiter votes (see Dispute Resolution Flow).
Milestone 1 stays Locked until its unlock ledger (~17,280 ledgers, about one day). Check how long is left:
stellar contract invoke --id "$CONTRACT" --network testnet --source admin \
-- ledgers_until_unlock --engagement_id "ENG-DEMO-001" --milestone_index 1When this returns 0, anyone can unlock it. unlock_milestone is permissionless, so a keeper bot usually runs it:
stellar contract invoke --id "$CONTRACT" --source admin --network testnet \
-- unlock_milestone --engagement_id "ENG-DEMO-001" --milestone_index 1Milestone 1 moves from Locked to Pending.
stellar contract invoke --id "$CONTRACT" --source recruiter --network testnet \
-- submit_proof \
--recruiter "$RECRUITER" \
--engagement_id "ENG-DEMO-001" \
--milestone_index 1 \
--proof_hash "ipfs://QmRetentionPayrollDemo"
stellar contract invoke --id "$CONTRACT" --source company --network testnet \
-- confirm_milestone \
--company "$COMPANY" \
--engagement_id "ENG-DEMO-001" \
--milestone_index 1This releases the remaining 50 XLM. All milestones are now Confirmed, so the engagement becomes Completed and emits engagement_completed.
A company can rate the recruiter once per completed engagement, from 1 to 5 stars:
stellar contract invoke --id "$CONTRACT" --source company --network testnet \
-- rate_recruiter --company "$COMPANY" --engagement_id "ENG-DEMO-001" --stars 5Verify the final state:
stellar contract invoke --id "$CONTRACT" --network testnet --source admin \
-- get_engagement_summary --engagement_id "ENG-DEMO-001" # status: Completed, released_amount: 1000000000
stellar contract invoke --id "$CONTRACT" --network testnet --source admin \
-- get_escrow_balance --engagement_id "ENG-DEMO-001" # 0
stellar contract invoke --id "$CONTRACT" --network testnet --source admin \
-- get_recruiter_rating --recruiter "$RECRUITER" # {"rating_count":1,"total_stars":5}
stellar contract invoke --id "$TOKEN" --network testnet --source admin \
-- balance --id "$RECRUITER"The recruiter's XLM balance is about 100 XLM above its starting 10,000 XLM. It is slightly less than that because the recruiter paid transaction fees for its two submit_proof calls.
create_engagement ─► submit_proof(0) ─► confirm_milestone(0) ─► [~1 day] ─► unlock_milestone(1)
(escrow 100) Pending→Proof 50 XLM → recruiter Locked→Pending
│
rate_recruiter ◄── Completed ◄── confirm_milestone(1) ◄── submit_proof(1) ◄───────┘
50 XLM → recruiter
- Things went wrong? Look up
raise_dispute/cast_arbiter_vote,request_replacement,request_early_exit,cancel_engagementandexpire_engagementin Which write function do I call?. - Splitting a fee between two recruiters? Set
co_recruiterandrecruiter_split_bpsin the config (see Key Features). - A call panicked? Find the message in Errors.
- Building an indexer or UI? See Events and Which query do I call?.
The contract is organized into focused feature modules, all re-exported from lib.rs. The diagram below shows the module-to-module dependency flow (coarse-grained, function-level dependencies omitted).
flowchart TD
%% Foundational layer (no internal deps)
constants["constants<br/>(numeric defaults)"]
errors["errors<br/>(panic messages)"]
%% Core types & storage keys
enums["enums<br/>(MilestoneStatus, EngagementStatus,<br/>DataKey, ConfigKey, ExtKey, DataKey2)"]
types["types<br/>(Engagement, Milestone, ArbiterVoteRecord,<br/>PlatformFee, FeeTier, ...)"]
%% Internal utilities used by all feature modules
helpers["helpers<br/>(storage access, auth guards,<br/>fee resolution, validation, TTL)"]
%% Feature modules (alphabetical)
admin["admin<br/>(init, config setters, pause, upgrade)"]
engagement["engagement<br/>(create, lifecycle, top-up)"]
milestones["milestones<br/>(unlock, proof, confirm, batch, force)"]
disputes["disputes<br/>(raise, vote, escalate, super-arbiter)"]
transfers["transfers<br/>(cosigners, role transfer)"]
queries["queries<br/>(read-only getters)"]
arbiter_pool["arbiter_pool<br/>(pool mgmt, random panel draw)"]
payout["payout<br/>(swap adapter, token preference)"]
ratings["ratings<br/>(star ratings, summaries)"]
bonds["bonds<br/>(collateral bonds, bundles)"]
features["features<br/>(pooled escrow, emergency pause, rebate)"]
%% Dependency edges
constants --> helpers
errors --> helpers
enums --> types
enums --> helpers
types --> helpers
constants --> admin
errors --> admin
helpers --> admin
types --> admin
enums --> admin
constants --> engagement
errors --> engagement
helpers --> engagement
types --> engagement
enums --> engagement
constants --> milestones
errors --> milestones
helpers --> milestones
types --> milestones
enums --> milestones
errors --> disputes
helpers --> disputes
types --> disputes
enums --> disputes
helpers --> transfers
types --> transfers
enums --> transfers
helpers --> queries
types --> queries
enums --> queries
constants --> arbiter_pool
helpers --> arbiter_pool
types --> arbiter_pool
enums --> arbiter_pool
types --> payout
enums --> payout
helpers --> ratings
types --> ratings
enums --> ratings
helpers --> bonds
types --> bonds
enums --> bonds
constants --> features
helpers --> features
types --> features
enums --> features
%% Styling
classDef foundational fill:#f5f5f5,stroke:#999,stroke-width:1px;
classDef core fill:#e8f4fd,stroke:#2196f3,stroke-width:2px;
classDef internal fill:#fff3e0,stroke:#ff9800,stroke-width:2px;
classDef feature fill:#e8f5e9,stroke:#4caf50,stroke-width:1px;
class constants,errors foundational;
class enums,types core;
class helpers internal;
class admin,engagement,milestones,disputes,transfers,queries,arbiter_pool,payout,ratings,bonds,features feature;
- Engagement: An agreement or project contract initiated between parties on the platform.
- Milestone: A specific checkpoint or deliverable within an engagement that triggers payouts upon approval.
- Retention: Funds held in escrow until all engagement conditions or warranty periods are satisfied.
- Cosigner: An authorized third party required to sign or approve specific actions or state transitions.
- Super-arbiter: A high-level administrative entity with authority to resolve escalations and dispute deadlocks.
- Quorum: The minimum threshold of votes or signatures required to execute a governance or arbitration action.
- Amendment: A formal modification proposed and agreed upon to update the terms of an active engagement.
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.
- 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 (
PlacementorRetention), time-gate ledger, proof hash, and status (Locked→Pending→ProofSubmitted→Confirmed/Disputed→Resolved). - 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; ifreject_votes > arbiters.len() - quorum, the proof is cleared and the milestone returns toPending.
Each milestone transitions through the following states. For Retention milestones the initial state is Locked; Placement milestones start in Pending.
┌────────┐ unlock ┌─────────┐ submit ┌───────────────┐
│ Locked │ ────────> │ Pending │ ────────> │ ProofSubmitted │
└────────┘ └─────────┘ └───────┬───────┘
^ │
│ ┌────────────────────┼────────────────┐
│ │ │ │
│ v v │
│ ┌──────────┐ ┌──────────┐ │
│ │ Resolved │ │ Disputed │ │
│ └──────────┘ └─────┬────┘ │
│ │ │
│ ┌───────────────┼────────────┐ │
│ │ │ │ │
│ v v │ │
│ ┌──────────────────┐ ┌───────────┐ │ │
│ │ Reject quorum │ │ Approve │ │ │
│ │ (back to Pending)│ │ quorum │ │ │
│ └──────────────────┘ │ (released)│ │ │
│ └─────┬─────┘ │ │
│ │ │ │
└───────────────────────────────┼───────────┘ │
│ │
v │
┌──────────┐ │
│ Confirmed│ <───────┘
└──────────┘
force_confirm_milestone can also move ProofSubmitted → Confirmed
after the confirm window elapses.
batch_confirm_milestones can confirm multiple ProofSubmitted
milestones atomically.
Dispute Resolution Flow (via cast_arbiter_vote):
- Company calls
raise_dispute→ milestone moves toDisputed. - Arbiters call
cast_arbiter_vote(approve=true/false). - When
approve_votes >= quorum→ payment released, milestone →Confirmed. - When
reject_votes > arbiters.len() - quorum→ proof cleared, milestone returns toPending. - Vote tally stored as
ArbiterVoteRecord; duplicate votes rejected.
| 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. |
| Cosigners | A company or recruiter can register a secondary cosigner address equally authorized to act on its behalf, without sharing the primary key. |
The full on-chain record:
id,company,recruiter,arbiters,quorum,tokentotal_amount,released_amount,job_titlemetadata_hash(optional IPFS CID),contract_pdf_hash(optional attestation hash)created_at_ledger,last_activity_ledgermilestones(Vec),status,co_recruiter,recruiter_split_bps
name,payment_percent,kind(Placement | Retention),valid_after_ledgerproof_hash,status(Locked | Pending | ProofSubmitted | Confirmed | Disputed | Resolved),proof_submitted_at
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)is_public(bool) — opt the engagement intoget_public_engagement_ids. Set once at creation and cannot be changed afterwards. See Engagement visibility.pull_payouts(bool) — credit payouts to a claimable balance instead of transferring them. Defaultfalse. See Pull payouts.require_recruiter_acceptance(bool) — create the engagement as anOfferedoffer the recruiter must accept. Defaultfalse. See Recruiter acceptance.
Active → Completed | Cancelled | Expired | ReplacementRequested | ExitRequested
Offered → Active (recruiter accepts) | Cancelled (company withdraws) | Expired. Only engagements created with require_recruiter_acceptance start as Offered.
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.
pub struct ConfigSnapshot {
pub version: String,
pub admin: Address,
pub admin_renounced: bool,
pub paused: bool,
pub platform_fee_bps: u32,
pub platform_fee_treasury: Address,
pub arbiter_fee_bps: u32,
pub super_arbiter: Option<Address>,
pub confirm_window_ledgers: u32,
pub dispute_window_ledgers: u32,
pub proof_cooldown_ledgers: u32,
pub due_soon_window_ledgers: u32,
pub amendment_ttl_ledgers: u32,
pub extension_ttl_ledgers: u32,
pub inactivity_timeout_ledgers: u32,
pub storage_ttl_extend_to: u32,
pub upgrade_lock_duration_ledgers: u32,
pub ledgers_per_day: u32,
pub max_milestones: u32,
pub max_retention_days: u32,
pub max_replacements: u32,
pub max_active_per_company: u32,
pub max_proof_hash_length: u32,
pub min_engagement_amount: i128,
pub token_allowlist_enabled: bool,
pub min_quorum_ratio_bps: u32,
}Every admin-configurable contract parameter in one read-only struct, returned by
get_config_snapshot. Each field mirrors the value its dedicated getter would
return at the same ledger, including the defaults applied to parameters that were
never explicitly set.
The min_quorum_ratio_bps field enforces a minimum ratio between the quorum and
the total number of arbiters (panel size) when creating an engagement. This prevents
configurations where an unreasonably small quorum could compromise dispute resolution
quality.
- Configuration:
set_min_quorum_ratio_bps(admin, bps)— admin only. Value is in basis points (0–10000). - Query:
get_min_quorum_ratio_bps()— returns the current ratio in basis points. Default is 0 (no minimum). - Enforcement: At
create_engagementtime, the contract checks:If this condition fails, the transaction panics with "quorum below minimum ratio".quorum * 10_000 >= arbiters.len() * min_quorum_ratio_bps
Example: With min_quorum_ratio_bps set to 5000 (50%), a 5-arbiter panel must have
a quorum of at least 3 (since 3/5 = 60% ≥ 50%). A quorum of 1 or 2 would be rejected.
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.
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.
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).
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).
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.
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).
The contract can deduct a configurable fee from every milestone payout before the recruiter is paid.
- Set Fee:
set_platform_fee(admin, bps, treasury)— admin only.bpsis capped at 500 (5%); exceeding it panics withFeeTooHigh. - Read Fee:
get_platform_fee()— returns(bps, treasury). Permissionless. - Default: 0 bps, treasury = admin, set at
init. No fee is taken until an admin raises it.
On confirm_milestone, batch_confirm_milestones, and force_confirm_milestone:
Platform fee is deducted from each milestone payment before release to the recruiter.
gross_share = total_amount × payment_percent ÷ 100 fee_amount = gross_share × platform_fee_bps ÷ 10_000 net_payment = gross_share − fee_amount // this is what the recruiter actually receives
fee_amount is transferred to treasury; a platform_fee_collected event (milestone_index, fee_amount, treasury) is emitted whenever fee_amount > 0 (no event when the fee is 0). Approved disputes (cast_arbiter_vote, cast_arbiter_split_vote, super_arbiter_resolve) also deduct the platform fee, then take the arbiter fee from what is left (see set_arbiter_fee). Every payout path resolves the rate the same way; see Which fee reductions apply on each payout path. When multiple treasuries are configured, the fee is split across them instead and one platform_fee_collected event is emitted per treasury.
When fee tiers are configured, platform_fee_bps in the formula above is the resolved rate for that engagement's total_amount (see Fee tiers), not necessarily the base PlatformFee.bps returned by get_platform_fee().
Admin can scale the platform fee down for larger engagements instead of charging every size the same flat bps. Each bracket is a FeeTier. Tiers are optional: an empty list means the base PlatformFee.bps applies to every engagement.
- Set Tiers:
set_fee_tiers(admin, tiers)— admin only. Replaces the entire list. Pass an empty vector to clear all tiers (back to a flat fee). Emitsfee_tiers_setwith payloadtiers.len(). - Read Tiers:
get_fee_tiers()— returnsVec<FeeTier>. Permissionless. Empty vector = no tiering. - Default: no tiers stored.
get_platform_fee()still returns the base(bps, treasury)even when tiers are set; it does not return the resolved rate for a given amount.
pub struct FeeTier {
pub threshold: i128, // inclusive min engagement.total_amount
pub bps: u32, // platform fee for this bracket (basis points)
}At fee-calculation time the contract walks the stored list from highest threshold to lowest and uses the first tier whose threshold is <= engagement.total_amount. If no tier matches, the base PlatformFee.bps applies.
Example with base fee 200 bps and two tiers (10_000_000, 150) then (100_000_000, 100):
total_amount |
Rate used |
|---|---|
5_000_000 |
200 (below every threshold → base) |
10_000_000 |
150 |
100_000_000 |
100 (highest matching threshold wins) |
set_fee_tiers panics unless all of the following hold:
| Rule | Panic |
|---|---|
| At most 10 tiers | too many fee tiers |
Each bps ≤ current base PlatformFee.bps |
tier bps exceeds base platform fee |
Each threshold > 0 |
tier threshold must be positive |
| Thresholds strictly ascending | tiers must be sorted by ascending threshold |
The 500 bps FeeTooHigh cap is enforced on set_platform_fee, not here. A tier cannot charge more than the current base fee (tiers are discounts for larger size, not surcharges). Caller must also be admin and the contract must not be paused (ContractPaused, NoAdmin, unauthorized).
By default an engagement's tier is re-resolved against the live tier list at every payout, so a later set_fee_tiers call changes the rate of engagements already in flight. Setting EngagementConfig::snapshot_fee_tier = true resolves the tier once in create_engagement and reuses it for every later payout on that engagement. When no tier matches, the base rate at creation is frozen instead.
Only the tier lookup is frozen. At payout time a fee waiver still zeroes the fee, and the live referral discount is still subtracted from the frozen rate. get_fee_tier_snapshot(engagement_id) returns the frozen bps, or None for engagements that track the live tiers. The snapshot is not re-resolved when top_up_escrow grows total_amount.
Engagements may carry an optional referrer address (set at creation via
Engagement / EngagementConfig). That field alone does not change fees.
Admin setup:
add_referrer/remove_referrer/get_referrers— maintain the recognised referrer list stored underDataKey::Referrers.set_referral_discount_bps(admin, bps)/get_referral_discount_bps()— configure how many basis points to subtract from the platform fee when the engagement’sreferreris on that list (max 500 bps, same cap as the platform fee). Default discount is0.
On every payout path the contract computes an effective platform-fee rate as follows. An admin fee waiver overrides all of them.
- Start from the base rate: the company override
if one is set, otherwise the per-token override (or the global platform fee
bps) run through the matching fee tier whenset_fee_tiersis in use — largertotal_amountcan qualify for a lower tierbps. - If
engagement.referrerisSome(addr)andaddris in the recognised referrer list, subtractreferral_discount_bpsfrom that rate. - Floor at
0(the platform fee never goes negative).
Then:
fee_amount = payment × effective_bps ÷ 10_000
net_payment = payment − fee_amount
If there is no referrer, the referrer is not on the list, or the discount is 0, the effective rate is unchanged from the base / tier rate.
Referral discount applies only to the platform fee path above. Arbiter fees from dispute resolution
(cast_arbiter_vote / set_arbiter_fee) are separate and are not reduced by the referral discount.
pub struct PlatformFee {
pub bps: u32, // fee in basis points (max 500 = 5%)
pub treasury: Address, // fee recipient
}The admin can switch off the platform fee for a single engagement, for example a promotional or goodwill placement, without changing the contract-wide fee.
| Function | Caller | Description | Panics |
|---|---|---|---|
waive_platform_fee(admin, engagement_id) |
Admin | Waive the platform fee for every future payout on this engagement. | NoAdmin, unauthorized, engagement not found |
is_fee_waived(engagement_id) → bool |
Anyone | Return true if a waiver has been recorded. Unknown IDs return false instead of panicking. |
— |
Behaviour:
- It overrides every other fee setting. While a waiver is in place the effective platform-fee rate is
0, whatever the basePlatformFee.bps, the matching fee tier or the referral discount. This applies on every path that pays out a milestone:confirm_milestone,batch_confirm_milestones,force_confirm_milestone,cast_arbiter_vote,cast_arbiter_split_vote,super_arbiter_resolveandresolve_escalation_timeout. - Only the platform fee is waived. On an approved dispute the arbiter fee (
set_arbiter_fee) is still deducted, now from the full gross share. - No rebate activity. With a fee of
0,collect_platform_feenever runs, so the company's fee-rebate balance (set_fee_rebate_bps/redeem_company_rebate) is neither drawn down nor credited for waived payouts. - It is not retroactive. Fees already sent to the treasury before the waiver are not refunded.
- It is permanent. No function removes a waiver. To charge fees again you would need a new engagement.
- It is idempotent. Waiving an engagement that is already waived changes no state, but it emits the event again and refreshes the entry's TTL.
- It is not blocked by the global pause. The function only checks admin authority, so it works while the contract is paused. It works on an engagement in any status.
Storage: DataKey::FeeWaived(engagement_id) → true, persistent, TTL extended to 6_300_000 ledgers on each call.
Event: ("platform_fee_waived", engagement_id) with data (admin,).
// Admin waives the fee; later confirmations pay the recruiter the full gross share.
client.waive_platform_fee(&admin, &engagement_id);
assert!(client.is_fee_waived(&engagement_id));The admin can give one company a negotiated base rate that applies to every engagement it owns, on every payout path (confirm_milestone, batch_confirm_milestones, force_confirm_milestone, and every dispute resolution).
| Function | Caller | Description | Panics |
|---|---|---|---|
set_company_platform_fee(admin, company, bps) |
Admin | Set the company's base rate. Same 500 bps cap as set_platform_fee. |
ContractPaused, NoAdmin, unauthorized, FeeTooHigh |
remove_company_platform_fee(admin, company) |
Admin | Remove the override; the company falls back to the token / tier / global resolution. | ContractPaused, NoAdmin, unauthorized |
get_company_platform_fee(company) → Option<u32> |
Anyone | Return the override, if any. | — |
Base-rate precedence, highest first:
- Engagement fee waiver — the fee is
0. - Company override.
- Per-token override (
set_token_platform_fee) run through the fee tiers (live, or the snapshot). As before, a matching tier replaces the token base. - Global
PlatformFee.bpsrun through the fee tiers.
The company override also replaces a tier snapshot. The referral discount, the fee floor and the rebate still apply on top. The override follows the engagement's current company, so after transfer_company the new company's override applies.
Events: ("company_platform_fee_set",) with (company, bps); ("company_platform_fee_removed",) with company.
By default every fee goes to PlatformFee.treasury. The admin can instead split fees across several weighted treasuries, for example an operations wallet, an insurance fund and a grants pool.
| Function | Caller | Description | Panics |
|---|---|---|---|
set_fee_treasuries(admin, treasuries) |
Admin | Replace the list with Vec<(Address, u32)> of (treasury, weight_bps). At most 10 entries, no zero weights, no duplicate addresses, weights summing to exactly 10 000. |
ContractPaused, NoAdmin, unauthorized, EmptyTreasuryList, TooManyTreasuries, InvalidTreasuryWeight, DuplicateTreasury, TreasuryWeightsMustSumTo10000 |
clear_fee_treasuries(admin) |
Admin | Remove the list; fees go to PlatformFee.treasury again. |
ContractPaused, NoAdmin, unauthorized |
get_fee_treasuries() → Vec<(Address, u32)> |
Anyone | Return the list; empty when unset. | — |
The amount owed to the treasury (after any fee rebate credit from set_fee_rebate_bps) is split like this:
share_i (i ≥ 1) = amount × weight_i ÷ 10_000 (rounded down)
share_0 = amount − Σ share_i (the first treasury gets the dust)
So the shares always add up to the full fee. While a list is set, PlatformFee.treasury receives nothing unless it is in the list, and get_platform_fee() still returns it unchanged.
Events: one ("platform_fee_collected", engagement_id) per treasury, in list order, with (milestone_index, share, treasury). share is what that treasury received. With no list set, the single event still reports (milestone_index, fee_amount, treasury) as before. set_fee_treasuries emits ("fee_treasuries_set",) with the list; clear_fee_treasuries emits ("fee_treasuries_cleared",).
Contract storage is keyed by three #[contracttype] enums, all defined in
contracts/hiresettle/src/enums.rs. Soroban caps a contract-type union at 50
variants, so DataKey holds the original keys, admin-tunable scalars are
nested under DataKey::Config(ConfigKey), and newer features use DataKey2.
"Instance" keys live in the contract instance entry and share its TTL. "Persistent" keys are separate ledger entries, each with its own TTL.
| Variant | Storage | Holds |
|---|---|---|
Engagement(engagement_id) |
Persistent | Full Engagement record |
Admin |
Instance | Current admin address |
PendingArbiter(engagement_id) |
Persistent | Pending arbiter succession nomination |
PlatformFee |
Persistent | Base platform fee: bps and treasury |
Paused |
Persistent | Global pause flag |
PendingAdmin |
Persistent | Nominated admin successor |
ProposedRecruiterTransfer(engagement_id) |
Persistent | Proposed new recruiter awaiting company acceptance (#44) |
LastProofAt(engagement_id, milestone_index) |
Persistent | Ledger of the last proof submission, for the resubmission cooldown |
ArbiterVotes(engagement_id, milestone_index) |
Persistent | Running arbiter vote tally for a dispute |
EngagementCount |
Instance | Total engagements ever created (#34) |
CompanyEngagements(company) |
Persistent | Company's engagement IDs, in creation order (#35) |
RecruiterEngagements(recruiter) |
Persistent | Recruiter's engagement IDs, in creation order (#36) |
AllowedTokens |
Persistent | Token allowlist (#26) |
AllowlistEnabled |
Persistent | Whether the token allowlist is enforced (#26) |
DisputeReason(engagement_id, milestone_index) |
Persistent | Dispute reason string (#50) |
ReplacementReason(engagement_id, replacement_index) |
Persistent | Reason code for a replacement request, indexed from 0 (#51) |
ReplacementCount(engagement_id) |
Persistent | Replacements requested so far; the next replacement_index (#51) |
Version |
Persistent | Contract version label (#16) |
PendingUpgrade |
Instance | Pending WASM upgrade proposal (#69) |
AdminRenounced |
Instance | Set once the admin role has been permanently renounced (#59) |
CompanyActiveCount(company) |
Persistent | Company's currently active (non-terminal) engagements |
CompanyCosigner(company) |
Persistent | Cosigner allowed to act for the company (#254) |
RecruiterCosigner(recruiter) |
Persistent | Cosigner allowed to act for the recruiter (#257) |
FeeTiers |
Persistent | Vec<FeeTier> size-based fee brackets (#250) |
SuperArbiter |
Instance | Super-arbiter address for escalated disputes (#246) |
DisputeRaisedAt(engagement_id, milestone_index) |
Persistent | Ledger the dispute was raised, for the dispute window (#246) |
EscalatedDispute(engagement_id, milestone_index) |
Persistent | Whether the dispute was escalated to the super-arbiter (#246) |
TagEngagements(tag) |
Persistent | Engagement IDs carrying a tag (#248, #249) |
DueSoonNotified(engagement_id, milestone_index) |
— | Declared for the once-only milestone_due_soon event (#241); not currently read or written |
EngagementPaused(engagement_id) |
Persistent | Per-engagement quarantine flag, independent of Paused (#239) |
EngagementPauseReason(engagement_id) |
Persistent | Last reason given to pause_engagement; kept after unpause (#327) |
AllEngagements |
Persistent | Every engagement ID ever created; backs get_engagement_ids_by_status (#237) |
Referrers |
Persistent | Recognised referrer addresses (#251) |
Config(ConfigKey) |
Instance or persistent | One admin-tunable scalar; see ConfigKey |
EscalatedAt(engagement_id, milestone_index) |
Persistent | Ledger of escalation, for the super-arbiter response deadline (#318) |
SuperArbiterResolutionCount |
Instance | Disputes concluded through super-arbiter escalation (#317) |
FeeWaived(engagement_id) |
Persistent | Platform fee waived for this engagement (#335) |
MilestonePendingSince(engagement_id, milestone_index) |
Persistent | Ledger a placement milestone last re-entered Pending; absent means since creation (#465) |
NoShowForfeited(engagement_id) |
Persistent | Shares forfeited by trigger_no_show and not yet refunded (#465) |
StreamedPayout(engagement_id, milestone_index) |
Persistent | Vesting record for a streamed milestone payout (#466) |
ArbiterPool |
Persistent | Admin-curated pool for random arbiter panels (#467) |
ArbiterStats(arbiter) |
Persistent | Arbiter dispute-response history (#468) |
Ext(ExtKey) |
— | Reserved nested key space; ExtKey is declared but not currently used |
Each variant is stored as DataKey::Config(ConfigKey::…).
| Variant | Storage | Holds |
|---|---|---|
ProofCooldown |
Instance | Ledgers between proof resubmissions (default 2 880) |
LedgersPerDay |
Instance | Ledgers-per-day constant (#41) |
ConfirmWindow |
Instance | Confirm window in ledgers (default 86 400) |
DisputeWindow |
Instance | Dispute window in ledgers (default 51 840) |
MinEngagementAmount |
Persistent | Minimum engagement amount in raw token units (#17) |
UpgradeLockDuration |
Instance | Upgrade time-lock in ledgers (default 17 280, #69) |
MaxProofHashLength |
Instance | Max proof hash length in characters (default 200, #68) |
ArbiterFee |
Instance | Arbiter fee in bps, max 200 (#52) |
MaxActivePerCompany |
Instance | Max simultaneous active engagements per company (default 50) |
MaxReplacements |
Instance | Max replacements per engagement (default 3, #31) |
ReferralDiscountBps |
Persistent | Referral discount in bps (#251) |
SuperArbiterResponseWindow |
Instance | Super-arbiter deadline before auto-resolving for the recruiter (#318) |
TokenMinAmounts |
Persistent | Map<Address, i128> of per-token minimum amounts (#366) |
NoShowDeadline |
Instance | Recruiter no-show deadline in ledgers; 0 disables it (#465) |
SwapAdapter |
Instance | Trusted swap adapter for payout-token conversion (#458) |
ProofCooldownDiscount |
Instance | Rating-based proof cooldown discount curve (#470) |
BondForfeitBps |
Instance | Share of a recruiter bond forfeited on unresolved rejections (default 10 000, #459) |
AmendmentTTL |
Instance | Amendment proposal TTL in ledgers (default 17 280) |
FeeRebateBps |
Persistent | Share of each platform fee credited as a company rebate (#475) |
EmergencyVoteWindow |
Persistent | Emergency pause vote window in ledgers (#474) |
ArbiterVoteDeadline |
Instance | Ledgers after a dispute is raised before non-voting arbiters can be recused; 0 disables it (#560) |
All DataKey2 entries are persistent.
| Variant | Holds |
|---|---|
ArbiterSplitVotes(engagement_id, milestone_index) |
Split-vote tally for a dispute (#462) |
SplitVotingEnabled(engagement_id) |
Whether split voting is on (#462) |
SplitWithheld(engagement_id) |
Share withheld by split-vote resolutions, pending refund (#462) |
ArbiterVoteDelegate(engagement_id, arbiter) |
Vote delegate for an arbiter slot (#463) |
RecruiterPayoutToken(recruiter) |
Recruiter's preferred payout token (#458) |
DisputeWindowProposal(engagement_id) |
Pending dispute-window override proposal (#469) |
DisputeWindowOverride(engagement_id) |
Accepted dispute-window override in ledgers (#469) |
EngagementRated(engagement_id) |
Set once the company has rated the recruiter (#470) |
RecruiterRating(recruiter) |
Aggregated star ratings (#470) |
RecruiterBond(engagement_id) |
Recruiter collateral bond (#459) |
Bundle(bundle_id) |
Shared arbiter panel for a bundle (#464) |
BundleEngagements(bundle_id) |
Engagement IDs in a bundle, in creation order (#464) |
RecruiterVerified(recruiter) |
Admin-set verification badge; absent means false (#476) |
SplitAmendmentProposal(engagement_id) |
Pending co-recruiter split amendment (#471) |
SplitAmendmentLog(engagement_id) |
Accepted co-recruiter split amendments (#471) |
RecusedArbiters(engagement_id, milestone_index) |
Arbiters who recused from a dispute (#477) |
CompanyBalance(company, token) |
Pooled escrow balance (#472) |
PoolFunded(engagement_id) |
Whether the engagement was funded from the pool (#472) |
ConfigCosigner |
Second signer for sensitive admin setters (#473) |
SensitiveFunctions |
Setter function IDs that need the config cosigner (#473) |
PendingConfigChange(change_id) |
Pending cosigner-gated config change (#473) |
NextConfigChangeId |
Counter for pending config change IDs (#473) |
EmergencySigners |
Emergency M-of-N signer set and threshold (#474) |
EmergencyVotes(engagement_id) |
Emergency pause vote tally; empty string means a global pause (#474) |
CompanyRebate(company, token) |
Redeemable fee rebate balance (#475) |
ReplacementRecord(engagement_id, replacement_index) |
Who requested a replacement and at which ledger (#501) |
DisputeHistory(engagement_id) |
Every dispute raised, kept after resolution; last 50 (#501) |
StatusHistory(engagement_id) |
Every engagement status transition; last 50 (#501) |
FeeTierSnapshot(engagement_id) |
Fee bps frozen at creation for snapshot_fee_tier engagements (#505) |
CoRecruiterBond(engagement_id) |
Co-recruiter collateral bond (#506) |
RecruiterClaimable(recipient, token) |
Payouts credited by pull_payouts engagements, not yet claimed (#547) |
PullPayouts(engagement_id) |
Whether the engagement credits payouts instead of pushing them (#547) |
FeeTreasuries |
Weighted fee treasuries Vec<(Address, u32)> (#540) |
CompanyPlatformFee(company) |
Per-company base platform-fee override (#539) |
VoteDeadlineEnforced(engagement_id, milestone_index) |
Set when enforce_vote_deadline left a dispute undecided, so it can be escalated without waiting out the dispute window (#560) |
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 —
propose_amendment(proposer, engagement_id, milestone_index, new_payment_percent)- Caller must be the engagement's
companyorrecruiter; must sign the transaction. new_payment_percentis 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_proposedevent. - The pending state is stored as an
AmendmentProposalstruct underDataKey::AmendmentProposal(engagement_id, milestone_index).
- Caller must be the engagement's
-
Accept —
accept_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_percentis updated to the proposed value immediately. - An
AmendmentEntryis 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_acceptedevent.
-
Reject —
reject_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_rejectedevent with reasondeclined.
The contract exposes several admin-configurable time-based constants (TTLs, windows, and durations). The table below lists every such constant, its default value, and the setter/getter pair used to manage it. All values are denominated in ledgers (≈ 5 s each) unless otherwise noted.
| Constant | Default (ledgers) | Approx. Duration | Setter | Getter |
|---|---|---|---|---|
storage_ttl_extend_to |
1,036,800 | ~60 days | set_storage_ttl_extend_to(admin, ledgers) |
get_storage_ttl_extend_to() |
confirm_window |
86,400 | ~5 days | set_confirm_window(admin, ledgers) |
get_confirm_window() |
dispute_window |
51,840 | ~3 days | set_dispute_window(admin, ledgers) |
get_dispute_window() |
amendment_ttl |
17,280 | ~1 day | set_amendment_ttl(admin, ledgers) |
get_amendment_ttl() |
extension_ttl |
17,280 | ~1 day | set_extension_ttl(admin, ledgers) |
get_extension_ttl() |
upgrade_lock_duration |
17,280 | ~1 day | set_upgrade_lock_duration(admin, ledgers) |
get_upgrade_lock_duration() |
inactivity_timeout |
1,036,800 | ~60 days | set_inactivity_timeout_ledgers(admin, ledgers) |
get_inactivity_timeout_ledgers() |
due_soon_window |
17,280 | ~1 day | set_due_soon_window(admin, ledgers) |
get_due_soon_window() |
proof_cooldown |
2,880 | ~4 hours | set_proof_cooldown(admin, ledgers) |
get_config_snapshot().proof_cooldown_ledgers |
super_arbiter_response_window |
51,840 | ~3 days | set_super_arbiter_deadline(admin, ledgers) |
get_super_arbiter_deadline() |
ledgers_per_day |
17,280 | 1 day | set_ledgers_per_day(admin, value) |
get_ledgers_per_day() |
Tip:
get_config_snapshot()returns all of the above values in a single read-only call, so off-chain indexers do not need ~11 separate round-trips to reconstruct the current configuration.
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 withget_amendment_ttl(). - If the current ledger exceeds
expires_at_ledger, the proposal is considered expired:- Calling
accept_amendmenton an expired proposal clears it, emits anamendment_rejectedevent with reasonexpired, and panics withamendment_expired. get_pending_amendmentautomatically treats expired proposals as non-existent and returnsNone.
- Calling
- 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 nextpropose_amendmentfor the same milestone.
accept_amendment and reject_amendment are restricted to the counterparty.
The original proposer can instead clear their own pending proposal immediately:
withdraw_amendment_proposal(proposer, engagement_id, milestone_index)The function applies these checks in order:
proposermust authorize the transaction.- A proposal must exist for the specified
engagement_idandmilestone_index; otherwise it panics withno pending amendment proposal. proposermust match the proposal's recorded proposer; otherwise it panics withunauthorized.
It does not perform a separate engagement lookup, so merely being the
engagement's company or recruiter is not sufficient. On success, the proposal
is removed and an amendment_withdrawn event is emitted with
(milestone_index, proposer, new_payment_percent). The milestone's amendment
log is unchanged because the proposal was never applied. An
expired-but-uncleared proposal can also be withdrawn to remove its stored entry.
After withdrawal, get_pending_amendment returns None and a fresh proposal can
be made for the same milestone.
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.
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_amendmentto inspect a live proposal.
A retention milestone unlocks automatically once valid_after_ledger passes. A milestone extension request lets the recruiter ask the company for more time before that deadline arrives, without touching payment_percent the way an amendment does.
-
Propose —
propose_milestone_extension(recruiter, engagement_id, milestone_index, additional_ledgers)- Caller must be the engagement's
recruiter(or its registered cosigner); must sign the transaction. Unlike amendments, this is one-directional — only the recruiter may propose. - The engagement must be
Active, and the target milestone must be aRetentionmilestone currentlyLocked. Placement milestones, and milestones that have progressed pastLocked, cannot be extended. additional_ledgersmust be greater than zero.- Panics with
MilestoneExtensionLimitReachedonce the milestone has already been granted the admin-configured maximum number of extensions (default 3, viaget_max_milestone_extensions()). - A new proposal overwrites any existing pending proposal for the same milestone (only one pending per milestone at a time).
- Emits a
milestone_extension_proposedevent. - The pending state is stored as a
MilestoneExtensionProposalstruct underDataKey::MilestoneExtensionProposal(engagement_id, milestone_index).
- Caller must be the engagement's
-
Accept —
accept_milestone_extension(company, engagement_id, milestone_index)- Caller must be the engagement's
company(or its cosigner). additional_ledgersis added to the milestone'svalid_after_ledgerimmediately, pushing its unlock deadline further out.- Clears any
milestone_due_soonnotification flag for the milestone, since the deadline it referred to has moved — the new deadline can be announced again in its own right. - Increments the per-milestone granted-extension count used by the cap above and by
get_milestone_extension_count. - Emits a
milestone_extension_acceptedevent.
- Caller must be the engagement's
-
Reject —
reject_milestone_extension(company, engagement_id, milestone_index)- Caller must be the engagement's
company(or its cosigner). - The pending proposal is cleared from storage without any change to the milestone.
- Emits a
milestone_extension_rejectedevent with reasondeclined.
- Caller must be the engagement's
Use get_pending_milestone_extension(engagement_id, milestone_index) → Option<MilestoneExtensionProposal> to inspect a live proposal — mirrors get_pending_amendment.
Each proposal carries an expiry ledger computed as proposed_at_ledger + extension_ttl (see the extension_ttl row in the Amendments TTL table — default 17,280 ledgers, ≈ 1 day).
- Admin can change the default globally via
set_extension_ttl(admin, ledgers); the current value is queried withget_extension_ttl(). - If the current ledger exceeds
expires_at_ledger, the proposal is considered expired:- Calling
accept_milestone_extensionon an expired proposal clears it, emits amilestone_extension_rejectedevent with reasonexpired, and panics withmilestone_extension_expired. get_pending_milestone_extensionautomatically treats expired proposals as non-existent and returnsNone.
- Calling
- Expired proposals are lazily cleared — either by a later
accept_milestone_extensioncall on them, or overwritten by the nextpropose_milestone_extensionfor the same milestone.
| Amendment | Milestone Extension | |
|---|---|---|
| Changes | payment_percent |
valid_after_ledger (unlock deadline) |
| Applies to | Any milestone | Retention milestones only, while Locked |
| Who may propose | Company or recruiter | Recruiter only |
| Who accepts/rejects | The other party | Company only |
| Repeat limit | None (20-entry history log, no proposal cap) | Capped at get_max_milestone_extensions() grants per milestone (default 3) |
| TTL constant | amendment_ttl |
extension_ttl |
get_engagement_timeline(engagement_id, page, page_size) returns one page of everything that happened on an engagement, oldest first, as TimelineEntry { kind, milestone_index, actor, ledger, source_index } (#501). It is merged at read time from the per-kind histories, so it always agrees with them:
kind |
Source | milestone_index |
actor |
|---|---|---|---|
Amendment |
get_split_amendment_log |
None |
Proposer |
Replacement |
get_replacement_record / get_replacement_reason |
None |
Company |
Extension |
Reserved; milestone extensions are not recorded in this version | — | — |
Dispute |
get_dispute_history |
Disputed milestone | Company or its co-signer |
StatusChange |
get_status_history |
None |
Caller; None for permissionless calls |
Entries are sorted by ledger. Entries on the same ledger follow the kind order above; for example, a replacement comes before the status change it causes. source_index is the entry's position in its source, or the replacement_index for replacements. Paging follows the other list queries: page is 0-indexed, and a page_size of 0, a page past the end, or an engagement with no history returns an empty vec.
The dispute, status and split-amendment histories keep their most recent entries only (50, 50 and 20), and replacements requested before replacement records existed have no ledger, so neither appears in the timeline.
EngagementConfig::recruiter_bond_amount escrows a bond from the recruiter at creation. When the engagement reaches Completed, Cancelled or Expired, the bond is settled once. If some milestone had a dispute resolve against its proof and was never confirmed or resolved afterwards, get_bond_forfeit_bps of the bond (default 100 %) goes to the company. Otherwise the whole bond returns to the recruiter. Query it with get_recruiter_bond.
EngagementConfig::co_recruiter_bond_amount escrows a separate bond from the co_recruiter at creation. It is ignored when there is no co-recruiter. It uses the same trigger as the recruiter bond, tracked on its own record, and is settled independently. The co-recruiter shares in payouts, so it shares the liability in proportion to its payout share:
co_forfeit = co_bond × bond_forfeit_bps × (10_000 − recruiter_split_bps) ÷ 10_000²
recruiter_split_bps is read at settlement. The rest returns to the co-recruiter, and the recruiter bond's own forfeiture does not change. Query it with get_co_recruiter_bond(engagement_id) → Option<(amount, forfeited)>.
By default every payout is pushed to the recruiter and co-recruiter inside the confirming transaction. If a recipient cannot receive the token, for example because it has no trustline or its balance is frozen, the whole confirmation reverts and the company cannot confirm.
Setting EngagementConfig::pull_payouts = true at creation switches the engagement to a claim model. Every amount that would be transferred to the recruiter side is instead credited to RecruiterClaimable(recipient, token). That covers milestone payouts on every confirmation and dispute path, streamed-payout claims and bond refunds. A confirmation then never fails because of the recipient's account.
| Function | Caller | Description |
|---|---|---|
claim_payouts(recruiter, token) → i128 |
Recruiter or co-recruiter (must sign) | Transfer everything credited in token across all engagements, and return the amount. Returns 0 without transferring when nothing is owed. Fails with ContractPaused while paused. If the account still cannot receive, the claim reverts and the balance stays credited. |
get_claimable_payout(recruiter, token) → i128 |
Anyone | Amount credited and not yet claimed. |
is_pull_payouts_enabled(engagement_id) → bool |
Anyone | Whether the engagement credits instead of pushing. |
Notes:
- Credits are in the engagement's escrow token. The payout token preference (
set_recruiter_payout_token) is not applied to credited amounts. - Fees, arbiter fees and refunds to the company are still transferred immediately.
- The flag is set once at creation and stored under
DataKey2::PullPayouts(engagement_id). It does not change theEngagementrecord.
Events: ("payout_credited", recipient) with (engagement_id, token, amount); ("payouts_claimed", recruiter) with (token, amount).
create_engagement normally locks escrow and makes the engagement Active straight away, without the recruiter agreeing to the terms. Setting EngagementConfig::require_recruiter_acceptance = true creates it in the Offered status instead. The escrow (and any bonds) are still locked at creation.
| Function | Caller | Description | Panics |
|---|---|---|---|
accept_engagement(recruiter, engagement_id) |
Recruiter or their cosigner | Offered → Active. Placement milestones' no-show clocks start at acceptance. Retention unlock ledgers stay as computed at creation. |
ContractPaused, EngagementPaused, EngagementNotOffered, unauthorized |
withdraw_offer(company, engagement_id) |
Company or its cosigner | Offered → Cancelled. Refunds the full escrow (to the pool for pool-funded engagements), returns any bonds, and frees the cap slot. |
ContractPaused, EngagementPaused, EngagementNotOffered, unauthorized |
While Offered:
- No milestone action is accepted:
submit_proof, confirmations, disputes,top_up_escrow,cancel_engagementand the rest fail withengagement is not active. - The engagement counts against the per-company cap (
set_max_active_per_company) and the recruiter caps (set_recruiter_active_cap,set_max_active_per_recruiter). - Status queries list it under
Offered, never underActive(get_engagement_ids_by_status,get_engagement_count_by_status, summaries). - An offer the recruiter never accepts can be closed by
expire_engagementonce the inactivity timeout passes, with a full refund.
Events: ("engagement_offered", engagement_id) with (company, recruiter, total_amount) after engagement_created; ("engagement_accepted", engagement_id) with recruiter; ("offer_withdrawn", engagement_id) with the refund. Acceptance and withdrawal also emit status_changed and are recorded in get_status_history.
Once an engagement reaches Completed, each side may rate the other once:
submit_recruiter_rating(company, engagement_id, rating)— the company rates the recruiter (issue #242).submit_company_rating(recruiter, engagement_id, rating)— the recruiter rates the company (issue #243).
rating must be 1–5; anything else panics with InvalidRating. Ratings are
folded into a running tally keyed by the rated party's address, not the
engagement, so reputation accumulates across every engagement that address
completes. Query with get_recruiter_rating(recruiter) /
get_company_rating(company) (issue #244).
Each engagement contributes at most one rating per side — a second submission
panics with AlreadyRated, so neither party can inflate or bury a counterparty's
score by rating a single job repeatedly. The two sides are independent: both may
rate each other for the same engagement. Use is_recruiter_rated /
is_company_rated to hide a rating prompt instead of surfacing the failure.
Ratings are credited to whoever holds the role at completion time. If the
recruiter role was transferred mid-engagement via
accept_recruiter_transfer, the incoming address receives
the rating — matching where the milestone payouts went.
The contract supports an optional allowlist to restrict which tokens can be used for escrow.
- 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).
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.
A company or recruiter may register a cosigner — a secondary address that is equally authorized to perform that party's actions (e.g. confirming milestones, submitting proof, approving amendments). This lets a primary signer delegate operations to a bot, vault, or backup key without sharing the primary key.
Registration is one-way per party and only the primary address may set its own cosigner:
set_company_cosigner(company, cosigner)— delegates company-side actions.set_recruiter_cosigner(recruiter, cosigner)— delegates recruiter-side actions.
The registered cosigner (or None if none was set) can be read back with:
get_company_cosigner(company)→Option<Address>get_recruiter_cosigner(recruiter)→Option<Address>
Once registered, the contract's is_authorized_company / is_authorized_recruiter
checks accept either the primary signer or the cosigner. A cosigned action
is therefore still a single on-chain call — it just uses the cosigner as the
authenticated caller instead of the primary.
sequenceDiagram
participant P as Primary (company/recruiter)
participant C as Cosigner
participant S as Contract
Note over P,S: Normal single-signer call
P->>S: action (e.g. confirm_milestone)
S->>S: require_auth(P) + is_authorized?(caller == P)
S-->>P: success
Note over P,C,S: Cosigned call (after set_*_cosigner)
P->>S: set_company_cosigner(P, C)
S-->>S: store cosigner = C
C->>S: action (same function, authenticated as C)
S->>S: require_auth(C) + is_authorized?(caller == P OR caller == C)
S-->>C: success
In both cases the function signature is identical; only the authenticated caller changes. Integrators adding a cosigner do not need new entry points — they simply route the existing call through the cosigner's key.
Use this to find the state-changing call for a task. It is the write-side
counterpart of Which query do I call?. The caller
column names the address that must sign. Wherever a company or recruiter
signs, that party's registered cosigner may sign instead (see
Cosigners). Engagement, dispute and payout calls fail with
ContractPaused while the contract is paused. Most admin configuration
setters, the cosigner setters and the payout-token setters still work while
paused. Engagement-scoped calls also fail with EngagementPaused while that
engagement is quarantined.
| I want to… | Call |
|---|---|
| Open an engagement and fund escrow | create_engagement |
| Withdraw an offer the recruiter has not accepted | withdraw_offer |
| Open an engagement with arbiters drawn from the admin pool | create_engagement_random_panel |
| Register an arbiter panel to share across several engagements | create_engagement_bundle |
| Add funds to an engagement's escrow | top_up_escrow |
| Pre-fund a pooled balance, or withdraw from it | deposit_company_balance / withdraw_company_balance |
| Accept a milestone's proof and pay the recruiter | confirm_milestone |
| Accept several milestones in one transaction | batch_confirm_milestones |
| Dispute a milestone's proof | raise_dispute |
| Ask the recruiter for a replacement candidate | request_replacement |
| Approve a recruiter's proposed handover to a new address | accept_recruiter_transfer |
| Hand the company role on an engagement to another address | transfer_company |
| Rate the recruiter on a completed engagement | rate_recruiter |
| Redeem accrued fee rebates | redeem_company_rebate |
| Let a second key act for the company | set_company_cosigner |
| I want to… | Call |
|---|---|
| Accept an engagement offered to me | accept_engagement |
| Submit proof that a milestone is done | submit_proof |
Withdraw payouts credited by pull_payouts engagements |
claim_payouts |
| Submit several evidence items as one proof | submit_proof_root (Merkle root) |
| Withdraw the vested part of a streamed milestone payout | claim_streamed_payout |
| Get paid in a different token | set_recruiter_payout_token / clear_recruiter_payout_token |
| Hand the engagement to another recruiter address | propose_recruiter_transfer (then the company calls accept_recruiter_transfer) |
| Let a second key act for the recruiter | set_recruiter_cosigner |
| I want to… | Call |
|---|---|
| Cancel the engagement and refund unreleased escrow | cancel_engagement (both must sign) |
| Change the co-recruiter fee split | propose_split_amendment, then the other party calls accept_split_amendment or reject_split_amendment |
| Change this engagement's dispute window | propose_dispute_window_override, then the other party calls accept_dispute_window_override or reject_dispute_window_override |
| Force-confirm a milestone the company ignored past the confirm window | force_confirm_milestone |
| I want to… | Call | Caller |
|---|---|---|
| Vote on a disputed milestone | cast_arbiter_vote |
Arbiter |
| Vote a percentage split (when split voting is enabled) | cast_arbiter_split_vote |
Arbiter |
| Let another address vote for my slot | set_arbiter_vote_delegate |
Arbiter |
| Step aside from one dispute | recuse_arbiter |
Arbiter |
| Hand my seat to a successor | nominate_arbiter_successor, then the successor calls claim_arbiter |
Arbiter, then nominee |
| Decide an escalated dispute | super_arbiter_resolve |
Super-arbiter |
These calls take no signer. They only succeed once their on-chain condition holds, so a keeper or either party can run them.
| I want to… | Call |
|---|---|
Move a retention milestone from Locked to Pending after its unlock ledger |
unlock_milestone |
| Recuse arbiters who missed the vote deadline, and settle the dispute on the votes cast | enforce_vote_deadline |
| Send a stalled dispute to the super-arbiter | escalate_dispute |
| Settle an escalated dispute the super-arbiter did not decide in time | resolve_escalation_timeout |
| Forfeit a placement milestone the recruiter never proved in time | trigger_no_show |
| Close an inactive engagement and refund the company | expire_engagement |
| Apply a time-locked contract upgrade | execute_upgrade |
| I want to… | Call |
|---|---|
| Initialise the contract | init |
| Hand over the admin role | nominate_admin, then the nominee calls claim_admin |
| Halt, or resume, every state-changing call | pause / unpause |
| Freeze, or unfreeze, a single engagement | pause_engagement / unpause_engagement |
| Set up the emergency M-of-N pause | set_emergency_signers, set_emergency_vote_window; signers then vote with cast_emergency_pause_vote |
| Require a second signer on sensitive setters | set_config_cosigner, set_sensitive_functions; the cosigner approves each change with accept_config_change |
| Set platform fees | set_platform_fee, set_fee_tiers, remove_fee_tier, waive_platform_fee, set_fee_rebate_bps, set_token_platform_fee, set_company_platform_fee / remove_company_platform_fee |
| Split fees across several treasuries | set_fee_treasuries / clear_fee_treasuries |
| Manage referrals | add_referrer, remove_referrer, set_referral_discount_bps |
| Control which tokens and amounts are accepted | add_allowed_token, remove_allowed_token, set_token_allowlist_enabled, set_min_amount, set_token_min_amount, remove_token_min_amount |
| Tune timing windows | set_confirm_window, set_dispute_window, set_proof_cooldown, set_cooldown_rating_discount, set_no_show_deadline_ledgers, set_amendment_ttl, set_super_arbiter_deadline, set_arbiter_vote_deadline |
| Tune limits | set_max_active_per_company, set_max_replacements |
| Configure arbitration | set_super_arbiter, set_arbiter_fee, set_split_voting_enabled, add_arbiter_pool_member, remove_arbiter_pool_member, set_bond_forfeit_bps |
| Grow or shrink one engagement's arbiter panel | admin_add_arbiter / admin_remove_arbiter |
| Configure payout-token swaps | set_swap_adapter / clear_swap_adapter |
| Mark a recruiter as verified | set_recruiter_verified |
| Upgrade the contract | set_upgrade_lock_duration, propose_upgrade, then anyone calls execute_upgrade |
| Label the deployed version | set_version |
Functions that manage contract-wide settings, admin succession, and operational state. All state-changing admin functions require the caller to authenticate as the current admin via require_auth().
| Function | Caller | Purpose | Panics |
|---|---|---|---|
set_platform_fee(admin, bps, treasury) |
Admin | Set platform fee in basis points (max 500 = 5%) and recipient treasury. | ContractPaused, NoAdmin, unauthorized, FeeTooHigh |
get_platform_fee() → (u32, Address) |
Anyone | Return current base (bps, treasury); defaults to (0, admin). Does not resolve fee tiers. |
— |
set_fee_tiers(admin, tiers) |
Admin | Replace the fee-tier list (max 10, strictly ascending threshold, each bps ≤ base platform fee). Empty vec clears tiers. |
ContractPaused, NoAdmin, unauthorized, too many fee tiers, tier bps exceeds base platform fee, tier threshold must be positive, tiers must be sorted by ascending threshold |
waive_platform_fee(admin, engagement_id) |
Admin | Permanently zero the platform fee for future payouts on one engagement. Overrides base fee, tiers and referral discount. Not blocked by pause. See Fee waiver. | NoAdmin, unauthorized, engagement not found |
is_fee_waived(engagement_id) → bool |
Anyone | Return true if the engagement's platform fee is waived. Unknown IDs return false. |
— |
get_fee_tiers() → Vec<FeeTier> |
Anyone | Return configured tiers; empty vec means flat base fee. | — |
set_company_platform_fee(admin, company, bps) |
Admin | Override the base fee rate for one company (max 500). See Per-company fee override. | ContractPaused, NoAdmin, unauthorized, FeeTooHigh |
remove_company_platform_fee(admin, company) |
Admin | Remove a company's override. | ContractPaused, NoAdmin, unauthorized |
get_company_platform_fee(company) → Option<u32> |
Anyone | Return a company's override, if any. | — |
set_fee_treasuries(admin, treasuries) |
Admin | Split fees across weighted treasuries. See Multiple treasuries. | ContractPaused, NoAdmin, unauthorized, EmptyTreasuryList, TooManyTreasuries, InvalidTreasuryWeight, DuplicateTreasury, TreasuryWeightsMustSumTo10000 |
clear_fee_treasuries(admin) |
Admin | Go back to the single PlatformFee.treasury. |
ContractPaused, NoAdmin, unauthorized |
get_fee_treasuries() → Vec<(Address, u32)> |
Anyone | Return the weighted treasuries; empty when unset. | — |
set_version(admin, version) |
Admin | Set a version label (max 32 chars); does not check upgrade or storage compatibility. | NoAdmin, unauthorized, VersionTooLong |
set_min_amount(admin, amount) |
Admin | Set minimum engagement amount in raw token units. | NoAdmin, unauthorized |
pause(admin) |
Admin | Pause all state-changing operations. | NoAdmin, unauthorized |
unpause(admin) |
Admin | Resume state-changing operations. | NoAdmin, unauthorized |
is_paused() → bool |
Anyone | Return true if contract is currently paused. |
— |
pause_engagement(admin, engagement_id) |
Admin | Quarantine a single engagement, blocking its state-changing operations without halting the contract. Works even when globally paused. | NoAdmin, unauthorized, engagement not found |
unpause_engagement(admin, engagement_id) |
Admin | Lift the quarantine on a single engagement. | NoAdmin, unauthorized, engagement not found |
is_engagement_paused(engagement_id) → bool |
Anyone | Return true if this specific engagement is quarantined. Unknown IDs return false. |
— |
set_due_soon_window(admin, ledgers) |
Admin | Set how many ledgers before a retention deadline a milestone counts as "due soon" (default 17 280 ≈ 1 day). | NoAdmin, unauthorized, InvalidDueSoonWindow |
nominate_admin(current_admin, new_admin) |
Admin | Nominate an admin successor. The nominee must call claim_admin to finalize. |
ContractPaused, NoAdmin, unauthorized |
claim_admin(nominee) |
Nominee | Claim admin rights after nomination. | ContractPaused, no pending admin nomination, unauthorized |
get_pending_admin() → Option<Address> |
Anyone | Return the pending admin nominee, if any. | — |
set_proof_cooldown(admin, ledgers) |
Admin | Set minimum ledger gap between proof resubmissions on the same milestone. Works even when paused. | contract not initialised, unauthorized |
Additional admin functions (documented elsewhere): init, renounce_admin, 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.
set_version stores a version string for identification; it is only a label. It
does not validate the WASM, check compatibility, or migrate contract state. An
execute_upgrade replaces the contract code but does not automatically transform
existing stored records. New WASM must continue to read records written by prior
versions, including Engagement and Milestone values. Treat changes to their
stored representation or field types as potentially incompatible, and provide
and verify an explicit migration before deploying such a change. Deployment and
upgrade procedures will be documented in DEPLOYMENT.md when that guide exists.
create_engagement, accept_engagement, withdraw_offer, unlock_milestone, notify_milestone_due_soon, submit_proof, submit_proof_root, 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, propose_recruiter_transfer, accept_recruiter_transfer, claim_payouts
submit_proof records one proof hash per milestone. When a milestone is backed
by several documents, the recruiter can instead call
submit_proof_root(recruiter, engagement_id, milestone_index, merkle_root) with
the root of a Merkle tree built off-chain over those documents (issue #486).
- Tree format. Each leaf is the 32-byte
sha256of one evidence item. Each parent issha256(min(a, b) || max(a, b)): the two children are sorted before hashing, so a proof is just the list of sibling hashes, with no left/right flags. A one-item set has the leaf itself as its root. - Same lifecycle.
submit_proof_rootgoes through the same checks, resubmission cooldown andPending → ProofSubmittedtransition assubmit_proof. The milestone'sproof_hashbecomes"merkle:"followed by the root in hex, so confirmation, disputes and replacements work unchanged. - Verification.
verify_proof_inclusion(engagement_id, milestone_index, leaf, proof)is permissionless and read-only. It returnstrueonly ifleafplus the sibling pathproof(at most 32 hashes) recomputes the milestone's committed root.get_proof_merkle_rootreturns that root. - Replacement. A root stops counting as soon as the milestone's proof
changes. That happens on a plain
submit_proofresubmission, a rejected dispute or a replacement request. After that, both queries returnNone/false.
The recruiter role on an engagement can be handed to a new address through a two-step propose → accept flow, so the outgoing recruiter cannot unilaterally redirect future payouts and the company must consent to the replacement.
- Propose — the current recruiter (or the recruiter's registered cosigner)
calls
propose_recruiter_transfer(recruiter, engagement_id, new_recruiter). This recordsnew_recruiteras a pending transfer but leavesengagement.recruiterunchanged: until the proposal is accepted, all payouts and ratings still follow the original recruiter. - Accept — the engagement's company (or the company's registered cosigner)
calls
accept_recruiter_transfer(company, engagement_id). Only then does the pending address become the engagement's recruiter.
Both steps require the engagement to be Active or ReplacementRequested, and
both respect the contract-wide pause and the per-engagement quarantine guards
(see Per-Engagement Pause).
On acceptance the contract changes the engagement as follows:
- Sets
engagement.recruiterto the proposed address, so all future milestone payouts and completion-time feedback ratings are credited to the new recruiter. - Updates
engagement.last_activity_ledgerto the current ledger sequence. - Removes the pending
ProposedRecruiterTransfer(engagement_id)record. - Emits
("recruiter_transferred", engagement_id)with(old_recruiter, new_recruiter).
Because the proposal is stored per engagement, only one transfer can be pending
at a time; a later propose_recruiter_transfer call overwrites the pending
address before it is accepted (no proposal event is emitted until acceptance).
See the role-transfer error entries for the failure
modes.
| Function | Caller | Purpose | Panics |
|---|---|---|---|
propose_recruiter_transfer(recruiter, engagement_id, new_recruiter) |
Current recruiter (or recruiter co-signer) | Record new_recruiter as the pending recruiter for the engagement. Does not change payouts until accepted. |
ContractPaused, EngagementPaused, unauthorized, engagement is not active |
accept_recruiter_transfer(company, engagement_id) |
Company (or company co-signer) | Promote the pending recruiter to engagement.recruiter and emit recruiter_transferred. |
ContractPaused, EngagementPaused, unauthorized, engagement is not active, no pending recruiter transfer |
Beyond the contract-wide pause / unpause, an admin can freeze a single
engagement with pause_engagement(admin, engagement_id). This lets an admin
quarantine one problematic engagement without halting every other engagement in
the contract.
While an engagement is quarantined, every lifecycle call for that ID is rejected
with EngagementPaused: milestone unlock, proof submission, confirmation
(including batch_confirm_milestones and force_confirm_milestone), disputes
and arbiter voting, escalation, replacements, amendments, milestone extensions,
role transfers, escrow top-ups, early exit, cancellation, and expiry.
Deliberately still permitted:
- Read-only queries — quarantine must not blind indexers or the parties to the engagement's own state.
admin_replace_arbiter— an engagement is frequently quarantined because its arbiter panel is the problem, so the admin keeps the tool to fix it.pause_engagement/unpause_engagementthemselves, so the admin can always lift the freeze — including while the contract is globally paused, which is exactly when triage is needed.
The two pause mechanisms are orthogonal and both must pass for a call to
proceed: an engagement can be quarantined while the contract runs normally, and
calling unpause on the contract does not lift a per-engagement quarantine.
The two mechanisms are tracked in separate storage flags and enforced by two
independent guards — assert_not_paused for the contract-wide pause and
assert_engagement_not_paused for the quarantine (see lib.rs). A
state-changing call must clear both guards to proceed; they are an AND,
not an OR.
Because they are independent, neither pause clears the other:
unpause(admin)— the contract-wide resume does not lift a per-engagement quarantine. An engagement quarantined before the contract was paused stays quarantined after the contract is resumed; its lifecycle calls keep failing withEngagementPauseduntilunpause_engagementis called for that ID.unpause_engagement(admin, engagement_id)— lifting a single engagement's quarantine does not resume a globally paused contract. While the whole contract is paused, that engagement's calls still fail withContractPausedregardless of its own quarantine state.
The combined effect for any given engagement:
| Global pause | Engagement quarantine | State-changing call |
|---|---|---|
| off | off | proceeds |
| off | on | rejected — EngagementPaused |
| on | off | rejected — ContractPaused |
| on | on | rejected — ContractPaused (global guard checked first) |
Example. An admin quarantines ENG-42 with pause_engagement while the
contract is running, then later pauses the whole contract with pause for
maintenance:
pause_engagement(admin, "ENG-42")→is_engagement_paused("ENG-42")istrue, whileis_paused()is stillfalse.pause(admin)→is_paused()becomestrue;ENG-42remains quarantined.- A lifecycle call on
ENG-42now fails withContractPaused(the global guard is checked before the engagement guard). unpause(admin)→is_paused()isfalseagain, butENG-42is still quarantined, so its calls still fail withEngagementPaused.unpause_engagement(admin, "ENG-42")→ quarantine lifted; lifecycle calls onENG-42proceed normally.
To check ahead of a write, query both is_paused() and
is_engagement_paused(engagement_id) — a call is only accepted when both
return false.
Soroban contracts cannot schedule their own work, so the due-soon signal follows
the same permissionless-keeper shape as unlock_milestone and
escalate_dispute. Once a Locked retention milestone is within
get_due_soon_window() ledgers of its valid_after_ledger, anyone may call
notify_milestone_due_soon(engagement_id, milestone_index) to emit a
milestone_due_soon event. Off-chain notification services subscribe to that
single event stream instead of polling ledgers_until_unlock for every locked
milestone.
The event fires at most once per deadline — a second call panics with
DueSoonAlreadyNotified. The record is cleared whenever the deadline itself
moves (an accepted milestone extension, or a replacement restarting the
retention timer) or the milestone unlocks, so a rescheduled milestone is
announced again for its new deadline. Use is_milestone_due_soon to test the
window condition and is_milestone_due_soon_notified to skip milestones that
have already been announced.
The call deliberately does not touch last_activity_ledger: a notification
is not engagement activity, and bumping it would let a keeper postpone
expire_engagement indefinitely.
Engagements can be labelled with free-form string tags so off-chain tooling can group and filter them (e.g. by client, role, or campaign). Tags are managed by the engagement's company (or its registered co-signer) and are only ever read by the tag-based queries — they do not affect escrow, payments, or lifecycle.
Limits (enforced when the engagement is created via config.tags):
- Max tags per engagement: 10 — creating with more panics
TooManyTags. - Max tag length: 32 characters — a longer tag panics
TagTooLong; a zero-length tag panicsTagEmpty. - Tags are de-duplicated at creation, so a repeated tag lists the engagement once in its index.
These bounds exist so storage stays predictable regardless of caller input. The
cap is checked at creation time; add_engagement_tag appends to a tag's
engagement index without re-checking the per-engagement cap.
| Function | Caller | Purpose | Panics |
|---|---|---|---|
add_engagement_tag(company, engagement_id, tag) |
Company (or co-signer) | Add engagement_id to a tag's index. Duplicate tags are silently ignored. |
unauthorized, engagement not found |
remove_engagement_tag(company, engagement_id, tag) |
Company (or co-signer) | Remove engagement_id from a tag's index. No-op if the tag was not present. |
unauthorized, engagement not found |
get_engagements_by_tag(tag, page, page_size) → Vec<String> |
Anyone | Paginated engagement IDs tagged with tag. Out-of-range pages return an empty vec. |
— |
get_engagement_tag_count(tag) → u32 |
Anyone | Total number of engagements tagged with tag. Returns 0 for a tag no engagement has. |
— |
get_engagement_tag_count is the companion to get_engagements_by_tag for
sizing pagination: pass the same tag to both to know how many pages to fetch.
propose_amendment, accept_amendment, reject_amendment, withdraw_amendment_proposal
set_company_cosigner, get_company_cosigner, set_recruiter_cosigner, get_recruiter_cosigner
See Cosigners for the full authorization-delegation flow.
nominate_arbiter_successor, claim_arbiter
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
Pendingstate, allowing the recruiter to submit new proof.
Each arbiter may vote only once for a dispute. Duplicate votes are rejected by the contract.
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.
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:
- The current arbiter nominates a successor using:
nominate_arbiter_successor()
- 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.
Succession swaps one slot for another. To change the panel's size instead, the admin calls:
| Function | Purpose |
|---|---|
admin_add_arbiter(admin, engagement_id, new_arbiter, new_quorum) |
Append a slot. Quorum is unchanged unless new_quorum is Some. On a weighted panel the new slot has weight 1. |
admin_remove_arbiter(admin, engagement_id, arbiter, new_quorum) |
Remove a slot and its weight. Panics QuorumUnreachable if the current quorum would exceed the remaining panel, unless new_quorum supplies a reachable one. Also clears the removed arbiter's vote delegate and any succession nomination it made. |
Whenever new_quorum is given, it must be between 1 and the new panel's total weight (invalid quorum). Both calls panic PanelChangeDuringDispute while any milestone is Disputed, and reject terminal engagements. Like other admin panel repairs, they still work on a quarantined engagement. Other panics: DuplicateArbiter, CompanyArbiterCollision and RecruiterArbiterCollision on add; ArbiterNotFound and at least one arbiter required on remove. They emit arbiter_added or arbiter_removed with (arbiter, quorum).
A dispute can stall when an arbiter never votes. With a vote deadline configured, anyone can recuse the arbiters who have not voted once the deadline passes, and the dispute is settled on the votes that were cast.
| Function | Access | Purpose |
|---|---|---|
set_arbiter_vote_deadline(admin, ledgers) |
Admin | Set the vote deadline in ledgers, measured from when the dispute was raised. 0 (the default) disables it. Emits arbiter_vote_deadline_set. |
get_arbiter_vote_deadline() → u32 |
Public | Return the vote deadline in ledgers (0 = disabled). |
enforce_vote_deadline(engagement_id, milestone_index) |
Permissionless | Recuse every panel arbiter who has not voted, then settle the dispute on the recomputed quorum or leave it ready to escalate. |
What enforcement does. Once the current ledger is past
dispute_raised_ledger + deadline:
-
Every panel arbiter who has not voted, and has not already recused, is added to the dispute's recusal list. This is the same list that
recuse_arbiterwrites, so they can no longer vote (binary or split). -
Each of them gets
ArbiterStats::missed_vote_deadlinesincremented. The random-pool selection weight counts a missed deadline as a second unanswered assignment, socompletion = 100 * votes_cast / (disputes_assigned + missed_vote_deadlines). -
The quorum is recomputed over the weight still on the dispute:
effective_quorum = ceil(quorum * active_weight / total_weight), at least 1. -
Every arbiter still on the dispute has voted, so the dispute settles right away if anyone voted:
- binary votes approve when
approve_weight >= effective_quorum, and otherwise reject, becausereject_weight > active_weight - effective_quorumthen holds; - split votes settle at the weighted median, as
cast_arbiter_split_votewould.
No arbiter fee is paid on a payout settled this way, because no single vote decided it.
- binary votes approve when
-
If nobody voted, the dispute stays
Disputedand every arbiter is recused.escalate_disputecan then be called right away, without waiting for the dispute window to elapse.
For example, with a 3-arbiter panel and quorum 2, if only one arbiter votes
before the deadline, the other two are recused, the effective quorum becomes
ceil(2 * 1 / 3) = 1, and the single vote decides the dispute.
Recusals are per dispute. They are cleared when the dispute resolves and when a new dispute is raised on the milestone.
| Panic | When |
|---|---|
VoteDeadlineDisabled |
The vote deadline is 0. |
engagement is not active / milestone is not in disputed status |
There is no live dispute to enforce. |
no dispute in progress |
No dispute raise ledger is recorded for the milestone. |
VoteDeadlineNotReached |
The current ledger is not yet past dispute_raised_ledger + deadline. |
NoPendingVoters |
Every panel arbiter has already voted or recused. |
If a dispute's arbiter votes remain split — neither the approval quorum nor the rejection threshold reached — past the dispute window, it can be escalated to a single configured Super Arbiter for a tie-breaking resolution.
| Function | Access | Purpose |
|---|---|---|
set_super_arbiter(admin, super_arbiter) |
Admin | Configure (or replace) the Super Arbiter address used to resolve escalated disputes. |
get_super_arbiter() → Option<Address> |
Public | Return the currently configured Super Arbiter, or None if none has been set. |
is_dispute_escalated(engagement_id, milestone_index) → bool |
Public | Return whether the dispute on that milestone is currently escalated. |
escalate_dispute(engagement_id, milestone_index) |
Permissionless | Escalate an eligible, still-hung dispute to the configured Super Arbiter. |
super_arbiter_resolve(super_arbiter, engagement_id, milestone_index, approve) |
Super Arbiter | Directly approve or reject an escalated dispute, bypassing the normal M-of-N vote. |
Escalation eligibility. A dispute becomes eligible for escalate_dispute
only when all of the following hold:
- The contract and the engagement are not paused.
- The engagement is
Active. - The milestone is
Disputed. - The dispute window has elapsed (measured from when
raise_disputewas called), orenforce_vote_deadlinehas run on the dispute (see Arbiter Vote Deadline). - Neither approval nor rejection has reached its quorum (the vote is still hung).
- A Super Arbiter has been configured.
Anyone can call escalate_dispute once those conditions are satisfied. No
client, freelancer, arbiter, or admin authorization is required — escalation
follows the same permissionless-keeper pattern as unlock_milestone.
Normal arbiter voting vs. Super Arbiter resolution. The Super Arbiter resolves an escalated dispute directly, without the M-of-N threshold that normal arbiter voting requires:
| Normal arbiter voting | Super Arbiter resolution | |
|---|---|---|
| Trigger | cast_arbiter_vote |
super_arbiter_resolve |
| Decision makers | Multiple arbiters | Configured Super Arbiter |
| Threshold | M-of-N quorum | No vote threshold |
| Authorization | Individual arbiter | Configured Super Arbiter |
| Approval | Quorum resolves dispute | Direct approval resolves dispute |
| Rejection | Quorum rejection returns dispute to Pending |
Direct rejection returns dispute to Pending |
| Arbiter fee | Deciding arbiter | Super Arbiter |
Timeout. If the Super Arbiter does not call super_arbiter_resolve within
the configured response window (default super_arbiter_response_window, ~3
days) after escalation, the permissionless resolve_escalation_timeout may be
invoked to auto-resolve the escalated dispute in the recruiter's favor — no
arbiter fee is deducted since the Super Arbiter did not act. The admin can tune
the window via set_super_arbiter_deadline(admin, ledgers) and read it with
get_super_arbiter_deadline(); get_super_arbiter_resolutions() reports the
total number of disputes concluded through the escalation path.
The early-exit protocol allows a recruiter to request a graceful exit from an engagement before all milestones are completed. It is a three-step flow involving both the recruiter and the company.
request_early_exit(recruiter, engagement_id)
Caller: Recruiter (must match the engagement's recruiter and sign the transaction).
The engagement must be in Active status. On success, the engagement transitions to ExitRequested. No funds are moved at this stage.
accept_early_exit(company, engagement_id)
Caller: Company (must match the engagement's company and sign the transaction).
On acceptance:
- The recruiter keeps all payments already released for confirmed or resolved milestones (
released_amount). - The remaining escrow balance (
total_amount - released_amount) is refunded to the company. - The engagement status moves to
Cancelled(terminal).
reject_early_exit(company, engagement_id)
Caller: Company.
On rejection, the engagement returns to Active and the recruiter must continue working through the remaining milestones. No funds are moved.
| Scenario | Recruiter receives | Company receives |
|---|---|---|
| Accepted (partial progress) | released_amount (already paid) |
total_amount - released_amount (refund) |
| Accepted (no milestones done) | Nothing | Full total_amount refund |
| Rejected | N/A -- engagement continues | N/A -- engagement continues |
get_config_snapshot(): Fetches all active contract configuration tunables and parameters in a single read-only call, eliminating the need for indexers to make multiple round-trips.
All read-only functions are permissionless and require no authentication.
A quick lookup for common frontend needs. See the subsections below for full argument and return-type details.
| Frontend need | Function |
|---|---|
| Show a company their active engagements | get_engagements_by_company + get_company_active_count |
| Show all engagements (admin / explorer view) | get_engagement_ids_by_status + get_engagement_count |
| List engagements that opted into public listing (job board / explorer) | get_public_engagement_ids |
| Show one engagement's full detail | get_engagement / get_engagement_summary |
| Show an engagement's milestone list + status | get_all_milestone_statuses |
| Show a single milestone's detail | get_milestone |
| Check if a milestone is unlockable now | is_milestone_unlockable |
| Show how long until a milestone unlocks | ledgers_until_unlock / get_estimated_unlock_seconds |
| Warn a user a milestone is due soon | is_milestone_due_soon |
| Show how much has been released / remains in escrow | get_total_released / get_escrow_balance |
| Check if an engagement is finished | get_is_engagement_complete |
| Show milestone unlock progress for a bar | get_unlock_progress |
| Show a recruiter's / company's star rating | get_recruiter_rating / get_company_rating |
| Check whether I already rated someone | is_recruiter_rated / is_company_rated |
| Show pending milestone amendment / its TTL | get_pending_amendment / get_amendment_ttl |
| Show amendment history for a milestone | get_amendment_log |
| Show arbiter vote tally on a dispute | get_arbiter_votes |
| Show why a milestone is in dispute | get_dispute_reason |
| Check that one evidence item is in a milestone's committed proof set | verify_proof_inclusion / get_proof_merkle_root |
| Show replacement history / reason | get_replacement_count / get_replacement_reason / get_replacement_record |
| Show everything that happened on an engagement, in order | get_engagement_timeline |
| Show past disputes / status transitions | get_dispute_history / get_status_history |
| Show a recruiter's / co-recruiter's bond | get_recruiter_bond / get_co_recruiter_bond |
| Fetch the contract PDF / metadata hash | get_contract_pdf_hash / get_metadata_hash |
| Is the contract (or one engagement) paused? | is_paused / is_engagement_paused |
| Load current config in one call (indexers) | get_config_snapshot |
| Fetch a single config value | get_version, get_min_amount, get_platform_fee, get_allowed_tokens, … |
| Show open dispute count on an engagement | get_active_dispute_count |
| 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 |
batch_get_engagement_summary |
engagement_ids: Vec<String> |
Vec<EngagementSummary> |
batch_get_engagement_summary(engagement_ids: Vec<String>) -> Vec<EngagementSummary>
returns the same EngagementSummary shape as
get_engagement_summary, but for many engagements in one call — letting a
dashboard render a list view without one round-trip per row.
- Max batch size: 20 IDs per call; passing more than 20 panics with
"too many IDs". - Unknown IDs: silently skipped rather than causing a panic — the
returned vector may be shorter than
engagement_idswhen some entries don't exist. Unlikeget_engagement_summary, which panics withEngagementNotFoundfor a single missing ID, the batch variant never panics on missing entries; check the length (or missingidvalues) of the result if you need to detect which IDs were skipped.
| 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 |
is_milestone_due_soon |
engagement_id: String, milestone_index: u32 |
bool |
is_milestone_due_soon_notified |
engagement_id: String, milestone_index: u32 |
bool |
get_arbiter_votes |
engagement_id: String, milestone_index: u32 |
ArbiterVoteCounts |
get_dispute_reason |
engagement_id: String, milestone_index: u32 |
Option<String> |
| 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 |
get_engagement_ids_by_status |
status: EngagementStatus, page: u32, page_size: u32 |
Vec<String> |
get_engagement_count_by_status |
status: EngagementStatus |
u32 |
get_engagements_by_amount_range |
min_amount: i128, max_amount: i128, page: u32, page_size: u32 |
Vec<String> |
get_engagement_count_by_amount |
min_amount: i128, max_amount: i128 |
u32 |
get_public_engagement_ids |
page: u32, page_size: u32 |
Vec<String> |
get_engagement_ids_by_status paginates the filtered result, so page 0 always
holds the first page_size matches regardless of how many non-matching
engagements they are interleaved with. It scans every engagement record, since
status lives on the engagement rather than in a per-status index — prefer the
per-company / per-recruiter queries when the party is already known. Its backing
index is populated at create_engagement, so engagements created before this
function was deployed are not enumerated (their records stay readable via
get_engagement).
page is 0-indexed and status accepts any
EngagementStatus variant, so
get_engagement_ids_by_status(EngagementStatus::Active, 0, 50) returns the
first 50 IDs currently in Active status. A page_size of 0 returns an
empty vec rather than erroring or falling back to a default size; an unset
caller-side page size therefore silently yields nothing. Requesting a page
past the last match also returns an empty vec, so iterate until a page comes
back empty instead of precomputing a page count
(get_engagement_count_by_status can size one, but its count shifts as
engagements change status between calls). Results reflect each engagement's
status at call time, and index entries whose engagement record has expired
from storage are skipped rather than returned stale.
get_engagements_by_amount_range works the same way, filtering on each
engagement's total_amount instead of its status. The range
[min_amount, max_amount] is inclusive on both ends, so
get_engagements_by_amount_range(1_000, 1_000, 0, 50) returns engagements of
exactly 1_000. Pagination follows the rules above: page is 0-indexed over
the filtered matches, a page_size of 0 returns an empty vec, and a page
past the last match comes back empty. get_engagement_count_by_amount returns
the total number of matches for the same range and can size a pagination loop.
Both functions scan the full engagement index, share its coverage caveat for
engagements created before the index existed, and skip expired records. Both
panic with "InvalidAmountRange" when min_amount > max_amount; the paginated
query checks page_size == 0 first, so a zero page size returns an empty vec
even for an inverted range.
Every engagement has an is_public flag. It is copied from
EngagementConfig::is_public in create_engagement and defaults to false in
the usual config. Only get_public_engagement_ids reads it, and that function
returns the IDs of engagements created with is_public: true.
let mut config = default_config();
config.is_public = true; // opt in at creation
client.create_engagement(/* ..., */ &config);
let page0 = client.get_public_engagement_ids(&0, &50); // first 50 public IDs- It is a listing flag, not access control. A private engagement is only
left out of this one enumeration. Soroban ledger state is public, so a
private engagement stays fully readable by anyone who knows its ID
(
get_engagement,get_engagement_summary, …). It also still appears inget_engagement_ids_by_status,get_engagements_by_amount_range,get_engagements_by_companyandget_engagements_by_recruiter, and in every event it emits. Never useis_public: falseto hide sensitive data. - It is fixed at creation. No function flips
is_publiclater, and it is not an amendable field. To change visibility you need a new engagement. - It is independent of status. Completed, cancelled and expired public engagements are still listed. Filter on status client-side if you only want open roles.
- Pagination works like
get_engagement_ids_by_status.pageis 0-indexed over the public matches only,page_size == 0returns an empty vec, and a page past the last match comes back empty, so iterate until you get an empty page. No count function exists for public engagements. - Cost and coverage are also the same as
get_engagement_ids_by_status. The function walksDataKey::AllEngagementsin creation order and loads each record until it has filled the requested page. Late pages therefore cost more, and a sparse public set means scanning many private records. Engagements missing from that index, and index entries whose record has expired, are skipped.
| 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 |
| Function | Arguments | Return Type |
|---|---|---|
get_recruiter_rating |
recruiter: Address |
RatingSummary |
get_company_rating |
company: Address |
RatingSummary |
is_recruiter_rated |
engagement_id: String |
bool |
is_company_rated |
engagement_id: String |
bool |
An address that has never been rated returns a zeroed summary rather than
panicking, so new recruiters and companies render without a prior existence
check. Always check count before presenting average_x100 — a 0 average
means "no ratings yet", not "rated zero".
| Function | Arguments | Return Type |
|---|---|---|
get_replacement_reason |
engagement_id: String, replacement_index: u32 |
Option<String> |
get_replacement_count |
engagement_id: String |
u32 |
get_replacement_record |
engagement_id: String, replacement_index: u32 |
Option<ReplacementRecord> |
| Function | Arguments | Return Type |
|---|---|---|
get_version |
— | String |
get_min_amount |
— | i128 |
get_platform_fee |
— | (u32, Address) |
get_fee_tiers |
— | Vec<FeeTier> |
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_due_soon_window |
— | u32 |
get_allowed_tokens |
— | Vec<Address> |
get_config_snapshot |
— | ConfigSnapshot |
get_config_snapshot returns every admin-configurable parameter above in a
single read-only call, so off-chain indexers do not need ~20 separate
round-trips to reconstruct the current configuration. Each field is sourced from
the same accessor its dedicated getter uses — including the defaults applied to
parameters that were never explicitly set — and because the whole struct is read
within one invocation the values are mutually consistent, with no window for an
admin transaction to land between two reads and yield a torn view.
| Function | Arguments | Return Type |
|---|---|---|
is_paused |
— | bool |
is_engagement_paused |
engagement_id: String |
bool |
get_admin |
— | Address |
get_pending_admin |
— | Option<Address> |
get_contract_health |
— | ContractHealth |
get_contract_health() bundles the most commonly polled diagnostic fields
into a single read-only call, so an off-chain keeper or status page doesn't
need four separate round-trips:
| Field | Type | Description |
|---|---|---|
paused |
bool |
Whether the global pause switch is engaged. false if never set. |
admin |
Address |
The current contract admin. |
version |
String |
Contract version string set via set_version; falls back to the default version string if never set. |
total_engagement_count |
u64 |
Total number of engagements ever created — monotonic, so cancelled/completed engagements stay counted and the value never decreases. |
Each call also emits a contract_health_snapshot event carrying the same
ContractHealth value, so keepers polling this view show up on-chain rather
than only in RPC access logs.
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.
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
ConfirmedorResolvedstatus. A later milestone cannot leapfrog an earlier unfinished one. - For
Retentionmilestones:env.ledger().sequence() >= milestone.valid_after_ledger. The retention time-gate is re-verified at confirmation time even ifunlock_milestonewas 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_outis 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. See Platform Fee for how
bpsis configured and its 5% cap. - The net remainder is distributed to the recruiter (and co-recruiter, if configured, per
recruiter_split_bps). - Milestone moves to
Confirmed; engagement moves toCompletedif this was the last outstanding milestone. - Emits
milestone_confirmedwith(milestone_index, payment).
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 withEmptyIndicesotherwise).
Preconditions (validated for every index in the batch before any state mutation or transfer):
- Engagement status is
Active. - Each target milestone is in
ProofSubmittedstatus. - For each
Retentionmilestone in the batch:current_ledger >= valid_after_ledger. - Sequential confirmation (Issue #67 / #184): For every index
idxin the batch, all milestones with index< idxmust be either (a) alreadyConfirmedorResolvedon-chain, or (b) present somewhere inmilestone_indicesitself. 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 thereplacement_paid_outsubtraction (see above). - Milestones move to
Confirmedin batch order; if the batch exhausts the engagement, engagement →Completed.
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_milestonedoes not re-verifyvalid_after_ledger. ARetentionmilestone inProofSubmittedstatus has, by definition, already been unlocked (either byunlock_milestoneor because it was aPlacement), so the time-gate is not re-tested. - No replacement paid-out deduction: Always pays the full gross share, matching
batch_confirm_milestonesand contrasting with singleconfirm_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 emitsconfirm_window_set. - get_confirm_window —
get_confirm_window(env) -> u32. Read-only. Returns the active window; defaults toDEFAULT_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 thereplacement_paid_outsubtraction. - Milestone →
Confirmed; engagement →Completedif this closes the last outstanding milestone. - Emits
milestone_force_confirmedwith(milestone_index, payment).
The contract emits Soroban events for all state transitions. Events are grouped by feature area below. Each event's first topic is its symbol name; engagement_id appears as a second topic for engagement-scoped events.
| Event | Topics | Payload | Trigger |
|---|---|---|---|
platform_fee_set |
— | (bps, treasury) |
set_platform_fee |
fee_tiers_set |
— | tiers.len() |
set_fee_tiers |
version_set |
— | version |
set_version |
min_amount_set |
— | amount |
set_min_amount |
paused |
— | admin |
pause |
unpaused |
— | admin |
unpause |
admin_nominated |
— | new_admin |
nominate_admin |
admin_claimed |
— | nominee |
claim_admin |
admin_renounced |
— | final_ledger |
renounce_admin |
max_replacements_set |
— | count |
set_max_replacements |
ledgers_per_day_set |
— | value |
set_ledgers_per_day |
max_retention_days_set |
— | days |
set_max_retention_days |
max_milestones_set |
— | count |
set_max_milestones |
max_active_per_company_set |
— | count |
set_max_active_per_company |
inactivity_timeout_set |
— | ledgers |
set_inactivity_timeout_ledgers |
storage_ttl_extend_to_set |
— | ledgers |
set_storage_ttl_extend_to |
confirm_window_set |
— | ledgers |
set_confirm_window |
dispute_window_set |
— | ledgers |
set_dispute_window |
upgrade_lock_duration_set |
— | ledgers |
set_upgrade_lock_duration |
max_proof_hash_length_set |
— | len |
set_max_proof_hash_length |
arbiter_fee_set |
— | bps |
set_arbiter_fee |
arbiter_vote_deadline_set |
— | ledgers |
set_arbiter_vote_deadline |
due_soon_window_set |
— | ledgers |
set_due_soon_window |
engagement_paused |
engagement_id |
admin |
pause_engagement |
engagement_unpaused |
engagement_id |
admin |
unpause_engagement |
token_allowlisted |
— | token |
add_allowed_token |
token_removed |
— | token |
remove_allowed_token |
allowlist_enabled_set |
— | enabled |
set_token_allowlist_enabled |
super_arbiter_set |
— | super_arbiter |
set_super_arbiter |
super_arbiter_deadline_set |
— | ledgers |
set_super_arbiter_deadline |
| Event | Topics | Payload | Trigger |
|---|---|---|---|
upgrade_proposed |
— | (new_wasm_hash, execute_after_ledger) |
propose_upgrade |
upgrade_executed |
— | new_wasm_hash |
execute_upgrade (permissionless after lock) |
| Event | Topics | Payload | Trigger |
|---|---|---|---|
engagement_created |
engagement_id |
engagement_id |
create_engagement |
engagement_offered |
engagement_id |
(company, recruiter, total_amount) |
create_engagement with require_recruiter_acceptance |
engagement_accepted |
engagement_id |
recruiter |
accept_engagement |
offer_withdrawn |
engagement_id |
refund |
withdraw_offer |
payout_credited |
recipient |
(engagement_id, token, amount) |
Any payout on a pull_payouts engagement |
payouts_claimed |
recruiter |
(token, amount) |
claim_payouts |
engagement_cancelled |
engagement_id |
refund |
cancel_engagement (company + recruiter) |
engagement_expired |
engagement_id |
refund |
expire_engagement (permissionless keeper) |
engagement_completed |
engagement_id |
(engagement_id, released_amount, ledger) |
Final milestone confirmed |
escrow_topped_up |
engagement_id |
(amount, total_amount) |
top_up_escrow |
company_transferred |
engagement_id |
(old_company, new_company) |
transfer_company_role |
recruiter_transferred |
engagement_id |
(old_recruiter, new_recruiter) |
accept_recruiter_transfer |
status_changed |
engagement_id |
(old_status, new_status) |
Any engagement status transition |
| Event | Topics | Payload | Trigger |
|---|---|---|---|
milestone_unlocked |
engagement_id |
(milestone_index, valid_after_ledger, unlocked_at_ledger) |
unlock_milestone |
milestone_due_soon |
engagement_id |
(milestone_index, valid_after_ledger, ledgers_remaining, window) |
notify_milestone_due_soon (permissionless keeper) |
proof_submitted |
engagement_id |
milestone_index |
submit_proof / submit_proof_root (first submission) |
proof_resubmitted |
engagement_id |
(milestone_index, old_hash, proof_hash) |
submit_proof / submit_proof_root (resubmission) |
proof_root_submitted |
engagement_id |
(milestone_index, merkle_root) |
submit_proof_root (after proof_submitted / proof_resubmitted) |
milestone_confirmed |
engagement_id |
(milestone_index, payment) |
confirm_milestone / batch_confirm_milestones |
milestone_force_confirmed |
engagement_id |
(milestone_index, payment) |
force_confirm_milestone (permissionless after window) |
milestone_status_changed |
engagement_id |
(milestone_index, old_status, new_status) |
Any milestone status transition |
platform_fee_collected |
engagement_id |
(milestone_index, fee_amount, treasury) |
Milestone confirmation when fee > 0. With multiple treasuries, one event per treasury with that treasury's share as the amount |
| Event | Topics | Payload | Trigger |
|---|---|---|---|
dispute_raised |
engagement_id |
(milestone_index, reason) |
raise_dispute |
arbiter_voted |
engagement_id |
(milestone_index, approve) |
cast_arbiter_vote |
dispute_resolved |
engagement_id |
(milestone_index, approved) |
Quorum reached in cast_arbiter_vote |
arbiter_vote_deadline_missed |
engagement_id |
(milestone_index, arbiter) |
enforce_vote_deadline, once per recused arbiter |
vote_deadline_enforced |
engagement_id |
(milestone_index, recused_count, effective_quorum) |
enforce_vote_deadline, before any resolution events |
dispute_escalated |
engagement_id |
(milestone_index, super_arbiter) |
escalate_dispute |
dispute_escalation_resolved |
engagement_id |
(milestone_index, super_arbiter, approved) |
super_arbiter_resolve |
super_arbiter_timeout_resolved |
engagement_id |
milestone_index |
resolve_escalation_timeout (auto-favor recruiter) |
Disputes normally resolve through M-of-N arbiter voting. If no quorum is reached before the dispute window elapses, the dispute can be escalated to a configured super arbiter for a tie-breaking resolution (issue #246):
Company Arbiter(s) HireSettle Super-Arbiter
│ │ │ │
│ raise_dispute(engagement_id, milestone_index, reason) │
│────────────────────────────────────────▶│ │
│ │ │ │
│ │ │ milestone: ProofSubmitted ▶ Disputed
│ │ │ │
│ │ cast_arbiter_vote(approve = true/false) │
│ │───────────────────▶│ │
│ │ │ │
│ │ │ tallies M-of-N votes│
│ │ │ │
│ │ │ approve_votes >= quorum ▶ Confirmed (payment released)
│ │ │ reject_votes > N - quorum ▶ Pending (proof cleared)
│ │ │ │
│ │ no quorum reached │ │
│ │ dispute window elapses │
│ │ │ │
│ escalate_dispute(engagement_id, milestone_index) [any keeper] │
│────────────────────────────────────────▶│ │
│ │ │ │
│ │ │ dispute_escalated │
│ │ │────────────────────▶│
│ │ │ │
│ │ │ super_arbiter_resolve(engagement_id, milestone_index, approve)
│ │ │◀────────────────────│
│ │ │ │
│ │ │ approve ▶ Resolved (payment released)
│ │ │ reject ▶ Pending (proof cleared)
│ │ │ │
| Event | Topics | Payload | Trigger |
|---|---|---|---|
amendment_proposed |
engagement_id |
(milestone_index, proposer, new_payment_percent, expires_at_ledger) |
propose_amendment |
amendment_accepted |
engagement_id |
(milestone_index, acceptor, old_payment_percent, new_payment_percent) |
accept_amendment |
amendment_rejected |
engagement_id |
(milestone_index, rejector, reason) |
reject_amendment or expired proposal on accept_amendment |
amendment_withdrawn |
engagement_id |
(milestone_index, proposer, new_payment_percent) |
withdraw_amendment_proposal |
| Event | Topics | Payload | Trigger |
|---|---|---|---|
recruiter_rated |
engagement_id |
(recruiter, rating, new_count) |
submit_recruiter_rating |
company_rated |
engagement_id |
(company, rating, new_count) |
submit_company_rating |
| Event | Topics | Payload | Trigger |
|---|---|---|---|
arbiter_nominated |
engagement_id |
successor |
nominate_arbiter_successor |
arbiter_claimed |
engagement_id |
nominee |
claim_arbiter |
| Event | Topics | Payload | Trigger |
|---|---|---|---|
early_exit_requested |
engagement_id |
recruiter |
request_early_exit |
early_exit_accepted |
engagement_id |
refund |
accept_early_exit |
early_exit_rejected |
engagement_id |
company |
reject_early_exit |
| Event | Topics | Payload | Trigger |
|---|---|---|---|
replacement_requested |
engagement_id |
(replacement_index, reason) |
request_replacement |
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
Run the full test suite from the contract directory:
cd contracts/hiresettle && cargo testTests 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.
See TESTING.md for how test.rs is organised, its shared helpers, and how to run a single test or a group of tests (cargo test <name> -- --exact).
// 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);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
Note: the payout amounts in the examples above assume the default 0 bps platform fee. If
set_platform_feehas been called, the recruiter (and co-recruiter, if configured) receivesgross_share − platform_fee, not the full gross share. See Platform Fee.
All money math in the contract uses integers. There are no floats and no fixed-point library. Knowing the three unit systems below and the one rounding rule tells you, to the unit, what every party is paid.
| Quantity | Type | Unit | Range / cap |
|---|---|---|---|
Token amounts (total_amount, released_amount, fees, payouts, bonds, min amounts) |
i128 |
Raw base units of the engagement's token | total_amount > 0 and >= the effective min amount |
Milestone.payment_percent |
u32 |
Whole percent, divisor 100 |
All milestones must sum to exactly 100 |
Split-vote split_percent / settled split |
u32 |
Whole percent, divisor 100 |
0..=100 |
Platform fee PlatformFee.bps |
u32 |
Basis points, divisor 10_000 |
<= 500 (5%) — FeeTooHigh |
Fee tier FeeTier.bps |
u32 |
Basis points | <= current base platform fee |
| Referral discount | u32 |
Basis points, subtracted from the fee rate | <= 500 |
| Arbiter fee | u32 |
Basis points | <= 200 (2%) — ArbiterFeeTooHigh |
Fee rebate (set_fee_rebate_bps) |
u32 |
Basis points of each collected platform fee | <= 500 — FeeTooHigh |
recruiter_split_bps |
u32 |
Basis points kept by the primary recruiter | <= 10_000 — InvalidSplitBps |
Bond forfeit (set_bond_forfeit_bps) |
u32 |
Basis points | <= 10_000 — InvalidBondForfeitBps |
A milestone percentage and a basis-point rate are different scales. payment_percent: 30 means 30%, and platform_fee_bps: 30 means 0.30%. Milestone percentages are whole numbers, so a 33.33% milestone can't be expressed. Use something like 33 / 33 / 34.
The contract never calls a token's decimals(). 1_000_000_000 means 100 USDC for a 7-decimal Stellar asset and 0.000000001 of an 18-decimal token. Every percentage and bps result is the same fraction on any token, but absolute thresholds are not: set_min_amount and fee-tier thresholds are compared against raw units. Prefer per-token minimums (set_token_min_amount). Only allowlist tokens of comparable precision if you rely on tiers. All examples in this README use 7-decimal USDC, where 1 USDC = 10_000_000 units. (Tests: test_engagement_payout_math_is_decimal_agnostic_for_18_decimal_token, test_min_amount_is_raw_units_not_scaled_per_token_decimals.)
For a single milestone payout:
gross = total_amount × payment_percent ÷ 100 (minus any replacement_paid_out)
platform_fee = gross × effective_platform_bps ÷ 10_000
after_fee = gross − platform_fee
arbiter_fee = after_fee × arbiter_fee_bps ÷ 10_000 (approved disputes only)
net = after_fee − arbiter_fee
primary = net × recruiter_split_bps ÷ 10_000 (only when a co-recruiter is set)
co_recruiter = net − primary
The platform fee is then split further: credit = (platform_fee − rebate_offset) × fee_rebate_bps ÷ 10_000 goes to the company's rebate balance and the rest goes to the treasury. For a split-vote dispute, gross is first scaled by settled_percent ÷ 100 and the unreleased part is withheld for the company. For a streamed payout, net vests as net × min(elapsed, duration) ÷ duration.
The arbiter fee is taken from the amount after the platform fee, not from gross, so the two rates compound rather than add: 250 bps + 200 bps removes 4.45%, not 4.50%.
Every ÷ above is Rust integer division on non-negative i128, which rounds down. Wherever the contract computes a remainder by subtraction (gross − fee, net − primary, fee − credit), the party on the subtracted side receives the rounding dust. So:
| Division | Rounded down | Receives the dust |
|---|---|---|
| Platform / arbiter fee | Treasury / arbiter | Recruiter(s) |
| Co-recruiter split | Primary recruiter | Co-recruiter |
| Fee rebate credit | Company rebate | Treasury |
| Streamed vesting | Early claims | Final claim (vested == total exactly once elapsed >= duration) |
Milestone share total × pct ÷ 100 |
Every milestone | Nobody — see below |
Worked example: 1,000 USDC engagement (10_000_000_000 units), 30% milestone, 250 bps fee, 60/40 co-recruiter split:
gross = 10_000_000_000 × 30 ÷ 100 = 3_000_000_000
platform_fee = 3_000_000_000 × 250 ÷ 10_000 = 75_000_000 → treasury
net = 3_000_000_000 − 75_000_000 = 2_925_000_000
primary = 2_925_000_000 × 6_000 ÷ 10_000 = 1_755_000_000 → recruiter
co_recruiter = 2_925_000_000 − 1_755_000_000 = 1_170_000_000 → co-recruiter
Rounding examples:
- A fee rounds to zero on tiny payouts. A fee is only charged once
gross × bps >= 10_000. At 250 bps a gross share of399units gives399 × 250 ÷ 10_000 = 9(not 9.975), and anything under40units pays no fee and emits noplatform_fee_collectedevent. - The split remainder goes to the co-recruiter. With a net of
1_001andrecruiter_split_bps = 3_333, the primary gets333and the co-recruiter gets668. (Test:test_co_recruiter_split_with_odd_percentage_remainder.)
Each milestone's share is rounded down on its own. The shares therefore add up to exactly total_amount only when total_amount × payment_percent is divisible by 100 for every milestone. For example, total_amount = 1_000_000_007 with a 30 / 40 / 30 schedule pays 300_000_002 + 400_000_002 + 300_000_002 = 1_000_000_006, which leaves 1 unit behind. released_amount never reaches total_amount, so get_escrow_balance reports 1 after the engagement is Completed. No function refunds or sweeps it: cancel_engagement requires an Active or ReplacementRequested engagement, and expire_engagement refuses a Completed one. The loss is at most milestone_count − 1 units (well under one cent for 7-decimal tokens), but it is real.
To avoid it, make total_amount a multiple of 100. Every whole-percent share is then exact.
Every payout path resolves the effective platform-fee rate through the same helper, so the fee waiver, company override, per-token override, fee tiers (or snapshot) and referral discount apply identically everywhere. Every path also collects the fee the same way, so the fee rebate and multi-treasury routing apply everywhere. Only the arbiter fee differs:
| Payout path | Platform fee (all reductions) | Arbiter fee |
|---|---|---|
confirm_milestone |
✓ | — |
batch_confirm_milestones |
✓ | — |
force_confirm_milestone |
✓ | — |
cast_arbiter_vote (approve) / cast_arbiter_split_vote |
✓ | ✓ |
super_arbiter_resolve (approve) |
✓ | ✓ |
resolve_escalation_timeout |
✓ | — |
Before #539, batch_confirm_milestones and force_confirm_milestone skipped the referral discount, and batch_confirm_milestones paid the treasury directly without the fee rebate.
Every intermediate product is at most total_amount × 10_000, so any total_amount below about 1.7 × 10³⁴ units is safe in i128. The contract uses plain operators, not checked_*. The workspace release profile sets overflow-checks = true, so an overflow aborts the transaction and never wraps silently. Counters and ledger arithmetic use saturating_* where they could overflow (page × page_size, TTL expiry ledgers).
- 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_engagementrejectscompany == recruiter,companyappearing in the arbiter set, orrecruiterappearing 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'sdecimals(). 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. See Numeric Precision. - 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-verifiesvalid_after_ledgereven ifunlock_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.
- 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 thereplacement_requestedevent) - Dispute resolution via arbiter
- Flexible milestone structure (2-milestone 50/50, 3-milestone, custom)
- Unit tests — 282
#[test]functions (seecargo testoutput for current count) - Multi-candidate engagements (multiple positions, one company-recruiter pair)
- Partial payout on replacement (configurable replacement fee)
- Contract upgrade mechanism
- Mainnet deployment
The contract uses panic messages (strings) to signal errors instead of a formal error enum. This reference table documents the most frequently encountered error messages, their triggering conditions, and which function(s) raise them. Off-chain integrators can match on these message strings to classify and handle errors appropriately.
| Message | Triggered by | Condition |
|---|---|---|
ContractPaused |
Any state-changing function | The admin has called pause() and not yet called unpause(). Check is_paused() before retrying. |
EngagementPaused |
Any engagement-scoped state-changing function | The admin has quarantined this specific engagement via pause_engagement(). Check is_engagement_paused(engagement_id) before retrying. |
NoAdmin |
Any admin-gated function | The admin has called renounce_admin(), permanently disabling all admin operations. This is irreversible. |
unauthorized |
Any function with role-based authorization | Caller does not match the required role (e.g. called confirm_milestone as recruiter instead of company) or is not a registered co-signer. Check authorization helpers: is_authorized_company(), is_authorized_recruiter(), get_admin(). |
| Message | Triggered by | Condition |
|---|---|---|
AlreadyInitialized |
init (repeated call) |
The contract has already been initialized. init is idempotent by re-write but not by guard. |
InvalidEngagementId |
create_engagement |
engagement_id is empty, exceeds 64 characters, or contains non-alphanumeric + hyphen characters. |
JobTitleEmpty |
create_engagement |
job_title is empty. Provide a non-empty short job title (max 64 chars). |
JobTitleTooLong |
create_engagement |
job_title exceeds 64 characters. Truncate and retry. |
ZeroMilestones |
create_engagement |
Engagement created with an empty milestone list. At least one milestone is required. |
TooManyMilestones |
create_engagement |
Milestone count exceeds the admin-configured max (default 10). Check get_max_milestones() and request higher limit from admin or split into multiple engagements. |
MilestoneNameEmpty: index {i} |
create_engagement |
Milestone i has an empty name field. All milestones must have a non-empty name. |
MilestoneNameTooLong: index {i} |
create_engagement |
Milestone i's name exceeds 64 characters. Truncate milestone names and retry. |
DuplicateMilestoneName: {name} |
create_engagement |
Two or more milestones share the same name. Each milestone must have a unique name. |
TooManyTags |
create_engagement or get_engagements_by_multiple_tags |
More than 10 tags provided. Reduce tag count to ≤ 10. |
TagEmpty: index {i} |
create_engagement |
Tag i in config.tags is an empty string. All tags must be non-empty. |
TagTooLong: index {i} |
create_engagement |
Tag i exceeds 32 characters. Shorten all tags to ≤ 32 chars. |
amount must be greater than zero |
create_engagement |
total_amount ≤ 0. The escrow fee must be a positive amount. |
AmountBelowMinimum |
create_engagement |
total_amount is below the admin-configured minimum (default 100,000 stroops). Check get_effective_min_amount(token) for the resolved minimum and retry with higher amount. |
CompanyRecruiterCollision |
create_engagement |
company == recruiter. The company and recruiter must be different addresses. |
CompanyArbiterCollision |
create_engagement |
company appears in the arbiter list. The company cannot be an arbiter on its own engagements (conflict of interest). |
RecruiterArbiterCollision |
create_engagement |
recruiter appears in the arbiter list. The recruiter cannot be an arbiter on its own engagements (conflict of interest). |
at least one arbiter required |
create_engagement |
The arbiter list is empty. Provide at least one arbiter address. |
invalid quorum |
create_engagement |
quorum is 0, greater than the number of arbiters, or otherwise invalid. Set quorum to 1 ≤ quorum ≤ arbiters.len(). |
TokenNotAllowed |
create_engagement |
The token allowlist is enabled and token is not in it. Check get_allowed_tokens() or request the admin to allowlist your token with add_allowed_token(). |
CompanyBlacklisted |
create_engagement |
The company address is on the admin's blacklist (issue #358). Contact the admin to be removed. |
RecruiterBlacklisted |
create_engagement |
The recruiter address is on the admin's blacklist (issue #359). Contact the admin to be removed. |
engagement already exists |
create_engagement |
An engagement with this engagement_id has already been created. Use a unique engagement ID. |
milestone percentages must sum to 100 |
create_engagement |
The payment_percent fields across all milestones do not sum to exactly 100. Adjust percentages so they total 100. |
CompanyActiveLimitReached |
create_engagement |
The company has reached the admin-configured limit of simultaneously active engagements (default 50). Cancel or complete an existing engagement before creating a new one. Check get_company_active_count() and get_max_active_per_company(). |
engagement not found |
Any query or state-changing function | The engagement ID does not exist. Check the ID spelling and ensure create_engagement has been successfully called. |
RetentionDaysZero |
create_engagement |
A retention milestone has days = 0 in the retention_days vector. Retention windows must be positive. Set all retention days ≥ 1. |
RetentionDaysTooLarge |
create_engagement |
A retention milestone exceeds the admin-configured max (default 365 days). Either request a higher limit from the admin or split multi-year retentions into shorter windows. |
InvalidMetadataHash |
create_engagement |
config.metadata_hash is an empty string. Omit the field entirely or provide a real IPFS CID. |
InvalidContractPdfHash |
create_engagement |
config.contract_pdf_hash is an empty string. Omit the field entirely or provide a real attestation hash. |
InvalidSplitBps |
create_engagement |
config.recruiter_split_bps > 10_000. The split percentage must be ≤ 100% (≤ 10,000 basis points). |
| Message | Triggered by | Condition |
|---|---|---|
engagement is not active |
Various | The engagement status is not Active (e.g. it is Completed, Cancelled, or ReplacementRequested when only Active is valid). Check engagement status and only perform this operation on active engagements. |
invalid milestone index |
Any function taking milestone_index |
The index is out of bounds for the engagement's milestone list. Ensure milestone_index < milestone_count. |
milestone is not locked |
unlock_milestone |
The target milestone's status is not Locked. Call is_milestone_unlockable() first to check preconditions. |
only retention milestones can be unlocked this way |
unlock_milestone |
A Placement milestone (which starts Pending) cannot be unlocked. Only Retention milestones have a Locked state. |
retention window has not elapsed yet |
unlock_milestone or confirm_milestone |
current_ledger < valid_after_ledger. Wait additional ledgers for the retention period to elapse before retrying. Call ledgers_until_unlock() to check how many ledgers remain. |
InvalidProofHash |
submit_proof or add_milestone_attachment |
The proof hash is empty. Provide a non-empty IPFS CID or proof URI. |
ProofHashTooLong |
submit_proof or add_milestone_attachment |
The proof hash exceeds the admin-configured max length (default 200 chars). Provide a shorter proof identifier or request the admin to increase the limit with set_max_proof_hash_length(). |
milestone is not pending |
submit_proof |
The milestone status is not Pending (e.g. already ProofSubmitted or further along). Reset the milestone or check the status before resubmitting. |
ResubmitTooSoon |
submit_proof |
A proof was previously submitted on this milestone and rejected. The admin-configured proof cooldown (default ~4 hours) has not elapsed. Wait before resubmitting. |
DuplicateProofHash |
submit_proof |
The same proof hash is already used by another milestone in this engagement. Use a unique proof hash per milestone or per resubmission. |
milestone proof not yet submitted |
confirm_milestone or similar |
The milestone status is not ProofSubmitted. The recruiter must call submit_proof() first. |
PreviousMilestoneNotComplete |
confirm_milestone, batch_confirm_milestones, or execute_scheduled_auto_confirm |
An earlier milestone (lower index) has not yet been confirmed or resolved (issue #67: sequential confirmation rule). Complete earlier milestones first. |
only retention milestones can be due soon |
notify_milestone_due_soon |
Attempted to check due-soon status on a Placement milestone. Only Retention milestones have time-gate-based due-soon windows. |
milestone is already unlockable |
notify_milestone_due_soon |
The deadline has already passed; the milestone can be unlocked immediately. Call unlock_milestone() instead. |
DueSoonWindowNotReached |
notify_milestone_due_soon |
The milestone is still further out than the due-soon window. Wait for it to approach its deadline. |
DueSoonAlreadyNotified |
notify_milestone_due_soon |
The due-soon notification has already been emitted for this deadline. Check is_milestone_due_soon_notified() before retrying. |
milestone is not in disputed status |
cast_arbiter_vote or similar |
The milestone is not in Disputed status (e.g. ProofSubmitted or already resolved). The company must raise a dispute first via raise_dispute(). |
can only dispute a submitted proof |
raise_dispute |
The milestone status is not ProofSubmitted. The recruiter must submit proof before the company can dispute it. |
DisputeWindowClosed |
raise_dispute |
The dispute window has elapsed since the proof was submitted. Disputes must be raised within the admin-configured window (default ~3 days). Check get_dispute_window() for the current limit. |
| Message | Triggered by | Condition |
|---|---|---|
duplicate vote |
cast_arbiter_vote |
This arbiter has already voted on this dispute. Each arbiter may vote once per dispute. |
DisputeWindowNotElapsed |
escalate_dispute |
The dispute window has not yet elapsed since the dispute was raised, and enforce_vote_deadline has not run on it. Wait for the window to pass before escalating to the super arbiter. |
ArbiterRecused |
cast_arbiter_vote or cast_arbiter_split_vote |
The arbiter recused from this dispute, or was recused by enforce_vote_deadline. |
VoteDeadlineDisabled |
enforce_vote_deadline |
The admin has not set a vote deadline (set_arbiter_vote_deadline). |
VoteDeadlineNotReached |
enforce_vote_deadline |
The vote deadline has not passed since the dispute was raised. Check get_arbiter_vote_deadline(). |
NoPendingVoters |
enforce_vote_deadline |
Every panel arbiter has already voted or recused, so there is no one to recuse. |
dispute already resolvable without escalation |
escalate_dispute |
The dispute has already reached quorum (or rejection threshold) and can be resolved via normal arbiter voting. Escalation is only for tied disputes past the window. |
no super arbiter configured |
escalate_dispute or super_arbiter_resolve |
The admin has not set a super arbiter via set_super_arbiter(). Either the admin must configure one, or the dispute must be resolved through normal arbiter voting. |
no dispute in progress |
escalate_dispute |
Attempted to escalate a dispute that was not found in the vote tally. The dispute may have already resolved. |
dispute has not been escalated |
super_arbiter_resolve or resolve_escalation_timeout |
Attempted a super-arbiter resolution on a dispute that is not escalated. Call escalate_dispute() first. |
SuperArbiterResponseWindowNotElapsed |
resolve_escalation_timeout |
The super arbiter still has time to respond. Wait for the admin-configured response deadline to elapse before auto-resolving in the recruiter's favor. Check get_super_arbiter_deadline(). |
ReasonTooLong |
raise_dispute |
The dispute reason string exceeds 128 bytes. Provide a shorter reason. |
| Message | Triggered by | Condition |
|---|---|---|
payment percent must be 0-100 |
propose_amendment |
The new payment percent is outside 0–100 inclusive. |
no pending amendment proposal |
accept_amendment, reject_amendment, or get_pending_amendment |
No amendment proposal exists for this milestone. Either the proposal expired, was withdrawn, or was never proposed. |
proposer cannot accept their own proposal |
accept_amendment |
The acceptor is the same as the original proposer. The counterparty must accept or reject. |
amendment_expired |
accept_amendment |
The proposal's TTL has elapsed (current ledger > expires_at_ledger). The proposal is cleared. |
proposer cannot reject their own proposal |
reject_amendment |
The rejector is the same as the original proposer. The counterparty must accept or reject. |
unauthorized |
withdraw_amendment_proposal |
Caller is not the original proposer. Only the proposer may withdraw their own proposal. |
| Message | Triggered by | Condition |
|---|---|---|
only retention milestones can be extended |
propose_milestone_extension |
Attempted to extend a Placement milestone. Only Retention milestones have extendable deadlines. |
milestone is not locked |
propose_milestone_extension or accept_milestone_extension |
The milestone has progressed beyond Locked (e.g. to Pending or ProofSubmitted). Only locked milestones with pending deadlines can be extended. |
additional ledgers must be greater than zero |
propose_milestone_extension |
The extension request is for 0 ledgers. Provide a positive number of ledgers to extend. |
MilestoneExtensionLimitReached |
propose_milestone_extension |
This milestone has already been granted the admin-configured maximum number of extensions (default 3). No further extensions are allowed. |
no pending milestone extension proposal |
accept_milestone_extension or reject_milestone_extension |
No extension proposal exists for this milestone. |
milestone_extension_expired |
accept_milestone_extension |
The extension proposal's TTL has elapsed. The proposal is cleared and an event is emitted. |
| Message | Triggered by | Condition |
|---|---|---|
placement not yet confirmed — use cancel_engagement instead |
request_replacement |
The placement milestone has not been confirmed yet. Early-stage cancellations use cancel_engagement() instead. |
ReplacementLimitReached |
request_replacement |
The company has already requested the admin-configured maximum number of replacements (default 3). No further replacements are allowed. |
replacement reason too long |
request_replacement |
The reason string exceeds 128 bytes. Provide a shorter reason. |
| Message | Triggered by | Condition |
|---|---|---|
no exit request pending |
accept_early_exit or reject_early_exit |
The engagement status is not ExitRequested. The recruiter must call request_early_exit() first. |
engagement is in a terminal state |
nominate_arbiter_successor or claim_arbiter |
The engagement is Completed, Cancelled, or Expired. Arbiter succession has no purpose on terminal engagements. |
| Message | Triggered by | Condition |
|---|---|---|
Cannot expire completed engagement |
expire_engagement |
The engagement is already Completed and cannot expire. Expiry only applies to non-terminal states. |
Inactivity timeout not reached |
expire_engagement |
The inactivity threshold has not been met yet. Wait for get_inactivity_timeout_ledgers() ledgers since last_activity_ledger before expiring. |
engagement is in a terminal state |
expire_engagement |
The engagement is already Cancelled or Expired, so its escrow was already refunded. Checked after the timeout. |
EngagementNotOffered |
accept_engagement or withdraw_offer |
The engagement is not in Offered status: it was created without require_recruiter_acceptance, or the offer was already accepted, withdrawn or expired. |
| Message | Triggered by | Condition |
|---|---|---|
FeeTooHigh |
set_platform_fee, set_token_platform_fee or set_company_platform_fee |
Platform fee bps > 500 (5%). The fee is capped at 5% for safety. |
EmptyTreasuryList |
set_fee_treasuries |
The list is empty. Use clear_fee_treasuries to go back to the single treasury. |
TooManyTreasuries |
set_fee_treasuries |
More than 10 treasuries. |
InvalidTreasuryWeight |
set_fee_treasuries |
A treasury has weight 0. |
DuplicateTreasury |
set_fee_treasuries |
The same address appears more than once. |
TreasuryWeightsMustSumTo10000 |
set_fee_treasuries |
The weights do not add up to exactly 10 000 bps. |
discount too high |
set_referral_discount_bps |
Referral discount bps > 500. The discount cannot exceed the maximum platform fee. |
too many fee tiers |
set_fee_tiers |
More than 10 fee tiers provided. Limit to ≤ 10 tiers. |
tier bps exceeds base platform fee |
set_fee_tiers |
A tier's bps is higher than the current base platform fee. Tiers must be discounts (lower bps), not surcharges. |
tier threshold must be positive |
set_fee_tiers |
A tier's threshold ≤ 0. All tier thresholds must be positive integers. |
tiers must be sorted by ascending threshold |
set_fee_tiers |
Fee tiers are not in strictly ascending threshold order. Sort tiers before retrying. |
VersionTooLong |
set_version |
Version string exceeds 32 characters. Provide a shorter version identifier (e.g. "0.2.0"). |
InvalidDueSoonWindow |
set_due_soon_window |
Due-soon window is 0 ledgers. The window must be ≥ 1 ledger. |
InvalidLedgersPerDay |
set_ledgers_per_day |
Value is outside the range 1–25,920 ledgers. Pick a value in that range. |
InvalidMaxActivePerCompany |
set_max_active_per_company |
Count is 0. At least 1 active engagement per company must be allowed. |
ArbiterFeeTooHigh |
set_arbiter_fee |
Arbiter fee bps > 200 (2%). The fee is capped at 2% for fairness. |
InvalidMaxProofHashLength |
set_max_proof_hash_length |
Length is outside 1–500 characters. |
InvalidSuperArbiterResponseWindow |
set_super_arbiter_deadline |
Response window is 0 ledgers. Must be ≥ 1. |
InvalidMinAmount |
set_token_min_amount |
Minimum amount ≤ 0. Must be positive. |
ArbiterNotFound |
admin_replace_arbiter |
The old arbiter is not in the engagement's arbiter list. |
CompanyArbiterCollision |
admin_replace_arbiter |
The new arbiter is the engagement's company (conflict of interest). |
RecruiterArbiterCollision |
admin_replace_arbiter |
The new arbiter is the engagement's recruiter (conflict of interest). |
DuplicateArbiter |
admin_replace_arbiter |
The new arbiter is already in the arbiter list. |
| Message | Triggered by | Condition |
|---|---|---|
no pending recruiter transfer |
accept_recruiter_transfer |
No recruiter transfer has been proposed. The current recruiter must call propose_recruiter_transfer() first. |
referrer already exists |
add_referrer |
This referrer address is already in the recognized list. |
referrer not found |
remove_referrer |
This referrer address is not in the recognized list. |
| Message | Triggered by | Condition |
|---|---|---|
EmptyIndices |
batch_confirm_milestones |
The milestone_indices vector is empty. Provide at least one milestone index. |
too many IDs |
batch_get_engagement_summary |
More than 20 engagement IDs requested in a single batch. Paginate or reduce the batch size. |
EmptyConfigs |
batch_create_engagements |
The configs vector is empty. Provide at least one engagement configuration. |
TooManyEngagements |
batch_create_engagements |
More than 20 engagements in a single batch (issue #260 limit). Split into multiple batches or create individually. |
| Message | Triggered by | Condition |
|---|---|---|
ConfirmWindowNotElapsed |
force_confirm_milestone |
The confirm window has not elapsed since the proof was submitted. Wait for the admin-configured window (default ~5 days) before force-confirming. |
milestone is not in ProofSubmitted status |
force_confirm_milestone |
The milestone has already transitioned (e.g. to Confirmed or Disputed). Can only force-confirm from ProofSubmitted. |
| Message | Triggered by | Condition |
|---|---|---|
ScheduledLedgerInPast |
schedule_auto_confirm |
The target ledger is not in the future. Schedule for a future ledger. |
NoScheduledAutoConfirm |
execute_scheduled_auto_confirm or cancel_scheduled_auto_confirm |
No scheduled auto-confirm exists for this milestone. Check with get_scheduled_auto_confirm() before executing or canceling. |
ScheduledLedgerNotReached |
execute_scheduled_auto_confirm |
The current ledger has not yet reached the scheduled target ledger. Wait or check get_scheduled_auto_confirm(). |
| Message | Triggered by | Condition |
|---|---|---|
no pending upgrade |
execute_upgrade |
No upgrade proposal is pending. The admin must call propose_upgrade() first. |
UpgradeLockNotElapsed |
execute_upgrade |
The time-lock period has not elapsed since the proposal. Wait for the admin-configured lock duration (default ~1 day). |
| Message | Triggered by | Condition |
|---|---|---|
EngagementNotCompleted |
submit_recruiter_rating or submit_company_rating |
The engagement is not yet in Completed status. Ratings can only be submitted after an engagement completes. |
InvalidRating |
submit_recruiter_rating or submit_company_rating |
Rating is not in the range 1–5. Provide a rating between 1 (worst) and 5 (best). |
AlreadyRated |
submit_recruiter_rating or submit_company_rating |
A rating for this engagement has already been submitted by this party. Each side may rate once per engagement. Check is_recruiter_rated() / is_company_rated() first. |
NewTagSameAsOld |
rename_engagement_tag |
The new tag name is identical to the old tag name. Provide a different tag name. |
TagNotFound |
rename_engagement_tag |
The old tag has no engagements indexed under it. |
TagEmpty |
rename_engagement_tag or add_engagement_tag |
The tag string is empty. Provide a non-empty tag. |
TagTooLong |
rename_engagement_tag or add_engagement_tag |
The tag exceeds 32 characters. Shorten the tag. |
| Message | Triggered by | Condition |
|---|---|---|
InvalidReorderLength |
reorder_milestones |
The new_order vector length does not match the number of milestones. |
DuplicateReorderIndex |
reorder_milestones |
The new_order is not a valid permutation (an index appears more than once). |
CannotReorderProgressedMilestone |
reorder_milestones |
Attempted to move a milestone that is ProofSubmitted, Confirmed, Disputed, or Resolved. Only Locked/Pending milestones (pre-activation) can be reordered. |
MIT
- #579: feat: get_engagements_by_created_range query — filter by creation ledger
- #580: feat: recruiter profile aggregate query
- #582: feat: get_protocol_stats query — global totals for dashboards