soma.cell

Daily chest — one claim per day, clock tricks be damned

Daily-reward double claims — via device-clock manipulation or plain request replay — are the most farmed exploit in free-to-play games, and "fixes" that compare timestamps keep losing to timezone math. The fix is not a better clock check. It is that each (player, day) claim is a one-way machine: unclaimed -> claimed, with `claimed` TERMINAL. The replayed request has no transition to fire — soma verify proves it. The coin ledger's >= 0 invariant keeps the wallet honest on the way through.

A Soma program from the games_economy part of the verified corpus: 57 lines, cells DailyChest, ChestTests. It passes soma check with soma 3.2.4.

How it is built

The cell DailyChest declares 2 signals in its face: claim_chest(player: String, day: String) -> Map and coins_of(player: String) -> Int; keeps 1 memory slot: coins (Map<String, Int>, persistent and consistent) under the invariant coins >= 0. Its state machine chest_claim starts in unclaimed and allows 1 transition: unclaimed -> claimed. claimed is terminal. Handlers: claim_chest (line 30) and coins_of (line 38).

The file also holds 1 test cell (ChestTests) with 9 assertions, run by soma test.

Generated from soma describe on this file.

The source

daily_chest.cell

// ============================================================================
// DAILY CHEST — one claim per day, clock tricks be damned
// ============================================================================
// Daily-reward double claims — via device-clock manipulation or plain
// request replay — are the most farmed exploit in free-to-play games, and
// "fixes" that compare timestamps keep losing to timezone math. The fix is
// not a better clock check. It is that each (player, day) claim is a
// one-way machine: unclaimed -> claimed, with `claimed` TERMINAL. The
// replayed request has no transition to fire — soma verify proves it. The
// coin ledger's >= 0 invariant keeps the wallet honest on the way through.
// ============================================================================

cell DailyChest {
    face {
        signal claim_chest(player: String, day: String) -> Map
        signal coins_of(player: String) -> Int
    }

    memory {
        coins: Map<String, Int> [persistent, consistent]
        invariant coins >= 0
    }

    // one instance per (player, day) — the smallest possible ratchet
    state chest_claim {
        initial: unclaimed
        unclaimed -> claimed
    }

    on claim_chest(player: String, day: String) {
        let key = "{player}@{day}"
        let r = try { transition(key, "claimed") }
        if r.error != () { return map("ok", false, "reason", "already claimed on {day}") }
        coins.set(player, (coins.get(player) ?? 0) + 50)
        return map("ok", true, "coins", coins.get(player))
    }

    on coins_of(player: String) { return coins.get(player) ?? 0 }
}

cell test ChestTests {
    rules {
        assert claim_chest("zoe", "2026-06-11").ok == true
        assert coins_of("zoe") == 50
        // THE REPLAY: same player, same day — the transition does not exist
        assert claim_chest("zoe", "2026-06-11").ok == false
        assert coins_of("zoe") == 50
        assert_fails transition("zoe@2026-06-11", "claimed")
        // a new day is a fresh machine instance, claimable exactly once
        assert claim_chest("zoe", "2026-06-12").ok == true
        assert coins_of("zoe") == 100
        assert claim_chest("zoe", "2026-06-12").ok == false
        // the wallet itself refuses corruption
        assert_fails coins.set("zoe", -5)
    }
}

What the toolchain says

Run on a fresh copy of the program at build time; the outputs are pasted unedited.

$ soma check daily_chest.cell
✓ All checks passed.
$ soma test daily_chest.cell
test ChestTests ...
  ✓ assert claim_chest("zoe", "2026-06-11").ok == true
  ✓ assert coins_of("zoe") == 50
  ✓ assert claim_chest("zoe", "2026-06-11").ok == false
  ✓ assert coins_of("zoe") == 50
  ✓ assert_fails transition("zoe@2026-06-11", "claimed") — raised invalid transition: claimed → claimed. Current state: 'claimed'. Valid targets: []
  ✓ assert claim_chest("zoe", "2026-06-12").ok == true
  ✓ assert coins_of("zoe") == 100
  ✓ assert claim_chest("zoe", "2026-06-12").ok == false
  ✓ assert_fails coins.set("zoe", -5) — raised memory invariant violated on 'coins': coins >= 0 — rejected write of -5 (key "zoe"); the slot is unchanged

9 tests: 9 passed, 0 failed
$ soma verify daily_chest.cell
Verifying daily_chest.cell...

State machine 'chest_claim': 2 states, initial 'unclaimed'
  States: [claimed, unclaimed]

  ✓ 2 states, 1 transitions
  ✓ all states reachable from 'unclaimed'
  ✓ terminal states: [claimed]
  ✓ no deadlocks
  ✓ liveness: every state can eventually reach a terminal state
  ✓ think-isolated: CTL safety properties hold regardless of LLM output (1 handlers, 1 literal transitions, 0 dynamic)
  ✓ termination: all 2 handlers structurally terminate
  ✓ refinement: handler `claim_chest` ⟶ {claimed}
  · transition sources: `claim_chest` calls transition(id, target) without declaring the source — the runtime refuses a move the graph forbids, but the proof does not see where the handler starts; `transition(id, from, to)` names the edge (checked by `soma check`, refused at run time from any other state)

DailyChest/invariants:

  ✓ invariant coins >= 0 — writer 'claim_chest' proven by induction (writes (coins.get(player) ?? 0) + 50)

9 passed, 0 warnings, 0 failures

  Temporal properties for 'chest_claim':
  ✓ deadlock_free — no deadlocks in any reachable state
Temporal: 1 passed, 0 failed
VERIFY OK

Builtins used

map, transition

Source file: /corpus/games_economy/daily_chest.cell · examples/corpus/games_economy/daily_chest.cell