THE ROLL
The roll, and why you can't cheat it
The app can't grind for a mythic cuz it commits to its random before it sees the registry's. The registry can't pick a seed for you cuz it only ever sees a hash of yours. Both halves end up in the file so anyone can recompute the seed and check.
App side
Draw 32 bytes, hash them, ask for a ticket, check the ticket against the baked-in key and against the commit, derive the seed. One function.
api::Result<ClaimableRoll> rollClaimable(const std::string& registry, const std::string& token) {
ClaimableRoll r{};
cf::randomBytes(r.clientRandom, 32);
auto commit = cf::commitOf(r.clientRandom);
auto t = api::ticket(registry, token, commit.data());
api::Result<ClaimableRoll> out; out.offline = t.offline; out.error = t.error;
if (!t) return out;
if (!cf::verifyTicketSig(*t.value, CHAOS_SERVER_PUBKEY)) { out.error = api::Error{ "the ticket does not check against the registry key we ship", 0 }; return out; }
if (std::memcmp(t.value->commit, commit.data(), 32) != 0) { out.error = api::Error{ "the ticket carries a different commit", 0 }; return out; }
r.ticket = *t.value;
r.seed = cf::deriveSeed(r.ticket, r.clientRandom);
out.value = r;
return out;
}Registry side, the ticket
One claimable roll a week per account, bank of one. The ticket carries your commit and the registry's 32 bytes and it's signed.
async issueTicket(user: User, commit: Uint8Array): Promise<{ ticket: Uint8Array; banked: number; nextInMs: number }> {
if (commit.length !== 32) throw new Refused("commit must be 32 bytes");
const a = await this.allowance(user);
if (a.banked < 1) throw new Refused(`no claimable rolls left, next one in ${Math.ceil(a.nextInMs / 60000)} min`, 429);
const t: Ticket = { id: randomBytes(16), userId: user.id, issuedMs: BigInt(this.now()), commit: new Uint8Array(commit), serverRandom: randomBytes(32), sig: new Uint8Array(64) };
this.signer.signTicket(t);
await this.store.insertTicket({ idHex: hex(t.id), userId: user.id, issuedMs: this.now(), used: false });
await this.store.updateUserAllowance(user.id, a.banked - 1, user.lastAccrualMs);
user.banked = a.banked - 1;
return { ticket: ticketToBytes(t), banked: user.banked, nextInMs: nextTicketInMs({ banked: user.banked, lastAccrualMs: user.lastAccrualMs }, this.now()) };
}// claimable rolls accrue once per period (a week by default) and bank up to a cap (one by default: use it or wait).
// computed on demand, no cron. numbers from config. a roll needs a signed-in account: tickets are only issued to one.
import { config } from "./config";
export const DAY_MS = 86_400_000;
export const periodMs = () => config.rollPeriodDays * DAY_MS;
interface Allowance { banked: number; lastAccrualMs: number }
export function accrue(a: Allowance, nowMs: number, perPeriod = config.rollsPerPeriod, bankMax = config.bankMax): Allowance {
const periods = Math.floor((nowMs - a.lastAccrualMs) / periodMs());
if (periods <= 0) return a;
return { banked: Math.min(bankMax, a.banked + periods * perPeriod), lastAccrualMs: a.lastAccrualMs + periods * periodMs() };
}
export function nextTicketInMs(a: Allowance, nowMs: number, bankMax = config.bankMax): number { return a.banked >= bankMax ? 0 : Math.max(0, a.lastAccrualMs + periodMs() - nowMs); }Registry side, the claim
Verify the file, find the ticket, make sure it's unused and yours, make sure the seed isn't claimed already, derive the tier itself, sign, keep the bytes.
async claim(user: User, bytes: Uint8Array, preview?: Uint8Array): Promise<{ file: Uint8Array; row: SeedRow }> {
const f = new ChaosFile(bytes);
if (f.state !== STATE.ROLLED) throw new Refused("only a rolled file can be claimed (preview rolls have no ticket)");
const c = verify(f, this.signer.pubkey);
if (!ok(c)) throw new Refused(c.why);
const t = await this.store.ticket(hex(f.ticketId));
if (!t) throw new Refused("ticket was never issued by this registry");
if (t.used) throw new Refused("ticket already used");
if (t.userId !== user.id) throw new Refused("ticket belongs to another user", 403);
const seedHex = f.seedHex();
if (await this.store.seed(seedHex)) throw new Refused("seed already claimed", 409);
const p = deriveProps(f.seed); // never trust what the client wrote
f.family = p.family; f.tier = p.tier; f.mode = p.mode; f.mutationSeed = p.mutationSeed;
f.ownerId = user.id; f.claimedAtMs = BigInt(this.now());
f.nonce.set(randomBytes(16)); f.setDisplay(); f.state = STATE.CLAIMED;
this.signer.signCert(f);
const row: SeedRow = { seedHex, ownerId: user.id, family: p.family, tier: p.tier, mode: p.mode, rarityScore: rarityScore(p.tier),
valueCents: valueCents({ tier: p.tier, family: p.family, mode: p.mode, sameTierClaimed: await this.store.countTier(p.tier) }),
state: "owned", nonceHex: f.nonceHex(), priceCents: null, claimedMs: this.now(), updatedMs: this.now() };
await this.store.insertSeed(row);
await this.store.markTicketUsed(t.idHex);
await this.store.putFile(seedHex, f.bytes); // the registry keeps the bytes, so a buyer gets a real file
if (preview && preview.length) {
if (preview.length > config.previewMaxBytes) throw new Refused(`preview too big (max ${config.previewMaxBytes} bytes)`);
if (!(preview[0] === 0x89 && preview[1] === 0x50 && preview[2] === 0x4e && preview[3] === 0x47)) throw new Refused("preview must be a png");
await this.store.putPreview(seedHex, preview);
}
return { file: f.bytes, row };
}By hand
chaos --commit out.reveal prints the commit, the vault page turns it into a .ticket, chaos --ticket t --reveal r rolls, chaos --claim file claims. The setup tab keys do all that for you.