A Gold Price Escrow

ETC that pays out on a gold fixing. GoldEscrow reads etchd price records, proves each one on chain against the operator that anchored it, and settles on the median of an operator list with a quorum.

Alpha, unaudited. GoldEscrow is an example. Nobody has audited it, and it is not a price feed. Nothing here comes with a guarantee. Alpha, No Guarantees says what that means.

Why

Say Alice holds ETC and agrees to pay Bob if gold fixes at or above $4,000 an ounce at 12:00 UTC on a given day. If it fixes lower, she keeps the ETC. Neither wants to hand the money over in advance, and neither wants to trust the other to report the price. An escrow contract solves the first problem. The second is harder, because a contract on ETC cannot see the price of gold.

The usual answer is an oracle network, a feed contract that a set of operators keeps up to date round after round and pays gas for. Etchd already captures gold prices every minute as typed records, commits them in each tick and anchors the tick on ETC. Nobody sends a price to the chain until somebody wants to settle. Then one transaction carries the records of the fixing minute with their paths, and the contract checks each path up to an anchor of the operator it names.

Alice deploys GoldEscrow with her deposit and the terms. After the fixing, anyone submits the records. Once settleBy has passed, anyone calls settle, and the contract pays Bob when the fixing is at or above the strike and Alice otherwise. The same shape fits any contract that pays on a price at a known minute.

What It Trusts

A price record is an operator's account of what a provider said. Providers sign nothing, so the record shows that the operator committed those bytes by the time of its anchor, and the operator's honesty covers the rest. The contract proves every record against its operator's own anchors through recordExistedAtBy, so it trusts nobody who submits.

One operator runs today. Calendar a is run by Classix. GoldEscrow takes a list of operators, each with the pairs counted for it, and a quorum, so more operators plug in the same way once they run. The demo lists a alone with quorum 1, which means the demo escrow trusts Classix for the price. With several independent operators the median band below drops one bad operator, and two operators together decide the outcome.

An operator is one address. An escrow cannot tell two signer epochs of one calendar apart, so it lists one address per calendar, the anchor key in /v1/signers on the day of deploy. A key rotation before the escrow settles leaves that operator's later anchors under another address, which the escrow does not count, and Signers says so too.

Two different records of one operator and one listed pair in the fixing slot mark that operator as equivocating, and nothing of it counts. A calendar writes at most one record per pair per slot, so an honest operator never trips this rule, even when an alarm replays a capture. The contract cannot tell which of two records is honest, so it counts neither, and a submitter who holds one back gains nothing.

How the Contract Checks It

submit(PriceProof[]) takes records until settleBy. Each proof is an operator, the 89-byte record and its record path. The contract reads the record's bytes by offset, in calldata, and checks them in this order.

  1. The length is 89 and the schema at bytes 5 to 6 is 1, else BadRecord.
  2. The kind at byte 77 is not 5, and bit 3 of the flags at byte 78 is clear, else NotSpot. Kind 5 records are official closes, and bit 3 marks a revision of one. They share a day and are deduplicated on pair, quote time and price, so an honest operator can hold two of them in one slot. SPEC says escrows count kinds 0 to 4 only.
  3. The pair at bytes 11 to 43 is listed for that operator, else UnknownPair. Its position in the operator's list is its rank.
  4. A record already stored is skipped. A second, different record of the same operator and pair marks the operator as equivocating.
  5. The price at bytes 43 to 59 is a uint128 with 18 decimals and not zero. captured_at at bytes 67 to 75 falls in the fixing slot, fixingTime <= captured_at < fixingTime + slotSeconds, else NotTheFixingSlot.
  6. quoted_at at bytes 59 to 67 is no older than maxQuoteAge at the fixing and no more than 60 s after the capture, else StaleQuote.
  7. recordExistedAtBy(operator, 1, record, path) on EtchdVerifier returns the time the operator's anchor first reached the registry. It must be by the deadline, else AnchoredLate, and the capture no more than 60 s after it, else CapturedAfterAnchor.
  8. The record is stored as the operator's first record of that pair, and it becomes the operator's counted price when nothing is counted yet or its pair ranks higher than the counted one.

Whoever submits and in whatever order, the stored state ends as the best-ranked record of each operator that did not equivocate. Both parties see every record and both can submit until settleBy, so the data fixes the outcome, and the first caller does not. The record layout is on Records, and the generic record check on Proofs in Contracts.

