@idosgames/mcp 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idosgames/mcp",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "MCP server that serves the iDosGames Module & Skills Registry to AI coding agents (Claude Code, Codex, Cursor…): list/pull composable game modules and the host scaffold, and load skills for @idosgames/core, the module contract, and composition.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,7 +11,11 @@
11
11
  },
12
12
  {
13
13
  "path": "package.json",
14
- "content": "{\n \"name\": \"@idosgames/host-starter\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The seed project for the AI Coder: a host shell that composes feature modules. Fresh projects start here with zero modules; the developer/agent plugs modules into src/modules.ts.\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite build\",\n \"preview\": \"vite preview\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\"\n },\n \"//\": \"Versions are pinned exactly: this is a seed for AI Coder projects, which build offline against a dependency allowlist baked at these versions (see scripts/pack-builder.mjs). Modules bring their own engine deps (three/phaser) when added.\",\n \"dependencies\": {\n \"@idosgames/app-shell\": \"0.1.16\",\n \"@idosgames/core\": \"0.10.0\",\n \"@idosgames/module-sdk\": \"0.1.11\",\n \"@idosgames/react\": \"0.2.3\",\n \"@idosgames/wallet\": \"0.2.3\",\n \"@tanstack/react-query\": \"5.101.2\",\n \"react\": \"19.2.7\",\n \"react-dom\": \"19.2.7\",\n \"wagmi\": \"3.7.2\"\n },\n \"devDependencies\": {\n \"@types/react\": \"19.2.17\",\n \"@types/react-dom\": \"19.2.3\",\n \"@vitejs/plugin-react\": \"6.0.4\",\n \"typescript\": \"5.9.3\",\n \"vite\": \"8.1.5\"\n }\n}\n"
14
+ "content": "{\n \"name\": \"@idosgames/host-starter\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The seed project for the AI Coder: a host shell that composes feature modules. Fresh projects start here with zero modules; the developer/agent plugs modules into src/modules.ts.\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite build\",\n \"preview\": \"vite preview\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\"\n },\n \"//\": \"Versions are pinned exactly: this is a seed for AI Coder projects, which build offline against a dependency allowlist baked at these versions (see scripts/pack-builder.mjs). Modules bring their own engine deps (three/phaser) when added.\",\n \"dependencies\": {\n \"@idosgames/app-shell\": \"0.1.17\",\n \"@idosgames/core\": \"0.11.0\",\n \"@idosgames/module-sdk\": \"0.1.12\",\n \"@idosgames/react\": \"0.2.4\",\n \"@idosgames/wallet\": \"0.2.4\",\n \"@tanstack/react-query\": \"5.101.2\",\n \"react\": \"19.2.7\",\n \"react-dom\": \"19.2.7\",\n \"wagmi\": \"3.7.2\"\n },\n \"devDependencies\": {\n \"@types/react\": \"19.2.17\",\n \"@types/react-dom\": \"19.2.3\",\n \"@vitejs/plugin-react\": \"6.0.4\",\n \"typescript\": \"5.9.3\",\n \"vite\": \"8.1.5\"\n }\n}\n"
15
+ },
16
+ {
17
+ "path": "public/sw.js",
18
+ "content": "// Service worker for push notifications. This file is YOURS to edit — it ships as a starting\n// point, not as SDK code.\n//\n// ⚠⚠ THERE IS NO `fetch` HANDLER HERE, AND THERE MUST NEVER BE ONE.\n//\n// A caching service worker looks like a free win and is the single most expensive mistake you can\n// make on this platform. A cached `index.html` points at content-hashed chunks that the NEXT\n// deploy deletes; the player then gets a white screen served from INSIDE their own browser, where\n// neither a CDN purge nor Ctrl+F5 reaches it. The platform has already paid for that bug once, in\n// the publisher dashboard, and the fix was to stop caching HTML at all. A `fetch` handler here\n// would bring it back in a form that is much harder to undo. If you want offline support, do it\n// deliberately and never cache the document.\n//\n// ⚠ Registered RELATIVE to the page (`./sw.js`), so on a hosted build its scope is exactly\n// `/drive/app/{titleID}/`. That is what gives each game its own push subscription on the shared\n// `cloud.idosgames.com` origin. Do not add `Service-Worker-Allowed` to widen it — a wider scope\n// would let one game's worker answer for another's.\n\nself.addEventListener(\"install\", (event) => {\n // Take over immediately. Without this the new worker waits for every tab of this game to close,\n // and a player with the game open all day would keep the old one for days.\n event.waitUntil(self.skipWaiting());\n});\n\nself.addEventListener(\"activate\", (event) => {\n event.waitUntil(self.clients.claim());\n});\n\n/**\n * A notification arrived.\n *\n * ⚠ `showNotification` is NOT optional. The subscription was created with `userVisibleOnly: true`,\n * which is a promise to display every message; break it and the browser first shows its own\n * \"this site is sending background notifications\" warning, then revokes the permission.\n *\n * The payload is the small JSON the server encrypts per device: `{ title, body?, url?, icon? }`.\n * The text is already resolved into the device's language — do not translate it here.\n */\nself.addEventListener(\"push\", (event) => {\n let payload = {};\n try {\n payload = event.data ? event.data.json() : {};\n } catch {\n // Never let a malformed payload swallow the notification: the promise above still stands.\n }\n\n const title = payload.title || \"\";\n if (!title) return; // An empty notification is a blank grey box. Better to show nothing.\n\n event.waitUntil(\n self.registration.showNotification(title, {\n body: payload.body || undefined,\n icon: payload.icon || undefined,\n // Keeps a repeated notification from stacking into a pile the player has to dismiss one by\n // one. Give the server-side producer a stable key per kind of message.\n tag: payload.tag || undefined,\n data: { url: payload.url || \"./\" },\n }),\n );\n});\n\n/**\n * Where a click is allowed to take the player.\n *\n * The URL arrives IN THE PAYLOAD, and for game events the server takes it from the TITLE CONFIG.\n * Anything outside this worker's own scope is refused: a notification shown under your game's\n * name and icon must not be able to send the tab to an arbitrary site, which is exactly what a\n * phishing notification is for (\"your session expired, sign in again\").\n *\n * A refused URL does not cancel the click — the player pressed it, and opening the game is more\n * honest than doing nothing, which reads as a broken notification.\n */\nfunction safeTarget(raw) {\n const scope = self.registration.scope;\n\n try {\n const url = new URL(raw || scope, scope);\n return url.href.startsWith(scope) ? url.href : scope;\n } catch {\n return scope;\n }\n}\n\n/**\n * The player tapped the notification.\n *\n * Focuses an already-open tab of this game instead of opening a second one — two copies of the\n * same game in two tabs is its own kind of bug.\n */\nself.addEventListener(\"notificationclick\", (event) => {\n event.notification.close();\n\n const target = safeTarget(\n event.notification.data && event.notification.data.url,\n );\n\n event.waitUntil(\n self.clients\n .matchAll({ type: \"window\", includeUncontrolled: true })\n .then((clients) => {\n for (const client of clients) {\n if (client.url === target && \"focus\" in client) return client.focus();\n }\n return self.clients.openWindow\n ? self.clients.openWindow(target)\n : undefined;\n }),\n );\n});\n"
15
19
  },
