diff --git a/src/index.js b/src/index.js index 10855fe..7a48220 100644 --- a/src/index.js +++ b/src/index.js @@ -21,6 +21,7 @@ const dexRouter = require("./routes/dex"); const liquidityPoolRouter = require("./routes/liquidityPool"); const streamRouter = require("./routes/stream"); const utilsRouter = require("./routes/utils"); +const claimableBalancesRouter = require("./routes/claimableBalances"); const app = express(); const PORT = process.env.PORT || 3000; @@ -61,6 +62,7 @@ app.use("/dex", dexRouter); app.use("/liquidity-pools", liquidityPoolRouter); app.use("/stream", streamRouter); app.use("/utils", utilsRouter); +app.use("/claimable-balances", claimableBalancesRouter); // ── Root ───────────────────────────────────────────────────────────────────── app.get("/", (req, res) => { @@ -88,6 +90,7 @@ app.get("/", (req, res) => { { method: "GET", path: "/account/:id/transactions/search", description: "Search account transactions by memo content" }, { method: "GET", path: "/transactions/:id", description: "Transaction history for an account" }, { method: "GET", path: "/transactions/:id/operations", description: "Operation history for an account" }, + { method: "GET", path: "/claimable-balances/:id/evaluate/:accountId", description: "Evaluate claimability of a balance for a specific account" }, { method: "GET", path: "/asset/:code/:issuer", description: "Asset metadata and statistics" }, { method: "GET", path: "/asset/:code/:issuer/holders", description: "Paginated accounts holding an asset" }, { method: "GET", path: "/asset/search?code=:code", description: "Search assets by code across all issuers" }, diff --git a/src/routes/account.js b/src/routes/account.js index 582c8f8..d66a097 100644 --- a/src/routes/account.js +++ b/src/routes/account.js @@ -573,6 +573,153 @@ router.get("/:id/payments", async (req, res, next) => { * GET /account/GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN/timeline * GET /account/GAAZI4.../timeline?limit=20 */ +/** + * GET /account/:id/operation-breakdown + * Analyzes the last 200 operations and returns a breakdown by operation type. + * Useful for understanding how an account is being used. + * + * @param {string} id - Stellar account public key (G...) + */ +router.get("/:id/operation-breakdown", async (req, res, next) => { + try { + const { id } = req.params; + validateAccountId(id); + + // Fetch last 200 operations + const opResponse = await server + .operations() + .forAccount(id) + .limit(200) + .order("desc") + .call(); + + const records = opResponse.records; + const total = records.length; + + if (total === 0) { + return success(res, { + total: 0, + breakdown: [], + mostUsedOperation: null, + leastUsedOperation: null, + }); + } + + const counts = {}; + records.forEach((op) => { + counts[op.type] = (counts[op.type] || 0) + 1; + }); + + const breakdown = Object.entries(counts) + .map(([type, count]) => ({ + type, + count, + percentage: parseFloat(((count / total) * 100).toFixed(2)), + })) + .sort((a, b) => b.count - a.count); + + return success(res, { + total, + breakdown, + mostUsedOperation: breakdown[0].type, + leastUsedOperation: breakdown[breakdown.length - 1].type, + }); + } catch (err) { + handleAccountNotFound(err, next); + } +}); + +/** + * GET /account/:id/offer-history + * Returns the full history of offers created, updated, and deleted by an account. + * + * Query params: + * - limit (number, default: 10) + * - order ("asc" | "desc", default: "desc") + * - cursor (string, pagination cursor) + * + * @param {string} id - Stellar account public key (G...) + */ +router.get("/:id/offer-history", async (req, res, next) => { + try { + const { id } = req.params; + validateAccountId(id); + + const limit = validateLimit(req.query.limit || 10, 200); + const order = ["asc", "desc"].includes(req.query.order) + ? req.query.order + : "desc"; + const cursor = req.query.cursor || undefined; + + let query = server + .operations() + .forAccount(id) + .limit(limit) + .order(order); + + if (cursor) query = query.cursor(cursor); + + const opResponse = await query.call(); + const records = opResponse.records; + + const offerOps = records + .filter((op) => + [ + "manage_sell_offer", + "manage_buy_offer", + "create_passive_sell_offer", + ].includes(op.type) + ) + .map((op) => { + let offerType = "updated"; + if (op.type === "create_passive_sell_offer") { + offerType = "created"; + } else if (parseFloat(op.amount) === 0) { + offerType = "deleted"; + } else { + // In Stellar, if we can't see the original request, it's hard to be 100% sure if it was created or updated + // just from the operation record if offer_id is already assigned. + // But usually, if it's the first time that offerId appears for this account, it was created. + // For this API, we'll label it 'created' if it appears to be a new offer or 'updated' otherwise. + // many developers use amount > 0 as updated/created. + // We'll use a heuristic or just label as requested. + // If the op has a price but it was a 'manage' op, we'll call it 'created/updated'. + offerType = op.offer_id === "0" ? "created" : "updated"; + } + + const formatAsset = (type, code, issuer) => { + if (type === "native") return "XLM"; + return `${code}:${issuer}`; + }; + + return { + offerId: op.offer_id, + type: offerType, + sellingAsset: formatAsset(op.selling_asset_type, op.selling_asset_code, op.selling_asset_issuer), + buyingAsset: formatAsset(op.buying_asset_type, op.buying_asset_code, op.buying_asset_issuer), + amount: op.amount, + price: op.price, + timestamp: op.created_at, + transactionHash: op.transaction_hash, + }; + }); + + const nextCursor = records.length > 0 ? records[records.length - 1].paging_token : null; + + return success(res, offerOps, { + meta: { + count: offerOps.length, + limit, + order, + nextCursor, + hasMore: records.length === limit, + }, + }); + } catch (err) { + handleAccountNotFound(err, next); + } +}); + router.get("/:id/timeline", async (req, res, next) => { try { const { id } = req.params; diff --git a/src/routes/asset.js b/src/routes/asset.js index f82586e..a1a0664 100644 --- a/src/routes/asset.js +++ b/src/routes/asset.js @@ -161,6 +161,117 @@ router.get("/:code/:issuer", async (req, res, next) => { } }); +/** + * GET /asset/:code/:issuer/distribution + * Analyzes the distribution of holders for a Stellar asset. + * Returns concentration metrics (Top 10/25) and Gini coefficient. + * + * @param {string} code - Asset code (e.g. USDC) + * @param {string} issuer - Issuer account public key (G...) + */ +router.get("/:code/:issuer/distribution", async (req, res, next) => { + try { + const { code, issuer } = req.params; + validateAssetCode(code); + validateAccountId(issuer); + + const assetCode = code.toUpperCase(); + + // 1. Verify asset exists and get total holder count + const assetsResponse = await server + .assets() + .forCode(assetCode) + .forIssuer(issuer) + .call(); + + if (!assetsResponse.records || assetsResponse.records.length === 0) { + return res.status(404).json({ + success: false, + error: { + type: "NotFound", + message: `Asset ${assetCode} issued by ${issuer} was not found on the Stellar network.`, + }, + }); + } + + const asset = assetsResponse.records[0]; + const totalHolders = asset.num_accounts; + + // 2. Fetch top holders (up to 200) + // Note: Horizon doesn't allow sorting /accounts by balance. + // We fetch a page of accounts holding the asset. + const accountsResponse = await server + .accounts() + .forAsset(new Asset(assetCode, issuer)) + .limit(200) + .call(); + + const records = accountsResponse.records || []; + if (records.length === 0) { + return success(res, { + totalHolders: 0, + top10HoldersPercent: 0, + top25HoldersPercent: 0, + giniCoefficient: 0, + largestHolder: null, + smallestHolder: null, + }); + } + + // Extract balances and sort descending + const balances = records.map(r => { + const b = r.balances.find(bal => bal.asset_code === assetCode && bal.asset_issuer === issuer); + return parseFloat(b ? b.balance : "0"); + }).sort((a, b) => b - a); + + const totalInFetched = balances.reduce((sum, b) => sum + b, 0); + const totalAssetSupply = parseFloat(asset.amount || "0"); + + // Concentration metrics relative to total supply + const top10Sum = balances.slice(0, 10).reduce((sum, b) => sum + b, 0); + const top25Sum = balances.slice(0, 25).reduce((sum, b) => sum + b, 0); + + const top10HoldersPercent = totalAssetSupply > 0 + ? parseFloat(((top10Sum / totalAssetSupply) * 100).toFixed(2)) + : 0; + const top25HoldersPercent = totalAssetSupply > 0 + ? parseFloat(((top25Sum / totalAssetSupply) * 100).toFixed(2)) + : 0; + + // Gini Coefficient Calculation (using the fetched set) + // G = (2 * sum(i * x_i) / (n * sum(x_i))) - ((n + 1) / n) + // where x_i is sorted ASCENDING + const n = balances.length; + const sortedAsc = [...balances].sort((a, b) => a - b); + let cumulativeSum = 0; + for (let i = 0; i < n; i++) { + cumulativeSum += (i + 1) * sortedAsc[i]; + } + + const G = totalInFetched > 0 + ? (2 * cumulativeSum) / (n * totalInFetched) - (n + 1) / n + : 0; + const giniCoefficient = parseFloat(Math.max(0, G).toFixed(4)); + + return success(res, { + totalHolders, + top10HoldersPercent, + top25HoldersPercent, + giniCoefficient, + largestHolder: records.find(r => { + const b = r.balances.find(bal => bal.asset_code === assetCode && bal.asset_issuer === issuer); + return parseFloat(b ? b.balance : "0") === balances[0]; + })?.id || null, + smallestHolder: records.find(r => { + const b = r.balances.find(bal => bal.asset_code === assetCode && bal.asset_issuer === issuer); + return parseFloat(b ? b.balance : "0") === balances[balances.length - 1]; + })?.id || null, + }); + } catch (err) { + next(err); + } +}); + /** * GET /asset/:code/:issuer/supply * Returns full supply breakdown for a Stellar asset. diff --git a/src/routes/claimableBalances.js b/src/routes/claimableBalances.js new file mode 100644 index 0000000..3dfe5bf --- /dev/null +++ b/src/routes/claimableBalances.js @@ -0,0 +1,137 @@ +const express = require("express"); +const router = express.Router(); +const { server } = require("../config/stellar"); +const { success } = require("../utils/response"); +const { validateAccountId } = require("../utils/validators"); + +/** + * Evaluates a claimable balance predicate recursively. + * + * @param {object} predicate - The predicate object from Horizon + * @param {number} nowSeconds - Current time in seconds since epoch + * @returns {object} { canClaim: boolean, reason: string } + */ +function evaluatePredicate(predicate, nowSeconds) { + if (predicate.unconditional) { + return { canClaim: true, reason: "The balance is claimable unconditionally." }; + } + + if (predicate.not) { + const res = evaluatePredicate(predicate.not, nowSeconds); + return { + canClaim: !res.canClaim, + reason: res.canClaim ? `NOT (${res.reason}) is false.` : `NOT (${res.reason}) is true.` + }; + } + + if (predicate.and) { + const results = predicate.and.map(p => evaluatePredicate(p, nowSeconds)); + const canClaim = results.every(r => r.canClaim); + if (canClaim) { + return { canClaim: true, reason: "All conditions are met: " + results.map(r => r.reason).join(", ") }; + } else { + const failed = results.filter(r => !r.canClaim).map(r => r.reason); + return { canClaim: false, reason: "Some conditions failed: " + failed.join(", ") }; + } + } + + if (predicate.or) { + const results = predicate.or.map(p => evaluatePredicate(p, nowSeconds)); + const canClaim = results.some(r => r.canClaim); + if (canClaim) { + const met = results.filter(r => r.canClaim).map(r => r.reason); + return { canClaim: true, reason: "At least one condition is met: " + met.join(", ") }; + } else { + return { canClaim: false, reason: "None of the conditions are met: " + results.map(r => r.reason).join(", ") }; + } + } + + if (predicate.abs_before) { + const deadline = Math.floor(new Date(predicate.abs_before).getTime() / 1000); + const canClaim = nowSeconds < deadline; + return { + canClaim, + reason: canClaim + ? `Current time is before the absolute deadline ${predicate.abs_before}.` + : `Deadline ${predicate.abs_before} has passed.` + }; + } + + if (predicate.abs_after) { + const startTime = Math.floor(new Date(predicate.abs_after).getTime() / 1000); + const canClaim = nowSeconds >= startTime; + return { + canClaim, + reason: canClaim + ? `The claimable window has started (after ${predicate.abs_after}).` + : `The claimable window has not started yet (starts at ${predicate.abs_after}).` + }; + } + + // Note: rel_before/rel_after are relative to the ledger close time when the balance was created. + // Horizon doesn't always provide this easily in the predicate object without context. + // However, Stellar SDK usually translates them. + // For the purpose of this task, we'll handle them if they appear as absolute timestamps (which Horizon often does) + // or return a generic message if we can't fully evaluate without creation ledger time. + + return { canClaim: false, reason: "Unknown or unsupported predicate type." }; +} + +/** + * GET /claimable-balances/:id/evaluate/:accountId + * Evaluates claimability of a balance for a specific account. + */ +router.get("/:id/evaluate/:accountId", async (req, res, next) => { + try { + const { id, accountId } = req.params; + validateAccountId(accountId); + + // Fetch claimable balance + let balance; + try { + balance = await server.claimableBalances().claimableBalance(id).call(); + } catch (err) { + if (err.response && err.response.status === 404) { + const notFoundErr = new Error("Claimable balance not found."); + notFoundErr.status = 404; + return next(notFoundErr); + } + throw err; + } + + // Check if account is a claimant + const claimant = balance.claimants.find(c => c.destination === accountId); + if (!claimant) { + const err = new Error("Account is not a listed claimant for this balance."); + err.status = 400; + return next(err); + } + + const nowSeconds = Math.floor(Date.now() / 1000); + const evaluation = evaluatePredicate(claimant.predicate, nowSeconds); + + // Find temporal bounds if any + let claimableFrom = null; + let claimableUntil = null; + + const findBounds = (p) => { + if (p.abs_after) claimableFrom = p.abs_after; + if (p.abs_before) claimableUntil = p.abs_before; + if (p.and) p.and.forEach(findBounds); + if (p.or) p.or.forEach(findBounds); + }; + findBounds(claimant.predicate); + + return success(res, { + canClaimNow: evaluation.canClaim, + reason: evaluation.reason, + claimableFrom, + claimableUntil, + predicate: claimant.predicate, + }); + } catch (err) { + next(err); + } +}); + +module.exports = router; diff --git a/tests/account.offerHistory.test.js b/tests/account.offerHistory.test.js new file mode 100644 index 0000000..aabf32c --- /dev/null +++ b/tests/account.offerHistory.test.js @@ -0,0 +1,70 @@ +const request = require("supertest"); +const app = require("../src/index"); +const { server } = require("../src/config/stellar"); +const { Keypair } = require("@stellar/stellar-sdk"); + +jest.mock("../src/config/stellar", () => { + const originalModule = jest.requireActual("../src/config/stellar"); + return { + ...originalModule, + server: { + operations: jest.fn(), + }, + }; +}); + +describe("Account Offer History API", () => { + const accountId = Keypair.random().publicKey(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns offer history operations", async () => { + const mockOperations = [ + { + type: "manage_sell_offer", + offer_id: "123", + amount: "100.0", + price: "1.5", + selling_asset_type: "native", + buying_asset_type: "credit_alphanum4", + buying_asset_code: "USDC", + buying_asset_issuer: "G_ISSUER", + created_at: "2024-01-01T00:00:00Z", + paging_token: "t1", + }, + { + type: "manage_sell_offer", + offer_id: "123", + amount: "0", + selling_asset_type: "native", + buying_asset_type: "credit_alphanum4", + buying_asset_code: "USDC", + buying_asset_issuer: "G_ISSUER", + created_at: "2024-01-01T01:00:00Z", + paging_token: "t2", + }, + { + type: "payment", + paging_token: "t3" + } + ]; + + server.operations.mockReturnValue({ + forAccount: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + order: jest.fn().mockReturnThis(), + call: jest.fn().mockResolvedValue({ records: mockOperations }), + }); + + const res = await request(app).get(`/account/${accountId}/offer-history`); + + expect(res.statusCode).toBe(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.data[0].type).toBe("updated"); + expect(res.body.data[1].type).toBe("deleted"); + expect(res.body.data[0].sellingAsset).toBe("XLM"); + expect(res.body.data[0].buyingAsset).toBe("USDC:G_ISSUER"); + }); +}); diff --git a/tests/account.operationBreakdown.test.js b/tests/account.operationBreakdown.test.js new file mode 100644 index 0000000..2df8c2b --- /dev/null +++ b/tests/account.operationBreakdown.test.js @@ -0,0 +1,83 @@ +const request = require("supertest"); +const app = require("../src/index"); +const { server } = require("../src/config/stellar"); +const { Keypair } = require("@stellar/stellar-sdk"); + +jest.mock("../src/config/stellar", () => { + const originalModule = jest.requireActual("../src/config/stellar"); + return { + ...originalModule, + server: { + operations: jest.fn(), + }, + }; +}); + +describe("Account Operation Breakdown API", () => { + const accountId = Keypair.random().publicKey(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns breakdown for an account with operations", async () => { + const mockOperations = [ + { type: "payment" }, + { type: "payment" }, + { type: "payment" }, + { type: "change_trust" }, + { type: "manage_sell_offer" }, + ]; + + server.operations.mockReturnValue({ + forAccount: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + order: jest.fn().mockReturnThis(), + call: jest.fn().mockResolvedValue({ records: mockOperations }), + }); + + const res = await request(app).get(`/account/${accountId}/operation-breakdown`); + + expect(res.statusCode).toBe(200); + expect(res.body.data.total).toBe(5); + expect(res.body.data.breakdown).toHaveLength(3); + expect(res.body.data.breakdown[0].type).toBe("payment"); + expect(res.body.data.breakdown[0].count).toBe(3); + expect(res.body.data.breakdown[0].percentage).toBe(60); + expect(res.body.data.mostUsedOperation).toBe("payment"); + expect(res.body.data.leastUsedOperation).toBeOneOf(["change_trust", "manage_sell_offer"]); + }); + + it("returns zeros for account with no operations", async () => { + server.operations.mockReturnValue({ + forAccount: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + order: jest.fn().mockReturnThis(), + call: jest.fn().mockResolvedValue({ records: [] }), + }); + + const res = await request(app).get(`/account/${accountId}/operation-breakdown`); + + expect(res.statusCode).toBe(200); + expect(res.body.data.total).toBe(0); + expect(res.body.data.breakdown).toEqual([]); + }); +}); + +// Helper for expect +expect.extend({ + toBeOneOf(received, argument) { + const pass = argument.includes(received); + if (pass) { + return { + message: () => `expected ${received} not to be one of ${argument}`, + pass: true, + }; + } else { + return { + message: () => `expected ${received} to be one of ${argument}`, + pass: false, + }; + } + }, +}); diff --git a/tests/asset.distribution.test.js b/tests/asset.distribution.test.js new file mode 100644 index 0000000..e663008 --- /dev/null +++ b/tests/asset.distribution.test.js @@ -0,0 +1,67 @@ +const request = require("supertest"); +const app = require("../src/index"); +const { server } = require("../src/config/stellar"); + +jest.mock("../src/config/stellar", () => { + const originalModule = jest.requireActual("../src/config/stellar"); + return { + ...originalModule, + server: { + assets: jest.fn(), + accounts: jest.fn(), + }, + }; +}); + +describe("Asset Distribution API", () => { + const ASSET_CODE = "USDC"; + const ASSET_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns distribution metrics for an asset", async () => { + server.assets.mockReturnValue({ + forCode: jest.fn().mockReturnThis(), + forIssuer: jest.fn().mockReturnThis(), + call: jest.fn().mockResolvedValue({ + records: [{ asset_code: ASSET_CODE, asset_issuer: ASSET_ISSUER, amount: "1000", num_accounts: 5 }] + }), + }); + + const mockAccounts = [ + { id: "A1", balances: [{ asset_code: ASSET_CODE, asset_issuer: ASSET_ISSUER, balance: "500" }] }, + { id: "A2", balances: [{ asset_code: ASSET_CODE, asset_issuer: ASSET_ISSUER, balance: "300" }] }, + { id: "A3", balances: [{ asset_code: ASSET_CODE, asset_issuer: ASSET_ISSUER, balance: "100" }] }, + { id: "A4", balances: [{ asset_code: ASSET_CODE, asset_issuer: ASSET_ISSUER, balance: "60" }] }, + { id: "A5", balances: [{ asset_code: ASSET_CODE, asset_issuer: ASSET_ISSUER, balance: "40" }] }, + ]; + + server.accounts.mockReturnValue({ + forAsset: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + call: jest.fn().mockResolvedValue({ records: mockAccounts }), + }); + + const res = await request(app).get(`/asset/${ASSET_CODE}/${ASSET_ISSUER}/distribution`); + + expect(res.statusCode).toBe(200); + expect(res.body.data.totalHolders).toBe(5); + expect(res.body.data.top10HoldersPercent).toBe(100); // All 5 holders are in top 10 + expect(res.body.data.largestHolder).toBe("A1"); + expect(res.body.data.smallestHolder).toBe("A5"); + expect(res.body.data.giniCoefficient).toBeGreaterThan(0); + }); + + it("returns 404 if asset not found", async () => { + server.assets.mockReturnValue({ + forCode: jest.fn().mockReturnThis(), + forIssuer: jest.fn().mockReturnThis(), + call: jest.fn().mockResolvedValue({ records: [] }), + }); + + const res = await request(app).get(`/asset/FAKE/GA.../distribution`); + expect(res.statusCode).toBe(404); + }); +}); diff --git a/tests/claimableBalances.evaluate.test.js b/tests/claimableBalances.evaluate.test.js new file mode 100644 index 0000000..07e197f --- /dev/null +++ b/tests/claimableBalances.evaluate.test.js @@ -0,0 +1,83 @@ +const request = require("supertest"); +const app = require("../src/index"); +const { server } = require("../src/config/stellar"); + +jest.mock("../src/config/stellar", () => { + const originalModule = jest.requireActual("../src/config/stellar"); + return { + ...originalModule, + server: { + claimableBalances: jest.fn(), + }, + }; +}); + +describe("Claimable Balance Evaluation API", () => { + const balanceId = "00000000abcdef..."; + const accountId = "GA...DESTINATION"; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns canClaimNow: true for unconditional predicate", async () => { + server.claimableBalances.mockReturnValue({ + claimableBalance: jest.fn().mockReturnThis(), + call: jest.fn().mockResolvedValue({ + id: balanceId, + claimants: [ + { + destination: accountId, + predicate: { unconditional: true }, + }, + ], + }), + }); + + const res = await request(app).get(`/claimable-balances/${balanceId}/evaluate/${accountId}`); + + expect(res.statusCode).toBe(200); + expect(res.body.data.canClaimNow).toBe(true); + expect(res.body.data.reason).toContain("unconditionally"); + }); + + it("returns canClaimNow: false if deadline passed (abs_before)", async () => { + const pastDate = new Date(Date.now() - 100000).toISOString(); + server.claimableBalances.mockReturnValue({ + claimableBalance: jest.fn().mockReturnThis(), + call: jest.fn().mockResolvedValue({ + id: balanceId, + claimants: [ + { + destination: accountId, + predicate: { abs_before: pastDate }, + }, + ], + }), + }); + + const res = await request(app).get(`/claimable-balances/${balanceId}/evaluate/${accountId}`); + + expect(res.body.data.canClaimNow).toBe(false); + expect(res.body.data.reason).toContain("passed"); + }); + + it("returns 400 if account is not a claimant", async () => { + server.claimableBalances.mockReturnValue({ + claimableBalance: jest.fn().mockReturnThis(), + call: jest.fn().mockResolvedValue({ + id: balanceId, + claimants: [ + { + destination: "OTHER_ACCOUNT", + predicate: { unconditional: true }, + }, + ], + }), + }); + + const res = await request(app).get(`/claimable-balances/${balanceId}/evaluate/${accountId}`); + expect(res.statusCode).toBe(400); + expect(res.body.error.message).toContain("not a listed claimant"); + }); +});