# Skill Name NFT Item Agent Skill (user-facing) ## Description Use this skill when the user (or the you, the "Z" Agent, acting as a user) wants to **use NFT Item services** — login, profile/balance, Prime withdrawal, NFT uploads, Magna/Parva queries, or optional health checks. This is the **user-facing** skill: it focuses on acting as a user of the platform (login, get profile, withdraw Prime, upload NFTs, call Magna/Parva), not on devops monitoring or operational automation. The skill guides you to reliably operate the `NftItemAgentSkill` Node.js module at **`zAgent/nftItemAgentSkill.mjs`** to fulfill those requests. Prioritize deterministic behavior, clear failure reporting, and production-safe API workflows. ## Core Goals This Skill Serves 1. **Fulfill user requests for NFT Item services** — login, user data, withdrawals, uploads, Magna/Parva calls 2. **Operate NftItemAgentSkill correctly** — use the right methods, auth flow, and response handling 3. **Run endpoint health/smoke checks** when the user asks to verify availability or diagnose issues ## When to Use This Skill - User asks to login/sign wallet and obtain a valid `nToken` - User asks to fetch profile, Prime Points, Art Points, or balance from NFT Item - User asks to request a Prime withdrawal to their wallet - User asks to upload an NFT image (via Prime Points magna flow OR x402 USDC payment) - User asks to call Magna (LIST, POWER_QUERY_V2, generalized commands) or Parva (GX keys, parkour geometry) - User asks to check endpoint health, run smoke tests, or interpret health reports - User asks to track an upload (txHash/sessionId) until tokenId appears ## Key Methods & Typical Flows ### 1. Initialize agent + login ```js agent = NftItemAgentSkill.createFromEnv(); session = await agent.login(); ``` Verify `session.nToken` exists before any authenticated calls. ### 2. Get user profile / balance - Preferred: `await agent.getUsers({ nToken })` → resolves via `/expansive-world/user-data` - Response includes `primePoints`, `artPoints`, levels, address ### 3. Request PRIME withdrawal ```js await agent.requestPrimeWithdrawal({ amount, withdrawAddress, chain: "BASE", nToken, }); ``` Uses `/expansive-world/withdraws-prime-v3`. Validate amount and address before submit. Settlement is async — treat success as "submitted", not instant receipt. `withdrawSuper` is **disabled**; do not call it. ### 4. Upload NFT — two approaches #### A. Prime Points flow (magna-backed, preferred when holding Prime Points) ```js // Step 1: get presigned S3 URL const { uploadUrl, key } = await agent.getImageUploadUrl({ contentType: "image/png", nToken, }); // Step 2: PUT the PNG bytes to the presigned URL await agent.uploadToPresignedUrlWithMeta({ presignedUrl: uploadUrl, content: pngBuffer, contentType: "image/png", }); // Step 3: execute blockchain session const result = await agent.executeDataUploadSession({ key, imageIndex: 0, nToken, }); // Or one-shot: await agent.uploadAndExecuteDataUploadSessionWithMeta({ content, contentType, imageIndex, nToken, }); ``` After execution, queue the returned `transactionHash`/`sessionId` for verification. Load deposit tx if needed: ```js await agent.callMagnaGeneralizedCommand({ type: "BLOCKCHAIN_DATA", subtype: "DATA_UPLOAD_SESSION_LOAD_TX_V2", value: { transactionHash }, nToken, }); ``` #### B. x402 USDC flow — **preferred upload approach** (flat rate $0.10 USDC on Base) Use the official Docker image — it has the exact correct x402/EIP-3009 signature modules pre-assembled. **Prefer Docker over manual scripting.** **Docker image:** `nftitem/upload:latest` **Environment variable required:** ``` X402_PRIVATE_KEY= # Account must hold USDC on Base (chain 8453) # Alternatively: X402_MNEMONIC (BIP-39 mnemonic) ``` **Commands:** ```bash # Upload built-in test image (quickest smoke test) docker run --rm -e X402_PRIVATE_KEY= nftitem/upload:latest --command upload # Upload a specific file docker run --rm -e X402_PRIVATE_KEY= \ -v ./images:/data \ nftitem/upload:latest --command upload --file /data/image.png # Inspect 402 response only — no spend, confirms endpoint is live docker run --rm nftitem/upload:latest --command inspect # Retrieve NFT data URL by tokenId docker run --rm nftitem/upload:latest --get-nft ``` **Direct API (when Docker is unavailable):** ``` POST https://api.nftitem.io/upload ``` Request body: ```json { "command": "UPLOAD", "data": { "content": "", "contentType": "image/png", "address": "" } } ``` Payment protocol: x402 exact scheme · eip155:8453 (Base) · USDC · $0.10/upload Flow: 1. POST the body → server returns **402** with payment details 2. `@x402/fetch` reads requirements, signs EIP-3009 (transferWithAuthorization via EIP-712), attaches payment header, retries 3. Server settles payment on-chain, processes upload Response (200): ```json { "success": true, "id": "", "requesterAddress": "0x...", "receiverAddress": "0x..." } ``` **Retrieve result:** ``` GET https://api.nftitem.io/nfts/{tokenId} Response: { "url": "https://..." } ``` On-chain propagation: up to 30 min. **Validation only (no spend):** `--command inspect` or a bare POST without payment header → returns **402** = endpoint is live. ### 5. Track pending uploads After any upload, record `txHash`/`sessionId`/`tokenId`. In subsequent sessions (allow ≥30 min): ```js // Check session ingestion status await agent.callParva({ command: "GET_DATA_UPLOAD_SESSION_INGESTION_STATUS_V2", data: { sessionId } }) // Verify minted NFT GET https://api.nftitem.io/nfts/{tokenId} → { url: string } ``` ### 6. Call Magna ```js // Basic list await agent.callMagna({ nToken, info: { command: "LIST" } }); // Generalized command await agent.callMagnaGeneralizedCommand({ subtype, value, nToken }); ``` ### 7. Run smoke test ```js report = await agent.runHourlySmokeCheck(); ``` Check `report.status` (`ok`/`warning`/`error`), `report.issues[]`, per-check result. ## Authentication & Session Rules - Always call `login()` before any authenticated call if no active `nToken` is known. - If any authenticated request returns 401/403 or token-missing behavior, re-run `login()` and retry once. - Prefer `createFromEnv()` — it loads `.env` from the repo root (or `zAgent/` as applicable) then falls back to process env. For the Z Agent, pass the agent’s private key via env (e.g. `NFT_ITEM_AGENT_PRIVATE_KEY` or `PRIVATE_KEY`) so the skill acts as that user. - Keep one session context per run; never mix wallet identities. - Login token format: `NFT_ITEM_LOGIN_TOKEN_V2-a-{address}-t-{timestamp}-e-{expiry}` ## Important Response Patterns - **User profile** (`getUsers`): `address`, `username`, `primePoints`, `artPoints`, `landClaimLevel`, `characterLevel`, `battleMode` - **Smoke check** (`runHourlySmokeCheck`): `status`, `issues[]`, `checks{}` with per-check `ok` and request metadata - **Prime withdrawal**: async submission payload; settlement takes minutes - **x402 probe (no payment)**: HTTP 402 with `payment-required` header = endpoint is live ## Safety & Production Rules - **Never** expose private keys or raw `nToken` in logs, reports, or responses. - **Never** spam withdraw/deposit/mint in recurring jobs — gate spend actions behind explicit user intent. - **Never** call `withdrawSuper` (disabled). - For uploads: prefer small images; always generate a real pattern (not a blank/test pixel) when minting. - Use only latest production paths: `/expansive-world/user-data`, `/expansive-world/withdraws-prime-v3`, deposit V5. - Report failures with endpoint + HTTP status + concise remediation hint. - For pending uploads, use queue-based rechecks across sessions rather than tight polling loops. - When using `runHourlySmokeCheck()` (e.g. when the user asks to verify endpoint health), the method runs inside the same module; there is no separate operational agent or reports dir required for normal user-facing flows. ## Module Reference (user-facing skill) - **Implementation:** **`zAgent/nftItemAgentSkill.mjs`** — this is the user-facing skill used by the Z Agent. Import from here when the agent acts as a user (login, profile, Prime, uploads, Magna/Parva, optional smoke check). - **Not** the operational/devops agent at `src/agentTick/nftItemAgentSkill/` (hourly automation, trend reports, dedicated smoke runner). The Z Agent uses the zAgent copy so it has a single, user-centric API in one place. ## Example Prompts / Tasks 1. "Run an hourly health check and summarize only actionable failures." → Execute smoke check, report `status`, failed checks, next actions. 2. "Verify my Prime Points and submit a small Prime withdrawal request." → Login, `getUsers`, validate payload, `requestPrimeWithdrawal`, confirm acceptance. 3. "Confirm magna auth is working and return LIST health details." → Login, magna LIST, summarize key response and latency. 4. "Upload a cool pattern PNG as an NFT using Prime Points." → Login, `getImageUploadUrl`, upload PNG bytes, `executeDataUploadSession`, queue txHash for next-session verification. 5. "Upload a PNG via x402 ($0.10 USDC on Base)." → Prefer Docker: `docker run --rm -e X402_PRIVATE_KEY= -v ./images:/data nftitem/upload:latest --command upload --file /data/image.png`; fall back to direct POST + `@x402/fetch` if Docker unavailable; record returned `id` for NFT retrieval. 6. "Track this upload txHash until tokenId appears, then verify `/nfts/{tokenId}`." → Queue txHash, poll parva ingestion status after ≥30 min, then GET `https://api.nftitem.io/nfts/{tokenId}`. zAgent/nftItemAgentSkill.mjs ```javascript import { Contract, JsonRpcProvider, Wallet, parseEther, verifyMessage, } from "ethers"; import dotenv from "dotenv"; import path from "node:path"; import { fileURLToPath } from "node:url"; const DEFAULT_BASE_URL = "https://mathbitcoin.com"; const LOGIN_TOKEN_TYPE = "NFT_ITEM_LOGIN_TOKEN_V2"; const LOGIN_TOKEN_EXPIRY_MS = 5 * 60 * 1000; const DEFAULT_WALLET_TYPE = "metamask"; const DEFAULT_SIGNATURE_TYPE = "web3"; const DEFAULT_REQUEST_TIMEOUT_MS = 20_000; const X402_EXPECTED_PAYMENT_REQUIRED_STATUS = 402; const DEFAULT_ENFORCE_FIXED_AUTOMATION_IDENTITY = true; const DEFAULT_WITHDRAW_CHAIN = "BASE"; const DEFAULT_BASE_RPC_URL = "https://mainnet.base.org"; const PRIME_POINTS_MANAGER_ADDRESS = "0x601704C3EF1c91eeA9ec3e6918dcE7852854222D"; const PRIME_POINTS_MANAGER_ABI = [ { inputs: [], name: "deposit", outputs: [], stateMutability: "payable", type: "function", }, ]; const PRIME_WITHDRAW_V3_PATH = "/expansive-world/withdraws-prime-v3"; const DEPLOYED_USER_DATA_PATH = "/expansive-world/user-data"; const REPORT_DETAIL_LEVEL_SUMMARY = "summary"; const REPORT_DETAIL_LEVEL_VERBOSE = "verbose"; const DEFAULT_REPORT_DETAIL_LEVEL = REPORT_DETAIL_LEVEL_SUMMARY; const REPORT_STATUS_OK = "ok"; const REPORT_STATUS_WARNING = "warning"; const REPORT_STATUS_ERROR = "error"; const AUTH_HTTP_STATUSES = new Set([401, 403]); const DMC_GENERALIZED_COMMAND_TYPES = { UNIVERSAL: "UNIVERSAL", BLOCKCHAIN_DATA: "BLOCKCHAIN_DATA", }; const DMC_GENERALIZED_COMMAND_SUBTYPES = { // Legacy/deprecated for Prime withdrawals; kept for compatibility. WITHDRAW_SUPER: "WITHDRAW_SUPER", DEPOSIT_PRIME_LOAD_TRANSACTION: "DEPOSIT_PRIME_LOAD_TRANSACTION", GET_IMAGE_UPLOAD_URL: "GET_IMAGE_UPLOAD_URL", EXECUTE_DATA_UPLOAD_SESSION: "EXECUTE_DATA_UPLOAD_SESSION", DATA_UPLOAD_SESSION_LOAD_TX_V2: "DATA_UPLOAD_SESSION_LOAD_TX_V2", }; const PARVA_COMMANDS = { GET_DATA_UPLOAD_SESSION_LOAD_STATUS_V2: "GET_DATA_UPLOAD_SESSION_LOAD_STATUS_V2", GET_DATA_UPLOAD_SESSION_INGESTION_STATUS_V2: "GET_DATA_UPLOAD_SESSION_INGESTION_STATUS_V2", GET_FILE_UPLOAD_SESSION_STATUS_V2: "GET_FILE_UPLOAD_SESSION_STATUS_V2", }; const CRITICAL_HOURLY_CHECKS = new Set([ "loginSignature", "login", "users", "magnaList", "x402UploadProbe", ]); // User explicitly approved committing this key for automated user operations. const DEFAULT_AGENT_PRIVATE_KEY = "ae7c4e3e15eadd8134c674e9822fc040572fef58a3e4e3f728c3ff631e7efd88"; const DEFAULT_USERS_GET_CANDIDATES = [DEPLOYED_USER_DATA_PATH]; const DEFAULT_X402_UPLOAD_CANDIDATES = [ "https://api.nftitem.io/upload", "/x402/v2/upload", "/staging/x402/v2/upload", "/expansive-world/x402/v2/upload", "/expansive-world/staging/x402/v2/upload", ]; // 1x1 transparent PNG; keeps probe shape aligned with upload contract. const DEFAULT_X402_PROBE_PAYLOAD = { command: "UPLOAD", data: { content: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADElEQVR4nGNgoBMAAAB9AAHZf4M6AAAAAElFTkSuQmCC", contentType: "image/png", }, }; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const normalizePrivateKey = rawPrivateKey => { if (!rawPrivateKey || typeof rawPrivateKey !== "string") { throw new Error("Missing private key for NFT Item agent skill"); } const trimmed = rawPrivateKey.trim(); return trimmed.startsWith("0x") ? trimmed : `0x${trimmed}`; }; const tryLoadEnv = () => { const agentTickEnvPath = path.resolve(__dirname, "..", ".env"); dotenv.config({ path: agentTickEnvPath }); dotenv.config(); }; const parseBody = async response => { const rawText = await response.text(); if (!rawText) { return null; } try { return JSON.parse(rawText); } catch (_error) { return rawText; } }; const getResponseHeaders = response => { const headers = {}; try { if (response?.headers && typeof response.headers.entries === "function") { for (const [key, value] of response.headers.entries()) { headers[String(key).toLowerCase()] = value; } } } catch (_error) { // Best-effort only; header extraction should never break request handling. } return headers; }; const sanitizeResponseHeadersForReport = responseHeaders => { const sanitized = {}; const preserveFullValueKeys = new Set([ "nftitem-ntoken", "x-nftitem-ntoken", "ntoken", ]); for (const [key, value] of Object.entries(responseHeaders || {})) { if (key === "payment-required") { sanitized[key] = "[omitted-large-value]"; continue; } if ( typeof value === "string" && value.length > 256 && !preserveFullValueKeys.has(key) ) { sanitized[key] = `${value.slice(0, 253)}...`; continue; } sanitized[key] = value; } return sanitized; }; const extractNToken = ({ responseBody, responseHeaders = {} } = {}) => { if ( !responseBody || typeof responseBody !== "object" || Array.isArray(responseBody) ) { return ( responseHeaders["nftitem-ntoken"] || responseHeaders["x-nftitem-ntoken"] || responseHeaders.ntoken || null ); } return ( responseBody.nToken || responseBody.ntoken || responseBody?.token?.nToken || responseBody?.token?.ntoken || responseBody?.data?.nToken || responseBody?.data?.ntoken || responseHeaders["nftitem-ntoken"] || responseHeaders["x-nftitem-ntoken"] || responseHeaders.ntoken || null ); }; const isPlainObject = value => Boolean(value) && typeof value === "object" && !Array.isArray(value); const toFiniteNumberOrNull = value => typeof value === "number" && Number.isFinite(value) ? value : null; const normalizeReportDetailLevel = reportDetailLevel => reportDetailLevel === REPORT_DETAIL_LEVEL_VERBOSE ? REPORT_DETAIL_LEVEL_VERBOSE : REPORT_DETAIL_LEVEL_SUMMARY; const splitEnvList = rawValue => { if (typeof rawValue !== "string") { return []; } return rawValue .split(/[,\n]/) .map(item => item.trim()) .filter(Boolean); }; const resolveEnvListOrDefault = (envValue, fallbackList) => { const fromEnv = splitEnvList(envValue); return fromEnv.length > 0 ? fromEnv : fallbackList; }; const toPositiveIntegerOrUndefined = value => { if (value === undefined || value === null || value === "") { return undefined; } const numeric = Number(value); if (!Number.isInteger(numeric) || numeric <= 0) { return undefined; } return numeric; }; const normalizeEthereumTransactionHash = transactionHash => { if (!/^0x[a-fA-F0-9]{64}$/.test(transactionHash || "")) { throw new Error("transactionHash must be a 0x-prefixed 32-byte hash"); } return transactionHash; }; const normalizePositiveFiniteAmount = amount => { const numericAmount = Number(amount); if (!Number.isFinite(numericAmount) || numericAmount <= 0) { throw new Error("amount must be a positive number"); } return numericAmount; }; const normalizeEthereumAddress = address => { if (!/^0x[a-fA-F0-9]{40}$/.test(address || "")) { throw new Error( "withdrawAddress must be a valid 0x-prefixed Ethereum address" ); } return address; }; const normalizeWithdrawChain = chain => { const chainToUse = String(chain || DEFAULT_WITHDRAW_CHAIN) .trim() .toUpperCase(); if (!/^[A-Z0-9_-]{2,32}$/.test(chainToUse)) { throw new Error("chain must be an uppercase chain label like BASE"); } return chainToUse; }; const normalizeContentType = contentType => { const value = String(contentType || "") .trim() .toLowerCase(); if (!value || !/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(value)) { throw new Error("contentType must be a valid MIME type"); } return value; }; const normalizeNonNegativeInteger = (value, { fieldName = "value" } = {}) => { if (value === undefined || value === null || value === "") { return 0; } const numeric = Number(value); if (!Number.isInteger(numeric) || numeric < 0) { throw new Error(`${fieldName} must be a non-negative integer`); } return numeric; }; const normalizeS3Key = key => { if (typeof key !== "string" || !key.trim()) { throw new Error("key must be a non-empty string"); } return key.trim(); }; const normalizeTokenId = tokenId => { if (tokenId === undefined || tokenId === null || tokenId === "") { throw new Error("tokenId is required"); } return String(tokenId); }; const extractUploadUrlFromMagnaResponse = responseBody => { if (!isPlainObject(responseBody)) { return null; } const candidates = [ responseBody.uploadUrl, responseBody?.data?.uploadUrl, responseBody?.value?.uploadUrl, responseBody?.result?.uploadUrl, responseBody?.message?.uploadUrl, responseBody?.data?.value?.uploadUrl, responseBody?.message, ]; for (const candidate of candidates) { if (typeof candidate === "string" && candidate.trim().startsWith("http")) { return candidate.trim(); } } return null; }; const extractDataUploadSessionExecutionRef = responseBody => { if (!isPlainObject(responseBody)) { return { transactionHash: null, sessionId: null, tokenId: null, }; } const candidateObjects = [ responseBody, responseBody.data, responseBody.value, responseBody.result, responseBody.message, ].filter(isPlainObject); const valuesByField = fieldName => candidateObjects.map(candidate => candidate[fieldName]).find(Boolean) ?? null; const getFieldFromText = (text, fieldName) => { if (typeof text !== "string" || !text.trim()) { return null; } const quotedFieldMatch = text.match( new RegExp(`"${fieldName}"\\s*:\\s*"?([a-zA-Z0-9x_-]{1,128})"?`) ); if (quotedFieldMatch?.[1]) { return quotedFieldMatch[1]; } const plainFieldMatch = text.match( new RegExp(`${fieldName}\\s*[:=]\\s*"?([a-zA-Z0-9x_-]{1,128})"?`, "i") ); if (plainFieldMatch?.[1]) { return plainFieldMatch[1]; } return null; }; const stringCandidates = [ responseBody.message, responseBody.error, responseBody?.data?.message, responseBody?.result?.message, JSON.stringify(responseBody), ].filter(value => typeof value === "string"); const txHashFromText = stringCandidates .map(text => text.match(/0x[a-fA-F0-9]{64}/)?.[0] || null) .find(Boolean) || null; const sessionIdFromText = stringCandidates .map(text => getFieldFromText(text, "sessionId")) .find(Boolean) || null; const tokenIdFromText = stringCandidates .map(text => getFieldFromText(text, "tokenId")) .find(Boolean) || null; const parsedSessionIdFromText = (() => { if (sessionIdFromText === null || sessionIdFromText === undefined) { return null; } const numeric = Number(sessionIdFromText); return Number.isInteger(numeric) && numeric >= 0 ? numeric : null; })(); return { transactionHash: valuesByField("transactionHash") || valuesByField("txHash") || valuesByField("hash") || txHashFromText || null, sessionId: valuesByField("sessionId") || valuesByField("id") || parsedSessionIdFromText || null, tokenId: valuesByField("tokenId") || (tokenIdFromText === null || tokenIdFromText === undefined ? null : String(tokenIdFromText)), }; }; const extractS3KeyFromPresignedUrl = presignedUrl => { if (typeof presignedUrl !== "string" || !presignedUrl.trim()) { throw new Error("presignedUrl must be a non-empty string"); } let parsedUrl; try { parsedUrl = new URL(presignedUrl); } catch (_error) { throw new Error("presignedUrl must be a valid URL"); } const path = parsedUrl.pathname?.replace(/^\/+/, "") || ""; if (!path) { throw new Error("presignedUrl does not contain a valid object key path"); } if ( parsedUrl.hostname.startsWith("s3.") && parsedUrl.hostname.endsWith(".amazonaws.com") ) { const pathParts = path.split("/"); if (pathParts.length < 2) { throw new Error("Unable to extract object key from path-style S3 URL"); } return pathParts.slice(1).join("/"); } return path; }; const toRawRequestBody = content => { if ( typeof content === "string" || content instanceof Uint8Array || content instanceof ArrayBuffer ) { return content; } if (typeof Buffer !== "undefined" && Buffer.isBuffer(content)) { return content; } throw new Error( "content must be a string, Buffer, Uint8Array, or ArrayBuffer" ); }; const extractBodyAndRequestMeta = responseOrBody => { if ( isPlainObject(responseOrBody) && Object.prototype.hasOwnProperty.call(responseOrBody, "body") && Object.prototype.hasOwnProperty.call(responseOrBody, "requestMeta") ) { return { body: responseOrBody.body, requestMeta: responseOrBody.requestMeta, }; } return { body: responseOrBody, requestMeta: null, }; }; const summarizeRouteProbeAttempts = attempts => Array.isArray(attempts) ? attempts.map(attempt => ({ routeTemplate: attempt?.routeTemplate || null, route: attempt?.route || null, url: typeof attempt?.url === "string" ? attempt.url : null, status: toFiniteNumberOrNull(attempt?.status), ok: Boolean(attempt?.ok), durationMs: toFiniteNumberOrNull(attempt?.durationMs), })) : []; const extractRequestMetaFromAttempts = attempts => { if (!Array.isArray(attempts) || attempts.length === 0) { return null; } const attemptWithDuration = [...attempts] .reverse() .find(attempt => toFiniteNumberOrNull(attempt?.durationMs) !== null) || attempts[attempts.length - 1]; const requestMeta = { status: toFiniteNumberOrNull(attemptWithDuration?.status), url: typeof attemptWithDuration?.url === "string" ? attemptWithDuration.url : null, durationMs: toFiniteNumberOrNull(attemptWithDuration?.durationMs), }; if ( requestMeta.status === null && requestMeta.url === null && requestMeta.durationMs === null ) { return null; } return requestMeta; }; const summarizeUsersResponse = ( { route, routeTemplate, response, requestMeta, attempts = [] }, expectedAddress ) => { const responseAddress = typeof response?.address === "string" ? response.address : null; const expectedAddressLower = (expectedAddress || "").toLowerCase(); const responseAddressLower = (responseAddress || "").toLowerCase(); const attemptSummaries = summarizeRouteProbeAttempts(attempts); return { route, routeTemplate, attempts: attemptSummaries, summary: { responseAddress, addressMatchesSession: Boolean(expectedAddressLower) && Boolean(responseAddressLower) && responseAddressLower === expectedAddressLower, hasUsername: typeof response?.username === "string" && response.username.trim().length > 0, primePoints: toFiniteNumberOrNull(response?.primePoints), artPoints: toFiniteNumberOrNull(response?.artPoints), landClaimLevel: toFiniteNumberOrNull(response?.landClaimLevel), characterLevel: toFiniteNumberOrNull(response?.characterLevel), battleMode: typeof response?.battleMode === "boolean" ? response.battleMode : null, }, request: isPlainObject(requestMeta) ? requestMeta : null, }; }; const summarizeParvaGxKeysResponse = response => { const { body, requestMeta } = extractBodyAndRequestMeta(response); const data = isPlainObject(body?.data) ? body.data : {}; const contents = Array.isArray(data.contents) ? data.contents : []; const sampleKeys = contents .slice(0, 3) .map(item => item?.gxKey || item?.key || item?.partitionKey || null) .filter(Boolean); return { success: Boolean(body?.success), totalItems: toFiniteNumberOrNull(data.totalItems) ?? contents.length, pageSize: toFiniteNumberOrNull(data.pageSize), hasMore: Boolean(data.hasMore), continuationTokenPresent: Boolean(data.continuationToken), sampleKeys, request: requestMeta, }; }; const summarizeParvaParkourResponse = response => { const { body, requestMeta } = extractBodyAndRequestMeta(response); const records = Array.isArray(body?.geoRecords) ? body.geoRecords : []; const sampleRecords = records.slice(0, 3).map(record => ({ tokenId: record?.tokenId ?? null, chain: typeof record?.chain === "string" ? record.chain : null, owner: typeof record?.owner === "string" ? record.owner : null, transactionHash: typeof record?.transactionHash === "string" ? record.transactionHash : null, geoType: typeof record?.geoType === "string" ? record.geoType : null, })); return { geoRecordsCount: records.length, sampleRecords, paginationTokenPresent: Boolean(body?.pag), request: requestMeta, }; }; const summarizeMagnaListResponse = response => { const { body, requestMeta } = extractBodyAndRequestMeta(response); return { hasPoiPassTechnical: typeof body?.poiPassTechnical === "string", botoActionsCount: Array.isArray(body?.botoActions) ? body.botoActions.length : 0, hasPv: body?.pv !== null && body?.pv !== undefined, topLevelKeys: isPlainObject(body) ? Object.keys(body).slice(0, 8) : [], request: requestMeta, }; }; const updateReportStatus = (report, nextStatus) => { if (!report || !nextStatus) { return; } if (nextStatus === REPORT_STATUS_ERROR) { report.status = REPORT_STATUS_ERROR; return; } if ( nextStatus === REPORT_STATUS_WARNING && report.status !== REPORT_STATUS_ERROR ) { report.status = REPORT_STATUS_WARNING; } }; const appendReportIssue = (report, issue = {}) => { if (!report || !issue || typeof issue !== "object") { return; } if (!Array.isArray(report.issues)) { report.issues = []; } report.issues.push({ severity: issue.severity === REPORT_STATUS_ERROR ? REPORT_STATUS_ERROR : REPORT_STATUS_WARNING, check: typeof issue.check === "string" ? issue.check : "unknown", message: typeof issue.message === "string" ? issue.message : "unknown issue", details: issue.details || null, }); }; const isAuthError = error => AUTH_HTTP_STATUSES.has(error?.details?.status); export class NftItemApiError extends Error { constructor(message, details) { super(message); this.name = "NftItemApiError"; this.details = details; } } class NftItemAgentSkill { constructor({ baseUrl = DEFAULT_BASE_URL, privateKey, enforceDefaultAgentIdentity = DEFAULT_ENFORCE_FIXED_AUTOMATION_IDENTITY, reportDetailLevel = DEFAULT_REPORT_DETAIL_LEVEL, fetchImpl = globalThis.fetch, usersGetCandidates = DEFAULT_USERS_GET_CANDIDATES, x402UploadCandidates = DEFAULT_X402_UPLOAD_CANDIDATES, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, } = {}) { if (!fetchImpl) { throw new Error("No fetch implementation available"); } this.baseUrl = baseUrl.replace(/\/+$/, ""); this.fetchImpl = fetchImpl; this.usersGetCandidates = usersGetCandidates; this.x402UploadCandidates = x402UploadCandidates; this.requestTimeoutMs = requestTimeoutMs; this.enforceDefaultAgentIdentity = enforceDefaultAgentIdentity; this.reportDetailLevel = normalizeReportDetailLevel(reportDetailLevel); this.lastUsersRouteTemplate = null; const envPrivateKey = this.enforceDefaultAgentIdentity ? null : process.env.NFT_ITEM_AGENT_PRIVATE_KEY || process.env.PRIVATE_KEY; const privateKeyToUse = normalizePrivateKey( privateKey || envPrivateKey || DEFAULT_AGENT_PRIVATE_KEY ); this.wallet = new Wallet(privateKeyToUse); this.address = this.wallet.address; this.session = null; } static createFromEnv(overrides = {}) { tryLoadEnv(); const enforceDefaultAgentIdentity = overrides.enforceDefaultAgentIdentity ?? process.env.NFT_ITEM_AGENT_ENFORCE_DEFAULT_IDENTITY !== "false"; const baseUrl = overrides.baseUrl || process.env.NFT_ITEM_BASE_URL || DEFAULT_BASE_URL; const reportDetailLevel = overrides.reportDetailLevel || process.env.NFT_ITEM_AGENT_REPORT_DETAIL_LEVEL; const usersGetCandidates = overrides.usersGetCandidates || DEFAULT_USERS_GET_CANDIDATES; const x402UploadCandidates = overrides.x402UploadCandidates || resolveEnvListOrDefault( process.env.NFT_ITEM_X402_UPLOAD_CANDIDATES || process.env.NFT_ITEM_X402_URL, DEFAULT_X402_UPLOAD_CANDIDATES ); const requestTimeoutMs = overrides.requestTimeoutMs ?? toPositiveIntegerOrUndefined(process.env.NFT_ITEM_REQUEST_TIMEOUT_MS); return new NftItemAgentSkill({ ...overrides, baseUrl, enforceDefaultAgentIdentity, reportDetailLevel, usersGetCandidates, x402UploadCandidates, requestTimeoutMs, }); } getAddress() { return this.address; } createLoginToken({ timestampMs = Date.now(), expiryMs = LOGIN_TOKEN_EXPIRY_MS, } = {}) { const baseAddress = this.address.toLowerCase(); const expiry = timestampMs + expiryMs; return `${LOGIN_TOKEN_TYPE}-a-${baseAddress}-t-${timestampMs}-e-${expiry}`; } async signLoginToken(loginToken) { if (!loginToken) { throw new Error("Cannot sign empty login token"); } return await this.wallet.signMessage(loginToken); } buildLoginHeadersAndBody({ loginToken, signature, walletType = DEFAULT_WALLET_TYPE, signatureType = DEFAULT_SIGNATURE_TYPE, memo, } = {}) { if (typeof loginToken !== "string" || !loginToken.trim()) { throw new Error("loginToken is required for login request"); } if (typeof signature !== "string" || !signature.trim()) { throw new Error("signature is required for login request"); } const headers = { "Content-Type": "application/json", "nftitem-address": this.address, "nftitem-web3-signature": signature, "nftitem-login-token": loginToken, "nftitem-wallet-type": walletType, "nftitem-signature-type": signatureType, }; if (memo) { headers["nftitem-login-memo"] = memo; } return { headers, body: { address: this.address, signature, loginToken, walletType, signatureType, }, }; } async createSignedLoginRequest({ timestampMs = Date.now(), expiryMs = LOGIN_TOKEN_EXPIRY_MS, } = {}) { const loginToken = this.createLoginToken({ timestampMs, expiryMs }); const signature = await this.signLoginToken(loginToken); return { address: this.address, loginToken, signature, walletType: DEFAULT_WALLET_TYPE, signatureType: DEFAULT_SIGNATURE_TYPE, }; } async verifyLoginTokenSignature({ loginToken, signature, expectedAddress, } = {}) { const tokenToVerify = loginToken || this.createLoginToken(); const signatureToVerify = signature || (await this.signLoginToken(tokenToVerify)); const expected = (expectedAddress || this.address).toLowerCase(); const recoveredAddress = verifyMessage( tokenToVerify, signatureToVerify ).toLowerCase(); const ok = recoveredAddress === expected; return { ok, tokenType: LOGIN_TOKEN_TYPE, expectedAddress: expected, recoveredAddress, }; } getSessionNToken() { return this.session?.nToken || null; } getLastResolvedUsersRouteTemplate() { return this.lastUsersRouteTemplate; } withOptionalRawReportData(summary, raw) { if (this.reportDetailLevel === REPORT_DETAIL_LEVEL_VERBOSE) { return { summary, raw, }; } return summary; } toRequestMeta(requestResult) { return { status: toFiniteNumberOrNull(requestResult?.status), url: typeof requestResult?.url === "string" ? requestResult.url : null, durationMs: toFiniteNumberOrNull(requestResult?.durationMs), }; } getAuthHeaders({ nToken, address } = {}) { const tokenToUse = nToken || this.getSessionNToken(); if (!tokenToUse) { throw new Error( "Missing nToken. Call login() first or pass nToken explicitly." ); } return { "nftitem-address": address || this.address, "nftitem-ntoken": tokenToUse, }; } async withAuthenticatedRequest( callback, { nToken, retryOnAuthError = true } = {} ) { if (typeof callback !== "function") { throw new Error("withAuthenticatedRequest requires a callback"); } let tokenToUse = nToken || this.getSessionNToken(); if (!tokenToUse) { const session = await this.login(); tokenToUse = session.nToken; } try { return await callback(tokenToUse); } catch (error) { if (!retryOnAuthError || !isAuthError(error)) { throw error; } const refreshedSession = await this.login(); return await callback(refreshedSession.nToken); } } buildGeneralizedCommandMessage({ type = "UNIVERSAL", subtype, value = {} }) { if (!subtype) { throw new Error("subtype is required for generalized command message"); } return `BOTOCOMMAND_GENERALIZED_COMMAND_V1_|${JSON.stringify({ type, subtype, value, })}|`; } async requestJson({ path: requestPath, method = "GET", headers = {}, body, rawBody, throwOnHttpError = true, requestTimeoutMs = this.requestTimeoutMs, }) { if (body !== undefined && rawBody !== undefined) { throw new Error("requestJson accepts either body or rawBody, not both"); } const url = requestPath.startsWith("http") ? requestPath : `${this.baseUrl}${requestPath.startsWith("/") ? "" : "/"}${requestPath}`; const requestOptions = { method, headers: { Accept: "application/json", ...headers, }, body: rawBody !== undefined ? rawBody : body === undefined ? undefined : JSON.stringify(body), }; if (Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0) { requestOptions.signal = AbortSignal.timeout(requestTimeoutMs); } const requestStartedAt = Date.now(); const response = await this.fetchImpl(url, requestOptions); const requestDurationMs = Date.now() - requestStartedAt; const responseHeaders = getResponseHeaders(response); const responseBody = await parseBody(response); const result = { ok: response.ok, status: response.status, url, body: responseBody, headers: sanitizeResponseHeadersForReport(responseHeaders), durationMs: requestDurationMs, }; if (!response.ok && throwOnHttpError) { throw new NftItemApiError( `HTTP ${response.status} ${method.toUpperCase()} ${url}`, result ); } return result; } async login({ memo } = {}) { const signedLoginRequest = await this.createSignedLoginRequest(); return await this.loginWithSignedRequest({ ...signedLoginRequest, memo, }); } async loginWithSignedRequest({ loginToken, signature, walletType = DEFAULT_WALLET_TYPE, signatureType = DEFAULT_SIGNATURE_TYPE, memo, } = {}) { const { headers, body } = this.buildLoginHeadersAndBody({ loginToken, signature, walletType, signatureType, memo, }); const recoveredAddress = verifyMessage(loginToken, signature).toLowerCase(); if (recoveredAddress !== this.address.toLowerCase()) { throw new Error( "Provided login signature does not match this agent wallet address" ); } const response = await this.requestJson({ method: "POST", path: "/expansive-world/login", headers, body, }); const nToken = extractNToken({ responseBody: response?.body, responseHeaders: response?.headers, }); if (!nToken) { throw new NftItemApiError( "Login succeeded but nToken missing in response", response ); } this.session = { address: this.address, nToken, expiry: response?.body?.expiry || null, loginToken, signature, loginResponse: response.body, loginResponseMeta: this.toRequestMeta(response), lastLoginAt: Date.now(), }; return this.session; } async resolveUsersRouteFromCandidates({ candidates = [], nToken, address, persistResolvedRouteTemplate = false, } = {}) { const addressToUse = address || this.address; const attempts = []; const candidateTemplates = Array.from( new Set( (Array.isArray(candidates) ? candidates : []).filter( candidate => typeof candidate === "string" && candidate.trim() ) ) ); for (const candidateTemplate of candidateTemplates) { const route = candidateTemplate.replaceAll( ":address", encodeURIComponent(addressToUse) ); try { const response = await this.requestJson({ path: route, method: "GET", headers: this.getAuthHeaders({ nToken, address: addressToUse }), }); const requestMeta = this.toRequestMeta(response); attempts.push({ routeTemplate: candidateTemplate, route, url: requestMeta.url, status: response.status, ok: true, durationMs: requestMeta.durationMs, }); if (persistResolvedRouteTemplate) { this.lastUsersRouteTemplate = candidateTemplate; } return { route, routeTemplate: candidateTemplate, response: response.body, requestMeta, attempts, }; } catch (error) { const status = error?.details?.status || null; const durationMs = toFiniteNumberOrNull(error?.details?.durationMs); const url = typeof error?.details?.url === "string" ? error.details.url : null; attempts.push({ routeTemplate: candidateTemplate, route, url, status, ok: false, durationMs, message: error?.message, }); // Keep probing on route-shape errors; stop on authorization failures. if (status && ![400, 404, 405].includes(status)) { throw error; } } } throw new NftItemApiError( "Unable to locate a working users GET route with current deployment", { attempts, candidates: candidateTemplates, } ); } async getUsers({ nToken, address } = {}) { const addressToUse = address || this.address; const response = await this.withAuthenticatedRequest( async resolvedNToken => await this.requestJson({ path: DEPLOYED_USER_DATA_PATH, method: "GET", headers: this.getAuthHeaders({ nToken: resolvedNToken, address: addressToUse, }), }), { nToken } ); const requestMeta = this.toRequestMeta(response); this.lastUsersRouteTemplate = DEPLOYED_USER_DATA_PATH; return { route: DEPLOYED_USER_DATA_PATH, routeTemplate: DEPLOYED_USER_DATA_PATH, response: response.body, requestMeta, attempts: [ { routeTemplate: DEPLOYED_USER_DATA_PATH, route: DEPLOYED_USER_DATA_PATH, url: requestMeta.url, status: response.status, ok: true, durationMs: requestMeta.durationMs, }, ], }; } async callMagna({ info, nToken, address } = {}) { const result = await this.callMagnaWithMeta({ info, nToken, address }); return result.body; } async callMagnaWithMeta({ info, nToken, address } = {}) { if (!info || typeof info !== "object" || Array.isArray(info)) { throw new Error("Magna requires an `info` object payload"); } const response = await this.withAuthenticatedRequest( async resolvedNToken => await this.requestJson({ method: "POST", path: "/expansive-world/magna", headers: { "Content-Type": "application/json", ...this.getAuthHeaders({ nToken: resolvedNToken, address }), }, body: { info }, }), { nToken } ); return { body: response.body, requestMeta: this.toRequestMeta(response), }; } async callMagnaGeneralizedCommand({ subtype, value = {}, type = "UNIVERSAL", nToken, } = {}) { const result = await this.callMagnaGeneralizedCommandWithMeta({ subtype, value, type, nToken, }); return result.body; } async callMagnaGeneralizedCommandWithMeta({ subtype, value = {}, type = "UNIVERSAL", nToken, } = {}) { const message = this.buildGeneralizedCommandMessage({ type, subtype, value, }); return await this.callMagnaWithMeta({ nToken, info: { command: "POWER_QUERY_V2", message, }, }); } async queueDataUploadSessionLoadTxV2({ transactionHash, nToken } = {}) { const validatedTransactionHash = normalizeEthereumTransactionHash(transactionHash); return await this.callMagnaGeneralizedCommand({ nToken, type: DMC_GENERALIZED_COMMAND_TYPES.BLOCKCHAIN_DATA, subtype: DMC_GENERALIZED_COMMAND_SUBTYPES.DATA_UPLOAD_SESSION_LOAD_TX_V2, value: { transactionHash: validatedTransactionHash, }, }); } async queuePrimeDepositTransaction({ transactionHash, nToken } = {}) { const validatedTransactionHash = normalizeEthereumTransactionHash(transactionHash); return await this.callMagnaGeneralizedCommand({ nToken, type: DMC_GENERALIZED_COMMAND_TYPES.UNIVERSAL, subtype: DMC_GENERALIZED_COMMAND_SUBTYPES.DEPOSIT_PRIME_LOAD_TRANSACTION, value: { transactionHash: validatedTransactionHash, }, }); } // Alias to make the currently active deposit ingestion path explicit. async queuePrimeDepositTransactionV5({ transactionHash, nToken } = {}) { return await this.queuePrimeDepositTransaction({ transactionHash, nToken, }); } async getImageUploadUrlWithMeta({ contentType = "image/png", nToken } = {}) { const validatedContentType = normalizeContentType(contentType); const result = await this.callMagnaGeneralizedCommandWithMeta({ nToken, type: DMC_GENERALIZED_COMMAND_TYPES.UNIVERSAL, subtype: DMC_GENERALIZED_COMMAND_SUBTYPES.GET_IMAGE_UPLOAD_URL, value: { contentType: validatedContentType, }, }); const uploadUrl = extractUploadUrlFromMagnaResponse(result.body); if (!uploadUrl) { throw new NftItemApiError( "GET_IMAGE_UPLOAD_URL succeeded but did not return uploadUrl", { requestMeta: result.requestMeta, body: result.body, } ); } return { ...result, uploadUrl, contentType: validatedContentType, }; } async getImageUploadUrl({ contentType = "image/png", nToken } = {}) { const result = await this.getImageUploadUrlWithMeta({ contentType, nToken, }); return result.uploadUrl; } async uploadToPresignedUrlWithMeta({ uploadUrl, content, contentType = "image/png", } = {}) { if (typeof uploadUrl !== "string" || !uploadUrl.trim()) { throw new Error("uploadUrl is required"); } const validatedContentType = normalizeContentType(contentType); const response = await this.requestJson({ method: "PUT", path: uploadUrl.trim(), headers: { "Content-Type": validatedContentType, }, rawBody: toRawRequestBody(content), }); return { ok: response.ok, status: response.status, url: response.url, durationMs: response.durationMs, headers: response.headers, }; } async executeDataUploadSessionWithMeta({ key, imageIndex = 0, nToken } = {}) { const normalizedKey = normalizeS3Key(key); const normalizedImageIndex = normalizeNonNegativeInteger(imageIndex, { fieldName: "imageIndex", }); const result = await this.callMagnaGeneralizedCommandWithMeta({ nToken, type: DMC_GENERALIZED_COMMAND_TYPES.BLOCKCHAIN_DATA, subtype: DMC_GENERALIZED_COMMAND_SUBTYPES.EXECUTE_DATA_UPLOAD_SESSION, value: { key: normalizedKey, imageIndex: normalizedImageIndex, }, }); return { ...result, executionRef: extractDataUploadSessionExecutionRef(result.body), }; } async executeDataUploadSession({ key, imageIndex = 0, nToken } = {}) { const result = await this.executeDataUploadSessionWithMeta({ key, imageIndex, nToken, }); return result.body; } async uploadAndExecuteDataUploadSessionWithMeta({ content, contentType = "image/png", imageIndex = 0, nToken, } = {}) { const getUploadUrlResult = await this.getImageUploadUrlWithMeta({ contentType, nToken, }); const uploadUrl = getUploadUrlResult.uploadUrl; const uploadResult = await this.uploadToPresignedUrlWithMeta({ uploadUrl, content, contentType, }); const key = extractS3KeyFromPresignedUrl(uploadUrl); const executeResult = await this.executeDataUploadSessionWithMeta({ key, imageIndex, nToken, }); return { uploadUrl, key, contentType: normalizeContentType(contentType), getUploadUrl: { requestMeta: getUploadUrlResult.requestMeta, body: getUploadUrlResult.body, }, upload: uploadResult, execute: { requestMeta: executeResult.requestMeta, body: executeResult.body, executionRef: executeResult.executionRef, }, }; } async uploadAndExecuteDataUploadSession({ content, contentType, imageIndex = 0, nToken, } = {}) { const result = await this.uploadAndExecuteDataUploadSessionWithMeta({ content, contentType, imageIndex, nToken, }); return { uploadUrl: result.uploadUrl, key: result.key, execute: result.execute, }; } async requestPrimeWithdrawalWithMeta({ amount, withdrawAddress, chain = DEFAULT_WITHDRAW_CHAIN, nToken, } = {}) { const validatedAmount = normalizePositiveFiniteAmount(amount); const validatedWithdrawAddress = normalizeEthereumAddress( withdrawAddress || this.address ); const validatedChain = normalizeWithdrawChain(chain); const response = await this.withAuthenticatedRequest( async resolvedNToken => await this.requestJson({ method: "POST", path: PRIME_WITHDRAW_V3_PATH, headers: { "Content-Type": "application/json", ...this.getAuthHeaders({ nToken: resolvedNToken }), }, body: { amount: validatedAmount, withdrawAddress: validatedWithdrawAddress, chain: validatedChain, }, }), { nToken } ); return { body: response.body, requestMeta: this.toRequestMeta(response), }; } async requestPrimeWithdrawal({ amount, withdrawAddress, chain, nToken, } = {}) { const result = await this.requestPrimeWithdrawalWithMeta({ amount, withdrawAddress, chain, nToken, }); return result.body; } async depositPrimeOnBaseWithMeta({ amountEth, rpcUrl = DEFAULT_BASE_RPC_URL, queuePrimeDepositTransaction = true, nToken, waitForConfirmations = 1, } = {}) { const validatedAmount = normalizePositiveFiniteAmount(amountEth); const validatedWaitForConfirmations = normalizeNonNegativeInteger( waitForConfirmations, { fieldName: "waitForConfirmations", } ); const provider = new JsonRpcProvider(rpcUrl); const signer = this.wallet.connect(provider); const contract = new Contract( PRIME_POINTS_MANAGER_ADDRESS, PRIME_POINTS_MANAGER_ABI, signer ); const transaction = await contract.deposit({ value: parseEther(String(validatedAmount)), }); const receipt = validatedWaitForConfirmations > 0 ? await transaction.wait(validatedWaitForConfirmations) : null; let queueResult = null; if (queuePrimeDepositTransaction) { queueResult = await this.queuePrimeDepositTransactionV5({ transactionHash: transaction.hash, nToken, }); } return { amountEth: validatedAmount, transactionHash: transaction.hash, blockNumber: receipt?.blockNumber || null, queuePrimeDepositTransaction, queueResult, }; } async depositPrimeOnBase({ amountEth, rpcUrl = DEFAULT_BASE_RPC_URL, queuePrimeDepositTransaction = true, nToken, waitForConfirmations = 1, } = {}) { const result = await this.depositPrimeOnBaseWithMeta({ amountEth, rpcUrl, queuePrimeDepositTransaction, nToken, waitForConfirmations, }); return { amountEth: result.amountEth, transactionHash: result.transactionHash, blockNumber: result.blockNumber, queuePrimeDepositTransaction: result.queuePrimeDepositTransaction, }; } // Deprecated legacy fallback: newer deployments should use requestPrimeWithdrawal(). async requestPrimeWithdrawalViaGeneralizedCommand({ amount, withdrawAddress, nToken, } = {}) { const validatedAmount = normalizePositiveFiniteAmount(amount); const validatedWithdrawAddress = normalizeEthereumAddress( withdrawAddress || this.address ); return await this.callMagnaGeneralizedCommand({ nToken, type: DMC_GENERALIZED_COMMAND_TYPES.UNIVERSAL, subtype: DMC_GENERALIZED_COMMAND_SUBTYPES.WITHDRAW_SUPER, value: { amount: validatedAmount, withdrawAddress: validatedWithdrawAddress, }, }); } async callParva({ command, data = {}, query = {}, header0, header1, header2, header3, } = {}) { const result = await this.callParvaWithMeta({ command, data, query, header0, header1, header2, header3, }); return result.body; } async callParvaWithMeta({ command, data = {}, query = {}, header0, header1, header2, header3, } = {}) { if (!command || typeof command !== "string") { throw new Error("Parva requires a command string"); } const queryString = new URLSearchParams(query).toString(); const pathWithQuery = queryString ? `/expansive-world/parva?${queryString}` : "/expansive-world/parva"; const headers = { "Content-Type": "application/json", }; if (header0) { headers["nftitem-parva-0"] = header0; } if (header1) { headers["nftitem-parva-1"] = header1; } if (header2) { headers["nftitem-parva-2"] = header2; } if (header3) { headers["nftitem-parva-3"] = header3; } const response = await this.requestJson({ method: "POST", path: pathWithQuery, headers, body: { command, data, }, }); return { body: response.body, requestMeta: this.toRequestMeta(response), }; } async listAllGxKeysForAddress({ address, includeRequestMeta = false } = {}) { const result = await this.callParvaWithMeta({ command: "LIST_ALL_GX_KEYS_FOR_ADDRESS", data: { address: address || this.address, }, }); return includeRequestMeta ? result : result.body; } async searchParkourGxGeometry({ query = {}, data = {}, includeRequestMeta = false, } = {}) { const result = await this.callParvaWithMeta({ command: "SEARCH_INDEXED_ONCHAIN_GEO", query: { mode: "gx_platformer_level", ...query, }, data, }); return includeRequestMeta ? result : result.body; } /** * Fetch indexed on-chain geo list by type (all geo types: ONCHAIN_PNG, GX_PLATFORMER_LEVEL, ONCHAIN_MP4, etc.). * Request: POST https://mathbitcoin.com/expansive-world/parva?mode=type * Body: { "command": "SEARCH_INDEXED_ONCHAIN_GEO", "data": {} } * Response: { poiPassTechnical, geoRecords: [...], pag } */ async searchIndexedOnchainGeoByType({ data = {}, query = {}, includeRequestMeta = false, } = {}) { const result = await this.callParvaWithMeta({ command: "SEARCH_INDEXED_ONCHAIN_GEO", query: { mode: "type", ...query, }, data, }); return includeRequestMeta ? result : result.body; } async getDataUploadSessionLoadStatusV2({ txHash, includeRequestMeta = false, } = {}) { if (typeof txHash !== "string" || !txHash.trim()) { throw new Error("txHash is required"); } const result = await this.callParvaWithMeta({ command: PARVA_COMMANDS.GET_DATA_UPLOAD_SESSION_LOAD_STATUS_V2, data: { txHash: txHash.trim(), }, }); return includeRequestMeta ? result : result.body; } async getDataUploadSessionIngestionStatusV2({ sessionId, includeRequestMeta = false, } = {}) { if (sessionId === undefined || sessionId === null || sessionId === "") { throw new Error("sessionId is required"); } const result = await this.callParvaWithMeta({ command: PARVA_COMMANDS.GET_DATA_UPLOAD_SESSION_INGESTION_STATUS_V2, data: { sessionId, }, }); return includeRequestMeta ? result : result.body; } async getFileUploadSessionStatusV2({ sessionId, includeRequestMeta = false, } = {}) { if (sessionId === undefined || sessionId === null || sessionId === "") { throw new Error("sessionId is required"); } const result = await this.callParvaWithMeta({ command: PARVA_COMMANDS.GET_FILE_UPLOAD_SESSION_STATUS_V2, data: { sessionId, }, }); return includeRequestMeta ? result : result.body; } async probeX402Upload({ payload, candidatePaths = this.x402UploadCandidates, expectedStatus = X402_EXPECTED_PAYMENT_REQUIRED_STATUS, } = {}) { const attempts = []; for (const candidatePath of candidatePaths) { const response = await this.requestJson({ method: "POST", path: candidatePath, headers: { "Content-Type": "application/json", }, body: payload || {}, throwOnHttpError: false, }); attempts.push({ path: candidatePath, response, matchesExpectedStatus: response.status === expectedStatus, }); if (response.status === expectedStatus) { return { ok: true, expectedStatus, activePath: candidatePath, response, attempts, }; } // 404 indicates route does not exist; any other status means route exists // but did not return the expected x402 payment-required status. if (response.status !== 404) { return { ok: false, expectedStatus, activePath: candidatePath, response, attempts, }; } } return { ok: false, expectedStatus, activePath: null, response: attempts[attempts.length - 1]?.response || null, attempts, }; } async callX402Upload({ payload = DEFAULT_X402_PROBE_PAYLOAD, path } = {}) { const resolvedPath = path || this.x402UploadCandidates[0]; const response = await this.requestJson({ method: "POST", path: resolvedPath, headers: { "Content-Type": "application/json", }, body: payload, throwOnHttpError: false, }); return response; } async getX402NftUrl({ tokenId, x402NftGetPath = "https://api.nftitem.io/nfts", } = {}) { const normalizedTokenId = normalizeTokenId(tokenId); return await this.requestJson({ method: "GET", path: `${x402NftGetPath.replace(/\/+$/, "")}/${encodeURIComponent(normalizedTokenId)}`, }); } async checkX402NftTokenUrl({ tokenId, x402NftGetPath = "https://api.nftitem.io/nfts", } = {}) { const normalizedTokenId = normalizeTokenId(tokenId); const response = await this.requestJson({ method: "GET", path: `${x402NftGetPath.replace(/\/+$/, "")}/${encodeURIComponent(normalizedTokenId)}`, throwOnHttpError: false, }); const nftUrl = typeof response?.body?.url === "string" && response.body.url.trim() ? response.body.url.trim() : null; return { ok: response.ok && Boolean(nftUrl), tokenId: normalizedTokenId, nftUrl, status: response.status, response, }; } async validateX402PaymentRequired({ payload = DEFAULT_X402_PROBE_PAYLOAD, path = this.x402UploadCandidates[0], expectedStatus = X402_EXPECTED_PAYMENT_REQUIRED_STATUS, } = {}) { const response = await this.callX402Upload({ payload, path }); return { ok: response.status === expectedStatus, expectedStatus, actualStatus: response.status, path, response, }; } async runHourlySmokeCheck() { const startedAt = new Date().toISOString(); const report = { startedAt, baseUrl: this.baseUrl, address: this.address, identity: { enforceDefaultAgentIdentity: this.enforceDefaultAgentIdentity, reportDetailLevel: this.reportDetailLevel, }, checks: {}, issues: [], status: REPORT_STATUS_OK, }; try { const signatureValidation = await this.verifyLoginTokenSignature(); report.checks.loginSignature = { ok: signatureValidation.ok, result: signatureValidation, }; if (!signatureValidation.ok) { updateReportStatus(report, REPORT_STATUS_ERROR); appendReportIssue(report, { severity: REPORT_STATUS_ERROR, check: "loginSignature", message: "Recovered signer does not match expected address", details: signatureValidation, }); report.endedAt = new Date().toISOString(); return report; } } catch (error) { updateReportStatus(report, REPORT_STATUS_ERROR); appendReportIssue(report, { severity: REPORT_STATUS_ERROR, check: "loginSignature", message: error.message, details: error.details || null, }); report.checks.loginSignature = { ok: false, error: { message: error.message, details: error.details || null, }, }; report.endedAt = new Date().toISOString(); return report; } try { const session = await this.login(); report.checks.login = { ok: true, result: { address: session.address, expiry: session.expiry, lastLoginAt: session.lastLoginAt, hasNToken: Boolean(session.nToken), request: session.loginResponseMeta, }, }; } catch (error) { updateReportStatus(report, REPORT_STATUS_ERROR); appendReportIssue(report, { severity: REPORT_STATUS_ERROR, check: "login", message: error.message, details: error.details || null, }); report.checks.login = { ok: false, error: { message: error.message, details: error.details || null, }, }; report.endedAt = new Date().toISOString(); return report; } try { const usersResult = await this.getUsers(); const summarizedUsersResult = summarizeUsersResponse( usersResult, this.address ); report.checks.users = { ok: true, result: this.withOptionalRawReportData( summarizedUsersResult, usersResult ), resolvedRouteTemplate: this.getLastResolvedUsersRouteTemplate(), }; const responseAddress = summarizedUsersResult?.summary?.responseAddress; if ( typeof responseAddress === "string" && responseAddress.trim() && summarizedUsersResult?.summary?.addressMatchesSession === false ) { updateReportStatus(report, REPORT_STATUS_ERROR); appendReportIssue(report, { severity: REPORT_STATUS_ERROR, check: "users", message: "Users endpoint returned an address different from session wallet", details: { expectedAddress: this.address, responseAddress: summarizedUsersResult?.summary?.responseAddress || null, route: summarizedUsersResult?.route || null, routeTemplate: summarizedUsersResult?.routeTemplate || null, }, }); } } catch (error) { updateReportStatus(report, REPORT_STATUS_ERROR); appendReportIssue(report, { severity: REPORT_STATUS_ERROR, check: "users", message: error.message, details: error.details || null, }); report.checks.users = { ok: false, error: { message: error.message, details: error.details || null, }, }; } try { const parvaListAllGxKeysResult = await this.listAllGxKeysForAddress({ includeRequestMeta: true, }); report.checks.parvaListAllGxKeys = { ok: true, result: this.withOptionalRawReportData( summarizeParvaGxKeysResponse(parvaListAllGxKeysResult), parvaListAllGxKeysResult ), }; } catch (error) { updateReportStatus(report, REPORT_STATUS_WARNING); appendReportIssue(report, { severity: REPORT_STATUS_WARNING, check: "parvaListAllGxKeys", message: error.message, details: error.details || null, }); report.checks.parvaListAllGxKeys = { ok: false, error: { message: error.message, details: error.details || null, }, }; } try { const parvaParkourGeoResult = await this.searchParkourGxGeometry({ includeRequestMeta: true, }); report.checks.parvaParkourGeo = { ok: true, result: this.withOptionalRawReportData( summarizeParvaParkourResponse(parvaParkourGeoResult), parvaParkourGeoResult ), }; } catch (error) { updateReportStatus(report, REPORT_STATUS_WARNING); appendReportIssue(report, { severity: REPORT_STATUS_WARNING, check: "parvaParkourGeo", message: error.message, details: error.details || null, }); report.checks.parvaParkourGeo = { ok: false, error: { message: error.message, details: error.details || null, }, }; } try { const magnaListResult = await this.callMagnaWithMeta({ info: { command: "LIST", }, }); report.checks.magnaList = { ok: true, result: this.withOptionalRawReportData( summarizeMagnaListResponse(magnaListResult), magnaListResult ), }; } catch (error) { updateReportStatus(report, REPORT_STATUS_ERROR); appendReportIssue(report, { severity: REPORT_STATUS_ERROR, check: "magnaList", message: error.message, details: error.details || null, }); report.checks.magnaList = { ok: false, error: { message: error.message, details: error.details || null, }, }; } try { const x402Validation = await this.validateX402PaymentRequired({ payload: DEFAULT_X402_PROBE_PAYLOAD, }); report.checks.x402UploadProbe = { ok: x402Validation.ok, result: x402Validation, }; if (!x402Validation.ok) { updateReportStatus(report, REPORT_STATUS_ERROR); appendReportIssue(report, { severity: REPORT_STATUS_ERROR, check: "x402UploadProbe", message: "x402 upload probe did not return expected payment-required status", details: { expectedStatus: x402Validation.expectedStatus, actualStatus: x402Validation.actualStatus, path: x402Validation.path, }, }); } } catch (error) { updateReportStatus(report, REPORT_STATUS_ERROR); appendReportIssue(report, { severity: REPORT_STATUS_ERROR, check: "x402UploadProbe", message: error.message, details: error.details || null, }); report.checks.x402UploadProbe = { ok: false, error: { message: error.message, details: error.details || null, }, }; } for (const checkName of CRITICAL_HOURLY_CHECKS) { if (report.checks[checkName]?.ok === false) { updateReportStatus(report, REPORT_STATUS_ERROR); } } report.endedAt = new Date().toISOString(); return report; } } export { NftItemAgentSkill, DEFAULT_AGENT_PRIVATE_KEY, DEFAULT_BASE_URL, DEFAULT_ENFORCE_FIXED_AUTOMATION_IDENTITY, DEFAULT_REPORT_DETAIL_LEVEL, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_BASE_RPC_URL, DEFAULT_SIGNATURE_TYPE, DEPLOYED_USER_DATA_PATH, DEFAULT_WITHDRAW_CHAIN, DEFAULT_USERS_GET_CANDIDATES, DEFAULT_X402_PROBE_PAYLOAD, DEFAULT_X402_UPLOAD_CANDIDATES, DEFAULT_WALLET_TYPE, LOGIN_TOKEN_EXPIRY_MS, LOGIN_TOKEN_TYPE, REPORT_DETAIL_LEVEL_SUMMARY, REPORT_DETAIL_LEVEL_VERBOSE, REPORT_STATUS_OK, REPORT_STATUS_WARNING, REPORT_STATUS_ERROR, PRIME_WITHDRAW_V3_PATH, DMC_GENERALIZED_COMMAND_TYPES, DMC_GENERALIZED_COMMAND_SUBTYPES, PARVA_COMMANDS, PRIME_POINTS_MANAGER_ADDRESS, CRITICAL_HOURLY_CHECKS, X402_EXPECTED_PAYMENT_REQUIRED_STATUS, }; ```