Headline Bets
Two bets on what the news says, each settled from etchd headline records. MentionMarket asks whether a BBC headline will mention Ethereum Classic before 2030 and settles on one record. SameDayMarket asks whether five news sources will put a pandemic in their headlines on the same UTC day, and counts them over as many transactions as it takes.
Alpha, unaudited. MentionMarket and SameDayMarket are examples. Nobody has audited them. Nothing here comes with a guarantee. Alpha, No Guarantees says what that means.
What They Share
Etchd reads the front-page feeds of many news sources, from the BBC and the Guardian to Al Jazeera, the South China Morning Post and Hacker News. Every item becomes a headline record in branch 5 of a tick, with the source id, such as bbc-front, the title and the link, and the tick is anchored on ETC. So the day a matching headline appears, a record of it exists on chain in hashed form, and anyone holding its record path can prove it 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 an operator's account of what a feed listed. No publisher signs its feed, 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 each record against the operator's own anchors through recordExistedAtBy, so it trusts nobody who submits.
A market names one operator, the anchor key of one calendar on the day of deploy. The demo names calendar a, run by Classix, which means the demo bets trust Classix to have read the feeds as they were. A key rotation before settleBy leaves that operator's later anchors under another address, which the market does not count, and Signers says so too.
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, the BBC's or any other, 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 the operator's anchor of the tick first reached the registry, and the bracket bounds that.
openedAt <= anchoredAt < deadline anchored after the bet existed and before the deadline
block.timestamp <= settleBy submit
block.timestamp > settleBy settleNo
block.timestamp < deadline take
openedAt < deadline < settleByA 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.
A Mention Before 2030
Will a BBC headline mention Ethereum Classic before 2030? Alice thinks the BBC will put Ethereum Classic in a front-page headline by then, and Bob thinks it will not. MentionMarket settles on one etchd headline record. 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 its record path 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.
- 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. - The source hashes to the market's source,
bbc-frontin the example, elseWrongSource. A prefix or an extension of the id is another source. - The title holds the phrase, else
NoMatch. A record without a title never matches, and neither does the phrase in the link. recordExistedAtBy(operator, 5, record, path)on EtchdVerifier returns the time the operator's anchor first reached the registry. It must be at or afteropenedAt, the block time of the deploy, elseAnchoredBeforeOpen, and before thedeadline, elseAnchoredLate.- 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, and the generic record check on Proofs in Contracts.
struct Terms {
address operator; address no; // the calendar's anchor key, the NO side
bytes source; bytes phrase; // "bbc-front", "ethereum classic"
uint64 deadline; uint64 settleBy; // anchors before deadline, proofs until settleBy
}
struct HeadlineProof { bytes record; EtchdVerifier.RecordPath path; }
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 payoutGas
Measured on Anvil with Shanghai rules and chain id 61 in 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,916 |
| submit of one matching headline with a short title, paying 1 ETC to YES | 74,318 |
| submit of a headline with a 200-byte title and the phrase at its end | 104,307 |
| settleNo with the 1 ETC payout | 37,786 |
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.
Figure
Tested on a Local Network
We do not deploy MentionMarket or SameDayMarket on ETC mainnet. Both run in the end-to-end test, step 12 of scripts/e2e.ts, on the local devnet with operator a, and in the contract tests on anvil. 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 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.
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 marketsProducing the Inputs
The demo script for both markets is scripts/examples/headlines.ts. It takes one --calendar <cal>=<url>, calendar a by default, reads the operator from /v1/signers and refuses a demo key that the calendar anchors with. The example terms are the defaults, so Alice gives only Bob's address and the stake.
# 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 \
--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.
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 --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 runs MentionMarket on Anvil from 2027-01-15, with one account as the operator 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 another key, 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.
Limits
With one operator the bet trusts Classix for the title. 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 phrase is bytes, not meaning. 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 something more. Here 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 calendar's source list. With calendar a named, five sources means five of the feeds that one calendar reads. A source that etchd does not read never counts, and the source ids are the calendar's own, listed in Record 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 with any source allowed, and then through the count.
- The envelope reads as a headline of schema 1, laid out as in Records, with a source and, when present, a title inside its length, else
BadRecord. - The title holds the phrase,
pandemicin the example, with A to Z folded to lower case, elseNoMatch. SoPANDEMIC,pandemicsandpost-pandemicmatch. recordExistedAtBy(operator, 5, record, path)returns the anchor time, which must be at or afteropenedAtand before thedeadline, elseAnchoredBeforeOpenorAnchoredLate.- The day is the anchor time 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. - 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.
struct Terms {
address operator; address no; // the calendar's anchor key, 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 MentionMarketWhich 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 the operator's anchor of the tick first reached the registry, and that is the time the day comes from.
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.
day = anchoredAt / 86400 the UTC day of the anchor
counted[day] = distinct sources so far one per source per day
YES when counted[day] == sourcesNeeded, before settleBy
NO settleNo after settleByA 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 may 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, whose take and settleNo figures, 44,916 and 37,786, hold here as well since both calls are in the base.
| Call | Gas |
|---|---|
| submit of five headlines of one day, one tick, paying 1 ETC to YES | 339,058 |
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
Tested on a Local Network
SameDayMarket runs in the same step 12 as the mention market. 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.
# 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 \
--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.
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 --id 29900123-5-4 --json > guardian.json
etchd records proof https://a.etc.calendar.etchd.app --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 runs SameDayMarket on Anvil with one account as the operator. 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 another key NotAnchored, and a source count of 0 or above 255 or a phrase with capitals is refused at deploy. The example terms deploy with the 2030 deadline and read back.
Limits
With one operator the bet trusts Classix for every title and for the source list. Five sources out of the feeds one calendar reads 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. A word is bytes, not meaning, 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, with the settings in 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.
// 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 operator's anchor and anchored before the deadline. NO wins by timeout
/// after settleBy (see HeadlineMarket for why).
/// @dev The record shows that the operator's calendar read that title in that source's feed by the time of its
/// anchor. The publisher signs nothing, so the bet trusts the operator for the title, as GoldEscrow trusts it for
/// the price.
contract MentionMarket is HeadlineMarket {
struct Terms {
address operator; // the calendar's anchor sender
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 record's anchor 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.operator, 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 and pays both stakes to the YES side.
function submit(HeadlineProof calldata p) external {
checkOpenForProofs();
(, bytes calldata title, uint64 anchoredAt) = check(0, p, sourceHash, phrase);
emit Mentioned(title, anchoredAt);
resolve(true);
}
}// 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, are proven for 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. 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 operator; // the calendar's anchor sender
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 record's anchor 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 (anchor time / 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.operator, 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 anchor. 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;
}
}
}
}// 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 the operator 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 the operator's anchor of its tick first reached
/// the registry, which recordExistedAtBy returns, so that time is what the deadline and openedAt bound.
abstract contract HeadlineMarket {
enum State {
Open, // YES staked, waiting for NO
Taken, // both staked, proofs accepted until settleBy
Done
}
struct HeadlineProof {
bytes record; // the branch 5 headline envelope
EtchdVerifier.RecordPath path;
}
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;
address public immutable operator;
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;
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 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 operator_, address no_, bytes memory phrase_, uint64 deadline_, uint64 settleBy_)
payable
{
if (msg.value == 0 || operator_ == address(0) || no_ == address(0) || no_ == msg.sender) revert BadTerms();
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_;
operator = operator_;
yes = msg.sender;
no = no_;
stake = msg.value;
openedAt = uint64(block.timestamp);
deadline = deadline_;
settleBy = settleBy_;
phrase = phrase_;
}
/// @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 the operator must have anchored it from openedAt to before the deadline.
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,) = verifier.recordExistedAtBy(operator, HEADLINE_BRANCH, p.record, p.path);
// A record anchored before the bet existed is already known to both sides, so it cannot decide it.
if (anchoredAt < openedAt) revert AnchoredBeforeOpen(i, anchoredAt);
if (anchoredAt >= deadline) revert AnchoredLate(i, anchoredAt);
}
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
}
}
}
}
}