MilestoneEscrow is a per-deal smart contract that holds stablecoins in trust and releases them tranche by tranche as work is delivered and approved. It is designed for trade finance, project contracts, and any multi-stage payment agreement where the payer and payee benefit from an on-chain neutral party holding funds. Each escrow is its own deployed contract — deployed by EscrowFactory — and is independent of all other escrows on the same network.
The contract is available on both chains:
Contract architecture
EscrowFactory deploys a new MilestoneEscrow contract for each deal. The factory maintains a whitelist of accepted stablecoins — only tokens on this list can be used as the escrow’s payment token. The EscrowManager SDK class wraps EscrowFactory and provides typed deploy, fund, and lifecycle methods.
Each escrow holds:
- A fixed set of milestones defined at deployment (amounts and description hashes)
- A single ERC-20 / SEP-41 stablecoin as the payment token
- References to the
payer, payee, optional arbiter, and optional identityVerifier
- A
timelockDurationSeconds window that protects the payee from indefinite payer inaction
Roles
Milestone state machine
Each milestone moves through the following states independently. Other milestones in the same escrow are unaffected by a dispute or delivery on one.
In TypeScript, the state values are the MilestoneStatus enum:
Funding model
Milestones are funded independently. The payer does not need to fund the entire deal upfront — they can deposit one tranche at a time as each milestone begins. This reduces counterparty risk for the payer:
A funded: boolean field on each Milestone struct tracks whether that specific tranche has been deposited.
Timelock protection
After a payee calls markDelivered(milestoneId), the milestone enters DELIVERED state. If the payer neither approves nor disputes within timelockDurationSeconds, the payee can call claimTimelockRelease(milestoneId) to collect the funds unilaterally.
The default timelockDurationSeconds is 7 days on-chain when 0 is passed at deployment. You can set a custom window via DeployEscrowOptions.timelockDurationSeconds:
Dispute flow
If the payer believes a milestone was not delivered to specification, they can open a dispute:
- Payer calls
raiseDispute(milestoneId) → milestone moves to DISPUTED.
- Arbiter reviews off-chain evidence (delivery proof, contract terms).
- Arbiter calls
resolveDispute(milestoneId, releaseToPayee):
true → funds released to payee (milestone → RELEASED)
false → funds refunded to payer (milestone → REFUNDED)
If no arbiter was set at deployment (address(0)), disputes cannot be resolved on-chain. Both parties must reach off-chain agreement and use the cancel mechanism, or one party must re-deploy the escrow with an arbiter. Always set an arbiter for high-value deals.
Cancellation
Both the payer and payee can vote to cancel a deal:
- Either party calls
voteCancel().
- Once both parties have voted, and no milestones remain in a
DELIVERED or DISPUTED state that could still release funds, the deal is cancelled.
- Any funded but unreleased milestone amounts are refunded to the payer.
- The contract emits
EscrowCancelled(uint256 refundedAmount).
EscrowDeployResult
The object returned by EscrowManager.deployEscrow():
Full example