Sybil-resistant airdrops & governance
One person can spin up a thousand wallets to farm an airdrop meant for a thousand people — a sock-puppet attack (a Sybil attack). Anchor eligibility to the device, not the wallet: the security chip in a real machine costs real money to obtain while a fresh wallet costs nothing, so one human with one machine can't farm a thousand allocations. Wallet-graph analytics catch the lazy farms; this catches the ones that rotate fresh wallets behind cloud VMs and proxies.
Mint a challenge on your server
When a wallet begins a claim or a vote, your backend mints a single-use challenge with your rh_sk_ secret and relays the nonce to the dApp. The nonce is the anti-replay floor: the client has to quote a live TPM over it.
import { RootHerald } from "@rootherald/node";
const rh = new RootHerald({ secretKey: process.env.RH_SECRET_KEY! }); // rh_sk_… (backend only)
app.post("/claim/challenge", async (_req, res) => {
const { challengeId, nonce } = await rh.issueChallenge();
res.json({ challengeId, nonce }); // relay to the dApp
});Collect evidence on the client
The dApp quotes the local TPM over the nonce and posts the opaque evidence blob back with the wallet address. The collector holds no key and never contacts Root Herald: it only talks to your server.
import { attest } from "@rootherald/browser";
const { challengeId, nonce } = await fetch("/claim/challenge", { method: "POST" }).then((r) => r.json());
const evidence = await attest(nonce); // quotes the TPM over the nonce; no keys on the client
await fetch("/claim", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ challengeId, evidence, wallet }),
});Verify server→server and bind the device to the wallet
Your backend appraises the evidence with rh.verify() and gets the verdict back directly. On a pass you get a stable per-tenant device.ueid — the pseudonym you dedupe on. Bind (device.ueid → wallet) and enforce the rule that matches your distribution: one allocation per device, weight by device-distinct wallets, or flag wallets that share a device.
app.post("/claim", async (req, res) => {
const { challengeId, evidence, wallet } = req.body;
const verdict = await rh.verify(evidence, {
challengeId,
policy: "rootherald:builtin:strict-hardware",
});
if (verdict.device.verdict !== "pass") {
return res.status(403).json({ error: "device_check_failed" });
}
// One allocation per device. (Or: store every (ueid, wallet) pair and weight at snapshot.)
const existing = await db.allocations.findByDevice(verdict.device.ueid);
if (existing && existing.wallet !== wallet) {
return res.status(409).json({ error: "device_already_claimed", with: existing.wallet });
}
await db.allocations.upsert({
deviceId: verdict.device.ueid,
wallet,
attestationType: verdict.device.attestationType,
});
res.json({ ok: true });
});What comes back
rh.verify() returns the verdict synchronously — no token, no JWKS, no offline step. You branch on device.verdict and key everything off device.ueid.
{
"acr": "urn:rootherald:device:high",
"amr": ["hwk"],
"device": {
"ueid": "2f9c4a…", // stable per-tenant device id — dedupe on this
"verdict": "pass", // pass | warn | fail
"earStatus": "affirming", // affirming | warning | contraindicated
"attestationType": "tpm20"
}
}Keep the strict-hardware policy — it rejects the cloud
A farmer's cheapest move is a fleet of cloud VMs, and a cloud vTPM (AWS NitroTPM, Azure, GCP) is an identity they mint for pennies. The default rootherald:builtin:strict-hardware rejects every cloud-vtpm and emulated class outright, so a NitroTPM-backed claim fails. This is the one use case where you almost never want to loosen it.
Beat pre-snapshot farming: bind continuously
Bind devices on connect and on any reward-relevant action, not just at the snapshot; store the device set over time. A wallet that only ever appears alongside the same device as 200 other wallets is the signal you want, and it's visible even when each wallet's on-chain history looks clean.