Game economy infrastructure on Canton Network

Two ledger writes per game cycle. Nothing else is written.

Mint assets, run a marketplace, and settle staked gameplay on Canton - without running a validator, a registry, or writing Daml. Scores and sessions stay in your database. Only value reaches the ledger, and when it does it settles atomically.

Base URL https://sdk.arccade.io. JSON in, JSON out. All amounts are strings.

What goes on-chain

There is no endpoint that writes non-value data. This is a hard property of the API, not a guideline: "record this score" does not exist, so it cannot be called, batched or abused.

Your database
  • Scores, levels, XP, quests
  • Matchmaking, sessions, replays
  • Item stats, art, descriptions
  • Leaderboards, achievements
The ledger
  • Stake committed and released
  • Ownership changing hands
  • Value transferred
  • Assets minted and burned

Notes for agents

Facts an autonomous integration needs before its first call.

QuestionAnswer
Are writes idempotent?No. Retrying a 5xx may double-write. See Idempotency.
Can I discover state?Yes - GET /assets/:party and GET /cycle/:updateId/outcome are safe to poll.
Is there a dry run?Yes - POST /preflight validates without spending quota or writing.
What is rate limited?Writes only, per tenant. Response carries X-Quota-Remaining and X-Quota-Reset.
Do contract ids persist?No. Any id you cache can be consumed by the next write. Re-read before use.
Are amounts floats?Never. Send decimal strings; fractional JSON numbers are rejected.
What fails closed?Namespace violations, quota, unknown parties, expired locks, digest mismatches.
Validate before you write POST /preflight costs no quota and touches no ledger. Run it whenever a plan was constructed from model output rather than from a previous response.

Authentication

Two credentials. Neither can perform the other's operations.

HeaderHeld byScope
X-Internal-Service-Key arCCade Tenant lifecycle, quotas, registry and venue creation
X-Arccade-Key You Everything inside your namespace

Keys look like ags_yourgame_.... The tenant name inside is a lookup hint, not proof - authorisation is a constant-time hash comparison, and only the hash is stored. A wrong key and an unknown tenant return an identical 401, so tenant names cannot be enumerated.

Because every tenant shares one registry admin, your namespace is the isolation boundary. Instrument ids are always yourgame/..., checked at the API and again in the contract. Shared assets such as Canton Coin carry no prefix.

Call sequence

Order matters. Each step depends on an id returned by the one before it.

Onboarding, once per tenant

  1. POST /registry/init -> registryCid. Once per deployment, by arCCade.
  2. POST /tenants -> secret. Shown once. The tenant party must already exist.
  3. POST /tenants/:id/mint-right -> grants minting with a quota.
  4. POST /venue/init -> venueCid. Only if you run staked cycles.

Per player, once

  1. POST /players/account -> accountCid. The player signs here, and only here.
  2. POST /venue/:cid/entitlements -> entitlementCid. Needed for cycles only.

Marketplace trade

  1. Both sides POST /assets/allocate -> one allocationCid per leg. Use the same settlementId.
  2. POST /trade/propose -> proposalCid.
  3. POST /trade/:cid/accept -> tradeCid.
  4. POST /trade/:cid/settle -> every leg executes in one transaction.

Staked cycle

  1. Compute entryDigest from your entry document before play begins.
  2. POST /cycle/commit -> stakeCid. Consumes the player's slot.
  3. Run the game. No calls.
  4. POST /cycle/:cid/settle -> returns the slot. Fails before minCycleSeconds.

Tenant admin

POST/api/game/tenantsinternal

Opens a tenant and returns its key once. The key is not stored and cannot be retrieved again.

{ "tenantId": "yourgame",        // [a-z0-9-], 3-32 chars
  "tenantParty": "yourgame::1220ab...",  // must already exist
  "maxWrites": 60, "quotaWindowSeconds": 60 }

201 -> { "tenantId", "tenantParty", "secret", "createdAt" }
400 -> tenantParty unknown to this participant
409 -> tenant already exists
GET/api/game/tenantsinternal

Lists tenants. Key hashes are never included in any response.

POST/api/game/tenants/:tenantId/rotate-keyinternal

Issues a new key. The previous key stops working on the same call - there is no grace period.

POST/api/game/tenants/:tenantId/suspendinternal

Blocks the tenant from acting. Players keep every asset they already own.

{ "suspended": true, "reason": "under review" }
POST/api/game/tenants/:tenantId/mint-rightinternal

Grants minting inside the tenant's namespace. Above the quota, minting needs explicit approval.

{ "registryCid": "00d37a98...",
  "mintQuota": 100, "quotaWindowSeconds": 3600 }

201 -> { "tenantId", "mintRightCid", "updateId" }
POST/api/game/registry/initinternal

Creates the ecosystem asset registry. Once per deployment. Returns contractId.

POST/api/game/venue/initinternal

Creates a venue and its policy. Dry-run venues must be prefixed dryrun- and cannot charge fees or pay out.

