Version 1. TernEscrow is deployed on every supported chain, with no owner and no upgrade path. Its addresses are in Contract addresses.
In short: each cross-chain swap is two deposits, one on each chain, locked to the same secret. The user’s deposit sits on the source chain and the solver’s on the destination chain. Revealing the secret pays the user first, and then lets the solver collect the user’s deposit. If the secret is never revealed, both deposits return to whoever made them. This page is the full technical specification of that mechanism, for auditors and for teams building on the contracts directly. To integrate through the API, start with How Tern works instead.

1. Summary

Tern settles cross-chain swaps as pairs of conditional transfers. On every chain, a single escrow contract holds funds under an execution condition: the SHA-256 hash of a 32-byte fulfillment. Revealing the fulfillment pays the escrow’s receiver. If the escrow reaches its expiry, the funds return to the sender. A cross-chain swap is tracked off-chain as a quote. It consists of a source escrow prepared by the user and one or more destination escrows prepared by a solver, all bound to the same execution condition. The user reveals the fulfillment to collect the destination escrows; the same fulfillment then lets the solver collect the source escrow. The design rests on five choices:
  1. One escrow contract per chain, holding every escrow, so there is one address to index and to trust.
  2. Absolute expiries (expiresAt, Unix seconds), never block counts, so expiries on two chains can be compared.
  3. Escrow IDs that do not include the amount, so an actual amount can differ from the quote without breaking matching.
  4. Deposit addresses for every escrow, so users can fund a swap with a plain transfer, including from an exchange.
  5. No admin: no owner, pause, token allowlist, or upgrade path.
This page specifies the contracts on EVM chains and Starknet, where they are deployed. Bitcoin (Section 8) is specified for a later release.

Scope

Same-chain swaps do not use escrows: TernSwap is specified on its own page, TernSwap. The key words MUST, MUST NOT, SHOULD, and MAY are used as in RFC 2119.

2. Terminology

The vocabulary follows Interledger (ILPv4) and the IETF Crypto-Conditions draft, whose PREIMAGE-SHA-256 condition type is the condition used here.

3. Design goals

  1. Trust-minimized. No attesters, oracles, multisigs, or settlement ledger. Funds move only by the escrow rules.
  2. One mechanism everywhere. The same terms, states, and transitions on every chain.
  3. Amount-independent identity. Every party can compute an escrow ID before funds move, and an escrow’s identity does not change if its amount differs from the quote.
  4. Simple to audit. Small, immutable contracts with a short list of invariants (Section 10.1).
  5. Good UX without trust. Gasless entry, plain-transfer deposits, fast unwinds, and automatic returns.

4. Architecture

4.1 Swap lifecycle

Alternative outcomes:
  • Solver declines (before preparing): the solver signs a reject for the source escrow; anyone submits it and the user’s funds return immediately.
  • Fill below the user’s minimum: the solver prepares an additional destination escrow with the same condition for the shortfall, or both sides unwind: the solver rejects the source escrow and the user’s app rejects the destination escrows.
  • Nobody acts: each escrow is expired by a keeper after its expiry and returns to its sender.

5. Escrow data model

5.1 Escrow terms

On Starknet the same fields use ContractAddress, u256 for the condition, u64 for the expiry, and felt252 for the salt. Terms MUST satisfy:
  • sender != 0, receiver != 0, sender != receiver
  • executionCondition != 0
  • block.timestamp < expiresAt <= block.timestamp + MAX_DURATION at prepare time

5.2 Escrow ID

EVM:
Starknet:
The amount is deliberately excluded. Off-chain services MUST implement these derivations exactly.

5.3 Stored state

The contract stores only what changes:
Terms are not stored. Every state-changing call after prepare receives the terms in calldata, and the contract recomputes the escrow ID. Terms are always recoverable from the Prepared event.

5.4 State machine

fulfill is allowed only strictly before expiresAt; expire only at or after it. The two windows never overlap. Every escrow leaves Prepared exactly once.

6. EVM escrow contract

6.1 Interface