struct Source { address operator; bytes32[] pairs; }   // pairs most preferred first
struct Terms {
    address beneficiary; Source[] sources;              // operators strictly ascending
    uint256 quorum; uint256 strike;                     // strike with 18 decimals
    uint256 maxSpreadBps;                               // the band around the median
    uint64 slotSeconds; uint64 slotOffset;              // the pinned cadence and offset of the pairs
    uint64 fixingTime;                                  // a slot start
    uint64 maxQuoteAge; uint64 deadline; uint64 settleBy; uint64 refundAfter;
}
struct PriceProof { address operator; bytes record; EtchdVerifier.RecordPath path; }

function submit(PriceProof[] calldata proofs) external;    // until settleBy
function outcome() public view returns (bool ok, uint256 price);
function settle() external returns (uint256 price);         // after settleBy, when outcome() is ok
function refund() external;                                 // after refundAfter, only when outcome() is not ok
function withdraw() external;                               // a credit left by a refused payout

Sources and Pairs

Every operator in the terms lists the pairs counted for it, most preferred first. The demo lists swissquote:XAU/USD and then goldapi:XAU/USD, both spot gold in dollars, so a minute with no Swissquote record settles on gold-api. These are the gold pairs calendar a records. Every one runs every 60 s at offset 1 s.

PairKindquoted_atNote
swissquote:XAU/USD0, midThe provider's quote timeMid of the prime bid and ask. Frozen over the weekend
goldapi:XAU/USD4, aggregated spotThe provider's update time, flag bit 1Moves on weekends with a frozen price
chainlink-ethereum:XAU/USD3, oracle answerupdatedAt of the roundThe round id sits in ref, so etchd records verify --rpc rechecks it
kraken:PAXG/USD, kraken:XAUT/USD, coinbase:PAXG/USD0, midThe HTTP Date minus Age, flag bit 0Gold-backed tokens, which drift from spot when their market is thin

We probed every source on 2026-10-25 from our own machines, and a probe Worker logged the gap between capture and quote for each provider from Cloudflare. The demo's maxSpreadBps and maxQuoteAge come from that log.

The pairs differ in what quoted_at means. An escrow that mixes providers reads flags or sizes maxQuoteAge for the weakest of them. The demo is all spot gold, so the token basis does not widen the band, and its maxQuoteAge covers gold-api's update time.

slotSeconds and slotOffset must equal the pinned cadence and offset of every listed pair, 60 and 1 on mainnet and 10 and 1 on the devnet. The contract cannot read the cadence, and with wrong terms an honest operator's record falls outside the bucket the contract counts. So escrow.ts deploy reads the pinned schedule of each pair from every listed calendar's /v1/sources and refuses terms that differ, a fixing that is not a slot start, and pairs on two cadences. A pair's schedule is pinned for good, and a new cadence is a new pair name, so the terms stay right for the life of the escrow.

A fixing is named by its slot start. The 12:00 minute on mainnet is the slot that starts at 12:00:01 UTC, and it counts records with 12:00:01 <= captured_at < 12:01:01. A retry that lands at second 00 of the next minute still belongs to the slot before it, in the calendar's storage and in the escrow alike. The constructor refuses slotOffset >= slotSeconds and a fixingTime whose remainder by slotSeconds is not slotOffset. The API and the CLI name fixings the same way.

The Median Band

outcome() takes the best-ranked price of every operator that did not equivocate. It needs at least quorum of them, takes their median m0 (the mean of the middle two for an even count), drops every price farther than maxSpreadBps from m0, needs quorum prices left, and returns the median of those.

m0      = median(prices)
kept    = every p with |p - m0| * 10,000 <= maxSpreadBps * m0
ok      = len(kept) >= quorum
fixing  = median(kept)
paid    = fixing >= strike ? beneficiary : depositor

With three listed operators and quorum 2, one wrong, broken or stale operator falls outside the band and the other two settle. Two operators on opposite sides of the band leave one price, and the escrow refunds. Two operators that agree on a wrong price decide the outcome, since the band drops one bad operator only. With the demo's one operator and quorum 1 the band has nothing to compare against, and a's record is the fixing.

The Time Bracket