{ "venueId": "yourgame-arena", "mode": "ModeLive",
  "gameCodes": ["yourgame-runner-v1"],
  "policy": { "minStakeAmount": "10.0", "minPlatformFee": "0.01",
              "minLockSeconds": 14400, "minCycleSeconds": 600,
              "cooldownSeconds": 30, "concurrencyLimit": 1 } }

Players & assets

GET/api/game/whoamitenant

Confirms the key and returns your namespace and quota. No quota cost. Use it as a health check.

POST/api/game/preflighttenant

Validates instrument ids against isolation rules. No quota, no ledger write.

{ "instrumentIds": [ { "admin": "reg::1220ee...", "id": "yourgame/gold" } ] }

200 -> { "ok": true, "quotaRemaining", "quotaResetAt" }
403 -> isolation violation, with the offending id named
POST/api/game/assets/validatetenant

Parses one instrument id and reports its shape.

200 -> { "ok": true, "tenantId": "yourgame",
        "localId": "sword-of-dawn", "instanceId": "4a91c8f2",
        "assetClass": "unique" }
POST/api/game/players/accounttenant

Opens a player's asset account. This is the only call the player signs; afterwards the registry can mint to them without a signature per reward.

{ "playerParty": "alice::1220ab...", "accountId": "alice-1" }

201 -> { "playerParty", "accountCid", "updateId" }
POST/api/game/assets/minttenant

Mints into a player account. localId is namespaced automatically - send gold, receive yourgame/gold. Append #instance for a unique asset, whose amount must be "1.0".

{ "accountCid": "00c8a438...",
  "localId": "sword-of-dawn#4a91c8f2",
  "amount": "1.0",
  "assetMeta": { "attack": "9", "tier": "legendary" } }

201 -> { "instrumentId": { "admin", "id" }, "assetCid", "updateId" }
409 -> no mint right granted yet
500 -> quota exhausted (contract-level); request approval
GET/api/game/assets/:partytenant

A player's holdings, filtered to your namespace. Safe to poll; costs no quota.

200 -> { "party", "count",
        "assets": [ { "contractId", "instrumentId", "amount" } ] }
POST/api/game/assets/allocatetenant

Sets an asset aside for a settlement. Both sides of a trade must allocate with the same settlementId, using legId "offer" and "ask". An allocated asset cannot be allocated again.

{ "registryCid": "00d37a98...", "assetCid": "00b480a9...",
  "sender": "alice::1220ab...", "receiver": "bob::1220cd...",
  "localId": "sword-of-dawn#4a91c8f2", "amount": "1.0",
  "settlementId": "trade-8814", "legId": "offer" }

201 -> { "allocationCid", "legId", "instrumentId", "updateId" }

Trade

POST/api/game/trade/proposetenant

Two legs minimum - a one-sided gift cannot be written with this primitive. Set taker to null for an open offer.

{ "tradeId": "trade-8814", "maker": "alice::...", "taker": "bob::...",
  "tradeDigest": "<64-char sha256>",
  "legs": {
    "offer": { "sender": "alice::...", "receiver": "bob::...",
                "instrumentId": {...}, "amount": "1.0" },
    "ask":   { "sender": "bob::...", "receiver": "alice::...",
                "instrumentId": {...}, "amount": "300.0" } } }
POST/api/game/trade/:proposalCid/accepttenant

Body { "acceptor": "bob::..." }. Returns tradeCid. Fails after the proposal expires.

POST/api/game/trade/:tradeCid/settletenant

Executes every leg in one transaction. Allocation keys must match leg keys exactly - a missing leg is rejected, never silently skipped.

{ "allocations": { "offer": "00fb0ae4...", "ask": "00881beb..." },
  "parties": ["alice::...", "bob::..."] }

200 -> { "settled": true, "updateId",
        "created": [ { "instrumentId", "amount", "owner" } ] }

Cycle

POST/api/game/venue/:venueCid/entitlementstenant

Issues player slots. The slot is what enforces the concurrency limit - a second commit on a consumed slot is rejected by the ledger, not by this service.

{ "grants": [ { "player": "alice::...", "concurrencyIndex": 0,
                "tier": "silver" } ] }
POST/api/game/cycle/committenant

Write 1. Fix entryDigest before play so entry conditions cannot be retrofitted afterwards.

{ "entitlementCid": "002769ea...", "player": "alice::...",
  "gameCode": "yourgame-runner-v1", "cycleId": "run-3f2a...",
  "entryDigest": "<64-char sha256>",
  "stakeAmount": "100.0", "lockSeconds": 14400 }

201 -> { "cycleId", "stakeCid", "custodyTag", "lockExpiresAt", "updateId" }
POST/api/game/cycle/:stakeCid/settletenant

Write 2. If you send revealedOutcome, the ledger recomputes its hash and rejects a mismatch. Fails before minCycleSeconds have elapsed.

{ "player": "alice::...", "disposition": "ReturnedInFull",
  "returnedAmount": "100.0", "forfeitedAmount": "0.0",
  "outcomeDigest": "<64-char sha256>",
  "revealedOutcome": "{\"score\":4200,\"result\":\"win\"}" }

