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.
- Scores, levels, XP, quests
- Matchmaking, sessions, replays
- Item stats, art, descriptions
- Leaderboards, achievements
- 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.
| Question | Answer |
|---|---|
| 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. |
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.
| Header | Held by | Scope |
|---|---|---|
| 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
POST /registry/init->registryCid. Once per deployment, by arCCade.POST /tenants->secret. Shown once. The tenant party must already exist.POST /tenants/:id/mint-right-> grants minting with a quota.POST /venue/init->venueCid. Only if you run staked cycles.
Per player, once
POST /players/account->accountCid. The player signs here, and only here.POST /venue/:cid/entitlements->entitlementCid. Needed for cycles only.
Marketplace trade
- Both sides
POST /assets/allocate-> oneallocationCidper leg. Use the samesettlementId. POST /trade/propose->proposalCid.POST /trade/:cid/accept->tradeCid.POST /trade/:cid/settle-> every leg executes in one transaction.
Staked cycle
- Compute
entryDigestfrom your entry document before play begins. POST /cycle/commit->stakeCid. Consumes the player's slot.- Run the game. No calls.
POST /cycle/:cid/settle-> returns the slot. Fails beforeminCycleSeconds.
Tenant admin
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
Lists tenants. Key hashes are never included in any response.
Issues a new key. The previous key stops working on the same call - there is no grace period.
Blocks the tenant from acting. Players keep every asset they already own.
{ "suspended": true, "reason": "under review" }
Grants minting inside the tenant's namespace. Above the quota, minting needs explicit approval.
{ "registryCid": "00d37a98...",
"mintQuota": 100, "quotaWindowSeconds": 3600 }
201 -> { "tenantId", "mintRightCid", "updateId" }
Creates the ecosystem asset registry. Once per deployment. Returns contractId.
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
Confirms the key and returns your namespace and quota. No quota cost. Use it as a health check.
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
Parses one instrument id and reports its shape.
200 -> { "ok": true, "tenantId": "yourgame",
"localId": "sword-of-dawn", "instanceId": "4a91c8f2",
"assetClass": "unique" }
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" }
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
A player's holdings, filtered to your namespace. Safe to poll; costs no quota.
200 -> { "party", "count",
"assets": [ { "contractId", "instrumentId", "amount" } ] }
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
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" } } }
Body { "acceptor": "bob::..." }. Returns tradeCid. Fails after the proposal expires.
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
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" } ] }
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" }
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" }
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.
| Code | Meaning | What to do |
|---|---|---|
| 400 | Malformed body, bad digest length, unknown party | Fix and resend. Never retry unchanged. |
| 401 | Missing or invalid key | Do not retry. Same response for unknown tenants. |
| 403 | Namespace violation, or tenant suspended | Do not retry. The message names the offending id. |
| 404 | Contract or tenant not found | Re-read state; a cached id is likely consumed. |
| 409 | Tenant exists, or no mint right yet | Resolve the prerequisite first. |
| 429 | Quota exhausted | Wait until X-Quota-Reset. Backoff will not help before then. |
| 500 | Ledger rejected the command | Read details.cause - usually an assertion. See below. |
| 503 | Registry not configured | Operator action needed; not retryable by you. |
Common ledger causes inside details
| Cause | Means |
|---|---|
| UNKNOWN_INFORMEES | A party in the command does not exist on this participant. |
| CONTRACT_NOT_FOUND | The id was already consumed by an earlier write. |
| basim kotasi doldu | Mint quota exhausted for this window; request approved minting. |
| cycle has not run... | Settlement attempted before minCycleSeconds. |
| digest mismatch | The 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 |
|---|---|
| mint | GET /assets/:party - look for the instrument id |
| allocate | Re-read the asset; if it is gone, the allocation exists |
| trade settle | GET /assets/:party for both parties |
| cycle commit | The entitlement is consumed if the commit landed |
| cycle settle | GET /cycle/:updateId/outcome |
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
| Field | Type | Rule |
|---|---|---|
| amount | string | Decimal string. Fractional JSON numbers are rejected, not rounded. |
| *Digest | string | Exactly 64 lowercase hex characters. |
| cycleId / tradeId | string | <= 64 chars, no : or |. Generate, never reuse. |
| localId | string | [a-z0-9._-], plus optional #instance. |
| instrumentId | object | { admin, id } - id is tenant/local. |
| *Cid | string | Contract id from a prior response. Opaque; never construct one. |
| disposition | enum | ReturnedInFull, ReturnedWithForfeit, ForfeitedInFull |
Enforced limits
Where each rule actually lives. Anything marked contract cannot be bypassed by calling the ledger directly.
| Rule | Enforced by | Value |
|---|---|---|
| Namespace isolation | API + contract | tenant/ prefix |
| Write quota | API | 60 / 60 s |
| Mint quota | Contract | sliding window |
| Concurrent cycles | Contract | slot token |
| Lock & cycle duration | Contract | ledger time only |
| Unique asset amount | API + contract | exactly 1 |
| Trade atomicity | Canton engine | all legs or none |
| Burn | Contract | owner + 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
codePointCount, never String.length().
Sharp edges
| Behaviour | Consequence |
|---|---|
| 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. |