The Escrow contract
Escrow.sol, in packages/contracts, is where the sum of every Scontract is held. One deal per mandate. It does three things: it accepts the funding, pays the payee on a signed attestation, and refunds the payer after the deadline.
It has no owner, can't be paused and can't be upgraded. Nobody, not even us, can move the money outside its rules.
The address in this environment is on the Test environment page.
Functions
| Function | Who | What it does |
|---|---|---|
open(mandate, payeeSignature, attestor) |
the payer | Opens the deal. The id is the EIP-712 hash of the mandate. Checks that the signature is the payee's, on those terms. |
fund(id) |
the payer | Pulls in the sum, which must first be authorized with approve. Only before the deadline. |
release(id, observedAt, signature) |
anyone | Pays the payee, with the signature of the attestor recorded in the deal. Valid only for an observation made by the deadline. |
settleExpired(id) |
anyone, after the deadline | Refunds the payer, or pays the payee if the mandate provides for tacit execution. |
cancel(id) |
the payer | Cancels a deal that is open and not yet funded. |
deals(id) |
view | The parties, the token, the sum, the condition, the deadline, the attestor, the status. |
mandateId(mandate) |
view | The deal id, that is the EIP-712 hash of the mandate. |
attestationDigest(id, conditionHash, observedAt) |
view | What the attestor signs. |
A deal's status is None, Open, Funded, Released, Refunded or Cancelled.
Signatures
The EIP-712 domain is { name: "Scontratto", version: "1", chainId, verifyingContract: <escrow> }. It is computed every time, so a copy of the contract at another address or on another chain has a different domain, and signatures can't be reused.
The payee signs the mandate:
Mandate(address payer, address payee, address token, uint256 amount,
bytes32 conditionHash, uint64 deadline, bool executeOnExpiry, bytes32 nonce)
The attestor signs the attestation:
Attestation(bytes32 dealId, bytes32 conditionHash, bool met, uint64 observedAt)
The core computes the same hash in TypeScript. A Foundry test compares the two byte for byte: if they diverged, the signature given in the app would not open the deal.
Events
Opened(id, payer, payee, amount, conditionHash, deadline), Funded(id), Released(id, conditionHash), Refunded(id), Cancelled(id).
Errors
| Error | When |
|---|---|
NotPayer |
Someone other than the payer tries to open, fund or cancel. |
BadPayeeSignature |
The signature isn't the payee's, or isn't on these terms. |
Exists |
A deal with the same mandate already exists. |
Unknown |
The deal doesn't exist. |
WrongStatus |
The deal isn't in the right status for this action. |
BadAttestor |
The attestation isn't signed by the deal's attestor. |
Expired |
Funding or observation after the deadline. |
NotExpired |
settleExpired called before the deadline. |
TransferFailed |
The token rejected the transfer. |
Before mainnet
The contract is a skeleton with no external dependencies. Before going to mainnet, signature handling moves to the OpenZeppelin libraries, and the contract goes through an audit. The next step for funding is EIP-3009: the payer signs just once, with no approve transaction and no gas.