Every record carries two times of its own, and the anchor gives a third. The escrow bounds all three.

fixingTime <= captured_at < fixingTime + slotSeconds     the fixing slot
quoted_at + maxQuoteAge >= fixingTime                    the quote is fresh
quoted_at <= captured_at + 60                            and not from the future
anchoredAt <= deadline                                   committed in time
captured_at <= anchoredAt + 60                           and captured before it
fixingTime + slotSeconds <= deadline <= settleBy < refundAfter

The lower bound comes from the slot. A record of the fixing minute cannot exist before the minute starts on an honest calendar, and the operator's anchor shows it was committed by deadline, so nobody can wait a day and pick a better price. deadline - fixingTime covers the confirmations a submitter waits for plus a margin, one hour in the demo.

Records go in until settleBy. settle works after it and needs an outcome, and refund works only after refundAfter and only when the stored records give none. A provable outcome cannot be overridden by a refund. A recipient that rejects ETC is credited instead and takes it later with withdraw.

Gas

Measured on Anvil with Shanghai rules and chain id 61 in escrow.test.ts and proofs.test.ts. Each figure is a whole transaction, with the 21,000 base and the calldata.

CallGas
submit of three price records, one per test operator253,306
settle with the 1 ETC payout76,345
recordExistedAtBy on one price record with 5 record hops, 3 map hops and 1 batch hop46,400

Figures

Diagram. GoldEscrow over a list of operators with a quorum. Calendar a and two dashed operators that could be listed each anchor a price record for the fixing slot. submit checks each record against its operator and keeps the best-ranked pair of each, the price outside the band around the median is dropped, and when a quorum is left the median pays Bob if it is at or above the strike. With no outcome the ETC goes back to Alice after refundAfter. Alice deposits ETC that goes to Bob if gold fixes at or above the strike. GoldEscrow takes a list of operators, each with its price pairs in order of preference, and a quorum. Anyone can submit price records until settleBy. The escrow checks each one against the operator that anchored it, keeps the best-ranked pair of each operator, drops every price outside the band around the median, and settles on the median of what is left when a quorum remains. Without an outcome the ETC goes back to Alice after refundAfter. The demo lists calendar a alone with quorum 1, and other operators plug in the same way.
Diagram. A timeline of one gold fixing. A record counts when its captured_at falls in the slot that starts at fixingTime, its quoted_at is at most maxQuoteAge before fixingTime, and its anchor was first seen by the deadline. Records can be submitted until settleBy and settle runs after it. From refundAfter the ETC goes back to Alice only when there is no outcome. A gold fixing is one slot, named by its start, 12:00:01 UTC for the 12:00 minute. A record counts when its captured_at falls inside that slot, its quoted_at is at most maxQuoteAge before the fixing, and its anchor was first seen by the deadline. Records can be submitted until settleBy, and settle runs after it. A refund opens at refundAfter and only when the counted records give no outcome.

The Source

Compiled with solc 0.8.38, optimizer 200 and EVM version shanghai, with the settings in packages/contract/verify. This is GoldEscrow.sol.

GoldEscrow.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.38;

import {EtchdVerifier} from "../EtchdVerifier.sol";

