diff --git a/README.md b/README.md
index 185578b..f4eb200 100644
--- a/README.md
+++ b/README.md
@@ -2,26 +2,41 @@
[![.github/workflows/bun-test.yml](https://github.com/pinax-network/antelope-token-api/actions/workflows/bun-test.yml/badge.svg)](https://github.com/pinax-network/antelope-token-api/actions/workflows/bun-test.yml)
-> Token balances, supply and transfers from the Antelope blockchains
+> Tokens information from the Antelope blockchains, powered by [Substreams](https://substreams.streamingfast.io/)
## REST API
+### Usage
+
| Method | Path | Description |
| :---: | --- | --- |
| GET
`text/html` | `/` | [Swagger](https://swagger.io/) API playground |
-| GET
`application/json` | `/supply` | Antelope Tokens total supply |
-| GET
`application/json` | `/balance` | Antelope Tokens balance changes |
-| GET
`application/json` | `/transfers` | Antelope Tokens transfers |
-| GET
`text/plain` | `/health` | Performs health checks and checks if the database is accessible |
+| GET
`application/json` | `/balance` | Balances of an account. |
| GET
`application/json` | `/head` | Information about the current head block in the database |
-| GET
`text/plain` | `/metrics` | [Prometheus](https://prometheus.io/) metrics for the API |
+| GET
`application/json` | `/holders` | List of holders of a token |
+| GET
`application/json` | `/supply` | Total supply for a token |
+| GET
`application/json` | `/tokens` | List of available tokens |
+| GET
`application/json` | `/transfers` | All transfers related to a token |
+| GET
`application/json` | `/transfers/{trx_id}` | Specific transfer related to a token |
+
+### Docs
+
+| Method | Path | Description |
+| :---: | --- | --- |
| GET
`application/json` | `/openapi` | [OpenAPI](https://www.openapis.org/) specification |
-| GET
`application/json` | `/version` | API version and commit hash |
+| GET
`application/json` | `/version` | API version and Git short commit hash |
+
+### Monitoring
+
+| Method | Path | Description |
+| :---: | --- | --- |
+| GET
`text/plain` | `/health` | Checks database connection |
+| GET
`text/plain` | `/metrics` | [Prometheus](https://prometheus.io/) metrics |
## Requirements
- [ClickHouse](clickhouse.com/)
-- (Optional) A [Substream sink](https://substreams.streamingfast.io/reference-and-specs/glossary#sink) for loading data into ClickHouse. We recommend [Substreams Sink ClickHouse](https://github.com/pinax-network/substreams-sink-clickhouse/) or [Substreams Sink SQL](https://github.com/streamingfast/substreams-sink-sql).
+- (Optional) A [Substream sink](https://substreams.streamingfast.io/reference-and-specs/glossary#sink) for loading data into ClickHouse. We recommend [Substreams Sink ClickHouse](https://github.com/pinax-network/substreams-sink-clickhouse/) or [Substreams Sink SQL](https://github.com/streamingfast/substreams-sink-sql). You should use the generated [`protobuf` files](tsp-output/@typespec/protobuf) to build your substream.
## Quick start
@@ -40,10 +55,11 @@ $ bun test
## [`Bun` Binary Releases](https://github.com/pinax-network/antelope-token-api/releases)
-> For Linux x86
+> [!WARNING]
+> Linux x86 only
```console
-$ wget https://github.com/pinax-network/antelope-token-api/releases/download/v2.0.0/antelope-token-api
+$ wget https://github.com/pinax-network/antelope-token-api/releases/download/v3.0.0/antelope-token-api
$ chmod +x ./antelope-token-api
$ ./antelope-token-api --help
Usage: antelope-token-api [options]
@@ -90,6 +106,7 @@ VERBOSE=true
```bash
docker pull ghcr.io/pinax-network/antelope-token-api:latest
```
+
**For head of `develop` branch**
```bash
docker pull ghcr.io/pinax-network/antelope-token-api:develop
diff --git a/bun.lockb b/bun.lockb
index f5c34e3..7e5a13a 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/index.ts b/index.ts
index 24d3f09..b5c9872 100644
--- a/index.ts
+++ b/index.ts
@@ -1,16 +1,153 @@
-import { config } from "./src/config.js";
-import { logger } from "./src/logger.js";
-import GET from "./src/fetch/GET.js";
-import { APIError } from "./src/fetch/utils.js";
-
-const app = Bun.serve({
- hostname: config.hostname,
- port: config.port,
- fetch(req: Request) {
- let pathname = new URL(req.url).pathname;
- if (req.method === "GET") return GET(req);
- return APIError(pathname, 405, "invalid_request_method", "Invalid request method, only GET allowed");
- }
-});
-
-logger.info(`Server listening on http://${app.hostname}:${app.port}`);
\ No newline at end of file
+import client from './src/clickhouse/client.js';
+import openapi from "./tsp-output/@typespec/openapi3/openapi.json";
+
+import { Hono } from "hono";
+import { ZodBigInt, ZodBoolean, ZodDate, ZodNumber, ZodOptional, ZodTypeAny, ZodUndefined, ZodUnion, z } from "zod";
+import { EndpointByMethod } from './src/types/zod.gen.js';
+import { APP_VERSION } from "./src/config.js";
+import { logger } from './src/logger.js';
+import * as prometheus from './src/prometheus.js';
+import { makeUsageQuery } from "./src/usage.js";
+import { APIErrorResponse } from "./src/utils.js";
+
+import type { Context } from "hono";
+import type { EndpointParameters, EndpointReturnTypes, UsageEndpoints } from "./src/types/api.js";
+
+function AntelopeTokenAPI() {
+ const app = new Hono();
+
+ app.use(async (ctx: Context, next) => {
+ const pathname = ctx.req.path;
+ logger.trace(`Incoming request: [${pathname}]`);
+ prometheus.request.inc({ pathname });
+
+ await next();
+ });
+
+ app.get(
+ "/",
+ async (_) => new Response(Bun.file("./swagger/index.html"))
+ );
+
+ app.get(
+ "/favicon.ico",
+ async (_) => new Response(Bun.file("./swagger/favicon.ico"))
+ );
+
+ app.get(
+ "/openapi",
+ async (ctx: Context) => ctx.json<{ [key: string]: EndpointReturnTypes<"/openapi">; }, 200>(openapi)
+ );
+
+ app.get(
+ "/version",
+ async (ctx: Context) => ctx.json, 200>(APP_VERSION)
+ );
+
+ app.get(
+ "/health",
+ async (ctx: Context) => {
+ const response = await client.ping();
+
+ if (!response.success) {
+ return APIErrorResponse(ctx, 500, "bad_database_response", response.error.message);
+ }
+
+ return new Response("OK");
+ }
+ );
+
+ app.get(
+ "/metrics",
+ async (_) => new Response(await prometheus.registry.metrics(), { headers: { "Content-Type": prometheus.registry.contentType } })
+ );
+
+ const createUsageEndpoint = (endpoint: UsageEndpoints) => app.get(
+ // Hono using different syntax than OpenAPI for path parameters
+ // `/{path_param}` (OpenAPI) VS `/:path_param` (Hono)
+ endpoint.replace(/{([^}]+)}/, ":$1"),
+ async (ctx: Context) => {
+ // Add type coercion for query and path parameters since the codegen doesn't coerce types natively
+ const endpoint_parameters = Object.values(EndpointByMethod["get"][endpoint].parameters.shape).map(p => p.shape);
+ endpoint_parameters.forEach(
+ // `p` can query or path parameters
+ (p) => Object.keys(p).forEach(
+ (key, _) => {
+ const zod_type = p[key] as ZodTypeAny;
+ let underlying_zod_type: ZodTypeAny;
+ let isOptional = false;
+
+ // Detect the underlying type from the codegen
+ if (zod_type instanceof ZodUnion) {
+ underlying_zod_type = zod_type.options[0];
+ isOptional = zod_type.options.some((o: ZodTypeAny) => o instanceof ZodUndefined);
+ } else if (zod_type instanceof ZodOptional) {
+ underlying_zod_type = zod_type.unwrap();
+ isOptional = true;
+ } else {
+ underlying_zod_type = zod_type;
+ }
+
+ // Query and path user input parameters come as strings and we need to coerce them to the right type using Zod
+ if (underlying_zod_type instanceof ZodNumber) {
+ p[key] = z.coerce.number();
+ } else if (underlying_zod_type instanceof ZodBoolean) {
+ p[key] = z.coerce.boolean();
+ } else if (underlying_zod_type instanceof ZodBigInt) {
+ p[key] = z.coerce.bigint();
+ } else if (underlying_zod_type instanceof ZodDate) {
+ p[key] = z.coerce.date();
+ // Any other type will be coerced as string value directly
+ } else {
+ p[key] = z.coerce.string();
+ }
+
+ if (isOptional)
+ p[key] = p[key].optional();
+
+ // Mark parameters with default values explicitly as a workaround
+ // See https://github.com/astahmer/typed-openapi/issues/34
+ if (key == "limit")
+ p[key] = p[key].default(10);
+ else if (key == "page")
+ p[key] = p[key].default(1);
+
+ }
+ )
+ );
+
+ const result = EndpointByMethod["get"][endpoint].parameters.safeParse({
+ query: ctx.req.query(),
+ path: ctx.req.param()
+ }) as z.SafeParseSuccess>;
+
+ if (result.success) {
+ return makeUsageQuery(
+ ctx,
+ endpoint,
+ {
+ ...result.data.query,
+ // Path parameters may not always be present
+ ...("path" in result.data ? result.data.path : {})
+ }
+ );
+ } else {
+ return APIErrorResponse(ctx, 400, "bad_query_input", result.error);
+ }
+ }
+ );
+
+ createUsageEndpoint("/balance"); // TODO: Maybe separate `block_num`/`timestamp` queries with path parameters (additional response schemas)
+ createUsageEndpoint("/head");
+ createUsageEndpoint("/holders");
+ createUsageEndpoint("/supply"); // TODO: Same as `balance``
+ createUsageEndpoint("/tokens");
+ createUsageEndpoint("/transfers"); // TODO: Redefine `block_range` params
+ createUsageEndpoint("/transfers/{trx_id}");
+
+ app.notFound((ctx: Context) => APIErrorResponse(ctx, 404, "route_not_found", `Path not found: ${ctx.req.method} ${ctx.req.path}`));
+
+ return app;
+}
+
+export default AntelopeTokenAPI();
\ No newline at end of file
diff --git a/package.json b/package.json
index bd9d7b2..d3eac6a 100644
--- a/package.json
+++ b/package.json
@@ -1,10 +1,9 @@
{
"name": "antelope-token-api",
"description": "Token balances, supply and transfers from the Antelope blockchains",
- "version": "2.3.0",
+ "version": "3.0.0",
"homepage": "https://github.com/pinax-network/antelope-token-api",
"license": "MIT",
- "type": "module",
"authors": [
{
"name": "Etienne Donneger",
@@ -17,24 +16,38 @@
"url": "https://github.com/DenisCarriere/"
}
],
- "scripts": {
- "start": "export APP_VERSION=$(git rev-parse --short HEAD) && bun index.ts",
- "dev": "export APP_VERSION=$(git rev-parse --short HEAD) && bun --watch index.ts",
- "lint": "export APP_VERSION=$(git rev-parse --short HEAD) && bunx tsc --noEmit --skipLibCheck --pretty",
- "test": "export APP_VERSION=$(git rev-parse --short HEAD) && bun test --coverage",
- "build": "export APP_VERSION=$(git rev-parse --short HEAD) && bun build --compile ./index.ts --outfile antelope-token-api"
- },
"dependencies": {
"@clickhouse/client-web": "latest",
- "commander": "latest",
- "dotenv": "latest",
- "openapi3-ts": "latest",
- "prom-client": "latest",
- "tslog": "latest",
+ "@typespec/compiler": "latest",
+ "@typespec/openapi3": "latest",
+ "@typespec/protobuf": "latest",
+ "commander": "^12.1.0",
+ "dotenv": "^16.4.5",
+ "hono": "latest",
+ "prom-client": "^15.1.2",
+ "tslog": "^4.9.2",
+ "typed-openapi": "latest",
"zod": "latest"
},
+ "private": true,
+ "scripts": {
+ "build": "export APP_VERSION=$(git rev-parse --short HEAD) && bun build --compile index.ts --outfile antelope-token-api",
+ "clean": "bun i --force",
+ "dev": "export APP_VERSION=$(git rev-parse --short HEAD) && bun --watch index.ts",
+ "lint": "export APP_VERSION=$(git rev-parse --short HEAD) && bun run tsc --noEmit --skipLibCheck --pretty",
+ "start": "export APP_VERSION=$(git rev-parse --short HEAD) && bun index.ts",
+ "test": "bun test --coverage",
+ "types": "bun run tsp compile ./src/typespec && bun run typed-openapi ./tsp-output/@typespec/openapi3/openapi.json -o ./src/types/zod.gen.ts -r zod",
+ "types:check": "bun run tsp compile ./src/typespec --no-emit --pretty --warn-as-error",
+ "types:format": "bun run tsp format src/typespec/**/*.tsp",
+ "types:watch": "bun run tsp compile ./src/typespec --watch --pretty --warn-as-error"
+ },
+ "type": "module",
"devDependencies": {
"bun-types": "latest",
"typescript": "latest"
+ },
+ "prettier": {
+ "tabWidth": 4
}
-}
+}
\ No newline at end of file
diff --git a/src/clickhouse/createClient.ts b/src/clickhouse/client.ts
similarity index 85%
rename from src/clickhouse/createClient.ts
rename to src/clickhouse/client.ts
index 79ae600..17d484f 100644
--- a/src/clickhouse/createClient.ts
+++ b/src/clickhouse/client.ts
@@ -2,15 +2,17 @@ import { createClient } from "@clickhouse/client-web";
import { ping } from "./ping.js";
import { APP_NAME, config } from "../config.js";
+// TODO: Check how to abort previous queries if haven't returned yet
// TODO: Make client connect to all DB instances
const client = createClient({
...config,
clickhouse_settings: {
allow_experimental_object_type: 1,
readonly: "1",
+ exact_rows_before_limit: 1
},
application: APP_NAME,
-})
+});
// These overrides should not be required but the @clickhouse/client-web instance
// does not work well with Bun's implementation of Node streams.
diff --git a/src/clickhouse/makeQuery.ts b/src/clickhouse/makeQuery.ts
index 7922578..d30d1dc 100644
--- a/src/clickhouse/makeQuery.ts
+++ b/src/clickhouse/makeQuery.ts
@@ -1,36 +1,25 @@
+import client from "./client.js";
+
import { logger } from "../logger.js";
import * as prometheus from "../prometheus.js";
-import client from "./createClient.js";
-export interface Meta {
- name: string,
- type: string
-}
-export interface Query {
- meta: Meta[],
- data: T[],
- rows: number,
- rows_before_limit_at_least: number,
- statistics: {
- elapsed: number,
- rows_read: number,
- bytes_read: number,
- }
-}
+import type { ResponseJSON } from "@clickhouse/client-web";
+import type { ValidQueryParams } from "../types/api.js";
-export async function makeQuery(query: string) {
- try {
- const response = await client.query({ query })
- const data: Query = await response.json();
+export async function makeQuery(query: string, query_params: ValidQueryParams) {
+ logger.trace({ query, query_params });
- prometheus.query.inc();
+ const response = await client.query({ query, query_params, format: "JSON" });
+ const data: ResponseJSON = await response.json();
+
+ prometheus.query.inc();
+ if ( data.statistics ) {
prometheus.bytes_read.inc(data.statistics.bytes_read);
prometheus.rows_read.inc(data.statistics.rows_read);
prometheus.elapsed.inc(data.statistics.elapsed);
- logger.trace("\n", { query, statistics: data.statistics, rows: data.rows });
-
- return data;
- } catch (e: any) {
- throw new Error(e.message);
}
+
+ logger.trace({ statistics: data.statistics, rows: data.rows, rows_before_limit_at_least: data.rows_before_limit_at_least });
+
+ return data;
}
\ No newline at end of file
diff --git a/src/clickhouse/ping.ts b/src/clickhouse/ping.ts
index f751036..5cb8720 100644
--- a/src/clickhouse/ping.ts
+++ b/src/clickhouse/ping.ts
@@ -1,5 +1,5 @@
import { PingResult } from "@clickhouse/client-web";
-import client from "./createClient.js";
+import client from "./client.js";
import { logger } from "../logger.js";
// Does not work with Bun's implementation of Node streams.
diff --git a/src/config.ts b/src/config.ts
index 1fbd3f4..d6de2cd 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -14,12 +14,15 @@ export const DEFAULT_MAX_LIMIT = 10000;
export const DEFAULT_VERBOSE = false;
export const DEFAULT_SORT_BY = "DESC";
export const APP_NAME = pkg.name;
-export const APP_VERSION = `${pkg.version}+${process.env.APP_VERSION || "unknown"}`;
+export const APP_VERSION = {
+ version: pkg.version,
+ commit: process.env.APP_VERSION || "unknown"
+};
// parse command line options
const opts = program
.name(pkg.name)
- .version(APP_VERSION)
+ .version(`${APP_VERSION.version}+${APP_VERSION.commit}`)
.description(pkg.description)
.showHelpAfterError()
.addOption(new Option("-p, --port ", "HTTP port on which to attach the API").env("PORT").default(DEFAULT_PORT))
diff --git a/src/fetch/GET.ts b/src/fetch/GET.ts
deleted file mode 100644
index 5fc3396..0000000
--- a/src/fetch/GET.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { registry } from "../prometheus.js";
-import openapi from "./openapi.js";
-import health from "./health.js";
-import head from "./head.js";
-import balance from "./balance.js";
-import supply from "./supply.js";
-import * as prometheus from "../prometheus.js";
-import swaggerHtml from "../../swagger/index.html"
-import swaggerFavicon from "../../swagger/favicon.png"
-import transfers from "./transfers.js";
-import { APIError, toJSON } from "./utils.js";
-import { APP_VERSION } from "../config.js";
-import { logger } from "../logger.js";
-
-export default async function (req: Request) {
- const { pathname } = new URL(req.url);
- logger.trace(`Incoming request: [${pathname}]`)
- prometheus.request.inc({ pathname });
-
- // Landing page
- if (pathname === "/") return new Response(Bun.file(swaggerHtml));
- if (pathname === "/favicon.png") return new Response(Bun.file(swaggerFavicon));
-
- // Utils
- if (pathname === "/health") return health(req);
- if (pathname === "/metrics") return new Response(await registry.metrics(), { headers: { "Content-Type": registry.contentType } });
- if (pathname === "/openapi") return new Response(openapi, { headers: { "Content-Type": "application/json" } });
- if (pathname === "/version") return toJSON({ version: APP_VERSION.split('+')[0], commit: APP_VERSION.split('+')[1] });
-
- // Token endpoints
- if (pathname === "/head") return head(req);
- if (pathname === "/supply") return supply(req);
- if (pathname === "/balance") return balance(req);
- if (pathname === "/transfers") return transfers(req);
-
- return APIError(pathname, 404, "path_not_found", "Invalid pathname");
-}
diff --git a/src/fetch/balance.ts b/src/fetch/balance.ts
deleted file mode 100644
index 57243a0..0000000
--- a/src/fetch/balance.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { makeQuery } from "../clickhouse/makeQuery.js";
-import { logger } from "../logger.js";
-import { getBalanceChanges } from "../queries.js";
-import { APIError, addMetadata, toJSON } from "./utils.js";
-import { parseLimit, parsePage } from "../utils.js";
-
-function verifyParams(searchParams: URLSearchParams) {
- const account = searchParams.get("account");
- const contract = searchParams.get("contract");
-
- if (!account && !contract) throw new Error("account or contract is required");
-}
-
-export default async function (req: Request) {
- const { pathname, searchParams } = new URL(req.url);
- logger.trace("\n", { searchParams: Object.fromEntries(Array.from(searchParams)) });
-
- try {
- verifyParams(searchParams);
- } catch (e: any) {
- return APIError(pathname, 400, "bad_query_input", e.message);
- }
-
- const query = getBalanceChanges(searchParams);
- let response;
-
- try {
- response = await makeQuery(query);
- } catch (e: any) {
- return APIError(pathname, 500, "failed_database_query", e.message);
- }
-
- try {
- return toJSON(
- addMetadata(
- response,
- parseLimit(searchParams.get("limit")),
- parsePage(searchParams.get("page"))
- )
- );
- } catch (e: any) {
- return APIError(pathname, 500, "failed_response", e.message);
- }
-}
\ No newline at end of file
diff --git a/src/fetch/head.ts b/src/fetch/head.ts
deleted file mode 100644
index 1778183..0000000
--- a/src/fetch/head.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { APIError, addMetadata, toJSON } from "./utils.js";
-import { makeQuery } from "../clickhouse/makeQuery.js";
-
-export default async function (req: Request) {
- let query = "SELECT block_num FROM cursors ORDER BY block_num DESC LIMIT 1";
- let pathname = new URL(req.url).pathname;
- let response;
-
- try {
- response = await makeQuery(query);
- } catch (e: any) {
- return APIError(pathname, 500, "failed_database_query", e.message);
- }
-
- try {
- return toJSON(addMetadata(response));
- } catch (e: any) {
- return APIError(pathname, 500, "failed_response", e.message);
- }
-}
\ No newline at end of file
diff --git a/src/fetch/health.ts b/src/fetch/health.ts
deleted file mode 100644
index 105a2b0..0000000
--- a/src/fetch/health.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import client from "../clickhouse/createClient.js";
-import { APIError } from "./utils.js";
-
-export default async function (req: Request) {
- const response = await client.ping();
-
- if (!response.success) {
- return APIError(new URL(req.url).pathname, 503, "failed_ping_database", response.error.message);
- }
-
- return new Response("OK");
-}
\ No newline at end of file
diff --git a/src/fetch/openapi.ts b/src/fetch/openapi.ts
deleted file mode 100644
index 80d95c2..0000000
--- a/src/fetch/openapi.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-import pkg from "../../package.json" assert { type: "json" };
-
-import { OpenApiBuilder, SchemaObject, ExampleObject, ParameterObject } from "openapi3-ts/oas31";
-import { config } from "../config.js";
-import { registry } from "../prometheus.js";
-import { makeQuery } from "../clickhouse/makeQuery.js";
-import { getBalanceChanges, getTotalSupply, getTransfers } from "../queries.js";
-import { APIError, addMetadata } from "./utils.js";
-import { logger } from "../logger.js";
-const TAGS = {
- MONITORING: "Monitoring",
- HEALTH: "Health",
- USAGE: "Usage",
- DOCS: "Documentation",
-} as const;
-
-const timestampExamplesArrayFilter = ["greater_or_equals_by_timestamp", "greater_by_timestamp", "less_or_equals_by_timestamp", "less_by_timestamp"];
-const blockExamplesArrayFilter = ["greater_or_equals_by_block", "greater_by_block", "less_or_equals_by_block", "less_by_block"];
-const amountExamplesArrayFilter = ["amount_greater_or_equals", "amount_greater", "amount_less_or_equals", "amount_less"];
-
-const head_example = addMetadata({
- meta: [],
- data: [{ block_num: "107439534" }],
- rows: 0,
- rows_before_limit_at_least: 0,
- statistics: {
- elapsed: 0.00132,
- rows_read: 4,
- bytes_read: 32
- }
-});
-
-logger.debug("Querying examples for OpenAPI...");
-const supply_example = await makeQuery(
- getTotalSupply(new URLSearchParams({ limit: "1" }), true)
-).then(
- res => addMetadata(res, 1, 1)
-).catch(
- e => APIError("/openapi", 500, "failed_database_query", e.message)
-);
-
-const balance_example = await makeQuery(
- getBalanceChanges(new URLSearchParams({ limit: "2" }), true)
-).then(
- res => addMetadata(res, 2, 1)
-).catch(
- e => APIError("/openapi", 500, "failed_database_query", e.message)
-);
-
-const transfers_example = await makeQuery(
- getTransfers(new URLSearchParams({ limit: "5" }), true)
-).then(
- res => addMetadata(res, 5, 1)
-).catch(
- e => APIError("/openapi", 500, "failed_database_query", e.message)
-);
-
-const timestampSchema: SchemaObject = {
- anyOf: [
- { type: "number" },
- { type: "string", format: "date" },
- { type: "string", format: "date-time" }
- ]
-};
-const timestampExamples: ExampleObject = {
- unix: { summary: `Unix Timestamp (seconds)` },
- date: { summary: `Full-date notation`, value: '2023-10-18' },
- datetime: { summary: `Date-time notation`, value: '2023-10-18T00:00:00Z' },
-};
-
-const parameterString = (name: string = "address", required = false) => ({
- name,
- in: "query",
- description: `Filter by ${name}`,
- required,
- schema: { type: "string" },
-} as ParameterObject);
-
-const parameterLimit: ParameterObject = {
- name: "limit",
- in: "query",
- description: "Maximum number of records to return per query.",
- required: false,
- schema: { type: "number", maximum: config.maxLimit, minimum: 1 },
-};
-
-const parameterOffset: ParameterObject = {
- name: "page",
- in: "query",
- description: "Page index for results pagination.",
- required: false,
- schema: { type: "number", minimum: 1 },
-};
-
-const timestampFilter = timestampExamplesArrayFilter.map(name => {
- return {
- name,
- in: "query",
- description: "Filter " + name.replace(/_/g, " "),
- required: false,
- schema: timestampSchema,
- examples: timestampExamples,
- } as ParameterObject;
-});
-
-const blockFilter = blockExamplesArrayFilter.map(name => {
- return {
- name,
- in: "query",
- description: "Filter " + name.replace(/_/g, " "),
- required: false,
- schema: { type: "number" },
- } as ParameterObject;
-});
-
-const amountFilter = amountExamplesArrayFilter.map(name => {
- return {
- name,
- in: "query",
- description: "Filter " + name.replace(/_/g, " "),
- required: false,
- schema: { type: "number" },
- } as ParameterObject;
-});
-
-export default new OpenApiBuilder()
- .addInfo({
- title: pkg.name,
- version: pkg.version,
- description: pkg.description,
- license: { name: `License: ${pkg.license}`, url: `${pkg.homepage}/blob/main/LICENSE` },
- })
- .addExternalDocs({ url: pkg.homepage, description: "Homepage" })
- .addSecurityScheme("auth-key", { type: "http", scheme: "bearer" })
- .addPath("/supply", {
- get: {
- tags: [TAGS.USAGE],
- summary: "Antelope tokens latest finalized supply",
- parameters: [
- parameterString("contract"),
- parameterString("issuer"),
- parameterString("symbol"),
- ...amountFilter,
- ...timestampFilter,
- ...blockFilter,
- parameterLimit,
- parameterOffset,
- ],
- responses: {
- 200: { description: "Latest finalized supply", content: { "application/json": { example: supply_example, schema: { type: "array" } } } },
- 400: { description: "Bad request" },
- },
- },
- })
- .addPath("/balance", {
- get: {
- tags: [TAGS.USAGE],
- summary: "Antelope tokens latest finalized balance change",
- parameters: [
- parameterString("account"),
- parameterString("contract"),
- ...amountFilter,
- ...timestampFilter,
- ...blockFilter,
- parameterLimit,
- parameterOffset,
- ],
- responses: {
- 200: { description: "Latest finalized balance change", content: { "application/json": { example: balance_example, schema: { type: "array" } } } },
- 400: { description: "Bad request" },
- },
- },
- }).addPath("/transfers", {
- get: {
- tags: [TAGS.USAGE],
- summary: "Antelope tokens transfers",
- parameters: [
- parameterString("contract"),
- parameterString("from"),
- parameterString("to"),
- parameterString("transaction_id"),
- ...amountFilter,
- ...timestampFilter,
- ...blockFilter,
- parameterLimit,
- parameterOffset,
- ],
- responses: {
- 200: { description: "Array of transfers", content: { "application/json": { example: transfers_example, schema: { type: "array" } } } },
- 400: { description: "Bad request" },
- },
- },
- })
- .addPath("/health", {
- get: {
- tags: [TAGS.HEALTH],
- summary: "Performs health checks and checks if the database is accessible",
- responses: { 200: { description: "OK", content: { "text/plain": { example: "OK" } } } },
- },
- })
- .addPath("/head", {
- get: {
- tags: [TAGS.MONITORING],
- summary: "Information about the current head block in the database",
- responses: { 200: { description: "Information about the current head block in the database", content: { "application/json": { example: head_example } } } },
- },
- })
- .addPath("/metrics", {
- get: {
- tags: [TAGS.MONITORING],
- summary: "Prometheus metrics for the API",
- responses: { 200: { description: "Prometheus metrics for the API", content: { "text/plain": { example: await registry.metrics(), schema: { type: "string" } } } } },
- },
- })
- .addPath("/openapi", {
- get: {
- tags: [TAGS.DOCS],
- summary: "OpenAPI JSON specification",
- responses: { 200: { description: "OpenAPI JSON specification", content: { "application/json": {} } } },
- },
- })
- .addPath("/version", {
- get: {
- tags: [TAGS.DOCS],
- summary: "API version",
- responses: { 200: { description: "API version and commit hash", content: { "application/json": {} } } },
- },
- })
- .getSpecAsJson();
\ No newline at end of file
diff --git a/src/fetch/supply.ts b/src/fetch/supply.ts
deleted file mode 100644
index 4671aeb..0000000
--- a/src/fetch/supply.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { makeQuery } from "../clickhouse/makeQuery.js";
-import { logger } from "../logger.js";
-import { getTotalSupply } from "../queries.js";
-import { APIError, addMetadata, toJSON } from "./utils.js";
-import { parseLimit, parsePage } from "../utils.js";
-
-function verifyParams(searchParams: URLSearchParams) {
- const contract = searchParams.get("contract");
- const issuer = searchParams.get("issuer");
-
- if (!issuer && !contract) throw new Error("issuer or contract is required");
-}
-
-export default async function (req: Request) {
- const { pathname, searchParams } = new URL(req.url);
- logger.trace("\n", { searchParams: Object.fromEntries(Array.from(searchParams)) });
-
- try {
- verifyParams(searchParams);
- } catch (e: any) {
- return APIError(pathname, 400, "bad_query_input", e.message);
- }
-
- const query = getTotalSupply(searchParams);
- let response;
-
- try {
- response = await makeQuery(query);
- } catch (e: any) {
- return APIError(pathname, 500, "failed_database_query", e.message);
- }
-
- try {
- return toJSON(
- addMetadata(
- response,
- parseLimit(searchParams.get("limit")),
- parsePage(searchParams.get("page"))
- )
- );
- } catch (e: any) {
- return APIError(pathname, 500, "failed_response", e.message);
- }
-}
\ No newline at end of file
diff --git a/src/fetch/transfers.ts b/src/fetch/transfers.ts
deleted file mode 100644
index 622892a..0000000
--- a/src/fetch/transfers.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { makeQuery } from "../clickhouse/makeQuery.js";
-import { logger } from "../logger.js";
-import { getTransfers } from "../queries.js";
-import { APIError, addMetadata, toJSON } from "./utils.js";
-import { parseLimit, parsePage } from "../utils.js";
-
-export default async function (req: Request) {
- const { pathname, searchParams } = new URL(req.url);
- logger.trace("\n", { searchParams: Object.fromEntries(Array.from(searchParams)) });
-
- const query = getTransfers(searchParams);
- let response;
-
- try {
- response = await makeQuery(query);
- } catch (e: any) {
- return APIError(pathname, 500, "failed_database_query", e.message);
- }
-
- try {
- return toJSON(
- addMetadata(
- response,
- parseLimit(searchParams.get("limit")),
- parsePage(searchParams.get("page"))
- )
- );
- } catch (e: any) {
- return APIError(pathname, 500, "failed_response", e.message);
- }
-}
\ No newline at end of file
diff --git a/src/fetch/utils.spec.ts b/src/fetch/utils.spec.ts
deleted file mode 100644
index fe1f8f9..0000000
--- a/src/fetch/utils.spec.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { expect, test } from "bun:test";
-import { addMetadata } from "./utils.js";
-import { Query } from "../clickhouse/makeQuery.js";
-
-const limit = 5;
-const mock_query_reponse: Query = {
- meta: [],
- data: Array(limit),
- rows: limit,
- rows_before_limit_at_least: 5 * limit, // Simulate query with more total results than the query limit making pagination relevant
- statistics: {
- elapsed: 0,
- rows_read: 0,
- bytes_read: 0,
- }
-};
-
-test("addMetadata pagination", () => {
- const first_page = addMetadata(mock_query_reponse, limit, 1);
- expect(first_page.meta.next_page).toBe(2);
- expect(first_page.meta.previous_page).toBe(1); // Previous page should be set to 1 on first page
- expect(first_page.meta.total_pages).toBe(5);
- expect(first_page.meta.total_results).toBe(5 * limit);
-
- const odd_page = addMetadata(mock_query_reponse, limit, 3);
- expect(odd_page.meta.next_page).toBe(4);
- expect(odd_page.meta.previous_page).toBe(2);
- expect(odd_page.meta.total_pages).toBe(5);
- expect(odd_page.meta.total_results).toBe(5 * limit);
-
- const even_page = addMetadata(mock_query_reponse, limit, 4);
- expect(even_page.meta.next_page).toBe(5);
- expect(even_page.meta.previous_page).toBe(3);
- expect(even_page.meta.total_pages).toBe(5);
- expect(even_page.meta.total_results).toBe(5 * limit);
-
- const last_page = addMetadata(mock_query_reponse, limit, 5);
- // @ts-ignore
- expect(last_page.meta.next_page).toBe(last_page.meta.total_pages); // Next page should be capped to total_pages on last page
- expect(last_page.meta.previous_page).toBe(4);
- expect(last_page.meta.total_pages).toBe(5);
- expect(last_page.meta.total_results).toBe(5 * limit);
-
- // Expect error message on beyond last page
- expect(() => addMetadata(mock_query_reponse, limit, limit + 1)).toThrow(`Requested page (${limit + 1}) exceeds total pages (${limit})`);
-});
-
-test("addMetadata no pagination", () => {
- const no_pagination = addMetadata(mock_query_reponse);
-
- expect(no_pagination.meta.next_page).toBeUndefined();
- expect(no_pagination.meta.previous_page).toBeUndefined();
- expect(no_pagination.meta.total_pages).toBeUndefined();
- expect(no_pagination.meta.total_results).toBeUndefined();
-});
\ No newline at end of file
diff --git a/src/fetch/utils.ts b/src/fetch/utils.ts
deleted file mode 100644
index 1cbf5af..0000000
--- a/src/fetch/utils.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Query } from "../clickhouse/makeQuery.js";
-import { logger } from "../logger.js";
-import * as prometheus from "../prometheus.js";
-
-interface APIError {
- status: number,
- code?: string,
- detail?: string;
-}
-
-export function APIError(pathname: string, status: number, code?: string, detail?: string) {
- const api_error: APIError = {
- status,
- code: code ? code : "unknown",
- detail: detail ? detail : ""
- };
-
- logger.error("\n", api_error);
- prometheus.request_error.inc({ pathname, status });
- return toJSON(api_error, status);
-}
-
-export function toJSON(data: any, status: number = 200) {
- return new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" } });
-}
-
-export function addMetadata(response: Query, req_limit?: number, req_page?: number) {
- if (typeof (req_limit) !== 'undefined' && typeof (req_page) !== 'undefined') {
- const total_pages = Math.max(Math.ceil(response.rows_before_limit_at_least / req_limit), 1); // Always have a least one total page
-
- if (req_page > total_pages)
- throw Error(`Requested page (${req_page}) exceeds total pages (${total_pages})`);
-
- return {
- data: response.data,
- meta: {
- statistics: response.statistics,
- next_page: (req_page * req_limit >= response.rows_before_limit_at_least) ? req_page : req_page + 1,
- previous_page: (req_page <= 1) ? req_page : req_page - 1,
- total_pages,
- total_results: response.rows_before_limit_at_least
- }
- };
- } else {
- return {
- data: response.data,
- meta: {
- statistics: response.statistics,
- }
- };
- }
-}
\ No newline at end of file
diff --git a/src/logger.ts b/src/logger.ts
index f6b718f..3f142c3 100644
--- a/src/logger.ts
+++ b/src/logger.ts
@@ -5,7 +5,7 @@ class TsLogger extends Logger {
constructor() {
super();
this.settings.minLevel = 5;
- this.settings.name = `${APP_NAME}:${APP_VERSION}`;
+ this.settings.name = `${APP_NAME}:${APP_VERSION.version}+${APP_VERSION.commit}`;
}
public enable(type: "pretty" | "json" = "pretty") {
diff --git a/src/prometheus.ts b/src/prometheus.ts
index f86e91c..baa906f 100644
--- a/src/prometheus.ts
+++ b/src/prometheus.ts
@@ -30,7 +30,7 @@ export function registerGauge(name: string, help = "help", labelNames: string[]
export async function getSingleMetric(name: string) {
const metric = registry.getSingleMetric(name);
const get = await metric?.get();
- return get?.values[0].value;
+ return get?.values[0]?.value;
}
// REST API metrics
diff --git a/src/queries.spec.ts b/src/queries.spec.ts
deleted file mode 100644
index 7351e14..0000000
--- a/src/queries.spec.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import { expect, test } from "bun:test";
-import {
- getTotalSupply,
- getBalanceChanges,
- addTimestampBlockFilter,
- getTransfers,
- addAmountFilter,
-} from "./queries.js";
-
-const contract = "eosio.token";
-const account = "push.sx";
-const limit = "1";
-const symbol = "EOS";
-const issuer = "test";
-const greater_or_equals_by_timestamp = "1697587200";
-const less_or_equals_by_timestamp = "1697587100";
-const transaction_id =
- "ab3612eed62a184eed2ae86bcad766183019cf40f82e5316f4d7c4e61f4baa44";
-
-function formatSQL(query: string) {
- return query.replace(/\s+/g, "");
-}
-
-test("addTimestampBlockFilter", () => {
- let where: any[] = [];
- const searchParams = new URLSearchParams({
- greater_or_equals_by_timestamp: "1697587200",
- less_or_equals_by_timestamp: "1697587100",
- greater_or_equals_by_block: "123",
- less_or_equals_by_block: "123",
- });
- addTimestampBlockFilter(searchParams, where);
-
- expect(where).toContain("block_num >= 123");
- expect(where).toContain("block_num <= 123");
- expect(where).toContain("toUnixTimestamp(timestamp) >= 1697587200");
- expect(where).toContain("toUnixTimestamp(timestamp) <= 1697587100");
-});
-
-test("addAmountFilter", () => {
- let where: any[] = [];
- const searchParams = new URLSearchParams({
- amount_greater_or_equals: "123123",
- amount_less_or_equals: "123123",
- amount_greater: "2323",
- amount_less: "2332",
- });
- addAmountFilter(searchParams, where);
-
- expect(where).toContain("amount >= 123123");
- expect(where).toContain("amount <= 123123");
- expect(where).toContain("amount > 2323");
- expect(where).toContain("amount < 2332");
-});
-
-test("getTotalSupply", () => {
- const parameters = new URLSearchParams({ contract });
- const query = formatSQL(getTotalSupply(parameters));
-
- expect(query).toContain(formatSQL('SELECT *, updated_at_block_num AS block_num, updated_at_timestamp AS timestamp FROM token_supplies'));
- expect(query).toContain(
- formatSQL(
- `WHERE(contract == '${contract}')`
- )
- );
- expect(query).toContain(formatSQL(`ORDER BY block_num DESC`));
- expect(query).toContain(formatSQL(`LIMIT 1`));
-});
-
-test("getTotalSupply with options", () => {
- const parameters = new URLSearchParams({
- contract,
- symbol,
- greater_or_equals_by_timestamp,
- less_or_equals_by_timestamp,
- issuer,
- limit,
- });
-
- expect(formatSQL(getTotalSupply(parameters))).toContain(
- formatSQL(
- `WHERE(contract == '${contract}'
- AND symcode == '${symbol}' AND issuer == '${issuer}'
- AND toUnixTimestamp(timestamp) >= ${greater_or_equals_by_timestamp}
- AND toUnixTimestamp(timestamp) <= ${less_or_equals_by_timestamp})`
- )
- );
-});
-
-test("getBalanceChange", () => {
- const parameters = new URLSearchParams({ account, contract });
- const query = formatSQL(getBalanceChanges(parameters));
-
- expect(query).toContain(formatSQL(`SELECT *, updated_at_block_num AS block_num, updated_at_timestamp AS timestamp FROM account_balances`));
- expect(query).toContain(
- formatSQL(
- `WHERE(account == '${account}' AND contract == '${contract}')`
- )
- );
- expect(query).toContain(formatSQL(`ORDER BY block_num DESC`));
- expect(query).toContain(formatSQL(`LIMIT 1`));
-});
-
-test("getBalanceChanges with options", () => {
- const parameters = new URLSearchParams({
- account,
- transaction_id,
- greater_or_equals_by_timestamp,
- less_or_equals_by_timestamp,
- limit,
- });
-
- expect(formatSQL(getBalanceChanges(parameters))).toContain(
- formatSQL(
- `WHERE(account == '${account}'
- AND toUnixTimestamp(timestamp) >= ${greater_or_equals_by_timestamp}
- AND toUnixTimestamp(timestamp) <= ${less_or_equals_by_timestamp})`
- )
- );
-});
-
-test("getTransfers", () => {
- const parameters = new URLSearchParams({ contract, from: account, to: account, transaction_id });
- const query = formatSQL(getTransfers(parameters));
-
- expect(query).toContain(
- formatSQL(`SELECT *`)
- );
- expect(query).toContain(
- formatSQL(
- `WHERE(contract == '${contract}'
- AND from == '${account}' AND to == '${account}' AND trx_id == '${transaction_id}')`
- )
- );
- expect(query).toContain(formatSQL(`ORDER BY block_num DESC`));
- expect(query).toContain(formatSQL(`LIMIT 1`));
-});
\ No newline at end of file
diff --git a/src/queries.ts b/src/queries.ts
deleted file mode 100644
index a27cd87..0000000
--- a/src/queries.ts
+++ /dev/null
@@ -1,163 +0,0 @@
-import { DEFAULT_SORT_BY, config } from "./config.js";
-import { parseLimit, parsePage, parseTimestamp } from "./utils.js";
-
-// For reference on Clickhouse Database tables:
-// https://raw.githubusercontent.com/pinax-network/substreams-antelope-tokens/main/schema.sql
-
-// Query for count of unique token holders grouped by token (contract, symcode) pairs
-/*
-SELECT
- Count(*),
- contract,
- symcode
-FROM
- (
- SELECT
- DISTINCT account,
- contract,
- symcode
- FROM
- eos_tokens_v1.account_balances FINAL
- )
-GROUP BY
- (contract, symcode)
-order BY
- (contract, symcode) ASC
-*/
-export function addTimestampBlockFilter(searchParams: URLSearchParams, where: any[]) {
- const operators = [
- ["greater_or_equals", ">="],
- ["greater", ">"],
- ["less_or_equals", "<="],
- ["less", "<"],
- ];
-
- for (const [key, operator] of operators) {
- const block_number = searchParams.get(`${key}_by_block`);
- const timestamp = parseTimestamp(searchParams.get(`${key}_by_timestamp`));
-
- if (block_number) where.push(`block_num ${operator} ${block_number}`);
- if (timestamp) where.push(`toUnixTimestamp(timestamp) ${operator} ${timestamp}`);
- }
-}
-
-export function addAmountFilter(searchParams: URLSearchParams, where: any[]) {
- const operators = [
- ["greater_or_equals", ">="],
- ["greater", ">"],
- ["less_or_equals", "<="],
- ["less", "<"],
- ];
-
- for (const [key, operator] of operators) {
- const amount = searchParams.get(`amount_${key}`);
-
- if (amount) where.push(`amount ${operator} ${amount}`);
- }
-}
-
-export function getTotalSupply(searchParams: URLSearchParams, example?: boolean) {
- //const chain = searchParams.get("chain");
- const contract = searchParams.get("contract");
- const symbol = searchParams.get("symbol");
- const issuer = searchParams.get("issuer");
-
- let query = 'SELECT *, updated_at_block_num AS block_num, updated_at_timestamp AS timestamp FROM token_supplies';
-
- if (!example) {
- // WHERE statements
- const where = [];
-
- //if (chain) where.push(`chain == '${chain}'`);
- if (contract) where.push(`contract == '${contract}'`);
- if (symbol) where.push(`symcode == '${symbol}'`);
- if (issuer) where.push(`issuer == '${issuer}'`);
-
- addAmountFilter(searchParams, where);
- addTimestampBlockFilter(searchParams, where);
-
- if (where.length) query += ` FINAL WHERE(${where.join(' AND ')})`;
-
- const sort_by = searchParams.get("sort_by");
- query += ` ORDER BY block_num ${sort_by ?? DEFAULT_SORT_BY} `;
- }
-
- const limit = parseLimit(searchParams.get("limit"));
- if (limit) query += ` LIMIT ${limit}`;
-
- const page = parsePage(searchParams.get("page"));
- if (page) query += ` OFFSET ${limit * (page - 1)} `;
-
- return query;
-}
-
-export function getBalanceChanges(searchParams: URLSearchParams, example?: boolean) {
- const contract = searchParams.get("contract");
- const account = searchParams.get("account");
-
- let query = 'SELECT *, updated_at_block_num AS block_num, updated_at_timestamp AS timestamp FROM account_balances';
-
- if (!example) {
- // WHERE statements
- const where = [];
-
- //if (chain) where.push(`chain == '${chain}'`);
- if (account) where.push(`account == '${account}'`);
- if (contract) where.push(`contract == '${contract}'`);
-
- addAmountFilter(searchParams, where);
- addTimestampBlockFilter(searchParams, where);
-
- if (where.length) query += ` WHERE(${where.join(' AND ')})`;
-
- if (contract && account) query += ` ORDER BY block_num DESC`;
- //if (!contract && account) query += `GROUP BY (contract, account) ORDER BY timestamp DESC`;
- //if (contract && !account) query += `GROUP BY (contract, account) ORDER BY timestamp DESC`;
- }
-
- const limit = parseLimit(searchParams.get("limit"));
- if (limit) query += ` LIMIT ${limit}`;
-
- const page = parsePage(searchParams.get("page"));
- if (page) query += ` OFFSET ${limit * (page - 1)} `;
-
- return query;
-}
-
-export function getTransfers(searchParams: URLSearchParams, example?: boolean) {
- const contract = searchParams.get("contract");
- const from = searchParams.get("from");
- const to = searchParams.get("to");
- const transaction_id = searchParams.get("transaction_id");
-
- let query = "SELECT * FROM ";
-
- if (from && !to) query += "transfers_from"
- else if (!from && to) query += "transfers_to"
- else query += "transfers_block_num"
-
- if (!example) {
- // WHERE statements
- const where = [];
-
- if (contract) where.push(`contract == '${contract}'`);
- if (from) where.push(`from == '${from}'`);
- if (to) where.push(`to == '${to}'`);
- if (transaction_id) where.push(`trx_id == '${transaction_id}'`);
-
- addAmountFilter(searchParams, where);
- addTimestampBlockFilter(searchParams, where);
-
- if (where.length) query += ` WHERE(${where.join(' AND ')})`;
-
- query += ` ORDER BY block_num DESC`;
- }
-
- const limit = parseLimit(searchParams.get("limit"));
- if (limit) query += ` LIMIT ${limit}`;
-
- const page = parsePage(searchParams.get("page"));
- if (page) query += ` OFFSET ${limit * (page - 1)} `;
-
- return query;
-}
diff --git a/src/types.d.ts b/src/types.d.ts
deleted file mode 100644
index 8fc1e89..0000000
--- a/src/types.d.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-declare module "*.png" {
- const content: string;
- export default content;
-}
-
-declare module "*.html" {
- const content: string;
- export default content;
-}
-
-declare module "*.sql" {
- const content: string;
- export default content;
-}
\ No newline at end of file
diff --git a/src/types/README.md b/src/types/README.md
new file mode 100644
index 0000000..cbd2004
--- /dev/null
+++ b/src/types/README.md
@@ -0,0 +1,10 @@
+### `zod.gen.ts`
+
+> [!WARNING]
+> **DO NOT EDIT**: Auto-generated [Zod](https://zod.dev/) schemas definitions from the [OpenAPI3](../tsp-output/@typespec/openapi3/openapi.json) specification using [`typed-openapi`](https://github.com/astahmer/typed-openapi/).
+
+Use `bun run types` to run the code generation for Zod schemas.
+
+### `api.ts`
+
+Utility types based on the generated Zod schemas.
\ No newline at end of file
diff --git a/src/types/api.ts b/src/types/api.ts
new file mode 100644
index 0000000..44d9344
--- /dev/null
+++ b/src/types/api.ts
@@ -0,0 +1,18 @@
+import z from "zod";
+
+import type { GetEndpoints } from './zod.gen.js';
+
+export type EndpointReturnTypes = E extends UsageEndpoints ? UsageResponse["data"] : z.infer;
+export type EndpointParameters = z.infer;
+
+// Usage endpoints interacts with the database
+export type UsageEndpoints = Exclude;
+export type UsageResponse = z.infer;
+
+export type ValidUserParams = { path: unknown; } extends EndpointParameters ?
+ // Combine path and query parameters only if path exists to prevent "never" on intersection
+ Extract, { query: unknown; }>["query"] & Extract, { path: unknown; }>["path"]
+ :
+ Extract, { query: unknown; }>["query"];
+// Allow any valid parameters from the endpoint to be used as SQL query parameters with the addition of the `OFFSET` for pagination
+export type ValidQueryParams = ValidUserParams & { offset?: number; };
\ No newline at end of file
diff --git a/src/types/zod.gen.ts b/src/types/zod.gen.ts
new file mode 100644
index 0000000..c668b6e
--- /dev/null
+++ b/src/types/zod.gen.ts
@@ -0,0 +1,383 @@
+import z from "zod";
+
+export type APIError = z.infer;
+export const APIError = z.object({
+ status: z.union([
+ z.literal(500),
+ z.literal(504),
+ z.literal(400),
+ z.literal(401),
+ z.literal(403),
+ z.literal(404),
+ z.literal(405),
+ ]),
+ code: z.union([
+ z.literal("bad_database_response"),
+ z.literal("bad_header"),
+ z.literal("missing_required_header"),
+ z.literal("bad_query_input"),
+ z.literal("database_timeout"),
+ z.literal("forbidden"),
+ z.literal("internal_server_error"),
+ z.literal("method_not_allowed"),
+ z.literal("route_not_found"),
+ z.literal("unauthorized"),
+ ]),
+ message: z.string(),
+});
+
+export type BalanceChange = z.infer;
+export const BalanceChange = z.object({
+ trx_id: z.string(),
+ action_index: z.number(),
+ contract: z.string(),
+ symcode: z.string(),
+ precision: z.number(),
+ amount: z.number(),
+ value: z.number(),
+ block_num: z.number(),
+ timestamp: z.number(),
+ account: z.string(),
+ balance: z.string(),
+ balance_delta: z.number(),
+});
+
+export type Holder = z.infer;
+export const Holder = z.object({
+ account: z.string(),
+ balance: z.number(),
+});
+
+export type Pagination = z.infer;
+export const Pagination = z.object({
+ next_page: z.number(),
+ previous_page: z.number(),
+ total_pages: z.number(),
+ total_results: z.number(),
+});
+
+export type QueryStatistics = z.infer;
+export const QueryStatistics = z.object({
+ elapsed: z.number(),
+ rows_read: z.number(),
+ bytes_read: z.number(),
+});
+
+export type ResponseMetadata = z.infer;
+export const ResponseMetadata = z.object({
+ statistics: z.union([QueryStatistics, z.null()]),
+ next_page: z.number(),
+ previous_page: z.number(),
+ total_pages: z.number(),
+ total_results: z.number(),
+});
+
+export type Supply = z.infer;
+export const Supply = z.object({
+ trx_id: z.string(),
+ action_index: z.number(),
+ contract: z.string(),
+ symcode: z.string(),
+ precision: z.number(),
+ amount: z.number(),
+ value: z.number(),
+ block_num: z.number(),
+ timestamp: z.number(),
+ issuer: z.string(),
+ max_supply: z.string(),
+ supply: z.string(),
+ supply_delta: z.number(),
+});
+
+export type Transfer = z.infer;
+export const Transfer = z.object({
+ trx_id: z.string(),
+ action_index: z.number(),
+ contract: z.string(),
+ symcode: z.string(),
+ precision: z.number(),
+ amount: z.number(),
+ value: z.number(),
+ block_num: z.number(),
+ timestamp: z.number(),
+ from: z.string(),
+ to: z.string(),
+ quantity: z.string(),
+ memo: z.string(),
+});
+
+export type Version = z.infer;
+export const Version = z.object({
+ version: z.string(),
+ commit: z.string(),
+});
+
+export type get_Usage_balance = typeof get_Usage_balance;
+export const get_Usage_balance = {
+ method: z.literal("GET"),
+ path: z.literal("/balance"),
+ parameters: z.object({
+ query: z.object({
+ block_num: z.union([z.number(), z.undefined()]),
+ contract: z.union([z.string(), z.undefined()]),
+ symcode: z.union([z.string(), z.undefined()]),
+ account: z.string(),
+ limit: z.union([z.number(), z.undefined()]),
+ page: z.union([z.number(), z.undefined()]),
+ }),
+ }),
+ response: z.object({
+ data: z.array(BalanceChange),
+ meta: ResponseMetadata,
+ }),
+};
+
+export type get_Usage_head = typeof get_Usage_head;
+export const get_Usage_head = {
+ method: z.literal("GET"),
+ path: z.literal("/head"),
+ parameters: z.object({
+ query: z.object({
+ limit: z.number().optional(),
+ page: z.number().optional(),
+ }),
+ }),
+ response: z.object({
+ data: z.array(
+ z.object({
+ block_num: z.number(),
+ }),
+ ),
+ meta: ResponseMetadata,
+ }),
+};
+
+export type get_Monitoring_health = typeof get_Monitoring_health;
+export const get_Monitoring_health = {
+ method: z.literal("GET"),
+ path: z.literal("/health"),
+ parameters: z.never(),
+ response: z.string(),
+};
+
+export type get_Usage_holders = typeof get_Usage_holders;
+export const get_Usage_holders = {
+ method: z.literal("GET"),
+ path: z.literal("/holders"),
+ parameters: z.object({
+ query: z.object({
+ contract: z.string(),
+ symcode: z.string(),
+ limit: z.union([z.number(), z.undefined()]),
+ page: z.union([z.number(), z.undefined()]),
+ }),
+ }),
+ response: z.object({
+ data: z.array(Holder),
+ meta: ResponseMetadata,
+ }),
+};
+
+export type get_Monitoring_metrics = typeof get_Monitoring_metrics;
+export const get_Monitoring_metrics = {
+ method: z.literal("GET"),
+ path: z.literal("/metrics"),
+ parameters: z.never(),
+ response: z.string(),
+};
+
+export type get_Docs_openapi = typeof get_Docs_openapi;
+export const get_Docs_openapi = {
+ method: z.literal("GET"),
+ path: z.literal("/openapi"),
+ parameters: z.never(),
+ response: z.unknown(),
+};
+
+export type get_Usage_supply = typeof get_Usage_supply;
+export const get_Usage_supply = {
+ method: z.literal("GET"),
+ path: z.literal("/supply"),
+ parameters: z.object({
+ query: z.object({
+ block_num: z.union([z.number(), z.undefined()]),
+ issuer: z.union([z.string(), z.undefined()]),
+ contract: z.string(),
+ symcode: z.string(),
+ limit: z.union([z.number(), z.undefined()]),
+ page: z.union([z.number(), z.undefined()]),
+ }),
+ }),
+ response: z.object({
+ data: z.array(Supply),
+ meta: ResponseMetadata,
+ }),
+};
+
+export type get_Usage_tokens = typeof get_Usage_tokens;
+export const get_Usage_tokens = {
+ method: z.literal("GET"),
+ path: z.literal("/tokens"),
+ parameters: z.object({
+ query: z.object({
+ limit: z.number().optional(),
+ page: z.number().optional(),
+ }),
+ }),
+ response: z.object({
+ data: z.array(Supply),
+ meta: ResponseMetadata,
+ }),
+};
+
+export type get_Usage_transfers = typeof get_Usage_transfers;
+export const get_Usage_transfers = {
+ method: z.literal("GET"),
+ path: z.literal("/transfers"),
+ parameters: z.object({
+ query: z.object({
+ block_range: z.array(z.number()).optional(),
+ from: z.string().optional(),
+ to: z.string().optional(),
+ contract: z.string().optional(),
+ symcode: z.string().optional(),
+ limit: z.number().optional(),
+ page: z.number().optional(),
+ }),
+ }),
+ response: z.object({
+ data: z.array(Transfer),
+ meta: ResponseMetadata,
+ }),
+};
+
+export type get_Usage_transfer = typeof get_Usage_transfer;
+export const get_Usage_transfer = {
+ method: z.literal("GET"),
+ path: z.literal("/transfers/{trx_id}"),
+ parameters: z.object({
+ query: z.object({
+ limit: z.number().optional(),
+ page: z.number().optional(),
+ }),
+ path: z.object({
+ trx_id: z.string(),
+ }),
+ }),
+ response: z.object({
+ data: z.array(Transfer),
+ meta: ResponseMetadata,
+ }),
+};
+
+export type get_Docs_version = typeof get_Docs_version;
+export const get_Docs_version = {
+ method: z.literal("GET"),
+ path: z.literal("/version"),
+ parameters: z.never(),
+ response: Version,
+};
+
+//
+export const EndpointByMethod = {
+ get: {
+ "/balance": get_Usage_balance,
+ "/head": get_Usage_head,
+ "/health": get_Monitoring_health,
+ "/holders": get_Usage_holders,
+ "/metrics": get_Monitoring_metrics,
+ "/openapi": get_Docs_openapi,
+ "/supply": get_Usage_supply,
+ "/tokens": get_Usage_tokens,
+ "/transfers": get_Usage_transfers,
+ "/transfers/{trx_id}": get_Usage_transfer,
+ "/version": get_Docs_version,
+ },
+};
+export type EndpointByMethod = typeof EndpointByMethod;
+//
+
+//
+export type GetEndpoints = EndpointByMethod["get"];
+export type AllEndpoints = EndpointByMethod[keyof EndpointByMethod];
+//
+
+//
+export type EndpointParameters = {
+ body?: unknown;
+ query?: Record;
+ header?: Record;
+ path?: Record;
+};
+
+export type MutationMethod = "post" | "put" | "patch" | "delete";
+export type Method = "get" | "head" | MutationMethod;
+
+export type DefaultEndpoint = {
+ parameters?: EndpointParameters | undefined;
+ response: unknown;
+};
+
+export type Endpoint = {
+ operationId: string;
+ method: Method;
+ path: string;
+ parameters?: TConfig["parameters"];
+ meta: {
+ alias: string;
+ hasParameters: boolean;
+ areParametersRequired: boolean;
+ };
+ response: TConfig["response"];
+};
+
+type Fetcher = (
+ method: Method,
+ url: string,
+ parameters?: EndpointParameters | undefined,
+) => Promise;
+
+type RequiredKeys = {
+ [P in keyof T]-?: undefined extends T[P] ? never : P;
+}[keyof T];
+
+type MaybeOptionalArg = RequiredKeys extends never ? [config?: T] : [config: T];
+
+//
+
+//
+export class ApiClient {
+ baseUrl: string = "";
+
+ constructor(public fetcher: Fetcher) {}
+
+ setBaseUrl(baseUrl: string) {
+ this.baseUrl = baseUrl;
+ return this;
+ }
+
+ //
+ get(
+ path: Path,
+ ...params: MaybeOptionalArg>
+ ): Promise> {
+ return this.fetcher("get", this.baseUrl + path, params[0]) as Promise>;
+ }
+ //
+}
+
+export function createApiClient(fetcher: Fetcher, baseUrl?: string) {
+ return new ApiClient(fetcher).setBaseUrl(baseUrl ?? "");
+}
+
+/**
+ Example usage:
+ const api = createApiClient((method, url, params) =>
+ fetch(url, { method, body: JSON.stringify(params) }).then((res) => res.json()),
+ );
+ api.get("/users").then((users) => console.log(users));
+ api.post("/users", { body: { name: "John" } }).then((user) => console.log(user));
+ api.put("/users/:id", { path: { id: 1 }, body: { name: "John" } }).then((user) => console.log(user));
+*/
+
+// TypeSpec is a language for defining cloud service APIs and shapes. TypeSpec is a highly extensible language with primitives that can describe API shapes common among REST, OpenAPI, gRPC, and other protocols.
+
+For Pinax's API projects, Typespec allows for both generating the [protobuf](./protobuf.tsp) definitions used at the *substreams* level **and** the [OpenAPI3](openapi3.tsp) specification, ensuring consistent data models for the whole pipeline.
+
+See https://typespec.io/docs to get started.
+
+## Common models
+
+The data models used for both outputs can be found in [`models.tsp`](./models.tsp).
+
+## Compiling definitions
+
+Use the `bun run types:watch` to auto-compile the definitions on file changes. Generated outputs can be found in the [`tsp-output`](/tsp-output/) folder.
+
+Typescript compiler options can be found in [`tspconfig.yaml`](/tspconfig.yaml).
\ No newline at end of file
diff --git a/src/typespec/main.tsp b/src/typespec/main.tsp
new file mode 100644
index 0000000..4eaf0f3
--- /dev/null
+++ b/src/typespec/main.tsp
@@ -0,0 +1,5 @@
+/**
+ * Main file to allow compiling for both protobuf and openapi3 specs with single command `tsp compile .`
+ */
+import "./protobuf.tsp";
+import "./openapi3.tsp";
diff --git a/src/typespec/models.tsp b/src/typespec/models.tsp
new file mode 100644
index 0000000..b33e43e
--- /dev/null
+++ b/src/typespec/models.tsp
@@ -0,0 +1,56 @@
+/**
+ * Common models used for protobuf and openapi3 outputs
+ */
+namespace Models {
+ model TraceInformation {
+ trx_id: string;
+ action_index: uint32;
+ }
+
+ model Scope {
+ contract: string;
+ symcode: string;
+ }
+
+ model Extras {
+ precision: uint32;
+ amount: int64;
+ value: float64;
+ }
+
+ // Use a generic to allow the model to represent a timestamp using different types for protobuf/openapi3
+ model BlockInfo {
+ block_num: uint64;
+ timestamp: TimestampType;
+ }
+
+ model CommonAntelope {
+ ...TraceInformation;
+ ...Scope;
+ ...Extras;
+ ...BlockInfo;
+ }
+
+ model Transfer {
+ ...CommonAntelope;
+ from: string;
+ to: string;
+ quantity: string;
+ memo: string;
+ }
+
+ model BalanceChange {
+ ...CommonAntelope;
+ account: string;
+ balance: string;
+ balance_delta: int64;
+ }
+
+ model Supply {
+ ...CommonAntelope;
+ issuer: string;
+ max_supply: string;
+ supply: string;
+ supply_delta: int64;
+ }
+}
diff --git a/src/typespec/openapi3.tsp b/src/typespec/openapi3.tsp
new file mode 100644
index 0000000..32356b1
--- /dev/null
+++ b/src/typespec/openapi3.tsp
@@ -0,0 +1,223 @@
+import "@typespec/http";
+import "./models.tsp";
+
+using TypeSpec.Http;
+
+@service({
+ title: "Antelope Token API",
+})
+namespace AntelopeTokenAPI;
+
+// Error codes adapted from https://github.com/pinax-network/golang-base/blob/develop/response/errors.go
+alias APIErrorCode =
+ | "bad_database_response" // invalid response from the database
+ | "bad_header" // invalid or malformed header given
+ | "missing_required_header" // request is missing a header
+ | "bad_query_input" // given query input is missing or malformed
+ | "database_timeout" // timeout while connecting to database
+ | "forbidden" // not allowed to access this endpoint
+ | "internal_server_error" // an unknown error occurred on the backend
+ | "method_not_allowed" // http method is not allowed on this endpoint
+ | "route_not_found" // the requested route was not found
+ | "unauthorized"; // invalid authorization information given
+
+alias ErrorStatusCode = 500 | 504 | 400 | 401 | 403 | 404 | 405;
+
+@error
+model APIError {
+ status: ErrorStatusCode;
+ code: APIErrorCode;
+ message: string;
+}
+
+// Models will be present in the OpenAPI components
+model Transfer is Models.Transfer;
+model BalanceChange is Models.BalanceChange;
+model Supply is Models.Supply;
+model Holder {
+ account: BalanceChange.account;
+ balance: BalanceChange.value;
+}
+
+model QueryStatistics {
+ elapsed: float;
+ rows_read: safeint;
+ bytes_read: safeint;
+}
+
+model Pagination {
+ next_page: safeint;
+ previous_page: safeint;
+ total_pages: safeint;
+ total_results: safeint;
+}
+
+model ResponseMetadata {
+ statistics: QueryStatistics | null;
+ ...Pagination;
+}
+
+model UsageResponse {
+ data: T;
+ meta: ResponseMetadata;
+}
+
+// Alias will *not* be present in the OpenAPI components.
+// This also helps preventing self-references in generated `components` for codegen to work properly.
+alias APIResponse = T | APIError;
+alias PaginationQueryParams = {
+ @query limit?: uint64 = 10;
+ @query page?: uint64 = 1;
+};
+
+// Helper aliases for accessing underlying properties
+alias BlockInfo = Models.BlockInfo;
+alias TokenIdentifier = Models.Scope;
+
+@tag("Usage")
+interface Usage {
+ /**
+ Balances of an account.
+ @returns Array of balances.
+ */
+ @summary("Token balance")
+ @route("/balance")
+ @get
+ balance(
+ @query block_num?: BlockInfo.block_num,
+ @query contract?: TokenIdentifier.contract,
+ @query symcode?: TokenIdentifier.symcode,
+ @query account: BalanceChange.account,
+ ...PaginationQueryParams,
+ ): APIResponse>;
+
+ /**
+ Information about the current head block in the database.
+ @returns Array of block information.
+ */
+ @summary("Head block information")
+ @route("/head")
+ @get
+ head(...PaginationQueryParams): APIResponse>;
+
+ /**
+ List of holders of a token.
+ @returns Array of accounts.
+ */
+ @summary("Token holders")
+ @route("/holders")
+ @get
+ holders(
+ @query contract: TokenIdentifier.contract,
+ @query symcode: TokenIdentifier.symcode,
+ ...PaginationQueryParams,
+ ): APIResponse>;
+
+ /**
+ Total supply for a token.
+ @returns Array of supplies.
+ */
+ @summary("Token supply")
+ @route("/supply")
+ @get
+ supply(
+ @query block_num?: BlockInfo.block_num,
+ @query issuer?: Supply.issuer,
+ @query contract: TokenIdentifier.contract,
+ @query symcode: TokenIdentifier.symcode,
+ ...PaginationQueryParams,
+ ): APIResponse>;
+
+ /**
+ List of available tokens.
+ @returns Array of supplies.
+ */
+ @summary("Tokens")
+ @route("/tokens")
+ @get
+ tokens(...PaginationQueryParams): APIResponse>;
+
+ /**
+ All transfers related to a token.
+ @returns Array of transfers.
+ */
+ @summary("Token transfers")
+ @route("/transfers")
+ @get
+ transfers(
+ @query({
+ format: "csv",
+ })
+ block_range?: BlockInfo.block_num[],
+
+ @query from?: Transfer.from,
+ @query to?: Transfer.to,
+ @query contract?: TokenIdentifier.contract,
+ @query symcode?: TokenIdentifier.symcode,
+ ...PaginationQueryParams,
+ ): APIResponse>;
+
+ /**
+ Specific transfer related to a token.
+ @returns Array of transfers.
+ */
+ @summary("Token transfer")
+ @route("/transfers/{trx_id}")
+ @get
+ transfer(
+ @path trx_id: Models.TraceInformation.trx_id,
+ ...PaginationQueryParams,
+ ): APIResponse>;
+}
+
+model Version {
+ @pattern("^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$") // Adapted from https://semver.org/
+ version: string;
+
+ @pattern("^[0-9a-f]{7}$")
+ commit: string;
+}
+
+@tag("Docs")
+interface Docs {
+ /**
+ Reflection endpoint to return OpenAPI JSON spec. Also used by Swagger to generate the frontpage.
+ @returns The OpenAPI JSON spec
+ */
+ @summary("OpenAPI JSON spec")
+ @route("/openapi")
+ @get
+ openapi(): APIResponse>;
+
+ /**
+ API version and Git short commit hash.
+ @returns The API version and commit hash.
+ */
+ @summary("API version")
+ @route("/version")
+ @get
+ version(): APIResponse;
+}
+
+@tag("Monitoring")
+interface Monitoring {
+ /**
+ Checks database connection.
+ @returns OK or APIError.
+ */
+ @summary("Health check")
+ @route("/health")
+ @get
+ health(): APIResponse;
+
+ /**
+ Prometheus metrics.
+ @returns Metrics as text.
+ */
+ @summary("Prometheus metrics")
+ @route("/metrics")
+ @get
+ metrics(): string;
+}
diff --git a/src/typespec/protobuf.tsp b/src/typespec/protobuf.tsp
new file mode 100644
index 0000000..9bfc8bd
--- /dev/null
+++ b/src/typespec/protobuf.tsp
@@ -0,0 +1,43 @@
+import "@typespec/protobuf";
+import "./models.tsp";
+
+using TypeSpec.Protobuf;
+
+@package({
+ name: "antelope.eosio.token.v1",
+})
+namespace AntelopeTokensV1;
+
+// `is` or `extends` syntax doesn't work here, see https://github.com/microsoft/typespec/issues/3266
+model Transfer {
+ ...Models.Transfer;
+}
+@@field(Transfer.trx_id, 1);
+@@field(Transfer.action_index, 2);
+@@field(Transfer.contract, 3);
+@@field(Transfer.symcode, 4);
+@@field(Transfer.from, 5);
+@@field(Transfer.to, 6);
+@@field(Transfer.quantity, 7);
+@@field(Transfer.memo, 8);
+@@field(Transfer.precision, 9);
+@@field(Transfer.amount, 10);
+@@field(Transfer.value, 11);
+@@field(Transfer.block_num, 12);
+@@field(Transfer.timestamp, 13);
+
+model BalanceChange {
+ ...Models.BalanceChange;
+}
+@@field(BalanceChange.trx_id, 1);
+@@field(BalanceChange.action_index, 2);
+@@field(BalanceChange.contract, 3);
+@@field(BalanceChange.symcode, 4);
+@@field(BalanceChange.account, 5);
+@@field(BalanceChange.balance, 6);
+@@field(BalanceChange.balance_delta, 7);
+@@field(BalanceChange.precision, 8);
+@@field(BalanceChange.amount, 9);
+@@field(BalanceChange.value, 10);
+@@field(BalanceChange.block_num, 11);
+@@field(BalanceChange.timestamp, 12);
diff --git a/src/usage.ts b/src/usage.ts
new file mode 100644
index 0000000..1d625a0
--- /dev/null
+++ b/src/usage.ts
@@ -0,0 +1,104 @@
+import { makeQuery } from "./clickhouse/makeQuery.js";
+import { APIErrorResponse } from "./utils.js";
+
+import type { Context } from "hono";
+import type { EndpointReturnTypes, UsageEndpoints, UsageResponse, ValidUserParams } from "./types/api.js";
+
+export async function makeUsageQuery(ctx: Context, endpoint: UsageEndpoints, user_params: ValidUserParams) {
+ type EndpointElementReturnType = EndpointReturnTypes[number];
+
+ let { page, ...query_params } = user_params;
+
+ if (!query_params.limit)
+ query_params.limit = 10;
+
+ if (!page)
+ page = 1;
+
+ let query = "";
+ if (endpoint == "/balance" || endpoint == "/supply") {
+ // Need to narrow the type of `query_params` explicitly to access properties based on endpoint value
+ // See https://github.com/microsoft/TypeScript/issues/33014
+ const q = query_params as ValidUserParams;
+ if (q.block_num)
+ query +=
+ `SELECT *`
+ + ` FROM ${endpoint == "/balance" ? "balance_change_events" : "supply_change_events"}`
+ + ` FINAL`;
+ else
+ query +=
+ `SELECT *, updated_at_block_num AS block_num, updated_at_timestamp AS timestamp`
+ + ` FROM ${endpoint == "/balance" ? "account_balances" : "token_supplies"}`
+ + ` FINAL`;
+ } else if (endpoint == "/transfers") {
+ query += `SELECT * FROM `;
+
+ const q = query_params as ValidUserParams;
+ if (q.block_range) {
+ query += `transfers_block_num `;
+ } else if (q.from) {
+ query += `transfers_from `;
+ } else if (q.to) {
+ query += `transfers_to `;
+ } else if (q.contract) {
+ query += `transfers_contract `;
+ } else {
+ query += `transfer_events `;
+ }
+
+ query += `FINAL`;
+ } else if (endpoint == "/holders") {
+ query += `SELECT DISTINCT account, value FROM eos_tokens_v1.account_balances FINAL WHERE value > 0`;
+ } else if (endpoint == "/head") {
+ query += `SELECT block_num FROM cursors`
+ } else if (endpoint == "/transfers/{trx_id}") {
+ query += `SELECT * FROM transfer_events FINAL`;
+ } else {
+ query += `SELECT DISTINCT *, updated_at_block_num AS block_num FROM eos_tokens_v1.token_supplies FINAL`;
+ }
+
+ query += endpoint == "/holders" ? " AND" : " WHERE";
+ for (const k of Object.keys(query_params).filter(k => k !== "limit")) // Don't add limit to WHERE clause
+ query += ` ${k} == {${k}: String} AND`;
+ query = query.substring(0, query.lastIndexOf(' ')); // Remove last item ` AND`
+
+ query += endpoint == "/holders" ? " ORDER BY value DESC" : " ORDER BY block_num DESC";
+ query += " LIMIT {limit: int}";
+ query += " OFFSET {offset: int}";
+
+ let query_results;
+ try {
+ query_results = await makeQuery(query, { ...query_params, offset: query_params.limit * (page - 1) });
+ } catch (err) {
+ return APIErrorResponse(ctx, 500, "bad_database_response", err);
+ }
+
+ // Always have a least one total page
+ const total_pages = Math.max(Math.ceil((query_results.rows_before_limit_at_least ?? 0) / query_params.limit), 1);
+
+ if (page > total_pages)
+ return APIErrorResponse(ctx, 400, "bad_query_input", `Requested page (${page}) exceeds total pages (${total_pages})`);
+
+ /* Solving the `data` type issue:
+ type A = string[] | number[]; // This is union of array types
+ type B = A[number][]; // This is array of elements of union type
+
+ let t: A;
+ let v: B;
+
+ t = v; // Error
+ */
+
+ return ctx.json({
+ // @ts-ignore
+ data: query_results.data,
+ meta: {
+ statistics: query_results.statistics ?? null,
+ next_page: (page * query_params.limit >= (query_results.rows_before_limit_at_least ?? 0)) ? page : page + 1,
+ previous_page: (page <= 1) ? page : page - 1,
+ total_pages,
+ total_results: query_results.rows_before_limit_at_least ?? 0
+ }
+ });
+}
+
diff --git a/src/utils.spec.ts b/src/utils.spec.ts
deleted file mode 100644
index 5b6911d..0000000
--- a/src/utils.spec.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { expect, test } from "bun:test";
-import { parseBlockId, parseLimit, parsePage, parseTimestamp } from "./utils.js";
-import { config } from "./config.js";
-
-test("parseBlockId", () => {
- expect(parseBlockId("0x123") as string).toBe("123");
-});
-
-test("parseLimit", () => {
- expect(parseLimit("1")).toBe(1);
- expect(parseLimit("0")).toBe(1);
- expect(parseLimit(10)).toBe(10);
- expect(parseLimit(config.maxLimit + 1)).toBe(config.maxLimit);
-});
-
-test("parsePage", () => {
- expect(parsePage("1")).toBe(1);
- expect(parsePage("0")).toBe(1);
- expect(parsePage(10)).toBe(10);
-});
-
-test("parseTimestamp", () => {
- expect(parseTimestamp("1697587100")).toBe(1697587100);
- expect(parseTimestamp("1697587100000")).toBe(1697587100);
- expect(parseTimestamp("awdawd")).toBeNaN();
- expect(parseTimestamp(null)).toBeUndefined();
-});
\ No newline at end of file
diff --git a/src/utils.ts b/src/utils.ts
index 83d4dd5..ce6162e 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,53 +1,29 @@
-import { config } from "./config.js";
+import { ZodError } from "zod";
-export function parseBlockId(block_id?: string | null) {
- return block_id ? block_id.replace("0x", "") : undefined;
-}
+import type { Context } from "hono";
+import type { APIError } from "./types/zod.gen.js";
+import { logger } from "./logger.js";
+import * as prometheus from "./prometheus.js";
-export function parseLimit(limit?: string | null | number, defaultLimit?: number) {
- let value = 1; // default 1
- if (defaultLimit)
- value = defaultLimit;
- if (limit) {
- if (typeof limit === "string") value = parseInt(limit);
- if (typeof limit === "number") value = limit;
- }
- // limit must be between 1 and maxLimit
- if (value <= 0) value = 1;
- if (value > config.maxLimit) value = config.maxLimit;
- return value;
-}
-
-export function parsePage(page?: string | null | number) {
- let value = 1;
+export function APIErrorResponse(ctx: Context, status: APIError["status"], code: APIError["code"], err: unknown) {
+ let message = "An unexpected error occured";
- if (page) {
- if (typeof page === "string") value = parseInt(page);
- if (typeof page === "number") value = page;
+ if (typeof err === "string") {
+ message = err;
+ } else if (err instanceof ZodError) {
+ message = err.issues.map(issue => `[${issue.code}] ${issue.path.join('/')}: ${issue.message}`).join('\n');
+ } else if (err instanceof Error) {
+ message = err.message;
}
- if (value <= 0)
- value = 1;
+ const api_error = {
+ status,
+ code,
+ message
+ };
- return value;
-}
+ logger.error(api_error);
+ prometheus.request_error.inc({ pathname: ctx.req.path, status });
-export function parseTimestamp(timestamp?: string | null | number) {
- if (timestamp !== undefined && timestamp !== null) {
- if (typeof timestamp === "string") {
- if (/^[0-9]+$/.test(timestamp)) {
- return parseTimestamp(parseInt(timestamp));
- }
- // append "Z" to timestamp if it doesn't have it
- if (!timestamp.endsWith("Z")) timestamp += "Z";
- return Math.floor(Number(new Date(timestamp)) / 1000);
- }
- if (typeof timestamp === "number") {
- const length = timestamp.toString().length;
- if (length === 10) return timestamp; // seconds
- if (length === 13) return Math.floor(timestamp / 1000); // convert milliseconds to seconds
- throw new Error("Invalid timestamp");
- }
- }
- return undefined;
-}
+ return ctx.json(api_error, status);
+}
\ No newline at end of file
diff --git a/swagger/favicon.ico b/swagger/favicon.ico
new file mode 100644
index 0000000..647590e
Binary files /dev/null and b/swagger/favicon.ico differ
diff --git a/swagger/favicon.png b/swagger/favicon.png
deleted file mode 100644
index ee33cc0..0000000
Binary files a/swagger/favicon.png and /dev/null differ
diff --git a/swagger/index.html b/swagger/index.html
index 77a6915..b17f0bf 100644
--- a/swagger/index.html
+++ b/swagger/index.html
@@ -9,7 +9,7 @@
/>
Substreams Antelope Token API - SwaggerUI
-
+
diff --git a/tsconfig.json b/tsconfig.json
index 7d8b6dd..fbfdeeb 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -13,6 +13,7 @@
"strictNullChecks": true,
"alwaysStrict": true,
"skipLibCheck": true,
+ "noUncheckedIndexedAccess": true,
"types": ["bun-types"]
}
}
diff --git a/tsp-output/@typespec/openapi3/openapi.json b/tsp-output/@typespec/openapi3/openapi.json
new file mode 100644
index 0000000..13ccea5
--- /dev/null
+++ b/tsp-output/@typespec/openapi3/openapi.json
@@ -0,0 +1,1126 @@
+{
+ "openapi": "3.0.0",
+ "info": {
+ "title": "Antelope Token API",
+ "version": "0.0.0"
+ },
+ "tags": [
+ {
+ "name": "Usage"
+ },
+ {
+ "name": "Docs"
+ },
+ {
+ "name": "Monitoring"
+ }
+ ],
+ "paths": {
+ "/balance": {
+ "get": {
+ "tags": [
+ "Usage"
+ ],
+ "operationId": "Usage_balance",
+ "summary": "Token balance",
+ "description": "Balances of an account.",
+ "parameters": [
+ {
+ "name": "block_num",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "uint64"
+ }
+ },
+ {
+ "name": "contract",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "symcode",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "account",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "uint64",
+ "default": 10
+ }
+ },
+ {
+ "name": "page",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "uint64",
+ "default": 1
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Array of balances.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "data",
+ "meta"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/BalanceChange"
+ }
+ },
+ "meta": {
+ "$ref": "#/components/schemas/ResponseMetadata"
+ }
+ }
+ }
+ }
+ }
+ },
+ "default": {
+ "description": "An unexpected error response.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/head": {
+ "get": {
+ "tags": [
+ "Usage"
+ ],
+ "operationId": "Usage_head",
+ "summary": "Head block information",
+ "description": "Information about the current head block in the database.",
+ "parameters": [
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "uint64",
+ "default": 10
+ }
+ },
+ {
+ "name": "page",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "uint64",
+ "default": 1
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Array of block information.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "data",
+ "meta"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "block_num": {
+ "type": "integer",
+ "format": "uint64"
+ }
+ },
+ "required": [
+ "block_num"
+ ]
+ }
+ },
+ "meta": {
+ "$ref": "#/components/schemas/ResponseMetadata"
+ }
+ }
+ }
+ }
+ }
+ },
+ "default": {
+ "description": "An unexpected error response.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/health": {
+ "get": {
+ "tags": [
+ "Monitoring"
+ ],
+ "operationId": "Monitoring_health",
+ "summary": "Health check",
+ "description": "Checks database connection.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "OK or APIError.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "default": {
+ "description": "An unexpected error response.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/holders": {
+ "get": {
+ "tags": [
+ "Usage"
+ ],
+ "operationId": "Usage_holders",
+ "summary": "Token holders",
+ "description": "List of holders of a token.",
+ "parameters": [
+ {
+ "name": "contract",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "symcode",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "uint64",
+ "default": 10
+ }
+ },
+ {
+ "name": "page",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "uint64",
+ "default": 1
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Array of accounts.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "data",
+ "meta"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Holder"
+ }
+ },
+ "meta": {
+ "$ref": "#/components/schemas/ResponseMetadata"
+ }
+ }
+ }
+ }
+ }
+ },
+ "default": {
+ "description": "An unexpected error response.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/metrics": {
+ "get": {
+ "tags": [
+ "Monitoring"
+ ],
+ "operationId": "Monitoring_metrics",
+ "summary": "Prometheus metrics",
+ "description": "Prometheus metrics.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "Metrics as text.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/openapi": {
+ "get": {
+ "tags": [
+ "Docs"
+ ],
+ "operationId": "Docs_openapi",
+ "summary": "OpenAPI JSON spec",
+ "description": "Reflection endpoint to return OpenAPI JSON spec. Also used by Swagger to generate the frontpage.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "The OpenAPI JSON spec",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": {}
+ }
+ }
+ }
+ },
+ "default": {
+ "description": "An unexpected error response.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/supply": {
+ "get": {
+ "tags": [
+ "Usage"
+ ],
+ "operationId": "Usage_supply",
+ "summary": "Token supply",
+ "description": "Total supply for a token.",
+ "parameters": [
+ {
+ "name": "block_num",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "uint64"
+ }
+ },
+ {
+ "name": "issuer",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "contract",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "symcode",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "uint64",
+ "default": 10
+ }
+ },
+ {
+ "name": "page",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "uint64",
+ "default": 1
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Array of supplies.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "data",
+ "meta"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Supply"
+ }
+ },
+ "meta": {
+ "$ref": "#/components/schemas/ResponseMetadata"
+ }
+ }
+ }
+ }
+ }
+ },
+ "default": {
+ "description": "An unexpected error response.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/tokens": {
+ "get": {
+ "tags": [
+ "Usage"
+ ],
+ "operationId": "Usage_tokens",
+ "summary": "Tokens",
+ "description": "List of available tokens.",
+ "parameters": [
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "uint64",
+ "default": 10
+ }
+ },
+ {
+ "name": "page",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "uint64",
+ "default": 1
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Array of supplies.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "data",
+ "meta"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Supply"
+ }
+ },
+ "meta": {
+ "$ref": "#/components/schemas/ResponseMetadata"
+ }
+ }
+ }
+ }
+ }
+ },
+ "default": {
+ "description": "An unexpected error response.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/transfers": {
+ "get": {
+ "tags": [
+ "Usage"
+ ],
+ "operationId": "Usage_transfers",
+ "summary": "Token transfers",
+ "description": "All transfers related to a token.",
+ "parameters": [
+ {
+ "name": "block_range",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "format": "uint64"
+ }
+ },
+ "style": "form",
+ "explode": false
+ },
+ {
+ "name": "from",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "to",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "contract",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "symcode",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "uint64",
+ "default": 10
+ }
+ },
+ {
+ "name": "page",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "uint64",
+ "default": 1
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Array of transfers.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "data",
+ "meta"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Transfer"
+ }
+ },
+ "meta": {
+ "$ref": "#/components/schemas/ResponseMetadata"
+ }
+ }
+ }
+ }
+ }
+ },
+ "default": {
+ "description": "An unexpected error response.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/transfers/{trx_id}": {
+ "get": {
+ "tags": [
+ "Usage"
+ ],
+ "operationId": "Usage_transfer",
+ "summary": "Token transfer",
+ "description": "Specific transfer related to a token.",
+ "parameters": [
+ {
+ "name": "trx_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "uint64",
+ "default": 10
+ }
+ },
+ {
+ "name": "page",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "format": "uint64",
+ "default": 1
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Array of transfers.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "data",
+ "meta"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Transfer"
+ }
+ },
+ "meta": {
+ "$ref": "#/components/schemas/ResponseMetadata"
+ }
+ }
+ }
+ }
+ }
+ },
+ "default": {
+ "description": "An unexpected error response.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/version": {
+ "get": {
+ "tags": [
+ "Docs"
+ ],
+ "operationId": "Docs_version",
+ "summary": "API version",
+ "description": "API version and Git short commit hash.",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "The API version and commit hash.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Version"
+ }
+ }
+ }
+ },
+ "default": {
+ "description": "An unexpected error response.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/APIError"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "APIError": {
+ "type": "object",
+ "required": [
+ "status",
+ "code",
+ "message"
+ ],
+ "properties": {
+ "status": {
+ "type": "number",
+ "enum": [
+ 500,
+ 504,
+ 400,
+ 401,
+ 403,
+ 404,
+ 405
+ ]
+ },
+ "code": {
+ "type": "string",
+ "enum": [
+ "bad_database_response",
+ "bad_header",
+ "missing_required_header",
+ "bad_query_input",
+ "database_timeout",
+ "forbidden",
+ "internal_server_error",
+ "method_not_allowed",
+ "route_not_found",
+ "unauthorized"
+ ]
+ },
+ "message": {
+ "type": "string"
+ }
+ }
+ },
+ "BalanceChange": {
+ "type": "object",
+ "required": [
+ "trx_id",
+ "action_index",
+ "contract",
+ "symcode",
+ "precision",
+ "amount",
+ "value",
+ "block_num",
+ "timestamp",
+ "account",
+ "balance",
+ "balance_delta"
+ ],
+ "properties": {
+ "trx_id": {
+ "type": "string"
+ },
+ "action_index": {
+ "type": "integer",
+ "format": "uint32"
+ },
+ "contract": {
+ "type": "string"
+ },
+ "symcode": {
+ "type": "string"
+ },
+ "precision": {
+ "type": "integer",
+ "format": "uint32"
+ },
+ "amount": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "value": {
+ "type": "number",
+ "format": "double"
+ },
+ "block_num": {
+ "type": "integer",
+ "format": "uint64"
+ },
+ "timestamp": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "account": {
+ "type": "string"
+ },
+ "balance": {
+ "type": "string"
+ },
+ "balance_delta": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ },
+ "Holder": {
+ "type": "object",
+ "required": [
+ "account",
+ "balance"
+ ],
+ "properties": {
+ "account": {
+ "type": "string"
+ },
+ "balance": {
+ "type": "number",
+ "format": "double"
+ }
+ }
+ },
+ "Pagination": {
+ "type": "object",
+ "required": [
+ "next_page",
+ "previous_page",
+ "total_pages",
+ "total_results"
+ ],
+ "properties": {
+ "next_page": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "previous_page": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "total_pages": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "total_results": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ },
+ "QueryStatistics": {
+ "type": "object",
+ "required": [
+ "elapsed",
+ "rows_read",
+ "bytes_read"
+ ],
+ "properties": {
+ "elapsed": {
+ "type": "number"
+ },
+ "rows_read": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "bytes_read": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ },
+ "ResponseMetadata": {
+ "type": "object",
+ "required": [
+ "statistics",
+ "next_page",
+ "previous_page",
+ "total_pages",
+ "total_results"
+ ],
+ "properties": {
+ "statistics": {
+ "type": "object",
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/QueryStatistics"
+ }
+ ],
+ "nullable": true
+ },
+ "next_page": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "previous_page": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "total_pages": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "total_results": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ },
+ "Supply": {
+ "type": "object",
+ "required": [
+ "trx_id",
+ "action_index",
+ "contract",
+ "symcode",
+ "precision",
+ "amount",
+ "value",
+ "block_num",
+ "timestamp",
+ "issuer",
+ "max_supply",
+ "supply",
+ "supply_delta"
+ ],
+ "properties": {
+ "trx_id": {
+ "type": "string"
+ },
+ "action_index": {
+ "type": "integer",
+ "format": "uint32"
+ },
+ "contract": {
+ "type": "string"
+ },
+ "symcode": {
+ "type": "string"
+ },
+ "precision": {
+ "type": "integer",
+ "format": "uint32"
+ },
+ "amount": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "value": {
+ "type": "number",
+ "format": "double"
+ },
+ "block_num": {
+ "type": "integer",
+ "format": "uint64"
+ },
+ "timestamp": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "issuer": {
+ "type": "string"
+ },
+ "max_supply": {
+ "type": "string"
+ },
+ "supply": {
+ "type": "string"
+ },
+ "supply_delta": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ },
+ "Transfer": {
+ "type": "object",
+ "required": [
+ "trx_id",
+ "action_index",
+ "contract",
+ "symcode",
+ "precision",
+ "amount",
+ "value",
+ "block_num",
+ "timestamp",
+ "from",
+ "to",
+ "quantity",
+ "memo"
+ ],
+ "properties": {
+ "trx_id": {
+ "type": "string"
+ },
+ "action_index": {
+ "type": "integer",
+ "format": "uint32"
+ },
+ "contract": {
+ "type": "string"
+ },
+ "symcode": {
+ "type": "string"
+ },
+ "precision": {
+ "type": "integer",
+ "format": "uint32"
+ },
+ "amount": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "value": {
+ "type": "number",
+ "format": "double"
+ },
+ "block_num": {
+ "type": "integer",
+ "format": "uint64"
+ },
+ "timestamp": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "from": {
+ "type": "string"
+ },
+ "to": {
+ "type": "string"
+ },
+ "quantity": {
+ "type": "string"
+ },
+ "memo": {
+ "type": "string"
+ }
+ }
+ },
+ "Version": {
+ "type": "object",
+ "required": [
+ "version",
+ "commit"
+ ],
+ "properties": {
+ "version": {
+ "type": "string",
+ "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$"
+ },
+ "commit": {
+ "type": "string",
+ "pattern": "^[0-9a-f]{7}$"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/tsp-output/@typespec/protobuf/antelope/eosio/token/v1.proto b/tsp-output/@typespec/protobuf/antelope/eosio/token/v1.proto
new file mode 100644
index 0000000..76f028a
--- /dev/null
+++ b/tsp-output/@typespec/protobuf/antelope/eosio/token/v1.proto
@@ -0,0 +1,38 @@
+// Generated by Microsoft TypeSpec
+
+syntax = "proto3";
+
+package antelope.eosio.token.v1;
+
+import "google/protobuf/timestamp.proto";
+
+message Transfer {
+ string trx_id = 1;
+ uint32 action_index = 2;
+ string contract = 3;
+ string symcode = 4;
+ uint32 precision = 9;
+ int64 amount = 10;
+ double value = 11;
+ uint64 block_num = 12;
+ google.protobuf.Timestamp timestamp = 13;
+ string from = 5;
+ string to = 6;
+ string quantity = 7;
+ string memo = 8;
+}
+
+message BalanceChange {
+ string trx_id = 1;
+ uint32 action_index = 2;
+ string contract = 3;
+ string symcode = 4;
+ uint32 precision = 8;
+ int64 amount = 9;
+ double value = 10;
+ uint64 block_num = 11;
+ google.protobuf.Timestamp timestamp = 12;
+ string account = 5;
+ string balance = 6;
+ int64 balance_delta = 7;
+}
diff --git a/tspconfig.yaml b/tspconfig.yaml
new file mode 100644
index 0000000..bf6f357
--- /dev/null
+++ b/tspconfig.yaml
@@ -0,0 +1,26 @@
+# Typespec compiler configuration file
+# See https://typespec.io/docs/handbook/configuration
+
+# extends: ../tspconfig.yaml # Extend another config file
+# emit: # Emitter name
+# - ":
+# "": ""
+# environment-variables: # Environment variables which can be used to interpolate emitter options
+# :
+# default: ""
+# parameters: # Parameters which can be used to interpolate emitter options
+# :
+# default: ""
+# trace: # Trace areas to enable tracing
+# - ""
+# warn-as-error: true # Treat warnings as errors
+# output-dir: "{project-root}/_generated" # Configure the base output directory for all emitters
+warn-as-error: true
+emit:
+ - "@typespec/protobuf"
+ - "@typespec/openapi3"
+options:
+ "@typespec/openapi3":
+ "file-type": "json"