diff --git a/src/fetch/GET.ts b/src/fetch/GET.ts index 2b2d3ba..7021df6 100644 --- a/src/fetch/GET.ts +++ b/src/fetch/GET.ts @@ -10,6 +10,8 @@ import * as prometheus from "../prometheus.js"; import { logger } from "../logger.js"; import swaggerHtml from "../../swagger/index.html" import swaggerFavicon from "../../swagger/favicon.png" +import transfers from "./transfers.js"; +import approvals from "./approvals.js"; export default async function (req: Request) { const { pathname } = new URL(req.url); @@ -23,6 +25,8 @@ export default async function (req: Request) { if (pathname === "/supply") return supply(req); if (pathname === "/balance") return balance(req); if (pathname === "/contract") return contract(req); + if (pathname === "/transfers") return transfers(req); + if (pathname === "/approvals") return approvals(req); if (pathname === "/holders") return holders(req); logger.warn(`Not found: ${pathname}`); prometheus.request_error.inc({ pathname, status: 404 }); diff --git a/src/fetch/approvals.ts b/src/fetch/approvals.ts new file mode 100644 index 0000000..5b90fdc --- /dev/null +++ b/src/fetch/approvals.ts @@ -0,0 +1,20 @@ +// from: https://github.com/pinax-network/substreams-clock-api/blob/main/src/fetch/block.ts +import { makeQuery } from "../clickhouse/makeQuery.js"; +import { logger } from "../logger.js"; +import { getApprovals } from "../queries.js"; +import * as prometheus from "../prometheus.js"; +import { toJSON } from "./utils.js"; + +export default async function (req: Request) { + try { + const { searchParams } = new URL(req.url); + logger.info({ searchParams: Object.fromEntries(Array.from(searchParams)) }); + const query = await getApprovals(searchParams); + const response = await makeQuery(query) + return toJSON(response.data); + } catch (e: any) { + logger.error(e); + prometheus.request_error.inc({ pathname: "/approvals", status: 400 }); + return new Response(e.message, { status: 400 }); + } +} \ No newline at end of file diff --git a/src/fetch/openapi.ts b/src/fetch/openapi.ts index 142c9db..18038ec 100644 --- a/src/fetch/openapi.ts +++ b/src/fetch/openapi.ts @@ -6,7 +6,7 @@ import { config } from "../config.js"; import { registry } from "../prometheus.js"; import { supportedChainsQuery } from "./chains.js"; import { makeQuery } from "../clickhouse/makeQuery.js"; -import { getBalanceChanges, getContracts, getHolders, getTotalSupply } from "../queries.js"; +import { getApprovals, getBalanceChanges, getContracts, getHolders, getTotalSupply, getTransfers } from "../queries.js"; const TAGS = { MONITORING: "Monitoring", HEALTH: "Health", @@ -16,11 +16,16 @@ const TAGS = { 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 chains = await supportedChainsQuery(); const supply_example = (await makeQuery(await getTotalSupply(new URLSearchParams({ limit: "2" }), true))).data; const contract_example = (await makeQuery(await getContracts(new URLSearchParams({ limit: "2" }), true))).data; const balance_example = (await makeQuery(await getBalanceChanges(new URLSearchParams({ limit: "2" }), true))).data; const holders_example = (await makeQuery(await getHolders(new URLSearchParams({ limit: "5" }), true))).data; +const transfers_example = (await makeQuery(await getTransfers(new URLSearchParams({ limit: "5" }), true))).data; +const approvals_example = (await makeQuery(await getApprovals(new URLSearchParams({ limit: "5" }), true))).data; + const timestampSchema: SchemaObject = { anyOf: [ { type: "number" }, @@ -88,6 +93,17 @@ const blockFilter = blockExamplesArrayFilter.map(name => { } 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, @@ -173,6 +189,48 @@ export default new OpenApiBuilder() 400: { description: "Bad request" }, }, }, + }).addPath("/transfers", { + get: { + tags: [TAGS.USAGE], + summary: "ERC20 Transfers", + parameters: [ + parameterChain, + parameterString("contract"), + parameterString("from"), + parameterString("to"), + parameterString("transaction_id"), + ...amountFilter, + ...timestampFilter, + ...blockFilter, + parameterLimit, + parameterOffset, + ], + responses: { + 200: { description: "Array of supply", content: { "application/json": { example: transfers_example, schema: { type: "array" } } } }, + 400: { description: "Bad request" }, + }, + }, + }).addPath("/approvals", { + get: { + tags: [TAGS.USAGE], + summary: "ERC20 Approvals", + parameters: [ + parameterChain, + parameterString("contract"), + parameterString("owner"), + parameterString("spender"), + parameterString("transaction_id"), + ...amountFilter, + ...timestampFilter, + ...blockFilter, + parameterLimit, + parameterOffset, + ], + responses: { + 200: { description: "Array of supply", content: { "application/json": { example: approvals_example, schema: { type: "array" } } } }, + 400: { description: "Bad request" }, + }, + }, }).addPath("/holders", { get: { tags: [TAGS.USAGE], diff --git a/src/fetch/transfers.ts b/src/fetch/transfers.ts new file mode 100644 index 0000000..177b5df --- /dev/null +++ b/src/fetch/transfers.ts @@ -0,0 +1,20 @@ +// from: https://github.com/pinax-network/substreams-clock-api/blob/main/src/fetch/block.ts +import { makeQuery } from "../clickhouse/makeQuery.js"; +import { logger } from "../logger.js"; +import { getTransfers } from "../queries.js"; +import * as prometheus from "../prometheus.js"; +import { toJSON } from "./utils.js"; + +export default async function (req: Request) { + try { + const { searchParams } = new URL(req.url); + logger.info({ searchParams: Object.fromEntries(Array.from(searchParams)) }); + const query = await getTransfers(searchParams); + const response = await makeQuery(query) + return toJSON(response.data); + } catch (e: any) { + logger.error(e); + prometheus.request_error.inc({ pathname: "/transfers", status: 400 }); + return new Response(e.message, { status: 400 }); + } +} \ No newline at end of file diff --git a/src/queries.spec.ts b/src/queries.spec.ts index cd77f6a..d4a06b6 100644 --- a/src/queries.spec.ts +++ b/src/queries.spec.ts @@ -8,6 +8,9 @@ import { getBalanceChanges, addTimestampBlockFilter, getHolders, + getTransfers, + getApprovals, + addAmountFilter, } from "./queries.js"; const chain = "eth"; @@ -85,6 +88,24 @@ test("addTimestampBlockFilter", () => { expect(where).toContain("toUnixTimestamp(timestamp) <= 1697587100"); }); +//Test Amount Filter +test("addAmountFilter", () => { + let where: any[] = []; + const searchParams = new URLSearchParams({ + address: address, + 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 TotalSupply test("getTotalSupply", () => { const parameters = new URLSearchParams({ chain, address }); @@ -198,6 +219,71 @@ test("getBalanceChanges with options", () => { ); }); +// Test getTransfers + +test("getTransfers", () => { + const parameters = new URLSearchParams({ chain, contract: address, from: address, to: address, transaction_id }); + expect(formatSQL(getTransfers(parameters))).toContain( + formatSQL(`SELECT + address as contract, + from, + to, + value as amount, + transaction as transaction_id, + block_number, + timestamp, + chain`) + ); + expect(formatSQL(getTransfers(parameters))).toContain( + formatSQL(`FROM Transfers`) + ); + + + expect(formatSQL(getTransfers(parameters))).toContain( + formatSQL(`WHERE(Transfers.chain == '${chain}' AND Transfers.address == '${address}' AND Transfers.from == '${address}' AND Transfers.to == '${address}' AND Transfers.transaction == '${transaction_id}')`) + ); + + expect(formatSQL(getTransfers(parameters))).toContain( + formatSQL(`ORDER BY block_number DESC`) + ); + + expect(formatSQL(getTransfers(parameters))).toContain( + formatSQL(`LIMIT 100`) + ); +}); + +// Test Approvals + +test("getApprovals", () => { + const parameters = new URLSearchParams({ chain, contract: address, owner: address, sender: address, transaction_id }); + expect(formatSQL(getApprovals(parameters))).toContain( + formatSQL(`SELECT + address as contract, + owner, + spender, + value as amount, + transaction as transaction_id, + block_number, + timestamp, + chain`) + ); + expect(formatSQL(getApprovals(parameters))).toContain( + formatSQL(`FROM Approvals`) + ); + + + expect(formatSQL(getApprovals(parameters))).toContain( + formatSQL(`WHERE(Approvals.chain == '${chain}' AND Approvals.address == '${address}' AND Approvals.owner == '${address}' AND Approvals.sender == '${address}' AND Approvals.transaction == '${transaction_id}')`) + ); + + expect(formatSQL(getApprovals(parameters))).toContain( + formatSQL(`ORDER BY block_number DESC`) + ); + + expect(formatSQL(getApprovals(parameters))).toContain( + formatSQL(`LIMIT 100`) + ); +}); // Test getHolders test("getHolders", () => { diff --git a/src/queries.ts b/src/queries.ts index 9d4c969..7a31049 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -18,6 +18,20 @@ export function addTimestampBlockFilter(searchParams: URLSearchParams, where: an } } +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) { // Params const address = getAddress(searchParams, "address", false)?.toLowerCase(); @@ -215,6 +229,114 @@ export function getHolders(searchParams: URLSearchParams, example?: boolean) { return query; } +export function getTransfers(searchParams: URLSearchParams, example?: boolean) { + // Params + const contract = getAddress(searchParams, "contract", false)?.toLowerCase(); + const from = getAddress(searchParams, "from", false)?.toLowerCase(); + const to = getAddress(searchParams, "to", false)?.toLowerCase(); + const chain = searchParams.get("chain"); + const transaction_id = searchParams.get("transaction_id")?.toLowerCase(); + const amount = searchParams.get("amount"); + // Query + const table = 'Transfers' + + let query = `SELECT + address as contract, + from, + to, + value as amount, + transaction as transaction_id, + block_number, + timestamp, + chain + FROM ${table} `; + + if (!example) { + // WHERE statements + const where = []; + + // equals + if (chain) where.push(`${table}.chain == '${chain}'`); + if (contract) where.push(`${table}.address == '${contract}'`); + if (from) where.push(`${table}.from == '${from}'`); + if (to) where.push(`${table}.to == '${to}'`); + if (transaction_id) where.push(`${table}.transaction == '${transaction_id}'`); + + //add amount filter + addAmountFilter(searchParams, where); + // timestamp and block filter + addTimestampBlockFilter(searchParams, where); + + // Join WHERE statements with AND + if (where.length) query += ` WHERE (${where.join(' AND ')})`; + + // Sort and Limit + const sort_by = searchParams.get("sort_by"); + query += ` ORDER BY block_number ${sort_by ?? DEFAULT_SORT_BY} ` + + } + const limit = parseLimit(searchParams.get("limit"), 100); + query += ` LIMIT ${limit} ` + const offset = searchParams.get("offset"); + if (offset) query += ` OFFSET ${offset} ` + return query; +} + + +export function getApprovals(searchParams: URLSearchParams, example?: boolean) { + // Params + const contract = getAddress(searchParams, "contract", false)?.toLowerCase(); + const owner = getAddress(searchParams, "owner", false)?.toLowerCase(); + const sender = getAddress(searchParams, "sender", false)?.toLowerCase(); + const chain = searchParams.get("chain"); + const transaction_id = searchParams.get("transaction_id")?.toLowerCase(); + const amount = searchParams.get("amount"); + // Query + const table = 'Approvals' + + let query = `SELECT + address as contract, + owner, + spender, + value as amount, + transaction as transaction_id, + block_number, + timestamp, + chain + FROM ${table} `; + + if (!example) { + // WHERE statements + const where = []; + + // equals + if (chain) where.push(`${table}.chain == '${chain}'`); + if (contract) where.push(`${table}.address == '${contract}'`); + if (owner) where.push(`${table}.owner == '${owner}'`); + if (sender) where.push(`${table}.sender == '${sender}'`); + if (transaction_id) where.push(`${table}.transaction == '${transaction_id}'`); + + //add amount filter + addAmountFilter(searchParams, where); + // timestamp and block filter + addTimestampBlockFilter(searchParams, where); + + // Join WHERE statements with AND + if (where.length) query += ` WHERE (${where.join(' AND ')})`; + + // Sort and Limit + const sort_by = searchParams.get("sort_by"); + query += ` ORDER BY block_number ${sort_by ?? DEFAULT_SORT_BY} ` + + } + const limit = parseLimit(searchParams.get("limit"), 100); + query += ` LIMIT ${limit} ` + const offset = searchParams.get("offset"); + if (offset) query += ` OFFSET ${offset} ` + return query; +} + + export function getChain() { return `SELECT DISTINCT chain FROM module_hashes`;