/// @title Gold escrow on etchd price records
/// @author Classix
/// @notice Alice deposits ETC that goes to Bob when gold fixes at or above the strike, as the price records of a list
///         of etchd operators show for one fixing slot. Each record is proven with EtchdVerifier against the anchor of
///         the operator that captured it, so nothing here trusts the submitter. Anyone may submit records until
///         settleBy, and the outcome follows from the records alone, whoever submits them and in whatever order.
/// @dev The fixing is the median of each operator's best-ranked price, with every price farther than maxSpreadBps from
///      the first median dropped and at least `quorum` prices left. An operator with two different records of one
///      listed pair in the fixing slot counts for nothing. Records of kind 5 (official closes) and revisions (flag bit
///      3) are refused, since an honest operator may hold two of those in one slot. One address per calendar.
contract GoldEscrow {
    struct Source {
        address operator; // the calendar's anchor sender
        bytes32[] pairs; // the pairs this operator may report, most preferred first
    }

    struct Terms {
        address beneficiary;
        Source[] sources; // operators strictly ascending
        uint256 quorum; // prices needed after the outliers are dropped, 1 to sources.length
        uint256 strike; // quote units per base unit, 18 decimals
        uint256 maxSpreadBps; // the band around the median of all counted prices
        uint64 slotSeconds; // the cadence of the operators' price sources, 60 on mainnet
        uint64 slotOffset; // their offsetSeconds, 1 on mainnet, below slotSeconds
        uint64 fixingTime; // a slot start, fixingTime % slotSeconds == slotOffset
        uint64 maxQuoteAge; // quoted_at + maxQuoteAge >= fixingTime
        uint64 deadline; // each record's anchor first seen by this time
        uint64 settleBy; // records accepted until this time
        uint64 refundAfter; // fixingTime + slotSeconds <= deadline <= settleBy < refundAfter
    }

    struct PriceProof {
        address operator;
        bytes record; // the 89-byte price record envelope
        EtchdVerifier.RecordPath path;
    }

    /// What an operator contributes: the price of its best-ranked pair so far, and whether it equivocated.
    struct Entry {
        uint128 price;
        uint8 rank; // 1 + the pair's position in the operator's list, 0 = nothing stored yet
        bool equivocated;
    }

    uint32 public constant PRICE_BRANCH = 1;
    uint16 public constant PRICE_SCHEMA = 1;
    uint256 public constant RECORD_LENGTH = 89;
    uint8 public constant KIND_OFFICIAL_CLOSE = 5;
    uint8 public constant FLAG_REVISION = 0x08;
    uint64 private constant CLOCK_SLACK = 60;

    EtchdVerifier public immutable verifier;
    address public immutable depositor;
    address public immutable beneficiary;
    uint256 public immutable quorum;
    uint256 public immutable strike;
    uint256 public immutable maxSpreadBps;
    uint64 public immutable slotSeconds;
    uint64 public immutable slotOffset;
    uint64 public immutable fixingTime;
    uint64 public immutable maxQuoteAge;
    uint64 public immutable deadline;
    uint64 public immutable settleBy;
    uint64 public immutable refundAfter;

    address[] public operators;
    /// 1 + the pair's position in the operator's list, 0 = not listed.
    mapping(address => mapping(bytes32 => uint256)) public rankOf;
    /// The first record proven for an operator and pair, by keccak256 of its bytes.
    mapping(address => mapping(bytes32 => bytes32)) public firstLeaf;
    mapping(address => Entry) public counted;
    mapping(address => uint256) public owed;
    bool public done;

    event Stored(address indexed operator, bytes32 pair, uint256 price, uint64 capturedAt);
    event Equivocated(address indexed operator, bytes32 pair);
    event Settled(uint256 price, address paid, uint256 amount);
    event Refunded(uint256 amount);
    event Owed(address to, uint256 amount);

    error BadTerms();
    error Closed();
    error BadRecord(uint256 index);
    error NotSpot(uint256 index);
    error UnknownPair(uint256 index, bytes32 pair);
    error NotTheFixingSlot(uint256 index, uint64 capturedAt);
    error StaleQuote(uint256 index, uint64 quotedAt);
    error AnchoredLate(uint256 index, uint64 anchoredAt);
    error CapturedAfterAnchor(uint256 index);
    error TooEarly();
    error NoOutcome();
    error HasOutcome();
    error AlreadyDone();
    error NothingOwed();

    constructor(EtchdVerifier verifier_, Terms memory t) payable {
        if (msg.value == 0 || t.beneficiary == address(0) || t.maxSpreadBps > 10_000) revert BadTerms();
        if (t.quorum == 0 || t.quorum > t.sources.length) revert BadTerms();
        if (t.slotSeconds == 0 || t.slotOffset >= t.slotSeconds || t.fixingTime == 0 || t.fixingTime % t.slotSeconds != t.slotOffset) {
            revert BadTerms();
        }
        if (t.maxQuoteAge > t.fixingTime || t.fixingTime + t.slotSeconds > t.deadline || t.deadline > t.settleBy || t.settleBy >= t.refundAfter) {
            revert BadTerms();
        }
        for (uint256 i; i < t.sources.length; i++) {
            Source memory s = t.sources[i];
            if (i == 0 ? s.operator == address(0) : s.operator <= t.sources[i - 1].operator) revert BadTerms();
            if (s.pairs.length == 0 || s.pairs.length > 255) revert BadTerms();
            for (uint256 j; j < s.pairs.length; j++) {
                if (s.pairs[j] == bytes32(0) || rankOf[s.operator][s.pairs[j]] != 0) revert BadTerms();
                rankOf[s.operator][s.pairs[j]] = j + 1;
            }
            operators.push(s.operator);
        }
        verifier = verifier_;
        depositor = msg.sender;
        beneficiary = t.beneficiary;
        quorum = t.quorum;
        strike = t.strike;
        maxSpreadBps = t.maxSpreadBps;
        slotSeconds = t.slotSeconds;
        slotOffset = t.slotOffset;
        fixingTime = t.fixingTime;
        maxQuoteAge = t.maxQuoteAge;
        deadline = t.deadline;
        settleBy = t.settleBy;
        refundAfter = t.refundAfter;
    }

    function operatorCount() external view returns (uint256) {
        return operators.length;
    }

    /// @notice Stores records until settleBy. The first record of each operator and listed pair is kept, whatever its
    ///         rank. A second, different record of the same operator and pair marks the operator as equivocating, and
    ///         nothing of it counts. A record already stored is skipped. So the final state depends only on which
    ///         records were proven, never on their order.
    function submit(PriceProof[] calldata proofs) external {
        if (done || block.timestamp > settleBy) revert Closed();
        for (uint256 i; i < proofs.length; i++) store(i, proofs[i]);
    }

    function store(uint256 i, PriceProof calldata p) private {
        bytes calldata r = p.record;
        if (r.length != RECORD_LENGTH || uint16(bytes2(r[5:7])) != PRICE_SCHEMA) revert BadRecord(i);
        if (uint8(r[77]) == KIND_OFFICIAL_CLOSE || uint8(r[78]) & FLAG_REVISION != 0) revert NotSpot(i);
        bytes32 pair = bytes32(r[11:43]);
        uint256 rank = rankOf[p.operator][pair];
        if (rank == 0) revert UnknownPair(i, pair);
        bytes32 leaf = keccak256(r);
        bytes32 first = firstLeaf[p.operator][pair];
        if (first == leaf) return;
        (uint256 price, uint64 capturedAt) = checkRecord(i, p);
        Entry memory e = counted[p.operator];
        if (first != bytes32(0)) {
            if (!e.equivocated) {
                counted[p.operator].equivocated = true;
                emit Equivocated(p.operator, pair);
            }
            return;
        }
        firstLeaf[p.operator][pair] = leaf;
        if (e.rank == 0 || rank < e.rank) counted[p.operator] = Entry(uint128(price), uint8(rank), e.equivocated);
        emit Stored(p.operator, pair, price, capturedAt);
    }

    /// The slot, quote and anchor checks of one record, and its proof.
    function checkRecord(uint256 i, PriceProof calldata p) private view returns (uint256 price, uint64 capturedAt) {
        bytes calldata r = p.record;
        price = uint128(bytes16(r[43:59]));
        uint64 quotedAt = uint64(bytes8(r[59:67]));
        capturedAt = uint64(bytes8(r[67:75]));
        if (price == 0) revert BadRecord(i);
        if (capturedAt < fixingTime || capturedAt >= fixingTime + slotSeconds) revert NotTheFixingSlot(i, capturedAt);
        if (quotedAt + maxQuoteAge < fixingTime || quotedAt > capturedAt + CLOCK_SLACK) revert StaleQuote(i, quotedAt);
        (uint64 anchoredAt,) = verifier.recordExistedAtBy(p.operator, PRICE_BRANCH, r, p.path);
        if (anchoredAt > deadline) revert AnchoredLate(i, anchoredAt);
        if (capturedAt > anchoredAt + CLOCK_SLACK) revert CapturedAfterAnchor(i);
    }

    /// @notice The fixing as the stored records give it. The median `m0` of each honest operator's best-ranked price,
    ///         every price farther than maxSpreadBps from `m0` dropped, at least `quorum` prices left, and the median of
    ///         those. `ok` is false when no outcome exists.
    function outcome() public view returns (bool ok, uint256 price) {
        uint256 n;
        uint256[] memory ps = new uint256[](operators.length);
        for (uint256 i; i < operators.length; i++) {
            Entry memory c = counted[operators[i]];
            if (c.rank != 0 && !c.equivocated) ps[n++] = c.price;
        }
        if (n < quorum) return (false, 0);
        sort(ps, n);
        uint256 m0 = median(ps, n);
        uint256 k;
        for (uint256 i; i < n; i++) {
            uint256 d = ps[i] > m0 ? ps[i] - m0 : m0 - ps[i];
            if (d * 10_000 <= maxSpreadBps * m0) ps[k++] = ps[i];
        }
        if (k < quorum) return (false, 0);
        return (true, median(ps, k));
    }

    /// @notice After settleBy, pays the deposit to the beneficiary when the fixing is at or above the strike and to the
    ///         depositor otherwise.
    function settle() external returns (uint256 price) {
        if (done) revert AlreadyDone();
        if (block.timestamp <= settleBy) revert TooEarly();
        bool ok;
        (ok, price) = outcome();
        if (!ok) revert NoOutcome();
        done = true;
        address to = price >= strike ? beneficiary : depositor;
        uint256 amount = address(this).balance;
        emit Settled(price, to, amount);
        pay(to, amount);
    }

    /// @notice After refundAfter, and only when the records stored give no outcome, returns the deposit. refundAfter is
    ///         past settleBy, so no refund lands in a block that still takes records.
    function refund() external {
        if (done) revert AlreadyDone();
        if (block.timestamp < refundAfter) revert TooEarly();
        (bool ok,) = outcome();
        if (ok) revert HasOutcome();
        done = true;
        uint256 amount = address(this).balance;
        emit Refunded(amount);
        pay(depositor, amount);
    }

    /// @notice Pays out a credit left by a payout the recipient refused.
    function withdraw() external {
        uint256 amount = owed[msg.sender];
        if (amount == 0) revert NothingOwed();
        owed[msg.sender] = 0;
        (bool ok,) = msg.sender.call{value: amount}("");
        if (!ok) revert NothingOwed();
    }

    function pay(address to, uint256 amount) private {
        (bool ok,) = to.call{value: amount}("");
        if (!ok) {
            owed[to] += amount;
            emit Owed(to, amount);
        }
    }

    function sort(uint256[] memory ps, uint256 n) private pure {
        for (uint256 i = 1; i < n; i++) {
            uint256 v = ps[i];
            uint256 j = i;
            for (; j > 0 && ps[j - 1] > v; j--) ps[j] = ps[j - 1];
            ps[j] = v;
        }
    }

    /// The median of the first n sorted values, the mean of the middle two for an even n.
    function median(uint256[] memory ps, uint256 n) private pure returns (uint256) {
        return n % 2 == 1 ? ps[n / 2] : (ps[n / 2 - 1] + ps[n / 2]) / 2;
    }
}

