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, running against Canton TestNet. JSON in, JSON
out. Send every amount as a decimal string.
What goes on-chain
There is no endpoint that writes game data. "Record this score" does not exist, so it cannot be called, batched or abused. A cycle moves value twice: once when the stake is committed and once when it is settled.
- 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
- One digest per audit period
Notes for agents
Facts an autonomous integration needs before its first call.
| Question | Answer |
|---|---|
| Is there a machine-readable index? | Yes. GET /api/game needs no key and lists every route by the credential it takes, including the closed ones. |
| Are writes idempotent? | No. Retrying a 5xx may double-write. See Idempotency. |
| Can I discover state? | Yes. GET /assets/:party, GET /cycle/open/:party, GET /venue/entitlements/:party, GET /venues and GET /cycle/:updateId/outcome cost no quota and are safe to poll. |
| Is there a dry run? | POST /preflight checks instrument ids against isolation rules without writing. For whole cycles, run a dry-run venue. |
| What is rate limited? | Writes, per tenant. Write responses carry X-Quota-Remaining and X-Quota-Reset. A write that fails validation still counts. |
| Who signs a stake? | For a party hosted on the participant, the service submits. For a player holding their own key: prepare, have the wallet sign the hash, execute. |
| Do contract ids persist? | No. Any id you cache can be consumed by the next write, including a venue's. Re-read before use. |
| Are amounts floats? | Never. Send decimal strings. Numbers are converted with String(), and a float can print as 1e-7, which the ledger rejects. |
| What fails closed? | Namespace violations, quota, unknown tenant parties, expired prepared requests, policy outside bounds, 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, with one exception noted below.
| Header | Held by | Scope |
|---|---|---|
| X-Internal-Service-Key | arCCade | Tenant lifecycle, mint rights, registry creation, venue policy changes |
| 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.
POST /venue/init accepts either header. With a tenant key the venue must sit in
your namespace and its policy must stay inside arCCade's bounds; with the internal key both
checks are skipped.
Because every tenant shares one registry admin, your namespace is the isolation
boundary. Instrument ids are always yourgame/..., venue ids
yourgame/... or dryrun-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->contractId. Once per deployment, by arCCade.POST /tenants->secret. Shown once. The tenant party must already exist.POST /tenants/:id/mint-right->mintRightCid. Only if you mint assets.POST /venue/init->venueCid. Only if you run staked cycles.
Per player, once
POST /players/account->accountCid. Needed for assets only.POST /venue/:venueCid/entitlements-> one entitlement per slot. 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, player holds their own key
- Compute
entryDigestfrom your entry document before play begins. POST /cycle/offer/prepare->requestId,preparedTransactionHash.- The player's wallet signs the hash.
POST /cycle/offer/execute->stakeCid,lockedAmulet. Write 1.- Run the game. No calls.
POST /cycle/:stakeCid/settle/offer/preparewithlockedHoldingCid->requestId,preparedTransactionHash.- The player's wallet signs the hash.
POST /cycle/settle/execute->entitlementCid. Write 2; the lock opens in the same transaction.
Staked cycle, party hosted on the participant
POST /cycle/commit->stakeCid,lockedAmulet. Consumes the player's slot.- Run the game. No calls.
POST /cycle/:stakeCid/settlewithlockedHoldingCid-> returns the slot. Fails beforeminCycleSeconds.
Either way, a cycle that cannot be settled has two exits: POST /cycle/:stakeCid/abort
before settlement, and POST /cycle/:stakeCid/expire once the lock has expired, which
the player authorises alone.
Audit period
GET /report/period->rows,leaves,merkleRootfor cycles that closed in the period.POST /venue/:venueCid/anchorwith those rows ->anchorCid. Compare the returnedmerkleRootHexwith your own.- Publish the report. Anyone can rebuild the root from the ledger stream and check it against the anchor.
Tenant admin
Opens a tenant and returns its key once. The key is not stored and cannot be retrieved
again. quotaWindowSeconds and maxWrites are recorded, but the quota
actually enforced is the service-wide one in Enforced limits.
{ "tenantId": "yourgame", // [a-z0-9-], 3-32 chars, no leading/trailing or double dash
"tenantParty": "yourgame::1220ab...", // must already exist on the participant
"note": "optional" }
201 -> { "tenantId", "tenantParty", "quotaWindowSeconds", "maxWrites",
"suspended", "note", "createdAt", "secret" }
400 -> invalid tenantId, or tenantParty unknown to this participant
409 -> tenant already exists
Lists tenants as { "tenants": [...] }. Key hashes are never included in any response.
Returns { "tenantId", "secret" }. The previous key stops working on the same call - there is no grace period.
Blocks the tenant from acting; its key then answers 403. Players keep every asset they already own. Send "suspended": false to lift it.
{ "suspended": true, "reason": "under review" }
200 -> { "tenantId", "suspended" }
Grants minting inside the tenant's namespace, limited by a quota enforced in the contract.
{ "registryCid": "00d37a98...",
"mintQuota": 100, "quotaWindowSeconds": 3600 } // defaults shown
201 -> { "tenantId", "mintRightCid", "updateId" }
Creates the ecosystem asset registry. Once per deployment: a second call creates a second registry. Body { "registryId" } is optional. Returns { "registryId", "contractId", "updateId" }.
Venues & audit
A venue is where staked cycles run: it holds the policy every stake is checked against. Its contract id changes whenever entitlements are issued or its policy is updated.
Creates a venue and its slot roster. A tenant's live venue id starts with
yourgame/; a dry-run venue id with dryrun-yourgame-. Dry-run venues
cannot charge fees or pay out, and live venues always require custody proof - both
enforced in the contract. Costs no quota.
{ "venueId": "yourgame/arena", "mode": "ModeLive", // default ModeDryRun
"gameCodes": ["yourgame-runner-v1"],
"policy": { "minStakeAmount": "10.0", "maxStakeAmount": "100.0",
"minPlatformFee": "1.0", "maxPayoutAmount": "100.0",
"minLockSeconds": "14400", "maxLockSeconds": "691200",
"minCycleSeconds": "60", "maxCycleSeconds": "7200",
"cooldownSeconds": "30", "abortCooldownSeconds": "300",
"concurrencyLimit": "1" },
"shardCapacity": 64 } // instrumentId defaults to Canton Coin
201 -> { "venueId", "mode", "shardCapacity", "venueCid", "updateId" }
400 -> dry-run venueId without "dryrun-", policy outside bounds ({ "violations": [...] }),
or minLockSeconds below maxCycleSeconds
403 -> venueId outside your namespace
Omitted policy fields take defaults inside the bounds, among them
minLockSeconds 7200 and minCycleSeconds 60. With a tenant key,
minLockSeconds must also be at least maxCycleSeconds, so a lock never
expires while a cycle can still be settled. Bounds are listed in
Enforced limits.
Changes a venue's policy. Only arCCade can call it: a tenant setting its own fee floor would
set it to zero. The path takes the venue id, not the contract id. Send only the fields that
change, plus gameCodes to replace that list. Bounds apply to this route even with
the internal key, and minLockSeconds must be at least maxCycleSeconds,
so a lock never expires before the claim window closes.
{ "minPlatformFee": "1.0" }
200 -> { "venueId", "previousCid", "venueCid", "policy", "updateId", "note" }
404 -> venue not found
The change does not reach open cycles, which carry the policy they committed under, nor entitlements already issued, which carry theirs. A new fee applies to entitlements issued after the update.
Your venues and their current contract ids. No quota cost. Read venueCid here before any call that takes it.
200 -> { "venues": [ { "venueCid", "venueId", "mode", "gameCodes",
"sdkVersion", "concurrencyLimit", "rosterReady" } ] }
Rebuilds the audit rows for your venues from the ledger's transaction stream. A cycle belongs
to the period in which it closed: fromMicros <= settledAtMicros < toMicros.
Settlements, aborts and expiries all close a cycle. No quota cost; a long period takes a while.
200 -> { "fromMicros", "toMicros", "cycleCount", "merkleRoot",
"leaves": [ "<64-char sha256>", ... ],
"rows": [ { "cycleId", "player", "gameCode", "concurrencyIndex",
"entryDigest", "outcomeDigest",
"committedUnits", "feeUnits", "returnedUnits",
"forfeitedUnits", "payoutUnits", "disposition",
"committedAtMicros", "settledAtMicros", "custodyTag" } ] }
400 -> fromMicros and toMicros required, toMicros > fromMicros
Units are integer strings in 1e-10 of the instrument. disposition is a label:
returned-in-full, returned-with-forfeit, forfeited-in-full,
aborted, expired-unsettled.
Anchors a period on the ledger. Send the rows from GET /report/period unchanged.
The contract recomputes the Merkle root, the anchor digest, the cycle count and the unit
totals from the rows; it does not verify reportUri, reportDigest,
prevAnchorDigest or the transaction counts, which you declare.
{ "periodId": "2026-09-11",
"periodStartMicros": "1789084800000000", "periodEndMicros": "1789171200000000",
"rows": [ ... ],
"qualifyingTxCount": "42", "nonQualifyingTxCount": "0",
"reportUri": "https://yourgame.example/audit/2026-09-11.json",
"reportDigest": "<64-char sha256>", "prevAnchorDigest": "<previous anchorDigest>" }
201 -> { "anchored": true, "anchorCid", "merkleRootHex", "anchorDigest",
"reportDigest", "reportUri", "cycleCount", "updateId" }
200 -> { "anchored": false, "reason", "anchorCid", ... } // this period is already anchored
400 -> periodId missing, or an unknown disposition label
One anchor per venue and period: an existing one is returned instead of a second write.
A large anchor can outlast the request and still land, so after a timeout read
GET /venue/anchors before retrying.
Your venues' anchors, newest period first. No quota cost.
200 -> { "anchors": [ { "anchorCid", "venueId", "periodId",
"periodStartMicros", "periodEndMicros", "cycleCount",
"merkleRootHex", "anchorDigest", "prevAnchorDigest",
"reportUri", "reportDigest" } ] }
Players & assets
Confirms the key. Returns your tenant record without the key hash, plus namespace and the write quota as { "windowSeconds", "maxWrites" }. 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, "tenantId", "quotaRemaining", "quotaResetAt" }
403 -> isolation violation, with the offending id named
Parses one instrument id and reports its shape. Body { "instrumentId": { "admin", "id" } }.
200 -> { "ok": true, "tenantId": "yourgame",
"localId": "sword-of-dawn", "instanceId": "4a91c8f2",
"assetClass": "unique" }
Opens a player's asset account, after which the registry can mint to them without a signature per reward. The service submits this on the player's behalf, so the player party must be hosted on this participant.
{ "playerParty": "alice::1220ab...", "accountId": "alice-1" } // accountId optional
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 -> mint quota for this window exhausted (contract); minting above it needs arCCade's 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",
"allocateBeforeSeconds": 600, "settleBeforeSeconds": 1800 } // defaults shown
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. Every leg's instrument must be in
your namespace.
{ "tradeId": "trade-8814", "maker": "alice::...", "taker": "bob::...",
"tradeDigest": "<64-char sha256>",
"expiresInSeconds": 600, "settleInSeconds": 1800, // defaults shown
"legs": {
"offer": { "sender": "alice::...", "receiver": "bob::...",
"instrumentId": {...}, "amount": "1.0" },
"ask": { "sender": "bob::...", "receiver": "alice::...",
"instrumentId": {...}, "amount": "300.0" } } }
201 -> { "tradeId", "proposalCid", "updateId" }
400 -> a leg without instrumentId
403 -> a leg's instrument outside your namespace; "leg" names it
Body { "acceptor": "bob::..." }. Returns { "tradeCid", "updateId" }. 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": [ { "contractId", "instrumentId", "amount", "owner" } ] }
Cycle
Slots, the participant-submitted stake and settlement, the two exits, and the reads that find a cycle again. For a player who signs with their own key, the stake and the settlement go through Player-signed cycle instead; everything else here applies to both.
Issues player slots. The venue's concurrencyLimit decides how many slots each
player gets; a second commit on a consumed slot is rejected by the ledger, not by this
service. Issuing again to a player who already has slots at this venue is rejected.
{ "grants": [ { "player": "alice::...", "tier": "silver" } ] } // tier defaults to "default"
201 -> { "venueCid", "updateId",
"entitlements": [ { "contractId", "player", "concurrencyIndex" } ] }
400 -> grants missing, or a grant still sends a non-zero concurrencyIndex
The venue's contract id changes: use the returned venueCid from here on.
A player's slots at your venues, lowest index first. No quota cost.
200 -> { "player", "entitlements": [ { "contractId", "venueId", "mode",
"concurrencyIndex", "tier", "nextEligibleAt", "cyclesCompleted" } ] }
Write 1, submitted by the service for a player hosted on this participant. Fix
entryDigest before play so entry conditions cannot be retrofitted afterwards. On
a live venue the player's Canton Coin is locked and the stake committed in one transaction.
{ "entitlementCid": "002769ea...", "player": "alice::...",
"gameCode": "yourgame-runner-v1", "cycleId": "run-3f2a...",
"entryDigest": "<64-char sha256>",
"stakeAmount": "100.0", "feeAmount": "1.0", "lockSeconds": 14400 }
201 -> { "cycleId", "stakeCid", "custodyTag", "lockExpiresAt", "mode",
"lockedAmulet", "inputAmount", "updateId" } // lockedAmulet, inputAmount: live venues
Write 2. On a live venue send lockedHoldingCid: the contract checks the lock
against the stake and the unlock runs in the same transaction. Without it only a dry-run
venue settles. If you send revealedOutcome or revealedEntry, the
ledger recomputes the hash and rejects a mismatch.
{ "player": "alice::...", "disposition": "ReturnedInFull",
"returnedAmount": "100.0", "forfeitedAmount": "0.0", "payoutAmount": "0.0",
"outcomeDigest": "<64-char sha256>",
"revealedOutcome": "{\"score\":4200,\"result\":\"win\"}",
"lockedHoldingCid": "00a1f3..." }
200 -> { "settled": true, "entitlementCid", "unlockedAmulet", "updateId" }
The contract requires returnedAmount + forfeitedAmount to equal the stake, allows
no forfeit on a time-locked stake, caps payoutAmount at the policy's
maxPayoutAmount, and rejects settlement outside
minCycleSeconds..maxCycleSeconds of the commit.
Closes a cycle without counting it. There is no minimum duration - this is the way out of
a cycle that should not have started. The slot comes back after the policy's
abortCooldownSeconds. Send lockedHoldingCid to unlock in the same
transaction; otherwise the lock stays until it expires.
{ "player": "alice::...", "reason": "client crashed", "lockedHoldingCid": "00a1f3..." }
200 -> { "aborted": true, "reason", "entitlementCid", "unlockedAmulet", "updateId" }
Closes an unsettled cycle once lockExpiresAt has passed, authorised by the player
alone: neither arCCade nor the venue is needed on the ledger. With
lockedHoldingCid the funds return to the player in the same transaction.
{ "player": "alice::...", "lockedHoldingCid": "00a1f3..." }
200 -> { "expired": true, "entitlementCid", "unlockedAmulet", "updateId" }
A player's open cycles at your venues, each with the lock that belongs to it. No quota cost. This is where recovery starts: a stake listed here was committed and has not been closed.
200 -> { "player", "openCycles",
"cycles": [ { "stakeCid", "cycleId", "venueId", "gameCode", "custodyTag",
"lockedAmulet", "concurrencyIndex", "committedAt",
"stakeAmount", "lockExpiresAt" } ] }
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. Only settlements at your
venues are returned; anything else, including aborts and expiries, returns 404.
200 -> { "choice": "GameStake_Settle", "consuming", "disposition",
"returnedAmount", "forfeitedAmount", "payoutAmount",
"outcomeDigest", "revealedOutcome" }
Player-signed cycle
For a player who holds their own key. Each write takes two calls: a prepare that returns a hash for the wallet to sign, and an execute that submits the signature. The prepared transaction itself never leaves the service, so what the player signs is exactly what is submitted.
preparedTransactionHash, sign those bytes with Ed25519 using the
player party's key, and send the signature base64-encoded. Do not send signedBy:
the service derives the key fingerprint from the party id (the part after ::)
and rejects a body that names one.
import nacl from 'tweetnacl'
const sig = nacl.sign.detached(Buffer.from(preparedTransactionHash, 'base64'), secretKey)
const body = { requestId, signature: Buffer.from(sig).toString('base64') }
A prepared request is single-use and lives 5 minutes. It is consumed before submission, so an execute that fails needs a fresh prepare. The offer each prepare creates also expires on the ledger after 5 minutes.
Prepares write 1. The fee and the lock come from the slot's policy, not from the request:
the fee is minPlatformFee and the lock runs minLockSeconds plus 600
seconds. The player is an observer of the offer, so a wallet can read these terms from the
ledger instead of trusting this response.
{ "entitlementCid": "002769ea...", "player": "alice::...",
"gameCode": "yourgame-runner-v1", "cycleId": "run-3f2a...",
"entryDigest": "<64-char sha256>", "stakeAmount": "100.0" }
200 -> { "requestId", "preparedTransactionHash", "offerCid", "cycleId",
"stakeAmount", "feeAmount", "lockExpiresAt", "expiresInSeconds": 300 }
403 -> the entitlement belongs to another player, or to another tenant's venue
404 -> entitlement not found
Submits write 1 with the player's signature: the Canton Coin lock, the platform fee and the stake commit land in one transaction.
{ "requestId": "p9Wk...", "signature": "<base64 Ed25519>" }
201 -> { "cycleId", "stakeCid", "custodyTag", "lockExpiresAt", "mode",
"lockedAmulet", "updateId", "contractsResolved" }
400 -> requestId or signature missing, or signedBy present
404 -> prepared request unknown, expired, or prepared with another tenant's key: prepare again
201 with "contractsResolved": false means the ledger accepted the
write but the new contracts were not visible within 10 seconds. Do not
resubmit; find the stake with GET /cycle/open/:party.
updateId can be null when the ledger's completion was not seen in
time; the write itself is unaffected.
Prepares write 2. The body is the same as POST /cycle/:stakeCid/settle, and
lockedHoldingCid is required: without a lock there is nothing for the player to
sign, and the settlement belongs on the participant-submitted route.
{ "player": "alice::...", "disposition": "ReturnedInFull",
"returnedAmount": "100.0", "outcomeDigest": "<64-char sha256>",
"lockedHoldingCid": "00a1f3..." }
200 -> { "requestId", "preparedTransactionHash", "stakeCid",
"lockedHoldingCid", "cycleId", "offerCid", "expiresInSeconds": 300 }
Compare the echoed stakeCid and lockedHoldingCid with the ones you hold before asking the player to sign.
Submits write 2 with the player's signature: the settlement and the unlock land in one transaction, and the slot comes back.
{ "requestId": "Qz3r...", "signature": "<base64 Ed25519>" }
200 -> { "settled": true, "stakeCid", "cycleId", "entitlementCid",
"unlockedAmulet": null, "updateId", "contractsResolved" }
unlockedAmulet is always null: nothing on the returned coin ties it to
this cycle, so read the player's balance from their wallet. With
"contractsResolved": false the stake's archival was not confirmed in time. Check
GET /cycle/open/:party; if the settlement did not land nothing is lost, because
the player can expire the cycle once the lock runs out.
POST /cycle/commit/prepare, POST /cycle/commit/execute and
POST /cycle/:stakeCid/settle/prepare return 410 with the route to use
in use. Each put two commands and several authorising parties into one
interactive submission, which a Canton participant rejects
(FAILED_TO_PREPARE_TRANSACTION). The offer routes above replace them.
Errors
Every error returns { "error": "..." }, some with details carrying the
ledger's own response. Branch on the status code; the text is for people and may change.
| Code | Meaning | What to do |
|---|---|---|
| 400 | Malformed body, bad digest, unknown tenant party, signedBy in an execute body, policy outside bounds (violations lists each) | 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 body names the offending id. |
| 404 | Contract, tenant or venue not found; prepared request unknown or expired | Re-read state; a cached id is likely consumed. For a prepared request, prepare again. |
| 409 | Tenant exists, or no mint right yet | Resolve the prerequisite first. |
| 410 | Route closed. use names its replacement | Switch to that route. |
| 429 | Quota exhausted. Body carries remaining and resetAt | Wait until X-Quota-Reset. Backoff will not help before then. |
| 500 | Ledger rejected the command, an upstream call failed, or the ledger request timed out | Read details. A timeout may still have committed: read state before retrying. |
| 502 | GET /report/period could not read part of the range | Retry, or use transport=stream. |
| 503 | Registry not configured, or the report's page budget ran out | For the registry, operator action is needed. For the report, use transport=stream. |
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. |
| dongu asgari suresini doldurmadi | Settlement attempted before minCycleSeconds. |
| dongu azami sureyi asti | Settlement attempted after maxCycleSeconds. Abort it, or expire it once the lock runs out. |
| giris / sonuc belgesi saklanan digest ile uyusmuyor | The revealed entry or outcome document does not hash to the committed digest. |
| custody kaniti zorunlu ama holding referansi verilmedi | A live venue was settled without lockedHoldingCid. |
| oyuncu cooldown suresinde | The slot is cooling down after its last cycle; see nextEligibleAt. |
| kilit suresi henuz dolmadi | Expire attempted before lockExpiresAt. |
| teklifin suresi doldu | A stake offer was signed after its 5 minutes. Prepare again. |
| kapanis teklifinin suresi doldu | A settlement offer was signed after its 5 minutes. Prepare again. |
| oyuncuya bu venue'da zaten slot verilmis | This player already has slots at this venue. |
| basim kotasi doldu (...) | Mint quota for this window exhausted; minting above it needs arCCade's approval. |
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 or offer execute | GET /cycle/open/:party - the stake is listed if the write landed |
| cycle settle or settle execute | GET /cycle/open/:party - the stake is gone if the write landed; then GET /cycle/:updateId/outcome |
| anchor | GET /venue/anchors - a second call for the same period returns the existing anchor |
| a prepare | Nothing to check. Prepare again; the unused offer expires on its own. |
mintRightCid, entitlementCid or venueCid 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. Never a JSON number. |
| *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 when settling. Report rows use labels such as returned-in-full. |
| policy integers | string | Seconds and counts as integer strings, e.g. "14400". |
| *Micros | string | Integer microseconds since the Unix epoch. |
| requestId | string | Opaque, single-use, valid 5 minutes. |
| preparedTransactionHash | string | Base64. Decode before signing. |
| signature | string | Base64 Ed25519 signature over the decoded hash. |
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 per 60 s, per tenant |
| Venue policy bounds | API | table below |
| Mint quota | Contract | per mint right |
| Concurrent cycles | Contract | slot token |
| Lock & cycle duration | Contract | ledger time only |
| Lock outlasts the claim window | API, on policy update | minLock >= maxCycle |
| Offer validity | Contract | 300 s |
| Prepared request | API | 300 s, single use |
| Unique asset amount | API + contract | exactly 1 |
| Trade atomicity | Canton engine | all legs or none |
| Burn | Contract | owner + registry |
Venue policy bounds
Checked for tenant keys on POST /venue/init and for everyone on PUT /venue/:venueId/policy. A venue outside them needs arCCade.
| Field | Bound |
|---|---|
| maxStakeAmount | <= 1000 |
| minPlatformFee | <= 10 |
| maxPayoutAmount | <= 1000 |
| minLockSeconds | >= 300 |
| maxLockSeconds | <= 691200 (8 days) |
| minCycleSeconds | >= 60 |
| maxCycleSeconds | <= 604800 (7 days) |
| concurrencyLimit | <= 5 |
| cooldownSeconds | >= 0 |
| abortCooldownSeconds | >= 60 |
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.
npm install @arccade/game-sdk gives you the encoding, cycle ids and the cycle
command builders. Version 1.6.0 adds the venue policy document, the period anchor and
settlement invariants; it is in the
repository and its npm release is
pending.
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().
Version 1.6.0 also brings an independent verifier for audit anchors. Given the ledger's transaction trees, or your published rows, it rebuilds the root and checks it against the anchor, trusting nothing arCCade published; given one row and its proof, it shows that cycle is in the report.
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. |
| An accepted write can come back without contract ids | 201 or 200 with "contractsResolved": false. Do not resubmit; read GET /cycle/open/:party. |
| A venue's contract id changes | Issuing entitlements and updating the policy both replace the venue contract. Read GET /venues before using a venueCid. |
| Policy changes do not reach issued slots | A slot keeps the policy it was issued under, and each cycle keeps the policy it committed under. A new fee reaches players on slots issued after the change. |
| A timeout is not a failure | A large anchor or a slow commit can land after the request gives up. Read state before retrying. |
| 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. |