@idosgames/mcp 0.1.10 → 0.1.12

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.
@@ -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
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "character-system",
3
3
  "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.",
4
- "content": "---\nname: character-system\ndescription: >-\n Build a character / hero system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.character (CharacterService): load the hero\n roster and title definitions, unlock or purchase characters, upgrade\n character levels/ranks and per-character stats, equip and unequip gear into\n slots, and read the server-authoritative Power score. Use this whenever the\n user is working in the iDosGames TS SDK or its game templates (board-game,\n idle-rpg) and wants character screens, hero rosters, stat/level/rank upgrade\n UIs, equipment or loadout systems, or otherwise touches client.character,\n CharacterService, CharacterModel, CharacterDefinitions, StatLevels, or\n character Power — even if they don't name the module explicitly.\n---\n\n# Character system (iDosGames TS SDK)\n\nThe Character module lets a title ship a roster of heroes that players own, rank\nup, spec into stats, and dress in gear. Everything is **server-authoritative**:\nthe client asks the backend to unlock / upgrade / equip, the backend validates\ncost and rules, and the SDK mirrors the confirmed result into a local cache your\nUI reads. You never mutate character state yourself — you call a method, check\nthe result, and render from the cache.\n\nThis skill is for **using** the production `CharacterService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule (cost,\ngate, lock) — surface the error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\nKeep these straight; every recipe below is just moving between them.\n\n1. **Definitions** (config, same for every player) — the title's catalog of what\n characters _can_ exist: their IDs, unlock rules & prices, upgradable stats,\n level/rank tables, and equipment slots. Fetched with\n `getCharacterDefinitions()`.\n2. **Player characters** (state, per player) — what _this_ player actually has:\n each owned character's `Level`, `StatLevels`, `Equipment`, and `Power`.\n Fetched with `getUserCharacters()`.\n\nA character is identified by a string `CharacterID`. The reserved id `\"Main\"` is\nthe always-available primary hero. Render the roster by walking Definitions and\nlooking up each player character by id.\n\nTwo kinds of progression, don't conflate them:\n\n- **Character Level** (aka rank / stars) — one track per character, upgraded via\n `upgradeCharacterLevel`. Raising it can unlock slots and lift the stat cap.\n- **Stat Levels** — many upgradable stats _per character_ (e.g. `\"Attack\"`,\n `\"AttackSpeed\"`), each with its own level in `StatLevels`, upgraded via\n `upgradeStatLevel`. A stat's max level can depend on the character's rank.\n\n`Power` is a single combat score the backend computes from stats, rank, and\nequipped gear. **Treat it as read-only** — never compute it yourself; read it\nfrom the response or the cached `CharacterModel.Power`.\n\nFor the full field-by-field shape of Definitions and state (stat cost formulas,\nequipment gates, rank multipliers, presets), read\n[references/data-model.md](references/data-model.md). You do **not** need it to\ncall the methods — only to drive richer UI off the config.\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 characters = client.character; // the CharacterService\n```\n\nEvery character method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. \"Character is locked\",\n\"Already at maximum level\", insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |\n| `getCharacterDefinitions()` | Load the title's character catalog (config). | `CharacterDefinitions` |\n| `getUserCharacters()` | Load this player's roster (state). | `{ Characters: Record<string, CharacterModel> }` |\n| `unlockCharacter(characterID, options?)` | Buy/unlock a locked character (charges the selected `Unlock.PriceOptions` option). | `UnlockCharacterResponse` |\n| `upgradeCharacterLevel(characterID, opts?)` | Raise the character's Level/rank (one step, or multi-level via `opts`). | `UpgradeCharacterLevelResponse` (`NewLevel`) |\n| `upgradeStatLevel(characterID, statID, opts?)` | Raise one stat (one step, or multi-level via `opts`). | `UpgradeStatLevelResponse` (`StatLevel`) |\n| `equipItems(characterID, pairs)` | Equip one or more items into slots. | `EquipItemsResponse` (`Equipment`, `ReplacedInstanceIDs`, `Power`, `Inventory`) |\n| `unequipItems(characterID, slotIDs)` | Clear specific slots. | `UnequipItemsResponse` (`ClearedSlotIDs`, `Power`, `Inventory`) |\n| `unequipAllCharacters()` | Strip gear off every character. | `UnequipAllCharactersResponse` (`Characters`, `Inventory`) |\n| `unlockCharactersBatch(characterIDs)` | Unlock many characters in one atomic call. | `BatchResponse<UnlockCharacterResponse>` |\n| `upgradeCharacterLevelsBatch(refs)` | Rank up many characters in one atomic call. | `BatchResponse<UpgradeCharacterLevelResponse>` |\n| `upgradeStatLevelsBatch(refs)` | Upgrade many stats (across characters) in one atomic call. | `BatchResponse<UpgradeStatLevelResponse>` |\n\n`opts` on the two single upgrades is `{ levels?, targetLevel? }`: raise `levels` steps at once (default 1), or pass an absolute `targetLevel` (wins over `levels`, clamped to the max). All levels in the range are charged and applied atomically — all-or-nothing.\n\n`equipItems` takes `EquipSlotPair[]`, each `{ SlotID, ItemInstanceID? , ItemID?,\nCatalogID? }`: give a `SlotID` plus **either** a specific `ItemInstanceID` **or**\nan `ItemID` (optionally `CatalogID`) to let the server auto-pick a matching\ninstance from inventory. In the response, read the equipped `ItemInstanceID`\nfrom `data.Equipment` — for stacked items the server splits off a fresh instance,\nso it can differ from what you sent. `ReplacedInstanceIDs` lists items knocked\nout of those slots (now back in inventory, unequipped).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. Consumed/granted resources\n(currencies, items) ride along in `data.Resources` and are already applied to\nthe cached balances, so read updated balances straight from the cache. For the\n**batch** methods the merged charge is at the wrapper's top-level `data.Resources`\n(per-item `Data.Resources` is null); it is applied once for you.\n\nEquip/unequip return an **`Inventory` delta** (`{ ChangedInstances, RemovedInstanceIDs }`)\nthat reconciles `InventoryV2.UnstackableItems`: equipped instances get their\n`EquippedSlot` set, evicted instances get it cleared, stack-splits add new\ninstances, and fully-consumed packs are removed. The SDK applies the delta to the\ncache for you — it is the authoritative source for unstackable-item changes.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values — that way every\nscreen stays consistent no matter which code path changed things.\n\n```ts\n// Current roster (only present after getUserCharacters()):\nconst roster = client.data.user.state?.Character?.Characters ?? {};\nconst hero = roster[\"Main\"];\nhero?.Level; // rank\nhero?.StatLevels; // { statID: level }\nhero?.Equipment; // { slotID: EquippedItem }\nhero?.Power; // server-computed combat score\n\n// Definitions (cached after getCharacterDefinitions()):\nimport type { CharacterDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `character:definitionsLoaded` → `CharacterDefinitions`\n- `character:userCharactersLoaded` → `Record<string, CharacterModel>`\n- `character:unlocked` → `UnlockCharacterResponse`\n- `character:levelUpgraded` → `UpgradeCharacterLevelResponse`\n- `character:statLevelUpgraded` → `UpgradeStatLevelResponse`\n- `character:itemsEquipped` → `EquipItemsResponse`\n- `character:itemsUnequipped` → `{ characterID, slotIDs, power? }`\n- `character:allUnequipped` → `UnequipAllCharactersResponse`\n- `character:charactersUnlocked` → `BatchResponse<UnlockCharacterResponse>`\n- `character:levelsUpgraded` → `BatchResponse<UpgradeCharacterLevelResponse>`\n- `character:statLevelsUpgraded` → `BatchResponse<UpgradeStatLevelResponse>`\n\nThe coarse `user:characterUpdated` (and `user:anyUpdated`) also fire on any\ncharacter cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"character:levelUpgraded\", (r) => {\n console.log(`${r.CharacterID} is now rank ${r.NewLevel}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show the roster (owned, locked, and default heroes together)\n\n```ts\nawait client.character.getCharacterDefinitions();\nawait client.character.getUserCharacters();\n\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\nconst owned = client.data.user.state?.Character?.Characters ?? {};\n\nfor (const [characterID, def] of Object.entries(defs?.Definitions ?? {})) {\n const mine = owned[characterID];\n const isOwned = !!mine && (mine.Level ?? 0) > 0;\n // def carries Identity/Unlock/etc (see references/data-model.md).\n // Locked & purchasable → show its Unlock.PriceOptions and an Unlock button.\n}\n```\n\n`getUserCharacters()` already **overlays default characters** (`Unlock\n.UnlockedByDefault === true`, e.g. `\"Main\"`) as virtual `Level: 1` entries even\nbefore the player touches them, so the roster is complete. Treat any character\npresent with `Level >= 1` as owned/active; `Level === 0` or absent means not yet\nactivated.\n\n### Unlock a character\n\n```ts\n// Third argument picks the way to pay and carries a store receipt when the option needs one.\nconst res = await client.character.unlockCharacter(\"Knight\");\nif (!res.ok) return showError(res.error); // e.g. \"already unlocked\", can't afford\n// cache now has Knight; balances already debited. UI re-renders from cache.\n```\n\nOnly characters whose config has `Unlock.PriceOptions` are purchasable this way.\nDefault characters reject with \"unlocked by default\"; characters meant to drop\nfrom lootboxes/quests have no options and reject with \"must be granted by other\nsystems\" — for those, grant them through that other feature, not here.\n\nWhen the character has several ways to pay, render them with\n`client.checkout.availableOptions(def.Unlock.PriceOptions)` and pass the chosen one:\n\n```ts\nawait client.character.unlockCharacter(\"Knight\", {\n selectedOptionID: option.OptionID,\n // required only when this option is paid in a store (a `Purchase` entry in its Cost)\n payment: { Store: \"GooglePlay\", Receipt: receiptJson, Signature: signature },\n});\n```\n\n### Upgrade rank, then a stat\n\n```ts\nconst lvl = await client.character.upgradeCharacterLevel(\"Knight\");\nif (!lvl.ok) return showError(lvl.error);\n\nconst stat = await client.character.upgradeStatLevel(\"Knight\", \"Attack\");\nif (!stat.ok) return showError(stat.error);\n// stat.data.StatLevel is the new level.\n```\n\nA stat can hit its cap before you expect: the effective max is the stat's\n`MaxLevel` scaled by the character's current rank. When `upgradeStatLevel`\nreturns \"Already at maximum level\", the fix is `upgradeCharacterLevel` to raise\nthe cap — surface that to the player. Stats can also gate on other stats (a\n`Requirements` list); a \"required stat did not reach the desired level\" error\nmeans level the prerequisite first.\n\nTo move several levels in one call, pass `opts`:\n\n```ts\nawait client.character.upgradeCharacterLevel(\"Knight\", { targetLevel: 5 });\nawait client.character.upgradeStatLevel(\"Knight\", \"Attack\", { levels: 3 });\n```\n\nThis is atomic — either the whole range is charged and applied, or nothing is.\nIf the range runs past the configured cap it stops at the cap (the response\ncarries the level actually reached).\n\n### Equip and unequip\n\n```ts\nconst eq = await client.character.equipItems(\"Knight\", [\n { SlotID: \"Weapon\", ItemInstanceID: \"inst-123\" },\n { SlotID: \"Head\", ItemID: \"iron-helm\" }, // auto-pick an instance\n]);\nif (!eq.ok) return showError(eq.error);\neq.data.Power; // new score\neq.data.ReplacedInstanceIDs; // items bumped back to inventory\neq.data.Inventory; // UnstackableItems delta (already applied to the cache)\n\nconst un = await client.character.unequipItems(\"Knight\", [\"Weapon\"]);\nun.ok && un.data.ClearedSlotIDs; // slots actually cleared (empty ones aren't listed)\nun.ok && un.data.Power; // recomputed score (null if the request was a no-op)\n\nawait client.character.unequipAllCharacters(); // whole-account reset\n```\n\n`unequipItems` reports only the slots it **actually** cleared in `ClearedSlotIDs`\n(already-empty slots are skipped), the recomputed `Power` (null when the request\nwas empty), and an `Inventory` delta. `unequipAllCharacters` returns a\nper-character `Characters` map (`{ ClearedSlotIDs, Power }` each; characters with\nno gear are omitted) plus one `Inventory` delta for the whole sweep. Both apply\neverything to the cache for you.\n\nEquipping is validated on **both sides** and can be rejected for many reasons:\nthe slot isn't allowed on this character, the character's rank/stats don't meet\nthe slot's requirements, the item's rarity/tags/instance-level don't pass the\nslot filter, the item isn't equippable or isn't allowed on this character, the\nitem is already equipped elsewhere, or it has expired. Each is a\n`reason: \"server\"` with a specific `error` string — show it. The item↔slot rule\nmatrix lives in [references/data-model.md](references/data-model.md).\n\n### Batch operations\n\nWhen the player acts on several characters at once (a \"rank up all\", a starter\nbundle that unlocks a squad, a spec preset that bumps many stats), use the batch\nmethods: one atomic backend call, one merged charge, instead of N round-trips.\n\n```ts\nconst res = await client.character.upgradeStatLevelsBatch([\n { CharacterID: \"Knight\", StatID: \"Attack\", Levels: 2 },\n { CharacterID: \"Knight\", StatID: \"Defense\", TargetLevel: 5 },\n { CharacterID: \"Mage\", StatID: \"Attack\" }, // Levels defaults to 1\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data.Items) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"Knight:Attack\"\n else showItemError(item.Id, item.Error); // this one was rejected\n}\n```\n\nEach batch resolves to a **`BatchResponse<T>` wrapper**: `data.Items` is the\nper-item list and `data.Resources` is the one merged charge for the whole batch\n(already applied to the cache). Batch results are **partial-aware**: the outer\n`res.ok` tells you the call ran; each element's `Success`/`Error` tells you\nwhether that item applied. But the resource charge is **all-or-nothing** — if the\nmerged cost can't be paid, every\nincluded item comes back `Success: false`. Items rejected on their own merits\n(already unlocked, unknown id, stat at cap) are filtered out _before_ the charge\nand simply report their reason. `unlockCharactersBatch(ids)` takes a string\narray; the two upgrade batches take `CharacterLevelRef[]` / `CharacterStatRef[]`\nwith the same `Levels`/`TargetLevel` options as the single calls; a ref without\na `CharacterID` targets `\"Main\"`. The server dedupes entries (by id /\n`CharacterID` / `CharacterID`+`StatID`) and processes at most **50 per call** —\nentries past 50 are silently dropped and don't appear in the results at all, so\nchunk larger sets into multiple calls yourself.\n\nOne caveat for stat batches: prerequisite checks use the levels _at the start of\nthe call_, so you can't chain \"raise A to 5, then raise B which requires A@5\" in\na single batch — split dependent steps across calls.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Upgrade\" can\n charge twice. Disable the control while a call is in flight. (Firing the same\n endpoint again within the throttle window, default 600 ms, is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.) The idempotency key only protects transport-level auto-retries\n inside a single call.\n- **Power is authoritative.** Read `CharacterModel.Power` / `response.Power`;\n never derive it. It's an integer combat score used for PvP ranking/matchmaking.\n **Every** mutating call now returns the recomputed `Power` (unlock, both level\n and stat upgrades, equip, unequip, and each batch item) and the SDK writes it to\n the cached character — so `CharacterModel.Power` is always current after a\n successful call. On `unequipItems` `Power` is nullable (null when the request\n was a no-op that never read the DB).\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n- **Lock before you upgrade.** Upgrading stats/levels or equipping on a\n not-yet-owned, non-default character fails with \"locked — unlock it first\".\n- **Equipment truth lives on the item instance.** The per-character `Equipment`\n map is a cache view; the source of truth is each item instance's\n `EquippedSlot`. The SDK keeps both in sync for you — just don't hand-edit.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, stat cost/scaling formulas, rank multipliers, the equip rule matrix, and\nshared stat/level/equipment presets. Read it when building config-driven UI\n(cost previews, upgrade math, slot filters) or when an error message points at a\nconfig rule you need to understand.\n",
4
+ "content": "---\nname: character-system\ndescription: >-\n Build a character / hero system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.character (CharacterService): load the hero\n roster and title definitions, unlock or purchase characters, upgrade\n character levels/ranks and per-character stats, equip and unequip gear into\n slots, and read the server-authoritative Power score. Use this whenever the\n user is working in the iDosGames TS SDK or its game templates (board-game,\n idle-rpg) and wants character screens, hero rosters, stat/level/rank upgrade\n UIs, equipment or loadout systems, or otherwise touches client.character,\n CharacterService, CharacterModel, CharacterDefinitions, StatLevels, or\n character Power — even if they don't name the module explicitly.\n---\n\n# Character system (iDosGames TS SDK)\n\nThe Character module lets a title ship a roster of heroes that players own, rank\nup, spec into stats, and dress in gear. Everything is **server-authoritative**:\nthe client asks the backend to unlock / upgrade / equip, the backend validates\ncost and rules, and the SDK mirrors the confirmed result into a local cache your\nUI reads. You never mutate character state yourself — you call a method, check\nthe result, and render from the cache.\n\nThis skill is for **using** the production `CharacterService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule (cost,\ngate, lock) — surface the error, don't try to reproduce the check client-side.\n\n## The two data shapes\n\nKeep these straight; every recipe below is just moving between them.\n\n1. **Definitions** (config, same for every player) — the title's catalog of what\n characters _can_ exist: their IDs, unlock rules & prices, upgradable stats,\n level/rank tables, and equipment slots. Fetched with\n `getCharacterDefinitions()`.\n2. **Player characters** (state, per player) — what _this_ player actually has:\n each owned character's `Level`, `StatLevels`, `Equipment`, and `Power`.\n Fetched with `getUserCharacters()`.\n\nA character is identified by a string `CharacterID`. The reserved id `\"Main\"` is\nthe always-available primary hero. Render the roster by walking Definitions and\nlooking up each player character by id.\n\nTwo kinds of progression, don't conflate them:\n\n- **Character Level** (aka rank / stars) — one track per character, upgraded via\n `upgradeCharacterLevel`. Raising it can unlock slots and lift the stat cap.\n- **Stat Levels** — many upgradable stats _per character_ (e.g. `\"Attack\"`,\n `\"AttackSpeed\"`), each with its own level in `StatLevels`, upgraded via\n `upgradeStatLevel`. A stat's max level can depend on the character's rank.\n\n`Power` is a single combat score the backend computes from stats, rank, and\nequipped gear. **Treat it as read-only** — never compute it yourself; read it\nfrom the response or the cached `CharacterModel.Power`.\n\nFor the full field-by-field shape of Definitions and state (stat cost formulas,\nequipment gates, rank multipliers, presets), read\n[references/data-model.md](references/data-model.md). You do **not** need it to\ncall the methods — only to drive richer UI off the config.\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 characters = client.character; // the CharacterService\n```\n\nEvery character method requires an authenticated session. Without one they\nreturn `{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args), `\"unauthorized\"`, `\"throttled\"` (fired the same endpoint again\ninside the throttle window), `\"connection\"` (transient, offer Retry),\n`\"validation\"` (response/schema drift), or `\"server\"` (backend rejected it —\n`error` carries the human-readable reason, e.g. \"Character is locked\",\n\"Already at maximum level\", insufficient funds).\n\n| Method | Purpose | `data` on success |\n| ---------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |\n| `getCharacterDefinitions()` | Load the title's character catalog (config). | `CharacterDefinitions` |\n| `getUserCharacters()` | Load this player's roster (state). | `{ Characters: Record<string, CharacterModel> }` |\n| `unlockCharacter(characterID, options?)` | Buy/unlock a locked character (charges the selected `Unlock.PriceOptions` option). | `UnlockCharacterResponse` |\n| `upgradeCharacterLevel(characterID, opts?)` | Raise the character's Level/rank (one step, or multi-level via `opts`). | `UpgradeCharacterLevelResponse` (`NewLevel`) |\n| `upgradeStatLevel(characterID, statID, opts?)` | Raise one stat (one step, or multi-level via `opts`). | `UpgradeStatLevelResponse` (`StatLevel`) |\n| `equipItems(characterID, pairs)` | Equip one or more items into slots. | `EquipItemsResponse` (`Equipment`, `ReplacedInstanceIDs`, `Power`, `Inventory`) |\n| `unequipItems(characterID, slotIDs)` | Clear specific slots. | `UnequipItemsResponse` (`ClearedSlotIDs`, `Power`, `Inventory`) |\n| `unequipAllCharacters()` | Strip gear off every character. | `UnequipAllCharactersResponse` (`Characters`, `Inventory`) |\n| `unlockCharactersBatch(characterIDs)` | Unlock many characters in one atomic call. | `BatchResponse<UnlockCharacterResponse>` |\n| `upgradeCharacterLevelsBatch(refs)` | Rank up many characters in one atomic call. | `BatchResponse<UpgradeCharacterLevelResponse>` |\n| `upgradeStatLevelsBatch(refs)` | Upgrade many stats (across characters) in one atomic call. | `BatchResponse<UpgradeStatLevelResponse>` |\n\n`opts` on the two single upgrades is `{ levels?, targetLevel? }`: raise `levels` steps at once (default 1), or pass an absolute `targetLevel` (wins over `levels`, clamped to the max). All levels in the range are charged and applied atomically — all-or-nothing.\n\n`equipItems` takes `EquipSlotPair[]`, each `{ SlotID, ItemInstanceID? , ItemID?,\nCatalogID? }`: give a `SlotID` plus **either** a specific `ItemInstanceID` **or**\nan `ItemID` (optionally `CatalogID`) to let the server auto-pick a matching\ninstance from inventory. In the response, read the equipped `ItemInstanceID`\nfrom `data.Equipment` — for stacked items the server splits off a fresh instance,\nso it can differ from what you sent. `ReplacedInstanceIDs` lists items knocked\nout of those slots (now back in inventory, unequipped).\n\nOn success, each method also **mirrors the confirmed change into the cache and\nemits an event** — you don't apply anything by hand. Consumed/granted resources\n(currencies, items) ride along in `data.Resources` and are already applied to\nthe cached balances, so read updated balances straight from the cache. For the\n**batch** methods the merged charge is at the wrapper's top-level `data.Resources`\n(per-item `Data.Resources` is null); it is applied once for you.\n\nEquip/unequip return an **`Inventory` delta** (`{ ChangedInstances, RemovedInstanceIDs }`)\nthat reconciles `InventoryV2.UnstackableItems`: equipped instances get their\n`EquippedSlot` set, evicted instances get it cleared, stack-splits add new\ninstances, and fully-consumed packs are removed. The SDK applies the delta to the\ncache for you — it is the authoritative source for unstackable-item changes.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values — that way every\nscreen stays consistent no matter which code path changed things.\n\n```ts\n// Current roster (only present after getUserCharacters()):\nconst roster = client.data.user.state?.Character?.Characters ?? {};\nconst hero = roster[\"Main\"];\nhero?.Level; // rank\nhero?.StatLevels; // { statID: level }\nhero?.Equipment; // { slotID: EquippedItem }\nhero?.Power; // server-computed combat score\n\n// Definitions (cached after getCharacterDefinitions()):\nimport type { CharacterDefinitions } from \"@idosgames/core\";\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\n```\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `character:definitionsLoaded` → `CharacterDefinitions`\n- `character:userCharactersLoaded` → `Record<string, CharacterModel>`\n- `character:unlocked` → `UnlockCharacterResponse`\n- `character:levelUpgraded` → `UpgradeCharacterLevelResponse`\n- `character:statLevelUpgraded` → `UpgradeStatLevelResponse`\n- `character:itemsEquipped` → `EquipItemsResponse`\n- `character:itemsUnequipped` → `{ characterID, slotIDs, power? }`\n- `character:allUnequipped` → `UnequipAllCharactersResponse`\n- `character:charactersUnlocked` → `BatchResponse<UnlockCharacterResponse>`\n- `character:levelsUpgraded` → `BatchResponse<UpgradeCharacterLevelResponse>`\n- `character:statLevelsUpgraded` → `BatchResponse<UpgradeStatLevelResponse>`\n\nThe coarse `user:characterUpdated` (and `user:anyUpdated`) also fire on any\ncharacter cache write — handy for a \"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"character:levelUpgraded\", (r) => {\n console.log(`${r.CharacterID} is now rank ${r.NewLevel}`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Show the roster (owned, locked, and default heroes together)\n\n```ts\nawait client.character.getCharacterDefinitions();\nawait client.character.getUserCharacters();\n\nconst defs = client.data.config.getSection<CharacterDefinitions>(\"Character\");\nconst owned = client.data.user.state?.Character?.Characters ?? {};\n\nfor (const [characterID, def] of Object.entries(defs?.Definitions ?? {})) {\n const mine = owned[characterID];\n const isOwned = !!mine && (mine.Level ?? 0) > 0;\n // def carries Identity/Unlock/etc (see references/data-model.md).\n // Locked & purchasable → show its Unlock.PriceOptions and an Unlock button.\n}\n```\n\n`getUserCharacters()` already **overlays default characters** (`Unlock\n.UnlockedByDefault === true`, e.g. `\"Main\"`) as virtual `Level: 1` entries even\nbefore the player touches them, so the roster is complete. Treat any character\npresent with `Level >= 1` as owned/active; `Level === 0` or absent means not yet\nactivated.\n\n### Unlock a character\n\n```ts\n// Third argument picks the way to pay and carries a store receipt when the option needs one.\nconst res = await client.character.unlockCharacter(\"Knight\");\nif (!res.ok) return showError(res.error); // e.g. \"already unlocked\", can't afford\n// cache now has Knight; balances already debited. UI re-renders from cache.\n```\n\nOnly characters whose config has `Unlock.PriceOptions` are purchasable this way.\nDefault characters reject with \"unlocked by default\"; characters meant to drop\nfrom lootboxes/quests have no options and reject with \"must be granted by other\nsystems\" — for those, grant them through that other feature, not here.\n\nWhen the character has several ways to pay, render them with\n`client.checkout.availableOptions(def.Unlock.PriceOptions)` and pass the chosen one:\n\n```ts\nawait client.character.unlockCharacter(\"Knight\", {\n selectedOptionID: option.OptionID,\n // required only when this option is paid in a store (a `Purchase` entry in its Cost)\n payment: { Store: \"GooglePlay\", Receipt: receiptJson, Signature: signature },\n});\n```\n\n### Upgrade rank, then a stat\n\n```ts\nconst lvl = await client.character.upgradeCharacterLevel(\"Knight\");\nif (!lvl.ok) return showError(lvl.error);\n\nconst stat = await client.character.upgradeStatLevel(\"Knight\", \"Attack\");\nif (!stat.ok) return showError(stat.error);\n// stat.data.StatLevel is the new level.\n```\n\nA stat can hit its cap before you expect: the effective max is the stat's\n`MaxLevel` scaled by the character's current rank. When `upgradeStatLevel`\nreturns \"Already at maximum level\", the fix is `upgradeCharacterLevel` to raise\nthe cap — surface that to the player. Stats can also gate on other stats (a\n`Requirements` list); a \"required stat did not reach the desired level\" error\nmeans level the prerequisite first.\n\nTo move several levels in one call, pass `opts`:\n\n```ts\nawait client.character.upgradeCharacterLevel(\"Knight\", { targetLevel: 5 });\nawait client.character.upgradeStatLevel(\"Knight\", \"Attack\", { levels: 3 });\n```\n\nThis is atomic — either the whole range is charged and applied, or nothing is.\nIf the range runs past the configured cap it stops at the cap (the response\ncarries the level actually reached).\n\n### Equip and unequip\n\n```ts\nconst eq = await client.character.equipItems(\"Knight\", [\n { SlotID: \"Weapon\", ItemInstanceID: \"inst-123\" },\n { SlotID: \"Head\", ItemID: \"iron-helm\" }, // auto-pick an instance\n]);\nif (!eq.ok) return showError(eq.error);\neq.data.Power; // new score\neq.data.ReplacedInstanceIDs; // items bumped back to inventory\neq.data.Inventory; // UnstackableItems delta (already applied to the cache)\n\nconst un = await client.character.unequipItems(\"Knight\", [\"Weapon\"]);\nun.ok && un.data.ClearedSlotIDs; // slots actually cleared (empty ones aren't listed)\nun.ok && un.data.Power; // recomputed score (null if the request was a no-op)\n\nawait client.character.unequipAllCharacters(); // whole-account reset\n```\n\n`unequipItems` reports only the slots it **actually** cleared in `ClearedSlotIDs`\n(already-empty slots are skipped), the recomputed `Power` (null when the request\nwas empty), and an `Inventory` delta. `unequipAllCharacters` returns a\nper-character `Characters` map (`{ ClearedSlotIDs, Power }` each; characters with\nno gear are omitted) plus one `Inventory` delta for the whole sweep. Both apply\neverything to the cache for you.\n\nEquipping is validated on **both sides** and can be rejected for many reasons:\nthe slot isn't allowed on this character, the character's rank/stats don't meet\nthe slot's requirements, the item's rarity/tags/instance-level don't pass the\nslot filter, the item isn't equippable or isn't allowed on this character, the\nitem is already equipped elsewhere, or it has expired. Each is a\n`reason: \"server\"` with a specific `error` string — show it. The item↔slot rule\nmatrix lives in [references/data-model.md](references/data-model.md).\n\n### Skins (alternative looks)\n\nA skin is an **item**. A character's `Skins.Definitions` names which catalog item\n_is_ each skin (non-stackable, no expiration). Owning a skin = owning a copy of\nthat item — so store offers, lootboxes, season tiers, quests and rewards grant\nskins with no extra wiring. Wearing binds that copy to the reserved equipment\nkey `SKIN_SLOT` (`\"@skin\"`); the worn skin is a regular `Equipment[SKIN_SLOT]`\nentry, and its item `Stats` (if any) count toward `Power` exactly like gear.\n\n```ts\nimport { SKIN_SLOT, SKIN_ALREADY_OWNED, isReservedSlot } from \"@idosgames/core\";\n\n// Buy from the character screen (only skins with Sale.PriceOptions are sold here)\nconst buy = await client.character.unlockSkin(\"Knight\", \"golden\", {\n autoEquip: true,\n});\nif (!buy.ok) {\n if (buy.error === SKIN_ALREADY_OWNED) hideBuyButton();\n else showError(buy.error);\n} else if (!buy.data.Equipped) {\n toast(buy.data.EquipError); // bought — but the wear requirements aren't met yet\n}\n\nawait client.character.equipSkin(\"Knight\", \"golden\"); // wear an owned skin\nawait client.character.equipSkin(\"Knight\", \"base\"); // the base look = no skin\nawait client.character.unequipSkin(\"Knight\"); // same, explicitly (idempotent)\n\n// Render: the worn skin (or none), gear slots without the reserved key\nconst worn = knight.Equipment?.[SKIN_SLOT]; // knight: the cached CharacterModel\nconst gear = Object.entries(knight.Equipment ?? {}).filter(\n ([slot]) => !isReservedSlot(slot),\n);\n```\n\n- **Which skin is worn** — match `worn.ItemID` against `Skins.Definitions[*].Item.ItemID`\n (the SkinID is not stored in state, so renaming a skin in config breaks nothing).\n- **Owned?** — `InventoryV2.Items[skin.Item.ItemID].TotalAmount > 0`.\n- **Sale.Schedule / Sale.Gate restrict buying only.** A skin the player owns can\n always be worn; `Requirements` (rank, stats) gate wearing.\n- Empty `Sale.PriceOptions` means **not sold directly** (granted by other systems\n only) — not \"free\", unlike most prices.\n- **What to show in the skin shop** — `client.character.getCharacterSkins(\"Knight\")`\n returns, per skin, `Owned` / `IsWorn` / `OnSale` / `GateOpen` /\n `MeetsRequirements` / `Purchasable` and the prices for this platform. Drive the\n Buy button from `Purchasable`: audience gates and sale windows can't be\n evaluated on the client.\n\n### Batch operations\n\nWhen the player acts on several characters at once (a \"rank up all\", a starter\nbundle that unlocks a squad, a spec preset that bumps many stats), use the batch\nmethods: one atomic backend call, one merged charge, instead of N round-trips.\n\n```ts\nconst res = await client.character.upgradeStatLevelsBatch([\n { CharacterID: \"Knight\", StatID: \"Attack\", Levels: 2 },\n { CharacterID: \"Knight\", StatID: \"Defense\", TargetLevel: 5 },\n { CharacterID: \"Mage\", StatID: \"Attack\" }, // Levels defaults to 1\n]);\nif (!res.ok) return showError(res.error);\nfor (const item of res.data.Items) {\n if (item.Success)\n applyOk(item.Id); // e.g. \"Knight:Attack\"\n else showItemError(item.Id, item.Error); // this one was rejected\n}\n```\n\nEach batch resolves to a **`BatchResponse<T>` wrapper**: `data.Items` is the\nper-item list and `data.Resources` is the one merged charge for the whole batch\n(already applied to the cache). Batch results are **partial-aware**: the outer\n`res.ok` tells you the call ran; each element's `Success`/`Error` tells you\nwhether that item applied. But the resource charge is **all-or-nothing** — if the\nmerged cost can't be paid, every\nincluded item comes back `Success: false`. Items rejected on their own merits\n(already unlocked, unknown id, stat at cap) are filtered out _before_ the charge\nand simply report their reason. `unlockCharactersBatch(ids)` takes a string\narray; the two upgrade batches take `CharacterLevelRef[]` / `CharacterStatRef[]`\nwith the same `Levels`/`TargetLevel` options as the single calls; a ref without\na `CharacterID` targets `\"Main\"`. The server dedupes entries (by id /\n`CharacterID` / `CharacterID`+`StatID`) and processes at most **50 per call** —\nentries past 50 are silently dropped and don't appear in the results at all, so\nchunk larger sets into multiple calls yourself.\n\nOne caveat for stat batches: prerequisite checks use the levels _at the start of\nthe call_, so you can't chain \"raise A to 5, then raise B which requires A@5\" in\na single batch — split dependent steps across calls.\n\n## Gotchas\n\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations — a double-clicked \"Upgrade\" can\n charge twice. Disable the control while a call is in flight. (Firing the same\n endpoint again within the throttle window, default 600 ms, is rejected with\n `reason: \"throttled\"` rather than duplicated, but don't rely on that for\n correctness.) The idempotency key only protects transport-level auto-retries\n inside a single call.\n- **Power is authoritative.** Read `CharacterModel.Power` / `response.Power`;\n never derive it. It's an integer combat score used for PvP ranking/matchmaking.\n **Every** mutating call now returns the recomputed `Power` (unlock, both level\n and stat upgrades, equip, unequip, and each batch item) and the SDK writes it to\n the cached character — so `CharacterModel.Power` is always current after a\n successful call. On `unequipItems` `Power` is nullable (null when the request\n was a no-op that never read the DB).\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n- **Lock before you upgrade.** Upgrading stats/levels or equipping on a\n not-yet-owned, non-default character fails with \"locked — unlock it first\".\n- **Equipment truth lives on the item instance.** The per-character `Equipment`\n map is a cache view; the source of truth is each item instance's\n `EquippedSlot`. The SDK keeps both in sync for you — just don't hand-edit.\n\n## Full reference\n\n[references/data-model.md](references/data-model.md) — every config and state\nfield, stat cost/scaling formulas, rank multipliers, the equip rule matrix, and\nshared stat/level/equipment presets. Read it when building config-driven UI\n(cost previews, upgrade math, slot filters) or when an error message points at a\nconfig rule you need to understand.\n",
5
5
  "references": [
6
6
  {
7
7
  "path": "data-model.md",
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "chat-system",
3
+ "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.",
4
+ "content": "---\nname: chat-system\ndescription: >-\n Build a player chat in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.chat (ChatService): list the title's channels,\n poll every room for new messages with one call, send to a channel, open and\n use 1-on-1 direct conversations, page back through history, mark a room read,\n join or leave opt-in channels, report a message, and keep a personal ignore\n list. Use this whenever the user is working in the iDosGames TS SDK or its\n game templates (board-game, idle-rpg) and wants a world chat, global chat,\n server chat, guild or clan chat, private messages, whispers, DMs, an\n in-game messenger, a chat window or chat bubbles, unread badges for\n conversations, muting or blocking another player, reporting abuse, or\n otherwise touches client.chat, ChatService, ChatDefinitions, ChatChannelView,\n ChatMessageView, ChatCursor, or ChatPollResponse — even if they don't name\n the module explicitly.\n---\n\n# Chat system (iDosGames TS SDK)\n\nThe Chat module runs a title's player chat: **persistent channels** declared in\nthe title config, and **1-on-1 conversations** addressed by the pair of\nparticipants. Everything is server-authoritative — the backend decides which\nchannels a player sees, which room he is placed into, whether he may write, and\nwhat the text ends up being after moderation.\n\nThis skill is for **using** the production `ChatService`, not for porting or\nextending it. A rejected call is the backend enforcing a rule (gate, slow mode,\nmute, blocked word) — surface the message, don't reproduce the check\nclient-side.\n\n## The three things you must understand first\n\n### 1. One poll covers every room — never loop over channels\n\n`client.chat.poll()` returns updates for **all** rooms the player can see:\nchannels and open conversations alike. It also remembers the cursors for you.\n\n```ts\nconst result = await client.chat.poll();\nif (result.ok) {\n for (const room of result.data.Rooms ?? []) {\n render(room.RoomID, room.Messages ?? []);\n }\n}\n```\n\n⚠ **Do not call `poll` per channel.** Every request is billed against the\npublisher's API quota, so a per-channel loop multiplies that bill by the number\nof channels — on every tick, for every player.\n\n### 2. The interval comes from the server, and you poll only while chat is open\n\n`PollIntervalSeconds` arrives with `getChannels()` and with every poll, and the\nserver may change it on the fly.\n\n```ts\nconst channels = await client.chat.getChannels();\nconst seconds = channels.data?.PollIntervalSeconds ?? 5;\n```\n\n⚠ **Stop polling when the chat screen closes.** The unread badge is refreshed by\n`getChannels()` the next time the player opens it, so a closed chat costs\nnothing. A background poll loop is the single easiest way to burn a publisher's\nquota on a feature nobody is looking at.\n\n### 3. Polling never marks anything read\n\nThat is deliberate: it is what keeps the poll a pure read on the server. Call\n`markRead()` when the player is _actually looking_ at a room — not when the app\nmerely fetched it.\n\n```ts\nawait client.chat.markRead(roomID, cursorOfTheLastMessageYouRendered);\n```\n\n⚠ **Pass the cursor when you open a room.** With nothing to fall back on the server marks the\nroom read up to _now_ — including messages that landed while the screen was opening and were\nnever drawn. Those are then gone for good: the next poll resumes from the cursor you just moved\npast them. Load the history page first and mark read up to its newest message.\n\n⚠ **Do not call it on every poll tick.** `markRead` is a WRITE, so a call per tick doubles the\ncost of an open chat, and every request is billed to the publisher. Keep the cursor from the poll\nand flush it when the player leaves the room, closes the chat, or every half-minute or so.\n\n## Channels and rooms are different things\n\nA **channel** is what the publisher configures (`ChannelID`: `\"world\"`,\n`\"trade\"`). A **room** is where _this_ player was placed inside it\n(`RoomID`: `\"world\"`, `\"bylang#ru\"`, `\"world##2\"`).\n\nAlways address messages by `ChannelID` when sending to a channel, and read\n`RoomID` from `getChannels()` for everything that identifies a feed (history,\nmark-read, reports).\n\nRooms exist because a channel can be **partitioned** by language or country and\n**sharded** by capacity. The partition key is computed by the server — the\nclient never chooses it.\n\n## Unread counts are nullable, and `null` is not zero\n\n```ts\n// WRONG — shows \"nothing new\" when the server simply did not count\nconst badge = channel.UnreadCount ?? 0;\n\n// RIGHT\nconst badge =\n channel.UnreadCount === null || channel.UnreadCount === undefined\n ? \"…\" // unknown\n : String(channel.UnreadCount);\n```\n\n`null` means the server's hot cache was cold, not that the room is quiet.\n\n## Sending\n\n```ts\nconst sent = await client.chat.sendMessage(\"world\", text);\nif (sent.ok) appendToFeed(sent.data.Message); // <- the SERVER's copy\n```\n\n⚠ **Render `sent.data.Message.Text`, not the string you typed.** With `Mask`\nmoderation the server returns the message with the offending word starred out;\nshowing the original would put text on screen that nobody else can see.\n\nCommon refusals to surface as-is: slow mode (`CooldownSeconds`), a daily cap,\nthe same text twice in a row, a blocked word, and being muted.\n\n## Direct conversations\n\n```ts\nconst opened = await client.chat.openDirectChannel(otherPlayerId);\nawait client.chat.sendDirect(opened.data.RoomID, \"gg\");\n\nconst list = await client.chat.getConversations(); // most recent first\n```\n\nThe address is derived from the sorted pair of user ids, so both sides get the\nsame `RoomID` and a conversation never doubles. Direct messages are **opt-in per\ntitle** (`Direct.IsEnabled`) and may be restricted to friends\n(`WhoCanMessage: \"Friends\"`).\n\n## Moderation the player controls\n\n```ts\nawait client.chat.ignoreUser(otherPlayerId); // one-sided, invisible to him\nawait client.chat.reportMessage(roomID, messageID, \"Abuse\", \"optional note\");\n```\n\nIgnoring is applied on the **server** at read time — the other player can still\npost, his messages simply stop being delivered here. Never build a client-side\nfilter instead: the messages would still arrive, and the filter is trivially\nbypassed.\n\nA repeat report returns `AlreadyReported: true` **with success**. Show a\nconfirmation, not an error — the player is being persistent, not wrong.\n\n## History\n\n```ts\nlet page = await client.chat.getHistory(roomID);\n// … later, for the next screenful:\npage = await client.chat.getHistory(roomID, page.data.NextBefore);\n```\n\n`NextBefore === null` means there is nothing older. History is bounded by the\nchannel's retention (hours for a world channel, much longer for conversations),\nso an empty page is normal, not an error.\n\n## Channel names are localization keys\n\n`DisplayNameKey` is a **key**, not a label:\n\n```ts\nconst title = client.localization.t(channel.DisplayNameKey ?? \"\");\n```\n\nA literal still renders (resolution falls back to the key itself), but a title\nwritten with literals cannot be translated.\n\n## Events\n\nSubscribe instead of diffing state yourself:\n\n- `chat:messages` — new messages in one room (once per room per poll)\n- `chat:channelsLoaded`, `chat:definitionsLoaded`\n- `chat:sent`, `chat:read`\n\n## What this module does NOT do\n\n- **No push.** Delivery is cursor polling over HTTP; there is no socket.\n- **No clan chat yet.** A `Group` channel resolves its roster from an external\n provider, and none exists in the engine today — such a channel is hidden\n rather than shown empty. When clans ship, the same channel starts working\n with no client change.\n- **Muting a player and resolving reports are publisher actions**, done from the\n dashboard, not from the game.\n",
5
+ "references": []
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "community-marketing-system",
3
+ "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.",
4
+ "content": "---\nname: community-marketing-system\ndescription: >-\n Add a creator reward programme (\"community marketing\") to a game on the\n iDosGames TypeScript SDK (@idosgames/core) via client.communityMarketing\n (CommunityMarketingService): show whether the title runs a programme, send\n the player to the creator portal, read what they earned for posting videos /\n clips / screenshots to YouTube, TikTok or Instagram, show the per-work\n breakdown of that number, and claim approved rewards in game currency or\n crypto. Use this whenever the user is working in the iDosGames TS SDK or its\n game templates and wants creator rewards, an ambassador or influencer\n programme, \"get paid for posting about the game\", UGC bounties, view-based\n payouts, a creator dashboard inside the game, or otherwise touches\n client.communityMarketing, CommunityMarketingService,\n CommunityMarketingState, CommunityEarningsResponse or CommunityClaimResponse\n — even if they don't name the module explicitly.\n---\n\n# Community Marketing (iDosGames TS SDK)\n\nA **creator reward programme**: community members post videos, clips or\nslideshows about the title on social platforms, the platform reads the public\nmetrics of those posts, and the creator gets paid — in the title's currency,\nan item, or crypto — according to rates and milestones the publisher set.\n\n**The game owns almost none of this**, and that is the single most important\nthing to understand before writing any UI.\n\n## What lives where\n\n| Step | Where it happens |\n| ----------------------------------------------------- | ----------------------------- |\n| Joining the programme, submitting a post link | Creator portal on the website |\n| Moderation, metric polling, anti-fraud, accrual maths | Management backend |\n| **Showing state, showing earnings, claiming money** | **The game — this module** |\n\nSo `client.communityMarketing` has exactly three calls. If you find yourself\nwanting a fourth — \"submit a video from the game\", \"approve a submission\" —\nstop: that surface does not exist and must not be recreated. Send the player to\nthe portal instead.\n\n```ts\nconst state = await client.communityMarketing.getState();\nif (isOk(state) && state.data.IsEnabled) {\n // ⚠ OPEN this URL. Never build one from a template.\n openExternal(state.data.CreatorPortalUrl!);\n}\n```\n\n## The three calls\n\n### `getState()`\n\nIs the programme on, is this player a creator, and where to send them.\n\n```ts\nconst res = await client.communityMarketing.getState();\nif (!isOk(res)) return;\n\nconst {\n IsEnabled,\n CreatorPortalUrl,\n ParticipationMode, // \"Open\" | \"Application\"\n IsCreator,\n CreatorStatus, // Pending | Approved | Rejected | Suspended | Left\n VerificationCode,\n Campaigns, // [{ CampaignID, DisplayNameKey, IconPath, IsRunning }]\n} = res.data;\n```\n\n- Answers with a **meaningful empty state**, not an error, for a player who\n never joined — that is the normal case for everyone opening the screen. Do\n not render an error banner for `IsCreator: false`.\n- `DisplayNameKey` / `DescriptionKey` are **localization keys**: wrap them in\n `client.localization.t()`. A title that never set up localization gets the\n literal back, so this is always safe.\n- `VerificationCode` is the creator's proof-of-authorship code — it only ever\n comes back to its owner. Showing it in-game is useful (they need it in the\n post description), but the post itself is submitted in the portal.\n\n### `getEarnings(limit?)`\n\nWhat the player earned, **why**, and the payout history.\n\n```ts\nconst res = await client.communityMarketing.getEarnings();\nif (!isOk(res)) return;\n\nconst { HoldDays, Campaigns, Payouts } = res.data;\n\nfor (const line of Campaigns ?? []) {\n line.Earned; // everything the formula has counted — an ESTIMATE, and it can go DOWN\n line.Mature; // the part past the hold period — still an estimate\n line.Granted; // ⚠ THE PROMISE: what a moderator approved for payout\n line.Paid; // already handed over\n line.Claimable; // what the button will actually pay, in WHOLE units, out of Granted\n line.BySubmission; // per-work breakdown: { [submissionID]: { Earned, Mature, CountedMetrics, ... } }\n}\n```\n\n⚠⚠ **`Earned` is an estimate; `Granted` is the promise.** The formula recomputes\nfrom metric snapshots every time, so `Earned` grows with views and **drops** when\nthe platform scrubs fake activity. It becomes money owed only once a human\napproves it. Showing only `Earned` is how this screen turns into a support\nticket — one day the number goes down with nothing on screen to explain it:\n\n| Number | Means |\n| ----------- | -------------------------------------------------------- |\n| `Earned` | what the current metrics are worth — moves both ways |\n| `Mature` | the part of it older than `HoldDays` — still an estimate |\n| `Granted` | **approved by a moderator; this is what gets paid** |\n| `Paid` | what has already been handed over |\n| `Claimable` | `floor(Granted − Paid)` — what the button pays now |\n\n⚠ **`Mature − Granted > 0` is a normal state, not a delay to hide.** It means\nthe work has matured and is waiting on a human. Say that: otherwise the creator\nsees two numbers that do not add up and no one to ask about it.\n\n`HoldDays` exists so the UI can **say why** maturing takes time: social\nplatforms strip fake activity in the first days, so paying immediately would\nmean paying for it. Show that sentence. A number that quietly refuses to be\nclaimable reads as a bug.\n\n⚠ **`Overpaid > 0` is not an error.** A metric fell after the player was\nalready paid for it. The platform never takes it back — future earnings absorb\nit. Say so, or `Mature < Paid` looks like broken arithmetic.\n\n⚠ **Render `BySubmission`.** The programme pays for someone's work by a formula\nthey never saw. A total with no \"which video, at what rate\" is\nindistinguishable from an arbitrary number.\n\n### `claim(campaignID)`\n\nHands over the **approved** reward in game currency or items. It lands in the\nordinary balance through the same path as any other reward.\n\n⚠ **Crypto campaigns are refused here** with `USE_WITHDRAW_FOR_CRYPTO` — see\n`withdrawCryptoReward` below.\n\n```ts\nconst res = await client.communityMarketing.claim(campaignID);\nif (!isOk(res)) return;\n\nconst { Claimed, CurrencyID, RemainingFraction, IdempotentReplay, Message } =\n res.data;\n```\n\n⚠ **`Claimed: 0` is a SUCCESS.** Payouts are whole units, so a creator holding\n0.4 currency gets zero and keeps the 0.4 in `RemainingFraction`. Show that\nnumber — without it the player concludes their earnings were eaten. `Message`\ncarries the server's reason for the zero.\n\n⚠ **`IdempotentReplay: true` means the money moved on an earlier attempt.** A\nretried claim is safe and never double-pays. Say \"already paid\", not \"paid\nagain\", and do not add the amount to the balance a second time.\n\n### `withdrawCryptoReward(campaignID, walletAddress, amount?)`\n\nCrypto rewards only. Sends the approved amount **out of the programme's pool\nbucket straight to the wallet** — it never passes through the in-game balance.\n\n```ts\nconst res = await client.communityMarketing.withdrawCryptoReward(\n campaignID,\n walletAddress,\n);\nif (!isOk(res)) return;\n\nconst {\n TitleTransactionID,\n ChainID,\n EvmSignature,\n SolanaSignature,\n NetAmountNative,\n} = res.data;\n\n// EVM — with @idosgames/wallet:\n// const hash = await submitEvmTokenWithdrawal(clients, EvmSignature);\n// await client.blockchain.confirmWithdrawal({ TitleTransactionID, TransactionHash: hash });\n\n// Solana — build `withdraw_spl` against the platform_pool program, ed25519 pre-instruction FIRST:\n// Ed25519Program.createInstructionWithPublicKey({ publicKey, message, signature }) // ← see below\n// ...then confirmWithdrawal the same way.\n```\n\n⚠⚠ **On Solana, do NOT re-encode the voucher message.** `SolanaSignature.Ed25519Message` is the\nexact byte string the server signed — hand those bytes to the precompile. The `WithdrawSigMessage`\nlayout is a cross-repo contract between the program, its SDK and two backends, and it has already\ndrifted once (`expires_at` was added, not every copy updated, the decoder silently died). Encoding\nthe instruction ARGUMENTS yourself is fine and unavoidable: the program rebuilds the signed message\nfrom them and compares, so a mistake yields `SigMismatch` — a rejected transaction, never a wrong\npayment.\n\n⚠ Solana specifics that cost real money to rediscover: the ed25519 instruction must be **first**\n(`SigIxIndex` points at it); every split recipient's ATA must exist (create idempotently, in the\nsame order as `Splits`); the token program comes from the **mint's owner** (Token-2022 lives\nelsewhere and the ATA addresses follow it); and browser wallets are safest with a **legacy**\ntransaction — not every adapter supports v0.\n\n⚠⚠ **Why this is a separate call and not a flavour of `claim`.** In-game crypto\nand Community Marketing money are different money with opposite rules. A player's\nin-game crypto balance promises no payout — if the pool is empty they simply\ncannot withdraw, and that is by design. A creator whose work was approved is owed\nthe money regardless. Put both in one balance and you lose both rules at once.\n\n⚠⚠ **The pool is debited and the request exists the moment this resolves**,\nbefore anything reaches the chain — exactly like an ordinary withdrawal. So\n\"it failed, press it again\" charges a **second** time. Keep `TitleTransactionID`\nand finish an interrupted one with `confirmWithdrawal` (if the transaction\nactually landed) or `retryWithdrawal`. Never ask for a fresh voucher.\n\n⚠ **`ChainID` comes from the server, next to the signature — do not derive it.**\n`NetworkID` is a name (`\"bsc\"`); the signature is bound to the number. Sent to\nthe wrong chain the transaction is not misdelivered, it is rejected — after\nspending gas. Solana carries `ProgramID` in the voucher for the same reason.\n\n⚠ **Which chain family a reward pays on is the server's answer, not a guess.** Publishers name\ntheir own networks, so `NetworkID` alone tells you nothing; the creator-facing programme view\ncarries the family explicitly. Guessing here means offering the wrong wallet.\n\n⚠ **`NetAmountNative` is what reaches the wallet**, `AmountNative` is what left\nthe pool. Commission and burn are the difference; show the one you mean.\n\n### `checkJoinEligibility()`\n\nEvaluates the publisher's join requirements against the signed-in player and, if they hold, joins\nthe programme.\n\n```ts\nconst res = await client.communityMarketing.checkJoinEligibility();\nif (!isOk(res)) return;\n\nconst { Passed, Approved, Conditions, WalletMissing } = res.data;\n```\n\n⚠ Only meaningful while the programme's participation mode is `Automatic` and the player has\nalready applied — the application itself is created in the creator portal. In the by-application\nmode the check still reports the breakdown but never admits anyone.\n\n⚠⚠ **It never overrides a human.** A creator the publisher rejected or suspended stays that way\neven with every condition satisfied — `Passed: true, Approved: false` is exactly that case, and the\nUI should say so rather than showing a silent no-op.\n\n⚠ **Render `Conditions`, including on success.** The programme pays for work by rules the person\nnever saw; a bare \"not allowed\" reads as arbitrary, while \"1000 GOLD needed, you have 400\" reads as\na rule they can meet. Showing it on success too tells them what they would lose by dropping below.\n\n⚠ **`Actual: null` means NOT MEASURED, not zero** — the condition was skipped because the outcome\nwas already decided, or the wallet balance could not be read. Rendering it as zero tells the player\nsomething false about their own balance.\n\n## Events\n\n```ts\nclient.on(\"communityMarketing:stateLoaded\", (state) => {});\nclient.on(\"communityMarketing:earningsLoaded\", (earnings) => {});\nclient.on(\"communityMarketing:claimed\", (claim) => {});\nclient.on(\"communityMarketing:withdrawn\", (voucher) => {});\nclient.on(\"communityMarketing:eligibilityChecked\", (verdict) => {});\n```\n\n⚠ `communityMarketing:withdrawn` fires when the **voucher is issued** — the pool\nis already debited, but nothing has reached the chain yet. It means \"payout\nstarted\", not \"money received\".\n\n⚠ `communityMarketing:claimed` fires on **any** successful claim — including\none that paid zero and one that was an idempotent replay. Check `Claimed` and\n`IdempotentReplay` before playing a reward animation.\n\n## A minimal screen\n\n```tsx\nconst [state, setState] = useState<CommunityMarketingState | null>(null);\nconst [earnings, setEarnings] = useState<CommunityEarningsResponse | null>(\n null,\n);\n\nuseEffect(() => {\n void (async () => {\n const s = await client.communityMarketing.getState();\n if (!isOk(s) || !s.data.IsEnabled) return;\n setState(s.data);\n\n // Earnings only make sense for someone actually in the programme.\n if (s.data.IsCreator) {\n const e = await client.communityMarketing.getEarnings();\n if (isOk(e)) setEarnings(e.data);\n }\n })();\n}, []);\n```\n\n- Not enabled → render nothing. A programme the publisher never set up should\n not leave a dead menu entry.\n- Enabled, not a creator → one button: open `CreatorPortalUrl`.\n- Creator → the four numbers (`Earned` / `Mature` / `Granted` / `Paid`), the\n breakdown, and per campaign either a Claim button (game currency) or a\n Withdraw-to-wallet button (crypto).\n\n## Do not\n\n- **Do not poll.** Metrics are sampled by a backend job on a slow schedule\n (hours, not seconds). Refresh on screen open and after a claim; a polling\n loop burns the publisher's API quota to re-read a number that did not move.\n- **Do not build `CreatorPortalUrl`.** It is a server contract; a locally\n assembled address silently 404s the day the route changes.\n- **Do not treat a rejected claim as a client bug.** Fund exhausted, hold\n period, per-creator cap, suspended participation — all of these are the\n backend enforcing the publisher's rules. Surface the message.\n- **Do not implement submission, moderation or metric reading.** They belong to\n the portal and the management backend, and a second implementation would be a\n second place able to create value.\n",
5
+ "references": []
6
+ }