Proofs in Contracts

A contract on Ethereum Classic can check an Etchd proof itself. EtchdRegistry keeps the time each anchor first reached it, and EtchdVerifier runs a proof's path from the digest up to an anchor and asks the registry when it saw it.

Alpha, unaudited. Proofs anchored through EtchdAnchor before 2026-10-24 are not in the registry, and the verifier reverts NotAnchored for them. They keep verifying off chain. The record starts fresh on 2026-10-25, the first full UTC day on the registry. Nothing here comes with a guarantee. Alpha, No Guarantees says what that means.

Both contracts are on ETC mainnet and verified on Sourcify. Blockscout does not list solc 0.8.38 yet. EtchdRegistry is at 0x0d92719423d6ccd72fec9ad3e12e8022d1b9a6f5 (verified source) and EtchdVerifier at 0x8b1793c31c21f11c4d1446009de4128039f55867 (verified source). The calendar has anchored every tick to the registry since 2026-10-24.

The Registry

Each minute the calendar sends the same 40 bytes it sent to EtchdAnchor, the newest tick number and a batch root. EtchdRegistry stores the block time and block number under the keccak256 of those bytes, the first time it sees them. A later write of the same bytes changes nothing, and every write is also logged with the sender as the only topic, as EtchdAnchor did.

calldata  = u64be(last_n) || batch_root                      40 bytes, unchangedrecord    = keccak256(calldata)slot      = u64(block.timestamp) << 64 | u64(block.number)   first write onlyseen(record) -> (time, blockNumber)                          zeros for a record never seen

The registry has no owner and no access check, and it rejects any ETC sent to it. Anyone can record 40 bytes, and a record only shows that those bytes existed by that block. A proof still checks the input of one exact transaction off chain, as Verifying describes, so off-chain verification does not change.

An open registry does not let anyone fake a time for your file. The verifier reaches a record by hashing up from your digest, and nobody can pick 40 bytes that your digest hashes to without knowing the digest first. Someone who copies our calldata and gets it mined first moves the record to an earlier block, which is still after your digest existed.

The Verifier

EtchdVerifier takes a digest and a path. It runs the path from the digest, expects 40 bytes at the end, and looks their keccak256 up in the registry.

function existedAt(bytes32 digest, bytes calldata path) external view returns (uint64 time, uint64 blockNumber);function recordOf(bytes32 digest, bytes calldata path) public pure returns (bytes32 record);error BadPath(uint256 at);         // malformed at byte at, or not 40 bytes at the enderror NotAnchored(bytes32 record); // the registry has never seen record

The time is the block time at which the registry first saw the record, the same upper bound an off-chain check gives. Your digest existed by then. The precision is the one What a Proof Proves sets out, the rest of the minute plus the wait for the anchor block.

Path Encoding

The path is the ops of the ETC branch of a .ots proof, each written with its OpenTimestamps tag. Etchd proofs use only these four ops, and the contract reverts on any other.

sha256     = 08keccak256  = 67append     = f0 || u16be(len) || arg       len 1 to 4,096prepend    = f1 || u16be(len) || arg       len 1 to 4,096

A message longer than 4,096 bytes reverts as well, and so does a path longer than 8,192 bytes. Real paths stay under 2 KB. The length is two bytes, and not the varuint of the .ots file, so the contract reads it with a single shift. contractPaths in @etchd/core turns a proof into the digest, the path and the 40 bytes it ends in. The Stamp page does the same for an anchored proof, under Check on ETC and For Developers.

Using It

The digest is the SHA-256 of the file, and the path comes from its proof.

interface IEtchdVerifier {
    function existedAt(bytes32 digest, bytes calldata path) external view returns (uint64 time, uint64 blockNumber);
}

(uint64 t,) = verifier.existedAt(digest, path);
require(t <= deadline);
import { contractPaths, DetachedTimestampFile, toHex } from "@etchd/core";

const [p] = contractPaths(DetachedTimestampFile.deserialize(otsBytes));
const args = ["0x" + toHex(p.digest), "0x" + toHex(p.path)];

The gold price escrow is a worked example. It uses the verifier for both ends of a time bracket.

Gas

Measured on Anvil in proofs.test.ts. Each figure is a whole transaction, so it includes the 21,000 base and the calldata. A call to existedAt from inside another contract skips both.

CallGas
One anchor to EtchdAnchor22,701
One first record in EtchdRegistry44,934
existedAt on the 435-byte proof from the spec59,555
GoldEscrow settle with three observations116,512

The cold store of a new slot makes each anchor to the registry cost the calendar about twice as much. At 1 gwei and one transaction a minute that is about 0.065 ETC a day, against 0.033 ETC for EtchdAnchor.

Reorgs

The registry's storage is chain state, so it reorgs with the anchor. When a reorg drops the anchor transaction, the record goes with it, and existedAt reverts NotAnchored until the calendar anchors the tick again. The new record carries the new block's time and number. A contract that pays out on a proof can wait for depth by comparing the returned blockNumber with block.number, as an off-chain verifier counts confirmations.

The Source