200 -> { "settled": true, "entitlementCid", "updateId" }
GET/api/game/cycle/:updateId/outcometenant

Reads the settlement out of the transaction's exercise node. This data does not appear in the default ledger event stream - this endpoint is how you get it.

200 -> { "choice": "GameStake_Settle", "disposition",
        "returnedAmount", "forfeitedAmount", "payoutAmount",
        "outcomeDigest", "revealedOutcome" }

Errors

Every error returns { "error": "..." }, some with details carrying the ledger's own code.

CodeMeaningWhat to do
400Malformed body, bad digest length, unknown partyFix and resend. Never retry unchanged.
401Missing or invalid keyDo not retry. Same response for unknown tenants.
403Namespace violation, or tenant suspendedDo not retry. The message names the offending id.
404Contract or tenant not foundRe-read state; a cached id is likely consumed.
409Tenant exists, or no mint right yetResolve the prerequisite first.
429Quota exhaustedWait until X-Quota-Reset. Backoff will not help before then.
500Ledger rejected the commandRead details.cause - usually an assertion. See below.
503Registry not configuredOperator action needed; not retryable by you.

Common ledger causes inside details

CauseMeans
UNKNOWN_INFORMEESA party in the command does not exist on this participant.
CONTRACT_NOT_FOUNDThe id was already consumed by an earlier write.
basim kotasi dolduMint quota exhausted for this window; request approved minting.
cycle has not run...Settlement attempted before minCycleSeconds.
digest mismatchThe revealed document does not hash to the committed digest.

Idempotency & retries

Writes are not idempotent. A timed-out request may have committed. Before retrying any write, read state and check whether it already succeeded.

After a failed...Check
mintGET /assets/:party - look for the instrument id
allocateRe-read the asset; if it is gone, the allocation exists
trade settleGET /assets/:party for both parties
cycle commitThe entitlement is consumed if the commit landed
cycle settleGET /cycle/:updateId/outcome
Do not cache contract ids across writes Most writes consume the contract they act on and create a replacement. A cached mintRightCid or entitlementCid goes stale after one use. The API re-reads these for you; if you build ledger commands directly, do the same.

Types & encoding

FieldTypeRule
amountstringDecimal string. Fractional JSON numbers are rejected, not rounded.
*DigeststringExactly 64 lowercase hex characters.
cycleId / tradeIdstring<= 64 chars, no : or |. Generate, never reuse.
localIdstring[a-z0-9._-], plus optional #instance.
instrumentIdobject{ admin, id } - id is tenant/local.
*CidstringContract id from a prior response. Opaque; never construct one.
dispositionenumReturnedInFull, ReturnedWithForfeit, ForfeitedInFull

Enforced limits

Where each rule actually lives. Anything marked contract cannot be bypassed by calling the ledger directly.

RuleEnforced byValue
Namespace isolationAPI + contracttenant/ prefix
Write quotaAPI60 / 60 s
Mint quotaContractsliding window
Concurrent cyclesContractslot token
Lock & cycle durationContractledger time only
Unique asset amountAPI + contractexactly 1
Trade atomicityCanton engineall legs or none
BurnContractowner + registry

Players always have an exit. Once a lock expires, a player recovers their funds and their slot with their own signature alone. arCCade cannot strand either.

@arccade/game-sdk

The HTTP API is sufficient on its own. Use the library when you need the canonical encoding locally - to compute a digest before sending it, or to verify one a player disputes.

import { newCycleId, canonDocument, canonText, canonDecimal, textDigest }
  from '@arccade/game-sdk'

const cycleId = newCycleId('run')        // always generate; ids must be unique

const doc = canonDocument('yourgame-entry', 1, [
  ['cycle-id',  canonText(cycleId)],
  ['stake',     canonDecimal('25.0')],   // strings, never floats
  ['seed-hash', canonText(seedHash)],
])

const entryDigest = textDigest(doc)      // send this; publish doc

Field names must be ASCII so sort order is identical in every language; anything else is rejected rather than allowed to diverge silently. A third party verifies your published document with no library at all:

curl https://yourgame.example/cycles/8814/entry.json | sha256sum
# matches entryDigest on the ledger
Byte-identical across three implementations The encoding exists in Daml, JavaScript and Python, with a golden-vector test that fails the build if any drifts. Lengths count Unicode code points - a Java port must use codePointCount, never String.length().

Sharp edges

BehaviourConsequence
Settlement results are not in the default event stream They live in the exercise node, visible only under TRANSACTION_SHAPE_LEDGER_EFFECTS. Use GET /cycle/:updateId/outcome.
Tenant parties are validated at registration Previously an unknown party failed days later on first mint with UNKNOWN_INFORMEES. Now it fails immediately.
Contract ids are consumed by writes Cached ids go stale after one use. Re-read before every write.
Dry-run venues cannot charge or pay out Contract-level rule. Dry-run activity can never be presented as qualifying economic activity.
Trade allocations are withdrawable before settlement Deliberate: a trade is not escrow. Stake locks behave the opposite way and are binding.