From ca6d059a5fef82c84938374292314ba3110fc5ef Mon Sep 17 00:00:00 2001 From: Aliu Salaudeen Date: Fri, 3 Jul 2026 16:15:54 +0100 Subject: [PATCH 1/5] fix(execute): bound result size to prevent context-window overflow The execute tool serialized the guest run() return value with no size limit, so whale-wallet whole-portfolio queries (all tokens across chains, all NFTs, all approvals on Ethereum) produced ~500k-token payloads that overflowed the consuming model's 400k-token context window before it could run the summarizing call. Enforce a generic size budget at the result boundary (DEBANK_MCP_EXECUTE_RESULT_BUDGET_CHARS, default 200k chars ~= 50k tokens): the envelope metadata (ok/error/log_lines/err_lines) is kept intact and the bulk `result` is truncated -- arrays keep the first N, objects trim their largest arrays, strings keep a prefix -- with a structured `_truncated: { shown, total, reason, hint }` signal attached so the consumer can refine the query instead of crashing. Results under budget are returned byte-for-byte unchanged. --- .changeset/bound-execute-result-size.md | 9 + README.md | 3 +- src/mcp/execute/result-budget.test.ts | 216 ++++++++++++++++++++++ src/mcp/execute/result-budget.ts | 233 ++++++++++++++++++++++++ src/mcp/execute/tool.test.ts | 40 ++++ src/mcp/execute/tool.ts | 15 +- 6 files changed, 511 insertions(+), 5 deletions(-) create mode 100644 .changeset/bound-execute-result-size.md create mode 100644 src/mcp/execute/result-budget.test.ts create mode 100644 src/mcp/execute/result-budget.ts diff --git a/.changeset/bound-execute-result-size.md b/.changeset/bound-execute-result-size.md new file mode 100644 index 0000000..d0d5a82 --- /dev/null +++ b/.changeset/bound-execute-result-size.md @@ -0,0 +1,9 @@ +--- +"@iqai/mcp-debank": patch +--- + +Bound the `execute` result size so whale-wallet queries can't overflow the consuming model's context window. + +The `execute` tool serialized the guest `run()` return value with no size limit and returned it verbatim to the MCP client. For large wallets (e.g. vitalik.eth), whole-portfolio queries — "all token holdings across all chains", "all NFT holdings", "all approvals on Ethereum" — produced payloads of ~500k tokens, which overflowed the consuming agent's context window before the summarizing model call could even run. + +There is now a generic size budget enforced at the result boundary (`DEBANK_MCP_EXECUTE_RESULT_BUDGET_CHARS`, default 200,000 chars ≈ 50k tokens). When a result exceeds it, the envelope metadata (`ok` / `error` / `log_lines` / `err_lines`) is kept intact and the bulk `result` is truncated — arrays keep the first N elements, objects trim their largest arrays, strings keep a prefix — with a structured `_truncated: { shown, total, reason, hint }` signal attached so the consumer can answer "showing top N of M — refine to see more" instead of crashing. Results under budget are returned byte-for-byte unchanged. diff --git a/README.md b/README.md index 262114b..e68d0cb 100644 --- a/README.md +++ b/README.md @@ -333,7 +333,7 @@ Search the embedded MiniSearch index over all DeBank API methods and cookbook en ### Safety limits -The `execute` sandbox enforces five bounds. Defaults are tuned for legitimate Promise.all-style work; the two budget knobs are env-overridable for tests and tightening. +The `execute` sandbox enforces six bounds. Defaults are tuned for legitimate Promise.all-style work; the budget knobs are env-overridable for tests and tightening. | Limit | Default | Override | Behaviour on hit | | :--- | :--- | :--- | :--- | @@ -342,6 +342,7 @@ The `execute` sandbox enforces five bounds. Defaults are tuned for legitimate Pr | Calls per `execute` | 100 | `DEBANK_MCP_EXECUTE_BUDGET` | Subsequent guest calls return `"Execute call budget exceeded (100 calls per invocation): "` | | Concurrent calls | 10 | `DEBANK_MCP_EXECUTE_CONCURRENCY` | Calls queue on a semaphore; no error, just back-pressure | | Per-call upstream | 5 s abort + 6 s axios | hard-coded | Returns `"DeBank call timed out after 5s: "` | +| Result size | 200,000 chars (~50k tokens) | `DEBANK_MCP_EXECUTE_RESULT_BUDGET_CHARS` | The bulk `result` is truncated (arrays keep the first N; objects trim their largest arrays; strings keep a prefix) and a `_truncated: { shown, total, reason, hint }` signal is attached. Envelope metadata (`ok` / `error` / `log_lines` / `err_lines`) is always kept, so the consuming model gets a bounded, actionable payload instead of overflowing its context window on a whale-wallet query. | The sandbox also blocks the source-level identifiers `process.`, `require(`, `import(`, and `eval(` at submission time. These aren't security boundaries (the isolate is) — they're a defense-in-depth check that fails fast with a clear message. diff --git a/src/mcp/execute/result-budget.test.ts b/src/mcp/execute/result-budget.test.ts new file mode 100644 index 0000000..71543ed --- /dev/null +++ b/src/mcp/execute/result-budget.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from "vitest"; +import { + boundExecuteResult, + DEFAULT_EXECUTE_RESULT_BUDGET_CHARS, + resolveResultBudgetChars, +} from "./result-budget.js"; +import type { SandboxResult } from "./sandbox.js"; + +const ok = (result: unknown): SandboxResult => ({ + ok: true, + result, + log_lines: [], + err_lines: [], +}); + +describe("boundExecuteResult — pass-through (normal path)", () => { + it("small result is returned byte-identical with no _truncated signal", () => { + const input = ok([1, 2, 3]); + const { text, truncated } = boundExecuteResult(input, 100_000); + expect(truncated).toBe(false); + expect(text).toBe(JSON.stringify(input)); + expect(text).not.toContain("_truncated"); + }); + + it("preserves ok/log_lines/err_lines exactly on the small path", () => { + const input: SandboxResult = { + ok: true, + result: { hello: "world" }, + log_lines: ["a", "b"], + err_lines: ["oops"], + }; + const parsed = JSON.parse(boundExecuteResult(input, 100_000).text); + expect(parsed).toEqual(input); + }); +}); + +describe("boundExecuteResult — array result over budget", () => { + const items = Array.from({ length: 500 }, (_, i) => ({ + id: i, + symbol: `TKN${i}`, + amount: i * 1.5, + chain: "eth", + })); + const input: SandboxResult = { + ok: true, + result: items, + log_lines: ["ran ok"], + err_lines: [], + }; + + it("keeps only the first N and stays within budget", () => { + const budget = 2_000; + const { text, truncated } = boundExecuteResult(input, budget); + expect(truncated).toBe(true); + expect(text.length).toBeLessThanOrEqual(budget); + const parsed = JSON.parse(text); + expect(Array.isArray(parsed.result)).toBe(true); + expect(parsed.result.length).toBeGreaterThan(0); + expect(parsed.result.length).toBeLessThan(items.length); + // kept elements are the ORIGINAL first N, untouched + expect(parsed.result[0]).toEqual(items[0]); + }); + + it("carries a structured _truncated signal (shown < total, reason, hint)", () => { + const { text } = boundExecuteResult(input, 2_000); + const parsed = JSON.parse(text); + expect(parsed._truncated.total).toBe(items.length); + expect(parsed._truncated.shown).toBe(parsed.result.length); + expect(parsed._truncated.shown).toBeLessThan(parsed._truncated.total); + expect(typeof parsed._truncated.reason).toBe("string"); + expect(parsed._truncated.reason.toLowerCase()).toContain("budget"); + expect(typeof parsed._truncated.hint).toBe("string"); + expect(parsed._truncated.hint.length).toBeGreaterThan(0); + }); + + it("keeps the small metadata (ok/log_lines) intact", () => { + const { text } = boundExecuteResult(input, 2_000); + const parsed = JSON.parse(text); + expect(parsed.ok).toBe(true); + expect(parsed.log_lines).toEqual(["ran ok"]); + }); +}); + +describe("boundExecuteResult — object-with-arrays over budget", () => { + const tokens = Array.from({ length: 400 }, (_, i) => ({ + symbol: `T${i}`, + amount: i, + price: i / 3, + })); + const input: SandboxResult = { + ok: true, + result: { + summary: { chains: 24, total_usd: 987654.32 }, + skipped: ["dust-chain"], + holdings: tokens, + }, + log_lines: [], + err_lines: [], + }; + + it("truncates the largest array while preserving scalar metadata", () => { + const budget = 3_000; + const { text, truncated } = boundExecuteResult(input, budget); + expect(truncated).toBe(true); + expect(text.length).toBeLessThanOrEqual(budget); + const parsed = JSON.parse(text); + // scalar/summary metadata preserved intact + expect(parsed.result.summary).toEqual({ chains: 24, total_usd: 987654.32 }); + expect(parsed.result.skipped).toEqual(["dust-chain"]); + // the bulk array was trimmed + expect(parsed.result.holdings.length).toBeLessThan(tokens.length); + expect(parsed.result.holdings[0]).toEqual(tokens[0]); + }); + + it("names the truncated field in _truncated.fields", () => { + const { text } = boundExecuteResult(input, 3_000); + const parsed = JSON.parse(text); + const holdings = parsed._truncated.fields.find( + (f: { field: string }) => f.field === "holdings", + ); + expect(holdings).toBeDefined(); + expect(holdings.total).toBe(tokens.length); + expect(holdings.shown).toBe(parsed.result.holdings.length); + expect(holdings.shown).toBeLessThan(holdings.total); + }); + + it("finds arrays nested inside child objects (path is dotted)", () => { + const nested: SandboxResult = ok({ + data: { + tokens: Array.from({ length: 400 }, (_, i) => ({ i, s: `S${i}` })), + }, + }); + const { text } = boundExecuteResult(nested, 3_000); + const parsed = JSON.parse(text); + expect(text.length).toBeLessThanOrEqual(3_000); + expect(parsed.result.data.tokens.length).toBeLessThan(400); + const field = parsed._truncated.fields.find( + (f: { field: string }) => f.field === "data.tokens", + ); + expect(field).toBeDefined(); + }); +}); + +describe("boundExecuteResult — non-array/non-object bulk", () => { + it("truncates a giant string result to a prefix within budget", () => { + const big = "x".repeat(50_000); + const { text, truncated } = boundExecuteResult(ok(big), 1_000); + expect(truncated).toBe(true); + expect(text.length).toBeLessThanOrEqual(1_000); + const parsed = JSON.parse(text); + expect(typeof parsed.result).toBe("string"); + expect(parsed.result.length).toBeLessThan(big.length); + expect(parsed._truncated.total).toBe(big.length); + }); + + it("falls back to dropping a non-reducible object bulk, still bounded", () => { + // An object with no arrays, dominated by one huge string value. + const input = ok({ blob: "y".repeat(50_000) }); + const { text, truncated } = boundExecuteResult(input, 1_000); + expect(truncated).toBe(true); + expect(text.length).toBeLessThanOrEqual(1_000); + const parsed = JSON.parse(text); + expect(parsed.result).toBeNull(); + expect(parsed._truncated.reason.toLowerCase()).toContain("truncat"); + }); +}); + +describe("boundExecuteResult — non-serializable propagates", () => { + it("throws on a BigInt result so the caller can emit its error envelope", () => { + expect(() => boundExecuteResult(ok(1n), 100_000)).toThrow(TypeError); + }); +}); + +describe("result budget configuration", () => { + const KEY = "DEBANK_MCP_EXECUTE_RESULT_BUDGET_CHARS"; + + it("default is ~50k tokens worth of chars", () => { + expect(DEFAULT_EXECUTE_RESULT_BUDGET_CHARS).toBe(200_000); + }); + + it("resolveResultBudgetChars reads a valid env override", () => { + const prev = process.env[KEY]; + process.env[KEY] = "12345"; + try { + expect(resolveResultBudgetChars()).toBe(12_345); + } finally { + if (prev === undefined) delete process.env[KEY]; + else process.env[KEY] = prev; + } + }); + + it("falls back to the default for missing / invalid / non-positive values", () => { + const prev = process.env[KEY]; + try { + delete process.env[KEY]; + expect(resolveResultBudgetChars()).toBe( + DEFAULT_EXECUTE_RESULT_BUDGET_CHARS, + ); + process.env[KEY] = "not-a-number"; + expect(resolveResultBudgetChars()).toBe( + DEFAULT_EXECUTE_RESULT_BUDGET_CHARS, + ); + process.env[KEY] = "0"; + expect(resolveResultBudgetChars()).toBe( + DEFAULT_EXECUTE_RESULT_BUDGET_CHARS, + ); + process.env[KEY] = "-5"; + expect(resolveResultBudgetChars()).toBe( + DEFAULT_EXECUTE_RESULT_BUDGET_CHARS, + ); + } finally { + if (prev === undefined) delete process.env[KEY]; + else process.env[KEY] = prev; + } + }); +}); diff --git a/src/mcp/execute/result-budget.ts b/src/mcp/execute/result-budget.ts new file mode 100644 index 0000000..fac861b --- /dev/null +++ b/src/mcp/execute/result-budget.ts @@ -0,0 +1,233 @@ +// src/mcp/execute/result-budget.ts +// +// Bounds the serialized size of an `execute` result before it is handed back to +// the MCP client. Whale-wallet queries ("all tokens across all chains", "all +// NFTs", "all approvals on Ethereum") produce DeBank payloads large enough to +// overflow the consuming model's context window (the aiden-adk leaf agent runs +// gpt-5-mini @ 400k tokens and feeds the whole result back in). Without a bound +// the call never reaches the summarizing model — it errors on input. +// +// The fix is a GENERIC cap at the result boundary (see execute/tool.ts): any +// heavy query is bounded, not just the known-bad three. The small envelope +// metadata (ok / error / log_lines / err_lines) is always kept; only the bulk +// `result` is trimmed, and a structured `_truncated` signal is attached so the +// model answers "showing top N of M — refine to see more" instead of crashing. + +import type { SandboxResult } from "./sandbox.js"; + +/** + * Default budget for the serialized `execute` result, in characters. + * + * The consumer's window is 400k tokens shared across system prompt, history, + * tool defs and the model's own output — the result is only one slice. We + * target ~50k tokens for it, and estimate tokens cheaply as chars / 4, giving + * 200k chars. Override per-deployment with + * `DEBANK_MCP_EXECUTE_RESULT_BUDGET_CHARS`. + */ +export const DEFAULT_EXECUTE_RESULT_BUDGET_CHARS = 200_000; + +/** Reads the budget from env, falling back to the default for absent/invalid values. */ +export function resolveResultBudgetChars(): number { + const raw = Number(process.env.DEBANK_MCP_EXECUTE_RESULT_BUDGET_CHARS); + return Number.isFinite(raw) && raw > 0 + ? raw + : DEFAULT_EXECUTE_RESULT_BUDGET_CHARS; +} + +const REASON = "result exceeded MCP size budget"; +const HINT = + "narrow the query (single chain / filter / paginate) or request a summary"; + +type FieldTruncation = { field: string; shown: number; total: number }; +type TruncationSignal = { + reason: string; + hint: string; + shown?: number; + total?: number; + fields?: FieldTruncation[]; + originalChars?: number; +}; + +export type BoundedExecuteResult = { text: string; truncated: boolean }; + +/** + * Serialize a {@link SandboxResult} within `budgetChars`, truncating the bulk + * `result` when necessary. + * + * - Small results are returned byte-identical (no `_truncated`) — the normal + * path is never modified. + * - Array results keep the first N elements. + * - Object results have their largest array(s) trimmed, deepest metadata kept. + * - String results are sliced to a prefix. + * - Anything else (or an object with no reducible arrays) is dropped to `null` + * with an explanatory signal. + * + * Throws (from `JSON.stringify`) on a non-JSON-serializable result — e.g. a + * BigInt — so the caller can emit its existing "not JSON-serializable" envelope. + */ +export function boundExecuteResult( + sandboxResult: SandboxResult, + budgetChars: number = resolveResultBudgetChars(), +): BoundedExecuteResult { + // Serialize the whole envelope first. This throws on BigInt/circular values; + // we let that propagate so tool.ts keeps producing its error envelope. + const full = JSON.stringify(sandboxResult); + if (full.length <= budgetChars) return { text: full, truncated: false }; + + // Over budget. Keep the small metadata; the `result` is the bulk to trim. + const { result, ...rest } = sandboxResult; + + const build = (resultValue: unknown, signal: TruncationSignal): string => + JSON.stringify({ ...rest, result: resultValue, _truncated: signal }); + const fits = (json: string): boolean => json.length <= budgetChars; + + // Array bulk: keep the first N original elements. + if (Array.isArray(result)) { + const total = result.length; + const n = largestFitting(total, (k) => + fits( + build(result.slice(0, k), { + reason: REASON, + hint: HINT, + shown: k, + total, + }), + ), + ); + return { + text: build(result.slice(0, n), { + reason: REASON, + hint: HINT, + shown: n, + total, + }), + truncated: true, + }; + } + + // String bulk: keep a leading prefix. + if (typeof result === "string") { + const total = result.length; + const n = largestFitting(total, (k) => + fits( + build(result.slice(0, k), { + reason: REASON, + hint: HINT, + shown: k, + total, + }), + ), + ); + return { + text: build(result.slice(0, n), { + reason: REASON, + hint: HINT, + shown: n, + total, + }), + truncated: true, + }; + } + + // Object bulk: trim the largest array(s) anywhere in the object tree, keeping + // scalar metadata and object structure intact. + if (result !== null && typeof result === "object") { + // Clone so we never mutate the guest's returned object. Safe: `full` + // serialized above, so `result` is a finite, acyclic JSON value. + const clone = JSON.parse(JSON.stringify(result)) as Record; + const arrays = collectArrays(clone, ""); + // Largest first so trimming the biggest array usually settles it in one pass. + arrays.sort( + (a, b) => + JSON.stringify(b.parent[b.key]).length - + JSON.stringify(a.parent[a.key]).length, + ); + const fieldsTrunc: FieldTruncation[] = []; + for (const a of arrays) { + if ( + fits(build(clone, { reason: REASON, hint: HINT, fields: fieldsTrunc })) + ) + break; + const items = a.parent[a.key] as unknown[]; + const total = items.length; + const n = largestFitting(total, (k) => { + a.parent[a.key] = items.slice(0, k); + return fits( + build(clone, { + reason: REASON, + hint: HINT, + fields: [...fieldsTrunc, { field: a.path, shown: k, total }], + }), + ); + }); + a.parent[a.key] = items.slice(0, n); + if (n < total) fieldsTrunc.push({ field: a.path, shown: n, total }); + } + const signal: TruncationSignal = { + reason: REASON, + hint: HINT, + fields: fieldsTrunc, + }; + if (fits(build(clone, signal))) { + return { text: build(clone, signal), truncated: true }; + } + // Arrays alone couldn't get us under budget (e.g. giant scalar fields). + // Fall through to the hard fallback below. + } + + // Hard fallback: not structurally reducible (scalar, or object dominated by + // non-array data). Drop the bulk so the output is still bounded. + return { + text: build(null, { + reason: `${REASON}; result could not be structurally truncated`, + hint: HINT, + originalChars: full.length, + }), + truncated: true, + }; +} + +/** + * Largest `n` in `[0, max]` for which `fits(n)` holds, assuming `fits` is + * monotonic (a smaller prefix is never larger). Returns 0 when even `fits(0)` + * is false (best effort — the caller's fallback still bounds the output). + */ +function largestFitting(max: number, fits: (n: number) => boolean): number { + if (fits(max)) return max; + let lo = 0; + let hi = max; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (fits(mid)) lo = mid; + else hi = mid - 1; + } + return lo; +} + +type ArrayHandle = { + parent: Record; + key: string; + path: string; +}; + +/** + * Collect every array that is the value of an object property, at any object + * nesting depth. Descends into nested objects but NOT into array elements, so a + * huge array's contents don't spawn thousands of handles — the realistic bulk + * lives at an object key (`holdings`, `data.tokens`, `chain_list`). + */ +function collectArrays( + node: Record, + path: string, + out: ArrayHandle[] = [], +): ArrayHandle[] { + for (const [key, value] of Object.entries(node)) { + const childPath = path ? `${path}.${key}` : key; + if (Array.isArray(value)) { + out.push({ parent: node, key, path: childPath }); + } else if (value !== null && typeof value === "object") { + collectArrays(value as Record, childPath, out); + } + } + return out; +} diff --git a/src/mcp/execute/tool.test.ts b/src/mcp/execute/tool.test.ts index 871f4e3..13de399 100644 --- a/src/mcp/execute/tool.test.ts +++ b/src/mcp/execute/tool.test.ts @@ -55,4 +55,44 @@ describe("executeTool error envelope", () => { vi.resetModules(); } }); + + it("over-budget result is bounded and carries a _truncated signal", async () => { + const KEY = "DEBANK_MCP_EXECUTE_RESULT_BUDGET_CHARS"; + const prev = process.env[KEY]; + process.env[KEY] = "2000"; + const items = Array.from({ length: 500 }, (_, i) => ({ + id: i, + symbol: `TKN${i}`, + amount: i * 1.5, + })); + vi.resetModules(); + vi.doMock("./sandbox.js", () => ({ + runInSandbox: vi.fn(async () => ({ + ok: true, + result: items, + log_lines: ["ran"], + err_lines: [], + })), + })); + try { + const { executeTool } = await import("./tool.js"); + const res = await executeTool.execute({ + code: "async function run(debank){ return []; }", + }); + expect(res.isError).toBe(false); + const text = (res.content[0] as { text: string }).text; + expect(text.length).toBeLessThanOrEqual(2000); + const inner = JSON.parse(text); + expect(inner.ok).toBe(true); + expect(inner.log_lines).toEqual(["ran"]); + expect(inner.result.length).toBeLessThan(items.length); + expect(inner._truncated.total).toBe(items.length); + expect(inner._truncated.shown).toBe(inner.result.length); + } finally { + if (prev === undefined) delete process.env[KEY]; + else process.env[KEY] = prev; + vi.doUnmock("./sandbox.js"); + vi.resetModules(); + } + }); }); diff --git a/src/mcp/execute/tool.ts b/src/mcp/execute/tool.ts index 4b49232..e3dad7d 100644 --- a/src/mcp/execute/tool.ts +++ b/src/mcp/execute/tool.ts @@ -5,6 +5,7 @@ // the addon doesn't load at server startup. import { z } from "zod"; +import { boundExecuteResult } from "./result-budget.js"; import type { SandboxResult } from "./sandbox.js"; import { cancelScope, createExecutionScope } from "./scope.js"; @@ -56,14 +57,20 @@ export const executeTool = { } /** - * JSON.stringify can throw on BigInt, circular refs, or other non-JSON - * values returned from the sandbox. Normalize to the {ok:false} envelope - * so the MCP contract ("always return a valid envelope") holds. + * Serialize within a size budget so whale-wallet payloads (all tokens / + * NFTs / approvals) can't overflow the consuming model's context window — + * boundExecuteResult keeps the metadata, trims the bulk `result`, and + * annotates with a `_truncated` signal. See result-budget.ts. + * + * boundExecuteResult (via JSON.stringify) can throw on BigInt, circular + * refs, or other non-JSON values returned from the sandbox. Normalize to + * the {ok:false} envelope so the MCP contract ("always return a valid + * envelope") holds. */ let inner: string; let isError: boolean; try { - inner = JSON.stringify(sandboxResult); + inner = boundExecuteResult(sandboxResult).text; isError = !sandboxResult.ok; } catch (err) { const msg = err instanceof Error ? err.message : String(err); From 304039f80e86dccf3dd364ee8104fce9a5eb42ca Mon Sep 17 00:00:00 2001 From: Aliu Salaudeen Date: Sat, 4 Jul 2026 13:04:29 +0100 Subject: [PATCH 2/5] fix(nft): fan out getUserAllNftList per-chain instead of the slow aggregate DeBank's /user/all_nft_list aggregates across every chain server-side and takes 10-20s for active wallets, routinely timing out to nothing for whales. getUserAllNftList now discovers the wallet's used chains and fans out the fast per-chain /user/nft_list calls in parallel with bounded concurrency (8), so wall-clock is ~ (chains / 8) x 1.5s rather than one long-hanging aggregate. The method degrades to a partial result instead of failing all-or-nothing: a chain that errors or is too slow is dropped into chains_skipped, and the fan-out enforces its own soft deadline (~75% of the call's timeout budget) so it returns the chains that landed before the execute client's hard abort fires. Each NFT is stamped with its chain (the per-chain endpoint often omits it). Return shape changes from a bare UserNFT[] to { nfts, chains, partial, chains_skipped }; tool description, response schema, cookbook, and instructions are updated to match, and the search-ranking test now expects the chain-less aggregate as the top hit for "get NFTs for wallet". --- .changeset/nft-per-chain-fanout.md | 11 + .../instructions/instructions.generated.ts | 7 +- src/mcp/instructions/instructions.md | 7 +- src/mcp/legacy/response-schemas.ts | 14 +- src/mcp/legacy/tool-metadata.ts | 21 +- .../cookbook/03-all-nft-holdings.md | 29 ++- src/mcp/search-docs/embedded-index.ts | 12 +- src/mcp/search-docs/tool.test.ts | 10 +- src/services/index.ts | 2 +- src/services/user.service.test.ts | 196 ++++++++++++++++- src/services/user.service.ts | 208 ++++++++++++++++-- 11 files changed, 466 insertions(+), 51 deletions(-) create mode 100644 .changeset/nft-per-chain-fanout.md diff --git a/.changeset/nft-per-chain-fanout.md b/.changeset/nft-per-chain-fanout.md new file mode 100644 index 0000000..a8efdfe --- /dev/null +++ b/.changeset/nft-per-chain-fanout.md @@ -0,0 +1,11 @@ +--- +"@iqai/mcp-debank": minor +--- + +Fetch whale NFT holdings by fanning out per-chain instead of the slow server-side aggregate. + +`getUserAllNftList` previously called DeBank's `/user/all_nft_list`, which aggregates across every chain server-side and takes 10–20 s for active wallets — routinely timing out to nothing for whales (e.g. vitalik.eth). It now discovers the wallet's used chains (`used_chain_list`) and fans out the fast per-chain `/user/nft_list` calls in parallel with bounded concurrency (8), so wall-clock is ≈ (chains / 8) × ~1.5 s. + +The method also degrades gracefully instead of failing all-or-nothing: a chain that errors or is too slow is dropped and reported, and the method enforces its own soft deadline (~75% of the call's timeout budget) so it returns the chains that landed *before* the client's hard abort fires — partial-on-timeout, mirroring the result-size-budget philosophy. + +**Return-shape change (guest-facing):** `getUserAllNftList` now resolves to `{ nfts, chains, partial, chains_skipped }` instead of a bare `UserNFT[]`. Read `result.nfts` for the holdings (each NFT is stamped with its `chain`); `partial: true` with `chains_skipped` names the chains to retry (narrow via `chain_ids`, or use `getUserNftList` per chain). The tool description, cookbook, and instructions are updated accordingly. diff --git a/src/mcp/instructions/instructions.generated.ts b/src/mcp/instructions/instructions.generated.ts index e29ebd2..3a23b02 100644 --- a/src/mcp/instructions/instructions.generated.ts +++ b/src/mcp/instructions/instructions.generated.ts @@ -40,7 +40,12 @@ async function run(debank) { \`\`\`js async function run(debank) { - return await debank.user.getUserAllNftList({ id: "0xWALLET" }); + // Host discovers the wallet's used chains and fans out per-chain nft_list + // in parallel. Returns { nfts, chains, partial, chains_skipped } — read + // \`.nfts\`. \`partial: true\` means some chains were skipped (retry via chain_ids). + const { nfts, chains, partial, chains_skipped } = + await debank.user.getUserAllNftList({ id: "0xWALLET" }); + return { count: nfts.length, chains, partial, chains_skipped, nfts }; } \`\`\` diff --git a/src/mcp/instructions/instructions.md b/src/mcp/instructions/instructions.md index 4493ebb..9466ff8 100644 --- a/src/mcp/instructions/instructions.md +++ b/src/mcp/instructions/instructions.md @@ -37,7 +37,12 @@ async function run(debank) { ```js async function run(debank) { - return await debank.user.getUserAllNftList({ id: "0xWALLET" }); + // Host discovers the wallet's used chains and fans out per-chain nft_list + // in parallel. Returns { nfts, chains, partial, chains_skipped } — read + // `.nfts`. `partial: true` means some chains were skipped (retry via chain_ids). + const { nfts, chains, partial, chains_skipped } = + await debank.user.getUserAllNftList({ id: "0xWALLET" }); + return { count: nfts.length, chains, partial, chains_skipped, nfts }; } ``` diff --git a/src/mcp/legacy/response-schemas.ts b/src/mcp/legacy/response-schemas.ts index 52a1bad..9df7e7c 100644 --- a/src/mcp/legacy/response-schemas.ts +++ b/src/mcp/legacy/response-schemas.ts @@ -342,8 +342,18 @@ const UserNFTSchema = z.object({ /** debank.user.getUserNftList */ export const UserNftListSchema = z.array(UserNFTSchema); -/** debank.user.getUserAllNftList */ -export const UserAllNftListSchema = z.array(UserNFTSchema); +/** + * debank.user.getUserAllNftList — host-side per-chain aggregate. Returns the + * flattened NFTs plus the chains covered and a partial-result signal (chains + * that failed or were abandoned at the timeout budget). See + * `UserService.getUserAllNftListRaw`. + */ +export const UserAllNftListSchema = z.object({ + nfts: z.array(UserNFTSchema), + chains: z.array(z.string()), + partial: z.boolean(), + chains_skipped: z.array(z.string()), +}); const UserHistoryItemSchema = z.object({ id: z.string(), diff --git a/src/mcp/legacy/tool-metadata.ts b/src/mcp/legacy/tool-metadata.ts index 57796d1..f7d95f3 100644 --- a/src/mcp/legacy/tool-metadata.ts +++ b/src/mcp/legacy/tool-metadata.ts @@ -634,28 +634,29 @@ export const TOOL_METADATA: ToolMetadata[] = [ qualified: "debank.user.getUserAllNftList", sandboxImpl: lazyMethod("userService", "getUserAllNftListRaw"), description: - "Retrieve a user's NFT holdings across all supported chains. Provides an aggregate list of NFTs held by the user with details including contract ID, name, and content type. Can be filtered by specific chains.", + "Retrieve a user's NFT holdings across chains. Host-side aggregate: discovers the wallet's used chains, then fans out the fast per-chain nft_list calls in parallel (bounded concurrency). Replaces DeBank's /user/all_nft_list, which aggregates server-side and routinely times out for active wallets. Result shape: `{ nfts: [...], chains: [...], partial, chains_skipped: [...] }` — read `result.nfts` for the holdings (each carries its `chain`). If a chain fails or is too slow it is dropped and listed in `chains_skipped` with `partial: true`, so the rest still come back — narrow to one chain via `chain_ids` to retry the skipped ones.", parameters: z.object({ id: z.string().describe("The user's wallet address."), is_all: z .boolean() .optional() - .describe("If true, includes all NFTs. Default is true."), + .describe( + "If false, only NFTs from verified collections. Default is true.", + ), chain_ids: z .string() .optional() .describe( - "Comma-separated chain IDs (e.g. 'eth,bsc,matic'). If omitted, includes all supported chains.", + "Comma-separated chain IDs (e.g. 'eth,bsc,matic') to restrict the fan-out to. If omitted, every chain the wallet has used is queried.", ), }), responseSchema: UserAllNftListSchema, - exampleCall: "await debank.user.getUserAllNftList({id: '0x...'})", - // DeBank's `/user/all_nft_list` aggregates across every chain server-side - // and can take 10-20 s for active wallets (vs. ~1.5 s for the per-chain - // `/user/nft_list`). The default 5 s wrapper rejects these legitimately - // in-flight responses. 20 s gives the upstream room to land while still - // leaving the agent ~10 s of the execute script's 30 s budget to project - // and return the data. + exampleCall: + "const r = await debank.user.getUserAllNftList({id: '0x...'}); return { count: r.nfts.length, chains: r.chains, partial: r.partial, skipped: r.chains_skipped };", + // Outer bound for the whole per-chain fan-out. The method finishes well + // inside this for typical wallets (chains / 8 × ~1.5 s) and enforces its + // own soft deadline at ~75% of the axios budget so it returns partial + // results before this hard abort fires (see _getUserNftsWithSkippedChains). timeoutMs: 20_000, }, { diff --git a/src/mcp/search-docs/cookbook/03-all-nft-holdings.md b/src/mcp/search-docs/cookbook/03-all-nft-holdings.md index 76b6cee..1930cb4 100644 --- a/src/mcp/search-docs/cookbook/03-all-nft-holdings.md +++ b/src/mcp/search-docs/cookbook/03-all-nft-holdings.md @@ -1,16 +1,27 @@ # List all NFTs across chains -Returns every NFT held by a wallet across all chains DeBank indexes. The response includes collection metadata, token IDs, and estimated USD values where available. This is the single call to use when you need a full picture of someone's NFT holdings. +Returns every NFT held by a wallet across the chains it has used. The call fans out the fast per-chain `nft_list` internally, so it stays well under the timeout even for whales — and if a chain fails or is too slow it is dropped into `chains_skipped` (with `partial: true`) instead of failing the whole call. + +The result is `{ nfts, chains, partial, chains_skipped }` — read `result.nfts` for the holdings (each carries its `chain`). ```js async function run(debank) { - const nfts = await debank.user.getUserAllNftList({ id: "0xWALLET" }); - // Each entry has: chain, contract_name, contract_id, inner_id, usd_price - return nfts.map((n) => ({ - chain: n.chain, - collection: n.contract_name, - tokenId: n.inner_id, - usd: n.usd_price ?? 0, - })); + const { nfts, chains, partial, chains_skipped } = + await debank.user.getUserAllNftList({ id: "0xWALLET" }); + // Each NFT has: chain, contract_name, contract_id, inner_id, usd_price + return { + count: nfts.length, + chains, + partial, // true if some chains were skipped — retry them one at a time + chains_skipped, + items: nfts.map((n) => ({ + chain: n.chain, + collection: n.contract_name, + tokenId: n.inner_id, + usd: n.usd_price ?? 0, + })), + }; } ``` + +To retry a skipped chain, restrict the fan-out with `chain_ids` (e.g. `getUserAllNftList({ id, chain_ids: "eth" })`) or call `getUserNftList({ id, chain_id })` directly. diff --git a/src/mcp/search-docs/embedded-index.ts b/src/mcp/search-docs/embedded-index.ts index d07bb9c..38fd0e0 100644 --- a/src/mcp/search-docs/embedded-index.ts +++ b/src/mcp/search-docs/embedded-index.ts @@ -677,7 +677,7 @@ export const ENTRIES: IndexEntry[] = [ name: "debank_get_user_all_nft_list", qualified: "debank.user.getUserAllNftList", description: - "Retrieve a user's NFT holdings across all supported chains. Provides an aggregate list of NFTs held by the user with details including contract ID, name, and content type. Can be filtered by specific chains.", + "Retrieve a user's NFT holdings across chains. Host-side aggregate: discovers the wallet's used chains, then fans out the fast per-chain nft_list calls in parallel (bounded concurrency). Replaces DeBank's /user/all_nft_list, which aggregates server-side and routinely times out for active wallets. Result shape: `{ nfts: [...], chains: [...], partial, chains_skipped: [...] }` — read `result.nfts` for the holdings (each carries its `chain`). If a chain fails or is too slow it is dropped and listed in `chains_skipped` with `partial: true`, so the rest still come back — narrow to one chain via `chain_ids` to retry the skipped ones.", params: { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", @@ -687,19 +687,21 @@ export const ENTRIES: IndexEntry[] = [ type: "string", }, is_all: { - description: "If true, includes all NFTs. Default is true.", + description: + "If false, only NFTs from verified collections. Default is true.", type: "boolean", }, chain_ids: { description: - "Comma-separated chain IDs (e.g. 'eth,bsc,matic'). If omitted, includes all supported chains.", + "Comma-separated chain IDs (e.g. 'eth,bsc,matic') to restrict the fan-out to. If omitted, every chain the wallet has used is queried.", type: "string", }, }, required: ["id"], additionalProperties: false, }, - exampleCall: "await debank.user.getUserAllNftList({id: '0x...'})", + exampleCall: + "const r = await debank.user.getUserAllNftList({id: '0x...'}); return { count: r.nfts.length, chains: r.chains, partial: r.partial, skipped: r.chains_skipped };", }, { kind: "method", @@ -985,7 +987,7 @@ export const ENTRIES: IndexEntry[] = [ id: "cookbook:03-all-nft-holdings.md", title: "List all NFTs across chains", content: - '# List all NFTs across chains\n\nReturns every NFT held by a wallet across all chains DeBank indexes. The response includes collection metadata, token IDs, and estimated USD values where available. This is the single call to use when you need a full picture of someone\'s NFT holdings.\n\n```js\nasync function run(debank) {\n const nfts = await debank.user.getUserAllNftList({ id: "0xWALLET" });\n // Each entry has: chain, contract_name, contract_id, inner_id, usd_price\n return nfts.map((n) => ({\n chain: n.chain,\n collection: n.contract_name,\n tokenId: n.inner_id,\n usd: n.usd_price ?? 0,\n }));\n}\n```\n', + '# List all NFTs across chains\n\nReturns every NFT held by a wallet across the chains it has used. The call fans out the fast per-chain `nft_list` internally, so it stays well under the timeout even for whales — and if a chain fails or is too slow it is dropped into `chains_skipped` (with `partial: true`) instead of failing the whole call.\n\nThe result is `{ nfts, chains, partial, chains_skipped }` — read `result.nfts` for the holdings (each carries its `chain`).\n\n```js\nasync function run(debank) {\n const { nfts, chains, partial, chains_skipped } =\n await debank.user.getUserAllNftList({ id: "0xWALLET" });\n // Each NFT has: chain, contract_name, contract_id, inner_id, usd_price\n return {\n count: nfts.length,\n chains,\n partial, // true if some chains were skipped — retry them one at a time\n chains_skipped,\n items: nfts.map((n) => ({\n chain: n.chain,\n collection: n.contract_name,\n tokenId: n.inner_id,\n usd: n.usd_price ?? 0,\n })),\n };\n}\n```\n\nTo retry a skipped chain, restrict the fan-out with `chain_ids` (e.g. `getUserAllNftList({ id, chain_ids: "eth" })`) or call `getUserNftList({ id, chain_id })` directly.\n', }, { kind: "prose", diff --git a/src/mcp/search-docs/tool.test.ts b/src/mcp/search-docs/tool.test.ts index 40a3a08..b3079e9 100644 --- a/src/mcp/search-docs/tool.test.ts +++ b/src/mcp/search-docs/tool.test.ts @@ -2,10 +2,16 @@ import { describe, expect, it } from "vitest"; import { searchDocsTool } from "./tool.js"; describe("search_docs", () => { - it("returns getUserNftList for 'get NFTs for wallet'", async () => { + it("surfaces the NFT-list endpoints for 'get NFTs for wallet'", async () => { const res = await searchDocsTool.execute({ query: "get NFTs for wallet" }); const inner = JSON.parse(res.content[0]?.text); - expect(inner.results[0].qualified).toMatch(/getUserNftList/i); + // The chain-less aggregate is the best match for a wallet-wide query (the + // per-chain lister needs a chain_id); both surface in the top results. + expect(inner.results[0].qualified).toBe("debank.user.getUserAllNftList"); + const top = inner.results + .slice(0, 3) + .map((r: { qualified: string }) => r.qualified); + expect(top).toContain("debank.user.getUserNftList"); }); it("returns empty results + hint for blank query", async () => { diff --git a/src/services/index.ts b/src/services/index.ts index bc2c6fc..86b755f 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -14,7 +14,7 @@ export { ChainService } from "./chain.service.js"; export { ProtocolService } from "./protocol.service.js"; export { TokenService } from "./token.service.js"; export { TransactionService } from "./transaction.service.js"; -export { UserService } from "./user.service.js"; +export { NFT_FANOUT_CONCURRENCY, UserService } from "./user.service.js"; // Create singleton instances export const chainService = new ChainService(); diff --git a/src/services/user.service.test.ts b/src/services/user.service.test.ts index 78d2e34..61fd1a0 100644 --- a/src/services/user.service.test.ts +++ b/src/services/user.service.test.ts @@ -12,7 +12,7 @@ import { UserTokenAuthorizedListSchema, UserTotalNetCurveSchema, } from "../mcp/legacy/response-schemas.js"; -import { userService } from "./index.js"; +import { NFT_FANOUT_CONCURRENCY, userService } from "./index.js"; vi.mock("../lib/entity-resolver.js", () => ({ resolveChain: vi.fn() })); @@ -968,3 +968,197 @@ describe("getTokenBalanceAcrossChainsRaw", () => { expect(r.mixed_representations).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// getUserAllNftListRaw — per-chain fan-out (replaces the slow /user/all_nft_list +// server-side aggregate) with partial-on-failure + partial-on-timeout. +// --------------------------------------------------------------------------- +const nft = (chain: string, inner_id: string, over: any = {}) => ({ + id: `${chain}-${inner_id}`, + contract_id: `0xcontract-${chain}`, + inner_id, + chain, + name: `NFT ${inner_id}`, + description: "", + content_type: "image", + content: "", + thumbnail_url: "", + total_supply: 1, + attributes: [], + ...over, +}); + +const delay = (ms: number, value: T): Promise => + new Promise((resolve) => { + const t = setTimeout(() => resolve(value), ms); + t.unref?.(); + }); + +describe("userService.getUserAllNftListRaw (per-chain fan-out)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("discovers chains via used_chain_list, fans out per-chain, and flattens", async () => { + const usedSpy = vi + .spyOn(userService, "getUserUsedChainListRaw") + .mockResolvedValue([{ chain_id: "eth" }, { chain_id: "bsc" }]); + const nftSpy = vi + .spyOn(userService, "getUserNftListRaw") + .mockImplementation(async (args) => { + if (args.chain_id === "eth") return [nft("eth", "1")]; + if (args.chain_id === "bsc") return [nft("bsc", "2")]; + throw new Error(`unexpected chain ${args.chain_id}`); + }); + + const result = await userService.getUserAllNftListRaw({ id: WALLET }); + + expect(usedSpy).toHaveBeenCalledTimes(1); + expect(nftSpy).toHaveBeenCalledTimes(2); + expect(result.nfts.map((n) => n.inner_id).sort()).toEqual(["1", "2"]); + expect(result.chains.sort()).toEqual(["bsc", "eth"]); + expect(result.partial).toBe(false); + expect(result.chains_skipped).toEqual([]); + }); + + it("honors explicit chain_ids and skips the used_chain_list call", async () => { + const usedSpy = vi.spyOn(userService, "getUserUsedChainListRaw"); + const nftSpy = vi + .spyOn(userService, "getUserNftListRaw") + .mockImplementation(async (args) => [nft(args.chain_id, "x")]); + + const result = await userService.getUserAllNftListRaw({ + id: WALLET, + chain_ids: "eth, arb", + is_all: false, + }); + + expect(usedSpy).not.toHaveBeenCalled(); + const chains = nftSpy.mock.calls.map((c) => (c[0] as any).chain_id).sort(); + expect(chains).toEqual(["arb", "eth"]); + // is_all is threaded through to each per-chain call. + expect((nftSpy.mock.calls[0]?.[0] as any).is_all).toBe(false); + expect(result.nfts.length).toBe(2); + }); + + it("returns empty without fanning out when the wallet has no used chains", async () => { + vi.spyOn(userService, "getUserUsedChainListRaw").mockResolvedValue([]); + const nftSpy = vi.spyOn(userService, "getUserNftListRaw"); + + const result = await userService.getUserAllNftListRaw({ id: WALLET }); + + expect(nftSpy).not.toHaveBeenCalled(); + expect(result.nfts).toEqual([]); + expect(result.partial).toBe(false); + expect(result.chains_skipped).toEqual([]); + }); + + it("stamps chain_id onto items whose upstream response omits `chain`", async () => { + vi.spyOn(userService, "getUserUsedChainListRaw").mockResolvedValue([ + { chain_id: "op" }, + ]); + vi.spyOn(userService, "getUserNftListRaw").mockResolvedValue([ + // Per-chain /user/nft_list often omits the `chain` field. + nft("", "9", { chain: undefined }), + ] as any); + + const result = await userService.getUserAllNftListRaw({ id: WALLET }); + + expect(result.nfts[0]?.chain).toBe("op"); + expect(result.chains).toEqual(["op"]); + }); + + it("returns partial results when one chain's nft_list rejects", async () => { + vi.spyOn(userService, "getUserUsedChainListRaw").mockResolvedValue([ + { chain_id: "eth" }, + { chain_id: "bsc" }, + ]); + vi.spyOn(userService, "getUserNftListRaw").mockImplementation( + async (args) => { + if (args.chain_id === "eth") return [nft("eth", "1")]; + throw new Error("upstream 500"); + }, + ); + + const result = await userService.getUserAllNftListRaw({ id: WALLET }); + + expect(result.nfts.map((n) => n.chain)).toEqual(["eth"]); + expect(result.partial).toBe(true); + expect(result.chains_skipped).toEqual(["bsc"]); + }); + + it("abandons a chain that exceeds the soft deadline and returns partial", async () => { + vi.spyOn(userService, "getUserUsedChainListRaw").mockResolvedValue([ + { chain_id: "eth" }, + { chain_id: "slow" }, + ]); + vi.spyOn(userService, "getUserNftListRaw").mockImplementation( + async (args) => { + if (args.chain_id === "eth") return [nft("eth", "1")]; + return delay(1_000, [nft("slow", "2")]); // never lands before soft budget + }, + ); + + // options.timeout = 200 → soft budget is a fraction of it (< 200ms). + const result = await userService.getUserAllNftListRaw( + { id: WALLET }, + { timeout: 200 }, + ); + + expect(result.nfts.map((n) => n.chain)).toEqual(["eth"]); + expect(result.partial).toBe(true); + expect(result.chains_skipped).toEqual(["slow"]); + }, 5_000); + + it("caps in-flight per-chain calls at NFT_FANOUT_CONCURRENCY", async () => { + const chainCount = NFT_FANOUT_CONCURRENCY + 4; + vi.spyOn(userService, "getUserUsedChainListRaw").mockResolvedValue( + Array.from({ length: chainCount }, (_, i) => ({ chain_id: `c${i}` })), + ); + let inFlight = 0; + let peak = 0; + vi.spyOn(userService, "getUserNftListRaw").mockImplementation( + async (args) => { + inFlight++; + peak = Math.max(peak, inFlight); + await delay(15, null); + inFlight--; + return [nft(args.chain_id, "1")]; + }, + ); + + const result = await userService.getUserAllNftListRaw({ id: WALLET }); + + expect(peak).toBeLessThanOrEqual(NFT_FANOUT_CONCURRENCY); + expect(result.nfts.length).toBe(chainCount); + expect(result.partial).toBe(false); + }, 5_000); + + it("rejects with AbortError when the scope signal is already aborted", async () => { + const ac = new AbortController(); + ac.abort(); + const nftSpy = vi.spyOn(userService, "getUserNftListRaw"); + const usedSpy = vi.spyOn(userService, "getUserUsedChainListRaw"); + + await expect( + userService.getUserAllNftListRaw({ id: WALLET }, { signal: ac.signal }), + ).rejects.toThrow(/abort/i); + expect(usedSpy).not.toHaveBeenCalled(); + expect(nftSpy).not.toHaveBeenCalled(); + }); + + it("propagates abort (not a skip) when a per-chain call fails while aborted", async () => { + const ac = new AbortController(); + vi.spyOn(userService, "getUserUsedChainListRaw").mockResolvedValue([ + { chain_id: "eth" }, + ]); + vi.spyOn(userService, "getUserNftListRaw").mockImplementation(async () => { + ac.abort(); + throw ac.signal.reason ?? new Error("aborted"); + }); + + await expect( + userService.getUserAllNftListRaw({ id: WALLET }, { signal: ac.signal }), + ).rejects.toThrow(); + }); +}); diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 47b8092..23be92b 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -24,6 +24,50 @@ import { BaseService, type RequestOptions } from "./base.service.js"; const logger = createChildLogger("DeBank User Service"); +/** + * Max per-chain NFT `nft_list` requests in flight at once during the + * `getUserAllNftListRaw` fan-out. A whale can have 30–70 used chains; an + * unbounded `Promise.all` would burst that many gateway requests at once. A + * small pool keeps wall-clock ≈ (chains / cap) × ~1.5 s while staying polite to + * upstream. Exported for the fan-out concurrency test. + */ +export const NFT_FANOUT_CONCURRENCY = 8; + +/** + * Fraction of the per-call `options.timeout` (the client's axios budget) that + * the NFT fan-out treats as its own soft deadline. The execute client races the + * whole `getUserAllNftListRaw` call against a hard abort at `timeoutMs`; if we + * ran right up to it the guest would get a bare timeout error and NOTHING. + * Instead we stop waiting on stragglers at ~75 % of the budget, mark them + * skipped, and RESOLVE with the chains that landed — partial-on-timeout rather + * than all-or-nothing. Only applied when a finite `options.timeout` is present. + */ +const NFT_FANOUT_SOFT_BUDGET_RATIO = 0.75; + +/** + * Run `worker(i)` for every index in `[0, count)` with at most `limit` in + * flight. Resolves once all workers settle; rejects on the first worker that + * throws (used to propagate scope-cancellation as fatal). Per-chain *errors* + * are swallowed inside the worker, so only genuine aborts reject here. + */ +async function mapWithConcurrency( + count: number, + limit: number, + worker: (index: number) => Promise, +): Promise { + let next = 0; + const runner = async () => { + while (true) { + const i = next++; + if (i >= count) return; + await worker(i); + } + }; + await Promise.all( + Array.from({ length: Math.min(limit, count) }, () => runner()), + ); +} + const logAndWrapError = (context: string, error: unknown): Error => { if (error instanceof Error) { logger.error(context, error); @@ -428,36 +472,162 @@ export class UserService extends BaseService { } } - async getUserAllNftListRaw( - args: { - id: string; - is_all?: boolean; - chain_ids?: string; - }, + /** + * Fan out per-chain NFT holdings and collect the ones that land, tracking + * chains that failed or were abandoned at the soft deadline. Mirrors + * `_getUserTokensWithSkippedChains` but for NFTs: + * 1. Chains come from `chain_ids` (explicit) or `getUserUsedChainListRaw` + * — used-chain-list, NOT total_balance.chain_list, because NFTs can sit + * on chains with ~0 token USD value. + * 2. Bounded-concurrency fan-out of `getUserNftListRaw` per chain. + * 3. A per-chain error → that chain is skipped (best-effort), NOT fatal. + * 4. A soft deadline (fraction of the client's `options.timeout`) abandons + * stragglers so we return partial before the client's hard abort fires. + * Scope cancellation (an aborted `options.signal`) is always fatal and + * propagates as an AbortError — it is never treated as a skip. + * + * Each returned NFT is stamped with its `chain` (the per-chain + * `/user/nft_list` response can omit it), so callers can group by chain. + */ + async _getUserNftsWithSkippedChains( + args: { id: string; is_all?: boolean; chain_ids?: string }, options?: RequestOptions, - ): Promise { + ): Promise<{ nfts: UserNFT[]; skipped: string[] }> { + const throwIfAborted = () => { + if (options?.signal?.aborted) { + throw ( + options.signal.reason ?? + new DOMException("This operation was aborted", "AbortError") + ); + } + }; + throwIfAborted(); try { - const params = new URLSearchParams({ - id: args.id, - ...(args.is_all !== undefined && { - is_all: args.is_all.toString(), - }), - ...(args.chain_ids !== undefined && { chain_ids: args.chain_ids }), - }); + const targetChains = args.chain_ids + ? args.chain_ids + .split(",") + .map((c) => c.trim()) + .filter(Boolean) + : (await this.getUserUsedChainListRaw({ id: args.id }, options)) + .map((c) => c?.chain_id) + .filter((id): id is string => Boolean(id)); + throwIfAborted(); + if (targetChains.length === 0) return { nfts: [], skipped: [] }; - return await this.fetchWithToolConfig( - `${this.baseUrl}/user/all_nft_list?${params}`, - this.DEFAULT_CACHE_TTL_SECONDS, - options, + // Filled positionally as each chain lands; unset slots at the soft + // deadline become skips. + const collected: (UserNFT[] | undefined)[] = new Array( + targetChains.length, ); + const done = new Set(); + const skipped: string[] = []; + + const worker = async (i: number) => { + const chain_id = targetChains[i] as string; + try { + const list = await this.getUserNftListRaw( + { id: args.id, chain_id, is_all: args.is_all }, + options, + ); + // The per-chain endpoint often omits `chain` on each item; stamp it + // so downstream grouping (and `chains`) is reliable. + collected[i] = list.map((n) => ({ + ...n, + chain: n.chain ?? chain_id, + })); + } catch (err) { + if (options?.signal?.aborted) throw err; // cancellation is NOT a skip + logger.warn( + `Skipping chain ${chain_id} NFTs for user ${args.id} due to upstream error`, + err as Error, + ); + collected[i] = []; + skipped.push(chain_id); + } finally { + done.add(i); + } + }; + + const allDone = mapWithConcurrency( + targetChains.length, + NFT_FANOUT_CONCURRENCY, + worker, + ); + const softBudgetMs = + options?.timeout && Number.isFinite(options.timeout) + ? options.timeout * NFT_FANOUT_SOFT_BUDGET_RATIO + : Number.POSITIVE_INFINITY; + + if (Number.isFinite(softBudgetMs)) { + let timer: NodeJS.Timeout | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(resolve, softBudgetMs); + timer.unref?.(); + }); + try { + await Promise.race([allDone, deadline]); + } finally { + if (timer) clearTimeout(timer); + } + // The fan-out may still be running if the deadline won; keep its late + // rejection from surfacing as an unhandled rejection. The execute + // scope's cancellation (post-call) aborts any lingering upstream work. + void allDone.catch(() => {}); + } else { + await allDone; + } + // A genuine scope abort during the fan-out must surface, not return partial. + throwIfAborted(); + + for (let i = 0; i < targetChains.length; i++) { + if (!done.has(i)) { + skipped.push(targetChains[i] as string); + collected[i] = []; + } + } + return { nfts: collected.flatMap((c) => c ?? []), skipped }; } catch (error) { + throwIfAborted(); throw logAndWrapError( - `Failed to fetch all NFT list for user ${args.id}`, + `Failed to fetch NFTs across chains for user ${args.id}`, error, ); } } + /** + * Aggregate NFT holdings across chains. Replaces DeBank's `/user/all_nft_list` + * server-side aggregate, which takes 10–20 s for active wallets and routinely + * times out to nothing. Fans out the fast per-chain `/user/nft_list` instead + * (see `_getUserNftsWithSkippedChains`) and degrades to a partial result — + * `partial: true` with the failed/slow chains in `chains_skipped` — rather + * than failing the whole call. + */ + async getUserAllNftListRaw( + args: { + id: string; + is_all?: boolean; + chain_ids?: string; + }, + options?: RequestOptions, + ): Promise<{ + nfts: UserNFT[]; + chains: string[]; + partial: boolean; + chains_skipped: string[]; + }> { + const { nfts, skipped } = await this._getUserNftsWithSkippedChains( + args, + options, + ); + return { + nfts, + chains: [...new Set(nfts.map((n) => n.chain))], + partial: skipped.length > 0, + chains_skipped: skipped, + }; + } + async getUserHistoryListRaw( args: { id: string; From 50ce066f12f4cd7fa3b430405206970bd1b22676 Mon Sep 17 00:00:00 2001 From: Aliu Salaudeen Date: Sat, 4 Jul 2026 20:06:04 +0100 Subject: [PATCH 3/5] fix(nft): read `id` (not `chain_id`) from used_chain_list in the fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeBank's /user/used_chain_list returns full chain objects keyed by `id` (verified against the live gateway: 69 chains for vitalik.eth, each with `id`/`community_id`/`name`/... and no `chain_id`). The NFT fan-out read `c.chain_id`, which was always undefined, so every chain was filtered out and getUserAllNftList early-returned empty for EVERY wallet — not just whales. Read `c.id` instead, and correct getUserUsedChainListRaw's return type ({ chain_id } -> { id }) and UserUsedChainListSchema (chain_id -> id, plus passthrough to reflect the full chain object DeBank actually returns). The fan-out unit tests had mocked used_chain_list in the wrong { chain_id } shape, encoding the same false assumption, so they passed while the code was broken. They now use the real { id } shape (and a schema fixture locks it), so the "reads a field that is always undefined" regression can't ship again. --- src/mcp/legacy/response-schemas.ts | 13 +++++---- src/services/user.service.test.ts | 43 ++++++++++++++++++++++++------ src/services/user.service.ts | 8 +++--- 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/mcp/legacy/response-schemas.ts b/src/mcp/legacy/response-schemas.ts index 9df7e7c..9e99365 100644 --- a/src/mcp/legacy/response-schemas.ts +++ b/src/mcp/legacy/response-schemas.ts @@ -165,13 +165,16 @@ export const TokenHistoryPriceSchema = TokenHistoricalPriceSchema; // ============================================================================ /** - * getUserUsedChainListRaw returns a minimal shape — only chain_id per entry, - * not the full ChainInfo object. + * getUserUsedChainListRaw returns full chain objects keyed by `id` (e.g. + * { id: "eth", community_id, name, native_token_id, ... }) — NOT `chain_id`. + * Only `id` is required for our use; passthrough preserves the rest. */ export const UserUsedChainListSchema = z.array( - z.object({ - chain_id: z.string().describe("DeBank chain ID the user has used."), - }), + z + .object({ + id: z.string().describe("DeBank chain ID the user has used."), + }) + .passthrough(), ); /** debank.user.getUserChainBalance */ diff --git a/src/services/user.service.test.ts b/src/services/user.service.test.ts index 61fd1a0..bd5f8b2 100644 --- a/src/services/user.service.test.ts +++ b/src/services/user.service.test.ts @@ -11,6 +11,7 @@ import { UserNftAuthorizedListSchema, UserTokenAuthorizedListSchema, UserTotalNetCurveSchema, + UserUsedChainListSchema, } from "../mcp/legacy/response-schemas.js"; import { NFT_FANOUT_CONCURRENCY, userService } from "./index.js"; @@ -509,6 +510,29 @@ describe("userService — endpoint contract regression tests", () => { * the parse, but missing required fields or type mismatches still do. */ describe("response schemas validate against realistic fixtures", () => { + it("UserUsedChainListSchema keys each chain by `id` (the real gateway shape)", () => { + // Verified against the live gateway for vitalik.eth: /user/used_chain_list + // returns full chain objects keyed by `id`, NOT `chain_id`. This fixture + // locks that so the fan-out's chain discovery can't silently regress to + // reading a field that is always undefined. + const fixture = [ + { + id: "bb", + community_id: 288, + name: "Combo", + native_token_id: "bb", + logo_url: "https://static.debank.com/image/chain/logo_url/bb/x.png", + wrapped_token_id: "0x0000", + is_support_pre_exec: true, + born_at: 1660000000, + }, + { id: "eth", community_id: 1, name: "Ethereum" }, + ]; + const result = UserUsedChainListSchema.safeParse(fixture); + expect(result.success).toBe(true); + expect(result.data?.[0]?.id).toBe("bb"); + }); + it("UserTotalNetCurveSchema parses a bare-array curve response", () => { // Two-point fixture (real responses are 288 points; shape per point is // what we assert). @@ -1002,7 +1026,10 @@ describe("userService.getUserAllNftListRaw (per-chain fan-out)", () => { it("discovers chains via used_chain_list, fans out per-chain, and flattens", async () => { const usedSpy = vi .spyOn(userService, "getUserUsedChainListRaw") - .mockResolvedValue([{ chain_id: "eth" }, { chain_id: "bsc" }]); + // used_chain_list keys each chain by `id` (the real DeBank shape), NOT + // `chain_id` — mocking the wrong shape here is exactly what let the + // "reads .chain_id → always empty" bug ship. + .mockResolvedValue([{ id: "eth" }, { id: "bsc" }]); const nftSpy = vi .spyOn(userService, "getUserNftListRaw") .mockImplementation(async (args) => { @@ -1055,7 +1082,7 @@ describe("userService.getUserAllNftListRaw (per-chain fan-out)", () => { it("stamps chain_id onto items whose upstream response omits `chain`", async () => { vi.spyOn(userService, "getUserUsedChainListRaw").mockResolvedValue([ - { chain_id: "op" }, + { id: "op" }, ]); vi.spyOn(userService, "getUserNftListRaw").mockResolvedValue([ // Per-chain /user/nft_list often omits the `chain` field. @@ -1070,8 +1097,8 @@ describe("userService.getUserAllNftListRaw (per-chain fan-out)", () => { it("returns partial results when one chain's nft_list rejects", async () => { vi.spyOn(userService, "getUserUsedChainListRaw").mockResolvedValue([ - { chain_id: "eth" }, - { chain_id: "bsc" }, + { id: "eth" }, + { id: "bsc" }, ]); vi.spyOn(userService, "getUserNftListRaw").mockImplementation( async (args) => { @@ -1089,8 +1116,8 @@ describe("userService.getUserAllNftListRaw (per-chain fan-out)", () => { it("abandons a chain that exceeds the soft deadline and returns partial", async () => { vi.spyOn(userService, "getUserUsedChainListRaw").mockResolvedValue([ - { chain_id: "eth" }, - { chain_id: "slow" }, + { id: "eth" }, + { id: "slow" }, ]); vi.spyOn(userService, "getUserNftListRaw").mockImplementation( async (args) => { @@ -1113,7 +1140,7 @@ describe("userService.getUserAllNftListRaw (per-chain fan-out)", () => { it("caps in-flight per-chain calls at NFT_FANOUT_CONCURRENCY", async () => { const chainCount = NFT_FANOUT_CONCURRENCY + 4; vi.spyOn(userService, "getUserUsedChainListRaw").mockResolvedValue( - Array.from({ length: chainCount }, (_, i) => ({ chain_id: `c${i}` })), + Array.from({ length: chainCount }, (_, i) => ({ id: `c${i}` })), ); let inFlight = 0; let peak = 0; @@ -1150,7 +1177,7 @@ describe("userService.getUserAllNftListRaw (per-chain fan-out)", () => { it("propagates abort (not a skip) when a per-chain call fails while aborted", async () => { const ac = new AbortController(); vi.spyOn(userService, "getUserUsedChainListRaw").mockResolvedValue([ - { chain_id: "eth" }, + { id: "eth" }, ]); vi.spyOn(userService, "getUserNftListRaw").mockImplementation(async () => { ac.abort(); diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 23be92b..a3fd7ca 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -83,9 +83,11 @@ export class UserService extends BaseService { async getUserUsedChainListRaw( args: { id: string }, options?: RequestOptions, - ): Promise<{ chain_id: string }[]> { + ): Promise<{ id: string }[]> { try { - return await this.fetchWithToolConfig<{ chain_id: string }[]>( + // DeBank's /user/used_chain_list returns full chain objects keyed by + // `id` (e.g. { id: "eth", community_id, name, ... }), NOT `chain_id`. + return await this.fetchWithToolConfig<{ id: string }[]>( `${this.baseUrl}/user/used_chain_list?id=${args.id}`, this.DEFAULT_CACHE_TTL_SECONDS, options, @@ -509,7 +511,7 @@ export class UserService extends BaseService { .map((c) => c.trim()) .filter(Boolean) : (await this.getUserUsedChainListRaw({ id: args.id }, options)) - .map((c) => c?.chain_id) + .map((c) => c?.id) .filter((id): id is string => Boolean(id)); throwIfAborted(); if (targetChains.length === 0) return { nfts: [], skipped: [] }; From d76591d638a79b0869122ec6ac1c93d0c9deb514 Mon Sep 17 00:00:00 2001 From: Aliu Salaudeen Date: Sat, 4 Jul 2026 20:19:20 +0100 Subject: [PATCH 4/5] fix(review): precompute array sizes for sort; clone skipped to prevent late mutation Addresses two findings from PR review: - result-budget.ts: the object-truncation sort called JSON.stringify inside the comparator, re-serializing potentially large arrays on every O(M log M) comparison. Precompute each array's serialized length once (O(M)) into a Map. - user.service.ts: when the NFT fan-out's soft deadline wins the race, straggler workers keep running; a late non-abort rejection runs skipped.push(chain_id), which mutated the by-reference chains_skipped AFTER the method returned (duplicate entry). Return a clone so the handed-back array is frozen. Adds a regression test that rejects a straggler post-deadline and asserts the returned chains_skipped does not grow. --- src/mcp/execute/result-budget.ts | 12 +++++++----- src/services/user.service.test.ts | 28 ++++++++++++++++++++++++++++ src/services/user.service.ts | 6 +++++- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/mcp/execute/result-budget.ts b/src/mcp/execute/result-budget.ts index fac861b..31d113d 100644 --- a/src/mcp/execute/result-budget.ts +++ b/src/mcp/execute/result-budget.ts @@ -136,12 +136,14 @@ export function boundExecuteResult( // serialized above, so `result` is a finite, acyclic JSON value. const clone = JSON.parse(JSON.stringify(result)) as Record; const arrays = collectArrays(clone, ""); - // Largest first so trimming the biggest array usually settles it in one pass. - arrays.sort( - (a, b) => - JSON.stringify(b.parent[b.key]).length - - JSON.stringify(a.parent[a.key]).length, + // Largest first so trimming the biggest array usually settles it in one + // pass. Precompute each array's serialized length ONCE (O(M)) — calling + // JSON.stringify inside the comparator would re-serialize potentially huge + // arrays on every one of the O(M log M) comparisons. + const sizes = new Map( + arrays.map((a) => [a, JSON.stringify(a.parent[a.key]).length]), ); + arrays.sort((a, b) => (sizes.get(b) ?? 0) - (sizes.get(a) ?? 0)); const fieldsTrunc: FieldTruncation[] = []; for (const a of arrays) { if ( diff --git a/src/services/user.service.test.ts b/src/services/user.service.test.ts index bd5f8b2..37becb3 100644 --- a/src/services/user.service.test.ts +++ b/src/services/user.service.test.ts @@ -1137,6 +1137,34 @@ describe("userService.getUserAllNftListRaw (per-chain fan-out)", () => { expect(result.chains_skipped).toEqual(["slow"]); }, 5_000); + it("does not mutate the returned chains_skipped when a straggler rejects after the soft deadline", async () => { + vi.spyOn(userService, "getUserUsedChainListRaw").mockResolvedValue([ + { id: "eth" }, + { id: "slow" }, + ]); + vi.spyOn(userService, "getUserNftListRaw").mockImplementation( + async (args) => { + if (args.chain_id === "eth") return [nft("eth", "1")]; + // Rejects AFTER the soft deadline — its catch would push "slow" + // again onto a by-reference skipped array post-return. + return new Promise((_, reject) => + setTimeout(() => reject(new Error("late 500")), 120), + ); + }, + ); + + // timeout 60 → soft budget 45ms; the straggler rejects at 120ms. + const result = await userService.getUserAllNftListRaw( + { id: WALLET }, + { timeout: 60 }, + ); + expect(result.chains_skipped).toEqual(["slow"]); + + // Let the late rejection fire; the already-returned array must be frozen. + await delay(150, null); + expect(result.chains_skipped).toEqual(["slow"]); // no duplicate "slow" + }, 5_000); + it("caps in-flight per-chain calls at NFT_FANOUT_CONCURRENCY", async () => { const chainCount = NFT_FANOUT_CONCURRENCY + 4; vi.spyOn(userService, "getUserUsedChainListRaw").mockResolvedValue( diff --git a/src/services/user.service.ts b/src/services/user.service.ts index a3fd7ca..7154656 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -587,7 +587,11 @@ export class UserService extends BaseService { collected[i] = []; } } - return { nfts: collected.flatMap((c) => c ?? []), skipped }; + // Clone `skipped`: if the soft deadline won, straggler workers may still + // be running and could `skipped.push(...)` on a late rejection — that + // must not mutate the array we hand back. (`flatMap` already returns a + // fresh array, so `nfts` is isolated too.) + return { nfts: collected.flatMap((c) => c ?? []), skipped: [...skipped] }; } catch (error) { throwIfAborted(); throw logAndWrapError( From 8e03ffeaeb7b84b7bd62025721dbfafda7bc0e14 Mon Sep 17 00:00:00 2001 From: Aliu Salaudeen Date: Sat, 4 Jul 2026 21:48:54 +0100 Subject: [PATCH 5/5] fix(nft): normalize and dedupe chain_ids before the fan-out Addresses a PR review finding: duplicate or mixed-case chain_ids (e.g. "ETH, eth") fanned out redundant parallel per-chain requests and double-counted NFTs, and non-lowercase ids could fail upstream. Trim + lowercase (every DeBank chain id is lowercase) + dedupe via Set, applied uniformly to both the explicit chain_ids path and the used_chain_list path. Adds a regression test. --- src/services/user.service.test.ts | 18 ++++++++++++++++++ src/services/user.service.ts | 22 ++++++++++++++-------- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/services/user.service.test.ts b/src/services/user.service.test.ts index 37becb3..855d40b 100644 --- a/src/services/user.service.test.ts +++ b/src/services/user.service.test.ts @@ -1068,6 +1068,24 @@ describe("userService.getUserAllNftListRaw (per-chain fan-out)", () => { expect(result.nfts.length).toBe(2); }); + it("normalizes and dedupes chain_ids (trim + lowercase + Set) before fanning out", async () => { + const usedSpy = vi.spyOn(userService, "getUserUsedChainListRaw"); + const nftSpy = vi + .spyOn(userService, "getUserNftListRaw") + .mockImplementation(async (args) => [nft(args.chain_id, "x")]); + + const result = await userService.getUserAllNftListRaw({ + id: WALLET, + chain_ids: "ETH, eth, BSC ", // duplicate + mixed-case + whitespace + }); + + expect(usedSpy).not.toHaveBeenCalled(); + // "ETH"/"eth" collapse to one; all lowercased & trimmed → exactly 2 calls. + const chains = nftSpy.mock.calls.map((c) => (c[0] as any).chain_id).sort(); + expect(chains).toEqual(["bsc", "eth"]); + expect(result.nfts.length).toBe(2); // no duplicate eth entry + }); + it("returns empty without fanning out when the wallet has no used chains", async () => { vi.spyOn(userService, "getUserUsedChainListRaw").mockResolvedValue([]); const nftSpy = vi.spyOn(userService, "getUserNftListRaw"); diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 7154656..34bb2d3 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -505,15 +505,21 @@ export class UserService extends BaseService { }; throwIfAborted(); try { - const targetChains = args.chain_ids - ? args.chain_ids - .split(",") - .map((c) => c.trim()) - .filter(Boolean) - : (await this.getUserUsedChainListRaw({ id: args.id }, options)) - .map((c) => c?.id) - .filter((id): id is string => Boolean(id)); + const rawChains = args.chain_ids + ? args.chain_ids.split(",") + : (await this.getUserUsedChainListRaw({ id: args.id }, options)).map( + (c) => c?.id, + ); throwIfAborted(); + // Normalize (trim + lowercase — every DeBank chain id is lowercase) and + // dedupe so duplicate or mixed-case input (e.g. "ETH, eth") can't fan out + // redundant per-chain requests or double-count NFTs. `?? ""` guards the + // runtime-nullable ids DeBank can return. + const targetChains = [ + ...new Set( + rawChains.map((c) => (c ?? "").trim().toLowerCase()).filter(Boolean), + ), + ]; if (targetChains.length === 0) return { nfts: [], skipped: [] }; // Filled positionally as each chain lands; unset slots at the soft