Profile
Back to NewsBack
Dev.to 9 min
Reader Mode
The Chain Is a Clock, Not a Witness: Proof of Priority for Skate Clips on Midnight

The Chain Is a Clock, Not a Witness: Proof of Priority for Skate Clips on Midnight

1 day ago

Skateboarding has had proof for forty years. It is called the clip. Land something, film it, post it, done. Two things are breaking that right now.

The first is old. Footage gets saved for video parts, sometimes for a year or more, and posting early to prove you did it first burns the clip. "Who did it first" arguments get settled by upload dates and by who has the louder crew.

The second is new. AI video is about to make a clip of a kickflip down a twelve stair cost nothing to produce. When that lands, the clip stops being proof, and a culture that cares this much about realness will want a way to say "this is real footage, filmed here, that day."

I build TrickBook, a skate app with a few hundred riders on the App Store, and I have been working on a Midnight feature called Claimed to handle both. This post is about the one idea in the design that makes a blockchain worth having here, and about a one-line bug I found in the official docs pattern while building it. Everything below runs on a local devnet today, and the commands to reproduce it are at the end.

What a commitment buys you

A rider lands a trick. The app hashes the clip on the phone and builds a commitment over the clip hash, the trick, the spot, the capture time, a fresh salt, and a secret that never leaves the device:

struct ClaimPreimage {
  tag: Bytes<32>,
  clipHash: Bytes<32>,
  trickId: Bytes<32>,
  spotId: Bytes<32>,
  capturedAt: Uint<64>,
  salt: Bytes<32>,
  secret: Bytes<32>
}

export circuit claimCommitment(
  secret: Bytes<32>, salt: Bytes<32>, clipHash: Bytes<32>,
  trickId: Bytes<32>, spotId: Bytes<32>, capturedAt: Uint<64>
): Bytes<32> {
  return persistentHash<ClaimPreimage>(ClaimPreimage {
    tag: pad(32, "tb:claim:v1"),
    clipHash: clipHash, trickId: trickId, spotId: spotId,
    capturedAt: capturedAt, salt: salt, secret: secret
  });
}

That 32-byte hash is the only thing that goes on chain:

export ledger claims: HistoricMerkleTree<16, Bytes<32>>;

export circuit seal(commitment: Bytes<32>): [] {
  claims.insert(disclose(commitment));
  claimCount.increment(1);
}

Nobody can read a trick, a spot, a date, or a rider out of that. The clip stays on the phone. Later, when the part drops or somebody starts an argument, the rider can reveal the body and prove it matches, exactly once, with a nullifier. That part is the standard commit and reveal pattern, and Midnight's example-bboard template gets you most of the way there.

The interesting question is the date. If the rider says "I sealed this before March 1," what proves it? Not the phone. The capture time inside the commitment is just a number the phone supplied, and a phone clock can be set to anything.

The clock

Every seal inserts one leaf into the Merkle tree, and every insert changes the tree's root. Midnight's HistoricMerkleTree remembers the old roots as well as the current one, and checkRoot accepts any root the tree has ever recorded.

So the priority proof does not prove membership under the current root. It proves membership under the root that was current at a specific earlier block:

export circuit provePriority(
  clipHash: Bytes<32>, trickId: Bytes<32>, spotId: Bytes<32>,
  capturedAt: Uint<64>, salt: Bytes<32>
): [Bytes<32>, Bytes<32>] {
  const secret = riderSecret();
  const c = claimCommitment(secret, salt, clipHash, trickId, spotId, capturedAt);
  const path = findClaimPath(c);
  assert(path.leaf == c, "path is not for this claim");
  assert(claims.checkRoot(disclose(merkleTreePathRoot<16, Bytes<32>>(path))), "no sealed claim matches");
  return [disclose(trickId), disclose(spotId)];
}

If the leaf was under the tree's root at block 12, it was sealed by block 12. The chain stamped block 12 with a time nobody can rewind. The rider discloses the trick, the spot, and a root. Not the clip, not the capture time, not which leaf is theirs.

Think of a notary's ledger that gets photographed at the end of every day. If your entry is in Tuesday's photo, you signed by Tuesday, whatever date you wrote next to your name. The chain is the camera. It is not a witness to the trick. It is a clock.

Two things I had to verify before trusting this, because the docs do not spell either out.