Both contracts are compiled with solc 0.8.38 and the settings in packages/contract/verify. This is EtchdRegistry.sol.

EtchdRegistry.sol
// SPDX-License-Identifier: MITpragma solidity 0.8.38; /// @title etchd registry reader/// @notice `seen` returns the block time and number at which a 40-byte record first reached EtchdRegistry, or zeros for///         a record it has never received.interface IEtchdRegistry {    function seen(bytes32 record) external view returns (uint64 time, uint64 blockNumber);} /// @title etchd registry/// @author Classix/// @notice Once every UTC minute etchd (https://etchd.app) sends this contract 40 bytes: the newest tick number///         covered (8 bytes, big-endian) and a Merkle root (32 bytes) over the ticks in that batch. The contract keeps///         the time and block number at which it first saw each 40 bytes, so other contracts can check an etchd proof///         on chain through EtchdVerifier./// @dev No owner and no access check. Anyone can record 40 bytes, and a record only shows that those bytes existed at///      that block, whoever sent them. The first write of a record stands and later writes of it change nothing. Every///      record is also logged unchanged with the sender as the only topic, as EtchdAnchor did. A 36-byte call with the///      selector of seen(bytes32) reads a record. Any other call reverts. No ABI call is 40 bytes long, so a record can///      never be taken for a function call.contract EtchdRegistry {    fallback() external {        assembly ("memory-safe") {            switch calldatasize()            case 40 {                calldatacopy(0, 0, 40)                // The record's slot is the keccak256 of its 40 bytes. The contract has no other storage.                let slot := keccak256(0, 40)                if iszero(sload(slot)) { sstore(slot, or(shl(64, timestamp()), number())) }                log1(0, 40, caller())                stop()            }            case 36 {                // seen(bytes32) is 0xbeeb3f34, checked against the interface in the contract tests.                if iszero(eq(shr(224, calldataload(0)), 0xbeeb3f34)) { revert(0, 0) }                let v := sload(calldataload(4))                mstore(0, shr(64, v))                mstore(32, and(v, 0xffffffffffffffff))                return(0, 64)            }            default { revert(0, 0) }        }    }}

And EtchdVerifier.sol.

EtchdVerifier.sol
// SPDX-License-Identifier: MITpragma solidity 0.8.38; import {IEtchdRegistry} from "./EtchdRegistry.sol"; /// @title etchd proof verifier/// @author Classix/// @notice Checks an etchd proof on chain. `existedAt` runs the ops of the ETC branch of a `.ots` proof from the///         digest, and when they end in a 40-byte record that EtchdRegistry has seen, it returns the time and block///         number of the first time it saw it. The digest existed by that time./// @dev The path is the ops of one branch, each as its OpenTimestamps tag. sha256 (0x08) and keccak256 (0x67) take no///      argument. append (0xf0) and prepend (0xf1) are followed by a 2-byte big-endian length, 1 to 4,096, and that///      many bytes. Other ops revert, and so does a message over 4,096 bytes or a path over 8,192 bytes. etchd proofs///      use only these four ops.///      The contract has no owner and cannot be changed.contract EtchdVerifier {    IEtchdRegistry public immutable registry;     /// The path is malformed at byte `at`, or does not end in 40 bytes.    error BadPath(uint256 at);    /// The path ends in `record`, which the registry has never seen.    error NotAnchored(bytes32 record);     constructor(IEtchdRegistry registry_) {        registry = registry_;    }     /// @return time the block time at which the registry first saw the record the digest leads to    /// @return blockNumber the number of that block    function existedAt(bytes32 digest, bytes calldata path) external view returns (uint64 time, uint64 blockNumber) {        bytes32 record = recordOf(digest, path);        (time, blockNumber) = registry.seen(record);        if (time == 0) revert NotAnchored(record);    }     /// @return record the keccak256 of the 40 bytes the path ends in, the key EtchdRegistry stores it under    function recordOf(bytes32 digest, bytes calldata path) public pure returns (bytes32 record) {        // Each op copies the message, so memory grows with the square of the path. Real paths stay under 2 KB.        if (path.length > 8192) revert BadPath(8192);        bytes memory m = abi.encodePacked(digest);        uint256 i;        while (i < path.length) {            uint8 op = uint8(path[i]);            if (op == 0x08) {                m = abi.encodePacked(sha256(m));                i += 1;            } else if (op == 0x67) {                m = abi.encodePacked(keccak256(m));                i += 1;            } else if (op == 0xf0 || op == 0xf1) {                if (i + 3 > path.length) revert BadPath(i);                uint256 n = uint16(bytes2(path[i + 1:i + 3]));                if (n == 0 || n > 4096 || i + 3 + n > path.length) revert BadPath(i);                bytes calldata arg = path[i + 3:i + 3 + n];                m = op == 0xf0 ? bytes.concat(m, arg) : bytes.concat(arg, m);                if (m.length > 4096) revert BadPath(i);                i += 3 + n;            } else {                revert BadPath(i);            }        }        if (m.length != 40) revert BadPath(path.length);        record = keccak256(m);    }}