From 0a70573edbfcaf0cd2abbeaecd9494f667ac45a9 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:41:49 +0900 Subject: [PATCH 01/28] fix unpack validators positional order --- packages/api-http/source/controllers/commits.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api-http/source/controllers/commits.ts b/packages/api-http/source/controllers/commits.ts index 82f832422..09809aba6 100644 --- a/packages/api-http/source/controllers/commits.ts +++ b/packages/api-http/source/controllers/commits.ts @@ -37,7 +37,7 @@ export class CommitsController extends Controller { // map bitmask -> indexes -> round.validators const packed = BigInt(block.validatorSet); const unpacked = validatorSetUnpack(packed, round.validators.length); - validators = unpacked.filter(Boolean).map((_, index) => round.validators[index]); + validators = round.validators.filter((_, index) => unpacked[index]); } return { From 6bb228a809f5101a851484f8f1e939fb0b74756d Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:29:57 +0900 Subject: [PATCH 02/28] defaults coverage --- packages/api-http/source/defaults.test.ts | 109 ++++++++++++++++++++++ packages/api-http/source/server.ts | 2 - 2 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 packages/api-http/source/defaults.test.ts diff --git a/packages/api-http/source/defaults.test.ts b/packages/api-http/source/defaults.test.ts new file mode 100644 index 000000000..b05771e47 --- /dev/null +++ b/packages/api-http/source/defaults.test.ts @@ -0,0 +1,109 @@ +import { describe } from "@mainsail/test-runner"; + +import { defaults } from "./defaults.js"; + +const ENV_KEYS = [ + "MAINSAIL_API_NO_ESTIMATED_TOTAL_COUNT", + "MAINSAIL_API_CACHE", + "MAINSAIL_API_LOG", + "MAINSAIL_API_RATE_LIMIT_BLACKLIST", + "MAINSAIL_API_RATE_LIMIT_USER_EXPIRES", + "MAINSAIL_API_RATE_LIMIT_DISABLED", + "MAINSAIL_API_RATE_LIMIT_USER_LIMIT", + "MAINSAIL_API_RATE_LIMIT_WHITELIST", + "MAINSAIL_API_TRUST_PROXY", + "MAINSAIL_API_DISABLED", + "MAINSAIL_API_HOST", + "MAINSAIL_API_PORT", + "MAINSAIL_API_SSL", + "MAINSAIL_API_SSL_HOST", + "MAINSAIL_API_SSL_PORT", + "MAINSAIL_API_SSL_CERT", + "MAINSAIL_API_SSL_KEY", + "MAINSAIL_API_TOKENS_DEFAULT_MINIMUM_BALANCE", +]; + +// The module reads the environment at import time, so a fresh copy is imported +// with a cache-busting query once the environment has been prepared. +const importFresh = async (): Promise => { + const { defaults: freshDefaults } = await import(`./defaults.js?bust=${Math.random()}`); + return freshDefaults; +}; + +describe<{ + previousEnv: Record; +}>("defaults", ({ it, beforeEach, afterEach, assert }) => { + beforeEach((context) => { + context.previousEnv = {}; + for (const key of ENV_KEYS) { + context.previousEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(({ previousEnv }) => { + for (const key of ENV_KEYS) { + if (previousEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = previousEnv[key]; + } + } + }); + + it("uses the documented defaults when no environment variables are set", async () => { + const fresh = await importFresh(); + + assert.true(fresh.options.estimateTotalCount); + assert.false(fresh.plugins.cache.enabled); + assert.false(fresh.plugins.log.enabled); + assert.equal(fresh.plugins.pagination.limit, 100); + assert.equal(fresh.plugins.rateLimit, { + blacklist: [], + duration: 60, + enabled: true, + points: 100, + whitelist: [], + }); + assert.false(fresh.plugins.trustProxy); + assert.equal(fresh.plugins.whitelist, ["*"]); + assert.true(fresh.server.http.enabled); + assert.equal(fresh.server.http.host, "0.0.0.0"); + assert.equal(fresh.server.http.port, 4003); + assert.false(fresh.server.https.enabled); + assert.equal(fresh.server.https.port, 8443); + assert.undefined(fresh.server.https.tls.cert); + assert.undefined(fresh.server.https.tls.key); + assert.equal(fresh.tokens.defaultMinimumBalance, 0.01); + }); + + it("honors the environment variable overrides", async () => { + process.env.MAINSAIL_API_NO_ESTIMATED_TOTAL_COUNT = "true"; + process.env.MAINSAIL_API_CACHE = "true"; + process.env.MAINSAIL_API_LOG = "1"; + process.env.MAINSAIL_API_RATE_LIMIT_BLACKLIST = "1.1.1.1,2.2.2.2"; + process.env.MAINSAIL_API_RATE_LIMIT_WHITELIST = "3.3.3.3"; + process.env.MAINSAIL_API_RATE_LIMIT_DISABLED = "true"; + process.env.MAINSAIL_API_TRUST_PROXY = "true"; + process.env.MAINSAIL_API_DISABLED = "true"; + process.env.MAINSAIL_API_HOST = "127.0.0.1"; + process.env.MAINSAIL_API_SSL = "true"; + process.env.MAINSAIL_API_SSL_CERT = "/tmp/cert.pem"; + process.env.MAINSAIL_API_SSL_KEY = "/tmp/key.pem"; + + const fresh = await importFresh(); + + assert.false(fresh.options.estimateTotalCount); + assert.true(fresh.plugins.cache.enabled); + assert.true(fresh.plugins.log.enabled); + assert.equal(fresh.plugins.rateLimit.blacklist, ["1.1.1.1", "2.2.2.2"]); + assert.equal(fresh.plugins.rateLimit.whitelist, ["3.3.3.3"]); + assert.false(fresh.plugins.rateLimit.enabled); + assert.true(fresh.plugins.trustProxy); + assert.false(fresh.server.http.enabled); + assert.equal(fresh.server.http.host, "127.0.0.1"); + assert.true(fresh.server.https.enabled); + assert.equal(fresh.server.https.tls.cert, "/tmp/cert.pem"); + assert.equal(fresh.server.https.tls.key, "/tmp/key.pem"); + }); +}); diff --git a/packages/api-http/source/server.ts b/packages/api-http/source/server.ts index ed909818c..aff7fad7b 100644 --- a/packages/api-http/source/server.ts +++ b/packages/api-http/source/server.ts @@ -37,13 +37,11 @@ export class Server extends AbstractServer { }, routes: { payload: { - /* istanbul ignore next */ async failAction(request: Hapi.Request, h: Hapi.ResponseToolkit, error: Error) { return badData(error.message); }, }, validate: { - /* istanbul ignore next */ async failAction(request: Hapi.Request, h: Hapi.ResponseToolkit, error: Error) { return badData(error.message); }, From dfe6f8e41934e0971e7bdee984128e603197b5c7 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:30:08 +0900 Subject: [PATCH 03/28] api-node coverage --- .../api-http/source/routes/api-nodes.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 packages/api-http/source/routes/api-nodes.test.ts diff --git a/packages/api-http/source/routes/api-nodes.test.ts b/packages/api-http/source/routes/api-nodes.test.ts new file mode 100644 index 000000000..1b7a36faf --- /dev/null +++ b/packages/api-http/source/routes/api-nodes.test.ts @@ -0,0 +1,47 @@ +import { describe } from "@mainsail/test-runner"; + +import { makeApiNode, makePage } from "../../test/fixtures/entities"; +import { bootstrapServer, makeRepos, Repos } from "../../test/helpers/server"; +import { Server } from "../server"; +import { ServiceProvider } from "../service-provider"; + +describe<{ + server: Server; + serviceProvider: ServiceProvider; + repos: Repos; +}>("ApiNodes routes", ({ it, assert, beforeEach, afterEach }) => { + beforeEach(async (context) => { + context.repos = makeRepos(); + + const { server, serviceProvider } = await bootstrapServer(context.repos); + context.server = server; + context.serviceProvider = serviceProvider; + }); + + afterEach(async ({ serviceProvider }) => { + await serviceProvider.dispose(); + }); + + it("GET /api/api-nodes - returns api nodes in a pagination envelope", async ({ server, repos }) => { + repos.apiNode.data.page = makePage([makeApiNode()]); + + const response = await server.inject({ method: "GET", url: "/api/api-nodes" }); + + assert.is(response.statusCode, 200); + + const body = JSON.parse(response.payload); + assert.is(body.meta.totalCount, 1); + assert.equal(body.data[0].url, "http://127.0.0.1:4003"); + + // The api node listing never estimates the total count. + const [, , , options] = repos.apiNode.calls.findManyByCriteria[0]; + assert.equal(options, { estimateTotalCount: false }); + }); + + it("GET /api/api-nodes - returns an empty page without nodes", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/api-nodes" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data, []); + }); +}); From af6e9ce8cd785442786315c7ce11e73d054508b7 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:30:17 +0900 Subject: [PATCH 04/28] blockchain coverage --- .../api-http/source/routes/blockchain.test.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 packages/api-http/source/routes/blockchain.test.ts diff --git a/packages/api-http/source/routes/blockchain.test.ts b/packages/api-http/source/routes/blockchain.test.ts new file mode 100644 index 000000000..ff657dbf5 --- /dev/null +++ b/packages/api-http/source/routes/blockchain.test.ts @@ -0,0 +1,70 @@ +import { describe } from "@mainsail/test-runner"; + +import { makeBlock, makeState } from "../../test/fixtures/entities"; +import { bootstrapServer, makeRepos, Repos } from "../../test/helpers/server"; +import { Server } from "../server"; +import { ServiceProvider } from "../service-provider"; + +describe<{ + server: Server; + serviceProvider: ServiceProvider; + repos: Repos; +}>("Blockchain routes", ({ it, assert, beforeEach, afterEach }) => { + beforeEach(async (context) => { + context.repos = makeRepos(); + + const { server, serviceProvider } = await bootstrapServer(context.repos); + context.server = server; + context.serviceProvider = serviceProvider; + }); + + afterEach(async ({ serviceProvider }) => { + await serviceProvider.dispose(); + }); + + it("GET /api/blockchain - returns the latest block and the supply", async ({ server, repos }) => { + repos.block.data.one = makeBlock({ hash: "aa".repeat(32), number: "90" }); + repos.block.data.latestHeight = 90; + repos.state.data.one = makeState({ supply: "12345" }); + + const response = await server.inject({ method: "GET", url: "/api/blockchain" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload), { + data: { + block: { hash: "aa".repeat(32), number: 90 }, + supply: "12345", + }, + }); + // Every non-503 response carries the current height. + assert.is(response.headers["x-block-number"], 90); + }); + + it("GET /api/blockchain - returns a null block and zero supply on an empty database", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/blockchain" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload), { + data: { block: null, supply: "0" }, + }); + }); + + it("GET /api/blockchain - responds 503 while the database is in maintenance", async ({ server, repos }) => { + repos.system.inMaintenance = async () => true; + + const response = await server.inject({ method: "GET", url: "/api/blockchain" }); + + assert.is(response.statusCode, 503); + assert.equal(JSON.parse(response.payload).reason, "Database not ready"); + }); + + it("GET /api/blockchain - responds 503 when the maintenance check fails", async ({ server, repos }) => { + repos.system.inMaintenance = async () => { + throw new Error("connection refused"); + }; + + const response = await server.inject({ method: "GET", url: "/api/blockchain" }); + + assert.is(response.statusCode, 503); + }); +}); From 9f8a6d2ef017ebda25e1de4c22693d5ff4f880b1 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:30:26 +0900 Subject: [PATCH 05/28] peers coverage --- packages/api-http/source/routes/peers.test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 packages/api-http/source/routes/peers.test.ts diff --git a/packages/api-http/source/routes/peers.test.ts b/packages/api-http/source/routes/peers.test.ts new file mode 100644 index 000000000..5c3751db2 --- /dev/null +++ b/packages/api-http/source/routes/peers.test.ts @@ -0,0 +1,73 @@ +import { describe } from "@mainsail/test-runner"; + +import { makePage, makePeer } from "../../test/fixtures/entities"; +import { bootstrapServer, makeRepos, Repos } from "../../test/helpers/server"; +import { Server } from "../server"; +import { ServiceProvider } from "../service-provider"; + +describe<{ + server: Server; + serviceProvider: ServiceProvider; + repos: Repos; +}>("Peers routes", ({ it, assert, beforeEach, afterEach }) => { + beforeEach(async (context) => { + context.repos = makeRepos(); + + const { server, serviceProvider } = await bootstrapServer(context.repos); + context.server = server; + context.serviceProvider = serviceProvider; + }); + + afterEach(async ({ serviceProvider }) => { + await serviceProvider.dispose(); + }); + + it("GET /api/peers - returns transformed peers in a pagination envelope", async ({ server, repos }) => { + repos.peer.data.page = makePage([makePeer({ plugins: { "api-http": {} }, ports: { "api-http": 4003 } })]); + + const response = await server.inject({ method: "GET", url: "/api/peers" }); + + assert.is(response.statusCode, 200); + + const body = JSON.parse(response.payload); + assert.is(body.meta.totalCount, 1); + assert.equal(body.data[0], { + blockNumber: 95, + ip: "127.0.0.1", + latency: 10, + plugins: { "api-http": {} }, + port: 4002, + ports: { "api-http": 4003 }, + version: "1.0.0", + }); + }); + + it("GET /api/peers - rejects a malformed ip filter", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/peers?ip=not-an-ip" }); + + assert.is(response.statusCode, 422); + }); + + it("GET /api/peers/{ip} - returns the peer", async ({ server, repos }) => { + repos.peer.data.one = makePeer(); + + const response = await server.inject({ method: "GET", url: "/api/peers/127.0.0.1" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data.ip, "127.0.0.1"); + + assert.equal(repos.peer.qb.calls.where[0], ["ip = :ip", { ip: "127.0.0.1" }]); + }); + + it("GET /api/peers/{ip} - responds 404 for an unknown peer", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/peers/10.0.0.1" }); + + assert.is(response.statusCode, 404); + }); + + it("GET /api/peers/{ip} - rejects a malformed ip", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/peers/nope" }); + + assert.is(response.statusCode, 422); + }); +}); From 88091a025f59f3768a599d14abd360c00edfda46 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:30:36 +0900 Subject: [PATCH 06/28] receipts coverage --- .../api-http/source/routes/receipts.test.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 packages/api-http/source/routes/receipts.test.ts diff --git a/packages/api-http/source/routes/receipts.test.ts b/packages/api-http/source/routes/receipts.test.ts new file mode 100644 index 000000000..31a1ce583 --- /dev/null +++ b/packages/api-http/source/routes/receipts.test.ts @@ -0,0 +1,128 @@ +import { describe } from "@mainsail/test-runner"; + +import { + ADDRESS_A, + ADDRESS_B, + makePage, + makeTransaction, + PUBLIC_KEY, + TRANSACTION_HASH, +} from "../../test/fixtures/entities"; +import { bootstrapServer, makeRepos, Repos } from "../../test/helpers/server"; +import { Server } from "../server"; +import { ServiceProvider } from "../service-provider"; + +describe<{ + server: Server; + serviceProvider: ServiceProvider; + repos: Repos; +}>("Receipts routes", ({ it, assert, beforeEach, afterEach }) => { + beforeEach(async (context) => { + context.repos = makeRepos(); + + const { server, serviceProvider } = await bootstrapServer(context.repos); + context.server = server; + context.serviceProvider = serviceProvider; + }); + + afterEach(async ({ serviceProvider }) => { + await serviceProvider.dispose(); + }); + + it("GET /api/receipts - maps transactions to receipts", async ({ server, repos }) => { + repos.transaction.data.page = makePage([makeTransaction({ decodedError: "reverted", status: 0 })]); + + const response = await server.inject({ method: "GET", url: "/api/receipts" }); + + assert.is(response.statusCode, 200); + + const body = JSON.parse(response.payload); + assert.is(body.meta.totalCount, 1); + assert.equal(body.data[0], { + blockNumber: "90", + cumulativeGasUsed: 21_000, + decodedError: "reverted", + gasRefunded: 0, + gasUsed: 21_000, + logs: [], + output: "0x", + status: 0, + transactionHash: TRANSACTION_HASH, + }); + }); + + it("GET /api/receipts - scopes the criteria to hash and contract", async ({ server, repos }) => { + await server.inject({ + method: "GET", + url: `/api/receipts?transactionHash=${TRANSACTION_HASH}&to=${ADDRESS_B}`, + }); + + const [, criteria] = repos.transaction.calls.findManyByCriteria[0]; + assert.equal(criteria, { hash: TRANSACTION_HASH, to: ADDRESS_B }); + }); + + it("GET /api/receipts - treats a 0x-prefixed sender as an address", async ({ server, repos }) => { + await server.inject({ method: "GET", url: `/api/receipts?from=${ADDRESS_A}` }); + + const [, criteria] = repos.transaction.calls.findManyByCriteria[0]; + assert.equal(criteria.from, ADDRESS_A); + assert.undefined(criteria.senderPublicKey); + }); + + it("GET /api/receipts - treats any other sender as a public key", async ({ server, repos }) => { + await server.inject({ method: "GET", url: `/api/receipts?from=${PUBLIC_KEY}` }); + + const [, criteria] = repos.transaction.calls.findManyByCriteria[0]; + assert.equal(criteria.senderPublicKey, PUBLIC_KEY); + assert.undefined(criteria.from); + }); + + it("GET /api/receipts/{transactionHash} - selects the full receipt columns by default", async ({ + server, + repos, + }) => { + repos.transaction.data.one = makeTransaction(); + + const response = await server.inject({ method: "GET", url: `/api/receipts/${TRANSACTION_HASH}` }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data.transactionHash, TRANSACTION_HASH); + + const [columns] = repos.transaction.qb.calls.select.at(-1)!; + assert.true(columns.includes("Transaction.output")); + assert.true(columns.includes("Transaction.logs")); + }); + + it("GET /api/receipts/{transactionHash} - selects the compact column set when opting out", async ({ + server, + repos, + }) => { + repos.transaction.data.one = makeTransaction(); + + await server.inject({ method: "GET", url: `/api/receipts/${TRANSACTION_HASH}?fullReceipt=false` }); + + const [columns] = repos.transaction.qb.calls.select.at(-1)!; + assert.false(columns.includes("Transaction.output")); + assert.false(columns.includes("Transaction.logs")); + }); + + it("GET /api/receipts/{transactionHash} - responds 404 for an unknown transaction", async ({ server }) => { + const response = await server.inject({ method: "GET", url: `/api/receipts/${"f".repeat(64)}` }); + + assert.is(response.statusCode, 404); + }); + + it("GET /api/receipts/contracts - restricts the criteria to deployments", async ({ server, repos }) => { + repos.transaction.data.page = makePage([ + makeTransaction({ deployedContractAddress: ADDRESS_B, to: undefined }), + ]); + + const response = await server.inject({ method: "GET", url: `/api/receipts/contracts?from=${ADDRESS_A}` }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data[0].contractAddress, ADDRESS_B); + + const [, criteria] = repos.transaction.calls.findManyByCriteria[0]; + assert.equal(criteria, { deployedContractAddress: true, from: ADDRESS_A }); + }); +}); From fa92d1180713c429dc01651f9e965c3b15b14ad6 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:30:45 +0900 Subject: [PATCH 07/28] transactions coverage --- .../source/routes/transactions.test.ts | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 packages/api-http/source/routes/transactions.test.ts diff --git a/packages/api-http/source/routes/transactions.test.ts b/packages/api-http/source/routes/transactions.test.ts new file mode 100644 index 000000000..f4b329927 --- /dev/null +++ b/packages/api-http/source/routes/transactions.test.ts @@ -0,0 +1,151 @@ +import { describe } from "@mainsail/test-runner"; + +import { makePage, makeState, makeTransaction, TOKEN_ADDRESS, TRANSACTION_HASH } from "../../test/fixtures/entities"; +import { bootstrapServer, makeRepos, Repos } from "../../test/helpers/server"; +import { Server } from "../server"; +import { ServiceProvider } from "../service-provider"; + +const makeTokenActionRow = (overrides: Record = {}) => ({ + action: "Transfer", + from: "0x1111111111111111111111111111111111111111", + index: 0, + to: "0x2222222222222222222222222222222222222222", + tokenAddress: TOKEN_ADDRESS, + tokenDecimals: 6, + tokenName: "Test Token", + tokenSymbol: "TEST", + transactionHash: TRANSACTION_HASH, + value: "1000", + ...overrides, +}); + +describe<{ + server: Server; + serviceProvider: ServiceProvider; + repos: Repos; +}>("Transactions routes", ({ it, assert, beforeEach, afterEach }) => { + beforeEach(async (context) => { + context.repos = makeRepos(); + + const { server, serviceProvider } = await bootstrapServer(context.repos); + context.server = server; + context.serviceProvider = serviceProvider; + }); + + afterEach(async ({ serviceProvider }) => { + await serviceProvider.dispose(); + }); + + it("GET /api/transactions - returns enriched transactions in a pagination envelope", async ({ server, repos }) => { + repos.transaction.data.page = makePage([makeTransaction()]); + repos.state.data.one = makeState({ blockNumber: "100" }); + + const response = await server.inject({ method: "GET", url: "/api/transactions" }); + + assert.is(response.statusCode, 200); + + const body = JSON.parse(response.payload); + assert.is(body.meta.totalCount, 1); + + const [transaction] = body.data; + assert.equal(transaction.hash, TRANSACTION_HASH); + assert.equal(transaction.confirmations, 11); + assert.equal(transaction.receipt, { + cumulativeGasUsed: 21_000, + gasRefunded: 0, + gasUsed: 21_000, + status: 1, + }); + assert.false("tokens" in transaction); + }); + + it("GET /api/transactions - includes token actions on request", async ({ server, repos }) => { + repos.transaction.data.page = makePage([makeTransaction()]); + repos.state.data.one = makeState(); + repos.dataSource.data.rawMany = [makeTokenActionRow()]; + + const response = await server.inject({ method: "GET", url: "/api/transactions?includeTokens=true" }); + + assert.is(response.statusCode, 200); + + const [transaction] = JSON.parse(response.payload).data; + assert.equal(transaction.tokens, [ + { + action: "Transfer", + from: makeTokenActionRow().from, + index: 0, + metadata: { + tokenAddress: TOKEN_ADDRESS, + tokenDecimals: 6, + tokenName: "Test Token", + tokenSymbol: "TEST", + }, + to: makeTokenActionRow().to, + value: "1000", + }, + ]); + + // The lookup is a single unnest query over the page's hashes. + const [sql, parameters] = repos.dataSource.queries[0]; + assert.true(sql.includes("unnest")); + assert.equal(parameters[0], [TRANSACTION_HASH]); + }); + + it("GET /api/transactions - exposes logs and output with a full receipt", async ({ server, repos }) => { + repos.transaction.data.page = makePage([makeTransaction({ logs: [{ topics: [] }], output: "0xdeadbeef" })]); + repos.state.data.one = makeState(); + + const response = await server.inject({ method: "GET", url: "/api/transactions?fullReceipt=true" }); + + const [transaction] = JSON.parse(response.payload).data; + assert.equal(transaction.receipt.logs, [{ topics: [] }]); + assert.equal(transaction.receipt.output, "0xdeadbeef"); + }); + + it("GET /api/transactions - rejects an unknown orderBy property", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/transactions?orderBy=nope:asc" }); + + assert.is(response.statusCode, 422); + }); + + it("GET /api/transactions/{hash} - returns the enriched transaction", async ({ server, repos }) => { + repos.transaction.data.one = makeTransaction({ decodedError: "out of gas", status: 0 }); + repos.state.data.one = makeState(); + + const response = await server.inject({ method: "GET", url: `/api/transactions/${TRANSACTION_HASH}` }); + + assert.is(response.statusCode, 200); + + const { data } = JSON.parse(response.payload); + assert.equal(data.hash, TRANSACTION_HASH); + assert.equal(data.receipt.status, 0); + assert.equal(data.receipt.decodedError, "out of gas"); + assert.false("tokens" in data); + }); + + it("GET /api/transactions/{hash} - includes token actions on request", async ({ server, repos }) => { + repos.transaction.data.one = makeTransaction(); + repos.state.data.one = makeState(); + repos.dataSource.data.rawMany = [makeTokenActionRow()]; + + const response = await server.inject({ + method: "GET", + url: `/api/transactions/${TRANSACTION_HASH}?includeTokens=true`, + }); + + assert.is(response.statusCode, 200); + assert.length(JSON.parse(response.payload).data.tokens, 1); + }); + + it("GET /api/transactions/{hash} - responds 404 for an unknown hash", async ({ server }) => { + const response = await server.inject({ method: "GET", url: `/api/transactions/${"f".repeat(64)}` }); + + assert.is(response.statusCode, 404); + }); + + it("GET /api/transactions/{hash} - rejects a malformed hash", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/transactions/nope" }); + + assert.is(response.statusCode, 422); + }); +}); From b2db96e611087954258d50c2838b5544b4f349a2 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:32:43 +0900 Subject: [PATCH 08/28] server coverage --- packages/api-http/source/server.test.ts | 78 ++++++++ packages/api-http/test/helpers/server.ts | 233 +++++++++++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 packages/api-http/source/server.test.ts create mode 100644 packages/api-http/test/helpers/server.ts diff --git a/packages/api-http/source/server.test.ts b/packages/api-http/source/server.test.ts new file mode 100644 index 000000000..d244d1a70 --- /dev/null +++ b/packages/api-http/source/server.test.ts @@ -0,0 +1,78 @@ +import { Identifiers } from "@mainsail/constants"; +import { Application, Providers } from "@mainsail/kernel"; +import { describe } from "@mainsail/test-runner"; + +import { Server } from "./server"; + +describe<{ + app: Application; + server: Server; + configuration: Providers.PluginConfiguration; +}>("Server", ({ beforeEach, it, assert }) => { + beforeEach((context) => { + context.app = new Application(); + + context.configuration = context.app.resolve(Providers.PluginConfiguration).from("api-http", { + plugins: { pagination: { limit: 100 }, socketTimeout: 5000 }, + }); + + context.app.bind(Identifiers.Services.Log.Service).toConstantValue({ + error: () => {}, + info: () => {}, + warn: () => {}, + }); + + context.app + .bind(Identifiers.ServiceProvider.Configuration) + .toConstantValue(context.configuration) + .whenTagged("plugin", "api-http"); + + context.server = context.app.resolve(Server); + }); + + it("baseName should be 'Public API'", ({ server }) => { + assert.is((server as any).baseName(), "Public API"); + }); + + it("pluginConfiguration should return the injected api-http configuration", ({ server, configuration }) => { + assert.is((server as any).pluginConfiguration(), configuration); + }); + + it("defaultOptions should strip trailing slash on the router", ({ server }) => { + const options = (server as any).defaultOptions(); + + assert.equal(options.router, { stripTrailingSlash: true }); + }); + + it("defaultOptions should expose the pagination limit as validation context", ({ server }) => { + const options = (server as any).defaultOptions(); + + assert.equal(options.routes.validate.options.context, { + configuration: { + plugins: { + pagination: { limit: 100 }, + }, + }, + }); + }); + + it("defaultOptions should map payload and validation errors to 422 bad data", async ({ server }) => { + const options = (server as any).defaultOptions(); + + // The payload failAction is unreachable over HTTP (all routes are GET), + // but hapi still requires it for payload parsing errors. + const payloadResult = await options.routes.payload.failAction(undefined, undefined, new Error("payload broke")); + assert.true(payloadResult.isBoom); + assert.is(payloadResult.output.statusCode, 422); + assert.is(payloadResult.output.payload.message, "payload broke"); + + const validateResult = await options.routes.validate.failAction( + undefined, + undefined, + new Error("validation broke"), + ); + assert.true(validateResult.isBoom); + assert.is(validateResult.output.statusCode, 422); + assert.is(validateResult.output.payload.message, "validation broke"); + }); +}); diff --git a/packages/api-http/test/helpers/server.ts b/packages/api-http/test/helpers/server.ts new file mode 100644 index 000000000..1ec0b8386 --- /dev/null +++ b/packages/api-http/test/helpers/server.ts @@ -0,0 +1,233 @@ +import { Identifiers as ApiDatabaseIdentifiers } from "@mainsail/api-database"; +import { Identifiers } from "@mainsail/constants"; +import { Application, Providers } from "@mainsail/kernel"; + +import { Identifiers as ApiHttpIdentifiers } from "../../source/identifiers"; +import { Server } from "../../source/server"; +import { ServiceProvider } from "../../source/service-provider"; +import { makePage } from "../fixtures/entities"; + +// Mutable per-repository result store; tests assign what the next terminal call returns. +export type RepoData = { + one?: unknown; // getOne / findOneByCriteria / getLatest + many?: unknown[]; // getMany + manyAndCount?: [unknown[], number]; // getManyAndCount (defaults to [many, many.length]) + rawMany?: unknown[]; // getRawMany + rawOne?: unknown; // getRawOne + page?: ReturnType; // findManyByCriteria / findManyValidatorsByCritera + feeStatistics?: { avg: string; max: string; min: string; sum: string }; + peerBlockNumberP90?: number; + latestHeight?: number; // getLatestHeight (x-block-number response header) +}; + +const CHAIN_METHODS = [ + "select", + "addSelect", + "where", + "andWhere", + "orWhere", + "orderBy", + "addOrderBy", + "limit", + "offset", + "innerJoin", + "leftJoin", + "setParameter", + "setParameters", + "from", + "addCommonTableExpression", +]; + +export const makeQueryBuilder = (data: RepoData) => { + const calls: Record = {}; + const record = (method: string, args: any[]) => { + (calls[method] ??= []).push(args); + }; + + const qb: any = { calls }; + for (const method of CHAIN_METHODS) { + qb[method] = (...args: any[]) => { + record(method, args); + return qb; + }; + } + + qb.clone = () => qb; + qb.getParameters = () => ({}); + qb.getQuery = () => "SELECT 1"; + // Terminal methods are recorded as well, so tests can assert a lookup was skipped. + qb.getOne = async () => { + record("getOne", []); + return data.one ?? null; + }; + qb.getMany = async () => { + record("getMany", []); + return data.many ?? []; + }; + qb.getManyAndCount = async () => { + record("getManyAndCount", []); + return data.manyAndCount ?? [data.many ?? [], (data.many ?? []).length]; + }; + qb.getRawMany = async () => { + record("getRawMany", []); + return data.rawMany ?? []; + }; + qb.getRawOne = async () => { + record("getRawOne", []); + return data.rawOne; + }; + + return qb; +}; + +export const makeRepo = () => { + const data: RepoData = {}; + const qb = makeQueryBuilder(data); + const calls: Record = {}; + const record = (method: string, args: any[]) => { + (calls[method] ??= []).push(args); + }; + + return { + calls, + createQueryBuilder: () => qb, + data, + findManyByCriteria: async (...args: any[]) => { + record("findManyByCriteria", args); + return data.page ?? makePage([]); + }, + findManyValidatorsByCritera: async (...args: any[]) => { + record("findManyValidatorsByCritera", args); + return data.page ?? makePage([]); + }, + findOneByCriteria: async (...args: any[]) => { + record("findOneByCriteria", args); + return data.one ?? null; + }, + getFeeStatistics: async (...args: any[]) => { + record("getFeeStatistics", args); + return data.feeStatistics; + }, + getLatest: async () => data.one ?? null, + getLatestHeight: async () => data.latestHeight ?? 0, + getPeerBlockNumberP90: async () => data.peerBlockNumberP90 ?? 0, + qb, + }; +}; + +const makeDataSource = () => { + const data: RepoData = {}; + const queries: any[][] = []; + + return { + createQueryBuilder: () => makeQueryBuilder(data), + data, + queries, + query: async (...args: any[]) => { + queries.push(args); + return data.rawMany ?? []; + }, + }; +}; + +export type Repos = ReturnType; + +export const makeRepos = () => ({ + apiNode: makeRepo(), + block: makeRepo(), + configuration: makeRepo(), + contract: makeRepo(), + dataSource: makeDataSource(), + legacyColdWallet: makeRepo(), + peer: makeRepo(), + plugin: makeRepo(), + state: makeRepo(), + system: { inMaintenance: async () => false }, + token: makeRepo(), + tokenAction: makeRepo(), + tokenHolder: makeRepo(), + tokenWhitelist: makeRepo(), + transaction: makeRepo(), + validatorRound: makeRepo(), + wallet: makeRepo(), +}); + +// A factory instead of a shared constant so tests can mutate their copy freely. +export const makeConfiguration = () => ({ + options: { estimateTotalCount: true }, + plugins: { + cache: { checkperiod: 120, enabled: false, stdTTL: 8 }, + log: { enabled: false }, + pagination: { limit: 100 }, + rateLimit: { blacklist: [], duration: 60, enabled: false, points: 100, whitelist: [] }, + socketTimeout: 5000, + trustProxy: false, + whitelist: ["*"], + }, + server: { + http: { enabled: true, host: "127.0.0.1", port: 0 }, + https: { enabled: false, host: "127.0.0.1", port: 0, tls: {} }, + }, + tokens: { defaultMinimumBalance: 0.01 }, +}); + +export const bindDependencies = (app: Application, repositories: Repos): void => { + app.bind(Identifiers.Services.Log.Service).toConstantValue({ + debug: () => {}, + error: () => {}, + info: () => {}, + notice: () => {}, + warning: () => {}, + }); + app.bind(Identifiers.Cryptography.Validator).toConstantValue({ addSchema: () => {}, hasSchema: () => false }); + + app.bind(ApiDatabaseIdentifiers.DataSource).toConstantValue(repositories.dataSource); + app.bind(ApiDatabaseIdentifiers.ApiNodeRepositoryFactory).toConstantValue(() => repositories.apiNode); + app.bind(ApiDatabaseIdentifiers.BlockRepositoryFactory).toConstantValue(() => repositories.block); + app.bind(ApiDatabaseIdentifiers.ConfigurationRepositoryFactory).toConstantValue(() => repositories.configuration); + app.bind(ApiDatabaseIdentifiers.ContractRepositoryFactory).toConstantValue(() => repositories.contract); + app.bind(ApiDatabaseIdentifiers.LegacyColdWalletRepositoryFactory).toConstantValue( + () => repositories.legacyColdWallet, + ); + app.bind(ApiDatabaseIdentifiers.PeerRepositoryFactory).toConstantValue(() => repositories.peer); + app.bind(ApiDatabaseIdentifiers.PluginRepositoryFactory).toConstantValue(() => repositories.plugin); + app.bind(ApiDatabaseIdentifiers.StateRepositoryFactory).toConstantValue(() => repositories.state); + app.bind(ApiDatabaseIdentifiers.SystemRepositoryFactory).toConstantValue(() => repositories.system); + app.bind(ApiDatabaseIdentifiers.TokenActionRepositoryFactory).toConstantValue(() => repositories.tokenAction); + app.bind(ApiDatabaseIdentifiers.TokenHolderRepositoryFactory).toConstantValue(() => repositories.tokenHolder); + app.bind(ApiDatabaseIdentifiers.TokenRepositoryFactory).toConstantValue(() => repositories.token); + app.bind(ApiDatabaseIdentifiers.TokenWhitelistRepositoryFactory).toConstantValue(() => repositories.tokenWhitelist); + app.bind(ApiDatabaseIdentifiers.TransactionRepositoryFactory).toConstantValue(() => repositories.transaction); + app.bind(ApiDatabaseIdentifiers.ValidatorRoundRepositoryFactory).toConstantValue(() => repositories.validatorRound); + app.bind(ApiDatabaseIdentifiers.WalletRepositoryFactory).toConstantValue(() => repositories.wallet); +}; + +export const registerServiceProvider = async (app: Application, config: object): Promise => { + const pluginConfiguration = app.resolve(Providers.PluginConfiguration).from("api-http", config); + app.bind(Identifiers.ServiceProvider.Configuration) + .toConstantValue(pluginConfiguration) + .whenTagged("plugin", "api-http"); + + const serviceProvider = app.resolve(ServiceProvider); + serviceProvider.setConfig(pluginConfiguration); + + await serviceProvider.register(); + + return serviceProvider; +}; + +export const bootstrapServer = async ( + repositories: Repos, + config: object = makeConfiguration(), +): Promise<{ app: Application; server: Server; serviceProvider: ServiceProvider }> => { + const app = new Application(); + bindDependencies(app, repositories); + + const serviceProvider = await registerServiceProvider(app, config); + + const server = app.isBound(ApiHttpIdentifiers.HTTP) + ? app.get(ApiHttpIdentifiers.HTTP) + : (undefined as unknown as Server); + + return { app, server, serviceProvider }; +}; From 05d9336a513fe0891aa3478c2f9e7184178ad328 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:31:06 +0900 Subject: [PATCH 09/28] resources coverage --- .../source/resources/resources.test.ts | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 packages/api-http/source/resources/resources.test.ts diff --git a/packages/api-http/source/resources/resources.test.ts b/packages/api-http/source/resources/resources.test.ts new file mode 100644 index 000000000..4c2f5706b --- /dev/null +++ b/packages/api-http/source/resources/resources.test.ts @@ -0,0 +1,144 @@ +import { describe } from "@mainsail/test-runner"; + +import { makeBlock, makePeer, makeState, makeTransaction, makeWallet } from "../../test/fixtures/entities"; +import { ApiNodeResource } from "./api-node.js"; +import { BlockResource } from "./block.js"; +import { LegacyColdWalletResource } from "./legacy-cold-wallet.js"; +import { PeerResource } from "./peer.js"; +import { ReceiptResource } from "./receipt.js"; +import { RoundResource } from "./round.js"; +import { TokenHolderResource } from "./token-holder.js"; +import { TokenTransferResource } from "./token-transfer.js"; +import { TokenWhitelistResource } from "./token-whitelist.js"; +import { TokenResource } from "./token.js"; +import { TransactionResource } from "./transaction.js"; +import { ValidatorRoundResource } from "./validator-round.js"; +import { ValidatorResource } from "./validator.js"; +import { WalletResource } from "./wallet.js"; + +describe("Resources", ({ it, assert }) => { + it("identity resources return the input from raw and transform", () => { + const entity = { marker: "entity" }; + + for (const Resource of [ + ApiNodeResource, + LegacyColdWalletResource, + TokenHolderResource, + TokenTransferResource, + TokenWhitelistResource, + TokenResource, + ValidatorRoundResource, + ValidatorResource, + WalletResource, + ]) { + const resource = new Resource(); + + assert.is(resource.raw(entity as any), entity); + assert.is(resource.transform(entity as any), entity); + } + }); + + it("RoundResource picks address and votes", () => { + const resource = new RoundResource(); + const round = { address: "0xa", extra: true, votes: "100" }; + + assert.is(resource.raw(round as any), round); + assert.equal(resource.transform(round as any), { address: "0xa", votes: "100" }); + }); + + it("PeerResource picks the public peer fields", () => { + const resource = new PeerResource(); + const peer = { ...makePeer(), secret: "internal" }; + + assert.is(resource.raw(peer as any), peer); + + const transformed = resource.transform(peer as any) as any; + assert.equal(transformed.ip, "127.0.0.1"); + assert.false("secret" in transformed); + }); + + it("ReceiptResource maps a transaction to its receipt for raw and transform", () => { + const resource = new ReceiptResource(); + const transaction = makeTransaction({ decodedError: "reverted" }); + + for (const receipt of [ + resource.raw(transaction as any) as any, + resource.transform(transaction as any) as any, + ]) { + assert.equal(receipt.transactionHash, transaction.hash); + assert.equal(receipt.decodedError, "reverted"); + assert.false("hash" in receipt); + } + }); + + it("ReceiptResource omits the decoded error of successful transactions", () => { + const resource = new ReceiptResource(); + + assert.false("decodedError" in (resource.transform(makeTransaction() as any) as any)); + }); + + it("BlockResource strips the enrichment from raw", () => { + const resource = new BlockResource(); + const enriched = { ...makeBlock(), generator: makeWallet(), state: makeState() }; + + const raw = resource.raw(enriched as any) as any; + assert.undefined(raw.generator); + assert.undefined(raw.state); + assert.equal(raw.hash, enriched.hash); + }); + + it("BlockResource derives confirmations, total and generator fields", () => { + const resource = new BlockResource(); + const enriched = { ...makeBlock(), generator: makeWallet(), state: makeState({ blockNumber: "100" }) }; + + const transformed = resource.transform(enriched as any) as any; + assert.equal(transformed.confirmations, 10); + assert.equal(transformed.total, "300"); + assert.equal(transformed.username, "genesis"); + + // A zero state height yields zero confirmations instead of a negative count. + const fresh = resource.transform({ ...enriched, state: makeState({ blockNumber: "0" }) } as any) as any; + assert.equal(fresh.confirmations, 0); + }); + + it("TransactionResource strips the state from raw", () => { + const resource = new TransactionResource(); + const enriched = { ...makeTransaction(), fullReceipt: false, state: makeState() }; + + const raw = resource.raw(enriched as any) as any; + assert.undefined(raw.state); + assert.equal(raw.hash, enriched.hash); + }); + + it("TransactionResource omits confirmations and timestamp for pending transactions", async () => { + const resource = new TransactionResource(); + const pending = { + ...makeTransaction({ blockNumber: undefined, timestamp: undefined }), + fullReceipt: false, + state: makeState(), + }; + + const transformed = (await resource.transform(pending as any)) as any; + assert.undefined(transformed.confirmations); + assert.undefined(transformed.timestamp); + }); + + it("TransactionResource keeps the legacy second signature only when present", async () => { + const resource = new TransactionResource(); + const enriched = { + ...makeTransaction({ legacySecondSignature: "aa".repeat(64) }), + fullReceipt: false, + state: makeState(), + }; + + const transformed = (await resource.transform(enriched as any)) as any; + assert.equal(transformed.legacySecondSignature, "aa".repeat(64)); + + const withoutSignature = (await resource.transform({ + ...makeTransaction(), + fullReceipt: false, + state: makeState(), + } as any)) as any; + assert.false("legacySecondSignature" in withoutSignature); + }); +}); From ea39356af04a0b344a6749c4f101da89b13f43b8 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:31:13 +0900 Subject: [PATCH 10/28] commits coverage --- .../api-http/source/routes/commits.test.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 packages/api-http/source/routes/commits.test.ts diff --git a/packages/api-http/source/routes/commits.test.ts b/packages/api-http/source/routes/commits.test.ts new file mode 100644 index 000000000..6195813c7 --- /dev/null +++ b/packages/api-http/source/routes/commits.test.ts @@ -0,0 +1,67 @@ +import { describe } from "@mainsail/test-runner"; + +import { ADDRESS_A, ADDRESS_B, BLOCK_HASH, makeBlock, makeValidatorRound } from "../../test/fixtures/entities"; +import { bootstrapServer, makeRepos, Repos } from "../../test/helpers/server"; +import { Server } from "../server"; +import { ServiceProvider } from "../service-provider"; + +const ADDRESS_C = "0xdddddddddddddddddddddddddddddddddddddddd"; + +describe<{ + server: Server; + serviceProvider: ServiceProvider; + repos: Repos; +}>("Commits routes", ({ it, assert, beforeEach, afterEach }) => { + beforeEach(async (context) => { + context.repos = makeRepos(); + + const { server, serviceProvider } = await bootstrapServer(context.repos); + context.server = server; + context.serviceProvider = serviceProvider; + }); + + afterEach(async ({ serviceProvider }) => { + await serviceProvider.dispose(); + }); + + it("GET /api/commits/{id} - unpacks the validator bitmask by position", async ({ server, repos }) => { + // 0b101 -> the first and third round validator signed, the second did not. + repos.block.data.one = makeBlock({ validatorSet: "5" }); + repos.validatorRound.data.one = makeValidatorRound({ + validators: [ADDRESS_A, ADDRESS_B, ADDRESS_C], + }); + + const response = await server.inject({ method: "GET", url: "/api/commits/90" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload), { + data: { + blockNumber: "90", + signature: makeBlock().signature, + // Positional selection: NOT the first two validators. + validators: [ADDRESS_A, ADDRESS_C], + }, + }); + }); + + it("GET /api/commits/{id} - returns no validators without a matching round", async ({ server, repos }) => { + repos.block.data.one = makeBlock({ validatorSet: "5" }); + + const response = await server.inject({ method: "GET", url: `/api/commits/${BLOCK_HASH}` }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data.validators, []); + }); + + it("GET /api/commits/{id} - responds 404 for an unknown block", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/commits/90" }); + + assert.is(response.statusCode, 404); + }); + + it("GET /api/commits/{id} - rejects a malformed id", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/commits/nope" }); + + assert.is(response.statusCode, 422); + }); +}); From 12a2fe0e29486cbf4d13d1f167920633261f02a7 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:31:25 +0900 Subject: [PATCH 11/28] tokens coverage --- .../api-http/source/routes/tokens.test.ts | 312 ++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 packages/api-http/source/routes/tokens.test.ts diff --git a/packages/api-http/source/routes/tokens.test.ts b/packages/api-http/source/routes/tokens.test.ts new file mode 100644 index 000000000..9ca391638 --- /dev/null +++ b/packages/api-http/source/routes/tokens.test.ts @@ -0,0 +1,312 @@ +import { describe } from "@mainsail/test-runner"; + +import { ADDRESS_A, ADDRESS_B, makeToken, TOKEN_ADDRESS, TRANSACTION_HASH } from "../../test/fixtures/entities"; +import { bootstrapServer, makeRepos, Repos } from "../../test/helpers/server"; +import { Server } from "../server"; +import { ServiceProvider } from "../service-provider"; + +const makeTransferRow = (overrides: Record = {}) => ({ + blockNumber: "90", + from: ADDRESS_A, + functionSig: Buffer.from("a9059cbb", "hex"), + timestamp: "1720000000000", + to: ADDRESS_B, + tokenAddress: TOKEN_ADDRESS, + tokenDecimals: 6, + tokenName: "Test Token", + tokenSymbol: "TEST", + transactionHash: TRANSACTION_HASH, + value: "1000", + ...overrides, +}); + +describe<{ + server: Server; + serviceProvider: ServiceProvider; + repos: Repos; +}>("Tokens routes", ({ it, assert, beforeEach, afterEach }) => { + beforeEach(async (context) => { + context.repos = makeRepos(); + + const { server, serviceProvider } = await bootstrapServer(context.repos); + context.server = server; + context.serviceProvider = serviceProvider; + }); + + afterEach(async ({ serviceProvider }) => { + await serviceProvider.dispose(); + }); + + it("GET /api/tokens - lists whitelisted tokens", async ({ server, repos }) => { + repos.token.data.manyAndCount = [[makeToken()], 1]; + + const response = await server.inject({ method: "GET", url: "/api/tokens" }); + + assert.is(response.statusCode, 200); + + const body = JSON.parse(response.payload); + assert.is(body.meta.totalCount, 1); + assert.equal(body.data[0].address, TOKEN_ADDRESS); + + // By default only whitelisted tokens are listed. + const [, , joinCondition] = repos.token.qb.calls.innerJoin[0]; + assert.equal(joinCondition, "tw.address = tok.address"); + }); + + it("GET /api/tokens - skips the whitelist on request", async ({ server, repos }) => { + repos.token.data.manyAndCount = [[makeToken()], 1]; + + await server.inject({ method: "GET", url: "/api/tokens?ignoreWhitelist=true" }); + + assert.undefined(repos.token.qb.calls.innerJoin); + assert.undefined(repos.token.qb.calls.leftJoin); + }); + + it("GET /api/tokens - extends the whitelist with custom addresses", async ({ server, repos }) => { + repos.token.data.manyAndCount = [[makeToken()], 1]; + + await server.inject({ method: "GET", url: `/api/tokens?whitelist=${ADDRESS_A}` }); + + // A custom whitelist switches to a left join with an OR condition. + assert.defined(repos.token.qb.calls.leftJoin); + assert.undefined(repos.token.qb.calls.innerJoin); + }); + + const replayBrackets = (bracket: any): any[][] => { + const replayed: any[][] = []; + const recorder: any = { + orWhere: (...args: any[]) => { + replayed.push(args); + return recorder; + }, + where: (...args: any[]) => { + replayed.push(args); + return recorder; + }, + }; + bracket.whereFactory(recorder); + return replayed; + }; + + it("GET /api/tokens - searches short names with a prefix and long names with a trigram match", async ({ + server, + repos, + }) => { + repos.token.data.manyAndCount = [[makeToken()], 1]; + + await server.inject({ method: "GET", url: "/api/tokens?name=te" }); + await server.inject({ method: "GET", url: "/api/tokens?name=test" }); + + // Short queries use the prefix index ... + assert.equal(replayBrackets(repos.token.qb.calls.andWhere[0][0]), [ + ["lower(tok.symbol) LIKE :prefix", { prefix: "te%" }], + ["lower(tok.name) LIKE :prefix", { prefix: "te%" }], + ]); + // ... longer queries the trigram index. + assert.equal(replayBrackets(repos.token.qb.calls.andWhere[1][0]), [ + ["tok.symbol ILIKE :like", { like: "%test%" }], + ["tok.name ILIKE :like", { like: "%test%" }], + ]); + + // Both name searches rank prefix matches first. + assert.length(repos.token.qb.calls.addSelect, 2); + assert.equal(repos.token.qb.calls.setParameter[0], ["orderByPrefix", "te%"]); + assert.equal(repos.token.qb.calls.setParameter[1], ["orderByPrefix", "test%"]); + }); + + it("GET /api/tokens/transfers - excludes custom blacklisted tokens", async ({ server, repos }) => { + await server.inject({ method: "GET", url: `/api/tokens/transfers?blacklist=${ADDRESS_A}` }); + + const blacklisted = (repos.tokenAction.qb.calls.andWhere ?? []).find( + ([condition]) => condition === "tok.address NOT IN (:...customBlacklist)", + ); + assert.defined(blacklisted); + assert.equal(blacklisted![1], { customBlacklist: [ADDRESS_A] }); + }); + + it("GET /api/tokens/{address} - returns the token", async ({ server, repos }) => { + repos.token.data.one = makeToken(); + + const response = await server.inject({ method: "GET", url: `/api/tokens/${TOKEN_ADDRESS}` }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data.symbol, "TEST"); + }); + + it("GET /api/tokens/{address} - responds 404 for an unknown token", async ({ server }) => { + const response = await server.inject({ method: "GET", url: `/api/tokens/${TOKEN_ADDRESS}` }); + + assert.is(response.statusCode, 404); + assert.equal(JSON.parse(response.payload).message, "Token not found"); + }); + + it("GET /api/tokens/{address} - rejects a malformed address", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/tokens/nope" }); + + assert.is(response.statusCode, 422); + }); + + it("GET /api/tokens/{address}/holders - lists the holders of the token", async ({ server, repos }) => { + repos.token.data.one = makeToken(); + repos.tokenHolder.data.manyAndCount = [ + [{ address: ADDRESS_A, balance: "12000000", tokenAddress: TOKEN_ADDRESS }], + 1, + ]; + + const response = await server.inject({ method: "GET", url: `/api/tokens/${TOKEN_ADDRESS}/holders` }); + + assert.is(response.statusCode, 200); + + // The holders route is not wrapped by the pagination plugin. + const body = JSON.parse(response.payload); + assert.is(body.totalCount, 1); + assert.equal(body.results[0].address, ADDRESS_A); + }); + + it("GET /api/tokens/{address}/holders - responds 404 for an unknown token", async ({ server }) => { + const response = await server.inject({ method: "GET", url: `/api/tokens/${TOKEN_ADDRESS}/holders` }); + + assert.is(response.statusCode, 404); + }); + + it("GET /api/tokens/transfers - lists transfer actions with token metadata", async ({ server, repos }) => { + repos.tokenAction.data.rawMany = [makeTransferRow()]; + repos.tokenAction.data.rawOne = { cnt: "1" }; + + const response = await server.inject({ method: "GET", url: "/api/tokens/transfers" }); + + assert.is(response.statusCode, 200); + + const body = JSON.parse(response.payload); + assert.is(body.meta.totalCount, 1); + assert.equal(body.data[0], { + blockNumber: "90", + from: ADDRESS_A, + functionSig: "0xa9059cbb", + timestamp: "1720000000000", + to: ADDRESS_B, + token: { address: TOKEN_ADDRESS, decimals: 6, name: "Test Token", symbol: "TEST" }, + transactionHash: TRANSACTION_HASH, + value: "1000", + }); + + // The action filter selects transfers. + assert.equal(repos.tokenAction.qb.calls.where[0], ["tf.action = :action", { action: "Transfer" }]); + }); + + it("GET /api/tokens/approvals - filters for approval actions", async ({ server, repos }) => { + await server.inject({ method: "GET", url: "/api/tokens/approvals" }); + + assert.equal(repos.tokenAction.qb.calls.where[0], ["tf.action = :action", { action: "Approval" }]); + }); + + it("GET /api/tokens/transfers - filters by transaction hash and both directions of an address list", async ({ + server, + repos, + }) => { + await server.inject({ + method: "GET", + url: `/api/tokens/transfers?transactionHash=${TRANSACTION_HASH}&addresses=${ADDRESS_A}`, + }); + + const flat = (repos.tokenAction.qb.calls.andWhere ?? []).map(([condition]) => condition); + assert.true(flat.includes("tf.transaction_hash = :transactionHash")); + // The addresses filter is applied as a bracketed from/to OR condition. + assert.true(flat.some((condition) => typeof condition === "object")); + }); + + it("GET /api/tokens/transfers - filters by from and to lists", async ({ server, repos }) => { + await server.inject({ + method: "GET", + url: `/api/tokens/transfers?from=${ADDRESS_A}&to=${ADDRESS_B}`, + }); + + const conditions = (repos.tokenAction.qb.calls.andWhere ?? []).map(([condition]) => condition); + assert.true(conditions.includes("tf.from IN (:...from)")); + assert.true(conditions.includes("tf.to IN (:...to)")); + }); + + it("GET /api/tokens/transfers - matches comma separated address lists in both directions", async ({ + server, + repos, + }) => { + await server.inject({ + method: "GET", + url: `/api/tokens/transfers?addresses=${ADDRESS_A},${ADDRESS_B}`, + }); + + // The addresses filter is a bracketed from-or-to condition; replay it to see its parameters. + const [bracket] = repos.tokenAction.qb.calls.andWhere.at(-1)!; + const replayed: any[][] = []; + const recorder: any = { + orWhere: (...args: any[]) => { + replayed.push(args); + return recorder; + }, + where: (...args: any[]) => { + replayed.push(args); + return recorder; + }, + }; + bracket.whereFactory(recorder); + + assert.equal(replayed, [ + ["tf.from IN (:...addresses)", { addresses: [ADDRESS_A, ADDRESS_B] }], + ["tf.to IN (:...addresses)", { addresses: [ADDRESS_A, ADDRESS_B] }], + ]); + }); + + it("GET /api/tokens/transfers - accepts comma separated from and to lists", async ({ server, repos }) => { + await server.inject({ + method: "GET", + url: `/api/tokens/transfers?from=${ADDRESS_A},${ADDRESS_B}&to=${ADDRESS_A},${ADDRESS_B}`, + }); + + const parameters = (repos.tokenAction.qb.calls.andWhere ?? []).map(([, parameter]) => parameter); + assert.true(parameters.some((p) => Array.isArray(p?.from) && p.from.length === 2)); + assert.true(parameters.some((p) => Array.isArray(p?.to) && p.to.length === 2)); + }); + + it("GET /api/tokens/{address}/transfers - scopes the actions to the token", async ({ server, repos }) => { + repos.token.data.one = makeToken(); + repos.tokenAction.data.rawMany = [makeTransferRow()]; + repos.tokenAction.data.rawOne = { cnt: "1" }; + + const response = await server.inject({ method: "GET", url: `/api/tokens/${TOKEN_ADDRESS}/transfers` }); + + assert.is(response.statusCode, 200); + + const conditions = (repos.tokenAction.qb.calls.andWhere ?? []).map(([condition]) => condition); + assert.true(conditions.includes("tf.address = :address")); + }); + + it("GET /api/tokens/{address}/transfers - responds 404 for an unknown token", async ({ server }) => { + const response = await server.inject({ method: "GET", url: `/api/tokens/${TOKEN_ADDRESS}/transfers` }); + + assert.is(response.statusCode, 404); + }); + + it("GET /api/tokens/{address}/approvals - scopes the actions to the token", async ({ server, repos }) => { + repos.token.data.one = makeToken(); + + const response = await server.inject({ method: "GET", url: `/api/tokens/${TOKEN_ADDRESS}/approvals` }); + + assert.is(response.statusCode, 200); + assert.equal(repos.tokenAction.qb.calls.where[0], ["tf.action = :action", { action: "Approval" }]); + }); + + it("GET /api/tokens/whitelist - lists the whitelist entries", async ({ server, repos }) => { + repos.tokenWhitelist.data.manyAndCount = [ + [{ address: TOKEN_ADDRESS, comment: "popular", createdAt: "2026-01-01T00:00:00.000Z" }], + 1, + ]; + + const response = await server.inject({ method: "GET", url: "/api/tokens/whitelist" }); + + assert.is(response.statusCode, 200); + + const body = JSON.parse(response.payload); + assert.is(body.meta.totalCount, 1); + assert.equal(body.data[0].address, TOKEN_ADDRESS); + }); +}); From b605e2778c661715afed3a68c04cfa10f4736988 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:31:32 +0900 Subject: [PATCH 12/28] votes coverage --- packages/api-http/source/routes/votes.test.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 packages/api-http/source/routes/votes.test.ts diff --git a/packages/api-http/source/routes/votes.test.ts b/packages/api-http/source/routes/votes.test.ts new file mode 100644 index 000000000..f2f6478ca --- /dev/null +++ b/packages/api-http/source/routes/votes.test.ts @@ -0,0 +1,64 @@ +import { describe } from "@mainsail/test-runner"; + +import { makePage, makeState, makeTransaction, TRANSACTION_HASH } from "../../test/fixtures/entities"; +import { bootstrapServer, makeRepos, Repos } from "../../test/helpers/server"; +import { Server } from "../server"; +import { ServiceProvider } from "../service-provider"; + +const VOTE_FUNCTION_SIG = "0x6dd7d8ea"; + +describe<{ + server: Server; + serviceProvider: ServiceProvider; + repos: Repos; +}>("Votes routes", ({ it, assert, beforeEach, afterEach }) => { + beforeEach(async (context) => { + context.repos = makeRepos(); + + const { server, serviceProvider } = await bootstrapServer(context.repos); + context.server = server; + context.serviceProvider = serviceProvider; + }); + + afterEach(async ({ serviceProvider }) => { + await serviceProvider.dispose(); + }); + + it("GET /api/votes - restricts the criteria to vote transactions", async ({ server, repos }) => { + repos.transaction.data.page = makePage([makeTransaction({ data: VOTE_FUNCTION_SIG })]); + repos.state.data.one = makeState(); + + const response = await server.inject({ method: "GET", url: "/api/votes" }); + + assert.is(response.statusCode, 200); + + const body = JSON.parse(response.payload); + assert.is(body.meta.totalCount, 1); + assert.equal(body.data[0].hash, TRANSACTION_HASH); + + // The vote function signature is forced into the search criteria. + const [, criteria] = repos.transaction.calls.findManyByCriteria[0]; + assert.equal(criteria.data, VOTE_FUNCTION_SIG); + }); + + it("GET /api/votes/{hash} - returns the vote transaction", async ({ server, repos }) => { + repos.transaction.data.one = makeTransaction({ data: VOTE_FUNCTION_SIG }); + repos.state.data.one = makeState(); + + const response = await server.inject({ method: "GET", url: `/api/votes/${TRANSACTION_HASH}` }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data.hash, TRANSACTION_HASH); + + // The lookup filters on the vote function signature prefix. + const [, parameters] = repos.transaction.qb.calls.andWhere[0]; + assert.equal(parameters, { data: String.raw`\x6dd7d8ea` }); + }); + + it("GET /api/votes/{hash} - responds 404 when the hash is not a vote", async ({ server }) => { + const response = await server.inject({ method: "GET", url: `/api/votes/${"f".repeat(64)}` }); + + assert.is(response.statusCode, 404); + assert.equal(JSON.parse(response.payload).message, "Vote not found"); + }); +}); From 7e8334c934504fabb8c1e1e99a006c94cac987d1 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:31:46 +0900 Subject: [PATCH 13/28] service-provider coverage --- .../api-http/source/service-provider.test.ts | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 packages/api-http/source/service-provider.test.ts diff --git a/packages/api-http/source/service-provider.test.ts b/packages/api-http/source/service-provider.test.ts new file mode 100644 index 000000000..1809404c3 --- /dev/null +++ b/packages/api-http/source/service-provider.test.ts @@ -0,0 +1,147 @@ +import { Application } from "@mainsail/kernel"; +import { describe } from "@mainsail/test-runner"; +import { execSync } from "child_process"; +import { mkdtempSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +import { makeBlock } from "../test/fixtures/entities"; +import { bootstrapServer, makeConfiguration, makeRepos, Repos } from "../test/helpers/server"; +import { Identifiers as ApiHttpIdentifiers } from "./identifiers"; +import { Server } from "./server"; +import { ServiceProvider } from "./service-provider"; + +describe<{ + repos: Repos; +}>("ServiceProvider", ({ it, assert, beforeEach }) => { + beforeEach((context) => { + context.repos = makeRepos(); + }); + + it("register: binds the http server; boot and dispose start and stop it", async ({ repos }) => { + const { app, server, serviceProvider } = await bootstrapServer(repos); + + try { + assert.true(app.isBound(ApiHttpIdentifiers.HTTP)); + assert.false(app.isBound(ApiHttpIdentifiers.HTTPS)); + assert.is(server.prettyName, "Public API (HTTP)"); + + await serviceProvider.boot(); + assert.string(server.uri); + } finally { + await serviceProvider.dispose(); + } + }); + + it("register: responds to requests through the full plugin stack", async ({ repos }) => { + const { server, serviceProvider } = await bootstrapServer(repos); + + try { + const response = await server.inject({ method: "GET", url: "/" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload), { data: "Hello World from Public API!" }); + } finally { + await serviceProvider.dispose(); + } + }); + + it("register: rate limits requests when the rate limiter is enabled", async ({ repos }) => { + repos.block.data.one = makeBlock(); + + const limited = makeConfiguration(); + limited.plugins.rateLimit.enabled = true; + limited.plugins.rateLimit.points = 1; + + const { server, serviceProvider } = await bootstrapServer(repos, limited); + + try { + assert.is((await server.inject({ method: "GET", url: "/api/blockchain" })).statusCode, 200); + assert.is((await server.inject({ method: "GET", url: "/api/blockchain" })).statusCode, 429); + } finally { + await serviceProvider.dispose(); + } + }); + + it("register: builds the https server when enabled", async ({ repos }) => { + const directory = mkdtempSync(join(tmpdir(), "api-http-tls-")); + const keyPath = join(directory, "key.pem"); + const certPath = join(directory, "cert.pem"); + execSync( + `openssl req -x509 -newkey rsa:2048 -keyout "${keyPath}" -out "${certPath}" -days 1 -nodes -subj "/CN=localhost"`, + { stdio: "ignore" }, + ); + + const https = makeConfiguration(); + https.server.http.enabled = false; + https.server.https.enabled = true; + https.server.https.tls = { cert: certPath, key: keyPath }; + + const { app, serviceProvider } = await bootstrapServer(repos, https); + + try { + await serviceProvider.boot(); + + const server = app.get(ApiHttpIdentifiers.HTTPS); + assert.is(server.prettyName, "Public API (HTTPS)"); + assert.startsWith(server.uri, "https://"); + + const response = await server.inject({ method: "GET", url: "/api/blockchain" }); + assert.is(response.statusCode, 200); + } finally { + await serviceProvider.dispose(); + } + }); + + it("register: skips both servers when disabled", async ({ repos }) => { + const disabled = makeConfiguration(); + disabled.server.http.enabled = false; + + const { app, serviceProvider } = await bootstrapServer(repos, disabled); + + await serviceProvider.boot(); + await serviceProvider.dispose(); + + assert.false(app.isBound(ApiHttpIdentifiers.HTTP)); + assert.false(app.isBound(ApiHttpIdentifiers.HTTPS)); + }); + + // The test configuration listens on port 0 to grab a free port; the schema + // itself requires real port numbers. + const makeValidatableConfiguration = () => { + const config = makeConfiguration(); + config.server.http.port = 4003; + config.server.https.port = 8443; + return config; + }; + + it("configSchema: accepts a valid configuration", () => { + const schema = new Application().resolve(ServiceProvider).configSchema(); + + const result = schema.validate(makeValidatableConfiguration()); + + assert.undefined(result.error); + assert.equal(result.value.plugins.pagination.limit, 100); + assert.equal(result.value.tokens.defaultMinimumBalance, 0.01); + }); + + it("configSchema: rejects invalid configurations", () => { + const schema = new Application().resolve(ServiceProvider).configSchema(); + + for (const mutate of [ + (config: any) => delete config.options, + (config: any) => delete config.plugins.cache, + (config: any) => delete config.plugins.rateLimit, + (config: any) => delete config.tokens, + (config: any) => (config.plugins.pagination.limit = -1), + (config: any) => (config.plugins.rateLimit.points = "nope"), + (config: any) => (config.tokens.defaultMinimumBalance = -1), + (config: any) => (config.server.http.port = 0), + ]) { + const config = makeValidatableConfiguration(); + mutate(config); + + assert.defined(schema.validate(config).error); + } + }); +}); From 9b84f73ed91e2835d3d4b37731dfa736b1d66fca Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:32:07 +0900 Subject: [PATCH 14/28] legacy coverage --- .../api-http/source/routes/legacy.test.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 packages/api-http/source/routes/legacy.test.ts diff --git a/packages/api-http/source/routes/legacy.test.ts b/packages/api-http/source/routes/legacy.test.ts new file mode 100644 index 000000000..e1f65dac7 --- /dev/null +++ b/packages/api-http/source/routes/legacy.test.ts @@ -0,0 +1,70 @@ +import { describe } from "@mainsail/test-runner"; + +import { makeLegacyColdWallet } from "../../test/fixtures/entities"; +import { bootstrapServer, makeRepos, Repos } from "../../test/helpers/server"; +import { Server } from "../server"; +import { ServiceProvider } from "../service-provider"; + +const LEGACY_ADDRESS = makeLegacyColdWallet().address as string; + +describe<{ + server: Server; + serviceProvider: ServiceProvider; + repos: Repos; +}>("Legacy routes", ({ it, assert, beforeEach, afterEach }) => { + beforeEach(async (context) => { + context.repos = makeRepos(); + + const { server, serviceProvider } = await bootstrapServer(context.repos); + context.server = server; + context.serviceProvider = serviceProvider; + }); + + afterEach(async ({ serviceProvider }) => { + await serviceProvider.dispose(); + }); + + it("GET /api/legacy/cold-wallets - returns cold wallets in a pagination envelope", async ({ server, repos }) => { + repos.legacyColdWallet.data.manyAndCount = [[makeLegacyColdWallet()], 3]; + + const response = await server.inject({ method: "GET", url: "/api/legacy/cold-wallets" }); + + assert.is(response.statusCode, 200); + + const body = JSON.parse(response.payload); + assert.is(body.meta.totalCount, 3); + assert.equal(body.data[0].address, LEGACY_ADDRESS); + assert.equal(body.data[0].balance, "500"); + }); + + it("GET /api/legacy/cold-wallets/{address} - returns the cold wallet", async ({ server, repos }) => { + repos.legacyColdWallet.data.one = makeLegacyColdWallet(); + + const response = await server.inject({ method: "GET", url: `/api/legacy/cold-wallets/${LEGACY_ADDRESS}` }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data.address, LEGACY_ADDRESS); + + assert.equal(repos.legacyColdWallet.qb.calls.where[0], [ + "address = :legacyAddress", + { legacyAddress: LEGACY_ADDRESS }, + ]); + }); + + it("GET /api/legacy/cold-wallets/{address} - responds 404 for an unknown wallet", async ({ server }) => { + const response = await server.inject({ method: "GET", url: `/api/legacy/cold-wallets/${LEGACY_ADDRESS}` }); + + assert.is(response.statusCode, 404); + assert.equal(JSON.parse(response.payload).message, "Cold Wallet not found"); + }); + + it("GET /api/legacy/cold-wallets/{address} - rejects a malformed legacy address", async ({ server }) => { + // 0 and O are not part of the base58 alphabet. + const response = await server.inject({ + method: "GET", + url: "/api/legacy/cold-wallets/O0O0O0O0O0O0O0O0O0O0O0O0O0O0O0O0O", + }); + + assert.is(response.statusCode, 422); + }); +}); From e3239421fb4839d1835a4047309a58a1e4a1802a Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:33:06 +0900 Subject: [PATCH 15/28] blocks coverage --- .../api-http/source/routes/blocks.test.ts | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 packages/api-http/source/routes/blocks.test.ts diff --git a/packages/api-http/source/routes/blocks.test.ts b/packages/api-http/source/routes/blocks.test.ts new file mode 100644 index 000000000..44f552558 --- /dev/null +++ b/packages/api-http/source/routes/blocks.test.ts @@ -0,0 +1,178 @@ +import { describe } from "@mainsail/test-runner"; + +import { + ADDRESS_A, + BLOCK_HASH, + makeBlock, + makePage, + makeState, + makeTransaction, + makeWallet, + TRANSACTION_HASH, +} from "../../test/fixtures/entities"; +import { bootstrapServer, makeRepos, Repos } from "../../test/helpers/server"; +import { Server } from "../server"; +import { ServiceProvider } from "../service-provider"; + +describe<{ + server: Server; + serviceProvider: ServiceProvider; + repos: Repos; +}>("Blocks routes", ({ it, assert, beforeEach, afterEach }) => { + beforeEach(async (context) => { + context.repos = makeRepos(); + + const { server, serviceProvider } = await bootstrapServer(context.repos); + context.server = server; + context.serviceProvider = serviceProvider; + }); + + afterEach(async ({ serviceProvider }) => { + await serviceProvider.dispose(); + }); + + it("GET /api/blocks - returns enriched blocks in a pagination envelope", async ({ server, repos }) => { + repos.block.data.page = makePage([makeBlock()]); + repos.state.data.one = makeState({ blockNumber: "100" }); + repos.wallet.data.many = [makeWallet()]; + + const response = await server.inject({ method: "GET", url: "/api/blocks" }); + + assert.is(response.statusCode, 200); + + const body = JSON.parse(response.payload); + assert.is(body.meta.totalCount, 1); + assert.length(body.data, 1); + + const [block] = body.data; + assert.equal(block.hash, BLOCK_HASH); + assert.equal(block.number, 90); + assert.equal(block.confirmations, 10); + assert.equal(block.total, "300"); // reward 200 + fee 100 + assert.equal(block.proposer, ADDRESS_A); + assert.equal(block.username, "genesis"); + assert.equal(block.publicKey, makeWallet().publicKey); + }); + + it("GET /api/blocks - skips the generator lookup for an empty page", async ({ server, repos }) => { + const response = await server.inject({ method: "GET", url: "/api/blocks" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data, []); + // No blocks -> the wallet repository is never queried. + assert.undefined(repos.wallet.qb.calls.getMany); + }); + + it("GET /api/blocks - rejects an unknown orderBy property", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/blocks?orderBy=nope:desc" }); + + assert.is(response.statusCode, 422); + }); + + it("GET /api/blocks - rejects a limit above the configured maximum", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/blocks?limit=101" }); + + assert.is(response.statusCode, 422); + }); + + it("GET /api/blocks/first - enriches the genesis block with defaults for unknown generators", async ({ + server, + repos, + }) => { + repos.block.data.one = makeBlock({ number: "0" }); + // No wallet and no state row -> fall back to a zero state and an empty generator. + + const response = await server.inject({ method: "GET", url: "/api/blocks/first" }); + + assert.is(response.statusCode, 200); + + const { data } = JSON.parse(response.payload); + assert.equal(data.number, 0); + assert.equal(data.confirmations, 0); + assert.equal(data.publicKey, ""); + assert.undefined(data.username); + }); + + it("GET /api/blocks/last - enriches the block with its generator wallet", async ({ server, repos }) => { + repos.block.data.one = makeBlock(); + repos.state.data.one = makeState(); + repos.wallet.data.one = makeWallet({ attributes: { username: "proposer" } }); + + const response = await server.inject({ method: "GET", url: "/api/blocks/last" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data.username, "proposer"); + }); + + it("GET /api/blocks/last - responds 404 on an empty chain", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/blocks/last" }); + + assert.is(response.statusCode, 404); + }); + + it("GET /api/blocks/{id} - looks up small numeric ids as block numbers", async ({ server, repos }) => { + repos.block.data.one = makeBlock(); + repos.state.data.one = makeState(); + + const response = await server.inject({ method: "GET", url: "/api/blocks/90" }); + + assert.is(response.statusCode, 200); + assert.equal(repos.block.calls.findOneByCriteria[0][0], { number: 90 }); + }); + + it("GET /api/blocks/{id} - looks up 64 character ids as block hashes", async ({ server, repos }) => { + repos.block.data.one = makeBlock(); + repos.state.data.one = makeState(); + + const response = await server.inject({ method: "GET", url: `/api/blocks/${BLOCK_HASH}` }); + + assert.is(response.statusCode, 200); + assert.equal(repos.block.calls.findOneByCriteria[0][0], { hash: BLOCK_HASH }); + }); + + it("GET /api/blocks/{id} - rejects a malformed id", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/blocks/not-a-block" }); + + assert.is(response.statusCode, 422); + }); + + it("GET /api/blocks/{id} - responds 404 for an unknown block", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/blocks/90" }); + + assert.is(response.statusCode, 404); + }); + + it("GET /api/blocks/{id}/transactions - returns the enriched transactions of the block", async ({ + server, + repos, + }) => { + repos.block.data.one = makeBlock(); + repos.state.data.one = makeState({ blockNumber: "100" }); + repos.transaction.data.page = makePage([makeTransaction()]); + + const response = await server.inject({ method: "GET", url: "/api/blocks/90/transactions" }); + + assert.is(response.statusCode, 200); + + const body = JSON.parse(response.payload); + assert.is(body.meta.totalCount, 1); + + const [transaction] = body.data; + assert.equal(transaction.hash, TRANSACTION_HASH); + assert.equal(transaction.confirmations, 11); // 100 - 90 + 1 + assert.equal(transaction.data, ""); // "0x" is normalized to an empty string + assert.equal(transaction.receipt.gasUsed, 21_000); + // Not requested -> the receipt omits logs and output. + assert.false("logs" in transaction.receipt); + + // The criteria passed to the repository are scoped to the block. + const [, criteria] = repos.transaction.calls.findManyByCriteria[0]; + assert.equal(criteria.blockHash, BLOCK_HASH); + }); + + it("GET /api/blocks/{id}/transactions - responds 404 for an unknown block", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/blocks/90/transactions" }); + + assert.is(response.statusCode, 404); + }); +}); From 93d1a21062dbfb5a303dddf8efe6079e2658d439 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:33:19 +0900 Subject: [PATCH 16/28] validators coverage --- .../api-http/source/routes/validators.test.ts | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 packages/api-http/source/routes/validators.test.ts diff --git a/packages/api-http/source/routes/validators.test.ts b/packages/api-http/source/routes/validators.test.ts new file mode 100644 index 000000000..4fc108cd6 --- /dev/null +++ b/packages/api-http/source/routes/validators.test.ts @@ -0,0 +1,117 @@ +import { describe } from "@mainsail/test-runner"; + +import { ADDRESS_A, ADDRESS_B, makeBlock, makePage, makeState, makeWallet } from "../../test/fixtures/entities"; +import { bootstrapServer, makeRepos, Repos } from "../../test/helpers/server"; +import { Server } from "../server"; +import { ServiceProvider } from "../service-provider"; + +const makeValidator = (overrides: Record = {}) => + makeWallet({ + attributes: { username: "validator", validatorPublicKey: "aa".repeat(48) }, + ...overrides, + }); + +describe<{ + server: Server; + serviceProvider: ServiceProvider; + repos: Repos; +}>("Validators routes", ({ it, assert, beforeEach, afterEach }) => { + beforeEach(async (context) => { + context.repos = makeRepos(); + + const { server, serviceProvider } = await bootstrapServer(context.repos); + context.server = server; + context.serviceProvider = serviceProvider; + }); + + afterEach(async ({ serviceProvider }) => { + await serviceProvider.dispose(); + }); + + it("GET /api/validators - lists validators with schema-scoped criteria", async ({ server, repos }) => { + repos.wallet.data.page = makePage([makeValidator()]); + + const response = await server.inject({ + method: "GET", + url: `/api/validators?address=${ADDRESS_A}&unknownCriteria=1`, + }); + + assert.is(response.statusCode, 422); // unknown keys are rejected by the schema + + const listed = await server.inject({ method: "GET", url: `/api/validators?address=${ADDRESS_A}` }); + assert.is(listed.statusCode, 200); + assert.is(JSON.parse(listed.payload).meta.totalCount, 1); + + // Only whitelisted criteria keys reach the repository. + const [criteria] = repos.wallet.calls.findManyValidatorsByCritera[0]; + assert.equal(criteria, { address: [ADDRESS_A] }); + }); + + it("GET /api/validators/{id} - returns the validator wallet", async ({ server, repos }) => { + repos.wallet.data.one = makeValidator(); + + const response = await server.inject({ method: "GET", url: `/api/validators/${ADDRESS_A}` }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data.address, ADDRESS_A); + }); + + it("GET /api/validators/{id} - responds 404 for an unknown validator", async ({ server }) => { + const response = await server.inject({ method: "GET", url: `/api/validators/${ADDRESS_A}` }); + + assert.is(response.statusCode, 404); + assert.equal(JSON.parse(response.payload).message, "Validator not found"); + }); + + it("GET /api/validators/{id}/voters - lists wallets voting for the validator", async ({ server, repos }) => { + repos.wallet.data.one = makeValidator(); + repos.wallet.data.page = makePage([makeWallet({ address: ADDRESS_B, attributes: { vote: ADDRESS_A } })]); + + const response = await server.inject({ method: "GET", url: `/api/validators/${ADDRESS_A}/voters` }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data[0].address, ADDRESS_B); + + // The vote attribute is forced into the criteria. + const [criteria] = repos.wallet.calls.findManyByCriteria[0]; + assert.equal(criteria.attributes, { vote: ADDRESS_A }); + }); + + it("GET /api/validators/{id}/voters - responds 404 for an unknown validator", async ({ server }) => { + const response = await server.inject({ method: "GET", url: `/api/validators/${ADDRESS_A}/voters` }); + + assert.is(response.statusCode, 404); + }); + + it("GET /api/validators/{id}/blocks - lists enriched blocks proposed by the validator", async ({ + server, + repos, + }) => { + repos.wallet.data.one = makeValidator(); + repos.block.data.page = makePage([makeBlock()]); + repos.state.data.one = makeState({ blockNumber: "100" }); + + const response = await server.inject({ method: "GET", url: `/api/validators/${ADDRESS_A}/blocks` }); + + assert.is(response.statusCode, 200); + + const [block] = JSON.parse(response.payload).data; + assert.equal(block.username, "validator"); + assert.equal(block.confirmations, 10); + + // The proposer is forced into the criteria. + const [criteria] = repos.block.calls.findManyByCriteria[0]; + assert.equal(criteria.proposer, ADDRESS_A); + }); + + it("GET /api/validators/{id}/blocks - responds 404 for a wallet without a public key", async ({ + server, + repos, + }) => { + repos.wallet.data.one = makeValidator({ publicKey: undefined }); + + const response = await server.inject({ method: "GET", url: `/api/validators/${ADDRESS_A}/blocks` }); + + assert.is(response.statusCode, 404); + }); +}); From 843a786624286e8cd12e9ecc265a5c69750d27aa Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:33:32 +0900 Subject: [PATCH 17/28] validator-rounds coverage --- .../source/routes/validator-rounds.test.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 packages/api-http/source/routes/validator-rounds.test.ts diff --git a/packages/api-http/source/routes/validator-rounds.test.ts b/packages/api-http/source/routes/validator-rounds.test.ts new file mode 100644 index 000000000..5741f6a21 --- /dev/null +++ b/packages/api-http/source/routes/validator-rounds.test.ts @@ -0,0 +1,79 @@ +import { describe } from "@mainsail/test-runner"; + +import { ADDRESS_A, ADDRESS_B, makeValidatorRound } from "../../test/fixtures/entities"; +import { bootstrapServer, makeRepos, Repos } from "../../test/helpers/server"; +import { Server } from "../server"; +import { ServiceProvider } from "../service-provider"; + +describe<{ + server: Server; + serviceProvider: ServiceProvider; + repos: Repos; +}>("ValidatorRounds routes", ({ it, assert, beforeEach, afterEach }) => { + beforeEach(async (context) => { + context.repos = makeRepos(); + + const { server, serviceProvider } = await bootstrapServer(context.repos); + context.server = server; + context.serviceProvider = serviceProvider; + }); + + afterEach(async ({ serviceProvider }) => { + await serviceProvider.dispose(); + }); + + it("GET /api/rounds - returns rounds in a pagination envelope", async ({ server, repos }) => { + repos.validatorRound.data.manyAndCount = [[makeValidatorRound()], 5]; + + const response = await server.inject({ method: "GET", url: "/api/rounds" }); + + assert.is(response.statusCode, 200); + + const body = JSON.parse(response.payload); + assert.is(body.meta.totalCount, 5); + assert.equal(body.data[0].round, 1); + }); + + it("GET /api/rounds/{round} - returns the round", async ({ server, repos }) => { + repos.validatorRound.data.one = makeValidatorRound(); + + const response = await server.inject({ method: "GET", url: "/api/rounds/1" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data.validators, [ADDRESS_A, ADDRESS_B]); + }); + + it("GET /api/rounds/{round} - responds 404 for an unknown round", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/rounds/99" }); + + assert.is(response.statusCode, 404); + }); + + it("GET /api/rounds/{round} - rejects a non-numeric round", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/rounds/nope" }); + + assert.is(response.statusCode, 422); + }); + + it("GET /api/rounds/{id}/validators - pairs validators with their votes", async ({ server, repos }) => { + repos.validatorRound.data.one = makeValidatorRound({ + validators: [ADDRESS_A, ADDRESS_B], + votes: ["100"], // fewer votes than validators + }); + + const response = await server.inject({ method: "GET", url: "/api/rounds/1/validators" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data, [ + { address: ADDRESS_A, votes: "100" }, + // A missing vote entry falls back to zero. + { address: ADDRESS_B, votes: "0" }, + ]); + }); + + it("GET /api/rounds/{id}/validators - responds 404 for an unknown round", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/rounds/99/validators" }); + + assert.is(response.statusCode, 404); + }); +}); From 1cc4226b00d5ddd7fd869d95aceb216cc5b7f815 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:33:49 +0900 Subject: [PATCH 18/28] wallets coverage --- .../api-http/source/routes/wallets.test.ts | 344 ++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 packages/api-http/source/routes/wallets.test.ts diff --git a/packages/api-http/source/routes/wallets.test.ts b/packages/api-http/source/routes/wallets.test.ts new file mode 100644 index 000000000..82b5dc2d2 --- /dev/null +++ b/packages/api-http/source/routes/wallets.test.ts @@ -0,0 +1,344 @@ +import { describe } from "@mainsail/test-runner"; + +import { + ADDRESS_A, + ADDRESS_B, + makePage, + makeState, + makeToken, + makeTransaction, + makeWallet, + PUBLIC_KEY, + TOKEN_ADDRESS, + TRANSACTION_HASH, +} from "../../test/fixtures/entities"; +import { bootstrapServer, makeRepos, Repos } from "../../test/helpers/server"; +import { Server } from "../server"; +import { ServiceProvider } from "../service-provider"; + +const VOTE_FUNCTION_SIG = "0x6dd7d8ea"; + +describe<{ + server: Server; + serviceProvider: ServiceProvider; + repos: Repos; + app: any; +}>("Wallets routes", ({ it, assert, beforeEach, afterEach }) => { + beforeEach(async (context) => { + context.repos = makeRepos(); + + const { app, server, serviceProvider } = await bootstrapServer(context.repos); + context.app = app; + context.server = server; + context.serviceProvider = serviceProvider; + }); + + afterEach(async ({ serviceProvider }) => { + await serviceProvider.dispose(); + }); + + it("GET /api/wallets - returns wallets in a pagination envelope", async ({ server, repos }) => { + repos.wallet.data.page = makePage([makeWallet()]); + + const response = await server.inject({ method: "GET", url: "/api/wallets" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data[0].address, ADDRESS_A); + }); + + it("GET /api/wallets/top - returns wallets in a pagination envelope", async ({ server, repos }) => { + repos.wallet.data.page = makePage([makeWallet()]); + + const response = await server.inject({ method: "GET", url: "/api/wallets/top" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data[0].balance, "1000"); + }); + + it("GET /api/wallets/{id} - returns the wallet", async ({ server, repos }) => { + repos.wallet.data.one = makeWallet(); + + const response = await server.inject({ method: "GET", url: `/api/wallets/${ADDRESS_A}` }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data.address, ADDRESS_A); + + // The wallet is matched by address, public key or username. + assert.equal(repos.wallet.qb.calls.where[0], ["address = :address", { address: ADDRESS_A }]); + assert.equal(repos.wallet.qb.calls.orWhere[0], ["public_key = :publicKey", { publicKey: ADDRESS_A }]); + assert.equal(repos.wallet.qb.calls.orWhere[1], [ + "attributes @> :username", + { username: { username: ADDRESS_A } }, + ]); + }); + + it("GET /api/wallets/{id} - responds 404 for an unknown wallet", async ({ server }) => { + const response = await server.inject({ method: "GET", url: `/api/wallets/${ADDRESS_A}` }); + + assert.is(response.statusCode, 404); + }); + + it("GET /api/wallets/{id}/transactions - filters by the wallet address", async ({ server, repos }) => { + repos.wallet.data.one = makeWallet(); + repos.transaction.data.page = makePage([makeTransaction()]); + repos.state.data.one = makeState(); + + const response = await server.inject({ method: "GET", url: `/api/wallets/${ADDRESS_A}/transactions` }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data[0].hash, TRANSACTION_HASH); + + const [, criteria] = repos.transaction.calls.findManyByCriteria[0]; + assert.equal(criteria.address, ADDRESS_A); + }); + + it("GET /api/wallets/{id}/transactions - responds 404 for an unknown wallet", async ({ server }) => { + const response = await server.inject({ method: "GET", url: `/api/wallets/${ADDRESS_A}/transactions` }); + + assert.is(response.statusCode, 404); + }); + + it("GET /api/wallets/{id}/transactions/sent - filters by the sender public key", async ({ server, repos }) => { + repos.wallet.data.one = makeWallet(); + repos.transaction.data.page = makePage([makeTransaction()]); + repos.state.data.one = makeState(); + + const response = await server.inject({ + method: "GET", + url: `/api/wallets/${ADDRESS_A}/transactions/sent`, + }); + + assert.is(response.statusCode, 200); + + const [, criteria] = repos.transaction.calls.findManyByCriteria[0]; + assert.equal(criteria.senderPublicKey, PUBLIC_KEY); + }); + + it("GET /api/wallets/{id}/transactions/sent - returns an empty page for a wallet without a public key", async ({ + server, + repos, + }) => { + repos.wallet.data.one = makeWallet({ publicKey: undefined }); + + const response = await server.inject({ + method: "GET", + url: `/api/wallets/${ADDRESS_A}/transactions/sent`, + }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data, []); + assert.undefined(repos.transaction.calls.findManyByCriteria); + }); + + it("GET /api/wallets/{id}/transactions/received - filters by the recipient address", async ({ server, repos }) => { + repos.wallet.data.one = makeWallet(); + repos.transaction.data.page = makePage([makeTransaction()]); + repos.state.data.one = makeState(); + + await server.inject({ method: "GET", url: `/api/wallets/${ADDRESS_A}/transactions/received` }); + + const [, criteria] = repos.transaction.calls.findManyByCriteria[0]; + assert.equal(criteria.to, ADDRESS_A); + }); + + it("GET /api/wallets/{id}/votes - filters by the vote function signature", async ({ server, repos }) => { + repos.wallet.data.one = makeWallet(); + repos.transaction.data.page = makePage([makeTransaction({ data: VOTE_FUNCTION_SIG })]); + repos.state.data.one = makeState(); + + const response = await server.inject({ method: "GET", url: `/api/wallets/${ADDRESS_A}/votes` }); + + assert.is(response.statusCode, 200); + + const [, criteria] = repos.transaction.calls.findManyByCriteria[0]; + assert.equal(criteria.data, VOTE_FUNCTION_SIG); + assert.equal(criteria.senderPublicKey, PUBLIC_KEY); + }); + + it("GET /api/wallets/{id}/votes - returns an empty page for a wallet without a public key", async ({ + server, + repos, + }) => { + repos.wallet.data.one = makeWallet({ publicKey: undefined }); + + const response = await server.inject({ method: "GET", url: `/api/wallets/${ADDRESS_A}/votes` }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data, []); + }); + + it("GET /api/wallets/tokens - aggregates holder balances per token", async ({ server, repos }) => { + // Page of matching token metadata + its total count. + repos.token.data.rawMany = [ + { decimals: 6, name: "Test Token", supply: "1000000000", symbol: "TEST", token: TOKEN_ADDRESS }, + ]; + repos.token.data.rawOne = { cnt: "1" }; + // Holder rows for the page tokens. + repos.tokenHolder.data.rawMany = [ + { address: ADDRESS_A, balance: "12000000", token: TOKEN_ADDRESS }, + { address: ADDRESS_B, balance: "10000000", token: TOKEN_ADDRESS }, + ]; + + const response = await server.inject({ + method: "GET", + url: `/api/wallets/tokens?addresses=${ADDRESS_A},${ADDRESS_B}`, + }); + + assert.is(response.statusCode, 200); + + const body = JSON.parse(response.payload); + assert.is(body.meta.totalCount, 1); + assert.equal(body.data, [ + { + addresses: { [ADDRESS_A]: "12000000", [ADDRESS_B]: "10000000" }, + decimals: 6, + name: "Test Token", + supply: "1000000000", + symbol: "TEST", + token: TOKEN_ADDRESS, + }, + ]); + }); + + it("GET /api/wallets/tokens - returns an empty page without addresses", async ({ server, repos }) => { + const response = await server.inject({ method: "GET", url: "/api/wallets/tokens" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data, []); + assert.undefined(repos.token.qb.calls.getRawMany); + }); + + it("GET /api/wallets/tokens - skips the holder lookup when no token matches", async ({ server, repos }) => { + repos.token.data.rawMany = []; + repos.token.data.rawOne = undefined; + + const response = await server.inject({ + method: "GET", + url: `/api/wallets/tokens?addresses=${ADDRESS_A}`, + }); + + assert.is(response.statusCode, 200); + + const body = JSON.parse(response.payload); + assert.is(body.meta.totalCount, 0); + assert.equal(body.data, []); + assert.undefined(repos.tokenHolder.qb.calls.getRawMany); + }); + + it("GET /api/wallets/{id}/tokens - lists the holdings of the wallet", async ({ server, repos }) => { + repos.wallet.data.one = makeWallet(); + repos.tokenHolder.data.rawMany = [ + { + address: ADDRESS_A, + balance: "12000000", + decimals: 6, + name: makeToken().name, + supply: makeToken().totalSupply, + symbol: makeToken().symbol, + tokenAddress: TOKEN_ADDRESS, + }, + ]; + repos.tokenHolder.data.rawOne = { cnt: "1" }; + + const response = await server.inject({ method: "GET", url: `/api/wallets/${ADDRESS_A}/tokens` }); + + assert.is(response.statusCode, 200); + + const body = JSON.parse(response.payload); + assert.is(body.meta.totalCount, 1); + assert.equal(body.data[0].tokenAddress, TOKEN_ADDRESS); + assert.equal(body.data[0].balance, "12000000"); + }); + + it("GET /api/wallets/{id}/tokens - filters by token address and falls back to the id", async ({ + server, + repos, + }) => { + // Wallet lookup misses -> the raw id is used as holder address. + await server.inject({ + method: "GET", + url: `/api/wallets/${ADDRESS_B}/tokens?tokenAddress=${TOKEN_ADDRESS}`, + }); + + assert.equal(repos.tokenHolder.qb.calls.where[0], ["th.address = :address", { address: ADDRESS_B }]); + assert.equal(repos.tokenHolder.qb.calls.andWhere[0], [ + "th.token_address = :tokenAddress", + { tokenAddress: TOKEN_ADDRESS }, + ]); + }); + + it("GET /api/wallets/activity - merges transactions and token actions", async ({ server, repos }) => { + repos.dataSource.data.rawMany = [ + { + action: undefined, + blockNumber: 90, + from: ADDRESS_A, + functionSig: Buffer.from("6dd7d8ea", "hex"), + index: undefined, + timestamp: 1_720_000_000, + to: ADDRESS_B, + tokenAddress: undefined, + transactionHash: TRANSACTION_HASH, + transactionIndex: 0, + value: "5", + }, + { + action: "Transfer", + blockNumber: 89, + from: ADDRESS_A, + functionSig: Buffer.from("a9059cbb", "hex"), + index: 1, + timestamp: 1_719_999_000, + to: ADDRESS_B, + tokenAddress: TOKEN_ADDRESS, + tokenDecimals: 6, + tokenName: "Test Token", + tokenSymbol: "TEST", + transactionHash: "b".repeat(64), + transactionIndex: 1, + value: "100", + }, + ]; + repos.dataSource.data.rawOne = { count: "2" }; + + const response = await server.inject({ + method: "GET", + url: `/api/wallets/activity?addresses=${ADDRESS_A}`, + }); + + assert.is(response.statusCode, 200); + + const body = JSON.parse(response.payload); + assert.is(body.meta.totalCount, 2); + + const [plain, tokenAction] = body.data; + assert.equal(plain.functionSig, "0x6dd7d8ea"); + assert.undefined(plain.token); + assert.equal(tokenAction.action, "Transfer"); + assert.equal(tokenAction.actionIndex, 1); + assert.equal(tokenAction.token, { + address: TOKEN_ADDRESS, + decimals: 6, + name: "Test Token", + symbol: "TEST", + }); + }); + + it("GET /api/wallets/activity - requires an address list", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/wallets/activity" }); + + assert.is(response.statusCode, 422); + }); + + it("activity - returns an empty page for an empty address list", async ({ app, repos }) => { + // The route schema requires addresses, so the defensive branch is only + // reachable through a direct call. + const { WalletsController } = await import("../controllers/wallets.js"); + const controller = app.resolve(WalletsController); + + const result = await controller.activity({ query: { addresses: [], limit: 100, page: 1 } } as any); + + assert.equal(result, { meta: { totalCountIsEstimate: false }, results: [], totalCount: 0 }); + assert.equal(repos.dataSource.queries, []); + }); +}); From cc0c0cc303cb10a70f83627b2bb02633487f0fb0 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:34:28 +0900 Subject: [PATCH 19/28] node coverage --- packages/api-http/source/routes/node.test.ts | 157 +++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 packages/api-http/source/routes/node.test.ts diff --git a/packages/api-http/source/routes/node.test.ts b/packages/api-http/source/routes/node.test.ts new file mode 100644 index 000000000..628f50e23 --- /dev/null +++ b/packages/api-http/source/routes/node.test.ts @@ -0,0 +1,157 @@ +import { describe } from "@mainsail/test-runner"; + +import { makeState } from "../../test/fixtures/entities"; +import { bootstrapServer, makeRepos, Repos } from "../../test/helpers/server"; +import { Server } from "../server"; +import { ServiceProvider } from "../service-provider"; + +const makeCryptoConfiguration = () => ({ + genesisBlock: { block: { timestamp: 1_700_000_000_000 } }, + network: { + client: { explorer: "https://explorer.example.org", symbol: "TÑ", token: "TEST" }, + nethash: "nethash", + pubKeyHash: 30, + wif: 186, + }, +}); + +describe<{ + server: Server; + serviceProvider: ServiceProvider; + repos: Repos; +}>("Node routes", ({ it, assert, beforeEach, afterEach }) => { + beforeEach(async (context) => { + context.repos = makeRepos(); + + const { server, serviceProvider } = await bootstrapServer(context.repos); + context.server = server; + context.serviceProvider = serviceProvider; + }); + + afterEach(async ({ serviceProvider }) => { + await serviceProvider.dispose(); + }); + + it("GET /api/node/status - reports synced when at most one block behind", async ({ server, repos }) => { + repos.state.data.one = makeState({ blockNumber: "95" }); + repos.peer.data.peerBlockNumberP90 = 96; + + const response = await server.inject({ method: "GET", url: "/api/node/status" }); + + assert.is(response.statusCode, 200); + + const { data } = JSON.parse(response.payload); + assert.equal(data.synced, true); + assert.equal(data.now, 95); + assert.equal(data.blocksCount, 1); + assert.number(data.timestamp); + }); + + it("GET /api/node/status - reports out of sync when trailing the network", async ({ server, repos }) => { + repos.state.data.one = makeState({ blockNumber: "90" }); + repos.peer.data.peerBlockNumberP90 = 95; + + const response = await server.inject({ method: "GET", url: "/api/node/status" }); + + const { data } = JSON.parse(response.payload); + assert.equal(data.synced, false); + assert.equal(data.blocksCount, 5); + }); + + it("GET /api/node/syncing - mirrors the status as a syncing flag", async ({ server, repos }) => { + repos.state.data.one = makeState({ blockNumber: "90", id: 7 }); + repos.peer.data.peerBlockNumberP90 = 95; + + const response = await server.inject({ method: "GET", url: "/api/node/syncing" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data, { + blockNumber: 90, + blocks: 5, + id: 7, + syncing: true, + }); + }); + + it("GET /api/node/fees - returns the aggregated fee statistics", async ({ server, repos }) => { + repos.configuration.data.one = { cryptoConfiguration: makeCryptoConfiguration() }; + repos.transaction.data.feeStatistics = { avg: "5", max: "10", min: "1", sum: "100" }; + + const response = await server.inject({ method: "GET", url: "/api/node/fees?days=7" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload), { + data: { evmCall: { avg: "5", max: "10", min: "1", sum: "100" } }, + meta: { days: 7 }, + }); + + // The statistics are anchored on the genesis timestamp. + assert.equal(repos.transaction.calls.getFeeStatistics[0], [1_700_000_000_000, 7]); + }); + + it("GET /api/node/fees - falls back to zeroes without any transactions", async ({ server, repos }) => { + repos.configuration.data.one = { cryptoConfiguration: makeCryptoConfiguration() }; + + const response = await server.inject({ method: "GET", url: "/api/node/fees" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data.evmCall, { avg: "0", max: "0", min: "0", sum: "0" }); + }); + + it("GET /api/node/fees - rejects an out of range days parameter", async ({ server }) => { + assert.is((await server.inject({ method: "GET", url: "/api/node/fees?days=0" })).statusCode, 422); + assert.is((await server.inject({ method: "GET", url: "/api/node/fees?days=31" })).statusCode, 422); + }); + + it("GET /api/node/configuration - exposes network constants and plugin ports", async ({ server, repos }) => { + repos.configuration.data.one = { + activeMilestones: { blockTime: 8000 }, + cryptoConfiguration: makeCryptoConfiguration(), + version: "1.2.3", + }; + repos.plugin.data.many = [ + // Nested server configuration wins over the top-level port. + { + configuration: { enabled: true, port: 4000, server: { enabled: true, port: 4102 } }, + name: "@mainsail/p2p", + }, + // No nested server -> top-level port. + { configuration: { enabled: true, port: 5432 }, name: "@mainsail/api-database" }, + // Disabled plugins are skipped. + { configuration: { enabled: false, port: 4004 }, name: "@mainsail/webhooks" }, + // Plugins outside the known set are ignored. + { configuration: { enabled: true, port: 9999 }, name: "@mainsail/unknown" }, + ]; + + const response = await server.inject({ method: "GET", url: "/api/node/configuration" }); + + assert.is(response.statusCode, 200); + + const { data } = JSON.parse(response.payload); + assert.equal(data.constants, { blockTime: 8000 }); + assert.equal(data.core, { version: "1.2.3" }); + assert.equal(data.explorer, "https://explorer.example.org"); + assert.equal(data.nethash, "nethash"); + assert.equal(data.symbol, "TÑ"); + assert.equal(data.token, "TEST"); + assert.equal(data.version, 30); + assert.equal(data.wif, 186); + assert.equal(data.ports, { "@mainsail/api-database": 5432, "@mainsail/p2p": 4102 }); + }); + + it("GET /api/node/configuration/crypto - returns the full crypto configuration", async ({ server, repos }) => { + repos.configuration.data.one = { cryptoConfiguration: makeCryptoConfiguration() }; + + const response = await server.inject({ method: "GET", url: "/api/node/configuration/crypto" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data.network.nethash, "nethash"); + }); + + it("GET /api/node/configuration/crypto - returns an empty object on a fresh database", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/node/configuration/crypto" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data, {}); + }); +}); From 756286374a0fe8ea25c5a2f354f7bc6baedb22ba Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:34:35 +0900 Subject: [PATCH 20/28] fixtures helper --- packages/api-http/test/fixtures/entities.ts | 132 ++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 packages/api-http/test/fixtures/entities.ts diff --git a/packages/api-http/test/fixtures/entities.ts b/packages/api-http/test/fixtures/entities.ts new file mode 100644 index 000000000..fc50ec27f --- /dev/null +++ b/packages/api-http/test/fixtures/entities.ts @@ -0,0 +1,132 @@ +export const ADDRESS_A = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +export const ADDRESS_B = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +export const TOKEN_ADDRESS = "0xcccccccccccccccccccccccccccccccccccccccc"; +export const PUBLIC_KEY = `03${"ab".repeat(32)}`; +export const BLOCK_HASH = "b".repeat(64); +export const TRANSACTION_HASH = "a".repeat(64); + +export const makeState = (overrides: Record = {}) => ({ + blockNumber: "100", + id: 1, + supply: "1000000", + ...overrides, +}); + +export const makeBlock = (overrides: Record = {}) => ({ + commitRound: 0, + fee: "100", + gasUsed: 21_000, + hash: BLOCK_HASH, + number: "90", + parentHash: "c".repeat(64), + payloadSize: 10, + proposer: ADDRESS_A, + reward: "200", + round: 0, + signature: "d".repeat(96), + stateRoot: "e".repeat(64), + timestamp: "1720000000000", + transactionsCount: 1, + transactionsRoot: "f".repeat(64), + validatorRound: 1, + validatorSet: "5", // 0b101 + version: 1, + ...overrides, +}); + +export const makeWallet = (overrides: Record = {}) => ({ + address: ADDRESS_A, + attributes: { username: "genesis" }, + balance: "1000", + nonce: "1", + publicKey: PUBLIC_KEY, + updated_at: "90", + ...overrides, +}); + +export const makeTransaction = (overrides: Record = {}) => ({ + blockHash: BLOCK_HASH, + blockNumber: "90", + cumulativeGasUsed: 21_000, + data: "0x", + decodedError: undefined, + deployedContractAddress: undefined, + from: ADDRESS_A, + gas: 21_000, + gasPrice: 5, + gasRefunded: 0, + gasUsed: 21_000, + hash: TRANSACTION_HASH, + legacySecondSignature: undefined, + logs: [], + nonce: "1", + output: "0x", + senderPublicKey: PUBLIC_KEY, + signature: "ff".repeat(65), + status: 1, + timestamp: "1720000000000", + to: ADDRESS_B, + transactionIndex: 0, + value: "0", + ...overrides, +}); + +export const makePeer = (overrides: Record = {}) => ({ + blockNumber: 95, + ip: "127.0.0.1", + latency: 10, + plugins: {}, + port: 4002, + ports: {}, + version: "1.0.0", + ...overrides, +}); + +export const makeApiNode = (overrides: Record = {}) => ({ + blockNumber: 95, + latency: 10, + url: "http://127.0.0.1:4003", + version: "1.0.0", + ...overrides, +}); + +export const makeToken = (overrides: Record = {}) => ({ + address: TOKEN_ADDRESS, + decimals: 6, + name: "Test Token", + symbol: "TEST", + totalSupply: "1000000000", + ...overrides, +}); + +export const makeValidatorRound = (overrides: Record = {}) => ({ + round: 1, + roundHeight: 1, + validators: [ADDRESS_A, ADDRESS_B], + votes: ["100", "200"], + ...overrides, +}); + +export const makeContract = (overrides: Record = {}) => ({ + activeImplementation: ADDRESS_B, + address: ADDRESS_A, + implementations: [{ abi: { abi: [] }, address: ADDRESS_B }], + name: "ConsensusV1", + proxy: "UUPS", + ...overrides, +}); + +export const makeLegacyColdWallet = (overrides: Record = {}) => ({ + address: "D6Z26L69gdk9qYmTv5uzk3uGepigtHY4ax", + attributes: {}, + balance: "500", + mergeInfoTransactionHash: undefined, + mergeInfoWalletAddress: undefined, + ...overrides, +}); + +export const makePage = (results: T[], totalCount = results.length) => ({ + meta: { totalCountIsEstimate: false }, + results, + totalCount, +}); From 05b1e8826189b0f22a1ce2df6df0586d45884ea5 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:47:39 +0900 Subject: [PATCH 21/28] fix ups --- packages/api-http/source/defaults.test.ts | 20 +++ packages/api-http/source/index.test.ts | 4 +- .../source/resources/resources.test.ts | 35 ++++- .../api-http/source/routes/api-nodes.test.ts | 13 ++ .../api-http/source/routes/blockchain.test.ts | 10 +- .../api-http/source/routes/blocks.test.ts | 29 ++++ .../api-http/source/routes/commits.test.ts | 3 + .../api-http/source/routes/contracts.test.ts | 124 ++++++++++++++++++ .../api-http/source/routes/legacy.test.ts | 21 +++ packages/api-http/source/routes/node.test.ts | 22 +++- packages/api-http/source/routes/peers.test.ts | 11 ++ .../api-http/source/routes/receipts.test.ts | 46 +++++++ .../api-http/source/routes/tokens.test.ts | 28 ++-- .../source/routes/transactions.test.ts | 18 ++- .../source/routes/validator-rounds.test.ts | 9 ++ .../api-http/source/routes/validators.test.ts | 39 +++++- packages/api-http/source/routes/votes.test.ts | 21 ++- .../api-http/source/routes/wallets.test.ts | 15 ++- .../api-http/source/service-provider.test.ts | 10 +- packages/api-http/test/fixtures/entities.ts | 5 +- packages/api-http/test/helpers/server.ts | 3 +- 21 files changed, 457 insertions(+), 29 deletions(-) create mode 100644 packages/api-http/source/routes/contracts.test.ts diff --git a/packages/api-http/source/defaults.test.ts b/packages/api-http/source/defaults.test.ts index b05771e47..e3f9664e7 100644 --- a/packages/api-http/source/defaults.test.ts +++ b/packages/api-http/source/defaults.test.ts @@ -106,4 +106,24 @@ describe<{ assert.equal(fresh.server.https.tls.cert, "/tmp/cert.pem"); assert.equal(fresh.server.https.tls.key, "/tmp/key.pem"); }); + + it("passes numeric environment overrides through as strings", async () => { + process.env.MAINSAIL_API_PORT = "8080"; + process.env.MAINSAIL_API_SSL_HOST = "10.0.0.1"; + process.env.MAINSAIL_API_SSL_PORT = "9443"; + process.env.MAINSAIL_API_RATE_LIMIT_USER_EXPIRES = "120"; + process.env.MAINSAIL_API_RATE_LIMIT_USER_LIMIT = "50"; + process.env.MAINSAIL_API_TOKENS_DEFAULT_MINIMUM_BALANCE = "0.5"; + + const fresh = await importFresh(); + + // Environment.get performs no numeric coercion: raw strings are passed + // through and only coerced later by the Joi configuration schema. + assert.is(fresh.server.http.port, "8080"); + assert.is(fresh.server.https.host, "10.0.0.1"); + assert.is(fresh.server.https.port, "9443"); + assert.is(fresh.plugins.rateLimit.duration, "120"); + assert.is(fresh.plugins.rateLimit.points, "50"); + assert.is(fresh.tokens.defaultMinimumBalance, "0.5"); + }); }); diff --git a/packages/api-http/source/index.test.ts b/packages/api-http/source/index.test.ts index 8c2727b44..726d23ee0 100644 --- a/packages/api-http/source/index.test.ts +++ b/packages/api-http/source/index.test.ts @@ -1,8 +1,10 @@ import * as index from "./index"; import { describe } from "@mainsail/test-runner"; +import { ServiceProvider } from "./service-provider"; + describe("Index", ({ assert, it }) => { it("should export ServiceProvider", () => { - assert.defined(index.ServiceProvider); + assert.is(index.ServiceProvider, ServiceProvider); }); }); diff --git a/packages/api-http/source/resources/resources.test.ts b/packages/api-http/source/resources/resources.test.ts index 4c2f5706b..a04de79f2 100644 --- a/packages/api-http/source/resources/resources.test.ts +++ b/packages/api-http/source/resources/resources.test.ts @@ -52,9 +52,16 @@ describe("Resources", ({ it, assert }) => { assert.is(resource.raw(peer as any), peer); - const transformed = resource.transform(peer as any) as any; - assert.equal(transformed.ip, "127.0.0.1"); - assert.false("secret" in transformed); + // The exact projection: every public field, nothing else. + assert.equal(resource.transform(peer as any), { + blockNumber: 95, + ip: "127.0.0.1", + latency: 10, + plugins: {}, + port: 4002, + ports: {}, + version: "1.0.0", + }); }); it("ReceiptResource maps a transaction to its receipt for raw and transform", () => { @@ -99,6 +106,10 @@ describe("Resources", ({ it, assert }) => { // A zero state height yields zero confirmations instead of a negative count. const fresh = resource.transform({ ...enriched, state: makeState({ blockNumber: "0" }) } as any) as any; assert.equal(fresh.confirmations, 0); + + // A generator without attributes has no username. + const bare = resource.transform({ ...enriched, generator: { address: enriched.proposer } } as any) as any; + assert.undefined(bare.username); }); it("TransactionResource strips the state from raw", () => { @@ -110,6 +121,24 @@ describe("Resources", ({ it, assert }) => { assert.equal(raw.hash, enriched.hash); }); + it("TransactionResource normalizes empty data and passes other payloads through", async () => { + const resource = new TransactionResource(); + + const empty = (await resource.transform({ + ...makeTransaction(), // data: "0x" + fullReceipt: false, + state: makeState(), + } as any)) as any; + assert.is(empty.data, ""); + + const payload = (await resource.transform({ + ...makeTransaction({ data: "0xdeadbeef" }), + fullReceipt: false, + state: makeState(), + } as any)) as any; + assert.is(payload.data, "0xdeadbeef"); + }); + it("TransactionResource omits confirmations and timestamp for pending transactions", async () => { const resource = new TransactionResource(); const pending = { diff --git a/packages/api-http/source/routes/api-nodes.test.ts b/packages/api-http/source/routes/api-nodes.test.ts index 1b7a36faf..b472d15cd 100644 --- a/packages/api-http/source/routes/api-nodes.test.ts +++ b/packages/api-http/source/routes/api-nodes.test.ts @@ -44,4 +44,17 @@ describe<{ assert.is(response.statusCode, 200); assert.equal(JSON.parse(response.payload).data, []); }); + + it("GET /api/api-nodes - forwards the ip filter as criteria", async ({ server, repos }) => { + await server.inject({ method: "GET", url: "/api/api-nodes?ip=127.0.0.1" }); + + const [criteria] = repos.apiNode.calls.findManyByCriteria[0]; + assert.equal(criteria.ip, "127.0.0.1"); + }); + + it("GET /api/api-nodes - rejects a malformed ip filter", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/api-nodes?ip=not-an-ip" }); + + assert.is(response.statusCode, 422); + }); }); diff --git a/packages/api-http/source/routes/blockchain.test.ts b/packages/api-http/source/routes/blockchain.test.ts index ff657dbf5..8e5c3f947 100644 --- a/packages/api-http/source/routes/blockchain.test.ts +++ b/packages/api-http/source/routes/blockchain.test.ts @@ -24,7 +24,8 @@ describe<{ it("GET /api/blockchain - returns the latest block and the supply", async ({ server, repos }) => { repos.block.data.one = makeBlock({ hash: "aa".repeat(32), number: "90" }); - repos.block.data.latestHeight = 90; + // Distinct from the block number so the header provably comes from getLatestHeight(). + repos.block.data.latestHeight = 123; repos.state.data.one = makeState({ supply: "12345" }); const response = await server.inject({ method: "GET", url: "/api/blockchain" }); @@ -37,7 +38,7 @@ describe<{ }, }); // Every non-503 response carries the current height. - assert.is(response.headers["x-block-number"], 90); + assert.is(response.headers["x-block-number"], 123); }); it("GET /api/blockchain - returns a null block and zero supply on an empty database", async ({ server }) => { @@ -47,6 +48,8 @@ describe<{ assert.equal(JSON.parse(response.payload), { data: { block: null, supply: "0" }, }); + // getLatestHeight() is undefined on an empty database -> the header is omitted. + assert.undefined(response.headers["x-block-number"]); }); it("GET /api/blockchain - responds 503 while the database is in maintenance", async ({ server, repos }) => { @@ -56,6 +59,9 @@ describe<{ assert.is(response.statusCode, 503); assert.equal(JSON.parse(response.payload).reason, "Database not ready"); + assert.is(response.headers["retry-after"], "10"); + // The height lookup is skipped for 503 responses. + assert.undefined(response.headers["x-block-number"]); }); it("GET /api/blockchain - responds 503 when the maintenance check fails", async ({ server, repos }) => { diff --git a/packages/api-http/source/routes/blocks.test.ts b/packages/api-http/source/routes/blocks.test.ts index 44f552558..e054079b9 100644 --- a/packages/api-http/source/routes/blocks.test.ts +++ b/packages/api-http/source/routes/blocks.test.ts @@ -91,6 +91,16 @@ describe<{ assert.equal(data.confirmations, 0); assert.equal(data.publicKey, ""); assert.undefined(data.username); + + // first() selects the genesis block by number, without ordering. + assert.equal(repos.block.qb.calls.where, [["number = :number", { number: 0 }]]); + assert.undefined(repos.block.qb.calls.orderBy); + }); + + it("GET /api/blocks/first - responds 404 on an empty chain", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/blocks/first" }); + + assert.is(response.statusCode, 404); }); it("GET /api/blocks/last - enriches the block with its generator wallet", async ({ server, repos }) => { @@ -102,6 +112,11 @@ describe<{ assert.is(response.statusCode, 200); assert.equal(JSON.parse(response.payload).data.username, "proposer"); + + // last() takes the single highest block instead of filtering. + assert.equal(repos.block.qb.calls.orderBy, [["number", "DESC"]]); + assert.equal(repos.block.qb.calls.limit, [[1]]); + assert.undefined(repos.block.qb.calls.where); }); it("GET /api/blocks/last - responds 404 on an empty chain", async ({ server }) => { @@ -130,6 +145,20 @@ describe<{ assert.equal(repos.block.calls.findOneByCriteria[0][0], { hash: BLOCK_HASH }); }); + it("GET /api/blocks/{id} - looks up all-digit ids beyond the safe integer range as block hashes", async ({ + server, + repos, + }) => { + repos.block.data.one = makeBlock(); + repos.state.data.one = makeState(); + + const digitHash = "1".repeat(64); + const response = await server.inject({ method: "GET", url: `/api/blocks/${digitHash}` }); + + assert.is(response.statusCode, 200); + assert.equal(repos.block.calls.findOneByCriteria[0][0], { hash: digitHash }); + }); + it("GET /api/blocks/{id} - rejects a malformed id", async ({ server }) => { const response = await server.inject({ method: "GET", url: "/api/blocks/not-a-block" }); diff --git a/packages/api-http/source/routes/commits.test.ts b/packages/api-http/source/routes/commits.test.ts index 6195813c7..40e1c633b 100644 --- a/packages/api-http/source/routes/commits.test.ts +++ b/packages/api-http/source/routes/commits.test.ts @@ -42,6 +42,9 @@ describe<{ validators: [ADDRESS_A, ADDRESS_C], }, }); + + // The round lookup uses the block's validatorRound (1), not its consensus round (0). + assert.equal(repos.validatorRound.qb.calls.where, [["round = :validatorRound", { validatorRound: 1 }]]); }); it("GET /api/commits/{id} - returns no validators without a matching round", async ({ server, repos }) => { diff --git a/packages/api-http/source/routes/contracts.test.ts b/packages/api-http/source/routes/contracts.test.ts new file mode 100644 index 000000000..c01bad7ff --- /dev/null +++ b/packages/api-http/source/routes/contracts.test.ts @@ -0,0 +1,124 @@ +import { describe } from "@mainsail/test-runner"; + +import { ADDRESS_A, ADDRESS_B, makeContract } from "../../test/fixtures/entities"; +import { bootstrapServer, makeRepos, Repos } from "../../test/helpers/server"; +import { Server } from "../server"; +import { ServiceProvider } from "../service-provider"; + +describe<{ + server: Server; + serviceProvider: ServiceProvider; + repos: Repos; +}>("Contracts routes", ({ it, assert, beforeEach, afterEach }) => { + beforeEach(async (context) => { + context.repos = makeRepos(); + + const { server, serviceProvider } = await bootstrapServer(context.repos); + context.server = server; + context.serviceProvider = serviceProvider; + }); + + afterEach(async ({ serviceProvider }) => { + await serviceProvider.dispose(); + }); + + it("GET /api/contracts - groups contracts by name", async ({ server, repos }) => { + repos.contract.data.many = [ + makeContract(), + makeContract({ + activeImplementation: ADDRESS_A, + address: ADDRESS_B, + implementations: [{ abi: { abi: [] }, address: ADDRESS_A }], + name: "UsernamesV1", + proxy: "UUPS", + }), + ]; + + const response = await server.inject({ method: "GET", url: "/api/contracts" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload), { + data: { + ConsensusV1: { + activeImplementation: ADDRESS_B, + address: ADDRESS_A, + implementations: [ADDRESS_B], + proxy: "UUPS", + }, + UsernamesV1: { + activeImplementation: ADDRESS_A, + address: ADDRESS_B, + implementations: [ADDRESS_A], + proxy: "UUPS", + }, + }, + }); + + // Contracts are listed in a stable name/address order. + assert.equal(repos.contract.qb.calls.orderBy, [["name"]]); + assert.equal(repos.contract.qb.calls.addOrderBy, [["address"]]); + }); + + it("GET /api/contracts - returns an empty object without contracts", async ({ server }) => { + const response = await server.inject({ method: "GET", url: "/api/contracts" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data, {}); + }); + + it("GET /api/contracts/{name}/{implementation}/abi - returns the abi", async ({ server, repos }) => { + repos.contract.data.one = makeContract(); + + const response = await server.inject({ + method: "GET", + url: `/api/contracts/ConsensusV1/${ADDRESS_B}/abi`, + }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload), { data: { abi: { abi: [] } } }); + }); + + it("GET /api/contracts/{name}/{implementation}/abi - responds 404 for an unknown contract", async ({ server }) => { + const response = await server.inject({ + method: "GET", + url: `/api/contracts/ConsensusV1/${ADDRESS_B}/abi`, + }); + + assert.is(response.statusCode, 404); + assert.equal(JSON.parse(response.payload).message, "contract not found"); + }); + + it("GET /api/contracts/{name}/{implementation}/abi - responds 404 for an unknown implementation", async ({ + server, + repos, + }) => { + repos.contract.data.one = makeContract(); + + const response = await server.inject({ + method: "GET", + url: `/api/contracts/ConsensusV1/${ADDRESS_A}/abi`, + }); + + assert.is(response.statusCode, 404); + assert.equal(JSON.parse(response.payload).message, "abi not found"); + }); + + it("GET /api/contracts/{name}/{implementation}/abi - rejects a malformed implementation address", async ({ + server, + }) => { + const response = await server.inject({ method: "GET", url: "/api/contracts/ConsensusV1/nope/abi" }); + + assert.is(response.statusCode, 422); + }); + + it("GET /api/contracts/{name}/{implementation}/abi - rejects a name outside the length bounds", async ({ + server, + }) => { + assert.is((await server.inject({ method: "GET", url: `/api/contracts/abc/${ADDRESS_B}/abi` })).statusCode, 422); + assert.is( + (await server.inject({ method: "GET", url: `/api/contracts/${"a".repeat(16)}/${ADDRESS_B}/abi` })) + .statusCode, + 422, + ); + }); +}); diff --git a/packages/api-http/source/routes/legacy.test.ts b/packages/api-http/source/routes/legacy.test.ts index e1f65dac7..46c4b0d48 100644 --- a/packages/api-http/source/routes/legacy.test.ts +++ b/packages/api-http/source/routes/legacy.test.ts @@ -37,6 +37,17 @@ describe<{ assert.equal(body.data[0].balance, "500"); }); + it("GET /api/legacy/cold-wallets - orders by address and windows by the query pagination", async ({ + server, + repos, + }) => { + await server.inject({ method: "GET", url: "/api/legacy/cold-wallets?page=2&limit=10" }); + + assert.equal(repos.legacyColdWallet.qb.calls.addOrderBy, [["address", "ASC"]]); + assert.equal(repos.legacyColdWallet.qb.calls.offset, [[10]]); + assert.equal(repos.legacyColdWallet.qb.calls.limit, [[10]]); + }); + it("GET /api/legacy/cold-wallets/{address} - returns the cold wallet", async ({ server, repos }) => { repos.legacyColdWallet.data.one = makeLegacyColdWallet(); @@ -67,4 +78,14 @@ describe<{ assert.is(response.statusCode, 422); }); + + it("GET /api/legacy/cold-wallets/{address} - rejects a too short legacy address", async ({ server }) => { + // Valid base58 characters, but one character short of the minimum length. + const response = await server.inject({ + method: "GET", + url: `/api/legacy/cold-wallets/${"D".repeat(32)}`, + }); + + assert.is(response.statusCode, 422); + }); }); diff --git a/packages/api-http/source/routes/node.test.ts b/packages/api-http/source/routes/node.test.ts index 628f50e23..6c195938a 100644 --- a/packages/api-http/source/routes/node.test.ts +++ b/packages/api-http/source/routes/node.test.ts @@ -73,6 +73,21 @@ describe<{ }); }); + it("GET /api/node/syncing - falls back to id 0 on a fresh database", async ({ server, repos }) => { + // No state row: getState() falls back to { blockNumber: "0", supply: "0" } without an id. + repos.peer.data.peerBlockNumberP90 = 5; + + const response = await server.inject({ method: "GET", url: "/api/node/syncing" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data, { + blockNumber: 0, + blocks: 5, + id: 0, + syncing: true, + }); + }); + it("GET /api/node/fees - returns the aggregated fee statistics", async ({ server, repos }) => { repos.configuration.data.one = { cryptoConfiguration: makeCryptoConfiguration() }; repos.transaction.data.feeStatistics = { avg: "5", max: "10", min: "1", sum: "100" }; @@ -117,8 +132,11 @@ describe<{ }, // No nested server -> top-level port. { configuration: { enabled: true, port: 5432 }, name: "@mainsail/api-database" }, - // Disabled plugins are skipped. - { configuration: { enabled: false, port: 4004 }, name: "@mainsail/webhooks" }, + // Nested server present but disabled -> top-level port. + { + configuration: { enabled: true, port: 4004, server: { enabled: false, port: 9999 } }, + name: "@mainsail/webhooks", + }, // Plugins outside the known set are ignored. { configuration: { enabled: true, port: 9999 }, name: "@mainsail/unknown" }, ]; diff --git a/packages/api-http/source/routes/peers.test.ts b/packages/api-http/source/routes/peers.test.ts index 5c3751db2..79490929d 100644 --- a/packages/api-http/source/routes/peers.test.ts +++ b/packages/api-http/source/routes/peers.test.ts @@ -40,6 +40,17 @@ describe<{ ports: { "api-http": 4003 }, version: "1.0.0", }); + + // The peer listing never estimates the total count. + const [, , , options] = repos.peer.calls.findManyByCriteria[0]; + assert.equal(options, { estimateTotalCount: false }); + }); + + it("GET /api/peers - forwards the version filter as criteria", async ({ server, repos }) => { + await server.inject({ method: "GET", url: "/api/peers?version=1.0.0" }); + + const [criteria] = repos.peer.calls.findManyByCriteria[0]; + assert.equal(criteria.version, "1.0.0"); }); it("GET /api/peers - rejects a malformed ip filter", async ({ server }) => { diff --git a/packages/api-http/source/routes/receipts.test.ts b/packages/api-http/source/routes/receipts.test.ts index 31a1ce583..b177569ae 100644 --- a/packages/api-http/source/routes/receipts.test.ts +++ b/packages/api-http/source/routes/receipts.test.ts @@ -12,6 +12,15 @@ import { bootstrapServer, makeRepos, Repos } from "../../test/helpers/server"; import { Server } from "../server"; import { ServiceProvider } from "../service-provider"; +// A transaction row as production returns it for receipts: options.selection limits +// the loaded columns, so anything outside #getReceiptColumns is absent from the entity. +const makeSelectedReceiptRow = (overrides: Record = {}) => { + const { decodedError, deployedContractAddress, gasRefunded, gasUsed, hash, logs, output, status } = + makeTransaction(overrides); + + return { decodedError, deployedContractAddress, gasRefunded, gasUsed, hash, logs, output, status }; +}; + describe<{ server: Server; serviceProvider: ServiceProvider; @@ -51,6 +60,32 @@ describe<{ }); }); + it("GET /api/receipts - forwards the hardcoded ordering and the receipt column selection", async ({ + server, + repos, + }) => { + // Note: the route schema does not even accept an orderBy parameter. + await server.inject({ method: "GET", url: "/api/receipts" }); + + // The controller hardcodes the listing order. + const [, , sorting, , options] = repos.transaction.calls.findManyByCriteria[0]; + assert.equal(sorting, [ + { direction: "desc", property: "timestamp" }, + { direction: "desc", property: "transactionIndex" }, + ]); + // fullReceipt defaults to true on this route, so output and logs are selected. + assert.equal(options.selection, [ + "Transaction.hash", + "Transaction.status", + "Transaction.gasUsed", + "Transaction.gasRefunded", + "Transaction.deployedContractAddress", + "Transaction.decodedError", + "Transaction.output", + "Transaction.logs", + ]); + }); + it("GET /api/receipts - scopes the criteria to hash and contract", async ({ server, repos }) => { await server.inject({ method: "GET", @@ -77,6 +112,17 @@ describe<{ assert.undefined(criteria.from); }); + it("GET /api/receipts - treats a 0x-prefixed sender that is not 42 characters long as a public key", async ({ + server, + repos, + }) => { + await server.inject({ method: "GET", url: "/api/receipts?from=0xabc" }); + + const [, criteria] = repos.transaction.calls.findManyByCriteria[0]; + assert.equal(criteria.senderPublicKey, "0xabc"); + assert.undefined(criteria.from); + }); + it("GET /api/receipts/{transactionHash} - selects the full receipt columns by default", async ({ server, repos, diff --git a/packages/api-http/source/routes/tokens.test.ts b/packages/api-http/source/routes/tokens.test.ts index 9ca391638..ca8ad5669 100644 --- a/packages/api-http/source/routes/tokens.test.ts +++ b/packages/api-http/source/routes/tokens.test.ts @@ -56,8 +56,12 @@ describe<{ it("GET /api/tokens - skips the whitelist on request", async ({ server, repos }) => { repos.token.data.manyAndCount = [[makeToken()], 1]; - await server.inject({ method: "GET", url: "/api/tokens?ignoreWhitelist=true" }); + const response = await server.inject({ method: "GET", url: "/api/tokens?ignoreWhitelist=true" }); + // The listing still runs and succeeds - just without any whitelist join. + assert.is(response.statusCode, 200); + assert.is(JSON.parse(response.payload).meta.totalCount, 1); + assert.defined(repos.token.qb.calls.getManyAndCount); assert.undefined(repos.token.qb.calls.innerJoin); assert.undefined(repos.token.qb.calls.leftJoin); }); @@ -65,11 +69,18 @@ describe<{ it("GET /api/tokens - extends the whitelist with custom addresses", async ({ server, repos }) => { repos.token.data.manyAndCount = [[makeToken()], 1]; - await server.inject({ method: "GET", url: `/api/tokens?whitelist=${ADDRESS_A}` }); + // Comma-separated values arrive as an array through the comma-array-query plugin. + await server.inject({ method: "GET", url: `/api/tokens?whitelist=${ADDRESS_A},${ADDRESS_B}` }); - // A custom whitelist switches to a left join with an OR condition. + // A custom whitelist switches to a left join ... assert.defined(repos.token.qb.calls.leftJoin); assert.undefined(repos.token.qb.calls.innerJoin); + + // ... and filters on "whitelisted OR explicitly allowed". + assert.equal(replayBrackets(repos.token.qb.calls.andWhere[0][0]), [ + ["tw.address IS NOT NULL"], + ["tok.address IN (:...customWhitelist)", { customWhitelist: [ADDRESS_A, ADDRESS_B] }], + ]); }); const replayBrackets = (bracket: any): any[][] => { @@ -95,23 +106,24 @@ describe<{ repos.token.data.manyAndCount = [[makeToken()], 1]; await server.inject({ method: "GET", url: "/api/tokens?name=te" }); - await server.inject({ method: "GET", url: "/api/tokens?name=test" }); + // Three characters is the boundary: already too long for the prefix index. + await server.inject({ method: "GET", url: "/api/tokens?name=tes" }); - // Short queries use the prefix index ... + // Queries of up to two characters use the prefix index ... assert.equal(replayBrackets(repos.token.qb.calls.andWhere[0][0]), [ ["lower(tok.symbol) LIKE :prefix", { prefix: "te%" }], ["lower(tok.name) LIKE :prefix", { prefix: "te%" }], ]); // ... longer queries the trigram index. assert.equal(replayBrackets(repos.token.qb.calls.andWhere[1][0]), [ - ["tok.symbol ILIKE :like", { like: "%test%" }], - ["tok.name ILIKE :like", { like: "%test%" }], + ["tok.symbol ILIKE :like", { like: "%tes%" }], + ["tok.name ILIKE :like", { like: "%tes%" }], ]); // Both name searches rank prefix matches first. assert.length(repos.token.qb.calls.addSelect, 2); assert.equal(repos.token.qb.calls.setParameter[0], ["orderByPrefix", "te%"]); - assert.equal(repos.token.qb.calls.setParameter[1], ["orderByPrefix", "test%"]); + assert.equal(repos.token.qb.calls.setParameter[1], ["orderByPrefix", "tes%"]); }); it("GET /api/tokens/transfers - excludes custom blacklisted tokens", async ({ server, repos }) => { diff --git a/packages/api-http/source/routes/transactions.test.ts b/packages/api-http/source/routes/transactions.test.ts index f4b329927..1522ee3a0 100644 --- a/packages/api-http/source/routes/transactions.test.ts +++ b/packages/api-http/source/routes/transactions.test.ts @@ -51,7 +51,7 @@ describe<{ assert.equal(transaction.hash, TRANSACTION_HASH); assert.equal(transaction.confirmations, 11); assert.equal(transaction.receipt, { - cumulativeGasUsed: 21_000, + cumulativeGasUsed: 42_000, gasRefunded: 0, gasUsed: 21_000, status: 1, @@ -102,6 +102,22 @@ describe<{ assert.equal(transaction.receipt.output, "0xdeadbeef"); }); + it("GET /api/transactions - forwards criteria, sorting, pagination and options to the repository", async ({ + server, + repos, + }) => { + await server.inject({ + method: "GET", + url: `/api/transactions?from=${makeTransaction().from}&limit=25&orderBy=nonce:asc`, + }); + + const [, criteria, sorting, pagination, options] = repos.transaction.calls.findManyByCriteria[0]; + assert.equal(criteria.from, makeTransaction().from); + assert.equal(sorting, [{ direction: "asc", property: "nonce" }]); + assert.equal(pagination, { limit: 25, offset: 0 }); + assert.equal(options, { estimateTotalCount: true, fullReceipt: false }); + }); + it("GET /api/transactions - rejects an unknown orderBy property", async ({ server }) => { const response = await server.inject({ method: "GET", url: "/api/transactions?orderBy=nope:asc" }); diff --git a/packages/api-http/source/routes/validator-rounds.test.ts b/packages/api-http/source/routes/validator-rounds.test.ts index 5741f6a21..aebe034f5 100644 --- a/packages/api-http/source/routes/validator-rounds.test.ts +++ b/packages/api-http/source/routes/validator-rounds.test.ts @@ -32,6 +32,11 @@ describe<{ const body = JSON.parse(response.payload); assert.is(body.meta.totalCount, 5); assert.equal(body.data[0].round, 1); + + // Latest rounds first, windowed by the query pagination defaults. + assert.equal(repos.validatorRound.qb.calls.addOrderBy, [["round", "DESC"]]); + assert.equal(repos.validatorRound.qb.calls.offset, [[0]]); + assert.equal(repos.validatorRound.qb.calls.limit, [[100]]); }); it("GET /api/rounds/{round} - returns the round", async ({ server, repos }) => { @@ -41,6 +46,7 @@ describe<{ assert.is(response.statusCode, 200); assert.equal(JSON.parse(response.payload).data.validators, [ADDRESS_A, ADDRESS_B]); + assert.equal(repos.validatorRound.qb.calls.where, [["round = :round", { round: 1 }]]); }); it("GET /api/rounds/{round} - responds 404 for an unknown round", async ({ server }) => { @@ -69,6 +75,9 @@ describe<{ // A missing vote entry falls back to zero. { address: ADDRESS_B, votes: "0" }, ]); + + // This route reads the round from the `id` path parameter. + assert.equal(repos.validatorRound.qb.calls.where, [["round = :round", { round: 1 }]]); }); it("GET /api/rounds/{id}/validators - responds 404 for an unknown round", async ({ server }) => { diff --git a/packages/api-http/source/routes/validators.test.ts b/packages/api-http/source/routes/validators.test.ts index 4fc108cd6..1935c49e0 100644 --- a/packages/api-http/source/routes/validators.test.ts +++ b/packages/api-http/source/routes/validators.test.ts @@ -16,6 +16,23 @@ describe<{ serviceProvider: ServiceProvider; repos: Repos; }>("Validators routes", ({ it, assert, beforeEach, afterEach }) => { + const replayBrackets = (bracket: any): any[][] => { + const replayed: any[][] = []; + const recorder: any = { + orWhere: (...args: any[]) => { + replayed.push(args); + return recorder; + }, + where: (...args: any[]) => { + replayed.push(args); + return recorder; + }, + }; + bracket.whereFactory(recorder); + + return replayed; + }; + beforeEach(async (context) => { context.repos = makeRepos(); @@ -54,6 +71,21 @@ describe<{ assert.is(response.statusCode, 200); assert.equal(JSON.parse(response.payload).data.address, ADDRESS_A); + + // Unlike the wallets lookup, only wallets with a registered validator public key match. + assert.equal(repos.wallet.qb.calls.where, [ + ["attributes ? :validatorPublicKey", { validatorPublicKey: "validatorPublicKey" }], + ]); + assert.equal(repos.wallet.qb.calls.andWhere[0], [ + "attributes->>:validatorPublicKey <> ''", + { validatorPublicKey: "validatorPublicKey" }, + ]); + // The id is matched against address, public key and username alternatives. + assert.equal(replayBrackets(repos.wallet.qb.calls.andWhere[1][0]), [ + ["address = :address", { address: ADDRESS_A }], + ["public_key = :publicKey", { publicKey: ADDRESS_A }], + ["attributes @> :username", { username: { username: ADDRESS_A } }], + ]); }); it("GET /api/validators/{id} - responds 404 for an unknown validator", async ({ server }) => { @@ -67,7 +99,9 @@ describe<{ repos.wallet.data.one = makeValidator(); repos.wallet.data.page = makePage([makeWallet({ address: ADDRESS_B, attributes: { vote: ADDRESS_A } })]); - const response = await server.inject({ method: "GET", url: `/api/validators/${ADDRESS_A}/voters` }); + // Request by username so the vote criteria provably comes from the resolved + // validator's address rather than from the raw path parameter. + const response = await server.inject({ method: "GET", url: "/api/validators/validator/voters" }); assert.is(response.statusCode, 200); assert.equal(JSON.parse(response.payload).data[0].address, ADDRESS_B); @@ -91,7 +125,8 @@ describe<{ repos.block.data.page = makePage([makeBlock()]); repos.state.data.one = makeState({ blockNumber: "100" }); - const response = await server.inject({ method: "GET", url: `/api/validators/${ADDRESS_A}/blocks` }); + // Request by username so the proposer criteria provably comes from the wallet's address. + const response = await server.inject({ method: "GET", url: "/api/validators/validator/blocks" }); assert.is(response.statusCode, 200); diff --git a/packages/api-http/source/routes/votes.test.ts b/packages/api-http/source/routes/votes.test.ts index f2f6478ca..721d644ae 100644 --- a/packages/api-http/source/routes/votes.test.ts +++ b/packages/api-http/source/routes/votes.test.ts @@ -41,6 +41,19 @@ describe<{ assert.equal(criteria.data, VOTE_FUNCTION_SIG); }); + it("GET /api/votes - a caller-supplied data filter cannot override the vote signature", async ({ + server, + repos, + }) => { + // `data` is an accepted query parameter; the controller must still win the spread. + const response = await server.inject({ method: "GET", url: "/api/votes?data=0xdeadbeef" }); + + assert.is(response.statusCode, 200); + + const [, criteria] = repos.transaction.calls.findManyByCriteria[0]; + assert.equal(criteria.data, VOTE_FUNCTION_SIG); + }); + it("GET /api/votes/{hash} - returns the vote transaction", async ({ server, repos }) => { repos.transaction.data.one = makeTransaction({ data: VOTE_FUNCTION_SIG }); repos.state.data.one = makeState(); @@ -50,9 +63,11 @@ describe<{ assert.is(response.statusCode, 200); assert.equal(JSON.parse(response.payload).data.hash, TRANSACTION_HASH); - // The lookup filters on the vote function signature prefix. - const [, parameters] = repos.transaction.qb.calls.andWhere[0]; - assert.equal(parameters, { data: String.raw`\x6dd7d8ea` }); + // The lookup matches the hash and filters on the vote function signature prefix. + assert.equal(repos.transaction.qb.calls.where, [["hash = :hash", { hash: TRANSACTION_HASH }]]); + assert.equal(repos.transaction.qb.calls.andWhere, [ + ["SUBSTRING(data FROM 1 FOR 4) = :data", { data: String.raw`\x6dd7d8ea` }], + ]); }); it("GET /api/votes/{hash} - responds 404 when the hash is not a vote", async ({ server }) => { diff --git a/packages/api-http/source/routes/wallets.test.ts b/packages/api-http/source/routes/wallets.test.ts index 82b5dc2d2..e10d0fc82 100644 --- a/packages/api-http/source/routes/wallets.test.ts +++ b/packages/api-http/source/routes/wallets.test.ts @@ -83,7 +83,8 @@ describe<{ repos.transaction.data.page = makePage([makeTransaction()]); repos.state.data.one = makeState(); - const response = await server.inject({ method: "GET", url: `/api/wallets/${ADDRESS_A}/transactions` }); + // Request by username so the criteria provably comes from the resolved wallet's address. + const response = await server.inject({ method: "GET", url: "/api/wallets/genesis/transactions" }); assert.is(response.statusCode, 200); assert.equal(JSON.parse(response.payload).data[0].hash, TRANSACTION_HASH); @@ -135,7 +136,8 @@ describe<{ repos.transaction.data.page = makePage([makeTransaction()]); repos.state.data.one = makeState(); - await server.inject({ method: "GET", url: `/api/wallets/${ADDRESS_A}/transactions/received` }); + // Request by username so the criteria provably comes from the resolved wallet's address. + await server.inject({ method: "GET", url: "/api/wallets/genesis/transactions/received" }); const [, criteria] = repos.transaction.calls.findManyByCriteria[0]; assert.equal(criteria.to, ADDRESS_A); @@ -265,6 +267,11 @@ describe<{ "th.token_address = :tokenAddress", { tokenAddress: TOKEN_ADDRESS }, ]); + // Without a minBalance query parameter, the configured default applies. + assert.equal(repos.tokenHolder.qb.calls.andWhere[1], [ + "th.balance / POW(10, tok.decimals) >= :minBalance", + { minBalance: 0.01 }, + ]); }); it("GET /api/wallets/activity - merges transactions and token actions", async ({ server, repos }) => { @@ -339,6 +346,8 @@ describe<{ const result = await controller.activity({ query: { addresses: [], limit: 100, page: 1 } } as any); assert.equal(result, { meta: { totalCountIsEstimate: false }, results: [], totalCount: 0 }); - assert.equal(repos.dataSource.queries, []); + // The early return skips the query building entirely: the transaction repository + // query builder (the first thing the full path touches) is never used. + assert.undefined(repos.transaction.qb.calls.leftJoin); }); }); diff --git a/packages/api-http/source/service-provider.test.ts b/packages/api-http/source/service-provider.test.ts index 1809404c3..0d804edc5 100644 --- a/packages/api-http/source/service-provider.test.ts +++ b/packages/api-http/source/service-provider.test.ts @@ -27,7 +27,7 @@ describe<{ assert.is(server.prettyName, "Public API (HTTP)"); await serviceProvider.boot(); - assert.string(server.uri); + assert.startsWith(server.uri, "http://"); } finally { await serviceProvider.dispose(); } @@ -82,6 +82,9 @@ describe<{ try { await serviceProvider.boot(); + // The http server is skipped when only https is enabled. + assert.false(app.isBound(ApiHttpIdentifiers.HTTP)); + const server = app.get(ApiHttpIdentifiers.HTTPS); assert.is(server.prettyName, "Public API (HTTPS)"); assert.startsWith(server.uri, "https://"); @@ -137,6 +140,11 @@ describe<{ (config: any) => (config.plugins.rateLimit.points = "nope"), (config: any) => (config.tokens.defaultMinimumBalance = -1), (config: any) => (config.server.http.port = 0), + // Enabling https requires tls cert and key paths. + (config: any) => { + config.server.https.enabled = true; + config.server.https.tls = {}; + }, ]) { const config = makeValidatableConfiguration(); mutate(config); diff --git a/packages/api-http/test/fixtures/entities.ts b/packages/api-http/test/fixtures/entities.ts index fc50ec27f..1eb69708d 100644 --- a/packages/api-http/test/fixtures/entities.ts +++ b/packages/api-http/test/fixtures/entities.ts @@ -47,12 +47,13 @@ export const makeWallet = (overrides: Record = {}) => ({ export const makeTransaction = (overrides: Record = {}) => ({ blockHash: BLOCK_HASH, blockNumber: "90", - cumulativeGasUsed: 21_000, + // gas, gasUsed and cumulativeGasUsed are deliberately distinct so field swaps are detectable. + cumulativeGasUsed: 42_000, data: "0x", decodedError: undefined, deployedContractAddress: undefined, from: ADDRESS_A, - gas: 21_000, + gas: 25_000, gasPrice: 5, gasRefunded: 0, gasUsed: 21_000, diff --git a/packages/api-http/test/helpers/server.ts b/packages/api-http/test/helpers/server.ts index 1ec0b8386..bf895e953 100644 --- a/packages/api-http/test/helpers/server.ts +++ b/packages/api-http/test/helpers/server.ts @@ -109,7 +109,8 @@ export const makeRepo = () => { return data.feeStatistics; }, getLatest: async () => data.one ?? null, - getLatestHeight: async () => data.latestHeight ?? 0, + // Matches the real contract: undefined when the database is empty. + getLatestHeight: async () => data.latestHeight, getPeerBlockNumberP90: async () => data.peerBlockNumberP90 ?? 0, qb, }; From d6c6d9828f2c6166bc19743799eced98e3d4390e Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:21:04 +0900 Subject: [PATCH 22/28] add missing fields to receipt --- .../api-http/source/controllers/receipts.ts | 2 + .../api-http/source/routes/receipts.test.ts | 37 ++++++++++++++++--- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/api-http/source/controllers/receipts.ts b/packages/api-http/source/controllers/receipts.ts index a9c0fb6d7..ad4b016d6 100644 --- a/packages/api-http/source/controllers/receipts.ts +++ b/packages/api-http/source/controllers/receipts.ts @@ -115,9 +115,11 @@ export class ReceiptsController extends Controller { #getReceiptColumns(fullReceipt?: boolean): string[] { let columns = [ "Transaction.hash", + "Transaction.blockNumber", "Transaction.status", "Transaction.gasUsed", "Transaction.gasRefunded", + "Transaction.cumulativeGasUsed", "Transaction.deployedContractAddress", "Transaction.decodedError", ]; diff --git a/packages/api-http/source/routes/receipts.test.ts b/packages/api-http/source/routes/receipts.test.ts index b177569ae..a53801f4a 100644 --- a/packages/api-http/source/routes/receipts.test.ts +++ b/packages/api-http/source/routes/receipts.test.ts @@ -15,10 +15,31 @@ import { ServiceProvider } from "../service-provider"; // A transaction row as production returns it for receipts: options.selection limits // the loaded columns, so anything outside #getReceiptColumns is absent from the entity. const makeSelectedReceiptRow = (overrides: Record = {}) => { - const { decodedError, deployedContractAddress, gasRefunded, gasUsed, hash, logs, output, status } = - makeTransaction(overrides); - - return { decodedError, deployedContractAddress, gasRefunded, gasUsed, hash, logs, output, status }; + const { + blockNumber, + cumulativeGasUsed, + decodedError, + deployedContractAddress, + gasRefunded, + gasUsed, + hash, + logs, + output, + status, + } = makeTransaction(overrides); + + return { + blockNumber, + cumulativeGasUsed, + decodedError, + deployedContractAddress, + gasRefunded, + gasUsed, + hash, + logs, + output, + status, + }; }; describe<{ @@ -39,7 +60,9 @@ describe<{ }); it("GET /api/receipts - maps transactions to receipts", async ({ server, repos }) => { - repos.transaction.data.page = makePage([makeTransaction({ decodedError: "reverted", status: 0 })]); + // Production only loads the receipt columns (options.selection resets the + // select), so the row carries exactly what #getReceiptColumns selects. + repos.transaction.data.page = makePage([makeSelectedReceiptRow({ decodedError: "reverted", status: 0 })]); const response = await server.inject({ method: "GET", url: "/api/receipts" }); @@ -49,7 +72,7 @@ describe<{ assert.is(body.meta.totalCount, 1); assert.equal(body.data[0], { blockNumber: "90", - cumulativeGasUsed: 21_000, + cumulativeGasUsed: 42_000, decodedError: "reverted", gasRefunded: 0, gasUsed: 21_000, @@ -76,9 +99,11 @@ describe<{ // fullReceipt defaults to true on this route, so output and logs are selected. assert.equal(options.selection, [ "Transaction.hash", + "Transaction.blockNumber", "Transaction.status", "Transaction.gasUsed", "Transaction.gasRefunded", + "Transaction.cumulativeGasUsed", "Transaction.deployedContractAddress", "Transaction.decodedError", "Transaction.output", From f13ecf57ab927fa90eca84d51a2d5cfe08f206d5 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:23:10 +0900 Subject: [PATCH 23/28] paginate token holders endpoint --- packages/api-http/source/controllers/tokens.ts | 4 ++++ packages/api-http/source/routes/tokens.test.ts | 12 ++++++++---- packages/api-http/source/routes/tokens.ts | 6 ++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/api-http/source/controllers/tokens.ts b/packages/api-http/source/controllers/tokens.ts index c04355be0..b1d7e41a7 100644 --- a/packages/api-http/source/controllers/tokens.ts +++ b/packages/api-http/source/controllers/tokens.ts @@ -86,12 +86,16 @@ export class TokensController extends Controller { return Boom.notFound("Token not found"); } + const pagination = this.getQueryPagination(request.query); + const [tokenHolders, totalCount] = await this.tokenHolderRepositoryFactory() .createQueryBuilder() .select() .where("token_address = :address", { address: request.params.address }) .orderBy("balance", "DESC") .addOrderBy("address", "ASC") + .offset(pagination.offset) + .limit(pagination.limit) .getManyAndCount(); return this.toPagination( diff --git a/packages/api-http/source/routes/tokens.test.ts b/packages/api-http/source/routes/tokens.test.ts index ca8ad5669..365a6b7eb 100644 --- a/packages/api-http/source/routes/tokens.test.ts +++ b/packages/api-http/source/routes/tokens.test.ts @@ -165,14 +165,18 @@ describe<{ 1, ]; - const response = await server.inject({ method: "GET", url: `/api/tokens/${TOKEN_ADDRESS}/holders` }); + const response = await server.inject({ method: "GET", url: `/api/tokens/${TOKEN_ADDRESS}/holders?limit=10` }); assert.is(response.statusCode, 200); - // The holders route is not wrapped by the pagination plugin. const body = JSON.parse(response.payload); - assert.is(body.totalCount, 1); - assert.equal(body.results[0].address, ADDRESS_A); + assert.is(body.meta.totalCount, 1); + assert.equal(body.data[0].address, ADDRESS_A); + + // The holder listing is ordered and windowed by the query pagination. + assert.equal(repos.tokenHolder.qb.calls.orderBy, [["balance", "DESC"]]); + assert.equal(repos.tokenHolder.qb.calls.offset, [[0]]); + assert.equal(repos.tokenHolder.qb.calls.limit, [[10]]); }); it("GET /api/tokens/{address}/holders - responds 404 for an unknown token", async ({ server }) => { diff --git a/packages/api-http/source/routes/tokens.ts b/packages/api-http/source/routes/tokens.ts index c5b69ee9c..19a3dee54 100644 --- a/packages/api-http/source/routes/tokens.ts +++ b/packages/api-http/source/routes/tokens.ts @@ -150,10 +150,16 @@ export const register = (server: Contracts.Api.ApiServer): void => { handler: (request: Types.HapiRequest) => controller.holders(request), method: "GET", options: { + plugins: { + pagination: { + enabled: true, + }, + }, validate: { params: Joi.object({ address, }), + query: Joi.object({}).concat(Schemas.pagination), }, }, path: "/tokens/{address}/holders", From 04daa6bed23ec118fb03a7f3e924a2fb319b4a6a Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:39:02 +0900 Subject: [PATCH 24/28] guard against missing rows --- packages/api-http/source/controllers/node.ts | 20 +++++++++++++++----- packages/api-http/source/routes/node.test.ts | 19 +++++++++++++++++-- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/api-http/source/controllers/node.ts b/packages/api-http/source/controllers/node.ts index a97f497f3..a066a916c 100644 --- a/packages/api-http/source/controllers/node.ts +++ b/packages/api-http/source/controllers/node.ts @@ -54,10 +54,15 @@ export class NodeController extends Controller { public async fees(request: Types.HapiRequest): Promise { const configuration = await this.getConfiguration(); - const cryptoConfiguration = configuration.cryptoConfiguration as Contracts.Crypto.NetworkConfig; - const genesisTimestamp = cryptoConfiguration.genesisBlock.block.timestamp; + const cryptoConfiguration = configuration.cryptoConfiguration as Contracts.Crypto.NetworkConfig | undefined; - const result = await this.transactionRepositoryFactory().getFeeStatistics(genesisTimestamp, request.query.days); + // A fresh database has no configuration row (and no transactions yet). + const result = cryptoConfiguration + ? await this.transactionRepositoryFactory().getFeeStatistics( + cryptoConfiguration.genesisBlock.block.timestamp, + request.query.days, + ) + : undefined; const grouped = { evmCall: { @@ -73,9 +78,14 @@ export class NodeController extends Controller { public async configuration(request: Types.HapiRequest): Promise { const configuration = await this.getConfiguration(); - const plugins = await this.getPlugins(); - const cryptoConfiguration = configuration.cryptoConfiguration as Contracts.Crypto.NetworkConfig; + const cryptoConfiguration = configuration.cryptoConfiguration as Contracts.Crypto.NetworkConfig | undefined; + if (!cryptoConfiguration) { + // A fresh database has no configuration row yet. + return { data: {} }; + } + + const plugins = await this.getPlugins(); const network = cryptoConfiguration.network; return { diff --git a/packages/api-http/source/routes/node.test.ts b/packages/api-http/source/routes/node.test.ts index 6c195938a..fdcd83f7d 100644 --- a/packages/api-http/source/routes/node.test.ts +++ b/packages/api-http/source/routes/node.test.ts @@ -1,7 +1,8 @@ import { describe } from "@mainsail/test-runner"; import { makeState } from "../../test/fixtures/entities"; -import { bootstrapServer, makeRepos, Repos } from "../../test/helpers/server"; +import { bootstrapServer, makeConfiguration, makeRepos, Repos } from "../../test/helpers/server"; +import { Identifiers as ApiHttpIdentifiers } from "../identifiers"; import { Server } from "../server"; import { ServiceProvider } from "../service-provider"; @@ -23,7 +24,12 @@ describe<{ beforeEach(async (context) => { context.repos = makeRepos(); - const { server, serviceProvider } = await bootstrapServer(context.repos); + // A realistic port so the configuration endpoint can report it; the + // server is never booted in these tests, so nothing binds to it. + const config = makeConfiguration(); + config.server.http.port = 4003; + + const { server, serviceProvider } = await bootstrapServer(context.repos, config); context.server = server; context.serviceProvider = serviceProvider; }); @@ -113,6 +119,15 @@ describe<{ assert.equal(JSON.parse(response.payload).data.evmCall, { avg: "0", max: "0", min: "0", sum: "0" }); }); + it("GET /api/node/fees - returns zeroes without querying on a fresh database", async ({ server, repos }) => { + // No configuration row -> no genesis timestamp to anchor the statistics on. + const response = await server.inject({ method: "GET", url: "/api/node/fees" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data.evmCall, { avg: "0", max: "0", min: "0", sum: "0" }); + assert.undefined(repos.transaction.calls.getFeeStatistics); + }); + it("GET /api/node/fees - rejects an out of range days parameter", async ({ server }) => { assert.is((await server.inject({ method: "GET", url: "/api/node/fees?days=0" })).statusCode, 422); assert.is((await server.inject({ method: "GET", url: "/api/node/fees?days=31" })).statusCode, 422); From 31decd53bfe445db17c0fe9bb2c4b655f9f58490 Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:40:54 +0900 Subject: [PATCH 25/28] assert empty configuration works --- packages/api-http/source/routes/node.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/api-http/source/routes/node.test.ts b/packages/api-http/source/routes/node.test.ts index fdcd83f7d..ea52ab895 100644 --- a/packages/api-http/source/routes/node.test.ts +++ b/packages/api-http/source/routes/node.test.ts @@ -169,7 +169,16 @@ describe<{ assert.equal(data.token, "TEST"); assert.equal(data.version, 30); assert.equal(data.wif, 186); - assert.equal(data.ports, { "@mainsail/api-database": 5432, "@mainsail/p2p": 4102 }); + assert.equal(data.ports, { "@mainsail/api-database": 5432, "@mainsail/p2p": 4102, "@mainsail/webhooks": 4004 }); + }); + + it("GET /api/node/configuration - returns an empty object on a fresh database", async ({ server, repos }) => { + const response = await server.inject({ method: "GET", url: "/api/node/configuration" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload), { data: {} }); + // The plugin lookup is skipped entirely. + assert.undefined(repos.plugin.qb.calls.getMany); }); it("GET /api/node/configuration/crypto - returns the full crypto configuration", async ({ server, repos }) => { From e5f7ae325c76888cc15c502bf7c9d61099517c1c Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:55:52 +0900 Subject: [PATCH 26/28] fix port mappings --- packages/api-http/source/controllers/node.ts | 17 ++++++++++- packages/api-http/source/routes/node.test.ts | 32 +++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/api-http/source/controllers/node.ts b/packages/api-http/source/controllers/node.ts index a066a916c..99d9b7dd7 100644 --- a/packages/api-http/source/controllers/node.ts +++ b/packages/api-http/source/controllers/node.ts @@ -138,7 +138,22 @@ export class NodeController extends Controller { const pluginRepository = this.pluginRepositoryFactory(); let plugins = await pluginRepository.createQueryBuilder().select().getMany(); - plugins = [{ configuration: this.apiConfiguration.all(), name: "@mainsail/api-http" }, ...plugins]; + + // Report this API's own port: lift enabled/port out of the server section + // (http preferred, https otherwise) into the shape buildPortMapping expects. + // Appended last so the live configuration wins over the raw configuration + // row that api-sync stores for this package, which carries no top-level port. + const { server } = this.apiConfiguration.all() as { + server: { http: { enabled: boolean; port: number }; https: { enabled: boolean; port: number } }; + }; + const activeServer = server.http.enabled ? server.http : server.https; + plugins = [ + ...plugins, + { + configuration: { enabled: activeServer.enabled, port: activeServer.port }, + name: "@mainsail/api-http", + } as Models.Plugin, + ]; const mappings: Record = {}; diff --git a/packages/api-http/source/routes/node.test.ts b/packages/api-http/source/routes/node.test.ts index ea52ab895..f0b0079ad 100644 --- a/packages/api-http/source/routes/node.test.ts +++ b/packages/api-http/source/routes/node.test.ts @@ -154,6 +154,9 @@ describe<{ }, // Plugins outside the known set are ignored. { configuration: { enabled: true, port: 9999 }, name: "@mainsail/unknown" }, + // api-sync stores this package's raw configuration (no top-level + // enabled/port); the live server configuration must win over it. + { configuration: { server: { http: { enabled: true, port: 9999 } } }, name: "@mainsail/api-http" }, ]; const response = await server.inject({ method: "GET", url: "/api/node/configuration" }); @@ -169,7 +172,34 @@ describe<{ assert.equal(data.token, "TEST"); assert.equal(data.version, 30); assert.equal(data.wif, 186); - assert.equal(data.ports, { "@mainsail/api-database": 5432, "@mainsail/p2p": 4102, "@mainsail/webhooks": 4004 }); + assert.equal(data.ports, { + "@mainsail/api-database": 5432, + "@mainsail/api-http": 4003, + "@mainsail/p2p": 4102, + "@mainsail/webhooks": 4004, + }); + }); + + it("GET /api/node/configuration - reports the https port when the http server is disabled", async ({ repos }) => { + const config = makeConfiguration(); + config.server.http.enabled = false; + config.server.https.enabled = true; + config.server.https.port = 8443; + + // Registration skips the disabled http server, so resolve the https one. + const { app, serviceProvider } = await bootstrapServer(repos, config); + + try { + repos.configuration.data.one = { cryptoConfiguration: makeCryptoConfiguration() }; + + const httpsServer = app.get(ApiHttpIdentifiers.HTTPS); + const response = await httpsServer.inject({ method: "GET", url: "/api/node/configuration" }); + + assert.is(response.statusCode, 200); + assert.equal(JSON.parse(response.payload).data.ports["@mainsail/api-http"], 8443); + } finally { + await serviceProvider.dispose(); + } }); it("GET /api/node/configuration - returns an empty object on a fresh database", async ({ server, repos }) => { From 9d3ace2859dc2cceb3e0c600b495b7dfb1d7be1f Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:12:39 +0900 Subject: [PATCH 27/28] fix integration tests --- packages/api-http/integration/routes/tokens.test.ts | 3 ++- packages/api-http/test/fixtures/node_configuration.json | 4 +++- packages/api-http/test/fixtures/receipts_result.json | 4 ++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/api-http/integration/routes/tokens.test.ts b/packages/api-http/integration/routes/tokens.test.ts index 56bc33b9a..c1f552a44 100644 --- a/packages/api-http/integration/routes/tokens.test.ts +++ b/packages/api-http/integration/routes/tokens.test.ts @@ -276,7 +276,8 @@ describe<{ } else { const { statusCode, data } = await request(endpoint, options); assert.equal(statusCode, result.statusCode); - assert.equal(data.results, result.data); + assert.equal(data.data, result.data); + assert.equal(data.meta.totalCount, result.data.length); } } }); diff --git a/packages/api-http/test/fixtures/node_configuration.json b/packages/api-http/test/fixtures/node_configuration.json index 38224d54e..294f84bf8 100644 --- a/packages/api-http/test/fixtures/node_configuration.json +++ b/packages/api-http/test/fixtures/node_configuration.json @@ -34,7 +34,9 @@ }, "explorer": "", "nethash": "5f919d94bacac4bf509c65eedd7cd8899da08f7ad97a074bdfc4a2f31d87ac4f", - "ports": {}, + "ports": { + "@mainsail/api-http": 4003 + }, "symbol": "TѦ", "token": "ARK", "version": 30, diff --git a/packages/api-http/test/fixtures/receipts_result.json b/packages/api-http/test/fixtures/receipts_result.json index 330040306..974235602 100644 --- a/packages/api-http/test/fixtures/receipts_result.json +++ b/packages/api-http/test/fixtures/receipts_result.json @@ -1,9 +1,11 @@ [ { "transactionHash": "d76eface634cab46ccc57f373a03e83cfbdaa7bedb84228ed8ce30bea128649a", + "blockNumber": "116", "status": 1, "gasUsed": 116802, "gasRefunded": 0, + "cumulativeGasUsed": 116802, "contractAddress": null, "logs": [ { @@ -16,9 +18,11 @@ }, { "transactionHash": "29c12f5fc3c25323eec3bae1ddbe72a1113cba634e0079735a34074d4dc8ac0b", + "blockNumber": "3", "status": 1, "gasUsed": 116802, "gasRefunded": 0, + "cumulativeGasUsed": 116802, "contractAddress": "0x0a26D3630D5EC868767d7F4563Fab98D31601A6e", "logs": [ { From af6759d4a5c987d56573c442be4b345394a8430a Mon Sep 17 00:00:00 2001 From: oXtxNt9U <120286271+oXtxNt9U@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:20:54 +0900 Subject: [PATCH 28/28] fix e2e token holder check --- tests/e2e/consensus/checks/index.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/consensus/checks/index.mjs b/tests/e2e/consensus/checks/index.mjs index c7dc64086..eccec5534 100644 --- a/tests/e2e/consensus/checks/index.mjs +++ b/tests/e2e/consensus/checks/index.mjs @@ -82,7 +82,7 @@ async function waitForResults() { try { let tokensOk = true; for (const validation of tokenContractValidations) { - const { results: holders } = await getApiHttp(config.peer, `/tokens/${validation.address}/holders`); + const { data: holders } = await getApiHttp(config.peer, `/tokens/${validation.address}/holders`); const hasTokenHolder = holders.some(h => h.address === config.tokenBeneficiary && h.balance === validation.tokenBeneficiaryBalance); if (!hasTokenHolder) { tokensOk = false;