Tested on a Local Network

We do not deploy GoldEscrow on ETC mainnet. It runs in the end-to-end test, steps 5 to 7 of scripts/e2e.ts, on the local devnet with operator a and quorum 1, and in the contract tests on anvil with an operator list and a quorum above 1. The prices are the fixture server's Swissquote and gold-api quotes, recorded by the devnet calendar on its 10 s slots.

The test deploys four escrows with escrow.ts deploy. Escrows 1 and 2 share a fixing slot, with a strike under the fixing price and one $1,000 above it. Escrow 3 fixes 20 s later with Swissquote switched off for its slot, and escrow 4 fixes 40 s later with Swissquote and gold-api both off. A fixing time that is not a slot start is refused at deploy.

Submits of a's records are stored in escrows 1 and 2. A record of another slot reverts NotTheFixingSlot, and a lower-ranked pair after a higher one is stored and never replaces the higher-ranked price. We stamped the sha256 of a forged price record on a and upgraded it to its anchor, so existedAtBy returns a time for it, and its submit still reverts. Escrow 3 takes a's gold-api record, and for escrow 4 a has no record of the slot to prove.

A submit after settleBy reverts Closed. Escrow 2 settles to the depositor at a's record price and escrow 3 to the beneficiary at a's gold-api price. After refundAfter, a refund of escrow 1 reverts HasOutcome and escrow 1 settles to the beneficiary with Settled.price equal to a's record. Escrow 4 has no outcome to settle on and refunds the depositor.