Does the tree really keep every root? Yes, until a contract calls resetHistory(), which this one never does. The generated JavaScript for claims.insert appends the new root into a history map and nothing ever removes one. And it holds live: after two seals the history held three roots, and a proof against the older one verified on chain after the current root had moved on.

How do you build a proof against an old root when the witness only sees current state? Do not fight the witness. The indexer's queryContractState takes a block height, so the service fetches the contract state as of that block, derives the Merkle path from that snapshot with the generated findPathForLeaf, and hands that path to the witness for one call. The circuit's own checkRoot over recorded history is what keeps that safe.

The end-to-end run on a local devnet, ten checks, about three minutes:

PASS: seal A [block 12]
PASS: seal B advances the root [block 15]
PASS: root after A is still in claims.history() [3 roots recorded]
PASS: reveal A discloses the body [block 19]
PASS: reveal A again rejected with 'claim already revealed'
PASS: provePriority A against the historic root (blockHeight 12 from the indexer)
PASS: provePriority discloses trick and spot only [kickflip at el-toro]
PASS: attest by the verifier
PASS: attest without the verifier secret rejected with 'not the verifier'
E2E RESULT: PASS (10/10 checks)

The one-line bug

Look at provePriority again. This line was not in my first version, and it is not in the concept page I learned the pattern from:

assert(path.leaf == c, "path is not for this claim");

Here is why it matters. A witness is plain TypeScript that runs outside the circuit. Midnight's docs are clear that witnesses are not cryptographically verified, and that the contract has to validate whatever they return. findClaimPath returns a Merkle path. Every leaf in that tree, and every path to it, is public data. So without the binding line, a rider can compute a commitment for a claim they never sealed, have their witness hand back somebody else's perfectly valid path, and pass the root check.

In my previous project, a verified-purchase review contract, that meant anyone could post a review without a purchase. In the docs' authorized-commitments example, it means any secret gets past "not authorized." The membership proof was proving that some leaf exists, not that your leaf exists.

The fix is the single assert. The test that proves it bites is short too: pin another rider's perfectly valid path into the witness and call provePriority. The circuit rejects it with path is not for this claim in a tenth of a second, during local execution, before anything reaches the proof server. A real proof takes about twenty seconds, so you can tell from the timing alone whether the guard ran.

The honest part is that Midnight's own security guide already says so. It calls the binding assert "the security-critical line" and shows exactly this shape. The concept page where most people first meet Merkle membership does not have it in either example, and does not link to the guide. I have a docs PR going in for that, and I fixed the review contract the same day.

Three other compiler rules I learned by getting errors, all stricter than the examples suggest: every circuit parameter that flows into a ledger write needs disclose() at the point of use, even public-looking ones like a trick id; Set.member() with a witness-derived key is itself a disclosure point, so disclose the nullifier once when you bind it; and checkRoot on a witness-supplied path needs disclose(merkleTreePathRoot(path)). Five compile iterations for the first contract, zero for the second.

What none of this proves

The chain proves who committed to what, and when. It never says the trick happened. A commitment over a synthetic clip seals just as well as one over a real one. Whether the footage is real is a separate question with separate answers: recording inside the app with a hash chain over the encoder output, device attestation, a sensor trace at capture, trick recognition, matching keyframes against the spot's photos. That all lives off chain, and only a tier bitmask gets anchored back to the commitment by a verifier key fixed at deploy.

Riders never see any of this. No wallet, no token, no word Midnight. The first version is custodial, the app's backend holds the wallet and generates proofs, and the docs say so plainly. Getting the proving onto the phone is the next track.

Try it

Claimed itself is not a standalone repo. It is a feature of TrickBook and ships inside it, so there is nothing to clone yet. The full design, including the parts about evidence and what the badges are allowed to claim, is in the TrickBook docs: https://docs.thetrickbook.com/docs/features/claimed

What you can run is the contract that taught me this, which is the same shape: wbaxterh/vouched, verified-purchase reviews, a commitment tree plus a nullifier set. Same binding assert, same forged-path test that pins another buyer's valid path into the witness and watches the circuit refuse it. You need Node 24, Docker, and the Compact toolchain (compact CLI 0.5.1 with compiler 0.31.1). From the contract directory, npm run compact builds the circuits. From the CLI directory, npm run e2e brings up the standalone devnet in Docker, deploys, and runs the flow end to end.

If you are building anything with Merkle membership on Midnight, grep your circuits for checkRoot and make sure each one has a path.leaf == next to it. It took me two contracts to notice.

Chat with me