DOCUMENTATIONv1 · KERNEL API

The economy kernel,
documented for builders.

Duped is a globally consistent economy kernel for online games. It makes item and gold duplication unrepresentable in the authoritative state — as long as every economic action goes through the kernel. These docs cover the model, the public API, and how a studio adopts it.

01What it is

Overview

A dupe bug lets a player end up with two of something that should exist once — a legendary blade, a stack of gold. It has wrecked game economies for 25 years (New World, Diablo II, RuneScape, EVE, WoW) because the root cause is distributed-systems correctness, not bad luck. Duped removes the root cause: every economic action is one atomic, idempotent transaction through a kernel, and uniqueness becomes a property the data model cannot violate.

THE THESIS

Duped makes item and gold duplication unrepresentable in the authoritative state — as long as every economic action goes through the kernel. Uniqueness isn't a check you run; it's a property the data model can't express.

Each classic exploit is a concurrency failure with a precise correctness property behind it:

Classic exploitWhat goes wrongProperty enforced
Trade-window racetwo trades move one itemversion-guarded transfer
Drop-and-relogitem in world and inventoryatomic all-or-nothing
Disconnect mid-tradeone side credited, other notatomic rollback
Cross-region dupeitem exists in two regionsactive-active consistency
Gold double-spendsame coins spent twiceconditional atomic debit

Scope — what Duped is, and isn't

Duped is the economy and trade settlement layer: trades, drops, pickups, mail, auction-house fills, gold transfers, and cross-region item movement. It is not the real-time combat loop, movement, accounts, matchmaking, or art. It owns the authoritative answer to “who owns what, and how much gold exists” — and nothing more. The guarantees hold for exactly the actions that pass through it.

02The model

Concepts

Duped models two kinds of economic object and protects each with the mechanism that actually fits it. A sword is not money — so they are never forced into the same structure.

UNIQUE ITEMS

One row per item in item_instances, with exactly one owner_id and a version. Every move — trade, drop, pickup, mail — is the same version-guarded conditional UPDATE. “Owned twice” has no representation.

FUNGIBLE GOLD

Sharded balances in currency_shards with a CHECK balance_minor >= 0 that makes overspend structurally impossible, plus a balanced double-entry ledger written in the same transaction. SUM = 0 proves no inflation.

The version-guarded transfer

The entire anti-dupe guarantee for unique items is this one conditional update. The row only moves if its current (owner, version) still matches what the caller read:

sql
UPDATE item_instances
   SET owner_type = :to_type,
       owner_id   = :to_id,
       region     = :region,
       version    = version + 1,
       updated_at = now()
 WHERE instance_id = :id
   AND owner_type  = :from_type
   AND owner_id    = :from_id
   AND version     = :expected;
-- rowCount MUST be 1. If 0 -> someone already moved it -> abort -> ITEM_MOVED

Two concurrent transfers cannot both match owner_id = :from AND version = :expected. The first to commit bumps the version; the second matches zero rows — or conflicts at COMMIT (DSQL surfaces OCC write conflicts as SQLSTATE 40001), retries with the same key, re-reads, and still finds zero rows. Exactly one wins, globally.

The trade kernel

executeTrade(req) is the only way the authoritative economy changes. One attempt is one DSQL transaction:

  1. Idempotency check — a registry hit with a matching request hash replays the stored snapshot; same key + different payload is a 409.
  2. Item legs — each is the version-guarded update above; any miss rolls the whole trade back.
  3. Gold legs — a sharded conditional debit of the payer and credit of the payee; no shard covers it ⇒ INSUFFICIENT_FUNDS.
  4. Record — trade header, provenance, the balanced ledger (when gold moved), the idempotency registry, and the event outbox, all in the same transaction.
  5. Commit — on 40001, roll back, jittered backoff, retry with the same key. Retries are surfaced on snapshot.attempts, never hidden.

Invariants — the properties that always hold

Correctness is checkable as plain SQL against the truth core. The same queries power pnpm reconcile, the /api/world/proof route, and the live invariant board — so the number on the dashboard and the number in the proof are the same number.

Legendary exists exactly oncecount = 1
No item instance owned twice= 0
Every item has exactly one owner0 ownerless
Gold supply conserved (no inflation)= minted
Gold ledger drift is zeroSUM = 0
Every gold transaction is balanced0 unbalanced
No negative gold balance= 0
03Run it locally

Quickstart

Duped runs on Aurora DSQL (IAM / Vercel OIDC auth — no passwords) as the truth core and DynamoDB as the live read model. Money is always BIGINT minor units (1 gold = 100 minor); there are no foreign keys, and indexes are created ASYNC.

Bring up the world