16
20
  {
17
21
  "path": "src/config.ts",
@@ -1,15 +1,15 @@
1
1
  {
2
- "generatedFromCommit": "cf76ac1511b491d8ad9852d9b74202b17fc55d36",
2
+ "generatedFromCommit": "9f5e03273160b45b33ffed88fb2ed85b9ea74893",
3
3
  "runtimePackages": {
4
- "@idosgames/core": "0.10.0",
5
- "@idosgames/wallet": "0.2.3",
6
- "@idosgames/module-sdk": "0.1.11",
7
- "@idosgames/react": "0.2.3",
8
- "@idosgames/app-shell": "0.1.16"
4
+ "@idosgames/core": "0.11.0",
5
+ "@idosgames/wallet": "0.2.4",
6
+ "@idosgames/module-sdk": "0.1.12",
7
+ "@idosgames/react": "0.2.4",
8
+ "@idosgames/app-shell": "0.1.17"
9
9
  },
10
10
  "host": {
11
11
  "id": "host-starter",
12
- "fileCount": 15
12
+ "fileCount": 16
13
13
  },
14
14
  "modules": [
15
15
  {
@@ -46,10 +46,10 @@
46
46
  },
47
47
  "version": "0.1.0",
48
48
  "dependencies": {
49
- "@idosgames/core": "0.10.0",
50
- "@idosgames/module-sdk": "0.1.11",
51
- "@idosgames/react": "0.2.3",
52
- "@idosgames/wallet": "0.2.3",
49
+ "@idosgames/core": "0.11.0",
50
+ "@idosgames/module-sdk": "0.1.12",
51
+ "@idosgames/react": "0.2.4",
52
+ "@idosgames/wallet": "0.2.4",
53
53
  "@tanstack/react-query": "5.101.2",
54
54
  "react": "19.2.7",
55
55
  "three": "0.185.1",
@@ -92,10 +92,10 @@
92
92
  },
93
93
  "version": "0.1.0",
94
94
  "dependencies": {
95
- "@idosgames/core": "0.10.0",
96
- "@idosgames/module-sdk": "0.1.11",
97
- "@idosgames/react": "0.2.3",
98
- "@idosgames/wallet": "0.2.3",
95
+ "@idosgames/core": "0.11.0",
96
+ "@idosgames/module-sdk": "0.1.12",
97
+ "@idosgames/react": "0.2.4",
98
+ "@idosgames/wallet": "0.2.4",
99
99
  "@tanstack/react-query": "5.101.2",
100
100
  "phaser": "4.2.1",
101
101
  "react": "19.2.7",
@@ -138,7 +138,7 @@
138
138
  },
139
139
  "version": "0.1.0",
140
140
  "dependencies": {
141
- "@idosgames/module-sdk": "0.1.11",
141
+ "@idosgames/module-sdk": "0.1.12",
142
142
  "three": "0.185.1"
143
143
  },
144
144
  "fileCount": 38
@@ -161,6 +161,10 @@
161
161
  "name": "character-system",
162
162
  "description": "Build a character / hero system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.character (CharacterService): load the hero roster and title definitions, unlock or purchase characters, upgrade character levels/ranks and per-character stats, equip and unequip gear into slots, and read the server-authoritative Power score. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants character screens, hero rosters, stat/level/rank upgrade UIs, equipment or loadout systems, or otherwise touches client.character, CharacterService, CharacterModel, CharacterDefinitions, StatLevels, or character Power — even if they don't name the module explicitly."
163
163
  },
164
+ {
165
+ "name": "chat-system",
166
+ "description": "Build a player chat in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.chat (ChatService): list the title's channels, poll every room for new messages with one call, send to a channel, open and use 1-on-1 direct conversations, page back through history, mark a room read, join or leave opt-in channels, report a message, and keep a personal ignore list. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a world chat, global chat, server chat, guild or clan chat, private messages, whispers, DMs, an in-game messenger, a chat window or chat bubbles, unread badges for conversations, muting or blocking another player, reporting abuse, or otherwise touches client.chat, ChatService, ChatDefinitions, ChatChannelView, ChatMessageView, ChatCursor, or ChatPollResponse — even if they don't name the module explicitly."
167
+ },
164
168
  {
165
169
  "name": "checkout-system",
166
170
  "description": "Take payment for anything priced in a game on the iDosGames TypeScript SDK: price options (PriceOptions), real-money purchases through Google Play / App Store (client.purchase, PurchaseService), paying with an on-chain currency from the player's wallet (@idosgames/wallet payWithWalletEvm), and choosing what to show per platform (client.checkout, CheckoutService, usePayment). Use this whenever the user works in the iDosGames TS SDK and touches prices, IAP, receipts, store products, \"pay with crypto\", platform gating of payment methods, PriceOption, PaymentProof, SelectedOptionID, or asks why an option is missing or a purchase is refused — even if they don't name the module."
@@ -173,6 +177,10 @@
173
177
  "name": "collection-system",
174
178
  "description": "Build a collection / sticker-album / TCG system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.collection (CollectionService): open collectible packs and pity-driven collection chests, spend \"joker\" wildcards to fill a specific slot, claim set-completion rewards (single + batch) and the collection Grand Prize, and run peer-to-peer collectible trading (send/cancel/accept/decline trade offers, list my/incoming offers). Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a sticker album, TCG-style collection/set-completion screen, pack-opening UI, duplicate/pity systems, or player-to-player item trading — or otherwise touches client.collection, CollectionService, CollectionDefinitions, UserCollectionState, or trade offers — even if they don't name the module explicitly."
175
179
  },
180
+ {
181
+ "name": "community-marketing-system",
182
+ "description": "Add a creator reward programme (\"community marketing\") to a game on the iDosGames TypeScript SDK (@idosgames/core) via client.communityMarketing (CommunityMarketingService): show whether the title runs a programme, send the player to the creator portal, read what they earned for posting videos / clips / screenshots to YouTube, TikTok or Instagram, show the per-work breakdown of that number, and claim approved rewards in game currency or crypto. Use this whenever the user is working in the iDosGames TS SDK or its game templates and wants creator rewards, an ambassador or influencer programme, \"get paid for posting about the game\", UGC bounties, view-based payouts, a creator dashboard inside the game, or otherwise touches client.communityMarketing, CommunityMarketingService, CommunityMarketingState, CommunityEarningsResponse or CommunityClaimResponse — even if they don't name the module explicitly."
183
+ },
176
184
  {
177
185
  "name": "coop-event-system",
178
186
  "description": "Build a cooperative / group event system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.coopEvent (CoopEventService): load coop event chain definitions, look up which event in a chain is currently active, load the player's own coop-event state, join or create a matchmade group, spin/contribute toward the group's shared BuildObjects goal, claim a per-member object reward or the group's grand prize, and leave a group. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a co-op event, group event, team event, alliance-style shared-goal feature, spin-to-contribute mechanic, or otherwise touches client.coopEvent, CoopEventService, CoopEventDefinitions, CoopGroupDocument, UserCoopEventState, or CoopSpinResponse — even if they don't name the module explicitly."
@@ -253,6 +261,10 @@
253
261
  "name": "purchase-system",
254
262
  "description": "Sell real-money in-app purchases (IAP) in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.purchase (PurchaseService): load the store product catalog with per-player availability, send a store receipt to the backend for verification, grant the product, restore purchases after a reinstall, and read the player's purchase state (ownership, counters, lifetime spend). Covers Apple App Store and Google Play receipts, consumables / non-consumables / subscriptions, and what happens when the store refunds a purchase. Use whenever the user wants real-money packs, \"remove ads\", a VIP subscription, a restore-purchases button, receipt validation, or touches client.purchase, PurchaseService, IapStore, ValidatePurchase, or IapProductDefinition — even if they don't name the module explicitly."
255
263
  },
264
+ {
265
+ "name": "push-notifications",
266
+ "description": "Add push notifications to a game on the iDosGames TypeScript SDK (@idosgames/core) via client.push (PushService): ask the player for permission from a tap, register the browser's Web Push subscription with the server, draw the right button state, list and remove the player's devices, and ship the service worker that displays a notification. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg, voxelcraft) and wants push notifications, browser notifications, web push, a \"notify me\" or \"enable notifications\" toggle, a re-engagement or comeback reminder, an energy-refilled or build-finished alert, a service worker, sw.js, VAPID, PushManager, Notification.permission, or otherwise touches client.push, PushService, PushConfigResponse, PushSubscriptionView or PushPermissionState — even if they don't name the module explicitly."
267
+ },
256
268
  {
257
269
  "name": "quest-system",
258
270
  "description": "Build a quest / daily-task system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.quest (QuestService): load quest and cycle definitions, load the player's quest progress state, add progress toward a metric, claim a completed quest's reward, claim a points-track milestone reward, claim a group-completion (grand) reward, and refresh cycles (dailies/ weeklies) forward. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants daily/weekly quest screens, task lists, objective/progress trackers, battle-pass-style points tracks, milestone reward ladders, quest-group completion bonuses, or otherwise touches client.quest, QuestService, QuestDefinitions, UserQuestState, QuestPointsTrackView, or MilestoneDefinition — even if they don't name the module explicitly."
@@ -275,7 +287,7 @@
275
287
  },
276
288
  {
277
289
  "name": "store-system",
278
- "description": "Build a store / shop system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.store (StoreService): load storefront and offer (SKU) definitions, load the player's purchase counters, and purchase one or many offers (currency/item packs, bundles, cosmetics) with virtual/item cost. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a shop/store screen, IAP-style SKU catalogs, purchase-limit UI (daily/total caps), or otherwise touches client.store, StoreService, StoreDefinitions, StoreOfferDefinition, or offer purchasing — even if they don't name the module explicitly."
290
+ "description": "Build a store / shop screen in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.store (StoreService): load the RESOLVED storefront for the current player (rotating daily shops, sections, slots, badges, refresh timers, remaining limits), load the offer catalogue, and purchase one or many offers. Prices can be virtual currency, crypto, a real money store product, a rewarded-video ad credit, or free. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a shop screen, a daily rotating shop, gem packs, free/ad-paid slots, first-purchase badges, purchase-limit UI, or otherwise touches client.store, StoreService, GetStorefrontResponse, StoreDefinitions or offer purchasing — even if they don't name the module."
279
291
  },
280
292
  {
281
293
  "name": "timed-boost-system",
@@ -37,10 +37,10 @@
37
37
  "version": "0.1.0"
38
38
  },
39
39
  "dependencies": {
40
- "@idosgames/core": "0.10.0",
41
- "@idosgames/module-sdk": "0.1.11",
42
- "@idosgames/react": "0.2.3",
43
- "@idosgames/wallet": "0.2.3",
40
+ "@idosgames/core": "0.11.0",
41
+ "@idosgames/module-sdk": "0.1.12",
42
+ "@idosgames/react": "0.2.4",
43
+ "@idosgames/wallet": "0.2.4",
44
44
  "@tanstack/react-query": "5.101.2",
45
45
  "react": "19.2.7",
46
46
  "three": "0.185.1",
@@ -37,10 +37,10 @@
37
37
  "version": "0.1.0"
38
38
  },
39
39
  "dependencies": {
40
- "@idosgames/core": "0.10.0",
41
- "@idosgames/module-sdk": "0.1.11",
42
- "@idosgames/react": "0.2.3",
43
- "@idosgames/wallet": "0.2.3",
40
+ "@idosgames/core": "0.11.0",
41
+ "@idosgames/module-sdk": "0.1.12",
42
+ "@idosgames/react": "0.2.4",
43
+ "@idosgames/wallet": "0.2.4",
44
44
  "@tanstack/react-query": "5.101.2",
45
45
  "phaser": "4.2.1",
46
46
  "react": "19.2.7",
@@ -37,7 +37,7 @@
37
37
  "version": "0.1.0"
38
38
  },
39
39
  "dependencies": {
40
- "@idosgames/module-sdk": "0.1.11",
40
+ "@idosgames/module-sdk": "0.1.12",
41
41
  "three": "0.185.1"
42
42
  },
43
43
  "files": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acquisition-attribution",
3
3
  "description": "Understand how a game built on the iDosGames TypeScript SDK (@idosgames/core) knows where a player came from, and how playtime reaches the publisher's analytics. Covers automatic capture of utm_* / ad click ids / ?ref= invite codes / idos_click tokens / Telegram start_param at launch, delivery with the login request, the deferred install match, the universal idosgames.com/go/{titleID} link, and the playtime tracker behind DAU/MAU and retention. Use this whenever the user asks about attribution, UTM tags, ad campaigns, install tracking, \"where did this player come from\", invite links, deep links carrying a referral code, session counting, playtime, DAU or MAU in the iDosGames SDK — and BEFORE writing any code that reads the URL or localStorage for campaign or referral parameters.",
4
- "content": "---\nname: acquisition-attribution\ndescription: >-\n Understand how a game built on the iDosGames TypeScript SDK (@idosgames/core)\n knows where a player came from, and how playtime reaches the publisher's\n analytics. Covers automatic capture of utm_* / ad click ids / ?ref= invite\n codes / idos_click tokens / Telegram start_param at launch, delivery with\n the login request, the deferred install match, the universal\n idosgames.com/go/{titleID} link, and the playtime tracker behind DAU/MAU and\n retention. Use this whenever the user asks about attribution, UTM tags, ad\n campaigns, install tracking, \"where did this player come from\", invite\n links, deep links carrying a referral code, session counting, playtime, DAU\n or MAU in the iDosGames SDK — and BEFORE writing any code that reads the URL\n or localStorage for campaign or referral parameters.\n---\n\n# Acquisition and attribution (iDosGames TS SDK)\n\nTwo things run by themselves in every client created with\n`createIDosGamesClient`, and **the correct amount of code you write for either\nis zero**:\n\n1. **`AcquisitionCapture`** reads the launch URL (and Telegram launch\n parameters) the moment the client is created, keeps what it found, and\n attaches it to whichever sign-in the player eventually uses.\n2. **`PlaytimeTracker`** counts how long the player actually plays and reports\n it, starting at login.\n\nThis skill exists mostly so you do **not** re-implement either one. If you find\nyourself writing `new URLSearchParams(location.search).get(\"utm_source\")` or a\n`setInterval` that posts playtime, stop: the SDK already did it, and a second\nimplementation competes with the first.\n\n## Why it matters\n\nEvery acquisition number a publisher sees — which campaign brought which\nplayer, retention split by source, invite conversion, K-factor — is derived\nfrom a signal the client sends **once**, with the login. And every engagement\nnumber — DAU, WAU, MAU, stickiness, average session, the retention cohorts on\ntop of them — is derived from what the playtime tracker posts. Neither has a\nfallback: nothing else on the platform writes those records.\n\n## What gets captured\n\nAt client construction, from the launch URL and the platform adapter:\n\n| Source | Lands in |\n| ---------------------------------------------------- | ----------------------------------------------------------- |\n| `utm_source/medium/campaign/term/content` | the matching `Utm*` fields |\n| `gclid`, `fbclid`, `ttclid`, `msclkid`, `yclid` | `ClickID` (whichever appears; ad networks never mix theirs) |\n| `?ref=` / `?r=` (bare code, or with a `ref_` prefix) | `ReferralCode`, `ChannelHint: \"query\"` |\n| `idos_click` | `ClaimToken` — an exact click receipt we issued |\n| Telegram `start_param` | `ReferralCode`, `ChannelHint: \"telegram\"` |\n| `document.referrer` | `Referrer` |\n\nPlus, on **every** login regardless of tags: `OsVersion`, and `AppVersion` if\nyou set one.\n\n`AppVersion` is the only piece the SDK cannot find on its own — the web has no\n`Application.version` — so pass it when you create the client:\n\n```ts\nconst client = createIDosGamesClient({\n titleID: \"MYTITLE\",\n appVersion: \"1.4.2\",\n});\n```\n\nLeave it out and the player is attributed without a build number, which makes\n\"did the 1.5 release change retention?\" unanswerable for that title.\n\n⚠ **Those device facts are not decoration and must not be stripped as\n\"empty signal\".** They are how the server matches an install back to a click\nthat happened in a browser before the app existed (see Deferred match). The\nserver deliberately does not treat them as a signal on their own — that is what\nlets the match run at all.\n\nThe captured signal is **persisted with a 30-day TTL**, because the login often\nhappens much later than the launch: after a redirect to a sign-in screen, after\nan e-mail confirmation, after a reload. Holding it in memory would lose it for\nexactly the players who arrived through a campaign or an invite. It is cleared\nafter a successful login — the next sign-in by the same person must not be\nre-attributed to a month-old campaign.\n\n## The methods you may actually call\n\n```ts\nimport { AcquisitionCapture } from \"@idosgames/core\";\n```\n\n| Call | When you need it |\n| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |\n| `AcquisitionCapture.parseReferralCode(input)` | A player pasted something into an \"enter a code\" box. Accepts a bare code, a code with separators, or the whole invite link. |\n| `AcquisitionCapture.detectDevicePlatform(ua)` | Only if you are building a landing page that registers clicks. See the warning below. |\n\nEverything else on the instance (`capture()`, `buildForLogin()`, `clear()`) is\ndriven by the client. Calling them yourself does not add data; `clear()` in\nparticular throws away a signal that has not been delivered.\n\n`client.referral.activateReferralCode` already runs `parseReferralCode` on its\ninput, so for the ordinary \"enter a friend's code\" field you do not even need\nthat — pass the raw text straight through.\n\n## Playtime\n\n`PlaytimeTracker` starts on `auth:loggedIn` and stops on `auth:loggedOut`.\nNothing to wire.\n\n- Flushes every **200 s**; a gap of **300 s** with the tab hidden ends the\n session. Both numbers are copied from the Unity SDK on purpose: different\n constants would make the same behaviour produce different session counts per\n platform, and a publisher comparing web against mobile would be comparing two\n different definitions of \"a session\".\n- Time with the tab in the background is **not** counted.\n- The buffer survives a reload, and a failed flush is put back rather than\n dropped — under-reported playtime is invisible downstream, so it is never\n discarded silently.\n\n## Deferred match — how a store install finds its click\n\nThere is no way to carry a parameter through an app store. So:\n\n1. The universal link `idosgames.com/go/{titleID}?ref=CODE` registers the click\n with the backend and gets a `ClaimToken`.\n2. Where we control the destination (the web build), the token travels in the\n URL and the match is **exact**.\n3. Where we do not (Google Play, App Store), the server matches the first\n launch to the click by a **fingerprint**: network, platform family, major OS\n version, country, plus a daily salt.\n\n⚠ **A fingerprint match is a guess and is reported as one.** Behind a mobile\ncarrier's NAT hundreds of people share it. Such touches are marked\n`Trust: \"Inferred\"` and shown on their own row in the publisher's report — do\nnot build UI that presents them as certain.\n\n⚠ **If you build a page that registers clicks, report the platform of the\nDEVICE (`\"Android\"` / `\"iOS\"`), never `\"Web\"`.** The fingerprint is compared\nbetween two different programs — your page in a browser and the installed game\n— and the game reports its own platform. Send `\"Web\"` from a phone and the keys\nnever line up, which turns the whole deferred match into dead code in exactly\nits main case. `AcquisitionCapture.detectDevicePlatform(navigator.userAgent)`\nreturns the right string.\n\n## Gotchas\n\n- **Do not read the URL yourself for campaign or referral parameters.** The SDK\n captured them at construction and the launch URL is frequently gone by the\n time your screen mounts. Read `client.data.user.state?.Referral` for the\n outcome instead.\n- **Do not build invite links from a template.** The server returns a\n ready-made one (`UserReferralStateResponse.InviteUrl`); a client-assembled\n link is an open redirect, and every title would word it differently. See the\n `referral-system` skill.\n- **The link always carries the title** (`/go/{titleID}?ref=...`) because a\n referral code is unique only inside a title. A link shaped like `/i/{code}`\n cannot exist.\n- **`/go/{titleID}` is deliberately not `/play/...`** — the platform publishes\n applications as well as games.\n- **A game embedded in an iframe does not inherit the page's query string.**\n If you host a build inside your own page, forward `ref` and `idos_click` into\n the iframe `src` yourself, or arrivals through an invite will look like\n ordinary launches — silently.\n- **Telegram `start_param` is the one channel the server actually trusts** (it\n arrives inside data signed by the publisher's bot). The server reads its own\n copy; a code you place in the request body is ignored on a Telegram sign-in,\n so do not try to override it.\n- **Nothing here works before login.** The signal rides on the sign-in request;\n a title that never signs a player in reports nothing, and its DAU stays zero.\n",
4
+ "content": "---\nname: acquisition-attribution\ndescription: >-\n Understand how a game built on the iDosGames TypeScript SDK (@idosgames/core)\n knows where a player came from, and how playtime reaches the publisher's\n analytics. Covers automatic capture of utm_* / ad click ids / ?ref= invite\n codes / idos_click tokens / Telegram start_param at launch, delivery with\n the login request, the deferred install match, the universal\n idosgames.com/go/{titleID} link, and the playtime tracker behind DAU/MAU and\n retention. Use this whenever the user asks about attribution, UTM tags, ad\n campaigns, install tracking, \"where did this player come from\", invite\n links, deep links carrying a referral code, session counting, playtime, DAU\n or MAU in the iDosGames SDK — and BEFORE writing any code that reads the URL\n or localStorage for campaign or referral parameters.\n---\n\n# Acquisition and attribution (iDosGames TS SDK)\n\nTwo things run by themselves in every client created with\n`createIDosGamesClient`, and **the correct amount of code you write for either\nis zero**:\n\n1. **`AcquisitionCapture`** reads the launch URL (and Telegram launch\n parameters) the moment the client is created, keeps what it found, and\n attaches it to whichever sign-in the player eventually uses.\n2. **`PlaytimeTracker`** counts how long the player actually plays and reports\n it, starting at login.\n\nThis skill exists mostly so you do **not** re-implement either one. If you find\nyourself writing `new URLSearchParams(location.search).get(\"utm_source\")` or a\n`setInterval` that posts playtime, stop: the SDK already did it, and a second\nimplementation competes with the first.\n\n## Why it matters\n\nEvery acquisition number a publisher sees — which campaign brought which\nplayer, retention split by source, invite conversion, K-factor — is derived\nfrom a signal the client sends **once**, with the login. And every engagement\nnumber — DAU, WAU, MAU, stickiness, average session, the retention cohorts on\ntop of them — is derived from what the playtime tracker posts. Neither has a\nfallback: nothing else on the platform writes those records.\n\n## What gets captured\n\nAt client construction, from the launch URL and the platform adapter:\n\n| Source | Lands in |\n| ---------------------------------------------------- | ----------------------------------------------------------- |\n| `utm_source/medium/campaign/term/content` | the matching `Utm*` fields |\n| `gclid`, `fbclid`, `ttclid`, `msclkid`, `yclid` | `ClickID` (whichever appears; ad networks never mix theirs) |\n| `?ref=` / `?r=` (bare code, or with a `ref_` prefix) | `ReferralCode`, `ChannelHint: \"query\"` |\n| `idos_click` | `ClaimToken` — an exact click receipt we issued |\n| Telegram `start_param` | `ReferralCode`, `ChannelHint: \"telegram\"` |\n| Telegram `start_param` beginning `a1_` | a whole packed mark set — see below |\n| `document.referrer` | `Referrer` |\n\nPlus, on **every** login regardless of tags: `OsVersion`, and `AppVersion` if\nyou set one.\n\n`AppVersion` is the only piece the SDK cannot find on its own — the web has no\n`Application.version` — so pass it when you create the client:\n\n```ts\nconst client = createIDosGamesClient({\n titleID: \"MYTITLE\",\n appVersion: \"1.4.2\",\n});\n```\n\nLeave it out and the player is attributed without a build number, which makes\n\"did the 1.5 release change retention?\" unanswerable for that title.\n\n⚠ **Those device facts are not decoration and must not be stripped as\n\"empty signal\".** They are how the server matches an install back to a click\nthat happened in a browser before the app existed (see Deferred match). The\nserver deliberately does not treat them as a signal on their own — that is what\nlets the match run at all.\n\nThe captured signal is **persisted with a 30-day TTL**, because the login often\nhappens much later than the launch: after a redirect to a sign-in screen, after\nan e-mail confirmation, after a reload. Holding it in memory would lose it for\nexactly the players who arrived through a campaign or an invite. It is cleared\nafter a successful login — the next sign-in by the same person must not be\nre-attributed to a month-old campaign.\n\n## The methods you may actually call\n\n```ts\nimport { AcquisitionCapture } from \"@idosgames/core\";\n```\n\n| Call | When you need it |\n| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |\n| `AcquisitionCapture.parseReferralCode(input)` | A player pasted something into an \"enter a code\" box. Accepts a bare code, a code with separators, or the whole invite link. |\n| `AcquisitionCapture.detectDevicePlatform(ua)` | Only if you are building a landing page that registers clicks. See the warning below. |\n\nEverything else on the instance (`capture()`, `buildForLogin()`, `clear()`) is\ndriven by the client. Calling them yourself does not add data; `clear()` in\nparticular throws away a signal that has not been delivered.\n\n`client.referral.activateReferralCode` already runs `parseReferralCode` on its\ninput, so for the ordinary \"enter a friend's code\" field you do not even need\nthat — pass the raw text straight through.\n\n## Playtime\n\n`PlaytimeTracker` starts on `auth:loggedIn` and stops on `auth:loggedOut`.\nNothing to wire.\n\n- Flushes every **200 s**; a gap of **1800 s** with the tab hidden closes the\n session locally. Both numbers match the Unity SDK on purpose.\n- **The session count is the server's decision, not this tracker's.** The\n backend opens a new session when the player's last recorded activity is older\n than `Usage.SessionIdleTimeoutMinutes` (default **30**, the same rule\n Firebase/GA4 use). The `IsNewSession` flag still travels on the wire and is\n ignored for counting — trusting it under-reported real players (a client\n resuming on a cached token never raised it, so a day of play could land with\n zero sessions and drop the player out of DAU and retention entirely), and the\n per-platform thresholds made the same behaviour count differently on web and\n mobile. The local threshold above still matters for\n `SessionDurationSeconds`: measuring a session's length by one rule while\n counting sessions by another would describe two different events.\n- Time with the tab in the background is **not** counted.\n- The buffer survives a reload, and a failed flush is put back rather than\n dropped — under-reported playtime is invisible downstream, so it is never\n discarded silently.\n\n## Deferred match — how a store install finds its click\n\nThere is no way to carry a parameter through an app store. So:\n\n1. The universal link `idosgames.com/go/{titleID}?ref=CODE` registers the click\n with the backend and gets a `ClaimToken`.\n2. Where we control the destination (the web build), the token travels in the\n URL and the match is **exact**.\n3. Where we do not (Google Play, App Store), the server matches the first\n launch to the click by a **fingerprint**: network, platform family, major OS\n version, country, plus a daily salt.\n\n⚠ **A fingerprint match is a guess and is reported as one.** Behind a mobile\ncarrier's NAT hundreds of people share it. Such touches are marked\n`Trust: \"Inferred\"` and shown on their own row in the publisher's report — do\nnot build UI that presents them as certain.\n\n⚠ **If you build a page that registers clicks, report the platform of the\nDEVICE (`\"Android\"` / `\"iOS\"`), never `\"Web\"`.** The fingerprint is compared\nbetween two different programs — your page in a browser and the installed game\n— and the game reports its own platform. Send `\"Web\"` from a phone and the keys\nnever line up, which turns the whole deferred match into dead code in exactly\nits main case. `AcquisitionCapture.detectDevicePlatform(navigator.userAgent)`\nreturns the right string.\n\n## Gotchas\n\n- **Do not read the URL yourself for campaign or referral parameters.** The SDK\n captured them at construction and the launch URL is frequently gone by the\n time your screen mounts. Read `client.data.user.state?.Referral` for the\n outcome instead.\n- **Do not build invite links from a template.** The server returns a\n ready-made one (`UserReferralStateResponse.InviteUrl`); a client-assembled\n link is an open redirect, and every title would word it differently. See the\n `referral-system` skill.\n- **The link always carries the title** (`/go/{titleID}?ref=...`) because a\n referral code is unique only inside a title. A link shaped like `/i/{code}`\n cannot exist.\n- **`/go/{titleID}` is deliberately not `/play/...`** — the platform publishes\n applications as well as games.\n- **A game embedded in an iframe does not inherit the page's query string.**\n If you host a build inside your own page, forward `ref` and `idos_click` into\n the iframe `src` yourself, or arrivals through an invite will look like\n ordinary launches — silently.\n- **Telegram `start_param` is the one channel the server actually trusts** (it\n arrives inside data signed by the publisher's bot). The server reads its own\n copy; a code you place in the request body is ignored on a Telegram sign-in,\n so do not try to override it.\n- **A `start_param` may carry a whole mark set, not just a code.** Telegram's\n `startapp` accepts only `A-Z a-z 0-9 _ -`, so `key=value&...` cannot travel\n there; the invite page base64url-encodes it behind an `a1_` prefix and the\n SDK unpacks it for you. Two things follow. Never show a raw `start_param` to\n the player as their invite code — decode it first, or a blob appears on\n screen. And if you generate Telegram links yourself, either use the same\n format or send a bare code; an invented one is read as a code verbatim.\n The prefix is versioned on purpose: a future `a2_` will ship alongside `a1_`,\n never in place of it, so links already sent out keep working.\n- **Android carries its marks through Google Play, not through us.** The invite\n page appends `&referrer=` to the store link; Play hands that string to the\n app on first launch. Build your own store links the same way, or installs\n from them fall back to a fingerprint guess that breaks whenever the player\n switches network between tapping and launching.\n- **Nothing here works before login.** The signal rides on the sign-in request;\n a title that never signs a player in reports nothing, and its DAU stays zero.\n",
5
5
  "references": []
6
6
  }
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "blockchain-system",
3
3
  "description": "Bridge in-game assets on-chain in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.blockchain (BlockchainService): load blockchain network/config definitions, load the player's on-chain state (linked wallets, pending withdrawals, KYC, stats), deposit a token or NFT from a wallet into the game, request a token or NFT withdrawal out to a wallet, read on-chain transaction history, retry a still-pending withdrawal's signature, confirm a withdrawal's on-chain tx hash, and donate crypto to the developer or a users' pool. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants crypto wallets, NFT deposits/withdrawals, token bridging, on-chain asset transfers, KYC status, or otherwise touches client.blockchain, BlockchainService, BlockchainDefinitions, UserBlockchainState, DepositTokenResponse, TokenWithdrawalResponse, or NFTWithdrawalResponse — even if they don't name the module explicitly.",
4
- "content": "---\nname: blockchain-system\ndescription: >-\n Bridge in-game assets on-chain in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.blockchain (BlockchainService): load blockchain\n network/config definitions, load the player's on-chain state (linked\n wallets, pending withdrawals, KYC, stats), deposit a token or NFT from a\n wallet into the game, request a token or NFT withdrawal out to a wallet,\n read on-chain transaction history, retry a still-pending withdrawal's\n signature, confirm a withdrawal's on-chain tx hash, and donate crypto to\n the developer or a users' pool. Use this whenever the user is working in\n the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants crypto wallets, NFT\n deposits/withdrawals, token bridging, on-chain asset transfers, KYC status,\n or otherwise touches client.blockchain, BlockchainService,\n BlockchainDefinitions, UserBlockchainState, DepositTokenResponse,\n TokenWithdrawalResponse, or NFTWithdrawalResponse — even if they don't name\n the module explicitly.\n---\n\n# Blockchain system (iDosGames TS SDK)\n\nThe Blockchain module bridges in-game assets to and from real wallets on\nsupported chains (EVM and Solana networks). A player can deposit a token or\nNFT they already sent on-chain (crediting their in-game balance/inventory),\nor request a withdrawal that pays an in-game token/NFT out to their wallet\n(debiting their in-game balance/inventory and producing a signature the\nplayer submits on-chain themselves). Everything is **server-authoritative**:\nthe client reports/requests, the backend validates the transaction against\nthe chain, applies rules (network enabled, KYC, account-safety policy), and\nthe SDK mirrors the confirmed result into a local cache your UI reads. You\nnever credit/debit balances yourself.\n\nThis skill is for **using** the production `BlockchainService`, not for\nporting or extending it, and not for signing/broadcasting transactions\nyourself — this SDK reports deposits and requests withdrawals; actually\nsending the on-chain transaction (the deposit transfer, or broadcasting a\nwithdrawal signature) happens with a wallet SDK outside this client.\n\n> **The on-chain half is the `@idosgames/wallet` companion package.** It\n> connects browser & mobile wallets (EVM via wagmi/viem/WalletConnect, Solana\n> via wallet-adapter) and runs the exact RewardPool contract calls, threading\n> them through this service's request → submit → confirm / approve → deposit →\n> report lifecycle. If the user wants to actually connect a wallet and move\n> tokens/NFTs (not just call `client.blockchain.*`), reach for that package —\n> see its README. Everything below documents the server-authoritative\n> `client.blockchain` surface that `@idosgames/wallet` builds on. The wallet\n> package's EVM surface covers both NFT standards: `submitEvmNftWithdrawal` /\n> `depositNftEvm` (+ `erc1155Abi`) for ERC-1155 collections, and\n> `submitEvmNftWithdrawal721` / `depositNftEvm721` (+ `erc721Abi`) for\n> ERC-721 unique-item collections (4-arg `safeTransferFrom`, no `id`/`amount`\n> — the token is always qty 1). `submitEvmTokenWithdrawal`'s `withdrawERC20`\n> call now also threads `sig.BurnAmount` through (see\n> [Burn on withdrawal](#gotchas) below) — the ABI/contract call order changed,\n> so an app pinned to an older `@idosgames/wallet` build will revert on-chain\n> against an updated RewardPool contract.\n\n> **Never import `@idosgames/wallet/react` (or `/react/solana`) from a file\n> that loads on startup.** Those subpaths pull in Reown AppKit, and AppKit is\n> deliberately _not_ a dependency of a generated project — the live preview\n> resolves every declared dependency up front and times out on AppKit's tree.\n> A static import therefore blanks the preview before any game code runs\n> (`Could not find dependency: '@reown/appkit-adapter-wagmi'`), while the real\n> build stays green — that mismatch is the signature of this mistake.\n> Two rules keep both working:\n>\n> - **Sign-in button:** import `LazyWalletLogin` / `LazySolanaWalletLogin` from\n> `@idosgames/wallet/react/lazy` (that entry has no AppKit in its graph; it\n> also re-exports the chains as plain objects — never import chains from\n> `wagmi/chains` or `viem/chains`, that barrel breaks the preview too).\n> - **In-game deposit/withdraw panel:** import `LazyWalletPanel` from\n> `@idosgames/wallet/react/lazy` and pass it the authenticated `client` (a\n> prop, like the login button — never a module context). Same lazy contract:\n> AppKit stays out of the startup graph. Don't hand-roll your own\n> `await import(\"./PanelImpl\")` wrapper — `LazyWalletPanel` is that wrapper.\n> Because the wallet config is memoised per WalletConnect project id, the\n> panel reuses the wallet the player connected at sign-in: same store, so it\n> opens already-connected, no second tap and no second modal. This is exactly\n> how the board-game and idle-rpg modules wire their `WalletPanel.tsx`.\n\n### Operation category (`game_topup` by default)\n\nThe updated RewardPool contract tags every deposit/withdrawal with a string\n**category** (default `\"game_topup\"`; `\"community_reward\"` is the other known\nvalue, and arbitrary strings are allowed). On a **withdrawal** the server signs\nthe category into the on-chain hash and returns it on the signature payload\n(`EvmSignature.Category` / `.TitleID`); whoever submits the transaction **must\npass the same value on-chain verbatim** or the contract rejects the signature —\n`@idosgames/wallet` does this for you. Pass it as the optional last arg to\n`requestTokenWithdrawal`/`requestNFTWithdrawal` (omit → `\"game_topup\"`). On a\n**deposit** the category is read back from the on-chain transaction, so you\ndon't pass it to `depositToken`/`depositNFT`. It also appears on transaction\ndocuments as `Category`. Import the constants from `@idosgames/core`:\n`BlockchainOperationCategory.GameTopUp` / `.CommunityReward`.\n\n## Mental model: deposits vs. withdrawals\n\n- **Deposit** = the player already sent tokens/an NFT to the platform's pool\n or vault address on-chain. The client then calls `depositToken`/`depositNFT`\n with that transaction's hash so the backend can verify it and credit the\n player in-game. One-shot: the credit happens directly on a successful call.\n- **Withdrawal** = the player wants an in-game token/NFT sent out to their\n wallet. The client calls `requestTokenWithdrawal`/`requestNFTWithdrawal`,\n which **debits in-game immediately** and returns a signed payload\n (`EvmSignature` or `SolanaSignature`) the player's wallet must submit\n on-chain to actually receive the asset. This is a **multi-step, async\n flow** — see [Recipes](#recipes) for the full lifecycle, including what to\n do when the on-chain submission fails.\n\nBoth flows are per-network: every call takes a `networkID` that must match one\nof the title's configured `Networks` (EVM or Solana), each with its own\ndeposit/withdrawal enable flags, contract/vault addresses, and (for NFTs) a\ncollection binding to an item catalog. Withdrawals additionally run through a\nlong chain of server-side gates (balances, per-network minimums, account\nsafety, KYC, daily/monthly compliance limits, a collective title-wide pool\ncap, and platform commission) — see\n[Withdrawal gates](#withdrawal-gates-what-can-reject-a-request) for the full\nlist with verbatim error strings.\n\nFor the full config/state field shapes (network definitions, NFT collection\nbindings, KYC tiers, transaction documents, signature payloads), read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI (network pickers, KYC gates,\ntransaction history tables).\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst blockchain = client.blockchain; // the BlockchainService\n```\n\nEvery blockchain method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always\nbranch on `result.ok` before touching `result.data`. `reason` is one of\n`\"client\"` (missing/empty required arg — rejected before any network call),\n`\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside the\n600ms client-side throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the exact backend message). Withdrawals in particular can be\nrejected by a long chain of server-side gates — see\n[Withdrawal gates](#withdrawal-gates-what-can-reject-a-request) below for the\nfull list with verbatim error strings.\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------------- |\n| `getDefinitions()` | Load the title's blockchain config: networks, NFT bindings, crypto currencies. | `BlockchainConfigResponse` |\n| `getUserState()` | Load this player's on-chain state: linked wallets, pending withdrawals, KYC, stats, crypto balances. | `UserBlockchainStateResponse` |\n| `depositToken(networkID, transactionHash)` | Report an on-chain token transfer; credits the matching crypto balance. | `DepositTokenResponse` |\n| `depositNFT(networkID, transactionHash)` | Report an on-chain NFT transfer; grants the matching in-game item. | `DepositNFTResponse` |\n| `requestTokenWithdrawal(currencyID, networkID, walletAddress, amount, category?)` | Debit a crypto balance and get a signed payload to withdraw on-chain. | `TokenWithdrawalResponse` |\n| `requestNFTWithdrawal(itemID, networkID, walletAddress, amount, category?, level?, itemInstanceID?)` | Consume an in-game item and get a signed payload to withdraw the NFT on-chain. | `NFTWithdrawalResponse` |\n| `getTransactionHistory(limit?)` | Load recent token + NFT transaction documents (default limit 50). | `TransactionHistoryResponse` |\n| `retryWithdrawal(titleTransactionID)` | Re-issue a fresh signature for a still-`Pending` withdrawal without re-debiting. | `RetryWithdrawalResponse` |\n| `confirmWithdrawal(titleTransactionID, onChainTransactionHash)` | Tell the backend the signed withdrawal was submitted on-chain, with its tx hash. | `ConfirmWithdrawalResponse` |\n| `donateToDeveloper(networkID, transactionHash)` | Report an on-chain transfer as a donation to the developer pool (no personal credit). | `DonationResponse` |\n| `donateToUsersPool(networkID, transactionHash)` | Report an on-chain transfer as a donation to the users' pool (no personal credit). | `DonationResponse` |\n\nAll string args (`networkID`, `transactionHash`, `currencyID`,\n`walletAddress`, `amount`, `itemID`, `titleTransactionID`,\n`onChainTransactionHash`) are required and checked client-side before any\nnetwork call — an empty one short-circuits with `reason: \"client\"`. `amount`\nis a decimal string for token withdrawals and an integer-as-string for NFT\nwithdrawals / not used for deposits (deposit amounts come from the verified\non-chain transaction, not from the client). `getTransactionHistory(limit)`\ndefaults to `50`, is capped at **200** server-side (values above are silently\nclamped, values `<= 0` fall back to 50), and is sent as `Amount` on the wire\n(reused request field, not an actual currency amount).\n\n`requestNFTWithdrawal`'s trailing `level`/`itemInstanceID` are both optional\nand only matter for NFT catalogs with leveled or unique (ERC-721) bindings:\n`level` selects which leveled instance/tokenId to withdraw (omit or `1` →\nbase level, prior behavior); `itemInstanceID` is **required** when the\nitem's NFT binding is ERC-721 — it tells the server exactly which\nunstackable instance to debit and tokenize (preserving its Level/RemainingUses/\nCustomData in the on-chain registry via a separate ItemBridge contract).\nBoth are ignored for stackable/ERC-1155 items.\n\nOn success, most methods **mirror the confirmed change into the cache and\nemit an event** — see the next section for exactly which cache each method\ntouches, since it's not uniform across this module.\n\n## Withdrawal gates (what can reject a request)\n\n`requestTokenWithdrawal` / `requestNFTWithdrawal` run through a long chain of\nserver-side checks, each a `reason: \"server\"` failure with a specific `error`\nstring. Surface the string; don't try to pre-validate all of these\nclient-side — the gate list can change without a client update:\n\n- **Global kill switch** — withdrawals can be turned off platform-wide\n independently of any per-network/per-currency flag: `\"Withdrawals are\ncurrently disabled.\"`\n- **Network / currency / binding disabled** — `\"Withdrawals disabled for this\nnetwork.\"`, `\"Withdrawals are disabled for currency '{id}'.\"`, `\"This\ncurrency cannot be withdrawn in this network.\"`, or (currency under\n maintenance) `\"Currency '{id}' is under maintenance. Try again later.\"`\n- **Per-network minimum** — every currency has a `MinWithdraw` for each\n network it's bound to (`CryptoNetworkBinding.MinWithdraw`, see the\n currency-system skill's data-model for the full shape): `\"Minimum withdraw\nis {MinWithdraw} {currencyID}.\"`\n- **Insufficient balance** — `\"Not enough balance: have {available}\n{currencyID}, need {amount}.\"` (tokens) or `\"Not enough items: have {owned},\nneed {amount}.\"` (NFTs).\n- **Account safety** (`BlockchainAccountSafetyPolicy`, read-only in\n `getDefinitions()`'s `AccountSafety` block) — applies to withdrawals only,\n never deposits: account younger than `MinAccountAgeDays` →\n `\"Account is too fresh. Try again later.\"`; banned account → `\"Account is\nbanned. Contact support.\"`; withdrawing to a wallet address another account\n already used, when `MultiAccountCheckEnabled` +\n `BanOnSharedWithdrawalAddress` are both on, **bans the account on the\n spot** and returns `\"Account banned. Contact support.\"`\n- **Compliance / KYC** (`CryptoCurrencyDefinition.Limits`, per currency) —\n checked in USD-equivalent of the requested amount:\n - Above `Limits.KycRequiredAboveUsd` without `Kyc.Status === \"Verified\"` →\n `\"KYC verification required for withdrawals above {threshold} USD.\"`\n - This UTC calendar day's spend would exceed `Limits.DailyWithdrawUsd`\n (window resets at 00:00 UTC, not a rolling 24h window) →\n `\"Daily withdraw limit exceeded ({spentSoFar} + {thisAmount} >\n{dailyLimit} USD).\"`\n - This UTC calendar month's spend would exceed `Limits.MonthlyWithdrawUsd`\n (window resets 00:00 UTC on the 1st) → `\"Monthly withdraw limit exceeded\n({spentSoFar} + {thisAmount} > {monthlyLimit} USD).\"`\n - Any `Limits` field can be absent/null, which disables that specific\n check for that currency. The daily/monthly counters live server-side on\n `UserCryptoCurrencyState.Compliance` (not exposed as its own client\n method) and reset at UTC day/month boundaries — there is no way to read\n \"USD spent so far today\" from the client ahead of a request; read it off\n a rejection's `error` string instead.\n- **Collective pool cap** — independent of the player's own balance, the\n title's whole player-withdrawable pool for that (network, currency) pair\n can be exhausted: `\"Title users-withdrawable limit reached: available\n{available} {currencyID}, requested {amount}.\"` This is a title-wide\n economic limit, not specific to one player — if you see it, don't retry\n immediately.\n- **Platform commission** — a platform-wide withdrawal commission percent can\n reduce the net payout; if it would consume the entire requested amount,\n the request is rejected outright: `\"Withdrawal amount is fully consumed by\nplatform commission.\"` Otherwise the withdrawal proceeds and\n `NetAmountNative` reflects the amount after commission (see\n [Gotchas](#gotchas)).\n\nNone of these are configurable or visible as a single \"can I withdraw right\nnow\" flag — the practical pattern is: build the request, call it, and render\n`error` on failure. Use `getDefinitions()`'s `AccountSafety` block and the\ncurrency's `Limits` (from `getDefinitions()`'s sibling `CryptoCurrencies` map)\nonly for soft, non-authoritative UI hints (e.g. \"KYC may be required above\n$X\").\n\n## Reading state and reacting to changes\n\n```ts\n// On-chain activity state (only present after getUserState()):\nconst bc = client.data.user.state?.Blockchain;\nbc?.LinkedWallets; // Record<networkID, LinkedWalletInfo>\nbc?.PendingWithdrawals; // PendingWithdrawalRef[] — light refs, not full tx docs\nbc?.Kyc; // UserKycState\nbc?.Stats; // BlockchainStats (deposit/withdrawal counters & volume)\n\n// Crypto balances (decimal-as-string), same cache Currency module reads:\nclient.data.user.getCryptoCurrencyAmount(\"usdt\");\n\n// Definitions (cached after getDefinitions()):\nimport type { BlockchainDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<BlockchainDefinitions>(\"Blockchain\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `blockchain:definitionsLoaded` → `BlockchainConfigResponse`\n- `blockchain:userStateLoaded` → `UserBlockchainStateResponse`\n- `blockchain:tokenDeposited` → `DepositTokenResponse`\n- `blockchain:nftDeposited` → `DepositNFTResponse`\n- `blockchain:tokenWithdrawalRequested` → `TokenWithdrawalResponse`\n- `blockchain:nftWithdrawalRequested` → `NFTWithdrawalResponse`\n- `blockchain:transactionHistoryLoaded` → `TransactionHistoryResponse`\n- `blockchain:withdrawalRetried` → `RetryWithdrawalResponse`\n- `blockchain:withdrawalConfirmed` → `ConfirmWithdrawalResponse`\n- `blockchain:donatedToDeveloper` → `DonationResponse`\n- `blockchain:donatedToUsersPool` → `DonationResponse`\n\n**Cache writes are not uniform across this module — read this carefully:**\n\n- `getUserState()` is the only call that writes `client.data.user.state.Blockchain`\n (`LinkedWallets`, `PendingWithdrawals`, `Kyc`, `Stats`) and fires the coarse\n `user:blockchainUpdated` (+ `user:anyUpdated`).\n- `depositToken` / `requestTokenWithdrawal` patch only the crypto **balance**\n (`InventoryV2.CryptoCurrencies`) via a decimal delta, firing\n `user:inventoryUpdated` (+ `user:anyUpdated`) — **not** `user:blockchainUpdated`.\n- `depositNFT` / `requestNFTWithdrawal` patch inventory (items and/or\n currencies) via the shared `Resources` resource-operation pipeline, firing\n `user:inventoryUpdated` (and `user:virtualCurrencyUpdated` if VC moved) —\n again **not** `user:blockchainUpdated`.\n- `getTransactionHistory`, `retryWithdrawal`, `confirmWithdrawal`,\n `donateToDeveloper`, `donateToUsersPool` only emit their own\n `blockchain:*` event — they don't touch `client.data.user.state` at all.\n\nPractical consequence: after a deposit or withdrawal request, your **balance**\nis fresh in the cache, but `client.data.user.state.Blockchain.PendingWithdrawals`\nand `.Stats` are stale until you call `getUserState()` again. Re-fetch\n`getUserState()` after a withdrawal request/confirm/retry if your UI shows the\npending-withdrawals list or stats.\n\n**`StateDelta` / `Inventory` — the response already carries what changed, if\nyou want to apply it yourself instead of re-fetching.** `DepositTokenResponse`,\n`TokenWithdrawalResponse`, `NFTWithdrawalResponse`, and\n`ConfirmWithdrawalResponse` all carry an optional `StateDelta`\n(`BlockchainStateDelta`): a signed `CryptoBalances` delta per currency\n(`{ AmountDelta, FrozenDelta, UpdatedAt }` — add, don't overwrite), a\n`PendingAdded` ref (this call's newly-added pending withdrawal, if any), and\n`PendingRemovedIDs` (pending withdrawals this call confirmed or lazily\nexpired). `DepositNFTResponse` / `NFTWithdrawalResponse` similarly carry an\n`Inventory` (`InventoryDelta`) for the NFT's `UnstackableItems` instance —\nsame shape/semantics as the character-system module's `Inventory` deltas\n(`ChangedInstances` to upsert, `RemovedInstanceIDs` to drop). This mirrors the\nself-sufficient-response pattern used elsewhere in this SDK (see the\ncharacter-system skill) so a client that wants to reconcile\n`PendingWithdrawals`/balances/instances without another round trip can do so\nstraight from the mutating call's response. **Note:** `BlockchainService`\nitself does not auto-apply `StateDelta`/`Inventory` into\n`client.data.user.state.Blockchain` today — only the crypto **balance**\n(via the existing `AmountNative`-based patch) and item `Resources` are\napplied automatically. If you need `PendingWithdrawals` reconciled without a\nfull `getUserState()` refetch, read `result.data.StateDelta` yourself. Both\nare `null`/absent on an idempotent replay (nothing new to apply).\n\n```ts\nconst off = client.on(\"blockchain:tokenWithdrawalRequested\", (r) => {\n console.log(`Withdrawal ${r.TitleTransactionID} expires at ${r.ExpiresAt}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load config + state on a wallet/blockchain screen\n\n```ts\nawait client.blockchain.getDefinitions();\nawait client.blockchain.getUserState();\n\nconst defs = client.data.config.getSection<BlockchainDefinitions>(\"Blockchain\");\nconst bc = client.data.user.state?.Blockchain;\n\nfor (const [networkID, net] of Object.entries(defs?.Networks ?? {})) {\n if (!net.DepositsEnabled && !net.WithdrawalsEnabled) continue;\n // render a network card; net.NftCollections binds contracts to item catalogs\n}\nbc?.Kyc?.Status; // gate withdrawal UI on KYC if the title requires it\n```\n\n### Deposit a token (player already sent it on-chain)\n\n```ts\nconst res = await client.blockchain.depositToken(\"polygon\", \"0xabc123...\");\nif (!res.ok) return showError(res.error); // e.g. \"Transaction not found on chain.\",\n// \"Not enough confirmations (required 12). Try again in a few minutes.\",\n// \"Transaction hash already used.\"\n\nres.data.CurrencyID; // e.g. \"usdt\"\nres.data.AmountNative; // decimal string credited\n// Balance is already updated in the cache:\nclient.data.user.getCryptoCurrencyAmount(res.data.CurrencyID!);\n```\n\n### Full withdrawal lifecycle: request -> submit on-chain -> confirm, with a retry-after-failure path\n\n```ts\n// 1. Request the withdrawal — debits in-game immediately, returns a signature payload.\nconst req = await client.blockchain.requestTokenWithdrawal(\n \"usdt\",\n \"polygon\",\n \"0xPlayerWallet...\",\n \"25.00\",\n);\nif (!req.ok) return showError(req.error); // e.g. \"Not enough balance: have 10 usdt, need 25.00.\",\n// \"KYC verification required for withdrawals above 1000 USD.\",\n// \"Title users-withdrawable limit reached: available 5 usdt, requested 25.00.\"\n// — see \"Withdrawal gates\" above for the full list.\n\nconst { TitleTransactionID, EvmSignature, ExpiresAt } = req.data;\n// Balance is already debited (gross amount) in the cache.\n\n// 2. Hand EvmSignature (or SolanaSignature on a Solana network) to the\n// player's wallet SDK to submit the on-chain transaction yourself —\n// this SDK does not sign/broadcast. That step can fail (rejected in\n// wallet, gas issue).\n\n// 2a. If on-chain submission failed WHILE the transaction is still Pending\n// (before ExpiresAt), retry — this re-issues a fresh signature WITHOUT\n// debiting again:\nconst retry = await client.blockchain.retryWithdrawal(TitleTransactionID!);\nif (!retry.ok) return showError(retry.error); // e.g. \"Transaction is not in Pending state (current: Abandoned).\"\nconst freshSignature = retry.data.EvmSignature ?? retry.data.SolanaSignature;\n// Submit freshSignature on-chain instead, then continue to step 3.\n//\n// IMPORTANT: retryWithdrawal only works while the transaction is Pending. If\n// ExpiresAt already passed, the backend has lazily moved it to Abandoned and\n// retryWithdrawal will reject it — there is no \"re-request\" for an Abandoned\n// withdrawal (the asset was already debited and is not refunded). The only\n// way to still complete it is confirmWithdrawal with a hash, if the player\n// actually managed to submit the original signature before it was swept —\n// see the Gotchas section.\n\n// 3. Once the wallet actually broadcasts the transaction, tell the backend\n// the resulting on-chain hash so it can verify and close out the withdrawal:\nconst confirm = await client.blockchain.confirmWithdrawal(\n TitleTransactionID!,\n \"0xOnChainTxHash...\",\n);\nif (!confirm.ok) return showError(confirm.error);\nconfirm.data.Status; // e.g. \"Completed\" once the chain confirms it\n\n// 4. Refresh state — request/retry/confirm don't touch Blockchain cache themselves.\nawait client.blockchain.getUserState();\nclient.data.user.state?.Blockchain?.PendingWithdrawals; // should no longer list it once Completed\n```\n\n### KYC-gated withdrawal\n\nThe client never decides whether KYC is required — the backend compares the\nwithdrawal's USD-equivalent against the currency's configured threshold at\nrequest time. Use `Kyc.Status` only to pre-empt an obvious rejection in the\nUI; still branch on the real error:\n\n```ts\nawait client.blockchain.getUserState();\nconst kyc = client.data.user.state?.Blockchain?.Kyc;\n\nif (kyc?.Status !== \"Verified\") {\n // Optional UX nicety: warn before the call for large amounts. This SDK has\n // no startKyc/submitKyc method — verification happens through whatever KYC\n // provider integration the title uses outside this SDK; Kyc here only\n // reflects the result.\n}\n\nconst req = await client.blockchain.requestTokenWithdrawal(\n \"usdt\",\n \"polygon\",\n \"0xPlayerWallet...\",\n \"5000.00\",\n);\nif (!req.ok) {\n if (req.error.startsWith(\"KYC verification required\")) {\n // Route the player to the title's KYC verification flow.\n }\n return showError(req.error);\n}\n```\n\n### Deposit / withdraw an NFT\n\n```ts\n// Deposit: player already transferred the NFT to the vault address on-chain.\nconst dep = await client.blockchain.depositNFT(\"ethereum\", \"0xNftDepositTx...\");\nif (!dep.ok) return showError(dep.error);\ndep.data.ItemID; // the in-game item granted\ndep.data.Resources; // already applied to inventory in the cache\n\n// Withdraw: consumes the in-game item, returns a signature to submit on-chain.\nconst wd = await client.blockchain.requestNFTWithdrawal(\n \"sword-of-embers-inst-1\", // ItemID (per NFTWithdrawalResponse/BlockchainRequest shape)\n \"ethereum\",\n \"0xPlayerWallet...\",\n \"1\",\n);\nif (!wd.ok) return showError(wd.error);\nwd.data.TitleTransactionID; // use with retryWithdrawal / confirmWithdrawal exactly as tokens above\n\n// ERC-721 unique NFT: pass the specific instance to tokenize. level/itemInstanceID\n// are the trailing optional args — see the Methods table above.\nconst wd721 = await client.blockchain.requestNFTWithdrawal(\n \"sword-of-embers\",\n \"ethereum\",\n \"0xPlayerWallet...\",\n \"1\",\n undefined, // category\n 1, // level\n \"sword-of-embers-inst-1\", // ItemInstanceID — required for ERC-721 bindings\n);\n```\n\n### Edge case: not logged in / missing args\n\n```ts\nconst res = await client.blockchain.depositToken(\"\", \"0xabc\");\n// res.ok === false, res.reason === \"client\" — \"NetworkID is required.\" — no network call.\n\nconst res2 = await client.blockchain.getUserState();\n// If called before auth.loginWithDeviceID() (or any auth.* login):\n// res2.ok === false, res2.reason === \"unauthorized\"\n```\n\n### Donate crypto (no personal credit)\n\n```ts\nconst res = await client.blockchain.donateToDeveloper(\n \"polygon\",\n \"0xdonateTx...\",\n);\nif (!res.ok) return showError(res.error);\nres.data.Target; // \"Developer\" — confirms which pool bucket it landed in\n\n// donateToUsersPool is identical in shape, credits the users' pool bucket instead:\nawait client.blockchain.donateToUsersPool(\"polygon\", \"0xdonateTx2...\");\n```\n\n## Gotchas\n\n- **Withdrawal request debits immediately; the on-chain leg is separate and\n can fail.** `requestTokenWithdrawal`/`requestNFTWithdrawal` already took the\n asset from the player before any on-chain transaction exists. If the\n player's wallet fails to submit (rejected, gas issue) **while the\n transaction is still `Pending`**, don't ask them to request again — that\n would debit twice. Use `retryWithdrawal` with the same\n `TitleTransactionID` to get a fresh signature without a new charge. This\n only works before `ExpiresAt` — see the next two points for what happens\n after.\n- **`retryWithdrawal` vs `confirmWithdrawal` are opposite ends of the same\n flow.** Retry re-issues the _signed payload_ before submission (nothing has\n reached the chain yet); confirm reports the _resulting tx hash_ after\n submission (the chain now has it). Calling confirm with a hash from a\n transaction that never actually landed on-chain will simply fail\n server-side verification — don't fabricate a hash to \"force\" completion.\n- **`ExpiresAt` is real, expiry does not refund the player, and — contrary to\n what the name suggests — an expired withdrawal is NOT retryable.** A\n withdrawal signature is time-boxed (`TokenWithdrawalResponse.ExpiresAt` /\n `NFTWithdrawalResponse.ExpiresAt`, driven by\n `BlockchainAccountSafetyPolicy.PendingWithdrawalTtlHours`). Once it passes\n without a submission, the backend lazily transitions the transaction to\n **`Abandoned`** (not `Expired` — that enum value exists but this backend\n path never assigns it) and drops it off `PendingWithdrawals` — but the\n already-debited asset is **not** credited back; this is intentional, not a\n bug. Critically, `retryWithdrawal` requires the transaction to still be\n `Pending` — calling it on an `Abandoned` one fails with `\"Transaction is\nnot in Pending state (current: Abandoned).\"` There is no \"re-request\"\n operation for an abandoned withdrawal.\n- **A withdrawal can still be confirmed after it's `Abandoned`.** If the\n player submits late — after `ExpiresAt` passed and the backend already\n swept it to `Abandoned` — `confirmWithdrawal` still accepts it as long as\n the on-chain transaction verifies (the signature itself doesn't expire\n on-chain, only the title's own bookkeeping window does). Don't treat an\n `Abandoned` transaction as unrecoverable if the player insists they\n submitted it; calling `confirmWithdrawal` with the resulting hash is still\n the right move, and is in fact the _only_ way to close out an\n already-expired-but-actually-submitted withdrawal.\n- **`retryWithdrawal` only works on a `Pending` transaction the caller owns.**\n It fails with `\"Transaction not found.\"` for an unknown or someone else's\n `TitleTransactionID`, `\"Transaction is not in Pending state (current:\n{status}).\"` if it already completed/failed/was abandoned, or\n `\"Signature data not found for this transaction.\"` if there's nothing to\n reissue. A banned account additionally gets `\"Account is banned. Contact\nsupport.\"` on retry (deposits stay allowed for banned accounts; retrying a\n withdrawal does not).\n- **Gross vs. net amounts on token withdrawals.** `AmountNative` is what was\n debited from the player (gross); `NetAmountNative` is what actually gets\n paid out on-chain after a platform commission percentage **and** an\n optional on-chain burn are deducted (`NetAmountNative = AmountNative −\ncommission − BurnAmountNative`). Show the player the net figure they'll\n receive, not the gross debit, to avoid support tickets about a \"missing\"\n amount. NFT withdrawals have no such split — there's no `NetAmountNative`\n on `NFTWithdrawalResponse`.\n- **Burn on withdrawal (EVM-only).** `TokenWithdrawalResponse.BurnAmountNative`\n is the amount burned on-chain (sent to the DEAD address) for this\n withdrawal, driven by the currency's `WithdrawalBurnPercent` (see the\n currency-system skill) — `0` if burn is disabled for that currency or the\n network is Solana. The raw-units counterpart, `WithdrawalSignatureResponse.\nBurnAmount`, is bound into the signed hash and must be passed to the\n contract call verbatim, same as `Amount`/`Nonce` — `@idosgames/wallet`'s\n `submitEvmTokenWithdrawal` does this for you; a client calling\n `withdrawERC20` directly must include it too, or the signature check fails.\n- **`client.data.user.state.Blockchain` goes stale after deposits/withdrawal\n requests.** Only `getUserState()` refreshes `LinkedWallets`,\n `PendingWithdrawals`, `Kyc`, and `Stats`. A deposit/withdrawal call updates\n your _balance_/_inventory_ cache correctly, but if your UI also shows the\n pending-withdrawals list or lifetime stats, re-call `getUserState()`\n afterward (see the withdrawal recipe above).\n- **Deposits are reporting, not sending.** `depositToken`/`depositNFT` don't\n move any asset on-chain — they tell the backend \"verify this transaction\n hash and credit me.\" The actual on-chain transfer to the platform's pool/\n vault address must already have happened via a wallet SDK before you call\n these.\n- **This SDK never signs or broadcasts.** `EvmSignature`/`SolanaSignature`\n payloads are inputs to a wallet SDK/contract call that happens outside\n `@idosgames/core`. Don't look for a \"submit on-chain\" method here — there\n isn't one; `confirmWithdrawal` only reports the result afterward. The\n `@idosgames/wallet` companion package is that outside layer — it submits the\n signature on-chain and calls `confirmWithdrawal` for you.\n- **Donations never touch personal balances.** `donateToDeveloper` /\n `donateToUsersPool` intentionally don't credit the player anything and\n don't touch `client.data.user.state` — they only emit their own\n `blockchain:donated*` event for a confirmation toast/receipt.\n- **Render from the cache, handle the error from the result.** The happy\n path updates the relevant cache slice + emits an event; the failure path\n gives you `reason` + `error`. Use `reason` to decide behavior (retry on\n `\"connection\"`, re-auth on `\"unauthorized\"`, toast the `error` on\n `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field: network definitions, NFT collection bindings, account-safety\npolicy, KYC state, transaction documents, the withdrawal-gate limits, and the\nEVM/Solana withdrawal signature payload shapes. Read it when building network\npickers, a transaction-history table, or KYC/limit-aware withdrawal UI. For\nthe shared `ResourceConsume`/`ResourceGrant`/`ResourceOperation`\ncost-and-reward shapes riding along on `depositNFT`/`requestNFTWithdrawal`,\nand for the full `CryptoCurrencyDefinition` shape (`Limits`, `Networks[].\nMinWithdraw`/`WithdrawFee`, `Permissions`) that the withdrawal gates enforce,\nsee the currency-system skill.\n",
4
+ "content": "---\nname: blockchain-system\ndescription: >-\n Bridge in-game assets on-chain in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.blockchain (BlockchainService): load blockchain\n network/config definitions, load the player's on-chain state (linked\n wallets, pending withdrawals, KYC, stats), deposit a token or NFT from a\n wallet into the game, request a token or NFT withdrawal out to a wallet,\n read on-chain transaction history, retry a still-pending withdrawal's\n signature, confirm a withdrawal's on-chain tx hash, and donate crypto to\n the developer or a users' pool. Use this whenever the user is working in\n the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants crypto wallets, NFT\n deposits/withdrawals, token bridging, on-chain asset transfers, KYC status,\n or otherwise touches client.blockchain, BlockchainService,\n BlockchainDefinitions, UserBlockchainState, DepositTokenResponse,\n TokenWithdrawalResponse, or NFTWithdrawalResponse — even if they don't name\n the module explicitly.\n---\n\n# Blockchain system (iDosGames TS SDK)\n\nThe Blockchain module bridges in-game assets to and from real wallets on\nsupported chains (EVM and Solana networks). A player can deposit a token or\nNFT they already sent on-chain (crediting their in-game balance/inventory),\nor request a withdrawal that pays an in-game token/NFT out to their wallet\n(debiting their in-game balance/inventory and producing a signature the\nplayer submits on-chain themselves). Everything is **server-authoritative**:\nthe client reports/requests, the backend validates the transaction against\nthe chain, applies rules (network enabled, KYC, account-safety policy), and\nthe SDK mirrors the confirmed result into a local cache your UI reads. You\nnever credit/debit balances yourself.\n\nThis skill is for **using** the production `BlockchainService`, not for\nporting or extending it, and not for signing/broadcasting transactions\nyourself — this SDK reports deposits and requests withdrawals; actually\nsending the on-chain transaction (the deposit transfer, or broadcasting a\nwithdrawal signature) happens with a wallet SDK outside this client.\n\n> **The on-chain half is the `@idosgames/wallet` companion package.** It\n> connects browser & mobile wallets (EVM via wagmi/viem/WalletConnect, Solana\n> via wallet-adapter) and runs the exact RewardPool contract calls, threading\n> them through this service's request → submit → confirm / approve → deposit →\n> report lifecycle. If the user wants to actually connect a wallet and move\n> tokens/NFTs (not just call `client.blockchain.*`), reach for that package —\n> see its README. Everything below documents the server-authoritative\n> `client.blockchain` surface that `@idosgames/wallet` builds on. The wallet\n> package's EVM surface covers both NFT standards: `submitEvmNftWithdrawal` /\n> `depositNftEvm` (+ `erc1155Abi`) for ERC-1155 collections, and\n> `submitEvmNftWithdrawal721` / `depositNftEvm721` (+ `erc721Abi`) for\n> ERC-721 unique-item collections (4-arg `safeTransferFrom`, no `id`/`amount`\n> — the token is always qty 1). `submitEvmTokenWithdrawal`'s `withdrawERC20`\n> call now also threads `sig.BurnAmount` through (see\n> [Burn on withdrawal](#gotchas) below) — the ABI/contract call order changed,\n> so an app pinned to an older `@idosgames/wallet` build will revert on-chain\n> against an updated RewardPool contract.\n\n> **Never import `@idosgames/wallet/react` (or `/react/solana`) from a file\n> that loads on startup.** Those subpaths pull in Reown AppKit, and AppKit is\n> deliberately _not_ a dependency of a generated project — the live preview\n> resolves every declared dependency up front and times out on AppKit's tree.\n> A static import therefore blanks the preview before any game code runs\n> (`Could not find dependency: '@reown/appkit-adapter-wagmi'`), while the real\n> build stays green — that mismatch is the signature of this mistake.\n> Two rules keep both working:\n>\n> - **Sign-in button:** import `LazyWalletLogin` / `LazySolanaWalletLogin` from\n> `@idosgames/wallet/react/lazy` (that entry has no AppKit in its graph; it\n> also re-exports the chains as plain objects — never import chains from\n> `wagmi/chains` or `viem/chains`, that barrel breaks the preview too).\n> - **In-game deposit/withdraw panel:** import `LazyWalletPanel` from\n> `@idosgames/wallet/react/lazy` and pass it the authenticated `client` (a\n> prop, like the login button — never a module context). Same lazy contract:\n> AppKit stays out of the startup graph. Don't hand-roll your own\n> `await import(\"./PanelImpl\")` wrapper — `LazyWalletPanel` is that wrapper.\n> Because the wallet config is memoised per WalletConnect project id, the\n> panel reuses the wallet the player connected at sign-in: same store, so it\n> opens already-connected, no second tap and no second modal. This is exactly\n> how the board-game and idle-rpg modules wire their `WalletPanel.tsx`.\n\n### Operation category (`game_topup` by default)\n\nThe updated RewardPool contract tags every deposit/withdrawal with a string\n**category** (default `\"game_topup\"`; `\"community_reward\"` is the other known\nvalue, and arbitrary strings are allowed). On a **withdrawal** the server signs\nthe category into the on-chain hash and returns it on the signature payload\n(`EvmSignature.Category` / `.TitleID`); whoever submits the transaction **must\npass the same value on-chain verbatim** or the contract rejects the signature —\n`@idosgames/wallet` does this for you. Pass it as the optional last arg to\n`requestTokenWithdrawal`/`requestNFTWithdrawal` (omit → `\"game_topup\"`). On a\n**deposit** the category is read back from the on-chain transaction, so you\ndon't pass it to `depositToken`/`depositNFT`. It also appears on transaction\ndocuments as `Category`. Import the constants from `@idosgames/core`:\n`BlockchainOperationCategory.GameTopUp` / `.CommunityReward`.\n\n## Mental model: deposits vs. withdrawals\n\n- **Deposit** = the player already sent tokens/an NFT to the platform's pool\n or vault address on-chain. The client then calls `depositToken`/`depositNFT`\n with that transaction's hash so the backend can verify it and credit the\n player in-game. One-shot: the credit happens directly on a successful call.\n- **Withdrawal** = the player wants an in-game token/NFT sent out to their\n wallet. The client calls `requestTokenWithdrawal`/`requestNFTWithdrawal`,\n which **debits in-game immediately** and returns a signed payload\n (`EvmSignature` or `SolanaSignature`) the player's wallet must submit\n on-chain to actually receive the asset. This is a **multi-step, async\n flow** — see [Recipes](#recipes) for the full lifecycle, including what to\n do when the on-chain submission fails.\n\nBoth flows are per-network: every call takes a `networkID` that must match one\nof the title's configured `Networks` (EVM or Solana), each with its own\ndeposit/withdrawal enable flags, contract/vault addresses, and (for NFTs) a\ncollection binding to an item catalog. Withdrawals additionally run through a\nlong chain of server-side gates (balances, per-network minimums, account\nsafety, KYC, daily/monthly compliance limits, a collective title-wide pool\ncap, and platform commission) — see\n[Withdrawal gates](#withdrawal-gates-what-can-reject-a-request) for the full\nlist with verbatim error strings.\n\nFor the full config/state field shapes (network definitions, NFT collection\nbindings, KYC tiers, transaction documents, signature payloads), read\n[references/data-model.md](references/data-model.md). You do **not** need it\nto call the methods — only to drive richer UI (network pickers, KYC gates,\ntransaction history tables).\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst blockchain = client.blockchain; // the BlockchainService\n```\n\nEvery blockchain method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that\nis either `{ ok: true, data }` or `{ ok: false, reason, error }`. Always\nbranch on `result.ok` before touching `result.data`. `reason` is one of\n`\"client\"` (missing/empty required arg — rejected before any network call),\n`\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again inside the\n600ms client-side throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the exact backend message). Withdrawals in particular can be\nrejected by a long chain of server-side gates — see\n[Withdrawal gates](#withdrawal-gates-what-can-reject-a-request) below for the\nfull list with verbatim error strings.\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------------- |\n| `getDefinitions()` | Load the title's blockchain config: networks, NFT bindings, crypto currencies. | `BlockchainConfigResponse` |\n| `getUserState()` | Load this player's on-chain state: linked wallets, pending withdrawals, KYC, stats, crypto balances. | `UserBlockchainStateResponse` |\n| `depositToken(networkID, transactionHash)` | Report an on-chain token transfer; credits the matching crypto balance. | `DepositTokenResponse` |\n| `depositNFT(networkID, transactionHash)` | Report an on-chain NFT transfer; grants the matching in-game item. | `DepositNFTResponse` |\n| `requestTokenWithdrawal(currencyID, networkID, walletAddress, amount, category?)` | Debit a crypto balance and get a signed payload to withdraw on-chain. | `TokenWithdrawalResponse` |\n| `requestNFTWithdrawal(itemID, networkID, walletAddress, amount, category?, level?, itemInstanceID?)` | Consume an in-game item and get a signed payload to withdraw the NFT on-chain. | `NFTWithdrawalResponse` |\n| `getTransactionHistory(limit?)` | Load recent token + NFT transaction documents (default limit 50). | `TransactionHistoryResponse` |\n| `retryWithdrawal(titleTransactionID)` | Re-issue a fresh signature for a still-`Pending` withdrawal without re-debiting. | `RetryWithdrawalResponse` |\n| `confirmWithdrawal(titleTransactionID, onChainTransactionHash)` | Tell the backend the signed withdrawal was submitted on-chain, with its tx hash. | `ConfirmWithdrawalResponse` |\n| `donateToDeveloper(networkID, transactionHash)` | Report an on-chain transfer as a donation to the developer pool (no personal credit). | `DonationResponse` |\n| `donateToUsersPool(networkID, transactionHash)` | Report an on-chain transfer as a donation to the users' pool (no personal credit). | `DonationResponse` |\n\nAll string args (`networkID`, `transactionHash`, `currencyID`,\n`walletAddress`, `amount`, `itemID`, `titleTransactionID`,\n`onChainTransactionHash`) are required and checked client-side before any\nnetwork call — an empty one short-circuits with `reason: \"client\"`. `amount`\nis a decimal string for token withdrawals and an integer-as-string for NFT\nwithdrawals / not used for deposits (deposit amounts come from the verified\non-chain transaction, not from the client). `getTransactionHistory(limit)`\ndefaults to `50`, is capped at **200** server-side (values above are silently\nclamped, values `<= 0` fall back to 50), and is sent as `Amount` on the wire\n(reused request field, not an actual currency amount).\n\n`requestNFTWithdrawal`'s trailing `level`/`itemInstanceID` are both optional\nand only matter for NFT catalogs with leveled or unique (ERC-721) bindings:\n`level` selects which leveled instance/tokenId to withdraw (omit or `1` →\nbase level, prior behavior); `itemInstanceID` is **required** when the\nitem's NFT binding is ERC-721 — it tells the server exactly which\nunstackable instance to debit and tokenize (preserving its Level/RemainingUses/\nCustomData in the on-chain registry via a separate ItemBridge contract).\nBoth are ignored for stackable/ERC-1155 items.\n\nOn success, most methods **mirror the confirmed change into the cache and\nemit an event** — see the next section for exactly which cache each method\ntouches, since it's not uniform across this module.\n\n## Withdrawal gates (what can reject a request)\n\n`requestTokenWithdrawal` / `requestNFTWithdrawal` run through a long chain of\nserver-side checks, each a `reason: \"server\"` failure with a specific `error`\nstring. Surface the string; don't try to pre-validate all of these\nclient-side — the gate list can change without a client update:\n\n- **Global kill switch** — withdrawals can be turned off platform-wide\n independently of any per-network/per-currency flag: `\"Withdrawals are\ncurrently disabled.\"`\n- **Network / currency / binding disabled** — `\"Withdrawals disabled for this\nnetwork.\"`, `\"Withdrawals are disabled for currency '{id}'.\"`, `\"This\ncurrency cannot be withdrawn in this network.\"`, or (currency under\n maintenance) `\"Currency '{id}' is under maintenance. Try again later.\"`\n- **Per-network minimum** — every currency has a `MinWithdraw` for each\n network it's bound to (`CryptoNetworkBinding.MinWithdraw`, see the\n currency-system skill's data-model for the full shape): `\"Minimum withdraw\nis {MinWithdraw} {currencyID}.\"`\n- **Insufficient balance** — `\"Not enough balance: have {available}\n{currencyID}, need {amount}.\"` (tokens) or `\"Not enough items: have {owned},\nneed {amount}.\"` (NFTs).\n- **Account safety** (`BlockchainAccountSafetyPolicy`, read-only in\n `getDefinitions()`'s `AccountSafety` block) — applies to withdrawals only,\n never deposits: account younger than `MinAccountAgeDays` →\n `\"Account is too fresh. Try again later.\"`; banned account → `\"Account is\nbanned. Contact support.\"`; withdrawing to a wallet address another account\n already used, when `MultiAccountCheckEnabled` +\n `BanOnSharedWithdrawalAddress` are both on, **bans the account on the\n spot** and returns `\"Account banned. Contact support.\"`\n- **Compliance / KYC** (`CryptoCurrencyDefinition.Limits`, per currency) —\n checked in USD-equivalent of the requested amount:\n - Above `Limits.KycRequiredAboveUsd` without `Kyc.Status === \"Verified\"` →\n `\"KYC verification required for withdrawals above {threshold} USD.\"`\n - This UTC calendar day's spend would exceed `Limits.DailyWithdrawUsd`\n (window resets at 00:00 UTC, not a rolling 24h window) →\n `\"Daily withdraw limit exceeded ({spentSoFar} + {thisAmount} >\n{dailyLimit} USD).\"`\n - This UTC calendar month's spend would exceed `Limits.MonthlyWithdrawUsd`\n (window resets 00:00 UTC on the 1st) → `\"Monthly withdraw limit exceeded\n({spentSoFar} + {thisAmount} > {monthlyLimit} USD).\"`\n - Any `Limits` field can be absent/null, which disables that specific\n check for that currency. The daily/monthly counters live server-side on\n `UserCryptoCurrencyState.Compliance` (not exposed as its own client\n method) and reset at UTC day/month boundaries — there is no way to read\n \"USD spent so far today\" from the client ahead of a request; read it off\n a rejection's `error` string instead.\n- **Collective pool cap** — independent of the player's own balance, the\n title's whole player-withdrawable pool for that (network, currency) pair\n can be exhausted: `\"Title users-withdrawable limit reached: available\n{available} {currencyID}, requested {amount}.\"` This is a title-wide\n economic limit, not specific to one player — if you see it, don't retry\n immediately.\n- **Platform commission** — a platform-wide withdrawal commission percent can\n reduce the net payout; if it would consume the entire requested amount,\n the request is rejected outright: `\"Withdrawal amount is fully consumed by\nplatform commission.\"` Otherwise the withdrawal proceeds and\n `NetAmountNative` reflects the amount after commission (see\n [Gotchas](#gotchas)).\n\nNone of these are configurable or visible as a single \"can I withdraw right\nnow\" flag — the practical pattern is: build the request, call it, and render\n`error` on failure. Use `getDefinitions()`'s `AccountSafety` block and the\ncurrency's `Limits` (from `getDefinitions()`'s sibling `CryptoCurrencies` map)\nonly for soft, non-authoritative UI hints (e.g. \"KYC may be required above\n$X\").\n\n## Reading state and reacting to changes\n\n```ts\n// On-chain activity state (only present after getUserState()):\nconst bc = client.data.user.state?.Blockchain;\nbc?.LinkedWallets; // Record<networkID, LinkedWalletInfo>\nbc?.PendingWithdrawals; // PendingWithdrawalRef[] — light refs, not full tx docs\nbc?.Kyc; // UserKycState\nbc?.Stats; // BlockchainStats (deposit/withdrawal counters & volume)\n\n// Crypto balances (decimal-as-string), same cache Currency module reads:\nclient.data.user.getCryptoCurrencyAmount(\"usdt\");\n\n// Definitions (cached after getDefinitions()):\nimport type { BlockchainDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<BlockchainDefinitions>(\"Blockchain\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `blockchain:definitionsLoaded` → `BlockchainConfigResponse`\n- `blockchain:userStateLoaded` → `UserBlockchainStateResponse`\n- `blockchain:tokenDeposited` → `DepositTokenResponse`\n- `blockchain:nftDeposited` → `DepositNFTResponse`\n- `blockchain:tokenWithdrawalRequested` → `TokenWithdrawalResponse`\n- `blockchain:nftWithdrawalRequested` → `NFTWithdrawalResponse`\n- `blockchain:transactionHistoryLoaded` → `TransactionHistoryResponse`\n- `blockchain:withdrawalRetried` → `RetryWithdrawalResponse`\n- `blockchain:withdrawalConfirmed` → `ConfirmWithdrawalResponse`\n- `blockchain:donatedToDeveloper` → `DonationResponse`\n- `blockchain:donatedToUsersPool` → `DonationResponse`\n\n**Cache writes are not uniform across this module — read this carefully:**\n\n- `getUserState()` is the only call that writes `client.data.user.state.Blockchain`\n (`LinkedWallets`, `PendingWithdrawals`, `Kyc`, `Stats`) and fires the coarse\n `user:blockchainUpdated` (+ `user:anyUpdated`).\n- `depositToken` / `requestTokenWithdrawal` patch only the crypto **balance**\n (`InventoryV2.CryptoCurrencies`) via a decimal delta, firing\n `user:inventoryUpdated` (+ `user:anyUpdated`) — **not** `user:blockchainUpdated`.\n- `depositNFT` / `requestNFTWithdrawal` patch inventory (items and/or\n currencies) via the shared `Resources` resource-operation pipeline, firing\n `user:inventoryUpdated` (and `user:virtualCurrencyUpdated` if VC moved) —\n again **not** `user:blockchainUpdated`.\n- `getTransactionHistory`, `retryWithdrawal`, `confirmWithdrawal`,\n `donateToDeveloper`, `donateToUsersPool` only emit their own\n `blockchain:*` event — they don't touch `client.data.user.state` at all.\n\nPractical consequence: after a deposit or withdrawal request, your **balance**\nis fresh in the cache, but `client.data.user.state.Blockchain.PendingWithdrawals`\nand `.Stats` are stale until you call `getUserState()` again. Re-fetch\n`getUserState()` after a withdrawal request/confirm/retry if your UI shows the\npending-withdrawals list or stats.\n\n**`StateDelta` / `Inventory` — the response already carries what changed, if\nyou want to apply it yourself instead of re-fetching.** `DepositTokenResponse`,\n`TokenWithdrawalResponse`, `NFTWithdrawalResponse`, and\n`ConfirmWithdrawalResponse` all carry an optional `StateDelta`\n(`BlockchainStateDelta`): a signed `CryptoBalances` delta per currency\n(`{ AmountDelta, FrozenDelta, UpdatedAt }` — add, don't overwrite), a\n`PendingAdded` ref (this call's newly-added pending withdrawal, if any), and\n`PendingRemovedIDs` (pending withdrawals this call confirmed or lazily\nexpired). `DepositNFTResponse` / `NFTWithdrawalResponse` similarly carry an\n`Inventory` (`InventoryDelta`) for the NFT's `UnstackableItems` instance —\nsame shape/semantics as the character-system module's `Inventory` deltas\n(`ChangedInstances` to upsert, `RemovedInstanceIDs` to drop). This mirrors the\nself-sufficient-response pattern used elsewhere in this SDK (see the\ncharacter-system skill) so a client that wants to reconcile\n`PendingWithdrawals`/balances/instances without another round trip can do so\nstraight from the mutating call's response. **Note:** `BlockchainService`\nitself does not auto-apply `StateDelta`/`Inventory` into\n`client.data.user.state.Blockchain` today — only the crypto **balance**\n(via the existing `AmountNative`-based patch) and item `Resources` are\napplied automatically. If you need `PendingWithdrawals` reconciled without a\nfull `getUserState()` refetch, read `result.data.StateDelta` yourself. Both\nare `null`/absent on an idempotent replay (nothing new to apply).\n\n```ts\nconst off = client.on(\"blockchain:tokenWithdrawalRequested\", (r) => {\n console.log(`Withdrawal ${r.TitleTransactionID} expires at ${r.ExpiresAt}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Load config + state on a wallet/blockchain screen\n\n```ts\nawait client.blockchain.getDefinitions();\nawait client.blockchain.getUserState();\n\nconst defs = client.data.config.getSection<BlockchainDefinitions>(\"Blockchain\");\nconst bc = client.data.user.state?.Blockchain;\n\nfor (const [networkID, net] of Object.entries(defs?.Networks ?? {})) {\n if (!net.DepositsEnabled && !net.WithdrawalsEnabled) continue;\n // render a network card; net.NftCollections binds contracts to item catalogs\n}\nbc?.Kyc?.Status; // gate withdrawal UI on KYC if the title requires it\n```\n\n### Deposit a token (player already sent it on-chain)\n\n```ts\nconst res = await client.blockchain.depositToken(\"polygon\", \"0xabc123...\");\nif (!res.ok) return showError(res.error); // e.g. \"Transaction not found on chain.\",\n// \"Not enough confirmations (required 12). Try again in a few minutes.\",\n// \"Transaction hash already used.\"\n\nres.data.CurrencyID; // e.g. \"usdt\"\nres.data.AmountNative; // decimal string credited\n// Balance is already updated in the cache:\nclient.data.user.getCryptoCurrencyAmount(res.data.CurrencyID!);\n```\n\n### Full withdrawal lifecycle: request -> submit on-chain -> confirm, with a retry-after-failure path\n\n```ts\n// 1. Request the withdrawal — debits in-game immediately, returns a signature payload.\nconst req = await client.blockchain.requestTokenWithdrawal(\n \"usdt\",\n \"polygon\",\n \"0xPlayerWallet...\",\n \"25.00\",\n);\nif (!req.ok) return showError(req.error); // e.g. \"Not enough balance: have 10 usdt, need 25.00.\",\n// \"KYC verification required for withdrawals above 1000 USD.\",\n// \"Title users-withdrawable limit reached: available 5 usdt, requested 25.00.\"\n// — see \"Withdrawal gates\" above for the full list.\n\nconst { TitleTransactionID, EvmSignature, ExpiresAt } = req.data;\n// Balance is already debited (gross amount) in the cache.\n\n// 2. Hand EvmSignature (or SolanaSignature on a Solana network) to the\n// player's wallet SDK to submit the on-chain transaction yourself —\n// this SDK does not sign/broadcast. That step can fail (rejected in\n// wallet, gas issue).\n\n// 2a. If on-chain submission failed WHILE the transaction is still Pending\n// (before ExpiresAt), retry — this re-issues a fresh signature WITHOUT\n// debiting again:\nconst retry = await client.blockchain.retryWithdrawal(TitleTransactionID!);\nif (!retry.ok) return showError(retry.error); // e.g. \"Transaction is not in Pending state (current: Abandoned).\"\nconst freshSignature = retry.data.EvmSignature ?? retry.data.SolanaSignature;\n// Submit freshSignature on-chain instead, then continue to step 3.\n//\n// IMPORTANT: retryWithdrawal only works while the transaction is Pending. If\n// ExpiresAt already passed, the backend has lazily moved it to Abandoned and\n// retryWithdrawal will reject it — there is no \"re-request\" for an Abandoned\n// withdrawal (the asset was already debited and is not refunded). The only\n// way to still complete it is confirmWithdrawal with a hash, if the player\n// actually managed to submit the original signature before it was swept —\n// see the Gotchas section.\n\n// 3. Once the wallet actually broadcasts the transaction, tell the backend\n// the resulting on-chain hash so it can verify and close out the withdrawal:\nconst confirm = await client.blockchain.confirmWithdrawal(\n TitleTransactionID!,\n \"0xOnChainTxHash...\",\n);\nif (!confirm.ok) return showError(confirm.error);\nconfirm.data.Status; // e.g. \"Completed\" once the chain confirms it\n\n// 4. Refresh state — request/retry/confirm don't touch Blockchain cache themselves.\nawait client.blockchain.getUserState();\nclient.data.user.state?.Blockchain?.PendingWithdrawals; // should no longer list it once Completed\n```\n\n### KYC-gated withdrawal\n\nThe client never decides whether KYC is required — the backend compares the\nwithdrawal's USD-equivalent against the currency's configured threshold at\nrequest time. Use `Kyc.Status` only to pre-empt an obvious rejection in the\nUI; still branch on the real error:\n\n```ts\nawait client.blockchain.getUserState();\nconst kyc = client.data.user.state?.Blockchain?.Kyc;\n\nif (kyc?.Status !== \"Verified\") {\n // Optional UX nicety: warn before the call for large amounts. This SDK has\n // no startKyc/submitKyc method — verification happens through whatever KYC\n // provider integration the title uses outside this SDK; Kyc here only\n // reflects the result.\n}\n\nconst req = await client.blockchain.requestTokenWithdrawal(\n \"usdt\",\n \"polygon\",\n \"0xPlayerWallet...\",\n \"5000.00\",\n);\nif (!req.ok) {\n if (req.error.startsWith(\"KYC verification required\")) {\n // Route the player to the title's KYC verification flow.\n }\n return showError(req.error);\n}\n```\n\n### Deposit / withdraw an NFT\n\n```ts\n// Deposit: player already transferred the NFT to the vault address on-chain.\nconst dep = await client.blockchain.depositNFT(\"ethereum\", \"0xNftDepositTx...\");\nif (!dep.ok) return showError(dep.error);\ndep.data.ItemID; // the in-game item granted\ndep.data.Resources; // already applied to inventory in the cache\n\n// Withdraw: consumes the in-game item, returns a signature to submit on-chain.\nconst wd = await client.blockchain.requestNFTWithdrawal(\n \"sword-of-embers-inst-1\", // ItemID (per NFTWithdrawalResponse/BlockchainRequest shape)\n \"ethereum\",\n \"0xPlayerWallet...\",\n \"1\",\n);\nif (!wd.ok) return showError(wd.error);\nwd.data.TitleTransactionID; // use with retryWithdrawal / confirmWithdrawal exactly as tokens above\n\n// ERC-721 unique NFT: pass the specific instance to tokenize. level/itemInstanceID\n// are the trailing optional args — see the Methods table above.\nconst wd721 = await client.blockchain.requestNFTWithdrawal(\n \"sword-of-embers\",\n \"ethereum\",\n \"0xPlayerWallet...\",\n \"1\",\n undefined, // category\n 1, // level\n \"sword-of-embers-inst-1\", // ItemInstanceID — required for ERC-721 bindings\n);\n```\n\n### Edge case: not logged in / missing args\n\n```ts\nconst res = await client.blockchain.depositToken(\"\", \"0xabc\");\n// res.ok === false, res.reason === \"client\" — \"NetworkID is required.\" — no network call.\n\nconst res2 = await client.blockchain.getUserState();\n// If called before auth.loginWithDeviceID() (or any auth.* login):\n// res2.ok === false, res2.reason === \"unauthorized\"\n```\n\n### Donate crypto (no personal credit)\n\n```ts\nconst res = await client.blockchain.donateToDeveloper(\n \"polygon\",\n \"0xdonateTx...\",\n);\nif (!res.ok) return showError(res.error);\nres.data.Target; // \"Developer\" — confirms which pool bucket it landed in\n\n// donateToUsersPool is identical in shape, credits the users' pool bucket instead:\nawait client.blockchain.donateToUsersPool(\"polygon\", \"0xdonateTx2...\");\n```\n\n## Gotchas\n\n- **Withdrawal request debits immediately; the on-chain leg is separate and\n can fail.** `requestTokenWithdrawal`/`requestNFTWithdrawal` already took the\n asset from the player before any on-chain transaction exists. If the\n player's wallet fails to submit (rejected, gas issue) **while the\n transaction is still `Pending`**, don't ask them to request again — that\n would debit twice. Use `retryWithdrawal` with the same\n `TitleTransactionID` to get a fresh signature without a new charge. This\n only works before `ExpiresAt` — see the next two points for what happens\n after.\n- **`retryWithdrawal` vs `confirmWithdrawal` are opposite ends of the same\n flow.** Retry re-issues the _signed payload_ before submission (nothing has\n reached the chain yet); confirm reports the _resulting tx hash_ after\n submission (the chain now has it). Calling confirm with a hash from a\n transaction that never actually landed on-chain will simply fail\n server-side verification — don't fabricate a hash to \"force\" completion.\n- **`ExpiresAt` is real, expiry does not refund the player, and — contrary to\n what the name suggests — an expired withdrawal is NOT retryable.** A\n withdrawal signature is time-boxed (`TokenWithdrawalResponse.ExpiresAt` /\n `NFTWithdrawalResponse.ExpiresAt`, driven by\n `BlockchainAccountSafetyPolicy.PendingWithdrawalTtlHours`). Once it passes\n without a submission, the backend lazily transitions the transaction to\n **`Abandoned`** (not `Expired` — that enum value exists but this backend\n path never assigns it) and drops it off `PendingWithdrawals` — but the\n already-debited asset is **not** credited back; this is intentional, not a\n bug. Critically, `retryWithdrawal` requires the transaction to still be\n `Pending` — calling it on an `Abandoned` one fails with `\"Transaction is\nnot in Pending state (current: Abandoned).\"` There is no \"re-request\"\n operation for an abandoned withdrawal.\n- **A withdrawal can still be confirmed after it's `Abandoned`.** If the\n player submits late — after `ExpiresAt` passed and the backend already\n swept it to `Abandoned` — `confirmWithdrawal` still accepts it as long as\n the on-chain transaction verifies (the signature itself doesn't expire\n on-chain, only the title's own bookkeeping window does). Don't treat an\n `Abandoned` transaction as unrecoverable if the player insists they\n submitted it; calling `confirmWithdrawal` with the resulting hash is still\n the right move, and is in fact the _only_ way to close out an\n already-expired-but-actually-submitted withdrawal.\n- **`retryWithdrawal` only works on a `Pending` transaction the caller owns.**\n It fails with `\"Transaction not found.\"` for an unknown or someone else's\n `TitleTransactionID`, `\"Transaction is not in Pending state (current:\n{status}).\"` if it already completed/failed/was abandoned, or\n `\"Signature data not found for this transaction.\"` if there's nothing to\n reissue. A banned account additionally gets `\"Account is banned. Contact\nsupport.\"` on retry (deposits stay allowed for banned accounts; retrying a\n withdrawal does not).\n- **Gross vs. net amounts on token withdrawals.** `AmountNative` is what was\n debited from the player (gross); `NetAmountNative` is what actually gets\n paid out on-chain after a platform commission percentage **and** an\n optional on-chain burn are deducted (`NetAmountNative = AmountNative −\ncommission − BurnAmountNative`). Show the player the net figure they'll\n receive, not the gross debit, to avoid support tickets about a \"missing\"\n amount. NFT withdrawals have no such split — there's no `NetAmountNative`\n on `NFTWithdrawalResponse`.\n- **Burn on withdrawal (EVM-only).** `TokenWithdrawalResponse.BurnAmountNative`\n is the amount burned on-chain (sent to the DEAD address) for this\n withdrawal, driven by the currency's `WithdrawalBurnPercent` (see the\n currency-system skill) — `0` if burn is disabled for that currency or the\n network is Solana. The raw-units counterpart, `WithdrawalSignatureResponse.\nBurnAmount`, is bound into the signed hash and must be passed to the\n contract call verbatim, same as `Amount`/`Nonce` — `@idosgames/wallet`'s\n `submitEvmTokenWithdrawal` does this for you; a client calling\n `withdrawERC20` directly must include it too, or the signature check fails.\n- **`client.data.user.state.Blockchain` goes stale after deposits/withdrawal\n requests.** Only `getUserState()` refreshes `LinkedWallets`,\n `PendingWithdrawals`, `Kyc`, and `Stats`. A deposit/withdrawal call updates\n your _balance_/_inventory_ cache correctly, but if your UI also shows the\n pending-withdrawals list or lifetime stats, re-call `getUserState()`\n afterward (see the withdrawal recipe above).\n- **Deposits are reporting, not sending.** `depositToken`/`depositNFT` don't\n move any asset on-chain — they tell the backend \"verify this transaction\n hash and credit me.\" The actual on-chain transfer to the platform's pool/\n vault address must already have happened via a wallet SDK before you call\n these.\n- **This SDK never signs or broadcasts.** `EvmSignature`/`SolanaSignature`\n payloads are inputs to a wallet SDK/contract call that happens outside\n `@idosgames/core`. Don't look for a \"submit on-chain\" method here — there\n isn't one; `confirmWithdrawal` only reports the result afterward. The\n `@idosgames/wallet` companion package is that outside layer — it submits the\n signature on-chain and calls `confirmWithdrawal` for you.\n- **Donations never touch personal balances.** `donateToDeveloper` /\n `donateToUsersPool` intentionally don't credit the player anything and\n don't touch `client.data.user.state` — they only emit their own\n `blockchain:donated*` event for a confirmation toast/receipt.\n- **Render from the cache, handle the error from the result.** The happy\n path updates the relevant cache slice + emits an event; the failure path\n gives you `reason` + `error`. Use `reason` to decide behavior (retry on\n `\"connection\"`, re-auth on `\"unauthorized\"`, toast the `error` on\n `\"server\"`).\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and\nstate field: network definitions, NFT collection bindings, account-safety\npolicy, KYC state, transaction documents, the withdrawal-gate limits, and the\nEVM/Solana withdrawal signature payload shapes. Read it when building network\npickers, a transaction-history table, or KYC/limit-aware withdrawal UI. For\nthe shared `ResourceConsume`/`ResourceGrant`/`ResourceOperation`\ncost-and-reward shapes riding along on `depositNFT`/`requestNFTWithdrawal`,\nand for the full `CryptoCurrencyDefinition` shape (`Limits`, `Networks[].\nMinWithdraw`, `Permissions`) that the withdrawal gates enforce,\nsee the currency-system skill.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
8
- "content": "# Blockchain data model — reference\n\nFull shape of the config (`BlockchainDefinitions`), player state\n(`UserBlockchainState`), transaction documents, and the withdrawal signature\npayloads. All of these are **strictly typed in the SDK** — every type below is\nexported from `@idosgames/core`, built with `zod` schemas that keep\n`.passthrough()`, so a field the backend adds later still round-trips. Field\nnames are PascalCase (straight from the backend JSON). Decimal-valued fields\n(`Amount`, balances, USD values) are decimal strings, not JS numbers — use\n`decimal.js` (already a dependency) rather than float math.\n\n## Contents\n\n- [Config: BlockchainDefinitions](#config-blockchaindefinitions) — what `getDefinitions()` returns\n- [BlockchainNetworkDefinition](#blockchainnetworkdefinition)\n- [NFT collection bindings](#nft-collection-bindings)\n- [Account safety policy](#account-safety-policy)\n- [Withdrawal gate mechanics](#withdrawal-gate-mechanics) — every check + formula the backend runs before paying out\n- [Player state: UserBlockchainState](#player-state-userblockchainstate) — what `getUserState()` returns\n- [KYC state](#kyc-state)\n- [Compliance counters](#compliance-counters) — the daily/monthly spend windows behind the limit errors\n- [Stats containers](#stats-containers)\n- [Transaction documents](#transaction-documents)\n- [Withdrawal signature payloads](#withdrawal-signature-payloads)\n- [Domain delta: BlockchainStateDelta](#domain-delta-blockchainstatedelta)\n- [Responses](#responses)\n- [Enums](#enums)\n\n---\n\n## Config: BlockchainDefinitions\n\nReturned by `getDefinitions()` as part of `BlockchainConfigResponse`; the\n`Blockchain` section is cached via\n`client.data.config.getSection<BlockchainDefinitions>(\"Blockchain\")`. The\nsibling `CryptoCurrencies` map (`Record<string, CryptoCurrencyDefinition>`)\nrides along in the same response — see the currency-system skill for that\nshape.\n\n```ts\ninterface BlockchainDefinitions {\n SystemState?: BlockchainSystemState; // title-wide kill switches\n Networks?: Record<string, BlockchainNetworkDefinition>; // key = NetworkID\n AccountSafety?: BlockchainAccountSafetyPolicy;\n}\n\ninterface BlockchainSystemState {\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n NftDepositsEnabled?: boolean;\n NftWithdrawalsEnabled?: boolean;\n PlatformOverrides?: PlatformBlockchainState; // per-platform (Ios/Android/Web) override\n}\n\ninterface PlatformBlockchainState {\n Ios?: boolean;\n Android?: boolean;\n Web?: boolean;\n}\n```\n\n`SystemState` is the title-wide switch; each `BlockchainNetworkDefinition` has\nits own matching flags that layer on top (both must allow an action for it to\nbe permitted — the backend enforces this, but mirror the check in UI to avoid\nshowing a dead button).\n\n---\n\n## BlockchainNetworkDefinition\n\nOne connected chain. Key in `Networks` is the `NetworkID` you pass to every\nservice method (`\"polygon\"`, `\"ethereum\"`, `\"solana\"`, etc. — title-defined\nstrings, not fixed by the SDK).\n\n```ts\ninterface BlockchainNetworkDefinition {\n NetworkID?: string;\n DisplayName?: string;\n Type?: \"EVM\" | \"Solana\"; // controls which signature payload shape you get back\n ChainID?: number; // EVM chain id; 0 for Solana (unused)\n ChainTicker?: string; // e.g. \"MATIC\", \"ETH\", \"SOL\" — used server-side to route RPC calls\n RewardPoolAddress?: string; // EVM: pool contract address; Solana: platform Program ID\n VaultDepositAddress?: string; // Solana-only: vault address for SPL deposits, when used\n ChainConfigVersion?: number; // default 1; controls the withdrawal signature payload format\n RequiredConfirmations?: number; // on-chain confirmations before the backend accepts a deposit; default 12\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n NftDepositsEnabled?: boolean;\n NftWithdrawalsEnabled?: boolean;\n PlatformOverrides?: PlatformBlockchainState;\n NftCollections?: BlockchainNftCollectionBinding[];\n AssetPaths?: Record<string, string>; // icons, chain logos, etc.\n}\n```\n\n`Type` determines which field is populated on withdrawal responses:\n`EvmSignature` for `\"EVM\"` networks, `SolanaSignature` for `\"Solana\"`\nnetworks. Always check `Type` (or just check which signature field is\nnon-null) rather than assuming one shape.\n\n`RequiredConfirmations` is why a `depositToken`/`depositNFT` call can fail\nright after the player submits their on-chain transaction — the backend\nwon't accept it until it has enough confirmations, returning `\"Not enough\nconfirmations (required {RequiredConfirmations}). Try again in a few\nminutes.\"` Ignored for Solana networks (finality is checked via commitment\nlevel instead). Surface \"still confirming, try again shortly\" for that\nspecific message rather than a hard failure.\n\n---\n\n## NFT collection bindings\n\nBinds one on-chain NFT contract/collection to an in-game item catalog, so the\nbackend knows which `ItemCatalogID` a deposited/withdrawn NFT maps to.\n\n```ts\ninterface BlockchainNftCollectionBinding {\n ContractAddress?: string;\n ItemCatalogID?: string; // which item catalog this contract maps to in-game\n DisplayName?: string;\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n}\n```\n\nA network can bind multiple collections (e.g. one contract for weapon NFTs,\nanother for cosmetic NFTs), each independently toggle-able.\n\n---\n\n## Account safety policy\n\n```ts\ninterface BlockchainAccountSafetyPolicy {\n MinAccountAgeDays?: number; // account must be at least this old to withdraw; default 7\n MultiAccountCheckEnabled?: boolean; // default true\n BanOnSharedWithdrawalAddress?: boolean; // default true; see below\n PendingWithdrawalTtlHours?: number; // signature validity window; default 24, min enforced 1\n}\n```\n\nRead-only/informational for the client — the backend enforces these; there's\nnothing to compute. Useful for showing a \"why is withdrawal locked\" message\n(e.g. \"Available after your account is 7 days old\"). Checked only on\nwithdrawal requests and `retryWithdrawal` — deposits are always accepted\nregardless of account age (an account can be auto-flagged from a deposit, but\nnever blocked from making one).\n\n`MultiAccountCheckEnabled` + `BanOnSharedWithdrawalAddress` together mean: if\na player requests a withdrawal to a wallet address that was already used as a\nwithdrawal _or deposit_ destination by a **different** account on this title,\nthe requesting account is **banned immediately** as part of the check (not\njust rejected) — `\"Account banned. Contact support.\"` There's no warning\nstep; a title enabling this should surface it clearly in withdrawal UI\ncopy before the player submits an address.\n\n---\n\n## Withdrawal gate mechanics\n\nThe full ordered set of server-side checks a `requestTokenWithdrawal` /\n`requestNFTWithdrawal` call goes through, with the exact backend formulas.\nThe SKILL.md's [Withdrawal gates](../SKILL.md#withdrawal-gates-what-can-reject-a-request)\nsection lists the corresponding verbatim error strings; this section is the\n\"why\" behind each one.\n\n1. **Global + per-network + per-currency + per-binding enable flags** — all\n of `BlockchainSystemState.WithdrawalsEnabled`,\n `BlockchainNetworkDefinition.WithdrawalsEnabled`,\n `CryptoCurrencyPermissions.WithdrawalsEnabled` (title-wide, all networks),\n and `CryptoNetworkBinding.WithdrawalsEnabled` (this specific\n currency+network pair) must be `true`. Any one `false` rejects the\n request — a title can pause withdrawals for one currency on one network\n (e.g. a drained hot wallet) without touching the others.\n2. **`MinWithdraw`** (`CryptoNetworkBinding.MinWithdraw`, per currency+network)\n — the requested `amount` must be `>= MinWithdraw`. Set with margin above\n `WithdrawFee` by the title so net payouts don't go negative (this SDK's\n flow doesn't apply `WithdrawFee` as a separate deduction anywhere client\n -visible — see the commission note below for what actually reduces the\n payout).\n3. **Balance check** — the player's `InventoryV2.CryptoCurrencies[currencyID]\n.Amount` (tokens) or owned item count (NFTs) must cover the requested\n amount.\n4. **Account safety** — see [above](#account-safety-policy).\n5. **Compliance / KYC** (`CryptoCurrencyDefinition.Limits`, all in\n USD-equivalent of the request, computed as `amountNative * ValueInUSD`):\n - `KycRequiredAboveUsd`: if the request's USD value exceeds this and\n `UserBlockchainState.Kyc.Status !== \"Verified\"`, rejected.\n - `DailyWithdrawUsd`: rejected if `DailyWithdrawnUsd (so far today) +\nthisRequestUsd > DailyWithdrawUsd`. The daily window is a fixed UTC\n calendar day (00:00 UTC), not a rolling 24h window.\n - `MonthlyWithdrawUsd`: same shape, UTC calendar month (00:00 UTC on the\n 1st).\n - Any of the three fields being absent/null on the currency disables that\n specific check.\n6. **Title-wide collective pool cap** — independent of the individual\n player's limits: the title has one `UsersWithdrawable` pool balance per\n (network, currency), fed by the remainder of every deposit after both the\n developer share (`CryptoCurrencyDefinition.DeveloperDepositSharePercent`)\n and the Community Marketing share\n (`CryptoCurrencyDefinition.CommunityMarketingDepositSharePercent`) are\n taken off the top (developer share wins on overflow if the two sum above\n 100%), plus any `donateToUsersPool` donations. A withdrawal request is\n rejected outright if `UsersWithdrawable < requestedAmount` for that pool —\n this is a platform economics limit, not a per-player one, and isn't\n exposed through any client-readable field; you only learn about it from\n the rejection.\n7. **Platform commission + EVM burn** — an operator-wide withdrawal\n commission percentage (0–100, not exposed in `BlockchainDefinitions`) is\n applied to the _gross_ requested amount, and (EVM only) a per-currency\n burn percentage (`CryptoCurrencyDefinition.WithdrawalBurnPercent`) is\n applied on top: `commission = amountNative * (commissionPercent / 100)`,\n `burn = amountNative * (WithdrawalBurnPercent / 100)` (0 on Solana),\n `net = amountNative - commission - burn`. The player is debited the full\n `amountNative` (gross); the signed payload authorizes paying out only\n `net` on-chain, with `burn` sent to the DEAD address by the contract\n itself. If `net <= 0` (commission + burn consume the whole request), the\n withdrawal is rejected before any signature is issued. This is why\n `TokenWithdrawalResponse.NetAmountNative` can be less than `AmountNative`\n — always display `NetAmountNative` as \"you'll receive,\" and\n `BurnAmountNative` if you want to show the burned portion separately. NFT\n withdrawals have no commission/burn step (no `NetAmountNative` /\n `BurnAmountNative` on `NFTWithdrawalResponse`).\n\nNone of steps 5–7 are visible ahead of time as a single client-readable\n\"can withdraw\" flag — the pattern is: attempt the call, branch on the error\nstring.\n\n---\n\n## Player state: UserBlockchainState\n\nReturned by `getUserState()` as `{ State, CryptoBalances }`\n(`UserBlockchainStateResponse`); `State` is cached at\n`client.data.user.state?.Blockchain`, `CryptoBalances` is folded into\n`client.data.user.state?.InventoryV2?.CryptoCurrencies` (same cache\n`client.data.user.getCryptoCurrencyAmount(id)` reads).\n\n```ts\ninterface UserBlockchainState {\n Version?: number;\n Stats?: BlockchainStats;\n LinkedWallets?: Record<string, LinkedWalletInfo>; // key = NetworkID\n PendingWithdrawals?: PendingWithdrawalRef[];\n Kyc?: UserKycState;\n FirstActivityAt?: string; // ISO datetime\n LastActivityAt?: string;\n IsFlagged?: boolean; // account-safety flag (see BlockchainAccountSafetyPolicy)\n FlagReason?: string;\n}\n\ninterface LinkedWalletInfo {\n NetworkID?: string;\n Address?: string;\n LinkedAt?: string;\n LastUsedAt?: string;\n LinkType?:\n \"AutoLinkedFromTransaction\" | \"SignatureVerified\" | \"ManuallyLinked\";\n IsSignatureVerified?: boolean;\n}\n\n/** Light reference only — full transaction data lives in the tx history documents. */\ninterface PendingWithdrawalRef {\n TitleTransactionID?: string;\n Type?: \"Token\" | \"Nft\";\n NetworkID?: string;\n AssetID?: string; // CurrencyID for Token withdrawals, ItemID for NFT withdrawals\n Amount?: string; // decimal string\n CreatedAt?: string;\n ExpiresAt?: string;\n}\n```\n\n`LinkedWallets` is populated automatically the first time a wallet address is\nused in a deposit/withdrawal on a network (`AutoLinkedFromTransaction`) —\nthere's no separate \"link wallet\" call in this module.\n`PendingWithdrawals` is a **light** list (id/type/asset/amount/expiry only)\nfor quickly rendering \"you have N pending withdrawals\" — cross-reference\n`TitleTransactionID` against `getTransactionHistory()` for full details\n(status, hash, fail reason).\n\n---\n\n## KYC state\n\n```ts\ninterface UserKycState {\n Status?: \"NotRequested\" | \"Pending\" | \"Verified\" | \"Rejected\" | \"Expired\";\n Tier?: \"None\" | \"Tier1\" | \"Tier2\" | \"Tier3\";\n VerifiedAt?: string;\n ExpiresAt?: string;\n RejectedAt?: string;\n ProviderReference?: string; // third-party KYC provider's reference id\n RejectionReason?: string;\n}\n```\n\nThis module surfaces KYC status for gating UI (e.g. \"verify your identity to\nwithdraw over $X\") — there's no `startKyc`/`submitKyc` method here; KYC\nverification itself happens through whatever provider integration the title\nuses outside this SDK, and this state just reflects the result.\n\n---\n\n## Compliance counters\n\nPer-currency AML spend windows that back the `\"Daily withdraw limit\nexceeded\"` / `\"Monthly withdraw limit exceeded\"` errors (see\n[Withdrawal gate mechanics](#withdrawal-gate-mechanics)). Not part of\n`UserBlockchainState` — these live alongside the balance, on each entry of\n`CryptoBalances` (the sibling map returned by `getUserState()`, cached into\n`InventoryV2.CryptoCurrencies`, read via `client.data.user\n.getCryptoCurrencyAmount(currencyID)` for the balance itself):\n\n```ts\ninterface UserCryptoComplianceCounters {\n DailyPeriodStartUtc?: string; // start of the current UTC calendar day counted\n DailyWithdrawnUsd?: string; // decimal string — USD spent so far this UTC day\n MonthlyPeriodStartUtc?: string; // start of the current UTC calendar month counted\n MonthlyWithdrawnUsd?: string; // decimal string — USD spent so far this UTC month\n}\n\ninterface UserCryptoCurrencyState {\n Amount: string; // decimal string — available balance\n Frozen: string; // decimal string — reserved by pending withdrawals\n Compliance?: UserCryptoComplianceCounters; // absent if the currency has no configured limits\n CreatedAt?: string;\n UpdatedAt?: string;\n}\n```\n\nThere is no client method to read \"USD spent so far today\" proactively — the\ncounters are internal bookkeeping the backend checks at request time and\nrolls forward automatically once the UTC day/month boundary passes (an\nexpired window resets to the new request's amount, it does not carry over).\nTreat a `\"Daily/Monthly withdraw limit exceeded (...)\"` error message as the\nonly place this data surfaces to the client, and parse the numbers out of the\nerror string if you need to show a friendlier message.\n\n---\n\n## Stats containers\n\n```ts\ninterface BlockchainStats {\n Tokens?: TokenStatsContainer;\n Nfts?: NftStatsContainer;\n}\n\ninterface TokenStatsContainer {\n TotalDeposits?: number;\n TotalWithdrawals?: number;\n FailedWithdrawals?: number;\n RejectedDeposits?: number;\n TotalDepositsVolumeUsd?: string;\n TotalWithdrawalsVolumeUsd?: string;\n PerCurrency?: Record<string, TokenCurrencyStats>; // key = CurrencyID\n}\n\ninterface TokenCurrencyStats {\n CurrencyID?: string;\n Deposits?: number;\n DepositsVolumeNative?: string;\n DepositsVolumeUsd?: string;\n Withdrawals?: number;\n WithdrawalsVolumeNative?: string;\n WithdrawalsVolumeUsd?: string;\n FailedWithdrawals?: number;\n RejectedDeposits?: number;\n FirstDepositAt?: string;\n LastDepositAt?: string;\n FirstWithdrawalAt?: string;\n LastWithdrawalAt?: string;\n}\n\ninterface NftStatsContainer {\n TotalDeposits?: number;\n TotalWithdrawals?: number;\n FailedWithdrawals?: number;\n RejectedDeposits?: number;\n PerCollection?: Record<string, NftCollectionStats>; // key = ItemCatalogID (or composite id)\n}\n\ninterface NftCollectionStats {\n NetworkID?: string;\n ItemCatalogID?: string;\n Deposits?: number;\n Withdrawals?: number;\n FailedWithdrawals?: number;\n RejectedDeposits?: number;\n FirstDepositAt?: string;\n LastDepositAt?: string;\n FirstWithdrawalAt?: string;\n LastWithdrawalAt?: string;\n}\n```\n\nLifetime counters/volumes for a player's own activity — handy for a \"your\non-chain activity\" summary screen. Purely informational; nothing to act on.\n\n---\n\n## Transaction documents\n\nReturned by `getTransactionHistory()` as `{ TokenTransactions, NFTTransactions }`\n(`TransactionHistoryResponse`). These are the full records — richer than the\nlight `PendingWithdrawalRef`. Both arrays are capped to the same `limit`\n(default 50, hard server-side ceiling 200 — values above 200 are silently\nclamped, values `<= 0` fall back to the default of 50); there's no separate\nper-type limit or pagination cursor.\n\n```ts\ninterface TokenTransactionDocument {\n ID?: string;\n TitleID?: string;\n CreatedAt?: string;\n UpdatedAt?: string;\n UserID?: string;\n TransactionHash?: string; // on-chain hash once known\n Nonce?: string;\n NetworkID?: string;\n ChainType?: string;\n ChainID?: number;\n Direction?: \"UsersCryptoWallet\" | \"Game\"; // which way the asset moved\n From?: string;\n To?: string;\n Amount?: string;\n Status?: \"Pending\" | \"Completed\" | \"Failed\" | \"Expired\" | \"Abandoned\";\n SignatureData?: string;\n CompletedAt?: string;\n ExpiresAt?: string;\n FailReason?: string;\n Reason?: string;\n Category?: string; // operation kind (\"game_topup\", \"community_reward\", …); V2-only, absent on V1 txs\n TokenID?: string;\n CurrencyID?: string;\n AmountUsd?: string;\n NetPayoutAmount?: string; // withdrawals only: amount after platform commission\n}\n\ninterface NFTTransactionDocument {\n ID?: string;\n TitleID?: string;\n CreatedAt?: string;\n UpdatedAt?: string;\n UserID?: string;\n TransactionHash?: string;\n Nonce?: string;\n NetworkID?: string;\n ChainType?: string;\n ChainID?: number;\n Direction?: \"UsersCryptoWallet\" | \"Game\";\n From?: string;\n To?: string;\n Amount?: string;\n Status?: \"Pending\" | \"Completed\" | \"Failed\" | \"Expired\" | \"Abandoned\";\n SignatureData?: string;\n CompletedAt?: string;\n ExpiresAt?: string;\n FailReason?: string;\n Reason?: string;\n Category?: string; // operation kind (\"game_topup\", \"community_reward\", …); V2-only, absent on V1 txs\n NFTID?: string;\n ItemID?: string;\n CatalogID?: string;\n SkinID?: string;\n}\n```\n\n`Direction: \"UsersCryptoWallet\"` = a withdrawal (asset moving to the player's\nwallet); `Direction: \"Game\"` = a deposit (asset moving into the game). `Status`\nis the authoritative lifecycle value for a transaction — cross-reference it\nagainst `PendingWithdrawalRef` (state) or the response you got from\n`requestTokenWithdrawal`/`requestNFTWithdrawal` (by `TitleTransactionID` ==\n`ID`) to know exactly where a withdrawal is:\n\n| Status | Meaning |\n| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `Pending` | Requested/signed but not yet confirmed on-chain. |\n| `Completed` | Confirmed on-chain (`confirmWithdrawal` succeeded and chain verified). |\n| `Failed` | Rejected — see `FailReason`. |\n| `Expired` | Modeled in the enum but not currently assigned by this backend path. |\n| `Abandoned` | TTL (`ExpiresAt`) passed without submission — this is the status a lazily-expired pending withdrawal actually lands on, not `Expired`. Can still be closed out by a late `confirmWithdrawal` if the player submitted on-chain before the backend swept it (see the SKILL.md gotcha). |\n\nNote the divergence from what the field name suggests: **`retryWithdrawal`\nonly accepts a transaction currently in `Pending`** — it rejects\n`Failed`/`Abandoned`/`Completed` alike with `\"Transaction is not in Pending\nstate (current: {status}).\"` (see the SKILL.md's\n[Gotchas](../SKILL.md#gotchas) section for the full retry/confirm lifecycle).\nIn practice a TTL-expired withdrawal (now `Abandoned`) is **not** retryable\nthrough `retryWithdrawal` — the only path forward for one is\n`confirmWithdrawal` with a hash, if the player actually submitted the\noriginal signature before it was swept.\n\n---\n\n## Withdrawal signature payloads\n\nExactly one of these is populated on a withdrawal response\n(`TokenWithdrawalResponse`, `NFTWithdrawalResponse`) and on\n`RetryWithdrawalResponse`, depending on the network's `Type`. Hand it to a\nwallet SDK/contract call outside this package — this SDK does not sign or\nbroadcast anything itself.\n\n```ts\n// EVM networks (Type: \"EVM\")\ninterface WithdrawalSignatureResponse {\n TokenAddress?: string;\n WalletAddress?: string;\n Amount?: string; // raw on-chain units (already scaled by decimals) — pass to the contract as-is\n BurnAmount?: string; // raw on-chain units burned by the contract; part of the signed hash for\n // withdrawERC20 — pass verbatim. Null on V1 / burn-disabled currencies.\n TokenId?: string; // NFT token id, when withdrawing an NFT (ERC-1155 id or ERC-721 tokenId)\n Nonce?: string;\n ContractAddress?: string; // the RewardPool contract to call withdrawERC20/ERC1155/ERC721 on\n UserID?: string;\n TitleID?: string; // part of the signed hash — pass on-chain verbatim\n Category?: string; // operation kind (\"game_topup\", …) — part of the signed hash, pass verbatim\n Signature?: string; // signed payload to submit to the withdrawal contract\n}\n\n// Solana networks (Type: \"Solana\")\ninterface SolanaWithdrawalSignature {\n Mint?: string;\n WalletAddress?: string;\n Amount?: string;\n Nonce?: string;\n ProgramID?: string;\n SignatureHex?: string;\n SigIxIndex?: number;\n Ed25519PublicKey?: string;\n Ed25519Message?: string;\n UserID?: string;\n}\n```\n\n---\n\n## Domain delta: BlockchainStateDelta\n\nReconciliation container for state changes NOT expressible via\n`ResourceOperation` — crypto balances are patched with a direct `$inc`\nserver-side rather than going through the shared resource pipeline, and the\npending-withdrawals list is a domain structure, not a grant/consume. It rides\nalong on the mutating responses below (`StateDelta`, optional, `null` on an\nidempotent replay — the client already applied it on the first success):\n\n```ts\ninterface BlockchainStateDelta {\n // Signed per-currency balance deltas applied by this call. Apply as\n // Amount += AmountDelta, Frozen += FrozenDelta. null if no crypto balance\n // changed (e.g. an NFT flow or a donation).\n CryptoBalances?: Record<string, CryptoBalanceChange>; // key = CurrencyID\n // A pending withdrawal added by this call (Request flows). null if none.\n PendingAdded?: PendingWithdrawalRef;\n // TitleTransactionIDs of pending withdrawals removed by this call — an\n // explicit confirm and/or lazily-expired stale ones. null/empty if none.\n PendingRemovedIDs?: string[];\n}\n\ninterface CryptoBalanceChange {\n CurrencyID?: string;\n AmountDelta?: string; // signed decimal string: + deposit, − withdrawal\n FrozenDelta?: string; // signed decimal string; 0 in current flows (withdrawal debits immediately)\n UpdatedAt?: string; // server-recorded UpdatedAt on the currency instance\n}\n```\n\nSee the SKILL.md's\n[StateDelta / Inventory](../SKILL.md#reading-state-and-reacting-to-changes)\nnote for which responses carry it and the current (manual-apply) cache\nbehavior.\n\n---\n\n## Responses\n\nMethod-by-method success shapes (see the main skill's Methods table for which\ncall returns which).\n\n```ts\ninterface BlockchainConfigResponse {\n Blockchain?: BlockchainDefinitions;\n CryptoCurrencies?: Record<string, CryptoCurrencyDefinition>; // see currency-system skill\n}\n\ninterface UserBlockchainStateResponse {\n State?: UserBlockchainState;\n CryptoBalances?: Record<string, UserCryptoCurrencyState>; // { Amount, Frozen, ... }, decimal strings\n}\n\ninterface DepositTokenResponse {\n ServerTimeUtc?: string;\n TransactionHash?: string;\n NetworkID?: string;\n CurrencyID?: string;\n AmountNative?: string; // credited amount, decimal string\n AmountUsd?: string;\n StateDelta?: BlockchainStateDelta; // crypto-balance credit\n}\n\ninterface DepositNFTResponse {\n ServerTimeUtc?: string;\n TransactionHash?: string;\n NetworkID?: string;\n ItemID?: string;\n CatalogID?: string;\n NftTokenID?: string;\n Amount?: number;\n Resources?: ResourceOperation; // see currency-system skill — already applied to cache\n Inventory?: InventoryDelta; // minted NFT's UnstackableItems instance delta; see character-system skill for the shape\n}\n\ninterface TokenWithdrawalResponse {\n ServerTimeUtc?: string;\n TitleTransactionID?: string;\n NetworkID?: string;\n CurrencyID?: string;\n AmountNative?: string; // debited (GROSS, before platform commission + burn)\n NetAmountNative?: string; // paid out on-chain (NET = GROSS − commission − burn)\n BurnAmountNative?: string; // burned on-chain for this withdrawal (0 if disabled or Solana)\n AmountUsd?: string;\n ExpiresAt?: string;\n EvmSignature?: WithdrawalSignatureResponse;\n SolanaSignature?: SolanaWithdrawalSignature;\n StateDelta?: BlockchainStateDelta; // crypto-balance debit, added pending withdrawal, lazy-expired ones\n}\n\ninterface NFTWithdrawalResponse {\n ServerTimeUtc?: string;\n TitleTransactionID?: string;\n NetworkID?: string;\n ItemID?: string;\n CatalogID?: string;\n NftTokenID?: string;\n Amount?: number;\n ExpiresAt?: string;\n Resources?: ResourceOperation; // the consumed item, already applied to cache\n EvmSignature?: WithdrawalSignatureResponse;\n SolanaSignature?: SolanaWithdrawalSignature;\n StateDelta?: BlockchainStateDelta; // added pending withdrawal, lazy-expired ones (item debit is in Resources)\n Inventory?: InventoryDelta; // withdrawn NFT's UnstackableItems instance delta (removed/reduced instances)\n}\n\ninterface TransactionHistoryResponse {\n TokenTransactions?: TokenTransactionDocument[];\n NFTTransactions?: NFTTransactionDocument[];\n}\n\ninterface RetryWithdrawalResponse {\n TitleTransactionID?: string;\n Kind?: \"Token\" | \"Nft\";\n EvmSignature?: WithdrawalSignatureResponse;\n SolanaSignature?: SolanaWithdrawalSignature;\n}\n\ninterface ConfirmWithdrawalResponse {\n TitleTransactionID?: string;\n OnChainTxHash?: string;\n Status?: \"Pending\" | \"Completed\" | \"Failed\" | \"Expired\" | \"Abandoned\";\n StateDelta?: BlockchainStateDelta; // pending withdrawals removed (confirmed + any lazy-expired)\n}\n\ninterface DonationResponse {\n ServerTimeUtc?: string;\n TransactionHash?: string;\n NetworkID?: string;\n CurrencyID?: string;\n AmountNative?: string;\n AmountUsd?: string;\n Target?: string; // \"Developer\" or \"UsersPool\"\n}\n```\n\n---\n\n## Enums\n\n```ts\ntype BlockchainNetworkType = \"EVM\" | \"Solana\";\ntype WalletLinkType =\n \"AutoLinkedFromTransaction\" | \"SignatureVerified\" | \"ManuallyLinked\";\ntype BlockchainTransactionType = \"Token\" | \"Nft\";\ntype KycStatus =\n \"NotRequested\" | \"Pending\" | \"Verified\" | \"Rejected\" | \"Expired\";\ntype KycTier = \"None\" | \"Tier1\" | \"Tier2\" | \"Tier3\";\ntype BlockchainTransactionStatus =\n \"Pending\" | \"Completed\" | \"Failed\" | \"Expired\" | \"Abandoned\";\ntype TransactionDirection = \"UsersCryptoWallet\" | \"Game\";\n```\n\n`WalletLinkType.SignatureVerified` and `ManuallyLinked` are modeled for\nforward compatibility but this module's methods only ever produce\n`AutoLinkedFromTransaction` today — there's no explicit \"link/verify wallet\"\ncall in `BlockchainService`. Treat the other two as reserved for a future\nsignature-based wallet-linking flow.\n"
8
+ "content": "# Blockchain data model — reference\n\nFull shape of the config (`BlockchainDefinitions`), player state\n(`UserBlockchainState`), transaction documents, and the withdrawal signature\npayloads. All of these are **strictly typed in the SDK** — every type below is\nexported from `@idosgames/core`, built with `zod` schemas that keep\n`.passthrough()`, so a field the backend adds later still round-trips. Field\nnames are PascalCase (straight from the backend JSON). Decimal-valued fields\n(`Amount`, balances, USD values) are decimal strings, not JS numbers — use\n`decimal.js` (already a dependency) rather than float math.\n\n## Contents\n\n- [Config: BlockchainDefinitions](#config-blockchaindefinitions) — what `getDefinitions()` returns\n- [BlockchainNetworkDefinition](#blockchainnetworkdefinition)\n- [NFT collection bindings](#nft-collection-bindings)\n- [Account safety policy](#account-safety-policy)\n- [Withdrawal gate mechanics](#withdrawal-gate-mechanics) — every check + formula the backend runs before paying out\n- [Player state: UserBlockchainState](#player-state-userblockchainstate) — what `getUserState()` returns\n- [KYC state](#kyc-state)\n- [Compliance counters](#compliance-counters) — the daily/monthly spend windows behind the limit errors\n- [Stats containers](#stats-containers)\n- [Transaction documents](#transaction-documents)\n- [Withdrawal signature payloads](#withdrawal-signature-payloads)\n- [Domain delta: BlockchainStateDelta](#domain-delta-blockchainstatedelta)\n- [Responses](#responses)\n- [Enums](#enums)\n\n---\n\n## Config: BlockchainDefinitions\n\nReturned by `getDefinitions()` as part of `BlockchainConfigResponse`; the\n`Blockchain` section is cached via\n`client.data.config.getSection<BlockchainDefinitions>(\"Blockchain\")`. The\nsibling `CryptoCurrencies` map (`Record<string, CryptoCurrencyDefinition>`)\nrides along in the same response — see the currency-system skill for that\nshape.\n\n```ts\ninterface BlockchainDefinitions {\n SystemState?: BlockchainSystemState; // title-wide kill switches\n Networks?: Record<string, BlockchainNetworkDefinition>; // key = NetworkID\n AccountSafety?: BlockchainAccountSafetyPolicy;\n}\n\ninterface BlockchainSystemState {\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n NftDepositsEnabled?: boolean;\n NftWithdrawalsEnabled?: boolean;\n PlatformOverrides?: PlatformBlockchainState; // per-platform (Ios/Android/Web) override\n}\n\ninterface PlatformBlockchainState {\n Ios?: boolean;\n Android?: boolean;\n Web?: boolean;\n}\n```\n\n`SystemState` is the title-wide switch; each `BlockchainNetworkDefinition` has\nits own matching flags that layer on top (both must allow an action for it to\nbe permitted — the backend enforces this, but mirror the check in UI to avoid\nshowing a dead button).\n\n---\n\n## BlockchainNetworkDefinition\n\nOne connected chain. Key in `Networks` is the `NetworkID` you pass to every\nservice method (`\"polygon\"`, `\"ethereum\"`, `\"solana\"`, etc. — title-defined\nstrings, not fixed by the SDK).\n\n```ts\ninterface BlockchainNetworkDefinition {\n NetworkID?: string;\n DisplayName?: string;\n Type?: \"EVM\" | \"Solana\"; // controls which signature payload shape you get back\n ChainID?: number; // EVM chain id; 0 for Solana (unused)\n ChainTicker?: string; // e.g. \"MATIC\", \"ETH\", \"SOL\" — used server-side to route RPC calls\n RewardPoolAddress?: string; // EVM: pool contract address; Solana: platform Program ID\n VaultDepositAddress?: string; // Solana-only: vault address for SPL deposits, when used\n ChainConfigVersion?: number; // default 1; controls the withdrawal signature payload format\n RequiredConfirmations?: number; // on-chain confirmations before the backend accepts a deposit; default 12\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n NftDepositsEnabled?: boolean;\n NftWithdrawalsEnabled?: boolean;\n PlatformOverrides?: PlatformBlockchainState;\n NftCollections?: BlockchainNftCollectionBinding[];\n AssetPaths?: Record<string, string>; // icons, chain logos, etc.\n}\n```\n\n`Type` determines which field is populated on withdrawal responses:\n`EvmSignature` for `\"EVM\"` networks, `SolanaSignature` for `\"Solana\"`\nnetworks. Always check `Type` (or just check which signature field is\nnon-null) rather than assuming one shape.\n\n`RequiredConfirmations` is why a `depositToken`/`depositNFT` call can fail\nright after the player submits their on-chain transaction — the backend\nwon't accept it until it has enough confirmations, returning `\"Not enough\nconfirmations (required {RequiredConfirmations}). Try again in a few\nminutes.\"` Ignored for Solana networks (finality is checked via commitment\nlevel instead). Surface \"still confirming, try again shortly\" for that\nspecific message rather than a hard failure.\n\n---\n\n## NFT collection bindings\n\nBinds one on-chain NFT contract/collection to an in-game item catalog, so the\nbackend knows which `ItemCatalogID` a deposited/withdrawn NFT maps to.\n\n```ts\ninterface BlockchainNftCollectionBinding {\n ContractAddress?: string;\n ItemCatalogID?: string; // which item catalog this contract maps to in-game\n DisplayName?: string;\n DepositsEnabled?: boolean;\n WithdrawalsEnabled?: boolean;\n}\n```\n\nA network can bind multiple collections (e.g. one contract for weapon NFTs,\nanother for cosmetic NFTs), each independently toggle-able.\n\n---\n\n## Account safety policy\n\n```ts\ninterface BlockchainAccountSafetyPolicy {\n MinAccountAgeDays?: number; // account must be at least this old to withdraw; default 7\n MultiAccountCheckEnabled?: boolean; // default true\n BanOnSharedWithdrawalAddress?: boolean; // default true; see below\n PendingWithdrawalTtlHours?: number; // signature validity window; default 24, min enforced 1\n}\n```\n\nRead-only/informational for the client — the backend enforces these; there's\nnothing to compute. Useful for showing a \"why is withdrawal locked\" message\n(e.g. \"Available after your account is 7 days old\"). Checked only on\nwithdrawal requests and `retryWithdrawal` — deposits are always accepted\nregardless of account age (an account can be auto-flagged from a deposit, but\nnever blocked from making one).\n\n`MultiAccountCheckEnabled` + `BanOnSharedWithdrawalAddress` together mean: if\na player requests a withdrawal to a wallet address that was already used as a\nwithdrawal _or deposit_ destination by a **different** account on this title,\nthe requesting account is **banned immediately** as part of the check (not\njust rejected) — `\"Account banned. Contact support.\"` There's no warning\nstep; a title enabling this should surface it clearly in withdrawal UI\ncopy before the player submits an address.\n\n---\n\n## Withdrawal gate mechanics\n\nThe full ordered set of server-side checks a `requestTokenWithdrawal` /\n`requestNFTWithdrawal` call goes through, with the exact backend formulas.\nThe SKILL.md's [Withdrawal gates](../SKILL.md#withdrawal-gates-what-can-reject-a-request)\nsection lists the corresponding verbatim error strings; this section is the\n\"why\" behind each one.\n\n1. **Global + per-network + per-currency + per-binding enable flags** — all\n of `BlockchainSystemState.WithdrawalsEnabled`,\n `BlockchainNetworkDefinition.WithdrawalsEnabled`,\n `CryptoCurrencyPermissions.WithdrawalsEnabled` (title-wide, all networks),\n and `CryptoNetworkBinding.WithdrawalsEnabled` (this specific\n currency+network pair) must be `true`. Any one `false` rejects the\n request — a title can pause withdrawals for one currency on one network\n (e.g. a drained hot wallet) without touching the others.\n2. **`MinWithdraw`** (`CryptoNetworkBinding.MinWithdraw`, per currency+network)\n — the requested `amount` must be `>= MinWithdraw`. There is no per-withdrawal\n fee field: what actually reduces the payout is the burn share and the pool\n commission — see the commission note below.\n3. **Balance check** — the player's `InventoryV2.CryptoCurrencies[currencyID]\n.Amount` (tokens) or owned item count (NFTs) must cover the requested\n amount.\n4. **Account safety** — see [above](#account-safety-policy).\n5. **Compliance / KYC** (`CryptoCurrencyDefinition.Limits`, all in\n USD-equivalent of the request, computed as `amountNative * ValueInUSD`):\n - `KycRequiredAboveUsd`: if the request's USD value exceeds this and\n `UserBlockchainState.Kyc.Status !== \"Verified\"`, rejected.\n - `DailyWithdrawUsd`: rejected if `DailyWithdrawnUsd (so far today) +\nthisRequestUsd > DailyWithdrawUsd`. The daily window is a fixed UTC\n calendar day (00:00 UTC), not a rolling 24h window.\n - `MonthlyWithdrawUsd`: same shape, UTC calendar month (00:00 UTC on the\n 1st).\n - Any of the three fields being absent/null on the currency disables that\n specific check.\n6. **Title-wide collective pool cap** — independent of the individual\n player's limits: the title has one `UsersWithdrawable` pool balance per\n (network, currency), fed by the remainder of every deposit after both the\n developer share (`CryptoCurrencyDefinition.DeveloperDepositSharePercent`)\n and the Community Marketing share\n (`CryptoCurrencyDefinition.CommunityMarketingDepositSharePercent`) are\n taken off the top (developer share wins on overflow if the two sum above\n 100%), plus any `donateToUsersPool` donations. A withdrawal request is\n rejected outright if `UsersWithdrawable < requestedAmount` for that pool —\n this is a platform economics limit, not a per-player one, and isn't\n exposed through any client-readable field; you only learn about it from\n the rejection.\n7. **Platform commission + EVM burn** — an operator-wide withdrawal\n commission percentage (0–100, not exposed in `BlockchainDefinitions`) is\n applied to the _gross_ requested amount, and (EVM only) a per-currency\n burn percentage (`CryptoCurrencyDefinition.WithdrawalBurnPercent`) is\n applied on top: `commission = amountNative * (commissionPercent / 100)`,\n `burn = amountNative * (WithdrawalBurnPercent / 100)` (0 on Solana),\n `net = amountNative - commission - burn`. The player is debited the full\n `amountNative` (gross); the signed payload authorizes paying out only\n `net` on-chain, with `burn` sent to the DEAD address by the contract\n itself. If `net <= 0` (commission + burn consume the whole request), the\n withdrawal is rejected before any signature is issued. This is why\n `TokenWithdrawalResponse.NetAmountNative` can be less than `AmountNative`\n — always display `NetAmountNative` as \"you'll receive,\" and\n `BurnAmountNative` if you want to show the burned portion separately. NFT\n withdrawals have no commission/burn step (no `NetAmountNative` /\n `BurnAmountNative` on `NFTWithdrawalResponse`).\n\nNone of steps 5–7 are visible ahead of time as a single client-readable\n\"can withdraw\" flag — the pattern is: attempt the call, branch on the error\nstring.\n\n---\n\n## Player state: UserBlockchainState\n\nReturned by `getUserState()` as `{ State, CryptoBalances }`\n(`UserBlockchainStateResponse`); `State` is cached at\n`client.data.user.state?.Blockchain`, `CryptoBalances` is folded into\n`client.data.user.state?.InventoryV2?.CryptoCurrencies` (same cache\n`client.data.user.getCryptoCurrencyAmount(id)` reads).\n\n```ts\ninterface UserBlockchainState {\n Version?: number;\n Stats?: BlockchainStats;\n LinkedWallets?: Record<string, LinkedWalletInfo>; // key = NetworkID\n PendingWithdrawals?: PendingWithdrawalRef[];\n Kyc?: UserKycState;\n FirstActivityAt?: string; // ISO datetime\n LastActivityAt?: string;\n IsFlagged?: boolean; // account-safety flag (see BlockchainAccountSafetyPolicy)\n FlagReason?: string;\n}\n\ninterface LinkedWalletInfo {\n NetworkID?: string;\n Address?: string;\n LinkedAt?: string;\n LastUsedAt?: string;\n LinkType?:\n \"AutoLinkedFromTransaction\" | \"SignatureVerified\" | \"ManuallyLinked\";\n IsSignatureVerified?: boolean;\n}\n\n/** Light reference only — full transaction data lives in the tx history documents. */\ninterface PendingWithdrawalRef {\n TitleTransactionID?: string;\n Type?: \"Token\" | \"Nft\";\n NetworkID?: string;\n AssetID?: string; // CurrencyID for Token withdrawals, ItemID for NFT withdrawals\n Amount?: string; // decimal string\n CreatedAt?: string;\n ExpiresAt?: string;\n}\n```\n\n`LinkedWallets` is populated automatically the first time a wallet address is\nused in a deposit/withdrawal on a network (`AutoLinkedFromTransaction`) —\nthere's no separate \"link wallet\" call in this module.\n`PendingWithdrawals` is a **light** list (id/type/asset/amount/expiry only)\nfor quickly rendering \"you have N pending withdrawals\" — cross-reference\n`TitleTransactionID` against `getTransactionHistory()` for full details\n(status, hash, fail reason).\n\n---\n\n## KYC state\n\n```ts\ninterface UserKycState {\n Status?: \"NotRequested\" | \"Pending\" | \"Verified\" | \"Rejected\" | \"Expired\";\n Tier?: \"None\" | \"Tier1\" | \"Tier2\" | \"Tier3\";\n VerifiedAt?: string;\n ExpiresAt?: string;\n RejectedAt?: string;\n ProviderReference?: string; // third-party KYC provider's reference id\n RejectionReason?: string;\n}\n```\n\nThis module surfaces KYC status for gating UI (e.g. \"verify your identity to\nwithdraw over $X\") — there's no `startKyc`/`submitKyc` method here; KYC\nverification itself happens through whatever provider integration the title\nuses outside this SDK, and this state just reflects the result.\n\n---\n\n## Compliance counters\n\nPer-currency AML spend windows that back the `\"Daily withdraw limit\nexceeded\"` / `\"Monthly withdraw limit exceeded\"` errors (see\n[Withdrawal gate mechanics](#withdrawal-gate-mechanics)). Not part of\n`UserBlockchainState` — these live alongside the balance, on each entry of\n`CryptoBalances` (the sibling map returned by `getUserState()`, cached into\n`InventoryV2.CryptoCurrencies`, read via `client.data.user\n.getCryptoCurrencyAmount(currencyID)` for the balance itself):\n\n```ts\ninterface UserCryptoComplianceCounters {\n DailyPeriodStartUtc?: string; // start of the current UTC calendar day counted\n DailyWithdrawnUsd?: string; // decimal string — USD spent so far this UTC day\n MonthlyPeriodStartUtc?: string; // start of the current UTC calendar month counted\n MonthlyWithdrawnUsd?: string; // decimal string — USD spent so far this UTC month\n}\n\ninterface UserCryptoCurrencyState {\n Amount: string; // decimal string — available balance\n Frozen: string; // decimal string — reserved by pending withdrawals\n Compliance?: UserCryptoComplianceCounters; // absent if the currency has no configured limits\n CreatedAt?: string;\n UpdatedAt?: string;\n}\n```\n\nThere is no client method to read \"USD spent so far today\" proactively — the\ncounters are internal bookkeeping the backend checks at request time and\nrolls forward automatically once the UTC day/month boundary passes (an\nexpired window resets to the new request's amount, it does not carry over).\nTreat a `\"Daily/Monthly withdraw limit exceeded (...)\"` error message as the\nonly place this data surfaces to the client, and parse the numbers out of the\nerror string if you need to show a friendlier message.\n\n---\n\n## Stats containers\n\n```ts\ninterface BlockchainStats {\n Tokens?: TokenStatsContainer;\n Nfts?: NftStatsContainer;\n}\n\ninterface TokenStatsContainer {\n TotalDeposits?: number;\n TotalWithdrawals?: number;\n FailedWithdrawals?: number;\n RejectedDeposits?: number;\n TotalDepositsVolumeUsd?: string;\n TotalWithdrawalsVolumeUsd?: string;\n PerCurrency?: Record<string, TokenCurrencyStats>; // key = CurrencyID\n}\n\ninterface TokenCurrencyStats {\n CurrencyID?: string;\n Deposits?: number;\n DepositsVolumeNative?: string;\n DepositsVolumeUsd?: string;\n Withdrawals?: number;\n WithdrawalsVolumeNative?: string;\n WithdrawalsVolumeUsd?: string;\n FailedWithdrawals?: number;\n RejectedDeposits?: number;\n FirstDepositAt?: string;\n LastDepositAt?: string;\n FirstWithdrawalAt?: string;\n LastWithdrawalAt?: string;\n}\n\ninterface NftStatsContainer {\n TotalDeposits?: number;\n TotalWithdrawals?: number;\n FailedWithdrawals?: number;\n RejectedDeposits?: number;\n PerCollection?: Record<string, NftCollectionStats>; // key = ItemCatalogID (or composite id)\n}\n\ninterface NftCollectionStats {\n NetworkID?: string;\n ItemCatalogID?: string;\n Deposits?: number;\n Withdrawals?: number;\n FailedWithdrawals?: number;\n RejectedDeposits?: number;\n FirstDepositAt?: string;\n LastDepositAt?: string;\n FirstWithdrawalAt?: string;\n LastWithdrawalAt?: string;\n}\n```\n\nLifetime counters/volumes for a player's own activity — handy for a \"your\non-chain activity\" summary screen. Purely informational; nothing to act on.\n\n---\n\n## Transaction documents\n\nReturned by `getTransactionHistory()` as `{ TokenTransactions, NFTTransactions }`\n(`TransactionHistoryResponse`). These are the full records — richer than the\nlight `PendingWithdrawalRef`. Both arrays are capped to the same `limit`\n(default 50, hard server-side ceiling 200 — values above 200 are silently\nclamped, values `<= 0` fall back to the default of 50); there's no separate\nper-type limit or pagination cursor.\n\n```ts\ninterface TokenTransactionDocument {\n ID?: string;\n TitleID?: string;\n CreatedAt?: string;\n UpdatedAt?: string;\n UserID?: string;\n TransactionHash?: string; // on-chain hash once known\n Nonce?: string;\n NetworkID?: string;\n ChainType?: string;\n ChainID?: number;\n Direction?: \"UsersCryptoWallet\" | \"Game\"; // which way the asset moved\n From?: string;\n To?: string;\n Amount?: string;\n Status?: \"Pending\" | \"Completed\" | \"Failed\" | \"Expired\" | \"Abandoned\";\n SignatureData?: string;\n CompletedAt?: string;\n ExpiresAt?: string;\n FailReason?: string;\n Reason?: string;\n Category?: string; // operation kind (\"game_topup\", \"community_reward\", …); V2-only, absent on V1 txs\n TokenID?: string;\n CurrencyID?: string;\n AmountUsd?: string;\n NetPayoutAmount?: string; // withdrawals only: amount after platform commission\n}\n\ninterface NFTTransactionDocument {\n ID?: string;\n TitleID?: string;\n CreatedAt?: string;\n UpdatedAt?: string;\n UserID?: string;\n TransactionHash?: string;\n Nonce?: string;\n NetworkID?: string;\n ChainType?: string;\n ChainID?: number;\n Direction?: \"UsersCryptoWallet\" | \"Game\";\n From?: string;\n To?: string;\n Amount?: string;\n Status?: \"Pending\" | \"Completed\" | \"Failed\" | \"Expired\" | \"Abandoned\";\n SignatureData?: string;\n CompletedAt?: string;\n ExpiresAt?: string;\n FailReason?: string;\n Reason?: string;\n Category?: string; // operation kind (\"game_topup\", \"community_reward\", …); V2-only, absent on V1 txs\n NFTID?: string;\n ItemID?: string;\n CatalogID?: string;\n SkinID?: string;\n}\n```\n\n`Direction: \"UsersCryptoWallet\"` = a withdrawal (asset moving to the player's\nwallet); `Direction: \"Game\"` = a deposit (asset moving into the game). `Status`\nis the authoritative lifecycle value for a transaction — cross-reference it\nagainst `PendingWithdrawalRef` (state) or the response you got from\n`requestTokenWithdrawal`/`requestNFTWithdrawal` (by `TitleTransactionID` ==\n`ID`) to know exactly where a withdrawal is:\n\n| Status | Meaning |\n| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `Pending` | Requested/signed but not yet confirmed on-chain. |\n| `Completed` | Confirmed on-chain (`confirmWithdrawal` succeeded and chain verified). |\n| `Failed` | Rejected — see `FailReason`. |\n| `Expired` | Modeled in the enum but not currently assigned by this backend path. |\n| `Abandoned` | TTL (`ExpiresAt`) passed without submission — this is the status a lazily-expired pending withdrawal actually lands on, not `Expired`. Can still be closed out by a late `confirmWithdrawal` if the player submitted on-chain before the backend swept it (see the SKILL.md gotcha). |\n\nNote the divergence from what the field name suggests: **`retryWithdrawal`\nonly accepts a transaction currently in `Pending`** — it rejects\n`Failed`/`Abandoned`/`Completed` alike with `\"Transaction is not in Pending\nstate (current: {status}).\"` (see the SKILL.md's\n[Gotchas](../SKILL.md#gotchas) section for the full retry/confirm lifecycle).\nIn practice a TTL-expired withdrawal (now `Abandoned`) is **not** retryable\nthrough `retryWithdrawal` — the only path forward for one is\n`confirmWithdrawal` with a hash, if the player actually submitted the\noriginal signature before it was swept.\n\n---\n\n## Withdrawal signature payloads\n\nExactly one of these is populated on a withdrawal response\n(`TokenWithdrawalResponse`, `NFTWithdrawalResponse`) and on\n`RetryWithdrawalResponse`, depending on the network's `Type`. Hand it to a\nwallet SDK/contract call outside this package — this SDK does not sign or\nbroadcast anything itself.\n\n```ts\n// EVM networks (Type: \"EVM\")\ninterface WithdrawalSignatureResponse {\n TokenAddress?: string;\n WalletAddress?: string;\n Amount?: string; // raw on-chain units (already scaled by decimals) — pass to the contract as-is\n BurnAmount?: string; // raw on-chain units burned by the contract; part of the signed hash for\n // withdrawERC20 — pass verbatim. Null on V1 / burn-disabled currencies.\n TokenId?: string; // NFT token id, when withdrawing an NFT (ERC-1155 id or ERC-721 tokenId)\n Nonce?: string;\n ContractAddress?: string; // the RewardPool contract to call withdrawERC20/ERC1155/ERC721 on\n UserID?: string;\n TitleID?: string; // part of the signed hash — pass on-chain verbatim\n Category?: string; // operation kind (\"game_topup\", …) — part of the signed hash, pass verbatim\n Signature?: string; // signed payload to submit to the withdrawal contract\n}\n\n// Solana networks (Type: \"Solana\")\ninterface SolanaWithdrawalSignature {\n Mint?: string;\n WalletAddress?: string;\n Amount?: string;\n Nonce?: string;\n ProgramID?: string;\n SignatureHex?: string;\n SigIxIndex?: number;\n Ed25519PublicKey?: string;\n Ed25519Message?: string;\n UserID?: string;\n}\n```\n\n---\n\n## Domain delta: BlockchainStateDelta\n\nReconciliation container for state changes NOT expressible via\n`ResourceOperation` — crypto balances are patched with a direct `$inc`\nserver-side rather than going through the shared resource pipeline, and the\npending-withdrawals list is a domain structure, not a grant/consume. It rides\nalong on the mutating responses below (`StateDelta`, optional, `null` on an\nidempotent replay — the client already applied it on the first success):\n\n```ts\ninterface BlockchainStateDelta {\n // Signed per-currency balance deltas applied by this call. Apply as\n // Amount += AmountDelta, Frozen += FrozenDelta. null if no crypto balance\n // changed (e.g. an NFT flow or a donation).\n CryptoBalances?: Record<string, CryptoBalanceChange>; // key = CurrencyID\n // A pending withdrawal added by this call (Request flows). null if none.\n PendingAdded?: PendingWithdrawalRef;\n // TitleTransactionIDs of pending withdrawals removed by this call — an\n // explicit confirm and/or lazily-expired stale ones. null/empty if none.\n PendingRemovedIDs?: string[];\n}\n\ninterface CryptoBalanceChange {\n CurrencyID?: string;\n AmountDelta?: string; // signed decimal string: + deposit, − withdrawal\n FrozenDelta?: string; // signed decimal string; 0 in current flows (withdrawal debits immediately)\n UpdatedAt?: string; // server-recorded UpdatedAt on the currency instance\n}\n```\n\nSee the SKILL.md's\n[StateDelta / Inventory](../SKILL.md#reading-state-and-reacting-to-changes)\nnote for which responses carry it and the current (manual-apply) cache\nbehavior.\n\n---\n\n## Responses\n\nMethod-by-method success shapes (see the main skill's Methods table for which\ncall returns which).\n\n```ts\ninterface BlockchainConfigResponse {\n Blockchain?: BlockchainDefinitions;\n CryptoCurrencies?: Record<string, CryptoCurrencyDefinition>; // see currency-system skill\n}\n\ninterface UserBlockchainStateResponse {\n State?: UserBlockchainState;\n CryptoBalances?: Record<string, UserCryptoCurrencyState>; // { Amount, Frozen, ... }, decimal strings\n}\n\ninterface DepositTokenResponse {\n ServerTimeUtc?: string;\n TransactionHash?: string;\n NetworkID?: string;\n CurrencyID?: string;\n AmountNative?: string; // credited amount, decimal string\n AmountUsd?: string;\n StateDelta?: BlockchainStateDelta; // crypto-balance credit\n}\n\ninterface DepositNFTResponse {\n ServerTimeUtc?: string;\n TransactionHash?: string;\n NetworkID?: string;\n ItemID?: string;\n CatalogID?: string;\n NftTokenID?: string;\n Amount?: number;\n Resources?: ResourceOperation; // see currency-system skill — already applied to cache\n Inventory?: InventoryDelta; // minted NFT's UnstackableItems instance delta; see character-system skill for the shape\n}\n\ninterface TokenWithdrawalResponse {\n ServerTimeUtc?: string;\n TitleTransactionID?: string;\n NetworkID?: string;\n CurrencyID?: string;\n AmountNative?: string; // debited (GROSS, before platform commission + burn)\n NetAmountNative?: string; // paid out on-chain (NET = GROSS − commission − burn)\n BurnAmountNative?: string; // burned on-chain for this withdrawal (0 if disabled or Solana)\n AmountUsd?: string;\n ExpiresAt?: string;\n EvmSignature?: WithdrawalSignatureResponse;\n SolanaSignature?: SolanaWithdrawalSignature;\n StateDelta?: BlockchainStateDelta; // crypto-balance debit, added pending withdrawal, lazy-expired ones\n}\n\ninterface NFTWithdrawalResponse {\n ServerTimeUtc?: string;\n TitleTransactionID?: string;\n NetworkID?: string;\n ItemID?: string;\n CatalogID?: string;\n NftTokenID?: string;\n Amount?: number;\n ExpiresAt?: string;\n Resources?: ResourceOperation; // the consumed item, already applied to cache\n EvmSignature?: WithdrawalSignatureResponse;\n SolanaSignature?: SolanaWithdrawalSignature;\n StateDelta?: BlockchainStateDelta; // added pending withdrawal, lazy-expired ones (item debit is in Resources)\n Inventory?: InventoryDelta; // withdrawn NFT's UnstackableItems instance delta (removed/reduced instances)\n}\n\ninterface TransactionHistoryResponse {\n TokenTransactions?: TokenTransactionDocument[];\n NFTTransactions?: NFTTransactionDocument[];\n}\n\ninterface RetryWithdrawalResponse {\n TitleTransactionID?: string;\n Kind?: \"Token\" | \"Nft\";\n EvmSignature?: WithdrawalSignatureResponse;\n SolanaSignature?: SolanaWithdrawalSignature;\n}\n\ninterface ConfirmWithdrawalResponse {\n TitleTransactionID?: string;\n OnChainTxHash?: string;\n Status?: \"Pending\" | \"Completed\" | \"Failed\" | \"Expired\" | \"Abandoned\";\n StateDelta?: BlockchainStateDelta; // pending withdrawals removed (confirmed + any lazy-expired)\n}\n\ninterface DonationResponse {\n ServerTimeUtc?: string;\n TransactionHash?: string;\n NetworkID?: string;\n CurrencyID?: string;\n AmountNative?: string;\n AmountUsd?: string;\n Target?: string; // \"Developer\" or \"UsersPool\"\n}\n```\n\n---\n\n## Enums\n\n```ts\ntype BlockchainNetworkType = \"EVM\" | \"Solana\";\ntype WalletLinkType =\n \"AutoLinkedFromTransaction\" | \"SignatureVerified\" | \"ManuallyLinked\";\ntype BlockchainTransactionType = \"Token\" | \"Nft\";\ntype KycStatus =\n \"NotRequested\" | \"Pending\" | \"Verified\" | \"Rejected\" | \"Expired\";\ntype KycTier = \"None\" | \"Tier1\" | \"Tier2\" | \"Tier3\";\ntype BlockchainTransactionStatus =\n \"Pending\" | \"Completed\" | \"Failed\" | \"Expired\" | \"Abandoned\";\ntype TransactionDirection = \"UsersCryptoWallet\" | \"Game\";\n```\n\n`WalletLinkType.SignatureVerified` and `ManuallyLinked` are modeled for\nforward compatibility but this module's methods only ever produce\n`AutoLinkedFromTransaction` today — there's no explicit \"link/verify wallet\"\ncall in `BlockchainService`. Treat the other two as reserved for a future\nsignature-based wallet-linking flow.\n"
9
9
  }
10
10
  ]
11
11
  }