Run them again
pnpm --filter @etchd/contract test         # GoldEscrow on anvil, with an operator list
DEVNET_PORT_OFFSET=2800 pnpm e2e           # the whole end-to-end test, steps 5 to 7 are the escrows

Producing the Inputs

The demo scripts sit in scripts/examples. Each takes --calendar <cal>=<url>, repeatable, and --quorum. Both default to calendar a and quorum 1, and a second operator is one more --calendar. The scripts refuse a demo key that any listed calendar anchors with. Deploy reads the operators from /v1/signers and the schedules from /v1/sources. --deadline, --settle-by and --refund-after count from the fixing.

Deploy
ETCHD_DEMO_KEY=0x... node scripts/examples/escrow.ts deploy \
  --beneficiary 0xB0B... --strike 4000 --fixing 2026-11-02T12:00:01Z --deposit 10000000000000000 \
  --pair swissquote:XAU/USD --pair goldapi:XAU/USD \
  --deadline +3600 --settle-by +7200 --refund-after +604800

After the fixing, etchd records proof builds the PriceProof[]. From the live API it reads /v1/records/:id, and from a daily CAR it works offline from the anchor blocks the CAR holds. --pair repeats in rank order, and the CLI takes, per calendar, the best-ranked pair present in the slot that starts at --slot, which is what the escrow counts for that operator. A --slot that is not a slot start is a usage error naming the slot start that holds it.