bash
pnpm install
pnpm db:check          # SELECT NOW() — confirm DSQL connectivity
pnpm db:migrate        # apply schema (one DDL per txn; indexes ASYNC)
pnpm db:index-status   # wait for indexes ACTIVE before traffic
pnpm db:setup-ddb      # create the DynamoDB world read-model table
pnpm db:seed           # Aetheria: ONE legendary, whale gold, players
pnpm dev               # the live world at http://localhost:3000

Run the attacks and prove it

bash
pnpm storm                 # the dupe storm — thousands of bots vs ONE legendary
pnpm storm --gold          # the gold double-spend storm
pnpm storm --market --sweep # independent-trade throughput sweep (scale)
pnpm reconcile             # the SQL proof: legendary=1, gold conserved, drift=0
pnpm projector             # drain the outbox -> DynamoDB (run during/after a storm)

Local dev uses .env.local (a single-region cluster). For the real Tokyo⇄Seoul cross-region demo, use .env.mr (a peered cluster), e.g. pnpm exec tsx --env-file=.env.mr scripts/<script>.ts.

04The kernel endpoint

API Reference

POST/api/v1/tradesThe public kernel endpoint. Takes a TradeRequest, returns a TradeSnapshot.

A single call settles a two-sided atomic exchange. A DROP, PICKUP, or MAIL is the same machinery with WORLD / MAIL as one side. Always send a stable idempotencyKey — the same key with the same payload replays the stored snapshot exactly.

Request — TradeRequest

realmId*
string
The realm / world the trade settles in.
idempotencyKey*
string
Exactly-once guard, keyed with the realm. Same key + same payload replays; same key + different payload is rejected with 409.
kind*
'TRADE' | 'DROP' | 'PICKUP' | 'MAIL'
Descriptive label for the move. All kinds use the same version guard.
playerA / playerB*
string
The two display identities recorded on the trade.
itemLegs*
ItemLeg[]
Unique-item ownership moves (may be empty for a pure gold transfer).
goldLegs*
GoldLeg[]
Fungible gold movements (may be empty for a pure item trade).
currency*
string
Currency code for the gold legs and ledger (e.g. GOLD).
region*
'TOKYO' | 'SEOUL'
Which regional DSQL endpoint settles the trade — TOKYO is primary, SEOUL secondary.
ItemLeg
instanceId
string
The unique item being moved.
expectedVersion
number
The version the caller read. A mismatch ⇒ ITEM_MOVED.
fromOwnerType / fromOwnerId
OwnerType / string
Current owner the move is guarded against.
toOwnerType / toOwnerId
OwnerType / string
Destination owner. PLAYER | WORLD | ESCROW | MAIL.
GoldLeg
fromPlayerId
string
Payer — debited from a covering shard.
toPlayerId
string
Payee — credited.
amountMinor
number
Amount in minor units (BIGINT-safe integer; 1 gold = 100 minor).

Response — TradeSnapshot

outcome
'COMMITTED' | 'DECLINED'
Whether the trade settled. A DECLINED is a deterministic business outcome, not an error.
tradeId
string
The trade header id.
movedItems
MovedItem[]
Items that changed hands, each with instanceId, fromOwnerId, toOwnerId, versionAfter. Present on commit.
goldMovedMinor
number
Total gold (minor units) moved across all gold legs.
ledgerTxnId
string?
Present on commit when any gold moved.
failureCode
TradeFailureCode?
Present on decline — see the codes below.
attempts
number
Number of attempts the kernel made (1 + OCC 40001 retries). Surfaced, never hidden.
replayed
boolean
True when the response came from the idempotency registry rather than a fresh commit.
committedAt
string?
ISO timestamp, present on commit.

Outcomes & failure codes

DECLINEDITEM_MOVEDan item's (owner, version) no longer matches
DECLINEDINSUFFICIENT_FUNDSno shard could cover a gold leg
DECLINEDITEM_NOT_FOUNDthe instance row doesn't exist in this realm
DECLINEDINVALID_REQUESTstructurally invalid leg set caught at runtime

HTTP status codes

201Created — trade COMMITTED
200OK — trade DECLINED (deterministic business outcome)
400VALIDATION_ERROR — malformed request
404NOT_FOUND — realm or instance missing
409Idempotency key reused with a different payload
503RETRY_EXHAUSTED — OCC 40001 storm beyond max attempts

Example — settle a trade

