# Example: A Gold Price Escrow

GoldEscrow holds ETC until a gold price fixing and then pays one of two parties, depending on where gold closed. Three signers read the price from three providers, and Etchd fixes when they signed from both sides.

**Alpha, unaudited.** GoldEscrow is an example of the primitive on [Proofs in Contracts](https://etchd.app/docs/contracts). Nobody has deployed or audited it, and it is not a price feed. Nothing here comes with a guarantee. [Alpha, No Guarantees](https://etchd.app/docs/trust#alpha) says what that means.

## The Deal

The depositor deploys the contract with the ETC locked in it and a Terms struct. The terms are fixed in the constructor and cannot change, and the constructor reverts BadTerms on lists out of order, a zero beneficiary or times out of sequence.

| Term | Meaning |
| --- | --- |
| beneficiary | Paid when the average price is at or above the strike |
| strike | USD per troy ounce, 8 decimals |
| signers, providers, quorum | Who can sign and which price sources count, each list in strictly ascending order, and how many observations settle it |
| maxSpreadBps | How far apart the prices can be, in basis points of the lowest. 100 is 1% |
| fixingTime, observeBy | The window an observation must fall in |
| deadline | The bundle of observations must be stamped by this time |
| refundAfter | When nobody has settled by now, the depositor can take the ETC back |

After the fixing each signer reads one provider and signs an EIP-712 Observation, the provider, the price and a registry record. Someone stamps the bundle of observations and signatures through Etchd. Once the anchor is on chain, anyone can call `settle` with the bundle and its path. The contract checks the signatures and the spread, averages the prices and pays the beneficiary when the average is at or above the strike, and the depositor otherwise.

```solidity
struct Observation {
    bytes32 provider;  // keccak256("swissquote:XAU/USD")
    uint256 price;     // USD per troy ounce, 8 decimals
    bytes32 notBefore; // an EtchdRegistry record first seen between fixingTime and observeBy
}

bundle = sha256(abi.encode(obs, sigs))     the bytes that get stamped
```

## Three Providers

We read three providers on 2026-10-24, each of which publishes a gold price anyone can read without an account. The provider id in an observation is the keccak256 of a name such as `swissquote:XAU/USD`.

| Provider | What it quotes |
| --- | --- |
| Swissquote | XAU/USD spot, from its public quote feed |
| Chainlink on Ethereum | The XAU/USD aggregator, read with eth_call |
| Kraken | PAXGUSD, a token backed by gold that trades close to spot |

Coinbase PAXG-USD, Binance PAXGUSDT, Kraken XAUTUSD and gold-api.com work as spares. On 2026-10-24 Pyth's Hermes API wanted a key, and the LBMA price JSON sat behind a Cloudflare challenge, so neither made the list. A token price drifts from spot when its market is thin, and the spread check catches that.

## Agreement

Each observation must name a different provider and come from a different signer, with the signers in ascending address order so none counts twice. The prices must sit within `maxSpreadBps` of each other, and their integer mean decides.

```text
(high - low) * 10,000 > maxSpreadBps * low    reverts SpreadTooWide
average = sum / quorum
paid    = average >= strike ? beneficiary : depositor
```

## The Time Bracket

A signature carries no time of its own. GoldEscrow takes both ends of the bracket from Etchd.

The lower bound is `notBefore`. Each observation names a record the registry first saw between `fixingTime` and `observeBy`, and the signer had to know those 40 bytes to sign. An anchor from Etchd's [published signer](https://etchd.app/docs/signers) commits to chain heads from its own minute, so nobody knows its bytes before it is made. The registry does not record who sent a record, so signers check off chain that the record they name is one of ours. The window also stops an observation signed for an earlier fixing from being used again, since its record is older.

The upper bound is the stamp. `settle` calls `existedAt` on the bundle's digest and reverts StampedLate when the time it returns is after the deadline. Between the two, every signature in the bundle was made after the fixing and before the deadline, to the precision of ETC block times. A block time on a proof-of-work chain can run up to about one block early, so terms leave a margin around `fixingTime` and `deadline`.

```solidity
(uint64 stampedAt,) = verifier.existedAt(sha256(abi.encode(obs, sigs)), path);
if (stampedAt > deadline) revert StampedLate(stampedAt);
(uint64 seenAt,) = verifier.registry().seen(o.notBefore);
if (seenAt < fixingTime || seenAt > observeBy) revert ObservedOutsideWindow(o.notBefore, seenAt);
```

Signers never send a transaction and need no ETC. Settling is no race either. A bundle stamped in time settles whenever someone sends it, before `refundAfter`. The signatures are EIP-712 with no verifying contract in the domain, so one observation serves every escrow that trusts its signer and its provider. A recipient that rejects ETC is credited instead and takes it later with `withdraw`, so the outcome never depends on whether it accepts.

## Equivocation

Nothing stops a signer from signing twice in the window with different prices. Both bundles pass every check once stamped, and the first one to reach `settle` wins, after which the contract reverts AlreadySettled. The same race opens when the terms trust more signers or providers than the quorum, since the parties can then pick different subsets. The contract cannot tell which bundle was honest. Both are public and signed, so the double signing is plain to see afterwards, but the escrow has paid by then. A narrow window and a quorum equal to the number of providers keep the room for it small, and GoldEscrow trusts its signers for the rest.

## Future Work

GoldEscrow checks every signature itself. A generic contract on top of the verifier would keep a price feed instead. It would settle each fixing once from a stamped bundle, and other contracts would read the price from it for free. Prices read from other blockchains are a second direction, starting from the block heads the beacon already records in every [tick](https://etchd.app/docs/ticks). A third is ZK fraud proofs, where a settlement posts only its result and anyone with a proof can challenge a wrong one, which saves the gas of checking every signature. None of this is built yet, and the [roadmap](https://github.com/classix-dev/etchd/blob/main/ROADMAP.md#on-chain-proofs) lists it.

## Tests

[proofs.test.ts](https://github.com/classix-dev/etchd/blob/main/packages/contract/test/proofs.test.ts) deploys the registry, the verifier and GoldEscrow on Anvil and runs a settlement with three observations, an average under the strike, a spread too wide, a late stamp, a reused provider, signers out of order, an observation outside the window, a recipient that rejects ETC and a refund. It is the reference for the exact encodings, and the signing follows [EIP-712](https://eips.ethereum.org/EIPS/eip-712).

## The Source

This is [GoldEscrow.sol](https://github.com/classix-dev/etchd/blob/main/packages/contract/src/examples/GoldEscrow.sol).

GoldEscrow.sol

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.38;

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

/// @title Gold price escrow, an etchd example
/// @author Classix
/// @notice An example of a contract that pays out on a signed price. The depositor locks ETC. Between `fixingTime`
///         and `observeBy`, a quorum of trusted signers each read the gold price (USD per troy ounce, 8 decimals)
///         from a different provider and sign it. Anyone stamps the bundle of observations and signatures through
///         etchd, and once the stamp is on chain anyone can settle. When the prices agree within `maxSpreadBps`,
///         their average decides. The beneficiary is paid when it is at least `strike`, and the depositor gets the ETC
///         back otherwise. When nobody settles by `refundAfter`, the depositor can take the ETC back.
/// @dev Etchd bounds each signature in time from both sides. An observation names an etchd record (`notBefore`) that
///      the registry first saw between `fixingTime` and `observeBy`, and the bundle's etchd proof shows it existed by
///      `deadline`. Signers never send a transaction. The first valid bundle to reach `settle` decides, so when more
///      signers or providers are trusted than the quorum, or a signer signs twice in the window, the parties can race
///      with different bundles. A narrow window and a quorum equal to the number of providers keep that small.
///      The registry does not record who sent a record, so `notBefore` only proves "signed after" when it is a record
///      the signer could not have known in advance, such as an anchor from etchd's published signer, which commits to
///      beacon chain heads. Signers check that off chain. Block times on a proof-of-work chain can run up to about one
///      block early, so leave a margin around `fixingTime` and `deadline`. Signatures are EIP-712 without a verifying
///      contract, so one observation serves every escrow that trusts its signer. This is an example of the primitive,
///      not a production price feed, and it has not been audited.
contract GoldEscrow {
    struct Observation {
        bytes32 provider; // keccak256 of the provider's name, for example keccak256("swissquote:XAU/USD")
        uint256 price; // USD per troy ounce, 8 decimals
        bytes32 notBefore; // an EtchdRegistry record first seen between fixingTime and observeBy
    }

    struct Signature {
        uint8 v;
        bytes32 r;
        bytes32 s;
    }

    struct Terms {
        address beneficiary;
        address[] signers; // strictly ascending, no zero address
        bytes32[] providers; // strictly ascending
        uint256 quorum;
        uint256 strike; // USD per troy ounce, 8 decimals
        uint256 maxSpreadBps; // widest allowed (high - low) / low, in basis points
        uint64 fixingTime;
        uint64 observeBy;
        uint64 deadline;
        uint64 refundAfter;
    }

    bytes32 public constant ASSET = keccak256("XAU/USD");
    bytes32 public constant OBSERVATION_TYPEHASH =
        keccak256("Observation(bytes32 asset,bytes32 provider,uint256 price,bytes32 notBefore)");
    bytes32 private constant DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId)");

    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 fixingTime;
    uint64 public immutable observeBy;
    uint64 public immutable deadline;
    uint64 public immutable refundAfter;
    uint256 private immutable cachedChainId;
    bytes32 private immutable cachedDomainSeparator;

    mapping(address => bool) public isSigner;
    mapping(bytes32 => bool) public isProvider;
    /// ETC a recipient could not take when it was paid, for it to withdraw.
    mapping(address => uint256) public owed;
    bool public settled;

    event Settled(uint256 average, address paid, uint256 amount);
    event Refunded(uint256 amount);
    event Owed(address to, uint256 amount);

    error AlreadySettled();
    error WrongCount();
    error StampedLate(uint64 stampedAt);
    error UnknownProvider(bytes32 provider);
    error RepeatedProvider(bytes32 provider);
    error ObservedOutsideWindow(bytes32 notBefore, uint64 seenAt);
    error BadSignature(uint256 index);
    error SignersOutOfOrder(uint256 index);
    error SpreadTooWide(uint256 low, uint256 high);
    error TooEarly();
    error NothingOwed();
    error BadTerms();

    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.signers.length || t.quorum > t.providers.length) revert BadTerms();
        if (t.fixingTime == 0 || t.fixingTime > t.observeBy || t.observeBy > t.deadline || t.deadline >= t.refundAfter) {
            revert BadTerms();
        }
        // Strictly ascending lists rule out duplicates, and the zero address cannot come first.
        for (uint256 i; i < t.signers.length; i++) {
            if (i == 0 ? t.signers[0] == address(0) : t.signers[i] <= t.signers[i - 1]) revert BadTerms();
            isSigner[t.signers[i]] = true;
        }
        for (uint256 i; i < t.providers.length; i++) {
            if (i > 0 && t.providers[i] <= t.providers[i - 1]) revert BadTerms();
            isProvider[t.providers[i]] = true;
        }
        verifier = verifier_;
        depositor = msg.sender;
        beneficiary = t.beneficiary;
        quorum = t.quorum;
        strike = t.strike;
        maxSpreadBps = t.maxSpreadBps;
        fixingTime = t.fixingTime;
        observeBy = t.observeBy;
        deadline = t.deadline;
        refundAfter = t.refundAfter;
        cachedChainId = block.chainid;
        cachedDomainSeparator = buildDomainSeparator();
    }

    /// Settles on `quorum` observations, each from a different provider and signed by a different signer, with the
    /// signers in ascending address order.
    /// @param obs the observations, in the order of their signers
    /// @param sigs the signature of each observation
    /// @param path the etchd proof of sha256(abi.encode(obs, sigs)), the bytes that were stamped
    /// @return average the mean of the observed prices
    function settle(Observation[] calldata obs, Signature[] calldata sigs, bytes calldata path) external returns (uint256 average) {
        if (settled) revert AlreadySettled();
        if (obs.length != quorum || sigs.length != quorum) revert WrongCount();
        (uint64 stampedAt,) = verifier.existedAt(sha256(abi.encode(obs, sigs)), path);
        if (stampedAt > deadline) revert StampedLate(stampedAt);

        address last;
        uint256 low = type(uint256).max;
        uint256 high;
        uint256 sum;
        for (uint256 i; i < obs.length; i++) {
            Observation calldata o = obs[i];
            if (!isProvider[o.provider]) revert UnknownProvider(o.provider);
            for (uint256 j; j < i; j++) if (obs[j].provider == o.provider) revert RepeatedProvider(o.provider);
            (uint64 seenAt,) = verifier.registry().seen(o.notBefore);
            if (seenAt < fixingTime || seenAt > observeBy) revert ObservedOutsideWindow(o.notBefore, seenAt);
            address signer = recover(o, sigs[i]);
            if (signer == address(0) || !isSigner[signer]) revert BadSignature(i);
            if (signer <= last) revert SignersOutOfOrder(i);
            last = signer;
            if (o.price < low) low = o.price;
            if (o.price > high) high = o.price;
            sum += o.price;
        }
        if ((high - low) * 10_000 > maxSpreadBps * low) revert SpreadTooWide(low, high);
        average = sum / obs.length;

        settled = true;
        address to = average >= strike ? beneficiary : depositor;
        uint256 amount = address(this).balance;
        emit Settled(average, to, amount);
        pay(to, amount);
    }

    /// Returns the deposit to the depositor when nobody has settled by refundAfter.
    function refund() external {
        if (settled) revert AlreadySettled();
        if (block.timestamp < refundAfter) revert TooEarly();
        settled = true;
        uint256 amount = address(this).balance;
        emit Refunded(amount);
        pay(depositor, amount);
    }

    /// Sends the caller the ETC it could not take when it was paid.
    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();
    }

    /// The EIP-712 domain separator, rebuilt when the chain id differs from the one at deploy, as after a fork.
    function DOMAIN_SEPARATOR() public view returns (bytes32) {
        return block.chainid == cachedChainId ? cachedDomainSeparator : buildDomainSeparator();
    }

    /// The EIP-712 digest a signer signs for `o`.
    function observationDigest(Observation calldata o) public view returns (bytes32) {
        bytes32 structHash = keccak256(abi.encode(OBSERVATION_TYPEHASH, ASSET, o.provider, o.price, o.notBefore));
        return keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR(), structHash));
    }

    function buildDomainSeparator() private view returns (bytes32) {
        return keccak256(abi.encode(DOMAIN_TYPEHASH, keccak256("Etchd Observation"), keccak256("1"), block.chainid));
    }

    // A recipient that rejects ETC gets it credited instead, so the outcome never depends on whether it accepts.
    function pay(address to, uint256 amount) private {
        (bool ok,) = to.call{value: amount}("");
        if (!ok) {
            owed[to] += amount;
            emit Owed(to, amount);
        }
    }

    function recover(Observation calldata o, Signature calldata sig) private view returns (address) {
        // Reject the high-s twin of each signature, as OpenZeppelin's ECDSA does.
        if (uint256(sig.s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) return address(0);
        return ecrecover(observationDigest(o), sig.v, sig.r, sig.s);
    }
}
```