6.2 prepare

  1. MUST require terms.sender == msg.sender.
  2. MUST validate the terms (Section 5.1) and 0 < amount <= type(uint248).max.
  3. MUST require escrows[id].state == None.
  4. Native asset: MUST require msg.value == amount. ERC-20: MUST require msg.value == 0, pull amount with transferFrom, and require that the contract’s balance increased by exactly amount. Fee-on-transfer tokens are therefore rejected.
  5. MUST store {amount, Prepared} and emit Prepared.

6.3 prepareWithPermit2

Gasless creation. Anyone (normally the relayer) may submit it.
  1. ERC-20 only.
  2. The sender is terms.sender, which MUST be the Permit2 signer.
  3. MUST call PERMIT2.permitWitnessTransferFrom with the escrow contract as recipient, requestedAmount = amount, owner = terms.sender, and a witness binding every term and the amount:
  1. Permit2 provides the nonce and deadline. The rest follows prepare steps 2–5.
Because the signature covers every term, a copied signature can only create the exact escrow the sender signed.

6.4 fulfill

fulfill(items, minTotal) settles one or more escrows. For each item:
  1. Recompute id from item.terms.
  2. MUST require state == Prepared and block.timestamp < terms.expiresAt.
  3. MUST require sha256(abi.encodePacked(item.fulfillment)) == terms.executionCondition.
  4. Set state = Fulfilled before transferring.
  5. Transfer the full amount to terms.receiver. Native transfers MUST forward all remaining gas (no stipend). A failed transfer MUST revert.
  6. Emit Fulfilled(id, fulfillment, amount).
If minTotal > 0, every item MUST share the same receiver and asset, and the sum of amounts paid MUST be at least minTotal. This is the slippage guard: the relayer passes the user’s minimum for the whole destination side of a quote. Solvers settling source escrows pass minTotal = 0. Anyone may call fulfill. Funds only ever go to each escrow’s receiver.
A transaction that reverts still publishes its calldata. Any party revealing a fulfillment MUST simulate fulfill first and broadcast only if the simulation succeeds (Section 9.4). Escrow amounts never change after prepare, so a passing simulation stays valid until the escrow expires.

6.5 reject

Early return, authorized by the receiver.
  1. MUST require state == Prepared.
  2. MUST verify receiverSignature from terms.receiver over the EIP-712 message below, using ERC-1271 for contract receivers.
  1. Set state = Rejected, transfer amount to terms.sender, emit Rejected.
A reject signature is single-use because the escrow can only leave Prepared once, and it cannot be replayed on another chain or contract because the escrow ID binds both. Uses:
  • the solver rejects a source escrow it will not fill, so the user is repaid immediately
  • the user’s app rejects destination escrows when it declines a fill, so the solver is repaid immediately

6.6 expire

For each terms entry: MUST require state == Prepared and block.timestamp >= terms.expiresAt; set state = Expired; transfer amount to terms.sender; emit Expired. Anyone may call it.

6.7 Deposit addresses

Every escrow can be funded through a deposit address bound to its terms and a deposit deadline.
DepositForwarder is a minimal clone with no arguments. It accepts native coin, and exposes one function, transferAll(asset, to), callable only by the escrow contract. sweep(terms, depositDeadline), callable by anyone:
  1. Compute id, depositId, and the deposit address; deploy the forwarder clone if it has no code.
  2. Read the forwarder’s balance of terms.asset. MUST require it to be non-zero.
  3. If all of the following hold, the deposit is accepted:
    • escrows[id].state == None
    • block.timestamp <= depositDeadline < terms.expiresAt
    • the terms are valid (Section 5.1)
    The forwarder transfers its full balance to the escrow contract. The escrow is created with amount equal to the balance actually received, and Prepared and Swept are emitted. There is no minimum on-chain; solvers decide whether to fill.
  4. Otherwise the forwarder transfers its full balance to terms.sender and emits Bounced.
rescue(terms, depositDeadline, token), callable by anyone, sends the forwarder’s full balance of any token != terms.asset to terms.sender. It covers wrong-token deposits.
For deposit addresses, terms.sender is the user’s refund address on the source chain. It MUST be an address the user controls, never an exchange deposit address.

