diff --git a/packages/api-database/source/contracts.ts b/packages/api-database/source/contracts.ts index c5697aa2d0..00eb96509f 100644 --- a/packages/api-database/source/contracts.ts +++ b/packages/api-database/source/contracts.ts @@ -14,6 +14,7 @@ import type { Token, TokenHolder, TokenTransfer, + TokenWhitelist, Transaction, ValidatorRound, Wallet, @@ -107,6 +108,8 @@ export type TokenHolderRepositoryExtension = Record; export type TokenHolderRepository = ExtendedRepository & TokenHolderRepositoryExtension; export type TokenTransferRepositoryExtension = Record; export type TokenTransferRepository = ExtendedRepository & TokenTransferRepositoryExtension; +export type TokenWhitelistRepositoryExtension = Record; +export type TokenWhitelistRepository = ExtendedRepository & TokenWhitelistRepositoryExtension; export type SystemRepositoryExtension = { inMaintenance(): Promise; @@ -140,6 +143,7 @@ export type TransactionRepositoryFactory = (customDataSource?: RepositoryDataSou export type TokenRepositoryFactory = (customDataSource?: RepositoryDataSource) => TokenRepository; export type TokenHolderRepositoryFactory = (customDataSource?: RepositoryDataSource) => TokenHolderRepository; export type TokenTransferRepositoryFactory = (customDataSource?: RepositoryDataSource) => TokenTransferRepository; +export type TokenWhitelistRepositoryFactory = (customDataSource?: RepositoryDataSource) => TokenWhitelistRepository; export type MultiPaymentRepositoryFactory = (customDataSource?: RepositoryDataSource) => MultiPaymentRepository; export type ValidatorRoundRepositoryFactory = (customDataSource?: RepositoryDataSource) => ValidatorRoundRepository; export type PluginRepositoryFactory = (customDataSource?: RepositoryDataSource) => PluginRepository; diff --git a/packages/api-database/source/identifiers.ts b/packages/api-database/source/identifiers.ts index 482e0300c7..2de3b1d70d 100644 --- a/packages/api-database/source/identifiers.ts +++ b/packages/api-database/source/identifiers.ts @@ -14,6 +14,7 @@ export const Identifiers = { TokenHolderRepositoryFactory: Symbol.for("API"), TokenRepositoryFactory: Symbol.for("API"), TokenTransferRepositoryFactory: Symbol.for("API"), + TokenWhitelistRepositoryFactory: Symbol.for("API"), TransactionRepositoryFactory: Symbol.for("API"), ValidatorRoundRepositoryFactory: Symbol.for("API"), WalletRepositoryFactory: Symbol.for("API"), diff --git a/packages/api-database/source/models/index.ts b/packages/api-database/source/models/index.ts index 22bfd836db..2cbc4722e1 100644 --- a/packages/api-database/source/models/index.ts +++ b/packages/api-database/source/models/index.ts @@ -9,8 +9,9 @@ export * from "./plugin.js"; export * from "./state.js"; export * from "./system.js"; export * from "./token.js"; -export * from "./token_holder.js"; -export * from "./token_transfers.js"; +export * from "./token-holder.js"; +export * from "./token-transfers.js"; +export * from "./token-whitelist.js"; export * from "./transaction.js"; export * from "./validator-round.js"; export * from "./wallet.js"; diff --git a/packages/api-database/source/models/token_holder.ts b/packages/api-database/source/models/token-holder.ts similarity index 100% rename from packages/api-database/source/models/token_holder.ts rename to packages/api-database/source/models/token-holder.ts diff --git a/packages/api-database/source/models/token_transfers.ts b/packages/api-database/source/models/token-transfers.ts similarity index 100% rename from packages/api-database/source/models/token_transfers.ts rename to packages/api-database/source/models/token-transfers.ts diff --git a/packages/api-database/source/models/token-whitelist.ts b/packages/api-database/source/models/token-whitelist.ts new file mode 100644 index 0000000000..cfc241c6c3 --- /dev/null +++ b/packages/api-database/source/models/token-whitelist.ts @@ -0,0 +1,25 @@ +import { Column, Entity } from "typeorm"; + +@Entity({ + name: "token_whitelist", +}) +export class TokenWhitelist { + @Column({ + primary: true, + type: "citext", + }) + public readonly address!: string; + + @Column({ + default: undefined, + nullable: true, + type: "text", + }) + public readonly comment: string | undefined; + + @Column({ + nullable: false, + type: "timestamptz", + }) + public readonly createdAt!: string; +} diff --git a/packages/api-database/source/models/wallet.ts b/packages/api-database/source/models/wallet.ts index 2ea100153f..53a61316d0 100644 --- a/packages/api-database/source/models/wallet.ts +++ b/packages/api-database/source/models/wallet.ts @@ -1,4 +1,4 @@ -import { Column, Entity, VirtualColumn } from "typeorm"; +import { Column, Entity } from "typeorm"; @Entity({ name: "wallets", @@ -36,18 +36,18 @@ export class Wallet { }) public attributes: string | undefined; - @VirtualColumn({ - query: (alias) => ` - COALESCE( - (SELECT tc.token_count - FROM wallet_token_counts tc - WHERE tc.address = ${alias}.address - LIMIT 1), - 0 - )`, - type: "integer", - }) - public tokenCount: number | undefined; + // @VirtualColumn({ + // query: (alias) => ` + // COALESCE( + // (SELECT tc.token_count + // FROM wallet_token_counts tc + // WHERE tc.address = ${alias}.address + // LIMIT 1), + // 0 + // )`, + // type: "integer", + // }) + // public tokenCount: number | undefined; @Column({ nullable: false, diff --git a/packages/api-database/source/repositories/index.ts b/packages/api-database/source/repositories/index.ts index f03e87f4dc..69c65d8c19 100644 --- a/packages/api-database/source/repositories/index.ts +++ b/packages/api-database/source/repositories/index.ts @@ -10,6 +10,7 @@ export { makeSystemRepository } from "./system-repository.js"; export { makeTokenHolderRepository } from "./token-holder-repository.js"; export { makeTokenRepository } from "./token-repository.js"; export { makeTokenTransferRepository } from "./token-transfer-repository.js"; +export { makeTokenWhitelistRepository } from "./token-whitelist-repository.js"; export { makeTransactionRepository } from "./transaction-repository.js"; export { makeValidatorRoundRepository } from "./validator-round-repository.js"; export { makeWalletRepository } from "./wallet-repository.js"; diff --git a/packages/api-database/source/repositories/token-whitelist-repository.ts b/packages/api-database/source/repositories/token-whitelist-repository.ts new file mode 100644 index 0000000000..34914c7923 --- /dev/null +++ b/packages/api-database/source/repositories/token-whitelist-repository.ts @@ -0,0 +1,12 @@ +import type { + RepositoryDataSource, + TokenWhitelistRepository, + TokenWhitelistRepositoryExtension, +} from "../contracts.js"; +import { TokenWhitelist } from "../models/index.js"; +import { makeExtendedRepository } from "./repository-extension.js"; + +export const makeTokenWhitelistRepository = (dataSource: RepositoryDataSource): TokenWhitelistRepository => + makeExtendedRepository(TokenWhitelist, dataSource, { + // Add any extensions here + }); diff --git a/packages/api-database/source/service-provider.ts b/packages/api-database/source/service-provider.ts index 0ba6ff2b16..81d8aa10f0 100644 --- a/packages/api-database/source/service-provider.ts +++ b/packages/api-database/source/service-provider.ts @@ -20,6 +20,7 @@ import { TokenHolderRepository, TokenRepository, TokenTransferRepository, + TokenWhitelistRepository, TransactionRepository, ValidatorRoundRepository, WalletRepository, @@ -40,6 +41,7 @@ import { Token, TokenHolder, TokenTransfer, + TokenWhitelist, Transaction, ValidatorRound, Wallet, @@ -58,6 +60,7 @@ import { makeTokenHolderRepository, makeTokenRepository, makeTokenTransferRepository, + makeTokenWhitelistRepository, makeTransactionRepository, makeValidatorRoundRepository, makeWalletRepository, @@ -107,6 +110,7 @@ export class ServiceProvider extends Providers.ServiceProvider { Token, TokenHolder, TokenTransfer, + TokenWhitelist, MultiPayment, ValidatorRound, Wallet, @@ -220,6 +224,13 @@ export class ServiceProvider extends Providers.ServiceProvider { makeTokenTransferRepository(customDataSource ?? dataSource), ); + this.app + .bind<() => TokenWhitelistRepository>(Identifiers.TokenWhitelistRepositoryFactory) + .toFactory( + () => (customDataSource?: RepositoryDataSource) => + makeTokenWhitelistRepository(customDataSource ?? dataSource), + ); + this.app .bind<() => ValidatorRoundRepository>(Identifiers.ValidatorRoundRepositoryFactory) .toFactory( diff --git a/packages/api-http/integration/routes/tokens.test.ts b/packages/api-http/integration/routes/tokens.test.ts index 9d22e18212..b63b73ab0d 100644 --- a/packages/api-http/integration/routes/tokens.test.ts +++ b/packages/api-http/integration/routes/tokens.test.ts @@ -8,6 +8,7 @@ import tokenHolders from "../../test/fixtures/token_holders.json"; import tokenTransferTokens from "../../test/fixtures/token_transfer.tokens.json"; import tokenTransferTransactions from "../../test/fixtures/token_transfer.transactions.json"; import tokenTransfers from "../../test/fixtures/token_transfers.json"; +import tokenWhitelist from "../../test/fixtures/token_whitelist.json"; import tokenTransfersResponse from "../../test/fixtures/token_transfers.response.json"; describe<{ @@ -37,6 +38,7 @@ describe<{ it("/tokens", async () => { await apiContext.tokenRepository.save(tokens); + await apiContext.tokenWhitelistRepository.save(tokenWhitelist); const testCases = [ { @@ -109,6 +111,99 @@ describe<{ } }); + it("/tokens?ignoreWhitelist", async () => { + await apiContext.tokenRepository.save(tokens); + + const testCases = [ + { + query: "?ignoreWhitelist=true", + result: { + data: [...tokens].sort((a, b) => a.address.localeCompare(b.address)), + statusCode: 200, + }, + }, + { + query: "?ignoreWhitelist=false", + result: { + data: [], + statusCode: 200, + }, + }, + { + query: "", + result: { + data: [], + statusCode: 200, + }, + }, + ]; + + for (const { query, result } of testCases) { + const endpoint = `/tokens${query}`; + if (result.statusCode === 404) { + await assert.rejects(async () => request(endpoint, options), "Response code 404 (Not Found)"); + } else { + const { statusCode, data } = await request(endpoint, options); + assert.equal(statusCode, result.statusCode); + assert.equal(data.data, result.data); + } + } + }); + + it("/tokens custom whitelist (POST)", async () => { + await apiContext.tokenRepository.save(tokens); + await apiContext.tokenWhitelistRepository.save(tokenWhitelist.slice(0, 1)); + + const testCases = [ + { + query: "", + method: "POST", + result: { + data: [tokens[0]], + statusCode: 200, + }, + }, + { + query: "", + body: JSON.stringify({ whitelist: [tokens[1].address] }), + method: "POST", + result: { + data: [tokens[0], tokens[1]], + statusCode: 200, + }, + }, + { + query: "", + body: JSON.stringify({ whitelist: tokens.map((t) => t.address) }), + method: "POST", + result: { + data: [...tokens].sort((a, b) => a.address.localeCompare(b.address)), + statusCode: 200, + }, + }, + { + query: "", + body: JSON.stringify({ whitelist: ["0x0000000000000000000000000000000000000000"] }), + method: "POST", + result: { + data: [tokens[0]], + statusCode: 200, + }, + }, + ]; + + for (const { query, result, body, method } of testCases) { + const endpoint = `/tokens${query}`; + if (result.statusCode === 404) { + await assert.rejects(async () => request(endpoint, options), "Response code 404 (Not Found)"); + } else { + const { statusCode, data } = await request(endpoint, { ...options, body, method }); + assert.equal(statusCode, result.statusCode); + assert.equal(data.data, result.data); + } + } + }); + it("/tokens/{}", async () => { await apiContext.tokenRepository.save(tokens); diff --git a/packages/api-http/integration/routes/wallets.test.ts b/packages/api-http/integration/routes/wallets.test.ts index 06895e9415..dac56681a7 100644 --- a/packages/api-http/integration/routes/wallets.test.ts +++ b/packages/api-http/integration/routes/wallets.test.ts @@ -13,6 +13,7 @@ import multiPaymentWallets from "../../test/fixtures/multi_payments.wallets.json import multiPaymentTransactionsResponse from "../../test/fixtures/multi_payments.transactions.response.json"; import tokens from "../../test/fixtures/tokens.json"; import tokenHolders from "../../test/fixtures/token_holders.json"; +import tokenWhitelist from "../../test/fixtures/token_whitelist.json"; import walletsTokens from "../../test/fixtures/wallets_tokens.json"; import walletTokensResponse from "../../test/fixtures/wallet_tokens.response.json"; import walletTokenHoldersResponse from "../../test/fixtures/wallet_token_holders.response.json"; @@ -259,6 +260,7 @@ describe<{ await apiContext.walletRepository.save(walletsTokens); await apiContext.tokenRepository.save(tokens); await apiContext.tokenHolderRepository.save(tokenHolders); + await apiContext.tokenWhitelistRepository.save(tokenWhitelist); const testCases = [ { @@ -301,6 +303,7 @@ describe<{ it("/wallets/tokens?addresses", async () => { await apiContext.tokenRepository.save(tokens); await apiContext.tokenHolderRepository.save(tokenHolders); + await apiContext.tokenWhitelistRepository.save(tokenWhitelist); const testCases = [ { @@ -323,6 +326,7 @@ describe<{ it("/wallets/tokens?addresses&names", async () => { await apiContext.tokenRepository.save(tokens); await apiContext.tokenHolderRepository.save(tokenHolders); + await apiContext.tokenWhitelistRepository.save(tokenWhitelist); const path = "/wallets/tokens?addresses=0x8233F6Df6449D7655f4643D2E752DC8D2283fAd5,0x432b093d9542B905C87587607491C369408475b4,0x3949B5aEb77059945e96c513F8F712450Ca89Eb7"; @@ -356,6 +360,7 @@ describe<{ it("/wallets/tokens?addresses&minBalance", async () => { await apiContext.tokenRepository.save(tokens); await apiContext.tokenHolderRepository.save(tokenHolders); + await apiContext.tokenWhitelistRepository.save(tokenWhitelist); const path = "/wallets/tokens?addresses=0x8233F6Df6449D7655f4643D2E752DC8D2283fAd5,0x432b093d9542B905C87587607491C369408475b4,0x3949B5aEb77059945e96c513F8F712450Ca89Eb7"; @@ -389,6 +394,7 @@ describe<{ it("/wallets/tokens pagination", async () => { await apiContext.tokenRepository.save(tokens); await apiContext.tokenHolderRepository.save(tokenHolders); + await apiContext.tokenWhitelistRepository.save(tokenWhitelist); const testCases = [ { @@ -420,32 +426,61 @@ describe<{ } }); - it("/wallets returns token count", async () => { - await apiContext.walletRepository.save(wallets); + it("/wallets/tokens?ignoreWhitelist", async () => { await apiContext.tokenRepository.save(tokens); await apiContext.tokenHolderRepository.save(tokenHolders); - const wallet = wallets[0]; - const tokenWallet = wallets.find((w) => w.address === tokenHolders[2].address); + const testCases = [ + { + path: "/wallets/tokens?ignoreWhitelist=true&limit=5&addresses=0x8233F6Df6449D7655f4643D2E752DC8D2283fAd5,0x432b093d9542B905C87587607491C369408475b4,0x3949B5aEb77059945e96c513F8F712450Ca89Eb7", + result: walletTokenHoldersResponse, + }, + { + path: "/wallets/tokens?ignoreWhitelist=false&limit=5&addresses=0x8233F6Df6449D7655f4643D2E752DC8D2283fAd5,0x432b093d9542B905C87587607491C369408475b4,0x3949B5aEb77059945e96c513F8F712450Ca89Eb7", + result: [], + }, + { + path: "/wallets/tokens?limit=5&addresses=0x8233F6Df6449D7655f4643D2E752DC8D2283fAd5,0x432b093d9542B905C87587607491C369408475b4,0x3949B5aEb77059945e96c513F8F712450Ca89Eb7", + result: [], + }, + ]; + + for (const { path, result } of testCases) { + const { statusCode, data } = await request(path, options); + assert.equal(statusCode, 200); + assert.equal(data.data, result); + } + }); + + it("/wallets/tokens custom whitelist (POST)", async () => { + await apiContext.tokenRepository.save(tokens); + await apiContext.tokenHolderRepository.save(tokenHolders); + await apiContext.tokenWhitelistRepository.save(tokenWhitelist.slice(1, 2)); const testCases = [ { - id: wallet.address, - result: { ...wallet, tokenCount: 0 }, + method: "POST", + path: "/wallets/tokens?addresses=0x8233F6Df6449D7655f4643D2E752DC8D2283fAd5,0x432b093d9542B905C87587607491C369408475b4,0x3949B5aEb77059945e96c513F8F712450Ca89Eb7", + result: [walletTokenHoldersResponse[2]], }, { - id: tokenWallet!.address, - result: { ...tokenWallet, tokenCount: 1 }, + method: "POST", + body: JSON.stringify({ whitelist: [tokens[0].address] }), + path: "/wallets/tokens?addresses=0x8233F6Df6449D7655f4643D2E752DC8D2283fAd5,0x432b093d9542B905C87587607491C369408475b4,0x3949B5aEb77059945e96c513F8F712450Ca89Eb7", + result: [walletTokenHoldersResponse[0], walletTokenHoldersResponse[2]], + }, + { + method: "POST", + body: JSON.stringify({ whitelist: tokens.map((t) => t.address) }), + path: "/wallets/tokens?addresses=0x8233F6Df6449D7655f4643D2E752DC8D2283fAd5,0x432b093d9542B905C87587607491C369408475b4,0x3949B5aEb77059945e96c513F8F712450Ca89Eb7", + result: walletTokenHoldersResponse, }, ]; - for (const { id, result } of testCases) { - const { - statusCode, - data: { data }, - } = await request(`/wallets/${id}`, options); + for (const { path, result, body, method } of testCases) { + const { statusCode, data } = await request(path, { ...options, body, method }); assert.equal(statusCode, 200); - assert.equal(data, result); + assert.equal(data.data, result); } }); }); diff --git a/packages/api-http/source/controllers/tokens.ts b/packages/api-http/source/controllers/tokens.ts index 986b6426f4..4b97cb5bed 100644 --- a/packages/api-http/source/controllers/tokens.ts +++ b/packages/api-http/source/controllers/tokens.ts @@ -11,6 +11,7 @@ import { inject, injectable } from "@mainsail/container"; import { TokenResource } from "../resources/token.js"; import { TokenHolderResource } from "../resources/token-holder.js"; import { TokenTransferResource } from "../resources/token-transfer.js"; +import { TokenWhitelistResource } from "../resources/token-whitelist.js"; import { Controller } from "./controller.js"; type TokenTransferRaw = { @@ -39,11 +40,15 @@ export class TokensController extends Controller { @inject(ApiDatabaseIdentifiers.TokenTransferRepositoryFactory) private readonly tokenTransferRepositoryFactory!: ApiDatabaseContracts.TokenTransferRepositoryFactory; + @inject(ApiDatabaseIdentifiers.TokenWhitelistRepositoryFactory) + private readonly tokenWhitelistRepositoryFactory!: ApiDatabaseContracts.TokenWhitelistRepositoryFactory; + public async index(request: Hapi.Request): Promise { const pagination = this.getQueryPagination(request.query); const tokensQuery = this.tokenRepositoryFactory().createQueryBuilder("tok").select(); + TokensController.andWhereWhitelisted(tokensQuery, request); TokensController.andWhereNameSearch(tokensQuery, request.query.name); const [tokens, totalCount] = await TokensController.optionallyOrderedByName( @@ -106,6 +111,26 @@ export class TokensController extends Controller { return this.getTokenTransfers(request); } + public async whitelist(request: Hapi.Request): Promise { + const pagination = this.getListingPage(request); + const [tokenWhitelist, totalCount] = await this.tokenWhitelistRepositoryFactory() + .createQueryBuilder() + .select() + .orderBy("address", "ASC") + .limit(pagination.limit) + .offset(pagination.offset) + .getManyAndCount(); + + return this.toPagination( + { + meta: { totalCountIsEstimate: false }, + results: tokenWhitelist, + totalCount, + }, + TokenWhitelistResource, + ); + } + private async getTokenTransfers(request: Hapi.Request): Promise { const pagination = this.getListingPage(request); const tokenTransfersQuery = this.tokenTransferRepositoryFactory().createQueryBuilder("tf"); @@ -213,6 +238,32 @@ export class TokensController extends Controller { .getOne(); } + public static andWhereWhitelisted( + queryBuilder: TypeOrm.SelectQueryBuilder, + request: Hapi.Request, + ): void { + if (request.query.ignoreWhitelist) { + return; + } + + // POST allows user to whitelist selected tokens explicitly. + if (request.method === "post") { + const customWhitelist = (request.payload as unknown as { whitelist: string[] })?.whitelist ?? []; + if (customWhitelist.length > 0) { + queryBuilder.leftJoin(Models.TokenWhitelist, "tw", "tw.address = tok.address").andWhere( + new TypeOrm.Brackets((qb) => { + qb.where("tw.address IS NOT NULL").orWhere("tok.address IN (:...customWhitelist)", { + customWhitelist, + }); + }), + ); + return; + } + } + + queryBuilder.innerJoin(Models.TokenWhitelist, "tw", "tw.address = tok.address"); + } + public static andWhereNameSearch( queryBuilder: TypeOrm.SelectQueryBuilder, nameQuery?: string, diff --git a/packages/api-http/source/controllers/wallets.ts b/packages/api-http/source/controllers/wallets.ts index a600e491d7..60b33eea36 100644 --- a/packages/api-http/source/controllers/wallets.ts +++ b/packages/api-http/source/controllers/wallets.ts @@ -152,6 +152,7 @@ export class WalletsController extends Controller { { addresses: walletAddresses, minBalance }, ); + TokensController.andWhereWhitelisted(tokenPaginatedQuery, request); TokensController.andWhereNameSearch(tokenPaginatedQuery, request.query.name); const [pageTokensRows, totalCountRow] = await Promise.all([ @@ -291,6 +292,7 @@ export class WalletsController extends Controller { .where("th.address = :address", { address: walletAddress }) .andWhere("th.balance / POW(10, tok.decimals) >= :minBalance", { minBalance }); + TokensController.andWhereWhitelisted(tokenHoldersQuery, request); TokensController.andWhereNameSearch(tokenHoldersQuery, request.query.name); const [pageTokenHolderRows, totalCountRow] = await Promise.all([ diff --git a/packages/api-http/source/resources/token-whitelist.ts b/packages/api-http/source/resources/token-whitelist.ts new file mode 100644 index 0000000000..0e8c68817a --- /dev/null +++ b/packages/api-http/source/resources/token-whitelist.ts @@ -0,0 +1,14 @@ +import { Models } from "@mainsail/api-database"; +import { injectable } from "@mainsail/container"; +import type { Contracts } from "@mainsail/contracts"; + +@injectable() +export class TokenWhitelistResource implements Contracts.Api.Resource { + public raw(resource: Models.TokenWhitelist): object { + return resource; + } + + public transform(resource: Models.TokenWhitelist): object { + return resource; + } +} diff --git a/packages/api-http/source/routes/tokens.ts b/packages/api-http/source/routes/tokens.ts index 776b301a42..a87f3af24d 100644 --- a/packages/api-http/source/routes/tokens.ts +++ b/packages/api-http/source/routes/tokens.ts @@ -13,6 +13,11 @@ export const register = (server: Contracts.Api.ApiServer): void => { const controller = server.app.app.resolve(TokensController); server.bind(controller); + const tokensQuerySchema = Joi.object({ + ignoreWhitelist: Joi.bool().default(false), + name: Schemas.orEqualCriteria(tokenNameSchema), + }).concat(Schemas.pagination); + server.route({ handler: (request: Hapi.Request) => controller.index(request), method: "GET", @@ -23,9 +28,26 @@ export const register = (server: Contracts.Api.ApiServer): void => { }, }, validate: { - query: Joi.object({ - name: Schemas.orEqualCriteria(tokenNameSchema), - }).concat(Schemas.pagination), + query: tokensQuerySchema, + }, + }, + path: "/tokens", + }); + + server.route({ + handler: (request: Hapi.Request) => controller.index(request), + method: "POST", + options: { + plugins: { + pagination: { + enabled: true, + }, + }, + validate: { + payload: Joi.object({ + whitelist: Joi.array().items(Schemas.addressSchema).max(100).empty(null).default([]), + }).empty(null), + query: tokensQuerySchema, }, }, path: "/tokens", @@ -52,6 +74,22 @@ export const register = (server: Contracts.Api.ApiServer): void => { path: "/tokens/transfers", }); + server.route({ + handler: (request: Hapi.Request) => controller.whitelist(request), + method: "GET", + options: { + plugins: { + pagination: { + enabled: true, + }, + }, + validate: { + query: Schemas.pagination, + }, + }, + path: "/tokens/whitelist", + }); + server.route({ handler: (request: Hapi.Request) => controller.show(request), method: "GET", diff --git a/packages/api-http/source/routes/wallets.ts b/packages/api-http/source/routes/wallets.ts index 9d299d7716..583a0789c4 100644 --- a/packages/api-http/source/routes/wallets.ts +++ b/packages/api-http/source/routes/wallets.ts @@ -176,6 +176,13 @@ export const register = (server: Contracts.Api.ApiServer): void => { path: "/wallets/{id}/votes", }); + const walletTokensQuerySchema = Joi.object({ + addresses: Schemas.orEqualCriteria(walletAddressSchema), + ignoreWhitelist: Joi.bool().default(false), + minBalance: Schemas.orNumericCriteria(tokenBalanceSchema), + name: Schemas.orEqualCriteria(tokenNameSchema), + }).concat(Schemas.pagination); + server.route({ handler: (request: Hapi.Request) => controller.tokens(request), method: "GET", @@ -186,16 +193,37 @@ export const register = (server: Contracts.Api.ApiServer): void => { }, }, validate: { - query: Joi.object({ - addresses: Schemas.orEqualCriteria(walletAddressSchema), - minBalance: Schemas.orNumericCriteria(tokenBalanceSchema), - name: Schemas.orEqualCriteria(tokenNameSchema), - }).concat(Schemas.pagination), + query: walletTokensQuerySchema, + }, + }, + path: "/wallets/tokens", + }); + + server.route({ + handler: (request: Hapi.Request) => controller.tokens(request), + method: "POST", + options: { + plugins: { + pagination: { + enabled: true, + }, + }, + validate: { + payload: Joi.object({ + whitelist: Joi.array().items(Schemas.addressSchema).max(100).empty(null).default([]), + }).empty(null), + query: walletTokensQuerySchema, }, }, path: "/wallets/tokens", }); + const walletTokensIdQuerySchema = Joi.object({ + ignoreWhitelist: Joi.bool().default(false), + minBalance: Schemas.orNumericCriteria(tokenBalanceSchema), + name: Schemas.orEqualCriteria(tokenNameSchema), + }).concat(Schemas.pagination); + server.route({ handler: (request: Hapi.Request) => controller.tokensShow(request), method: "GET", @@ -209,10 +237,29 @@ export const register = (server: Contracts.Api.ApiServer): void => { params: Joi.object({ id: walletParameterSchema, }), - query: Joi.object({ - minBalance: Schemas.orNumericCriteria(tokenBalanceSchema), - name: Schemas.orEqualCriteria(tokenNameSchema), - }).concat(Schemas.pagination), + query: walletTokensIdQuerySchema, + }, + }, + path: "/wallets/{id}/tokens", + }); + + server.route({ + handler: (request: Hapi.Request) => controller.tokensShow(request), + method: "POST", + options: { + plugins: { + pagination: { + enabled: true, + }, + }, + validate: { + params: Joi.object({ + id: walletParameterSchema, + }), + payload: Joi.object({ + whitelist: Joi.array().items(Schemas.addressSchema).max(100).empty(null).default([]), + }).empty(null), + query: walletTokensIdQuerySchema, }, }, path: "/wallets/{id}/tokens", diff --git a/packages/api-http/test/fixtures/token_whitelist.json b/packages/api-http/test/fixtures/token_whitelist.json new file mode 100644 index 0000000000..c8dce1af93 --- /dev/null +++ b/packages/api-http/test/fixtures/token_whitelist.json @@ -0,0 +1,17 @@ +[ + { + "address": "0x0ba3d7cba9701f76f6285733a5a877a557c86034", + "comment": "A", + "createdAt": "2026-02-19T14:23:00.000Z" + }, + { + "address": "0xdeb478251073157e400c3d8d2ed92a85c958f9fa", + "comment": "B", + "createdAt": "2026-02-19T14:23:00.000Z" + }, + { + "address": "0x5a1aa6ce598411399672fc040956d0f9e159f5c3", + "comment": "C", + "createdAt": "2026-02-19T14:23:00.000Z" + } +] diff --git a/packages/api-http/test/fixtures/validators.json b/packages/api-http/test/fixtures/validators.json index dab141f89d..195859086f 100644 --- a/packages/api-http/test/fixtures/validators.json +++ b/packages/api-http/test/fixtures/validators.json @@ -24,7 +24,6 @@ "validatorProducedBlocks": 2, "validatorPublicKey": "b17da79a16b43ba4e12adafb700a6125ce44359c111c8d5dda71fda385bb0d5205e8816de28bcfa21c66c36229dddbaf" }, - "tokenCount": 0, "updated_at": "0" }, { @@ -52,7 +51,6 @@ "validatorProducedBlocks": 1, "validatorPublicKey": "ae1e968eab8da5afe443b71941fd132b542c774abad190586ab78827bdb8d816ac914e6e15f14d962e3399d4147c08e6" }, - "tokenCount": 0, "updated_at": "0" }, { @@ -80,7 +78,6 @@ "validatorProducedBlocks": 1, "validatorPublicKey": "9283a37556aa42aa7e2ee363fdd63adb8e91d2a97fe61c55a298075f4075471762371209f190d6d1f674b882e039b78b" }, - "tokenCount": 0, "updated_at": "0" }, { @@ -108,7 +105,6 @@ "validatorProducedBlocks": 1, "validatorPublicKey": "9429b3ab3d55edc1169dbcee25d070075b5fd2b372bb8b4f67bf82d7d9a37ca7f874a8fb54a200b6bc539f786952122c" }, - "tokenCount": 0, "updated_at": "0" }, { @@ -136,7 +132,6 @@ "validatorProducedBlocks": 1, "validatorPublicKey": "8b305af5825449c9a6a9a7468386a4c033b0db5b12d53aa0baa3b6809c82413d08305b23e707bd93063749109c2a521a" }, - "tokenCount": 0, "updated_at": "0" }, { @@ -164,7 +159,6 @@ "validatorProducedBlocks": 1, "validatorPublicKey": "8190ec14239cca612ae08b095876fdf2e7dc01c794c0d9182a7ad9b896eace7c50adf66bcec19c3dbc67c3ff0be4e004" }, - "tokenCount": 0, "updated_at": "0" }, { @@ -192,7 +186,6 @@ "validatorProducedBlocks": 1, "validatorPublicKey": "a48fa3df67f833f33082fa72ef25bacd0a87b46bf70287139e388e4607de7e9eebc8d3084d7c742fc51104b655ce3a9b" }, - "tokenCount": 0, "updated_at": "0" }, { @@ -220,7 +213,6 @@ "validatorProducedBlocks": 1, "validatorPublicKey": "b06f94ebadefd0bfb2aa41e27c3159aa4f9717adccda775978a01330833dc03bfeea8e3b3e7f64fb66a909a8188ff7b1" }, - "tokenCount": 0, "updated_at": "0" } ] diff --git a/packages/api-http/test/fixtures/wallets.json b/packages/api-http/test/fixtures/wallets.json index b17e38c9b3..ad33105991 100644 --- a/packages/api-http/test/fixtures/wallets.json +++ b/packages/api-http/test/fixtures/wallets.json @@ -22,7 +22,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -45,7 +44,6 @@ "validatorVotersCount": 1, "validatorProducedBlocks": 9 }, - "tokenCount": 0, "updated_at": "436" }, { @@ -71,7 +69,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -97,7 +94,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -123,7 +119,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -149,7 +144,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -172,7 +166,6 @@ "validatorVotersCount": 1, "validatorProducedBlocks": 9 }, - "tokenCount": 0, "updated_at": "428" }, { @@ -198,7 +191,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -224,7 +216,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -250,7 +241,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -276,7 +266,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -302,7 +291,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -328,7 +316,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -354,7 +341,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -377,7 +363,6 @@ "validatorVotersCount": 1, "validatorProducedBlocks": 9 }, - "tokenCount": 0, "updated_at": "429" }, { @@ -403,7 +388,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -429,7 +413,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -455,7 +438,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -481,7 +463,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -507,7 +488,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -533,7 +513,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -559,7 +538,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -582,7 +560,6 @@ "validatorVotersCount": 1, "validatorProducedBlocks": 9 }, - "tokenCount": 0, "updated_at": "430" }, { @@ -608,7 +585,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -634,7 +610,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -660,7 +635,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -686,7 +660,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -712,7 +685,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -738,7 +710,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -764,7 +735,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -790,7 +760,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -816,7 +785,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -842,7 +810,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -865,7 +832,6 @@ "validatorVotersCount": 1, "validatorProducedBlocks": 9 }, - "tokenCount": 0, "updated_at": "437" }, { @@ -888,7 +854,6 @@ "validatorVotersCount": 1, "validatorProducedBlocks": 9 }, - "tokenCount": 0, "updated_at": "438" }, { @@ -914,7 +879,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 9 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -937,7 +901,6 @@ "validatorVotersCount": 1, "validatorProducedBlocks": 9 }, - "tokenCount": 0, "updated_at": "432" }, { @@ -963,7 +926,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 9 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -989,7 +951,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -1015,7 +976,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -1038,7 +998,6 @@ "validatorVotersCount": 1, "validatorProducedBlocks": 9 }, - "tokenCount": 0, "updated_at": "431" }, { @@ -1061,7 +1020,6 @@ "validatorVotersCount": 1, "validatorProducedBlocks": 9 }, - "tokenCount": 0, "updated_at": "427" }, { @@ -1087,7 +1045,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -1113,7 +1070,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -1139,7 +1095,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -1165,7 +1120,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -1191,7 +1145,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -1214,7 +1167,6 @@ "validatorVotersCount": 1, "validatorProducedBlocks": 9 }, - "tokenCount": 0, "updated_at": "433" }, { @@ -1240,7 +1192,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -1263,7 +1214,6 @@ "validatorVotersCount": 1, "validatorProducedBlocks": 9 }, - "tokenCount": 0, "updated_at": "434" }, { @@ -1289,7 +1239,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" }, { @@ -1312,7 +1261,6 @@ "validatorVotersCount": 1, "validatorProducedBlocks": 9 }, - "tokenCount": 0, "updated_at": "435" }, { @@ -1338,7 +1286,6 @@ "validatorForgedRewards": "0", "validatorProducedBlocks": 8 }, - "tokenCount": 0, "updated_at": "0" } ] diff --git a/packages/api-http/test/helpers/prepare-sandbox.ts b/packages/api-http/test/helpers/prepare-sandbox.ts index e3096aee83..639bb521a3 100644 --- a/packages/api-http/test/helpers/prepare-sandbox.ts +++ b/packages/api-http/test/helpers/prepare-sandbox.ts @@ -163,6 +163,12 @@ export class ApiContext { )(); } + public get tokenWhitelistRepository(): ApiDatabaseContracts.TokenWhitelistRepository { + return this.app.get( + ApiDatabaseIdentifiers.TokenWhitelistRepositoryFactory, + )(); + } + public async reset() { const dataSource = this.app.get(ApiDatabaseIdentifiers.DataSource); await dataSource.dropDatabase(); diff --git a/packages/api-http/test/helpers/request.ts b/packages/api-http/test/helpers/request.ts index e29319a38a..3ff47ed830 100644 --- a/packages/api-http/test/helpers/request.ts +++ b/packages/api-http/test/helpers/request.ts @@ -11,8 +11,11 @@ export const request = async >( if (options?.fullReceipt !== undefined) { fullReceipt += (path.includes("?") ? "&" : "?") + `fullReceipt=${options.fullReceipt}`; } + const response = await got(`http://localhost:4003/api/${path}${fullReceipt}`, { + body: options?.body ?? undefined, + method: options?.method ?? "GET", + }); - const response = await got(`http://localhost:4003/api/${path}${fullReceipt}`); const { statusCode, headers, body } = response; return { data: JSON.parse(body) as T, headers, statusCode }; }; diff --git a/packages/api-sync/source/defaults.ts b/packages/api-sync/source/defaults.ts index bb2bf6314b..30cff98998 100644 --- a/packages/api-sync/source/defaults.ts +++ b/packages/api-sync/source/defaults.ts @@ -5,4 +5,12 @@ export const defaults = { enabled: Environment.isTrue(EnvironmentVariables.MAINSAIL_API_SYNC_ENABLED), syncInterval: Environment.get(EnvironmentVariables.MAINSAIL_API_SYNC_INTERVAL, 8000), tokenCacheSize: Environment.get(EnvironmentVariables.MAINSAIL_API_SYNC_TOKEN_CACHE_SIZE, 256), + tokenWhitelistRefreshInterval: Environment.get( + EnvironmentVariables.MAINSAIL_API_SYNC_TOKEN_WHITELIST_SYNC_INTERVAL, + 1000 * 60, + ), + tokenWhitelistRemoteUrl: Environment.get( + EnvironmentVariables.MAINSAIL_API_SYNC_TOKEN_WHITELIST_REMOTE_URL, + "", + ), }; diff --git a/packages/api-sync/source/restore.ts b/packages/api-sync/source/restore.ts index 320956766c..268f06a694 100644 --- a/packages/api-sync/source/restore.ts +++ b/packages/api-sync/source/restore.ts @@ -707,7 +707,6 @@ export class Restore { balance: BigNumber.make(account.balance).toFixed(), nonce: BigNumber.make(account.nonce).toFixed(), publicKey: context.addressToPublicKey[account.address] ?? null, - tokenCount: undefined, updated_at: "0", }); diff --git a/packages/api-sync/source/service-provider.ts b/packages/api-sync/source/service-provider.ts index 9ad1c21295..2ead4ba4a3 100644 --- a/packages/api-sync/source/service-provider.ts +++ b/packages/api-sync/source/service-provider.ts @@ -7,6 +7,7 @@ import { Listeners } from "./listeners.js"; import { Logger } from "./logger.js"; import { TokenParserService } from "./parsers/tokens.js"; import { Sync } from "./service.js"; +import { TokenWhitelist } from "./tokens/whitelist.js"; @injectable() export class ServiceProvider extends Providers.ServiceProvider { @@ -18,6 +19,7 @@ export class ServiceProvider extends Providers.ServiceProvider { this.app.bind(Identifiers.ApiSync.Listener).to(Listeners).inSingletonScope(); this.app.bind(Identifiers.ApiSync.Logger).to(Logger).inSingletonScope(); this.app.bind(Identifiers.ApiSync.TokenParser).to(TokenParserService).inSingletonScope(); + this.app.bind(Identifiers.ApiSync.TokenWhitelist).to(TokenWhitelist).inSingletonScope(); this.app.bind(Identifiers.ApiSync.Service).to(Sync).inSingletonScope(); // Listen to events during register, so we can catch all boot events. @@ -32,6 +34,7 @@ export class ServiceProvider extends Providers.ServiceProvider { await this.app.get(Identifiers.ApiSync.Service).flush(); await this.app.get(Identifiers.ApiSync.Listener).dispose(); + await this.app.get(Identifiers.ApiSync.TokenWhitelist).dispose(); } public configSchema(): Joi.ObjectSchema { diff --git a/packages/api-sync/source/service.ts b/packages/api-sync/source/service.ts index df719d70ae..dfeca03e3e 100644 --- a/packages/api-sync/source/service.ts +++ b/packages/api-sync/source/service.ts @@ -15,6 +15,7 @@ import { performance } from "perf_hooks"; import { Listeners, TokenParser } from "./contracts.js"; import { parseMultiPayments } from "./parsers/index.js"; import { Restore } from "./restore.js"; +import { TokenWhitelist } from "./tokens/whitelist.js"; interface DeferredSync { block: Models.Block; @@ -115,6 +116,9 @@ export class Sync implements Contracts.ApiSync.Service { @inject(Identifiers.ApiSync.TokenParser) private readonly tokenParser!: TokenParser; + @inject(Identifiers.ApiSync.TokenWhitelist) + private readonly tokenWhitelist!: TokenWhitelist; + public async bootstrap(): Promise { await this.migrations.synchronizeEntities(); await this.#resetDatabaseIfNecessary(); @@ -130,6 +134,8 @@ export class Sync implements Contracts.ApiSync.Service { await this.listeners.bootstrap(); + await this.tokenWhitelist.bootstrap(); + await this.#queue.start(); } diff --git a/packages/api-sync/source/tokens/sanitizers.test.ts b/packages/api-sync/source/tokens/sanitizers.test.ts new file mode 100644 index 0000000000..0fb34adddd --- /dev/null +++ b/packages/api-sync/source/tokens/sanitizers.test.ts @@ -0,0 +1,67 @@ +import { describe } from "@mainsail/test-runner"; +import { isValidPgTimestamptz, sanitizeComment } from "./sanitizers.js"; + +describe("Sanitizers", ({ assert, it }) => { + it("isValidPgTimestamptz: accepts strict ISO timestamptz with ms + Z", () => { + assert.true(isValidPgTimestamptz("2026-02-11T14:25:00.000Z")); + assert.true(isValidPgTimestamptz("1970-01-01T00:00:00.000Z")); + }); + + it("isValidPgTimestamptz: rejects non-strings and wrong shapes", () => { + assert.false(isValidPgTimestamptz(null)); + assert.false(isValidPgTimestamptz(undefined)); + assert.false(isValidPgTimestamptz(123)); + assert.false(isValidPgTimestamptz({})); + + // wrong timezone / missing ms / extra precision + assert.false(isValidPgTimestamptz("2026-02-11T14:25:00Z")); + assert.false(isValidPgTimestamptz("2026-02-11T14:25:00.000+09:00")); + assert.false(isValidPgTimestamptz("2026-02-11T14:25:00.0000Z")); + + // not ISO + assert.false(isValidPgTimestamptz("2026/02/11 14:25:00")); + assert.false(isValidPgTimestamptz("not-a-date")); + }); + + it("isValidPgTimestamptz: rejects impossible dates (round-trip check)", () => { + // Feb 30 is not real + assert.false(isValidPgTimestamptz("2026-02-30T14:25:00.000Z")); + + // invalid time + assert.false(isValidPgTimestamptz("2026-02-11T25:25:00.000Z")); + assert.false(isValidPgTimestamptz("2026-02-11T14:60:00.000Z")); + assert.false(isValidPgTimestamptz("2026-02-11T14:25:60.000Z")); + }); + + it("sanitizeComment: returns undefined for null/undefined/non-string/empty", () => { + assert.equal(sanitizeComment(null), undefined); + assert.equal(sanitizeComment(undefined), undefined); + assert.equal(sanitizeComment(123), undefined); + assert.equal(sanitizeComment({}), undefined); + + assert.equal(sanitizeComment(""), undefined); + assert.equal(sanitizeComment(" "), undefined); + assert.equal(sanitizeComment("\n\t "), undefined); + }); + + it("sanitizeComment: trims and keeps content", () => { + assert.equal(sanitizeComment(" hello "), "hello"); + assert.equal(sanitizeComment("hello"), "hello"); + }); + + it("sanitizeComment: truncates to max length without breaking surrogate pairs", () => { + const long = "a".repeat(300); + const out = sanitizeComment(long); + assert.defined(out); + assert.equal(out.length, 256); + assert.equal(out, "a".repeat(256)); + + // surrogate pairs: 😀 is length 2 in JS, but 1 code point + const emojis = "😀".repeat(400); + const out2 = sanitizeComment(emojis); + + // should truncate by code points to 256 emojis + assert.equal(Array.from(out2).length, 256); + assert.equal(out2, "😀".repeat(256)); + }); +}); diff --git a/packages/api-sync/source/tokens/sanitizers.ts b/packages/api-sync/source/tokens/sanitizers.ts new file mode 100644 index 0000000000..4f8a58bfb6 --- /dev/null +++ b/packages/api-sync/source/tokens/sanitizers.ts @@ -0,0 +1,31 @@ +const MAX_COMMENT_LEN = 256; + +// Strict ISO-8601 UTC with milliseconds: 2026-02-11T14:25:00.000Z +const PG_TIMESTAMPTZ_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +export const isValidPgTimestamptz = (timestamp: string): boolean => { + if (!PG_TIMESTAMPTZ_RE.test(timestamp)) { + return false; + } + + const parsed = Date.parse(timestamp); + return Number.isFinite(parsed) && new Date(parsed).toISOString() === timestamp; +}; + +export const sanitizeComment = (comment: string): string | undefined => { + if (comment === null) { + return; + } + + if (typeof comment !== "string") { + return; + } + + const trimmed = comment.trim(); + if (trimmed.length === 0) { + return; + } + + const clipped = [...trimmed].slice(0, MAX_COMMENT_LEN).join(""); + return clipped; +}; diff --git a/packages/api-sync/source/tokens/whitelist.ts b/packages/api-sync/source/tokens/whitelist.ts new file mode 100644 index 0000000000..5f318626a3 --- /dev/null +++ b/packages/api-sync/source/tokens/whitelist.ts @@ -0,0 +1,152 @@ +import { + Contracts as ApiDatabaseContracts, + Identifiers as ApiDatabaseIdentifiers, + Models, +} from "@mainsail/api-database"; +import { Identifiers, Units } from "@mainsail/constants"; +import { inject, injectable, tagged } from "@mainsail/container"; +import type { Contracts } from "@mainsail/contracts"; +import { http } from "@mainsail/utils"; + +import { isValidPgTimestamptz, sanitizeComment } from "./sanitizers.js"; + +interface WhitelistedToken { + address: string; + comment?: string; + createdAt: string; +} + +@injectable() +export class TokenWhitelist { + @inject(Identifiers.ServiceProvider.Configuration) + @tagged("plugin", "api-sync") + private readonly pluginConfiguration!: Contracts.Kernel.PluginConfiguration; + + @inject(ApiDatabaseIdentifiers.DataSource) + private readonly dataSource!: ApiDatabaseContracts.RepositoryDataSource; + + @inject(Identifiers.ApiSync.Logger) + private readonly logger!: Contracts.ApiSync.Logger; + + @inject(Identifiers.Cryptography.Identity.Address.Factory) + private readonly addressFactory!: Contracts.Crypto.AddressFactory; + + #syncInterval?: NodeJS.Timeout; + + public async bootstrap(): Promise { + const syncInterval = this.#getTokenWhitelistRefreshIntervalMs(); + + let running = false; + + this.logger.info(`Starting TokenWhitelist using remote: ${this.#getTokenWhitelistRemoteUrl()}`); + + this.#syncWhitelist() + .catch((error) => this.logger.error(`#syncWhitelist failed: ${error}`)) + .finally(() => { + this.#syncInterval = setInterval(async () => { + if (running) { + return; + } + + running = true; + + try { + await this.#syncWhitelist(); + } catch (ex) { + this.logger.error(`#syncWhitelist failed: ${ex}`); + } finally { + running = false; + } + }, syncInterval); + }); + } + + public async dispose(): Promise { + clearInterval(this.#syncInterval); + } + + async #syncWhitelist(): Promise { + const latestWhitelist = await this.#fetchWhitelist(); + if (!latestWhitelist) { + return; + } + + const sanitizedWhitelist = await this.#sanitizeWhitelist(latestWhitelist); + + this.logger.debug(`updating token whitelist (size: ${sanitizedWhitelist.length})`); + + await this.dataSource.transaction(async (entityManager) => { + await entityManager.clear(Models.TokenWhitelist); + await entityManager.save(Models.TokenWhitelist, sanitizedWhitelist, { chunk: 1000 }); + }); + } + + async #fetchWhitelist(): Promise { + const remoteUrl = this.#getTokenWhitelistRemoteUrl(); + if (!remoteUrl) { + return undefined; + } + + try { + const { data } = await http.get(this.#getTokenWhitelistRemoteUrl(), { + maxContentLength: 16 * Units.KILOBYTE, + timeout: 2500, + }); + return JSON.parse(data) as WhitelistedToken[]; + } catch (error) { + this.logger.error(`fetchWhitelist failed: ${error}`); + } + + return undefined; + } + + async #sanitizeWhitelist(whitelist: WhitelistedToken[]): Promise { + const sanitized: WhitelistedToken[] = []; + + for (const token of whitelist) { + const sanitizedToken = await this.#sanitizeToken(token); + if (!sanitizedToken) { + continue; + } + + sanitized.push(sanitizedToken); + } + + return sanitized; + } + + async #sanitizeToken(token: WhitelistedToken): Promise { + try { + if (!(await this.addressFactory.validate(token.address))) { + this.logger.debugExtra(`ignoring token for whitelist because of malformed address: ${token.address}`); + return undefined; + } + + if (!isValidPgTimestamptz(token.createdAt)) { + this.logger.debugExtra( + `ignoring token ${token.address} for whitelist because of malformed timestamp: ${token.createdAt}`, + ); + return undefined; + } + + if (token.comment) { + token.comment = sanitizeComment(token.comment); + } + } catch (error) { + this.logger.debugExtra( + `ignoring token ${token.address} for whitelist because of exception: ${error.message}`, + ); + return undefined; + } + + return token; + } + + #getTokenWhitelistRemoteUrl(): string { + return this.pluginConfiguration.getRequired("tokenWhitelistRemoteUrl"); + } + + #getTokenWhitelistRefreshIntervalMs(): number { + return this.pluginConfiguration.getRequired("tokenWhitelistRefreshInterval"); + } +} diff --git a/packages/constants/source/environment-variables.ts b/packages/constants/source/environment-variables.ts index 8c4bf37898..073c500f57 100644 --- a/packages/constants/source/environment-variables.ts +++ b/packages/constants/source/environment-variables.ts @@ -77,6 +77,8 @@ export const EnvironmentVariableNames = [ "MAINSAIL_API_SYNC_ENABLED", "MAINSAIL_API_SYNC_INTERVAL", "MAINSAIL_API_SYNC_TOKEN_CACHE_SIZE", + "MAINSAIL_API_SYNC_TOKEN_WHITELIST_SYNC_INTERVAL", + "MAINSAIL_API_SYNC_TOKEN_WHITELIST_REMOTE_URL", "MAINSAIL_API_SYNC_LOG_EXTRA", // Transaction pool API diff --git a/packages/constants/source/identifiers.ts b/packages/constants/source/identifiers.ts index d06a9f4696..8d581df62e 100644 --- a/packages/constants/source/identifiers.ts +++ b/packages/constants/source/identifiers.ts @@ -8,6 +8,7 @@ export const Identifiers = { Logger: Symbol("ApiSync"), Service: Symbol("ApiSync"), TokenParser: Symbol.for("ApiSync"), + TokenWhitelist: Symbol.for("ApiSync"), }, Application: { Environment: Symbol("Application"),