Submit and settle
# from the live API of calendar a
etchd records proof https://a.etc.calendar.etchd.app --pair swissquote:XAU/USD --pair goldapi:XAU/USD \
  --slot 2026-11-02T12:00:01Z --abi submit --json > proofs.json

# or offline, from a's daily CAR
etchd records proof --car a-day.car --pair swissquote:XAU/USD --pair goldapi:XAU/USD \
  --slot 2026-11-02T12:00:01Z --abi submit --check --rpc https://rpc.classix.dev --verifier 0xVERIFIER... --json > proofs.json

ETCHD_DEMO_KEY=0x... node scripts/examples/escrow.ts submit --address 0xESCROW... --proofs proofs.json
node scripts/examples/escrow.ts status --address 0xESCROW...
ETCHD_DEMO_KEY=0x... node scripts/examples/escrow.ts settle --address 0xESCROW...

submit and settle send a transaction, so they need ETCHD_DEMO_KEY. Anyone's funded key works, as long as it is not an operator key. status only reads. --check runs isRecordPath and an eth_call of the verifier before anything is sent. --calldata prints the ABI calldata of submit for any other wallet. The price series around the fixing is etchd records list a-day.car --branch price --key swissquote:XAU/USD --format csv.

Tests

escrow.test.ts runs GoldEscrow on Anvil with several accounts as operators, each anchoring its own ticks, since the registry keys on the sender. It settles on the median of three records, pays the depositor under the strike, takes the mean of the middle two for an even count, and settles with one operator and quorum 1 as the demo does. It drops one outlier above and one below, refunds when two outliers sit on opposite sides, keeps a price at the band edge, and refunds under quorum. A fallback pair counts until a better-ranked one replaces it. A record captured at second 00 counts for the slot that started at 01 before it. Two records of one operator in one slot disqualify it in every submit order, inside one call and across calls.

The negatives are the same record claimed for another operator, an unlisted operator or pair, a kind 5 close and a revision, a stale quote and one from the future, an anchor after the deadline, a capture long after its anchor, a wrong length, schema or zero price, a record stamped as a user digest, submit after settleBy, settle before it, a refund while an outcome exists, ETC sent to the escrow and bad terms. The end-to-end run in scripts/e2e.ts deploys four escrows against a devnet calendar with operator a and quorum 1, one paying each side, one settling on gold-api while Swissquote is off, and one refunding with both off.

Limits

With one operator the escrow trusts Classix for the price. The band and the quorum protect against one bad operator only once several independent operators are listed. A price record says what the operator saw, and a provider that quotes a wrong price is recorded faithfully.

The escrow settles one fixing once. A price feed that other contracts read for free, and prices from other blockchains read through the heads that ticks already record, are not built. The roadmap lists them.

A key rotation of a listed operator before settlement removes that operator from the escrow, so terms list operators whose keys are not about to rotate. Block times on a proof-of-work chain run up to about one block early or late, so terms leave a margin around deadline.