6.8 General requirements

  • No owner, pause, whitelist, upgradeability, or selfdestruct.
  • Reentrancy guard (transient storage) on every state-changing function, in addition to checks-effects-interactions.
  • MAX_DURATION = 30 days.
  • PERMIT2 is the canonical address 0x000000000022D473030F116dDEE9F6B43aC78BA3.
  • Rebasing tokens are unsupported. Tokens with blocklists are supported, subject to the simulation rule in Section 6.4.

7. Starknet escrow contract

The Starknet contract implements the same model with these differences.

7.1 Interface

Events mirror the EVM events with snake-case fields. escrow_id, sender, and execution_condition are keys.
An undeployed Starknet account cannot validate signatures, so a receiver with an undeployed account cannot sign a reject. Destination escrows paying such receivers can only be fulfilled or expired.

8. Bitcoin (informative)

Not deployed. The same terms map onto a Taproot output.
  • expiresAt is a Unix timestamp, at least 500,000,000, so OP_CHECKLOCKTIMEVERIFY compares it against median time past. Median time past lags wall-clock time by roughly an hour; Section 9.2 accounts for this.
  • Expire transactions MUST set nLockTime >= expiresAt and an input nSequence below 0xffffffff, otherwise the timelock is not enforced.
  • The Taproot address is itself the deposit address. Each UTXO paid to it is one escrow, identified by its outpoint; several UTXOs with the same condition are fulfilled together.
  • The amount is the UTXO value and is not part of the script, consistent with Section 5.2.

9. Off-chain protocol rules

9.1 Quotes

A quote record contains at least: Escrows are linked to a quote through the execution condition, which is an indexed field of Prepared. The source escrow ID can be computed before funding; additional destination escrows are discovered by condition.

9.2 Timing

Each chain c has: Global parameters: The server MUST reject any quote where:
The values in use, measured from the time a firm quote is made:

9.3 Solver rules before preparing a destination escrow

The solver MUST:
  1. Compute the expected source escrow ID from the quote and read it on-chain. It MUST be Prepared.
  2. Check the on-chain terms: receiver is the solver, the asset and condition match the quote, and the amount is acceptable for the price it intends to deliver.
  3. Check source.expiresAt - now is large enough for the destination expiry plus the margins in Section 9.2.
  4. Wait confirm(source chain).
If it will not fill, the solver SHOULD sign a reject for the source escrow so the user is repaid immediately.

9.4 App and relayer rules before revealing a fulfillment

The fulfillment MUST be generated from a CSPRNG (32 bytes) and MUST NOT leave the app until every check below passes:
  1. Find all destination escrows with the quote’s condition on the destination chain.
  2. Each MUST be Prepared, pay the user’s receiver, use the expected asset, and have at least minFulfillWindow left.
  3. The sum of their amounts MUST be at least the user’s minAmount.
  4. Simulate fulfill(items, minAmount) against current state. Broadcast only if it succeeds, through private submission where available.
The app reads chain state directly through RPC and MUST NOT rely only on server status. If the fill is below the minimum, the app SHOULD wait briefly for the solver to add an escrow for the shortfall, then sign rejects for the destination escrows and expect the solver to reject the source escrow.

9.5 Keepers

Keepers SHOULD call expire for every escrow past its expiry. Payments only go to senders, so anyone can run a keeper. Combined with deposit addresses, a user who funds an escrow either receives the swap or gets funds back without further action.

9.6 Watchers

  • One contract address per chain to index.
  • Events carry full terms, so watchers rebuild escrow state from logs alone.
  • Watchers MUST persist their cursor, wait chain-specific confirmations, and handle reorgs by re-reading from a safe block.

10. Security considerations

10.1 Invariants

10.2 Threats and mitigations

10.3 Trust assumptions

  • Users trust only the escrow contract code and their own app’s verification. They do not trust the server (including its relayer), the solver, or keepers.
  • Solvers trust the escrow contract and their own confirmation policy for source chains.
  • Availability (RPCs, relayers, keepers) affects speed, not safety.