bash
curl -X POST https://your-app.vercel.app/api/v1/trades \
  -H "Content-Type: application/json" \
  -d '{
    "realmId": "aetheria",
    "idempotencyKey": "trade-7f3c1a-001",
    "kind": "TRADE",
    "playerA": "player-aria",
    "playerB": "player-kade",
    "itemLegs": [
      {
        "instanceId": "blade-of-dawn-0001",
        "expectedVersion": 4,
        "fromOwnerType": "PLAYER", "fromOwnerId": "player-aria",
        "toOwnerType": "PLAYER",   "toOwnerId": "player-kade"
      }
    ],
    "goldLegs": [
      { "fromPlayerId": "player-kade", "toPlayerId": "player-aria", "amountMinor": 250000 }
    ],
    "currency": "GOLD",
    "region": "TOKYO"
  }'
json
// 201 Created
{
  "outcome": "COMMITTED",
  "tradeId": "trade-2b9e...",
  "kind": "TRADE",
  "movedItems": [
    { "instanceId": "blade-of-dawn-0001",
      "fromOwnerId": "player-aria", "toOwnerId": "player-kade",
      "versionAfter": 5 }
  ],
  "goldMovedMinor": 250000,
  "ledgerTxnId": "ldg-04c7...",
  "attempts": 2,
  "replayed": false,
  "committedAt": "2026-06-27T10:14:02.118Z"
}

World read endpoints

These serve the DynamoDB read model and the demo levers (read-only telemetry plus the storm controls):

GET/api/world/snapshotcurrent world read model
GET/api/world/streamlive SSE feed of world events
GET/api/world/proofrun the invariant SQL on demand
POST/api/world/stormfire the dupe storm
POST/api/world/gold-stormfire the gold double-spend storm
POST/api/world/market-stormindependent-trade throughput sweep
POST/api/world/regiontoggle the settling region
05How it fits together

Architecture

One truth core, one read plane, one deploy target. The write path is a single atomic transaction; the read plane is fed asynchronously and only ever projected, never written inside a trade.

  game clients / dupe-attack bots
            │  POST /api/v1/trades  (TradeRequest)
            ▼
   ┌──────────────────────────┐   executeTrade() — one idempotent,
   │      THE TRADE KERNEL     │   atomic, OCC-retrying transaction
   └────────────┬─────────────┘
                │ same txn: item_instances · currency_shards · trades
                │ item_moves · economy_ledger_* · idempotency_registry · event_outbox
                ▼
   ╔════════════════════════╗   outbox    ╔═════════════════════════╗
   ║   AURORA DSQL          ║──projector─▶ ║   DynamoDB              ║
   ║   truth core           ║   (async)   ║   live world read model ║
   ║   Tokyo ⇄ Seoul        ║             ╚═════════════════════════╝
   ║   active-active        ║                        │
   ╚════════════════════════╝                        ▼
            ▲                            world arena + console (Vercel)
   invariant board (live SQL)
AURORA DSQL — truth core

The single source of truth. A multi-region peered cluster is one logical database over two strongly-consistent endpoints (Tokyo primary ap-northeast-1, Seoul secondary ap-northeast-2). Optimistic concurrency; no foreign keys; CREATE INDEX ASYNC; money as BIGINT minor units.

DYNAMODB — read model

The read plane only. Written exclusively by the transactional-outbox projector via idempotent PutItem — never inside a trade. CQRS: the write model stays minimal and serializable, reads scale out.

VERCEL — runtime

Next.js 15 (App Router). Auth is IAM-token based — Vercel OIDC at runtime, the AWS credential chain locally. No passwords or secrets in code.

Exactly-once, end to end

Two pieces make the write path safe to retry. The idempotency registry (keyed realm_id, idempotency_key) stores the canonical snapshot, so a retried or duplicated request replays byte-identically instead of double-applying. The event outbox is written inside the same transaction as the economic change, then drained to DynamoDB by the projector — so the read model can never describe a state the truth core didn't commit.

06Adopting it

Integration

Adoption is a single discipline: route every economic action through the kernel. A trade, a drop, a pickup, mail, an auction fill, a gold transfer — all of them become a TradeRequest to executeTrade / POST /api/v1/trades, each carrying a stable idempotency key.

  1. Model your objects in two buckets. Unique items become item_instances rows; fungible currencies become sharded currency_shards + ledger.
  2. Express each action as legs. Item legs carry the expectedVersion you read; gold legs carry minor units. A drop is an item leg to WORLD; a sale is item legs one way and gold legs the other.
  3. Derive a deterministic idempotency key per logical action so client retries, disconnects, and replays settle exactly once.
  4. Read from the projection, not the truth core. Inventories, feeds, and dashboards read DynamoDB; the kernel keeps the write path lean.
  5. Surface retries, don't hide them. Trust snapshot.attempts and the failure codes as first-class outcomes in your client.
THE HONEST CAVEAT

The guarantees hold for exactly the actions that pass through the kernel. If a path bypasses executeTrade and mutates ownership or balances directly, it bypasses the proof too. Duped makes duplication unrepresentable in the authoritative state — so make the kernel the only door into that state.