Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 71 additions & 30 deletions plugins/tonco-dex/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,9 @@ const toncoGetPoolStats = {

execute: async (params) => {
try {
if (!params.pool_address) {
return { success: false, error: "pool_address parameter is required" };
}
const poolAddr = normalizeToRaw(params.pool_address);

const query = `
Expand Down Expand Up @@ -1063,6 +1066,10 @@ const toncoGetPositions = {
const includeClosed = params.include_closed ?? false;
const limit = params.limit ?? 20;

// NOTE: The TONCO indexer's Position.pool field is a String! (raw pool address),
// NOT an embedded object — querying subfields on it causes a GraphQL validation
// error: "Field 'pool' must not have a selection since type 'String!' has no
// subfields." Token info (jetton0/jetton1) is available directly on Position.
const query = `
query GetPositions($where: PositionWhere, $filter: Filter) {
positions(where: $where, filter: $filter) {
Expand All @@ -1085,17 +1092,9 @@ const toncoGetPositions = {
feeGrowthInside1LastX128
creationTime
closingTime
pool {
address
version
fee
tick
tickSpacing
priceSqrt
liquidity
jetton0 { address symbol decimals }
jetton1 { address symbol decimals }
}
pool
jetton0 { address symbol decimals }
jetton1 { address symbol decimals }
}
}
`;
Expand All @@ -1113,8 +1112,13 @@ const toncoGetPositions = {

let positions = data.positions ?? [];

// Sort by creation time descending client-side
positions.sort((a, b) => (b.creationTime ?? 0) - (a.creationTime ?? 0));
// Sort by creation time descending client-side.
// creationTime is returned as an ISO date string by the indexer.
positions.sort((a, b) => {
const ta = a.creationTime ? new Date(a.creationTime).getTime() : 0;
const tb = b.creationTime ? new Date(b.creationTime).getTime() : 0;
return tb - ta;
});

// Filter closed positions if not requested
if (!includeClosed) {
Expand All @@ -1126,25 +1130,61 @@ const toncoGetPositions = {
// Apply limit
positions = positions.slice(0, limit);

// Fetch pool-level data (tick, version, fee, tickSpacing) for in-range calculation.
// The indexer's Position type only carries a pool address string, so we need a
// separate pools query. Deduplicate pool addresses first.
const uniquePoolAddrs = [...new Set(positions.map((p) => p.pool).filter(Boolean))];
let poolInfoMap = {};
if (uniquePoolAddrs.length > 0) {
try {
const poolQuery = `
query GetPoolsForPositions($where: PoolWhere, $filter: Filter) {
pools(where: $where, filter: $filter) {
address
version
fee
tick
tickSpacing
}
}
`;
// NOTE: The indexer does not support an IN filter for addresses, so we
// request a broader set and filter client-side. We use a large first
// value and rely on the caller having a small number of distinct pools.
const poolData = await gqlQuery(poolQuery, {
where: { isInitialized: true },
filter: { first: 400 },
});
for (const pool of poolData.pools ?? []) {
poolInfoMap[pool.address] = pool;
}
} catch {
// Pool info unavailable — in-range calculation will fall back to null
}
}

const result = positions.map((p) => {
const pool = p.pool ?? {};
const dec0 = pool.jetton0?.decimals ?? 9;
const dec1 = pool.jetton1?.decimals ?? 9;
const poolAddr = p.pool;
const poolInfo = poolInfoMap[poolAddr] ?? {};
const dec0 = p.jetton0?.decimals ?? 9;
const dec1 = p.jetton1?.decimals ?? 9;
const liq = p.liquidity ? BigInt(p.liquidity) : 0n;
const isActive = liq > 0n;
const tickCurrent = pool.tick ?? 0;
const inRange = p.tickLower <= tickCurrent && tickCurrent < p.tickUpper;
const tickCurrent = poolInfo.tick ?? null;
const inRange = tickCurrent !== null
? p.tickLower <= tickCurrent && tickCurrent < p.tickUpper
: null;

return {
id: p.id,
nft_address: p.nftAddress,
status: isActive ? (inRange ? "in-range" : "out-of-range") : "closed",
status: isActive ? (inRange === null ? "active" : inRange ? "in-range" : "out-of-range") : "closed",
pool: {
address: pool.address,
version: pool.version,
fee_tier: pool.fee ? `${(pool.fee / 10000).toFixed(2)}%` : null,
token0_symbol: pool.jetton0?.symbol,
token1_symbol: pool.jetton1?.symbol,
address: poolAddr,
version: poolInfo.version ?? null,
fee_tier: poolInfo.fee ? `${(poolInfo.fee / 10000).toFixed(2)}%` : null,
token0_symbol: p.jetton0?.symbol,
token1_symbol: p.jetton1?.symbol,
},
tick_lower: p.tickLower,
tick_upper: p.tickUpper,
Expand All @@ -1153,9 +1193,9 @@ const toncoGetPositions = {
liquidity: p.liquidity,
current_amounts: {
token0: p.amount0 ? formatAmount(p.amount0, dec0) : null,
token0_symbol: pool.jetton0?.symbol,
token0_symbol: p.jetton0?.symbol,
token1: p.amount1 ? formatAmount(p.amount1, dec1) : null,
token1_symbol: pool.jetton1?.symbol,
token1_symbol: p.jetton1?.symbol,
},
deposited: {
token0: p.depositedJetton0 ? formatAmount(p.depositedJetton0, dec0) : null,
Expand All @@ -1167,12 +1207,13 @@ const toncoGetPositions = {
},
fees_collected: {
token0: p.collectedFeesJetton0 ? formatAmount(p.collectedFeesJetton0, dec0) : null,
token0_symbol: pool.jetton0?.symbol,
token0_symbol: p.jetton0?.symbol,
token1: p.collectedFeesJetton1 ? formatAmount(p.collectedFeesJetton1, dec1) : null,
token1_symbol: pool.jetton1?.symbol,
token1_symbol: p.jetton1?.symbol,
},
created_at: p.creationTime ? new Date(p.creationTime * 1000).toISOString() : null,
closed_at: p.closingTime ? new Date(p.closingTime * 1000).toISOString() : null,
// creationTime is returned as an ISO date string by the indexer
created_at: p.creationTime ?? null,
closed_at: p.closingTime ?? null,
};
});

Expand Down
103 changes: 103 additions & 0 deletions plugins/tonco-dex/tests/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,109 @@ describe("tonco-dex plugin", () => {
});
});

// ── Bug #141 fix: tonco_get_pool_stats missing pool_address ─────────────────
// Previously, when pool_address was undefined/not-passed, normalizeToRaw()
// returned undefined, JSON.stringify dropped the field, and the indexer returned
// ALL pools (silent wrong-result bug). Fix: explicit early-return check.

describe("Bug #141 fix: tonco_get_pool_stats validates pool_address", () => {
let tool;
before(() => {
tool = mod.tools(makeSdk()).find((t) => t.name === "tonco_get_pool_stats");
});

it("returns error when pool_address is missing (undefined)", async () => {
const result = await tool.execute({});
assert.equal(result.success, false, "must fail when pool_address is missing");
assert.ok(result.error, "must have error message");
assert.ok(
result.error.toLowerCase().includes("pool_address") ||
result.error.toLowerCase().includes("required"),
`error should mention pool_address or required, got: ${result.error}`
);
});

it("returns error when pool_address is empty string", async () => {
const result = await tool.execute({ pool_address: "" });
assert.equal(result.success, false, "must fail when pool_address is empty string");
assert.ok(result.error, "must have error message");
});

it("does not silently return wrong pool when pool_address is missing (source-level check)", async () => {
const { readFileSync } = await import("node:fs");
const { resolve } = await import("node:path");
const src = readFileSync(resolve("plugins/tonco-dex/index.js"), "utf8");
// The fix adds an explicit check before normalizeToRaw in tonco_get_pool_stats
assert.ok(
src.includes("if (!params.pool_address)"),
"Bug #141: tonco_get_pool_stats must validate pool_address before normalizeToRaw"
);
});
});

// ── Bug #141 fix: tonco_get_positions GraphQL query ─────────────────────────
// The TONCO indexer's Position.pool is a String! (pool address), NOT an object.
// The old query selected subfields on it: pool { address version fee ... }
// causing: "Field 'pool' must not have a selection since type 'String!' has no subfields."
// Fix: query `pool` as a scalar and use Position's own jetton0/jetton1 fields.

describe("Bug #141 fix: tonco_get_positions uses correct GraphQL schema", () => {
it("positions query does not select subfields on pool (source-level check)", async () => {
const { readFileSync } = await import("node:fs");
const { resolve } = await import("node:path");
const src = readFileSync(resolve("plugins/tonco-dex/index.js"), "utf8");

// Find the GetPositions query in source
const queryStart = src.indexOf("query GetPositions");
assert.ok(queryStart !== -1, "GetPositions query must exist in source");
const queryEnd = src.indexOf("`;", queryStart);
const gqlQuery = src.slice(queryStart, queryEnd);

// pool must appear as a bare scalar field, not with { ... } subfields
assert.ok(
!gqlQuery.includes("pool {"),
"Bug #141: positions query must NOT select subfields on pool — pool is String! in the indexer schema"
);
});

it("positions query selects jetton0 and jetton1 directly on Position (source-level check)", async () => {
const { readFileSync } = await import("node:fs");
const { resolve } = await import("node:path");
const src = readFileSync(resolve("plugins/tonco-dex/index.js"), "utf8");

const queryStart = src.indexOf("query GetPositions");
const queryEnd = src.indexOf("`;", queryStart);
const gqlQuery = src.slice(queryStart, queryEnd);

// Position has jetton0/jetton1 as direct fields (not nested under pool)
assert.ok(
gqlQuery.includes("jetton0 {") || gqlQuery.includes("jetton0{"),
"Bug #141: positions query must select jetton0 directly on Position, not via pool"
);
assert.ok(
gqlQuery.includes("jetton1 {") || gqlQuery.includes("jetton1{"),
"Bug #141: positions query must select jetton1 directly on Position, not via pool"
);
});

it("tonco_get_positions returns object with success field (does not throw)", async () => {
const tool = mod.tools(makeSdk()).find((t) => t.name === "tonco_get_positions");
const result = await tool.execute({
owner_address: "EQDemo_AddressForTesting",
});
assert.ok(typeof result === "object", "must return an object");
assert.ok("success" in result, "must have success field");
// Must NOT return a GraphQL validation error about pool subfields
if (!result.success) {
assert.ok(
!result.error?.includes("must not have a selection") &&
!result.error?.includes("has no subfields"),
`Bug #141: must not get GraphQL schema error, got: ${result.error}`
);
}
});
});

// ── Live integration tests (skipped by default) ───────────────────────────

describe("live integration tests (TONCO_TEST_LIVE=1 to enable)", { skip: !LIVE }, () => {
Expand Down
Loading