# Headline Bets

This page walks through two example contracts that settle a bet on what the news says from etchd headline records. MentionMarket pays if a BBC headline mentions Ethereum Classic before 2030, and settles on one record. SameDayMarket pays if five news sources put a pandemic in their headlines on the same UTC day, counted over as many transactions as it takes.

![A fan of newspapers beside a clipped headline strip pressed under a green wax seal and chained to a small stone block, next to a brass balance scale with coins in one pan and an open pocket watch.](https://etchd.app/docs/headline-bets-news-to-scale.webp)

A matching headline record proven before the deadline pays both stakes to YES, and with none by then the pot goes to NO.

**Alpha, unaudited.** MentionMarket and SameDayMarket are examples. Nobody has audited them. Nothing here comes with a guarantee. [Alpha, No Guarantees](https://etchd.app/docs/trust#alpha) says what that means.

**Three operators, one running.** The example contracts list three etchd operators, calendars `a`, `b` and `c`, with a quorum of two. Only calendar `a`, run by Classix, exists today, so these deployments cannot be made on mainnet as written. They show the trust a contract should ask for, and none of them is deployed. A contract that trusts one operator trusts Classix alone.

## What They Share

Every item etchd reads from a front-page feed becomes a [headline record](https://etchd.app/docs/records#headline) in branch 5 of a tick, with the source id (such as `bbc-front`), the title and the link. Once the tick is anchored on ETC, anyone holding the record path can prove the headline to a contract.

Both markets are a bet between two parties with even stakes. Say Alice stakes 0.5 ETC on YES and Bob 0.5 ETC on NO. Neither wants to hold the other's stake, and neither wants to be the one who says what the news printed. Alice deploys the market with her stake and names Bob, and Bob takes the bet with the same stake before the deadline. Anyone submits the headlines that answer the question, and the proof that does pays both stakes to Alice. If nobody has by `settleBy`, anyone calls `settleNo` and Bob gets both.

Until Bob takes the bet Alice can cancel it and get her stake back. A proof before he takes reverts `NotTaken`, so the bet never resolves with one side missing. A recipient that rejects ETC is credited instead and takes it later with `withdraw`. All of this sits in one base contract, HeadlineMarket, so the trust, the phrase and the time bracket below are the same code in both markets.

### What They Trust

A headline record is the operator's account of what a feed listed (see [Trust Classes](https://etchd.app/docs/sources#trust)). The contract proves each record against the operators' own anchors through `recordExistedAtBy`, so it trusts nobody who submits.

A market takes a list of operators and a quorum, as the [gold escrow](https://etchd.app/docs/gold-escrow) does. The list holds the anchor keys of the calendars on the day of deploy, strictly ascending, and the quorum is how many of them must each have anchored the same headline record before it counts. A proof carries the record once and one record path per operator, with the operators in ascending order, so no operator counts twice and an operator off the list reverts `NotOperator`. With quorum 2 of three listed calendars, one operator that invents or drops a headline cannot decide the bet alone.

The example terms of both markets use this list.

```solidity
operators = [
    0x0D9033c4b0b8d2e599968A458C3CFB84669bCe19,   // calendar a, run by Classix
    0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,   // calendar b, a placeholder
    0xcccccccccccccccccccccccccccccccccccccccc    // calendar c, a placeholder
];
quorum = 2;
```

A key rotation before `settleBy` leaves that operator's later anchors under an address the market does not count ([Operators](https://etchd.app/docs/signers#operators)).

NO has no proof of its own. Proving that no headline matched, or that no day reached five sources, would mean submitting every branch 5 record of every tick up to the deadline, about 7,000 a day, and proving each tree complete. So the contract takes the absence of a proof by `settleBy` as the answer. YES has every reason to submit a match, and anyone can, so a match that exists gets submitted.

### The Phrase

The phrase is 1 to 32 bytes of printable ASCII with no capital letter, and the constructor refuses anything else. The match folds A to Z in the title to lower case and compares bytes, so `Ethereum Classic`, `ETHEREUM CLASSIC` and `ethereum classics` match `ethereum classic`, and `Ethereum-Classic` or two spaces between the words do not. Bytes of multi-byte UTF-8 characters are 0x80 and up, so they never fold and never match. A title in full-width letters does not match, and a title with accented words around the phrase does.

The feed parser has normalized the title before it was recorded. Every run of white space is one space, both ends are trimmed, entities are decoded and the text is in NFC, so a feed's own markup does not split the phrase.

| Title | Matches ethereum classic |
| --- | --- |
| Why EtHeReUm ClAsSiC miners stayed | yes |
| Märkte: «Ethereum Classic» im Plus | yes |
| Ethereum-Classic forks | no |
| Ethereum rallies as classic cars sell | no |

### The Time Bracket

A headline holds no time of its own, since one item gives the same bytes on every capture. The only time the contract can prove is when an operator's anchor of the tick first reached the registry, and the bracket bounds that. Every operator a proof gives must have anchored the record inside the bracket. The record's time, `anchoredAt` below, is the latest of those anchors, the moment the quorum was complete.

```text
openedAt <= each operator's anchor < deadline   anchored after the bet existed and before the deadline
anchoredAt = the latest of those anchors        the time the quorum was complete
block.timestamp <= settleBy                submit
block.timestamp >  settleBy                settleNo
block.timestamp <  deadline                take
openedAt < deadline < settleBy
```

A headline anchored before the bet was deployed is known to both sides already, so it cannot decide it. The gap from `deadline` to `settleBy` is the grace for a match anchored in the last minutes, so its proof can wait for confirmations and still land. The example terms of both markets take 2030-01-01 00:00:00 UTC, Unix time 1893456000, as the deadline and seven days of grace. A headline anchored at 23:59:59 UTC on the last day of 2029 counts, and one anchored a second later does not. With quorum 2, a record that one operator anchored at 23:59:59 and another at 00:00:01 does not count through that pair, and counts through a third operator that anchored it before midnight.

## A Mention Before 2030

Will a BBC headline mention Ethereum Classic before 2030? Alice bets yes and Bob no. When a `bbc-front` headline holds the phrase, anyone submits it and the contract pays both stakes to Alice. The same shape fits any source and any phrase of up to 32 ASCII bytes.

### How the Contract Checks It

`submit(HeadlineProof)` takes one record and a record path per operator while the bet is taken and `settleBy` has not passed. The contract reads the record's bytes by offset, in calldata, and checks them in this order.

1. The envelope is tag 3, branch 5, schema 1, with a length field equal to the bytes after the header. The source is a u8 length and that many bytes, at least one, and the present byte follows. When its bit 0 is set, a u16 length and the title follow. Any length that runs past the end gives `BadRecord`.
2. The source hashes to the market's source, `bbc-front` in the example, else `WrongSource`. A prefix or an extension of the id is another source.
3. The title holds the phrase, else `NoMatch`. A record without a title never matches, and neither does the phrase in the link.
4. The proof gives at least `quorum` paths, else `TooFewOperators`. Each names a listed operator, else `NotOperator`, above the one before it, else `OperatorsOutOfOrder`.
5. For each of them `recordExistedAtBy(operator, 5, record, path)` on EtchdVerifier returns the time that operator's anchor first reached the registry. It must be at or after `openedAt`, the block time of the deploy, else `AnchoredBeforeOpen`, and before the `deadline`, else `AnchoredLate`. The latest of them is the record's `anchoredAt`.
6. The market is done, and both stakes go to YES.

The link, the publication time and the guid after the title are not read. The verifier proves the whole envelope, and nothing here depends on them. The record layout is on [Records](https://etchd.app/docs/records#headline), and the generic record check on [Proofs in Contracts](https://etchd.app/docs/contracts#check).

```solidity
struct Terms {
    address[] operators; uint256 quorum;   // the calendars' anchor keys, ascending, and how many must prove a record
    address no;                            // the NO side
    bytes source; bytes phrase;            // "bbc-front", "ethereum classic"
    uint64 deadline; uint64 settleBy;      // anchors before deadline, proofs until settleBy
}
struct OperatorPath { address operator; EtchdVerifier.RecordPath path; }
struct HeadlineProof { bytes record; OperatorPath[] paths; }   // operators strictly ascending

constructor(EtchdVerifier verifier, Terms memory t) payable;  // YES deploys with its stake
function take() external payable;                // NO, with an equal stake, before the deadline
function cancel() external;                      // YES, while NO has not taken
function submit(HeadlineProof calldata p) external;   // a match, until settleBy, pays YES
function settleNo() external;                    // after settleBy, pays NO
function withdraw() external;                    // a credit left by a refused payout
```

### Gas

Measured on Anvil with Shanghai rules and chain id 61 in [markets.test.ts](https://github.com/classix-dev/etchd/blob/main/packages/contract/test/markets.test.ts). Each figure is a whole transaction, with the 21,000 base and the calldata.

| Call | Gas |
| --- | --- |
| take with the 0.5 ETC stake | 44,938 |
| submit of one matching headline with a short title, one operator, paying 1 ETC to YES | 78,247 |
| submit of the same through two operators of three, quorum 2 | 99,845 |
| submit of a headline with a 200-byte title and the phrase at its end, one operator | 108,236 |
| settleNo with the 1 ETC payout | 37,808 |

The match compares one 32-byte word per position of the title, with the letters folded in the word, so a 200-byte title costs about 200 rounds of a few opcodes and no copy of the title to memory. Each operator past the first adds one more `recordExistedAtBy` call and its path in calldata, about 21,600 gas here.

### Figure

YES deploys MentionMarket with its stake and names NO, who takes the bet with an equal stake. Calendar a commits a bbc-front headline in branch 5 of a tick and anchors the tick on ETC. Anyone submits the record with its path, and the market checks that the source is bbc-front, that the title holds ethereum classic in any ASCII case, and that recordExistedAtBy puts the anchor at or after openedAt and before the deadline, 2030-01-01 00:00 UTC. A match pays both stakes to YES. With no match by settleBy, seven days later, settleNo pays NO.

### Tested on a Local Network

Neither market is on mainnet. Both run in step 12 of [scripts/e2e.ts](https://github.com/classix-dev/etchd/blob/main/scripts/e2e.ts) on the local devnet, listing operator `a` with quorum 1 since the devnet runs one calendar (see [development.md](https://github.com/classix-dev/etchd/blob/main/docs/development.md)). The contract tests on anvil also run them with three operators and quorum 2. The headlines are the fixture server's feeds, recorded by the devnet calendar each minute, and this market reads its BBC feed. No fixture headline mentions Ethereum Classic, so the test runs the YES half on a phrase the fixtures do hold.

The test takes a bbc-front headline that calendar `a` anchored before the markets open and makes the first word of its title the phrase of market 1. Market 0 has the example source and phrase. Market 1 closes fifteen minutes after deploy with two minutes of grace, and market 0 four minutes after with one. A phrase with capitals is refused by `headlines.ts deploy`, and NO takes both markets.

On market 1, the early headline reverts `AnchoredBeforeOpen`, a later bbc-front headline without the phrase `NoMatch`, and a guardian-front headline `WrongSource`. A bbc-front headline with the phrase, anchored after the market opened, resolves YES and pays both stakes to YES. On market 0 the early headline reverts `NoMatch`, `settle-no` before `settleBy` reverts `TooEarly`, and after it pays both stakes to NO.

Run them again

```bash
pnpm --filter @etchd/contract test         # MentionMarket and SameDayMarket on anvil
DEVNET_PORT_OFFSET=2800 pnpm e2e           # the whole end-to-end test, step 12 is the headline markets
```

### Producing the Inputs

The demo script for both markets is [scripts/examples/headlines.ts](https://github.com/classix-dev/etchd/tree/main/scripts/examples). It takes `--calendar <cal>=<url>`, repeatable, and `--quorum`, calendar `a` and quorum 1 by default, reads each operator from `/v1/signers` and refuses a demo key that any of the calendars signs or has signed with. The source, the phrase and the dates of the example terms are the defaults, so Alice gives the three calendars, the quorum, Bob's address and the stake. Hosts `b` and `c` are placeholders, so the deploy stops at their `/v1/signers`.

Deploy and take

```bash
# Alice, with the example terms: bbc-front, "ethereum classic", 2030-01-01T00:00:00Z, seven days of grace
ETCHD_DEMO_KEY=0xA11CE... node scripts/examples/headlines.ts deploy mention \
  --calendar a=https://a.etc.calendar.etchd.app --calendar b=https://b.calendar.example --calendar c=https://c.calendar.example --quorum 2 \
  --no 0xB0B... --stake 500000000000000000

# Bob reads the terms, then takes the bet with the same stake
node scripts/examples/headlines.ts status --address 0xMARKET...
ETCHD_DEMO_KEY=0xB0B... node scripts/examples/headlines.ts take --address 0xMARKET...
```

When the headline appears, `etchd records list` finds its id among the bbc-front records, and `etchd records proof` prints its `{operator, branchId, record, recordPath}` from the live API, or offline from the day's CAR. `submit` groups the proofs it is given by record, keeps one path per listed operator in ascending order, and refuses a record that fewer than `quorum` listed operators prove before it sends anything.

Submit or settle

```bash
etchd records list https://a.etc.calendar.etchd.app --branch headline --key bbc-front --format table
etchd records proof https://a.etc.calendar.etchd.app https://b.calendar.example https://c.calendar.example --id 29900123-5-17 --json > proof.json
ETCHD_DEMO_KEY=0x... node scripts/examples/headlines.ts submit --address 0xMARKET... --proofs proof.json

# anyone, after settleBy with no match
ETCHD_DEMO_KEY=0x... node scripts/examples/headlines.ts settle-no --address 0xMARKET...
```

`submit`, `take`, `cancel` and `settle-no` send a transaction, so they need `ETCHD_DEMO_KEY`. Anyone's funded key works for `submit` and `settle-no`, as long as it is not an operator key. `status` only reads, and tells which of the two markets sits at the address.

### Tests

[markets.test.ts](https://github.com/classix-dev/etchd/blob/main/packages/contract/test/markets.test.ts) runs MentionMarket on Anvil from 2027-01-15, with one account as the operator, quorum 1, anchoring its own ticks of headline records, built the way `encodeHeadline` builds them and checked against the 66-byte vector of SPEC.md. The example terms deploy with the 2030 deadline, take a match anchored at 2029-12-31 23:59:59 UTC, refuse one anchored a second after midnight, and pay NO after the grace, the last two inside a snapshot that is reverted. A bbc-front title with the phrase in mixed case pays YES, as does the phrase at the start or end of a title and among UTF-8 characters, and a match anchored before the deadline still lands in the grace.

The negatives are another source and a prefix or extension of bbc-front, a title without the phrase in five near misses, the phrase only in the link, an anchor at the deadline, an anchor before the market opened, a tick nobody anchored, the same record anchored by an unlisted key (`NotOperator`) or that key's path given as the listed operator's (`NotAnchored`), a title edited after anchoring, a record stamped as a user digest, seven malformed envelopes and a price record, a proof before NO takes, a proof after `settleBy`, `settleNo` before it, a take by someone else, with the wrong stake or after the deadline, a cancel after the take, ETC sent to the market and bad terms, among them a quorum of 0 or above the list, a repeated or unsorted operator and the zero address.

A market of three listed operators with quorum 2 reads its list back. One operator's path alone reverts `TooFewOperators`, and two distinct ones resolve YES. The same operator twice, with one path or two through two of its ticks, reverts `OperatorsOutOfOrder`, as do two operators in descending order. An unlisted operator beside a listed one reverts `NotOperator`. At the deadline, with one operator two seconds before it, one a second before and one on it, the pair that includes the last reverts `AnchoredLate` and the other pair resolves YES. A pair with one anchor from before the market opened reverts `AnchoredBeforeOpen`.

### Limits

A feed that is down for a day, or a headline that the BBC puts up and takes down between two captures, leaves no record, and the bet treats it as if it never ran. A headline that only the BBC's website shows, and not its feed, is never read.

NO wins by silence. If nobody holding a match submits it by `settleBy`, the contract pays NO, and a grace of a week is how long YES has to notice. The bet is fixed at two parties and even stakes, with no odds and no secondary trading.

The match compares bytes. A headline about Ethereum and classic cars matches nothing, and a headline that says ETC alone does not match `ethereum classic`. Terms that want both pick one market per phrase. Block times on a proof-of-work chain run up to about one block early or late, so terms leave a margin around `deadline`.

## Five Sources in One Day

Will five news sources put a pandemic in their headlines on the same day before 2030? One outlet can run a pandemic story on a slow day. Five on the same day says more. Alice stakes that five sources will carry the word in a headline on one UTC day before 2030, and Bob that they will not.

SameDayMarket counts distinct sources per UTC day from etchd headline records. Anyone submits headlines as they find them, in one transaction or in several, and the contract keeps, per day, which sources it has counted. The proof that brings a day to five pays both stakes to Alice.

The count is only as wide as the calendars' source lists. In the example terms a source counts only where two of the three listed calendars read the same headline. A source that etchd does not read never counts, and the source ids are the calendar's own, listed in [Record Sources](https://etchd.app/docs/sources). Two feeds of one publisher are two sources.

### How the Contract Checks It

`submit(HeadlineProof[])` takes records with their record paths while the bet is taken and `settleBy` has not passed. Each record goes through the checks of the [mention market](https://etchd.app/docs/headline-bets#mention-checks) with any source allowed, and then through the count.

1. The envelope reads as a headline of schema 1, laid out as in [Records](https://etchd.app/docs/records#headline), with a source and, when present, a title inside its length, else `BadRecord`.
2. The title holds the phrase, `pandemic` in the example, with A to Z folded to lower case, else `NoMatch`. So `PANDEMIC`, `pandemics` and `post-pandemic` match.
3. At least `quorum` listed operators in ascending order each prove the record through `recordExistedAtBy(operator, 5, record, path)`, with every anchor at or after `openedAt` and before the `deadline`, else `TooFewOperators`, `NotOperator`, `OperatorsOutOfOrder`, `AnchoredBeforeOpen` or `AnchoredLate`. The latest of those anchors is the record's `anchoredAt`.
4. The day is `anchoredAt` divided by 86,400, rounded down, the UTC day number. A source already counted for that day reverts `SourceCounted`, whatever the title, and the whole call reverts with it.
5. The source is counted for the day. When the day's count reaches `sourcesNeeded`, the market is done, both stakes go to YES, and the proofs after that one in the call are not read.

```solidity
struct Terms {
    address[] operators; uint256 quorum;   // the calendars' anchor keys, ascending, and how many must prove a record
    address no;                            // the NO side
    bytes phrase;                          // "pandemic"
    uint256 sourcesNeeded;                 // 5, distinct sources on one UTC day
    uint64 deadline; uint64 settleBy;      // anchors before deadline, proofs until settleBy
}

function submit(HeadlineProof[] calldata proofs) external;   // until settleBy
function counted(uint256 day) external view returns (uint256);
function sourceCounted(uint256 day, bytes32 sourceHash) external view returns (bool);
// take, cancel, settleNo and withdraw as in MentionMarket
```

### Which Time Makes the Day

A headline holds no time and no rank. The tick header holds the time the tick was sealed, but the verifier treats the header as an opaque hash. The one time the contract can prove is `anchoredAt`, the block time at which an operator's anchor of the tick first reached the registry, and that is the time the day comes from. With several operators it is the latest anchor among those the proof gives, the moment the quorum was complete, so a record that two operators anchored either side of midnight counts for the later day. A submitter holding more anchors than the quorum picks which ones to give, and so which of the days the record lands on when its anchors straddle midnight.

On mainnet calendar `a` anchors each minute, so the anchor runs about a minute behind the seal plus ETC's block time. A headline sealed in the last minute of a day can count for the next one. A day near midnight is therefore a minute shorter at one end and longer at the other, and two records whose anchors straddle midnight never combine, even a second apart.

```text
day          = anchoredAt / 86400              the UTC day of the quorum's latest anchor
counted[day] = distinct sources so far         one per source per day
YES          when counted[day] == sourcesNeeded, before settleBy
NO           settleNo after settleBy
```

A calendar commits each distinct headline once per export day, in the tick of the first capture of the day that lists it. A story that stays on a front page across midnight has a record in each day, with the same bytes, so a proof through either tick counts for that tick's day. A source counted on Monday counts again on Tuesday.

### Order and Duplicates

Proofs can arrive in any order and over any number of transactions, since the count of a day only grows. A second record of a source already counted that day reverts, so a submitter cannot pad a day with one outlet's five stories. Because the revert takes the whole call with it, a submitter sends one record per source and day. The first record of a source decides nothing about its title, since any matching title counts once.

| Records submitted | Result |
| --- | --- |
| Five sources, one day, one call | YES |
| Two sources, then one, then two, one day | YES on the third call |
| Four sources, one day | counted 4, NO after settleBy |
| A fifth record from a source already counted | SourceCounted |
| Three sources at 23:59:59 UTC and two at 00:00:00 UTC | 3 and 2 on two days, no YES |

### Gas

Measured as for [MentionMarket](https://etchd.app/docs/headline-bets#mention-gas), whose take and settleNo figures, 44,938 and 37,808, hold here as well since both calls are in the base.

| Call | Gas |
| --- | --- |
| submit of five headlines of one day, one tick, one operator, paying 1 ETC to YES | 350,847 |
| submit of the headline that brings a day to two sources, through two operators of three, paying 1 ETC to YES | 133,796 |

Each counted source writes one new storage word, its flag for the day, adds one to the day's count and makes one `recordExistedAtBy` call. Five records in one call share the cold access to the verifier and the registry.

### Figure

SameDayMarket counts headlines that hold pandemic, each proven against an anchor of calendar a. The day of a record is its anchor time floored to the UTC day, and the market keeps for each day the distinct sources counted so far, across any number of submit calls. A second record of a source already counted that day reverts SourceCounted, and a record anchored on another day counts toward that day only. The first day to reach five sources pays both stakes to YES. With no such day by settleBy, settleNo pays NO.

### Tested on a Local Network

SameDayMarket runs in the same step 12 as the [mention market](https://etchd.app/docs/headline-bets#mention-tested). The fixture server titles every feed item as a subject and an action, none of them a pandemic, so the test counts the word `the` in three sources instead.

The test takes three of bbc-front, guardian-front, aljazeera-front, scmp-front and hn-front whose headlines hold the phrase on one UTC day, kept five minutes clear of midnight, with two such headlines from one of them. The first submit carries two sources and counts 1 and 2. The second headline of a counted source reverts `SourceCounted`. The third source resolves YES.

### Producing the Inputs

The same `headlines.ts` deploys and drives SameDayMarket, with the example terms as the defaults.

Deploy and take

```bash
# Alice, with the example terms: "pandemic", five sources, 2030-01-01T00:00:00Z, seven days of grace
ETCHD_DEMO_KEY=0xA11CE... node scripts/examples/headlines.ts deploy same-day \
  --calendar a=https://a.etc.calendar.etchd.app --calendar b=https://b.calendar.example --calendar c=https://c.calendar.example --quorum 2 \
  --no 0xB0B... --stake 500000000000000000

ETCHD_DEMO_KEY=0xB0B... node scripts/examples/headlines.ts take --address 0xMARKET...
```

On the day, `etchd records list` finds the headlines of each source, and `etchd records proof` prints one proof per record. `submit` takes `--proofs` once per file and sends them in one call, so a day can go in at once or a source at a time.

Submit

```bash
etchd records list https://a.etc.calendar.etchd.app --branch headline --key guardian-front --format table
etchd records proof https://a.etc.calendar.etchd.app https://b.calendar.example https://c.calendar.example --id 29900123-5-4 --json > guardian.json
etchd records proof https://a.etc.calendar.etchd.app https://b.calendar.example https://c.calendar.example --id 29900123-5-31 --json > bbc.json
ETCHD_DEMO_KEY=0x... node scripts/examples/headlines.ts submit --address 0xMARKET... \
  --proofs guardian.json --proofs bbc.json
node scripts/examples/headlines.ts status --address 0xMARKET...
```

`status` prints the terms, the stakes and the phase. The count of a day is `counted(day)` on the contract, with the day as the anchor time divided by 86,400.

### Tests

[markets.test.ts](https://github.com/classix-dev/etchd/blob/main/packages/contract/test/markets.test.ts) runs SameDayMarket on Anvil with one account as the operator and quorum 1. Five distinct sources on one day resolve YES in one call, and the same five over three calls and three ticks of the day add up. Four sources do not resolve, and NO wins after `settleBy`. A second record of a counted source reverts `SourceCounted` alone and as a pair inside one call, and the count stays. Three sources anchored at 23:59:59 UTC and two at 00:00:00 UTC count 3 and 2 on two days, and the same sources count again on the second day until it reaches five.

The phrase matches in capitals and in `pandemics` and `post-pandemic`, and an epidemic does not count. An anchor at the deadline reverts `AnchoredLate`, a record anchored by an unlisted key `NotOperator` and the same path given as the listed operator's `NotAnchored`. A source count of 0 or above 255, a phrase with capitals, a quorum of 0 or above the list, a repeated or unsorted operator and the zero address are refused at deploy. The example terms deploy with the 2030 deadline and read back.

With three listed operators and quorum 2, one operator alone reverts `TooFewOperators`. Two operators that anchored bbc-front one second before midnight and on it count it for the later day. A pair anchored before midnight counts guardian-front for the earlier day, and bbc-front through that pair lands on the earlier day too and resolves YES there. A repeated operator reverts `OperatorsOutOfOrder`, and the `SourceCounted` rule holds for records proven by two operators.

### Limits

Five sources out of the feeds the listed calendars read is a narrower question than five sources in the world.

The day is the anchor's day and not the publisher's. A headline from Tokyo at 08:00 local time is the previous UTC day, and a headline sealed in the last minute before midnight UTC can land on the next one. A source that runs the word in a headline only between two captures leaves no record.

NO wins by silence and the stakes are fixed, as in the [mention market](https://etchd.app/docs/headline-bets#mention-limits). The match compares bytes, so a headline about a pandemic film counts as much as one about a pandemic.

## The Source

Three files, compiled with solc 0.8.38, optimizer 200 and EVM version shanghai (settings in [packages/contract/verify](https://github.com/classix-dev/etchd/tree/main/packages/contract/verify)). MentionMarket holds the source and the one-proof submit, and SameDayMarket the per-day count. HeadlineMarket is the base they share, with the stakes, the record reader, the match and the time bracket.

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

MentionMarket.sol

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

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

/// @title A bet that a headline source mentions a phrase before a deadline
/// @author Classix
/// @notice "Will a BBC headline mention Ethereum Classic before 2030?" The example terms are source "bbc-front",
///         phrase "ethereum classic" and deadline 1893456000 (2030-01-01T00:00:00Z), with settleBy seven days later.
///         YES wins as soon as anyone submits one headline record of that source whose title holds the phrase, proven
///         through EtchdVerifier against the anchors of at least `quorum` listed operators, each anchored before the
///         deadline. NO wins by timeout after settleBy (see HeadlineMarket for why).
/// @dev The record shows that each of those operators' calendars read that title in that source's feed by the time of
///      its anchor. The publisher signs nothing, so the bet trusts a quorum of the operators for the title, as
///      GoldEscrow trusts them for the price. The anchor time checked against the deadline is the latest of the
///      quorum's, so every operator counted must have anchored the record before the deadline.
contract MentionMarket is HeadlineMarket {
    struct Terms {
        address[] operators; // the calendars' anchor senders, strictly ascending
        uint256 quorum; // operators that must each prove the record, 1 to operators.length
        address no; // the NO side, who takes the bet with an equal stake
        bytes source; // the headline source id, 1 to 255 ASCII bytes
        bytes phrase; // lowercase ASCII, 1 to MAX_PHRASE bytes
        uint64 deadline; // each operator's anchor of the record before this time
        uint64 settleBy; // proofs accepted until this time, NO paid after it
    }

    /// keccak256 of the source id, which the record's source must equal.
    bytes32 public immutable sourceHash;
    bytes public source;

    event Mentioned(bytes title, uint64 anchoredAt);

    constructor(EtchdVerifier verifier_, Terms memory t)
        payable
        HeadlineMarket(verifier_, t.operators, t.quorum, t.no, t.phrase, t.deadline, t.settleBy)
    {
        if (t.source.length == 0 || t.source.length > 255) revert BadTerms();
        sourceHash = keccak256(t.source);
        source = t.source;
    }

    /// @notice Resolves YES with one matching headline record, proven through at least `quorum` listed operators, and
    ///         pays both stakes to the YES side. Mentioned carries the latest anchor time of the operators given.
    function submit(HeadlineProof calldata p) external {
        checkOpenForProofs();
        (, bytes calldata title, uint64 anchoredAt) = check(0, p, sourceHash, phrase);
        emit Mentioned(title, anchoredAt);
        resolve(true);
    }
}
```

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

SameDayMarket.sol

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

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

/// @title A bet that several headline sources carry a phrase on one UTC day
/// @author Classix
/// @notice "Will five news sources put a pandemic in their headlines on the same day before 2030?" The example terms
///         are phrase "pandemic", sourcesNeeded 5 and deadline 1893456000 (2030-01-01T00:00:00Z), with settleBy seven
///         days later. YES wins once headline records from sourcesNeeded distinct sources, each with a title holding
///         the phrase and each proven through at least `quorum` listed operators, fall on one UTC day. Proofs arrive
///         over one or several transactions, and the contract keeps per day the sources counted so far. NO wins by
///         timeout after settleBy (see HeadlineMarket for why).
/// @dev The day of a record is its anchor time from recordExistedAtBy, floored to the UTC day. With several operators
///      that time is the latest anchor among those the proof gives, the moment the quorum was complete, so a record
///      two operators anchored either side of midnight counts for the later day. A submitter who holds more than
///      `quorum` anchors picks which ones to give. A headline holds no time of its own, and the tick header that holds
///      the seal time is opaque to the verifier, so the anchor time is the only time the contract can prove. On mainnet
///      it runs about a minute behind the seal plus ETC's block time, so a headline sealed in the last minute of a day
///      can count for the next one. A calendar commits each distinct headline once per export day, so a story that
///      stays on a front page for two days has a record in each, and a proof through either tick counts for that
///      tick's day.
contract SameDayMarket is HeadlineMarket {
    struct Terms {
        address[] operators; // the calendars' anchor senders, strictly ascending
        uint256 quorum; // operators that must each prove a record, 1 to operators.length
        address no; // the NO side, who takes the bet with an equal stake
        bytes phrase; // lowercase ASCII, 1 to MAX_PHRASE bytes
        uint256 sourcesNeeded; // distinct sources on one UTC day, 1 to 255
        uint64 deadline; // each operator's anchor of a record before this time
        uint64 settleBy; // proofs accepted until this time, NO paid after it
    }

    uint256 public immutable sourcesNeeded;
    /// The distinct sources counted for a UTC day, by day number (latest anchor time of the quorum / 86400).
    mapping(uint256 => uint256) public counted;
    /// Whether a source, by keccak256 of its id, is counted for a day.
    mapping(uint256 => mapping(bytes32 => bool)) public sourceCounted;

    event Counted(uint256 indexed day, bytes source, uint256 count);

    error SourceCounted(uint256 index, uint256 day);

    constructor(EtchdVerifier verifier_, Terms memory t)
        payable
        HeadlineMarket(verifier_, t.operators, t.quorum, t.no, t.phrase, t.deadline, t.settleBy)
    {
        if (t.sourcesNeeded == 0 || t.sourcesNeeded > 255) revert BadTerms();
        sourcesNeeded = t.sourcesNeeded;
    }

    /// @notice Counts each record's source for the UTC day of its latest anchor among the operators that prove it. A second record of a source already counted
    ///         for that day reverts, whatever its title. When a day reaches sourcesNeeded the bet resolves YES, both
    ///         stakes go to the YES side, and any proofs after that one in the call are not read.
    function submit(HeadlineProof[] calldata proofs) external {
        checkOpenForProofs();
        bytes memory phrase_ = phrase;
        for (uint256 i; i < proofs.length; i++) {
            (bytes calldata source,, uint64 anchoredAt) = check(i, proofs[i], bytes32(0), phrase_);
            uint256 day = anchoredAt / 1 days;
            bytes32 key = keccak256(source);
            if (sourceCounted[day][key]) revert SourceCounted(i, day);
            sourceCounted[day][key] = true;
            uint256 n = ++counted[day];
            emit Counted(day, source, n);
            if (n == sourcesNeeded) {
                resolve(true);
                return;
            }
        }
    }
}
```

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

HeadlineMarket.sol

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

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

/// @title A two-party bet on etchd headline records
/// @author Classix
/// @notice The parts MentionMarket and SameDayMarket share. The YES side deploys with its stake and names the NO side,
///         who takes the bet with an equal stake before the deadline. YES wins when anyone proves headline records that
///         answer the question, each anchored by at least `quorum` of the listed operators before the deadline. NO wins
///         by timeout, once settleBy has passed with no such proof. Until NO takes the bet, YES can cancel it and take
///         the stake back.
/// @dev NO is a timeout because absence has no cheap proof. Showing that no headline matched would mean submitting
///      every branch 5 record of every tick up to the deadline, millions of them, and proving each tree complete. So
///      the contract takes the absence of a YES proof by settleBy as the answer, since anyone can submit one until
///      then and YES has every reason to. The gap from deadline to settleBy is the grace for a match anchored in the
///      last minutes before the deadline, so its proof can wait for confirmations and still land. A headline record
///      holds no time. The only time the contract has for one is when an operator's anchor of its tick first reached
///      the registry, which recordExistedAtBy returns, so that time is what the deadline and openedAt bound. A record
///      counts once `quorum` distinct listed operators have each anchored it, every one of those anchors from openedAt
///      to before the deadline, as GoldEscrow checks each of its records against its deadline. The record's time is
///      then the latest of those anchors, the moment the quorum was complete, as BitcoinOnEtc keeps for a head. Proofs
///      come strictly ascending by operator, so no operator counts twice. The operator list is pinned at deploy,
///      strictly ascending, with no zero address.
abstract contract HeadlineMarket {
    enum State {
        Open, // YES staked, waiting for NO
        Taken, // both staked, proofs accepted until settleBy
        Done
    }

    struct OperatorPath {
        address operator; // a listed operator that anchored the record
        EtchdVerifier.RecordPath path; // the record's path to that operator's anchor
    }

    struct HeadlineProof {
        bytes record; // the branch 5 headline envelope
        OperatorPath[] paths; // at least quorum, operators strictly ascending
    }

    uint32 public constant HEADLINE_BRANCH = 5;
    uint16 public constant HEADLINE_SCHEMA = 1;
    /// tag, u32 branch, u16 schema, u32 payload length
    uint256 private constant ENVELOPE_HEADER = 11;
    uint256 public constant MAX_PHRASE = 32;

    EtchdVerifier public immutable verifier;
    uint256 public immutable quorum;
    address public immutable yes;
    address public immutable no;
    uint256 public immutable stake;
    uint64 public immutable openedAt;
    uint64 public immutable deadline;
    uint64 public immutable settleBy;

    /// Lowercase ASCII, matched against titles with A to Z folded to lowercase.
    bytes public phrase;
    /// The calendars' anchor senders, strictly ascending.
    address[] public operators;
    mapping(address => bool) public isOperator;
    State public state;
    mapping(address => uint256) public owed;

    event Taken(address no, uint256 stake);
    event Cancelled(uint256 amount);
    event Resolved(bool yes, address paid, uint256 amount);
    event Owed(address to, uint256 amount);

    error BadTerms();
    error NotParty();
    error NotOpen();
    error NotTaken();
    error WrongStake(uint256 value, uint256 stake);
    error TooLate();
    error Closed();
    error TooEarly();
    error BadRecord(uint256 index);
    error TooFewOperators(uint256 index, uint256 n);
    error NotOperator(uint256 index, address operator);
    error OperatorsOutOfOrder(uint256 index, uint256 position);
    error WrongSource(uint256 index);
    error NoMatch(uint256 index);
    error AnchoredBeforeOpen(uint256 index, uint64 anchoredAt);
    error AnchoredLate(uint256 index, uint64 anchoredAt);
    error NothingOwed();

    constructor(
        EtchdVerifier verifier_,
        address[] memory operators_,
        uint256 quorum_,
        address no_,
        bytes memory phrase_,
        uint64 deadline_,
        uint64 settleBy_
    ) payable {
        if (msg.value == 0 || no_ == address(0) || no_ == msg.sender) revert BadTerms();
        if (quorum_ == 0 || quorum_ > operators_.length) revert BadTerms();
        for (uint256 i; i < operators_.length; i++) {
            if (i == 0 ? operators_[i] == address(0) : operators_[i] <= operators_[i - 1]) revert BadTerms();
            isOperator[operators_[i]] = true;
        }
        if (deadline_ <= block.timestamp || settleBy_ <= deadline_) revert BadTerms();
        if (phrase_.length == 0 || phrase_.length > MAX_PHRASE) revert BadTerms();
        for (uint256 i; i < phrase_.length; i++) {
            bytes1 b = phrase_[i];
            if (b < 0x20 || b > 0x7e || (b >= 0x41 && b <= 0x5a)) revert BadTerms();
        }
        verifier = verifier_;
        operators = operators_;
        quorum = quorum_;
        yes = msg.sender;
        no = no_;
        stake = msg.value;
        openedAt = uint64(block.timestamp);
        deadline = deadline_;
        settleBy = settleBy_;
        phrase = phrase_;
    }

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

    /// @notice The NO side takes the bet with a stake equal to YES's, before the deadline.
    function take() external payable {
        if (state != State.Open) revert NotOpen();
        if (msg.sender != no) revert NotParty();
        if (msg.value != stake) revert WrongStake(msg.value, stake);
        if (block.timestamp >= deadline) revert TooLate();
        state = State.Taken;
        emit Taken(msg.sender, msg.value);
    }

    /// @notice YES takes its stake back while NO has not taken the bet.
    function cancel() external {
        if (state != State.Open) revert NotOpen();
        if (msg.sender != yes) revert NotParty();
        state = State.Done;
        uint256 amount = address(this).balance;
        emit Cancelled(amount);
        pay(yes, amount);
    }

    /// @notice After settleBy, with no YES proof stored, pays both stakes to NO.
    function settleNo() external {
        if (state == State.Open) revert NotTaken();
        if (state == State.Done) revert Closed();
        if (block.timestamp <= settleBy) revert TooEarly();
        resolve(false);
    }

    /// @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();
    }

    /// Proofs go in only while the bet is taken and settleBy has not passed.
    function checkOpenForProofs() internal view {
        if (state == State.Open) revert NotTaken();
        if (state == State.Done || block.timestamp > settleBy) revert Closed();
    }

    /// The checks of one headline proof. Its source must hash to `wantSource`, or be any source when that is zero, its
    /// title must hold `phrase_`, and at least `quorum` listed operators, strictly ascending, must each have anchored it
    /// from openedAt to before the deadline. `anchoredAt` is the latest of those anchors.
    function check(uint256 i, HeadlineProof calldata p, bytes32 wantSource, bytes memory phrase_)
        internal
        view
        returns (bytes calldata source, bytes calldata title, uint64 anchoredAt)
    {
        bool ok;
        (ok, source, title) = fields(p.record);
        if (!ok) revert BadRecord(i);
        if (wantSource != bytes32(0) && keccak256(source) != wantSource) revert WrongSource(i);
        if (!contains(title, phrase_)) revert NoMatch(i);
        anchoredAt = quorumAnchor(i, p.record, p.paths);
    }

    /// The latest anchor of `record` among `paths`, after checking there are at least `quorum` of them, each by a
    /// listed operator above the one before, each proven, and each anchored from openedAt to before the deadline.
    function quorumAnchor(uint256 i, bytes calldata record, OperatorPath[] calldata paths) private view returns (uint64 latest) {
        if (paths.length < quorum) revert TooFewOperators(i, paths.length);
        address prev;
        for (uint256 j; j < paths.length; j++) {
            address op = paths[j].operator;
            if (!isOperator[op]) revert NotOperator(i, op);
            if (op <= prev) revert OperatorsOutOfOrder(i, j);
            prev = op;
            (uint64 t,) = verifier.recordExistedAtBy(op, HEADLINE_BRANCH, record, paths[j].path);
            // A record anchored before the bet existed is already known to both sides, so it cannot decide it.
            if (t < openedAt) revert AnchoredBeforeOpen(i, t);
            if (t >= deadline) revert AnchoredLate(i, t);
            if (t > latest) latest = t;
        }
    }

    function resolve(bool yesWins) internal {
        state = State.Done;
        address to = yesWins ? yes : no;
        uint256 amount = address(this).balance;
        emit Resolved(yesWins, to, amount);
        pay(to, amount);
    }

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

    /// The source and title of a headline record of schema 1, read by offset in calldata. `ok` is false when the bytes
    /// are not a headline envelope of their own length or end inside the source or the title. A headline without a
    /// title gives an empty one. The fields after the title (link, published, guid) are not read, since the verifier
    /// proves the whole envelope and nothing here depends on them.
    function fields(bytes calldata r) internal pure returns (bool ok, bytes calldata source, bytes calldata title) {
        source = r[0:0];
        title = r[0:0];
        if (
            r.length < ENVELOPE_HEADER + 2 || r[0] != 0x03 || uint32(bytes4(r[1:5])) != HEADLINE_BRANCH
                || uint16(bytes2(r[5:7])) != HEADLINE_SCHEMA || uint32(bytes4(r[7:11])) != r.length - ENVELOPE_HEADER
        ) return (false, source, title);
        // u8 source length, the source, then the present byte, whose bit 0 says a title follows.
        uint256 o = ENVELOPE_HEADER + 1 + uint8(r[ENVELOPE_HEADER]);
        if (o == ENVELOPE_HEADER + 1 || o >= r.length) return (false, source, title);
        source = r[ENVELOPE_HEADER + 1:o];
        if (uint8(r[o]) & 1 == 0) return (true, source, title);
        // u16 title length and the title, at least one byte.
        o += 1;
        if (o + 2 > r.length) return (false, source, title);
        uint256 end = o + 2 + uint16(bytes2(r[o:o + 2]));
        if (end == o + 2 || end > r.length) return (false, source, title);
        title = r[o + 2:end];
        ok = true;
    }

    /// Whether `text` holds `needle`, a lowercase ASCII phrase of 1 to 32 bytes, with A to Z in `text` folded to
    /// lowercase. Bytes of multi-byte UTF-8 characters are 0x80 and up, so they never fold and never match an ASCII
    /// byte. Each position compares one 32-byte word, so a 200-byte title costs about 200 rounds of a few opcodes.
    function contains(bytes calldata text, bytes memory needle) internal pure returns (bool found) {
        uint256 m = needle.length;
        if (m == 0 || m > MAX_PHRASE || text.length < m) return false;
        bytes32 want = bytes32(needle);
        bytes32 mask = ~bytes32(type(uint256).max >> (8 * m));
        assembly ("memory-safe") {
            let end := add(text.offset, sub(text.length, m))
            for { let p := text.offset } iszero(gt(p, end)) { p := add(p, 1) } {
                let w := calldataload(p)
                // Per byte, with the top bit cleared so no sum carries into the next byte: x + 0x3f has its top bit
                // set from 0x41 ("A") up, x + 0x25 from 0x5b (past "Z") up. A byte under 0x80 in both ranges is
                // upper case, and its 0x80 shifted right by two is the 0x20 that makes it lower case.
                let x := and(w, 0x7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f)
                let upper :=
                    and(
                        and(add(x, 0x3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f), not(add(x, 0x2525252525252525252525252525252525252525252525252525252525252525))),
                        and(not(w), 0x8080808080808080808080808080808080808080808080808080808080808080)
                    )
                if eq(and(or(w, shr(2, upper)), mask), want) {
                    found := 1
                    break
                }
            }
        }
    }
}
```
