From 40c85b55cbfd47462874c379b6fdffdec3c1cba7 Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Mon, 10 Feb 2025 13:50:29 +0530 Subject: [PATCH 1/8] feat: Add Balance Subscriptions feature --- sdk/src/Engine.ts | 3 + sdk/src/index.ts | 1 + .../services/BalanceSubscriptionsService.ts | 263 ++++++++++++++++++ sdk/src/services/WalletCredentialsService.ts | 53 +++- sdk/src/services/WebhooksService.ts | 4 +- .../migration.sql | 29 ++ src/prisma/schema.prisma | 24 ++ src/server/middleware/admin-routes.ts | 2 + .../routes/balance-subscriptions/add.ts | 89 ++++++ .../routes/balance-subscriptions/get-all.ts | 36 +++ .../routes/balance-subscriptions/remove.ts | 53 ++++ .../routes/balance-subscriptions/update.ts | 83 ++++++ src/server/routes/index.ts | 10 + src/server/routes/system/health.ts | 5 +- src/server/schemas/balance-subscription.ts | 51 ++++ .../create-balance-subscription.ts | 59 ++++ .../delete-balance-subscription.ts | 16 ++ .../get-all-balance-subscriptions.ts | 18 ++ .../update-balance-subscription.ts | 38 +++ .../schemas/balance-subscription-config.ts | 33 +++ src/shared/schemas/webhooks.ts | 13 + src/worker/index.ts | 2 + .../queues/balance-subscription-queue.ts | 14 + src/worker/queues/send-webhook-queue.ts | 26 +- .../tasks/balance-subscription-worker.ts | 138 +++++++++ src/worker/tasks/send-webhook-worker.ts | 15 +- .../balance-subscription-worker.test.ts | 159 +++++++++++ 27 files changed, 1229 insertions(+), 8 deletions(-) create mode 100644 sdk/src/services/BalanceSubscriptionsService.ts create mode 100644 src/prisma/migrations/20250210081712_balance_subscriptions/migration.sql create mode 100644 src/server/routes/balance-subscriptions/add.ts create mode 100644 src/server/routes/balance-subscriptions/get-all.ts create mode 100644 src/server/routes/balance-subscriptions/remove.ts create mode 100644 src/server/routes/balance-subscriptions/update.ts create mode 100644 src/server/schemas/balance-subscription.ts create mode 100644 src/shared/db/balance-subscriptions/create-balance-subscription.ts create mode 100644 src/shared/db/balance-subscriptions/delete-balance-subscription.ts create mode 100644 src/shared/db/balance-subscriptions/get-all-balance-subscriptions.ts create mode 100644 src/shared/db/balance-subscriptions/update-balance-subscription.ts create mode 100644 src/shared/schemas/balance-subscription-config.ts create mode 100644 src/worker/queues/balance-subscription-queue.ts create mode 100644 src/worker/tasks/balance-subscription-worker.ts create mode 100644 tests/e2e/tests/workers/balance-subscription-worker.test.ts diff --git a/sdk/src/Engine.ts b/sdk/src/Engine.ts index 72dd69d95..25deb3149 100644 --- a/sdk/src/Engine.ts +++ b/sdk/src/Engine.ts @@ -10,6 +10,7 @@ import { AccessTokensService } from './services/AccessTokensService'; import { AccountService } from './services/AccountService'; import { AccountFactoryService } from './services/AccountFactoryService'; import { BackendWalletService } from './services/BackendWalletService'; +import { BalanceSubscriptionsService } from './services/BalanceSubscriptionsService'; import { ChainService } from './services/ChainService'; import { ConfigurationService } from './services/ConfigurationService'; import { ContractService } from './services/ContractService'; @@ -41,6 +42,7 @@ class EngineLogic { public readonly account: AccountService; public readonly accountFactory: AccountFactoryService; public readonly backendWallet: BackendWalletService; + public readonly balanceSubscriptions: BalanceSubscriptionsService; public readonly chain: ChainService; public readonly configuration: ConfigurationService; public readonly contract: ContractService; @@ -83,6 +85,7 @@ class EngineLogic { this.account = new AccountService(this.request); this.accountFactory = new AccountFactoryService(this.request); this.backendWallet = new BackendWalletService(this.request); + this.balanceSubscriptions = new BalanceSubscriptionsService(this.request); this.chain = new ChainService(this.request); this.configuration = new ConfigurationService(this.request); this.contract = new ContractService(this.request); diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 9a6e7cda8..ff96cf3a3 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -14,6 +14,7 @@ export { AccessTokensService } from './services/AccessTokensService'; export { AccountService } from './services/AccountService'; export { AccountFactoryService } from './services/AccountFactoryService'; export { BackendWalletService } from './services/BackendWalletService'; +export { BalanceSubscriptionsService } from './services/BalanceSubscriptionsService'; export { ChainService } from './services/ChainService'; export { ConfigurationService } from './services/ConfigurationService'; export { ContractService } from './services/ContractService'; diff --git a/sdk/src/services/BalanceSubscriptionsService.ts b/sdk/src/services/BalanceSubscriptionsService.ts new file mode 100644 index 000000000..6a144e587 --- /dev/null +++ b/sdk/src/services/BalanceSubscriptionsService.ts @@ -0,0 +1,263 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CancelablePromise } from '../core/CancelablePromise'; +import type { BaseHttpRequest } from '../core/BaseHttpRequest'; + +export class BalanceSubscriptionsService { + + constructor(public readonly httpRequest: BaseHttpRequest) {} + + /** + * Get balance subscriptions + * Get all balance subscriptions. + * @returns any Default Response + * @throws ApiError + */ + public getAllBalanceSubscriptions(): CancelablePromise<{ + result: Array<{ + id: string; + /** + * A chain ID ("137") or slug ("polygon-amoy-testnet"). Chain ID is preferred. + */ + chain: string; + /** + * A contract or wallet address + */ + contractAddress?: string; + /** + * A contract or wallet address + */ + walletAddress: string; + config: { + threshold?: { + /** + * Minimum balance threshold + */ + min?: string; + /** + * Maximum balance threshold + */ + max?: string; + }; + }; + webhook?: { + url: string; + }; + createdAt: string; + updatedAt: string; + }>; + }> { + return this.httpRequest.request({ + method: 'GET', + url: '/balance-subscriptions/get-all', + errors: { + 400: `Bad Request`, + 404: `Not Found`, + 500: `Internal Server Error`, + }, + }); + } + + /** + * Add balance subscription + * Subscribe to balance changes for a wallet. + * @param requestBody + * @returns any Default Response + * @throws ApiError + */ + public addBalanceSubscription( + requestBody: { + /** + * A chain ID ("137") or slug ("polygon-amoy-testnet"). Chain ID is preferred. + */ + chain: string; + /** + * A contract or wallet address + */ + contractAddress?: string; + /** + * A contract or wallet address + */ + walletAddress: string; + config: { + threshold?: { + /** + * Minimum balance threshold + */ + min?: string; + /** + * Maximum balance threshold + */ + max?: string; + }; + }; + /** + * Webhook URL + */ + webhookUrl?: string; + }, + ): CancelablePromise<{ + result: { + id: string; + /** + * A chain ID ("137") or slug ("polygon-amoy-testnet"). Chain ID is preferred. + */ + chain: string; + /** + * A contract or wallet address + */ + contractAddress?: string; + /** + * A contract or wallet address + */ + walletAddress: string; + config: { + threshold?: { + /** + * Minimum balance threshold + */ + min?: string; + /** + * Maximum balance threshold + */ + max?: string; + }; + }; + webhook?: { + url: string; + }; + createdAt: string; + updatedAt: string; + }; + }> { + return this.httpRequest.request({ + method: 'POST', + url: '/balance-subscriptions/add', + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Bad Request`, + 404: `Not Found`, + 500: `Internal Server Error`, + }, + }); + } + + /** + * Update balance subscription + * Update an existing balance subscription. + * @param requestBody + * @returns any Default Response + * @throws ApiError + */ + public updateBalanceSubscription( + requestBody: { + /** + * The ID of the balance subscription to update. + */ + balanceSubscriptionId: string; + /** + * A chain ID ("137") or slug ("polygon-amoy-testnet"). Chain ID is preferred. + */ + chain?: string; + contractAddress?: (string | null); + /** + * A contract or wallet address + */ + walletAddress?: string; + config?: { + threshold?: { + /** + * Minimum balance threshold + */ + min?: string; + /** + * Maximum balance threshold + */ + max?: string; + }; + }; + webhookId?: (number | null); + }, + ): CancelablePromise<{ + result: { + id: string; + /** + * A chain ID ("137") or slug ("polygon-amoy-testnet"). Chain ID is preferred. + */ + chain: string; + /** + * A contract or wallet address + */ + contractAddress?: string; + /** + * A contract or wallet address + */ + walletAddress: string; + config: { + threshold?: { + /** + * Minimum balance threshold + */ + min?: string; + /** + * Maximum balance threshold + */ + max?: string; + }; + }; + webhook?: { + url: string; + }; + createdAt: string; + updatedAt: string; + }; + }> { + return this.httpRequest.request({ + method: 'POST', + url: '/balance-subscriptions/update', + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Bad Request`, + 404: `Not Found`, + 500: `Internal Server Error`, + }, + }); + } + + /** + * Remove balance subscription + * Remove an existing balance subscription + * @param requestBody + * @returns any Default Response + * @throws ApiError + */ + public removeBalanceSubscription( + requestBody: { + /** + * The ID for an existing balance subscription. + */ + balanceSubscriptionId: string; + }, + ): CancelablePromise<{ + result: { + status: string; + }; + }> { + return this.httpRequest.request({ + method: 'POST', + url: '/balance-subscriptions/remove', + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Bad Request`, + 404: `Not Found`, + 500: `Internal Server Error`, + }, + }); + } + +} diff --git a/sdk/src/services/WalletCredentialsService.ts b/sdk/src/services/WalletCredentialsService.ts index 1ac32e4ab..bb973fc73 100644 --- a/sdk/src/services/WalletCredentialsService.ts +++ b/sdk/src/services/WalletCredentialsService.ts @@ -21,9 +21,9 @@ export class WalletCredentialsService { label: string; type: 'circle'; /** - * 32-byte hex string. If not provided, a random one will be generated. + * 32-byte hex string. Consult https://developers.circle.com/w3s/entity-secret-management to create and register an entity secret. */ - entitySecret?: string; + entitySecret: string; /** * Whether this credential should be set as the default for its type. Only one credential can be default per type. */ @@ -102,7 +102,7 @@ export class WalletCredentialsService { id: string; type: string; label: (string | null); - isDefault: boolean; + isDefault: (boolean | null); createdAt: string; updatedAt: string; deletedAt: (string | null); @@ -122,4 +122,51 @@ export class WalletCredentialsService { }); } + /** + * Update wallet credential + * Update a wallet credential's label, default status, and entity secret. + * @param id The ID of the wallet credential to update. + * @param requestBody + * @returns any Default Response + * @throws ApiError + */ + public updateWalletCredential( + id: string, + requestBody?: { + label?: string; + /** + * Whether this credential should be set as the default for its type. Only one credential can be default per type. + */ + isDefault?: boolean; + /** + * 32-byte hex string. Consult https://developers.circle.com/w3s/entity-secret-management to create and register an entity secret. + */ + entitySecret?: string; + }, + ): CancelablePromise<{ + result: { + id: string; + type: string; + label: (string | null); + isDefault: (boolean | null); + createdAt: string; + updatedAt: string; + }; + }> { + return this.httpRequest.request({ + method: 'PUT', + url: '/wallet-credentials/{id}', + path: { + 'id': id, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Bad Request`, + 404: `Not Found`, + 500: `Internal Server Error`, + }, + }); + } + } diff --git a/sdk/src/services/WebhooksService.ts b/sdk/src/services/WebhooksService.ts index 5e6a0b90a..e9aee46b3 100644 --- a/sdk/src/services/WebhooksService.ts +++ b/sdk/src/services/WebhooksService.ts @@ -51,7 +51,7 @@ export class WebhooksService { */ url: string; name?: string; - eventType: ('queued_transaction' | 'sent_transaction' | 'mined_transaction' | 'errored_transaction' | 'cancelled_transaction' | 'all_transactions' | 'backend_wallet_balance' | 'auth' | 'contract_subscription'); + eventType: ('queued_transaction' | 'sent_transaction' | 'mined_transaction' | 'errored_transaction' | 'cancelled_transaction' | 'all_transactions' | 'backend_wallet_balance' | 'auth' | 'contract_subscription' | 'balance_subscription'); }, ): CancelablePromise<{ result: { @@ -113,7 +113,7 @@ export class WebhooksService { * @throws ApiError */ public getEventTypes(): CancelablePromise<{ - result: Array<('queued_transaction' | 'sent_transaction' | 'mined_transaction' | 'errored_transaction' | 'cancelled_transaction' | 'all_transactions' | 'backend_wallet_balance' | 'auth' | 'contract_subscription')>; + result: Array<('queued_transaction' | 'sent_transaction' | 'mined_transaction' | 'errored_transaction' | 'cancelled_transaction' | 'all_transactions' | 'backend_wallet_balance' | 'auth' | 'contract_subscription' | 'balance_subscription')>; }> { return this.httpRequest.request({ method: 'GET', diff --git a/src/prisma/migrations/20250210081712_balance_subscriptions/migration.sql b/src/prisma/migrations/20250210081712_balance_subscriptions/migration.sql new file mode 100644 index 000000000..a0476959c --- /dev/null +++ b/src/prisma/migrations/20250210081712_balance_subscriptions/migration.sql @@ -0,0 +1,29 @@ +-- AlterTable +ALTER TABLE "configuration" ADD COLUMN "balanceSubscriptionsCronSchedule" TEXT; + +-- CreateTable +CREATE TABLE "balance_subscriptions" ( + "id" TEXT NOT NULL, + "chainId" TEXT NOT NULL, + "contractAddress" TEXT, + "walletAddress" TEXT NOT NULL, + "config" JSONB NOT NULL, + "webhookId" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "balance_subscriptions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "balance_subscriptions_chainId_idx" ON "balance_subscriptions"("chainId"); + +-- CreateIndex +CREATE INDEX "balance_subscriptions_contractAddress_idx" ON "balance_subscriptions"("contractAddress"); + +-- CreateIndex +CREATE INDEX "balance_subscriptions_walletAddress_idx" ON "balance_subscriptions"("walletAddress"); + +-- AddForeignKey +ALTER TABLE "balance_subscriptions" ADD CONSTRAINT "balance_subscriptions_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "webhooks"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 1c4a473c4..6a486ce04 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -29,6 +29,8 @@ model Configuration { cursorDelaySeconds Int @default(2) @map("cursorDelaySeconds") contractSubscriptionsRetryDelaySeconds String @default("10") @map("contractSubscriptionsRetryDelaySeconds") + balanceSubscriptionsCronSchedule String? @map("balanceSubscriptionsCronSchedule") + // Wallet provider specific configurations, non-credential walletProviderConfigs Json @default("{}") @map("walletProviderConfigs") /// Eg: { "aws": { "defaultAwsRegion": "us-east-1" }, "gcp": { "defaultGcpKmsLocationId": "us-east1-b" } } @@ -221,6 +223,7 @@ model Webhooks { updatedAt DateTime @updatedAt @map("updatedAt") revokedAt DateTime? @map("revokedAt") ContractSubscriptions ContractSubscriptions[] + BalanceSubscriptions BalanceSubscriptions[] @@map("webhooks") } @@ -286,6 +289,27 @@ model ContractEventLogs { @@map("contract_event_logs") } +model BalanceSubscriptions { + id String @id @default(uuid()) + chainId String + contractAddress String? /// optional for ERC20 balances, if null then native balance + walletAddress String + + config Json + + webhookId Int? + webhook Webhooks? @relation(fields: [webhookId], references: [id], onDelete: SetNull) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + @@index([chainId]) + @@index([contractAddress]) + @@index([walletAddress]) + @@map("balance_subscriptions") +} + model ContractTransactionReceipts { chainId String blockNumber Int diff --git a/src/server/middleware/admin-routes.ts b/src/server/middleware/admin-routes.ts index f503a5b9b..e1c871ea0 100644 --- a/src/server/middleware/admin-routes.ts +++ b/src/server/middleware/admin-routes.ts @@ -15,6 +15,7 @@ import { ProcessTransactionReceiptsQueue } from "../../worker/queues/process-tra import { PruneTransactionsQueue } from "../../worker/queues/prune-transactions-queue"; import { SendTransactionQueue } from "../../worker/queues/send-transaction-queue"; import { SendWebhookQueue } from "../../worker/queues/send-webhook-queue"; +import { BalanceSubscriptionQueue } from "../../worker/queues/balance-subscription-queue"; export const ADMIN_QUEUES_BASEPATH = "/admin/queues"; const ADMIN_ROUTES_USERNAME = "admin"; @@ -31,6 +32,7 @@ const QUEUES: Queue[] = [ PruneTransactionsQueue.q, NonceResyncQueue.q, NonceHealthCheckQueue.q, + BalanceSubscriptionQueue.q, ]; export const withAdminRoutes = async (fastify: FastifyInstance) => { diff --git a/src/server/routes/balance-subscriptions/add.ts b/src/server/routes/balance-subscriptions/add.ts new file mode 100644 index 000000000..fecd56b30 --- /dev/null +++ b/src/server/routes/balance-subscriptions/add.ts @@ -0,0 +1,89 @@ +import { type Static, Type } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { createBalanceSubscription } from "../../../shared/db/balance-subscriptions/create-balance-subscription"; +import { insertWebhook } from "../../../shared/db/webhooks/create-webhook"; +import { balanceSubscriptionConfigSchema } from "../../../shared/schemas/balance-subscription-config"; +import { WebhooksEventTypes } from "../../../shared/schemas/webhooks"; +import { createCustomError } from "../../middleware/error"; +import { AddressSchema } from "../../schemas/address"; +import { chainIdOrSlugSchema } from "../../schemas/chain"; +import { standardResponseSchema } from "../../schemas/shared-api-schemas"; +import { getChainIdFromChain } from "../../utils/chain"; +import { isValidWebhookUrl } from "../../utils/validator"; +import { balanceSubscriptionSchema, toBalanceSubscriptionSchema } from "../../schemas/balance-subscription"; + +const requestBodySchema = Type.Object({ + chain: chainIdOrSlugSchema, + contractAddress: Type.Optional(AddressSchema), + walletAddress: AddressSchema, + config: balanceSubscriptionConfigSchema, + webhookUrl: Type.Optional( + Type.String({ + description: "Webhook URL", + examples: ["https://example.com/webhook"], + }), + ), +}); + +const responseSchema = Type.Object({ + result: balanceSubscriptionSchema, +}); + +export async function addBalanceSubscriptionRoute(fastify: FastifyInstance) { + fastify.route<{ + Body: Static; + Reply: Static; + }>({ + method: "POST", + url: "/balance-subscriptions/add", + schema: { + summary: "Add balance subscription", + description: "Subscribe to balance changes for a wallet.", + tags: ["Balance-Subscriptions"], + operationId: "addBalanceSubscription", + body: requestBodySchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseSchema, + }, + }, + handler: async (request, reply) => { + const { chain, contractAddress, walletAddress, config, webhookUrl } = request.body; + + const chainId = await getChainIdFromChain(chain); + + // Create the webhook (if provided). + let webhookId: number | undefined; + if (webhookUrl) { + if (!isValidWebhookUrl(webhookUrl)) { + throw createCustomError( + "Invalid webhook URL. Make sure it starts with 'https://'.", + StatusCodes.BAD_REQUEST, + "BAD_REQUEST", + ); + } + + const webhook = await insertWebhook({ + eventType: WebhooksEventTypes.BALANCE_SUBSCRIPTION, + name: "(Auto-generated)", + url: webhookUrl, + }); + webhookId = webhook.id; + } + + // Create the balance subscription. + const balanceSubscription = await createBalanceSubscription({ + chainId: chainId.toString(), + contractAddress: contractAddress?.toLowerCase(), + walletAddress: walletAddress.toLowerCase(), + config, + webhookId, + }); + + reply.status(StatusCodes.OK).send({ + result: toBalanceSubscriptionSchema(balanceSubscription), + }); + }, + }); +} \ No newline at end of file diff --git a/src/server/routes/balance-subscriptions/get-all.ts b/src/server/routes/balance-subscriptions/get-all.ts new file mode 100644 index 000000000..389f6dcd0 --- /dev/null +++ b/src/server/routes/balance-subscriptions/get-all.ts @@ -0,0 +1,36 @@ +import { type Static, Type } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { getAllBalanceSubscriptions } from "../../../shared/db/balance-subscriptions/get-all-balance-subscriptions"; +import { balanceSubscriptionSchema, toBalanceSubscriptionSchema } from "../../schemas/balance-subscription"; +import { standardResponseSchema } from "../../schemas/shared-api-schemas"; + +const responseSchema = Type.Object({ + result: Type.Array(balanceSubscriptionSchema), +}); + +export async function getAllBalanceSubscriptionsRoute(fastify: FastifyInstance) { + fastify.route<{ + Reply: Static; + }>({ + method: "GET", + url: "/balance-subscriptions/get-all", + schema: { + summary: "Get balance subscriptions", + description: "Get all balance subscriptions.", + tags: ["Balance-Subscriptions"], + operationId: "getAllBalanceSubscriptions", + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseSchema, + }, + }, + handler: async (_request, reply) => { + const balanceSubscriptions = await getAllBalanceSubscriptions(); + + reply.status(StatusCodes.OK).send({ + result: balanceSubscriptions.map(toBalanceSubscriptionSchema), + }); + }, + }); +} \ No newline at end of file diff --git a/src/server/routes/balance-subscriptions/remove.ts b/src/server/routes/balance-subscriptions/remove.ts new file mode 100644 index 000000000..500101d71 --- /dev/null +++ b/src/server/routes/balance-subscriptions/remove.ts @@ -0,0 +1,53 @@ +import { type Static, Type } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { deleteBalanceSubscription } from "../../../shared/db/balance-subscriptions/delete-balance-subscription"; +import { deleteWebhook } from "../../../shared/db/webhooks/revoke-webhook"; +import { standardResponseSchema } from "../../schemas/shared-api-schemas"; + +const requestBodySchema = Type.Object({ + balanceSubscriptionId: Type.String({ + description: "The ID for an existing balance subscription.", + }), +}); + +const responseSchema = Type.Object({ + result: Type.Object({ + status: Type.String(), + }), +}); + +export async function removeBalanceSubscriptionRoute(fastify: FastifyInstance) { + fastify.route<{ + Body: Static; + Reply: Static; + }>({ + method: "POST", + url: "/balance-subscriptions/remove", + schema: { + summary: "Remove balance subscription", + description: "Remove an existing balance subscription", + tags: ["Balance-Subscriptions"], + operationId: "removeBalanceSubscription", + body: requestBodySchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseSchema, + }, + }, + handler: async (request, reply) => { + const { balanceSubscriptionId } = request.body; + + const balanceSubscription = await deleteBalanceSubscription(balanceSubscriptionId); + if (balanceSubscription.webhookId) { + await deleteWebhook(balanceSubscription.webhookId); + } + + reply.status(StatusCodes.OK).send({ + result: { + status: "success", + }, + }); + }, + }); +} \ No newline at end of file diff --git a/src/server/routes/balance-subscriptions/update.ts b/src/server/routes/balance-subscriptions/update.ts new file mode 100644 index 000000000..dcc4f26cf --- /dev/null +++ b/src/server/routes/balance-subscriptions/update.ts @@ -0,0 +1,83 @@ +import { type Static, Type } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { updateBalanceSubscription } from "../../../shared/db/balance-subscriptions/update-balance-subscription"; +import { balanceSubscriptionConfigSchema } from "../../../shared/schemas/balance-subscription-config"; +import { AddressSchema } from "../../schemas/address"; +import { chainIdOrSlugSchema } from "../../schemas/chain"; +import { + balanceSubscriptionSchema, + toBalanceSubscriptionSchema, +} from "../../schemas/balance-subscription"; +import { standardResponseSchema } from "../../schemas/shared-api-schemas"; +import { getChainIdFromChain } from "../../utils/chain"; + +const requestBodySchema = Type.Object({ + balanceSubscriptionId: Type.String({ + description: "The ID of the balance subscription to update.", + }), + chain: Type.Optional(chainIdOrSlugSchema), + contractAddress: Type.Optional(Type.Union([AddressSchema, Type.Null()])), + walletAddress: Type.Optional(AddressSchema), + config: Type.Optional(balanceSubscriptionConfigSchema), + webhookId: Type.Optional( + Type.Union([ + Type.Integer({ + description: "The ID of an existing webhook to use.", + }), + Type.Null(), + ]), + ), +}); + +const responseSchema = Type.Object({ + result: balanceSubscriptionSchema, +}); + +export async function updateBalanceSubscriptionRoute(fastify: FastifyInstance) { + fastify.route<{ + Body: Static; + Reply: Static; + }>({ + method: "POST", + url: "/balance-subscriptions/update", + schema: { + summary: "Update balance subscription", + description: "Update an existing balance subscription.", + tags: ["Balance-Subscriptions"], + operationId: "updateBalanceSubscription", + body: requestBodySchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseSchema, + }, + }, + handler: async (request, reply) => { + const { + balanceSubscriptionId, + chain, + contractAddress, + walletAddress, + config, + webhookId, + } = request.body; + + // Get chainId if chain is provided + const chainId = chain ? await getChainIdFromChain(chain) : undefined; + + // Update the subscription + const balanceSubscription = await updateBalanceSubscription({ + id: balanceSubscriptionId, + chainId: chainId?.toString(), + contractAddress, + walletAddress, + config, + webhookId, + }); + + reply.status(StatusCodes.OK).send({ + result: toBalanceSubscriptionSchema(balanceSubscription), + }); + }, + }); +} diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index be174a78f..f52c060dc 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -117,6 +117,10 @@ import { createWalletCredentialRoute } from "./wallet-credentials/create"; import { getWalletCredentialRoute } from "./wallet-credentials/get"; import { getAllWalletCredentialsRoute } from "./wallet-credentials/get-all"; import { updateWalletCredentialRoute } from "./wallet-credentials/update"; +import { addBalanceSubscriptionRoute } from "./balance-subscriptions/add"; +import { getAllBalanceSubscriptionsRoute } from "./balance-subscriptions/get-all"; +import { removeBalanceSubscriptionRoute } from "./balance-subscriptions/remove"; +import { updateBalanceSubscriptionRoute } from "./balance-subscriptions/update"; export async function withRoutes(fastify: FastifyInstance) { // Backend Wallets @@ -268,6 +272,12 @@ export async function withRoutes(fastify: FastifyInstance) { await fastify.register(getContractIndexedBlockRange); await fastify.register(getLatestBlock); + // Balance Subscriptions + await fastify.register(getAllBalanceSubscriptionsRoute); + await fastify.register(addBalanceSubscriptionRoute); + await fastify.register(updateBalanceSubscriptionRoute); + await fastify.register(removeBalanceSubscriptionRoute); + // Contract Transactions // @deprecated await fastify.register(getContractTransactionReceipts); diff --git a/src/server/routes/system/health.ts b/src/server/routes/system/health.ts index ed3718df1..3613c64de 100644 --- a/src/server/routes/system/health.ts +++ b/src/server/routes/system/health.ts @@ -12,7 +12,8 @@ type EngineFeature = | "IP_ALLOWLIST" | "HETEROGENEOUS_WALLET_TYPES" | "SMART_BACKEND_WALLETS" - | "WALLET_CREDENTIALS"; + | "WALLET_CREDENTIALS" + | "BALANCE_SUBSCRIPTIONS"; const ReplySchema = Type.Object({ db: Type.Boolean(), @@ -28,6 +29,7 @@ const ReplySchema = Type.Object({ Type.Literal("HETEROGENEOUS_WALLET_TYPES"), Type.Literal("SMART_BACKEND_WALLETS"), Type.Literal("WALLET_CREDENTIALS"), + Type.Literal("BALANCE_SUBSCRIPTIONS"), ]), ), clientId: Type.String(), @@ -80,6 +82,7 @@ const getFeatures = (): EngineFeature[] => { "CONTRACT_SUBSCRIPTIONS", "SMART_BACKEND_WALLETS", "WALLET_CREDENTIALS", + "BALANCE_SUBSCRIPTIONS", ]; if (env.ENABLE_KEYPAIR_AUTH) features.push("KEYPAIR_AUTH"); diff --git a/src/server/schemas/balance-subscription.ts b/src/server/schemas/balance-subscription.ts new file mode 100644 index 000000000..c91922415 --- /dev/null +++ b/src/server/schemas/balance-subscription.ts @@ -0,0 +1,51 @@ +import { Type } from "@sinclair/typebox"; +import type { Prisma, Webhooks } from "@prisma/client"; +import { AddressSchema } from "./address"; +import { chainIdOrSlugSchema } from "./chain"; +import { balanceSubscriptionConfigSchema, balanceSubscriptionConfigZodSchema } from "../../shared/schemas/balance-subscription-config"; + +interface BalanceSubscriptionWithWebhook { + id: string; + chainId: string; + contractAddress: string | null; + walletAddress: string; + config: Prisma.JsonValue; + webhookId: number | null; + webhook: Webhooks | null; + createdAt: Date; + updatedAt: Date; +} + +export const balanceSubscriptionSchema = Type.Object({ + id: Type.String(), + chain: chainIdOrSlugSchema, + contractAddress: Type.Optional(AddressSchema), + walletAddress: AddressSchema, + config: balanceSubscriptionConfigSchema, + webhook: Type.Optional( + Type.Object({ + url: Type.String(), + }), + ), + createdAt: Type.String(), + updatedAt: Type.String(), +}); + +export type BalanceSubscriptionSchema = typeof balanceSubscriptionSchema; + +export function toBalanceSubscriptionSchema(subscription: BalanceSubscriptionWithWebhook) { + return { + id: subscription.id, + chain: subscription.chainId, + contractAddress: subscription.contractAddress ?? undefined, + walletAddress: subscription.walletAddress, + config: balanceSubscriptionConfigZodSchema.parse(subscription.config), + webhook: subscription.webhookId && subscription.webhook + ? { + url: subscription.webhook.url, + } + : undefined, + createdAt: subscription.createdAt.toISOString(), + updatedAt: subscription.updatedAt.toISOString(), + }; +} \ No newline at end of file diff --git a/src/shared/db/balance-subscriptions/create-balance-subscription.ts b/src/shared/db/balance-subscriptions/create-balance-subscription.ts new file mode 100644 index 000000000..86a0f6261 --- /dev/null +++ b/src/shared/db/balance-subscriptions/create-balance-subscription.ts @@ -0,0 +1,59 @@ +import type { Prisma } from "@prisma/client"; +import { prisma } from "../client"; +import type { BalanceSubscriptionConfig } from "../../schemas/balance-subscription-config"; + +interface CreateBalanceSubscriptionParams { + chainId: string; + contractAddress?: string; + walletAddress: string; + config: BalanceSubscriptionConfig; + webhookId?: number; +} + +export async function createBalanceSubscription({ + chainId, + contractAddress, + walletAddress, + config, + webhookId, +}: CreateBalanceSubscriptionParams) { + // Check if a non-deleted subscription already exists + const existingSubscription = await prisma.balanceSubscriptions.findFirst({ + where: { + chainId, + contractAddress, + walletAddress, + deletedAt: null, + }, + }); + + if (existingSubscription) { + // Update the existing subscription + return await prisma.balanceSubscriptions.update({ + where: { + id: existingSubscription.id, + }, + data: { + config: config as Prisma.InputJsonValue, + webhookId, + }, + include: { + webhook: true, + }, + }); + } + + // Create a new subscription + return await prisma.balanceSubscriptions.create({ + data: { + chainId, + contractAddress, + walletAddress, + config: config as Prisma.InputJsonValue, + webhookId, + }, + include: { + webhook: true, + }, + }); +} \ No newline at end of file diff --git a/src/shared/db/balance-subscriptions/delete-balance-subscription.ts b/src/shared/db/balance-subscriptions/delete-balance-subscription.ts new file mode 100644 index 000000000..541bf863c --- /dev/null +++ b/src/shared/db/balance-subscriptions/delete-balance-subscription.ts @@ -0,0 +1,16 @@ +import { prisma } from "../client"; + +export async function deleteBalanceSubscription(id: string) { + return await prisma.balanceSubscriptions.update({ + where: { + id, + deletedAt: null, + }, + data: { + deletedAt: new Date(), + }, + include: { + webhook: true, + }, + }); +} \ No newline at end of file diff --git a/src/shared/db/balance-subscriptions/get-all-balance-subscriptions.ts b/src/shared/db/balance-subscriptions/get-all-balance-subscriptions.ts new file mode 100644 index 000000000..0988a21c0 --- /dev/null +++ b/src/shared/db/balance-subscriptions/get-all-balance-subscriptions.ts @@ -0,0 +1,18 @@ +import { parseBalanceSubscriptionConfig } from "../../schemas/balance-subscription-config"; +import { prisma } from "../client"; + +export async function getAllBalanceSubscriptions() { + const subscriptions = await prisma.balanceSubscriptions.findMany({ + where: { + deletedAt: null, + }, + include: { + webhook: true, + }, + }); + + return subscriptions.map((subscription) => ({ + ...subscription, + config: parseBalanceSubscriptionConfig(subscription.config), + })); +} diff --git a/src/shared/db/balance-subscriptions/update-balance-subscription.ts b/src/shared/db/balance-subscriptions/update-balance-subscription.ts new file mode 100644 index 000000000..23683b28a --- /dev/null +++ b/src/shared/db/balance-subscriptions/update-balance-subscription.ts @@ -0,0 +1,38 @@ +import type { Prisma } from "@prisma/client"; +import { prisma } from "../client"; +import type { BalanceSubscriptionConfig } from "../../schemas/balance-subscription-config"; + +interface UpdateBalanceSubscriptionParams { + id: string; + chainId?: string; + contractAddress?: string | null; + walletAddress?: string; + config?: BalanceSubscriptionConfig; + webhookId?: number | null; +} + +export async function updateBalanceSubscription({ + id, + chainId, + contractAddress, + walletAddress, + config, + webhookId, +}: UpdateBalanceSubscriptionParams) { + return await prisma.balanceSubscriptions.update({ + where: { + id, + deletedAt: null, + }, + data: { + ...(chainId && { chainId }), + ...(contractAddress !== undefined && { contractAddress }), + ...(walletAddress && { walletAddress: walletAddress.toLowerCase() }), + ...(config && { config: config as Prisma.InputJsonValue }), + ...(webhookId !== undefined && { webhookId }), + }, + include: { + webhook: true, + }, + }); +} \ No newline at end of file diff --git a/src/shared/schemas/balance-subscription-config.ts b/src/shared/schemas/balance-subscription-config.ts new file mode 100644 index 000000000..c0f897482 --- /dev/null +++ b/src/shared/schemas/balance-subscription-config.ts @@ -0,0 +1,33 @@ +import { Type } from "@sinclair/typebox"; +import { z } from "zod"; + +// TypeBox schema for request validation +export const balanceSubscriptionConfigSchema = Type.Object({ + threshold: Type.Optional( + Type.Object({ + min: Type.Optional(Type.String({ + description: "Minimum balance threshold", + examples: ["1000000000000000000"], // 1 ETH in wei + })), + max: Type.Optional(Type.String({ + description: "Maximum balance threshold", + examples: ["10000000000000000000"], // 10 ETH in wei + })), + }), + ), +}); + +// Zod schema for response parsing +export const balanceSubscriptionConfigZodSchema = z.object({ + threshold: z.object({ + min: z.string().optional(), + max: z.string().optional(), + }).optional(), +}); + +export type BalanceSubscriptionConfig = z.infer; + +// Helper to ensure config is properly typed when creating +export function parseBalanceSubscriptionConfig(config: unknown): BalanceSubscriptionConfig { + return balanceSubscriptionConfigZodSchema.parse(config); +} \ No newline at end of file diff --git a/src/shared/schemas/webhooks.ts b/src/shared/schemas/webhooks.ts index 57279d378..451aec806 100644 --- a/src/shared/schemas/webhooks.ts +++ b/src/shared/schemas/webhooks.ts @@ -1,3 +1,6 @@ +import type { z } from "zod"; +import type { balanceSubscriptionConfigZodSchema } from "./balance-subscription-config"; + export enum WebhooksEventTypes { QUEUED_TX = "queued_transaction", SENT_TX = "sent_transaction", @@ -8,6 +11,7 @@ export enum WebhooksEventTypes { BACKEND_WALLET_BALANCE = "backend_wallet_balance", AUTH = "auth", CONTRACT_SUBSCRIPTION = "contract_subscription", + BALANCE_SUBSCRIPTION = "balance_subscription", } export type BackendWalletBalanceWebhookParams = { @@ -17,3 +21,12 @@ export type BackendWalletBalanceWebhookParams = { chainId: number; message: string; }; + +export interface BalanceSubscriptionWebhookParams { + subscriptionId: string; + chainId: string; + contractAddress: string | null; + walletAddress: string; + balance: string; + config: z.infer; +} diff --git a/src/worker/index.ts b/src/worker/index.ts index dce96767f..6fb16f071 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -7,6 +7,7 @@ import { newWebhooksListener, updatedWebhooksListener, } from "./listeners/webhook-listener"; +import { initBalanceSubscriptionWorker } from "./tasks/balance-subscription-worker"; import { initCancelRecycledNoncesWorker } from "./tasks/cancel-recycled-nonces-worker"; import { initMineTransactionWorker } from "./tasks/mine-transaction-worker"; import { initNonceHealthCheckWorker } from "./tasks/nonce-health-check-worker"; @@ -29,6 +30,7 @@ export const initWorker = async () => { initNonceHealthCheckWorker(); await initNonceResyncWorker(); + await initBalanceSubscriptionWorker(); // Listen for new & updated configuration data. await newConfigurationListener(); diff --git a/src/worker/queues/balance-subscription-queue.ts b/src/worker/queues/balance-subscription-queue.ts new file mode 100644 index 000000000..60b65f0c3 --- /dev/null +++ b/src/worker/queues/balance-subscription-queue.ts @@ -0,0 +1,14 @@ +import { Queue } from "bullmq"; +import { redis } from "../../shared/utils/redis/redis"; +import { defaultJobOptions } from "./queues"; + +export class BalanceSubscriptionQueue { + static q = new Queue("balance-subscription", { + connection: redis, + defaultJobOptions, + }); + + constructor() { + BalanceSubscriptionQueue.q.setGlobalConcurrency(1); + } +} \ No newline at end of file diff --git a/src/worker/queues/send-webhook-queue.ts b/src/worker/queues/send-webhook-queue.ts index 1f79bc224..b5ae15a3c 100644 --- a/src/worker/queues/send-webhook-queue.ts +++ b/src/worker/queues/send-webhook-queue.ts @@ -8,6 +8,7 @@ import SuperJSON from "superjson"; import { WebhooksEventTypes, type BackendWalletBalanceWebhookParams, + type BalanceSubscriptionWebhookParams, } from "../../shared/schemas/webhooks"; import { getWebhooksByEventType } from "../../shared/utils/cache/get-webhook"; import { redis } from "../../shared/utils/redis/redis"; @@ -34,11 +35,18 @@ export type EnqueueLowBalanceWebhookData = { body: BackendWalletBalanceWebhookParams; }; +export type EnqueueBalanceSubscriptionWebhookData = { + type: WebhooksEventTypes.BALANCE_SUBSCRIPTION; + webhook: Webhooks; + body: BalanceSubscriptionWebhookParams; +}; + // Add other webhook event types here. type EnqueueWebhookData = | EnqueueContractSubscriptionWebhookData | EnqueueTransactionWebhookData - | EnqueueLowBalanceWebhookData; + | EnqueueLowBalanceWebhookData + | EnqueueBalanceSubscriptionWebhookData; export interface WebhookJob { data: EnqueueWebhookData; @@ -66,6 +74,8 @@ export class SendWebhookQueue { return this._enqueueTransactionWebhook(data); case WebhooksEventTypes.BACKEND_WALLET_BALANCE: return this._enqueueBackendWalletBalanceWebhook(data); + case WebhooksEventTypes.BALANCE_SUBSCRIPTION: + return this._enqueueBalanceSubscriptionWebhook(data); } }; @@ -161,4 +171,18 @@ export class SendWebhookQueue { ); } }; + + private static _enqueueBalanceSubscriptionWebhook = async ( + data: EnqueueBalanceSubscriptionWebhookData, + ) => { + const { type, webhook, body } = data; + if (!webhook.revokedAt && type === webhook.eventType) { + const job: WebhookJob = { data, webhook }; + const serialized = SuperJSON.stringify(job); + await this.q.add( + `${type}:${body.chainId}:${body.walletAddress}:${body.subscriptionId}`, + serialized, + ); + } + }; } diff --git a/src/worker/tasks/balance-subscription-worker.ts b/src/worker/tasks/balance-subscription-worker.ts new file mode 100644 index 000000000..26ba1118d --- /dev/null +++ b/src/worker/tasks/balance-subscription-worker.ts @@ -0,0 +1,138 @@ +import { type Job, type Processor, Worker } from "bullmq"; +import { getAllBalanceSubscriptions } from "../../shared/db/balance-subscriptions/get-all-balance-subscriptions"; +import { getConfig } from "../../shared/utils/cache/get-config"; +import { logger } from "../../shared/utils/logger"; +import { redis } from "../../shared/utils/redis/redis"; +import { BalanceSubscriptionQueue } from "../queues/balance-subscription-queue"; +import { logWorkerExceptions } from "../queues/queues"; +import { SendWebhookQueue } from "../queues/send-webhook-queue"; +import { + WebhooksEventTypes, + type BalanceSubscriptionWebhookParams, +} from "../../shared/schemas/webhooks"; +import { parseBalanceSubscriptionConfig } from "../../shared/schemas/balance-subscription-config"; +import { getChain } from "../../shared/utils/chain"; +import { eth_getBalance, getContract, getRpcClient } from "thirdweb"; +import { thirdwebClient } from "../../shared/utils/sdk"; +import { balanceOf } from "thirdweb/extensions/erc20"; +import { maxUint256 } from "thirdweb/utils"; + +// Split array into chunks of specified size +function chunk(arr: T[], size: number): T[][] { + return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => + arr.slice(i * size, i * size + size), + ); +} + +// Must be explicitly called for the worker to run on this host. +export const initBalanceSubscriptionWorker = async () => { + const config = await getConfig(); + const cronPattern = + config.balanceSubscriptionsCronSchedule || "*/2 * * * * *"; // Default to every 30 seconds + + logger({ + service: "worker", + level: "info", + message: `Initializing balance subscription worker with cron pattern: ${cronPattern}`, + }); + + BalanceSubscriptionQueue.q.add("cron", "", { + repeat: { pattern: cronPattern }, + jobId: "balance-subscription-cron", + }); + + const _worker = new Worker(BalanceSubscriptionQueue.q.name, handler, { + connection: redis, + concurrency: 1, + }); + logWorkerExceptions(_worker); +}; + +/** + * Process all balance subscriptions and notify webhooks of changes. + */ +const handler: Processor = async (job: Job) => { + // Get all active balance subscriptions + const subscriptions = await getAllBalanceSubscriptions(); + if (subscriptions.length === 0) { + return; + } + + // Process in batches of 50 + const batches = chunk(subscriptions, 50); + for (const batch of batches) { + await Promise.all( + batch.map(async (subscription) => { + try { + console.log(`processing subscription ${subscription.id}`); + // Get the current balance + let currentBalance: bigint; + const chain = await getChain(Number.parseInt(subscription.chainId)); + if (subscription.contractAddress) { + const contract = getContract({ + address: subscription.contractAddress, + chain: chain, + client: thirdwebClient, + }); + + currentBalance = await balanceOf({ + contract, + address: subscription.walletAddress, + }); + } else { + const rpcRequest = getRpcClient({ + chain, + client: thirdwebClient, + }); + currentBalance = await eth_getBalance(rpcRequest, { + address: subscription.walletAddress, + }); + } + + job.log(`Current balance: ${currentBalance}`); + const max = subscription.config.threshold?.max + ? BigInt(subscription.config.threshold.max) + : 0n; // If no max set, use 0 (always below current) + const min = subscription.config.threshold?.min + ? BigInt(subscription.config.threshold.min) + : maxUint256; // If no min set, use max uint256 (always above current) + + if (currentBalance <= max && currentBalance >= min) { + return; + } + + // If there's a webhook, queue notification + if (subscription.webhookId && subscription.webhook) { + const webhookBody: BalanceSubscriptionWebhookParams = { + subscriptionId: subscription.id, + chainId: subscription.chainId, + contractAddress: subscription.contractAddress, + walletAddress: subscription.walletAddress, + balance: currentBalance.toString(), + config: parseBalanceSubscriptionConfig(subscription.config), + }; + + await SendWebhookQueue.enqueueWebhook({ + type: WebhooksEventTypes.BALANCE_SUBSCRIPTION, + webhook: subscription.webhook, + body: webhookBody, + }); + } + } catch (error) { + // Log error but continue processing other subscriptions + const message = + error instanceof Error ? error.message : String(error); + job.log( + `Error processing subscription ${subscription.id}: ${message}`, + ); + logger({ + service: "worker", + level: "error", + message: `Error processing balance subscription: ${message}`, + error: error as Error, + }); + } + }), + ); + } +}; diff --git a/src/worker/tasks/send-webhook-worker.ts b/src/worker/tasks/send-webhook-worker.ts index dda1f1f5b..d390096e9 100644 --- a/src/worker/tasks/send-webhook-worker.ts +++ b/src/worker/tasks/send-webhook-worker.ts @@ -5,6 +5,7 @@ import { TransactionDB } from "../../shared/db/transactions/db"; import { WebhooksEventTypes, type BackendWalletBalanceWebhookParams, + type BalanceSubscriptionWebhookParams, } from "../../shared/schemas/webhooks"; import { toEventLogSchema } from "../../server/schemas/event-log"; import { @@ -18,7 +19,10 @@ import { sendWebhookRequest, type WebhookResponse, } from "../../shared/utils/webhook"; -import { SendWebhookQueue, type WebhookJob } from "../queues/send-webhook-queue"; +import { + SendWebhookQueue, + type WebhookJob, +} from "../queues/send-webhook-queue"; const handler: Processor = async (job: Job) => { const { data, webhook } = superjson.parse(job.data); @@ -69,6 +73,15 @@ const handler: Processor = async (job: Job) => { resp = await sendWebhookRequest(webhook, webhookBody); break; } + + case WebhooksEventTypes.BALANCE_SUBSCRIPTION: { + const webhookBody: BalanceSubscriptionWebhookParams = data.body; + resp = await sendWebhookRequest( + webhook, + webhookBody as unknown as Record, + ); + break; + } } // Throw on 5xx so it remains in the queue to retry later. diff --git a/tests/e2e/tests/workers/balance-subscription-worker.test.ts b/tests/e2e/tests/workers/balance-subscription-worker.test.ts new file mode 100644 index 000000000..5c2b8e414 --- /dev/null +++ b/tests/e2e/tests/workers/balance-subscription-worker.test.ts @@ -0,0 +1,159 @@ +import { beforeAll, afterAll, describe, expect, test } from "vitest"; +import Fastify, { type FastifyInstance } from "fastify"; +import { setup } from "../setup"; +import type { BalanceSubscriptionWebhookParams } from "../../../../src/shared/schemas/webhooks"; +import type { Engine } from "../../../../sdk/dist/thirdweb-dev-engine.cjs"; + +describe("Balance Subscription Worker", () => { + let testCallbackServer: FastifyInstance; + let engine: Engine; + // state to be updated by webhook callback + let lastWebhookPayload: BalanceSubscriptionWebhookParams | null = null; + + beforeAll(async () => { + engine = (await setup()).engine; + testCallbackServer = await createTempCallbackServer(); + }); + + afterAll(async () => { + await testCallbackServer.close(); + }); + + const createTempCallbackServer = async () => { + const tempServer = Fastify(); + + tempServer.post("/callback", async (request) => { + console.log(request.body); + lastWebhookPayload = request.body as BalanceSubscriptionWebhookParams; + return { success: true }; + }); + + await tempServer.listen({ port: 3006 }); + + return tempServer; + }; + + // helper to fetch updated state value after webhook callback + const waitForWebhookCallback = async (): Promise<{ + status: boolean; + payload?: BalanceSubscriptionWebhookParams; + }> => { + return await new Promise((res, rej) => { + // check for webhook payload update + const interval = setInterval(() => { + if (!lastWebhookPayload) return; + clearInterval(interval); + res({ status: true, payload: lastWebhookPayload }); + }, 250); + + // reject if its taking too long to update state + setTimeout(() => { + rej({ status: false }); + }, 1000 * 30); + }); + }; + + const testWithThreshold = async ( + minBalance: string, + maxBalance: string, + expectedToTrigger: boolean, + ) => { + // Reset webhook payload + lastWebhookPayload = null; + + // Create balance subscription + const subscription = ( + await engine.balanceSubscriptions.addBalanceSubscription({ + chain: "137", + walletAddress: "0xE52772e599b3fa747Af9595266b527A31611cebd", + config: { + threshold: { + min: minBalance, + max: maxBalance, + }, + }, + webhookUrl: "http://localhost:3006/callback", + }) + ).result; + + // Check if subscription is created correctly + expect(subscription.chain).toEqual("137"); + expect(subscription.walletAddress.toLowerCase()).toEqual( + "0xE52772e599b3fa747Af9595266b527A31611cebd".toLowerCase(), + ); + expect(subscription.config.threshold?.min).toEqual(minBalance); + expect(subscription.config.threshold?.max).toEqual(maxBalance); + expect(subscription.webhook?.url).toEqual("http://localhost:3006/callback"); + + let testStatus: string; + try { + const response = await waitForWebhookCallback(); + if (expectedToTrigger) { + expect(response.status).toEqual(true); + expect(response.payload).toBeDefined(); + expect(response.payload?.subscriptionId).toEqual(subscription.id); + expect(response.payload?.chainId).toEqual("137"); + expect(response.payload?.walletAddress.toLowerCase()).toEqual( + "0xE52772e599b3fa747Af9595266b527A31611cebd".toLowerCase(), + ); + expect(response.payload?.balance).toBeDefined(); + expect(response.payload?.config).toEqual(subscription.config); + testStatus = "completed"; + } else { + testStatus = "webhook not called"; + } + } catch (e) { + console.error(e); + testStatus = "webhook not called"; + } + + // Cleanup + await engine.balanceSubscriptions.removeBalanceSubscription({ + balanceSubscriptionId: subscription.id, + }); + + // Verify test outcome + expect(testStatus).toEqual( + expectedToTrigger ? "completed" : "webhook not called", + ); + }; + + test( + "should not trigger webhook when balance is within thresholds", + async () => { + // Set thresholds that the current balance should be between + await testWithThreshold( + "100000000000000000", // 0.1 ETH + "10000000000000000000", // 10 ETH + false, + ); + }, + 1000 * 60, // increase timeout + ); + + test( + "should trigger webhook when balance is below min threshold", + async () => { + // Set min threshold higher than current balance + await testWithThreshold( + "1000000000000000000000", // 1000 ETH + "10000000000000000000000", // 10000 ETH + true, + ); + }, + 1000 * 60, + ); + + test( + "should trigger webhook when balance is above max threshold", + async () => { + // Set max threshold lower than current balance + await testWithThreshold( + "10000000000000000", // 0.01 ETH + "100000000000000000", // 0.1 ETH + true, + ); + }, + 1000 * 60, + ); +}); From f472a82ac65496f7970caef7b03e3b1dbc37bc31 Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Mon, 10 Feb 2025 13:54:06 +0530 Subject: [PATCH 2/8] remove bad logs --- src/worker/tasks/balance-subscription-worker.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/worker/tasks/balance-subscription-worker.ts b/src/worker/tasks/balance-subscription-worker.ts index 26ba1118d..d27cd74a1 100644 --- a/src/worker/tasks/balance-subscription-worker.ts +++ b/src/worker/tasks/balance-subscription-worker.ts @@ -64,7 +64,6 @@ const handler: Processor = async (job: Job) => { await Promise.all( batch.map(async (subscription) => { try { - console.log(`processing subscription ${subscription.id}`); // Get the current balance let currentBalance: bigint; const chain = await getChain(Number.parseInt(subscription.chainId)); @@ -89,7 +88,6 @@ const handler: Processor = async (job: Job) => { }); } - job.log(`Current balance: ${currentBalance}`); const max = subscription.config.threshold?.max ? BigInt(subscription.config.threshold.max) : 0n; // If no max set, use 0 (always below current) From 0b09f209a42581b49db0512139e9617328f50dde Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Mon, 10 Feb 2025 23:59:40 +0530 Subject: [PATCH 3/8] feat: Enhance Balance Subscriptions webhook handling - Add support for creating new webhooks with optional labels - Allow using existing webhooks by ID - Validate webhook event type and revocation status - Improve error handling for webhook creation and selection --- .../routes/balance-subscriptions/add.ts | 80 +++++++++++++++---- 1 file changed, 63 insertions(+), 17 deletions(-) diff --git a/src/server/routes/balance-subscriptions/add.ts b/src/server/routes/balance-subscriptions/add.ts index fecd56b30..7a47d052a 100644 --- a/src/server/routes/balance-subscriptions/add.ts +++ b/src/server/routes/balance-subscriptions/add.ts @@ -3,6 +3,7 @@ import type { FastifyInstance } from "fastify"; import { StatusCodes } from "http-status-codes"; import { createBalanceSubscription } from "../../../shared/db/balance-subscriptions/create-balance-subscription"; import { insertWebhook } from "../../../shared/db/webhooks/create-webhook"; +import { getWebhook } from "../../../shared/db/webhooks/get-webhook"; import { balanceSubscriptionConfigSchema } from "../../../shared/schemas/balance-subscription-config"; import { WebhooksEventTypes } from "../../../shared/schemas/webhooks"; import { createCustomError } from "../../middleware/error"; @@ -13,19 +14,36 @@ import { getChainIdFromChain } from "../../utils/chain"; import { isValidWebhookUrl } from "../../utils/validator"; import { balanceSubscriptionSchema, toBalanceSubscriptionSchema } from "../../schemas/balance-subscription"; -const requestBodySchema = Type.Object({ - chain: chainIdOrSlugSchema, - contractAddress: Type.Optional(AddressSchema), - walletAddress: AddressSchema, - config: balanceSubscriptionConfigSchema, - webhookUrl: Type.Optional( +const webhookUrlSchema = Type.Object({ + webhookUrl: Type.String({ + description: "Webhook URL to create a new webhook", + examples: ["https://example.com/webhook"], + }), + webhookLabel: Type.Optional( Type.String({ - description: "Webhook URL", - examples: ["https://example.com/webhook"], + description: "Optional label for the webhook when creating a new one", + examples: ["My Balance Subscription Webhook"], + minLength: 3, }), ), }); +const webhookIdSchema = Type.Object({ + webhookId: Type.Integer({ + description: "ID of an existing webhook to use", + }), +}); + +const requestBodySchema = Type.Intersect([ + Type.Object({ + chain: chainIdOrSlugSchema, + contractAddress: Type.Optional(AddressSchema), + walletAddress: AddressSchema, + config: balanceSubscriptionConfigSchema, + }), + Type.Union([webhookUrlSchema, webhookIdSchema]), +]); + const responseSchema = Type.Object({ result: balanceSubscriptionSchema, }); @@ -49,13 +67,15 @@ export async function addBalanceSubscriptionRoute(fastify: FastifyInstance) { }, }, handler: async (request, reply) => { - const { chain, contractAddress, walletAddress, config, webhookUrl } = request.body; - + const { chain, contractAddress, walletAddress, config } = request.body; const chainId = await getChainIdFromChain(chain); - // Create the webhook (if provided). - let webhookId: number | undefined; - if (webhookUrl) { + let finalWebhookId: number | undefined; + + // Handle webhook creation or validation + if ("webhookUrl" in request.body) { + // Create new webhook + const { webhookUrl, webhookLabel } = request.body; if (!isValidWebhookUrl(webhookUrl)) { throw createCustomError( "Invalid webhook URL. Make sure it starts with 'https://'.", @@ -66,19 +86,45 @@ export async function addBalanceSubscriptionRoute(fastify: FastifyInstance) { const webhook = await insertWebhook({ eventType: WebhooksEventTypes.BALANCE_SUBSCRIPTION, - name: "(Auto-generated)", + name: webhookLabel || "(Auto-generated)", url: webhookUrl, }); - webhookId = webhook.id; + finalWebhookId = webhook.id; + } else { + // Validate existing webhook + const { webhookId } = request.body; + const webhook = await getWebhook(webhookId); + if (!webhook) { + throw createCustomError( + `Webhook with ID ${webhookId} not found.`, + StatusCodes.NOT_FOUND, + "NOT_FOUND", + ); + } + if (webhook.eventType !== WebhooksEventTypes.BALANCE_SUBSCRIPTION) { + throw createCustomError( + `Webhook with ID ${webhookId} has incorrect event type. Expected '${WebhooksEventTypes.BALANCE_SUBSCRIPTION}' but got '${webhook.eventType}'.`, + StatusCodes.BAD_REQUEST, + "BAD_REQUEST", + ); + } + if (webhook.revokedAt) { + throw createCustomError( + `Webhook with ID ${webhookId} has been revoked.`, + StatusCodes.BAD_REQUEST, + "BAD_REQUEST", + ); + } + finalWebhookId = webhookId; } - // Create the balance subscription. + // Create the balance subscription const balanceSubscription = await createBalanceSubscription({ chainId: chainId.toString(), contractAddress: contractAddress?.toLowerCase(), walletAddress: walletAddress.toLowerCase(), config, - webhookId, + webhookId: finalWebhookId, }); reply.status(StatusCodes.OK).send({ From 6b98b197ae512531a61947e33c7f3665f8cac786 Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Tue, 11 Feb 2025 00:04:24 +0530 Subject: [PATCH 4/8] refactor: Simplify balance retrieval using thirdweb SDK - Replace manual balance fetching with `getWalletBalance` method - Support both native token and ERC20 token balance retrieval - Remove redundant contract and RPC client initialization code --- .../tasks/balance-subscription-worker.ts | 30 ++++++------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/src/worker/tasks/balance-subscription-worker.ts b/src/worker/tasks/balance-subscription-worker.ts index d27cd74a1..762d98ba2 100644 --- a/src/worker/tasks/balance-subscription-worker.ts +++ b/src/worker/tasks/balance-subscription-worker.ts @@ -12,10 +12,9 @@ import { } from "../../shared/schemas/webhooks"; import { parseBalanceSubscriptionConfig } from "../../shared/schemas/balance-subscription-config"; import { getChain } from "../../shared/utils/chain"; -import { eth_getBalance, getContract, getRpcClient } from "thirdweb"; import { thirdwebClient } from "../../shared/utils/sdk"; -import { balanceOf } from "thirdweb/extensions/erc20"; import { maxUint256 } from "thirdweb/utils"; +import { getWalletBalance } from "thirdweb/wallets"; // Split array into chunks of specified size function chunk(arr: T[], size: number): T[][] { @@ -67,26 +66,15 @@ const handler: Processor = async (job: Job) => { // Get the current balance let currentBalance: bigint; const chain = await getChain(Number.parseInt(subscription.chainId)); - if (subscription.contractAddress) { - const contract = getContract({ - address: subscription.contractAddress, - chain: chain, - client: thirdwebClient, - }); - currentBalance = await balanceOf({ - contract, - address: subscription.walletAddress, - }); - } else { - const rpcRequest = getRpcClient({ - chain, - client: thirdwebClient, - }); - currentBalance = await eth_getBalance(rpcRequest, { - address: subscription.walletAddress, - }); - } + const currentBalanceResponse = await getWalletBalance({ + address: subscription.walletAddress, + client: thirdwebClient, + tokenAddress: subscription.contractAddress ?? undefined, // get ERC20 balance if contract address is provided + chain, + }); + + currentBalance = currentBalanceResponse.value; const max = subscription.config.threshold?.max ? BigInt(subscription.config.threshold.max) From fa79d260680ec8b1945f3c379d265dc30daec0cf Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Tue, 11 Feb 2025 00:06:18 +0530 Subject: [PATCH 5/8] generate SDK --- .../services/BalanceSubscriptionsService.ts | 18 ++++++++++++++---- src/scripts/generate-sdk.ts | 4 +++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/sdk/src/services/BalanceSubscriptionsService.ts b/sdk/src/services/BalanceSubscriptionsService.ts index 6a144e587..b2c7fd0b8 100644 --- a/sdk/src/services/BalanceSubscriptionsService.ts +++ b/sdk/src/services/BalanceSubscriptionsService.ts @@ -68,7 +68,7 @@ export class BalanceSubscriptionsService { * @throws ApiError */ public addBalanceSubscription( - requestBody: { + requestBody?: ({ /** * A chain ID ("137") or slug ("polygon-amoy-testnet"). Chain ID is preferred. */ @@ -93,11 +93,21 @@ export class BalanceSubscriptionsService { max?: string; }; }; + } & ({ /** - * Webhook URL + * Webhook URL to create a new webhook */ - webhookUrl?: string; - }, + webhookUrl: string; + /** + * Optional label for the webhook when creating a new one + */ + webhookLabel?: string; + } | { + /** + * ID of an existing webhook to use + */ + webhookId: number; + })), ): CancelablePromise<{ result: { id: string; diff --git a/src/scripts/generate-sdk.ts b/src/scripts/generate-sdk.ts index 759dd8f00..13c3ca5fb 100644 --- a/src/scripts/generate-sdk.ts +++ b/src/scripts/generate-sdk.ts @@ -142,7 +142,9 @@ export class Engine extends EngineLogic { const ercServices: string[] = ["erc20", "erc721", "erc1155"]; for (const tag of ercServices) { - const fileName = `${tag.charAt(0).toUpperCase() + tag.slice(1)}Service.ts`; + const fileName = `${ + tag.charAt(0).toUpperCase() + tag.slice(1) + }Service.ts`; const filePath = path.join(servicesDir, fileName); const originalCode = fs.readFileSync(filePath, "utf-8"); From 0da7dfaaae8beb961a93223e1b0ff609ea030cc5 Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Tue, 11 Feb 2025 00:52:30 +0530 Subject: [PATCH 6/8] refactor: Rename contractAddress to tokenAddress in Balance Subscriptions - Update Prisma schema, migration, and database indexes - Modify TypeScript interfaces and schemas across services and routes - Ensure consistent naming for token-related address fields - Update worker and database interaction methods --- sdk/src/services/BalanceSubscriptionsService.ts | 10 +++++----- .../migration.sql | 4 ++-- src/prisma/schema.prisma | 10 +++++----- src/server/routes/balance-subscriptions/add.ts | 6 +++--- src/server/routes/balance-subscriptions/update.ts | 6 +++--- src/server/schemas/balance-subscription.ts | 7 ++++--- .../create-balance-subscription.ts | 8 ++++---- .../update-balance-subscription.ts | 6 +++--- src/shared/schemas/webhooks.ts | 2 +- src/worker/tasks/balance-subscription-worker.ts | 4 ++-- 10 files changed, 32 insertions(+), 31 deletions(-) rename src/prisma/migrations/{20250210081712_balance_subscriptions => 20250210192030_balance_subscriptions}/migration.sql (87%) diff --git a/sdk/src/services/BalanceSubscriptionsService.ts b/sdk/src/services/BalanceSubscriptionsService.ts index b2c7fd0b8..07589a86c 100644 --- a/sdk/src/services/BalanceSubscriptionsService.ts +++ b/sdk/src/services/BalanceSubscriptionsService.ts @@ -25,7 +25,7 @@ export class BalanceSubscriptionsService { /** * A contract or wallet address */ - contractAddress?: string; + tokenAddress?: string; /** * A contract or wallet address */ @@ -76,7 +76,7 @@ export class BalanceSubscriptionsService { /** * A contract or wallet address */ - contractAddress?: string; + tokenAddress?: string; /** * A contract or wallet address */ @@ -118,7 +118,7 @@ export class BalanceSubscriptionsService { /** * A contract or wallet address */ - contractAddress?: string; + tokenAddress?: string; /** * A contract or wallet address */ @@ -172,7 +172,7 @@ export class BalanceSubscriptionsService { * A chain ID ("137") or slug ("polygon-amoy-testnet"). Chain ID is preferred. */ chain?: string; - contractAddress?: (string | null); + tokenAddress?: (string | null); /** * A contract or wallet address */ @@ -201,7 +201,7 @@ export class BalanceSubscriptionsService { /** * A contract or wallet address */ - contractAddress?: string; + tokenAddress?: string; /** * A contract or wallet address */ diff --git a/src/prisma/migrations/20250210081712_balance_subscriptions/migration.sql b/src/prisma/migrations/20250210192030_balance_subscriptions/migration.sql similarity index 87% rename from src/prisma/migrations/20250210081712_balance_subscriptions/migration.sql rename to src/prisma/migrations/20250210192030_balance_subscriptions/migration.sql index a0476959c..a5e2c82c3 100644 --- a/src/prisma/migrations/20250210081712_balance_subscriptions/migration.sql +++ b/src/prisma/migrations/20250210192030_balance_subscriptions/migration.sql @@ -5,7 +5,7 @@ ALTER TABLE "configuration" ADD COLUMN "balanceSubscriptionsCronSchedule" TE CREATE TABLE "balance_subscriptions" ( "id" TEXT NOT NULL, "chainId" TEXT NOT NULL, - "contractAddress" TEXT, + "tokenAddress" TEXT, "walletAddress" TEXT NOT NULL, "config" JSONB NOT NULL, "webhookId" INTEGER, @@ -20,7 +20,7 @@ CREATE TABLE "balance_subscriptions" ( CREATE INDEX "balance_subscriptions_chainId_idx" ON "balance_subscriptions"("chainId"); -- CreateIndex -CREATE INDEX "balance_subscriptions_contractAddress_idx" ON "balance_subscriptions"("contractAddress"); +CREATE INDEX "balance_subscriptions_tokenAddress_idx" ON "balance_subscriptions"("tokenAddress"); -- CreateIndex CREATE INDEX "balance_subscriptions_walletAddress_idx" ON "balance_subscriptions"("walletAddress"); diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 6a486ce04..48bbf0813 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -290,10 +290,10 @@ model ContractEventLogs { } model BalanceSubscriptions { - id String @id @default(uuid()) - chainId String - contractAddress String? /// optional for ERC20 balances, if null then native balance - walletAddress String + id String @id @default(uuid()) + chainId String + tokenAddress String? /// optional for ERC20 balances, if null then native balance + walletAddress String config Json @@ -305,7 +305,7 @@ model BalanceSubscriptions { deletedAt DateTime? @@index([chainId]) - @@index([contractAddress]) + @@index([tokenAddress]) @@index([walletAddress]) @@map("balance_subscriptions") } diff --git a/src/server/routes/balance-subscriptions/add.ts b/src/server/routes/balance-subscriptions/add.ts index 7a47d052a..13898bc0b 100644 --- a/src/server/routes/balance-subscriptions/add.ts +++ b/src/server/routes/balance-subscriptions/add.ts @@ -37,7 +37,7 @@ const webhookIdSchema = Type.Object({ const requestBodySchema = Type.Intersect([ Type.Object({ chain: chainIdOrSlugSchema, - contractAddress: Type.Optional(AddressSchema), + tokenAddress: Type.Optional(AddressSchema), walletAddress: AddressSchema, config: balanceSubscriptionConfigSchema, }), @@ -67,7 +67,7 @@ export async function addBalanceSubscriptionRoute(fastify: FastifyInstance) { }, }, handler: async (request, reply) => { - const { chain, contractAddress, walletAddress, config } = request.body; + const { chain, tokenAddress, walletAddress, config } = request.body; const chainId = await getChainIdFromChain(chain); let finalWebhookId: number | undefined; @@ -121,7 +121,7 @@ export async function addBalanceSubscriptionRoute(fastify: FastifyInstance) { // Create the balance subscription const balanceSubscription = await createBalanceSubscription({ chainId: chainId.toString(), - contractAddress: contractAddress?.toLowerCase(), + tokenAddress: tokenAddress?.toLowerCase(), walletAddress: walletAddress.toLowerCase(), config, webhookId: finalWebhookId, diff --git a/src/server/routes/balance-subscriptions/update.ts b/src/server/routes/balance-subscriptions/update.ts index dcc4f26cf..ddf1d37b6 100644 --- a/src/server/routes/balance-subscriptions/update.ts +++ b/src/server/routes/balance-subscriptions/update.ts @@ -17,7 +17,7 @@ const requestBodySchema = Type.Object({ description: "The ID of the balance subscription to update.", }), chain: Type.Optional(chainIdOrSlugSchema), - contractAddress: Type.Optional(Type.Union([AddressSchema, Type.Null()])), + tokenAddress: Type.Optional(Type.Union([AddressSchema, Type.Null()])), walletAddress: Type.Optional(AddressSchema), config: Type.Optional(balanceSubscriptionConfigSchema), webhookId: Type.Optional( @@ -56,7 +56,7 @@ export async function updateBalanceSubscriptionRoute(fastify: FastifyInstance) { const { balanceSubscriptionId, chain, - contractAddress, + tokenAddress, walletAddress, config, webhookId, @@ -69,7 +69,7 @@ export async function updateBalanceSubscriptionRoute(fastify: FastifyInstance) { const balanceSubscription = await updateBalanceSubscription({ id: balanceSubscriptionId, chainId: chainId?.toString(), - contractAddress, + tokenAddress, walletAddress, config, webhookId, diff --git a/src/server/schemas/balance-subscription.ts b/src/server/schemas/balance-subscription.ts index c91922415..4321bceeb 100644 --- a/src/server/schemas/balance-subscription.ts +++ b/src/server/schemas/balance-subscription.ts @@ -7,19 +7,20 @@ import { balanceSubscriptionConfigSchema, balanceSubscriptionConfigZodSchema } f interface BalanceSubscriptionWithWebhook { id: string; chainId: string; - contractAddress: string | null; + tokenAddress: string | null; walletAddress: string; config: Prisma.JsonValue; webhookId: number | null; webhook: Webhooks | null; createdAt: Date; updatedAt: Date; + deletedAt: Date | null; } export const balanceSubscriptionSchema = Type.Object({ id: Type.String(), chain: chainIdOrSlugSchema, - contractAddress: Type.Optional(AddressSchema), + tokenAddress: Type.Optional(AddressSchema), walletAddress: AddressSchema, config: balanceSubscriptionConfigSchema, webhook: Type.Optional( @@ -37,7 +38,7 @@ export function toBalanceSubscriptionSchema(subscription: BalanceSubscriptionWit return { id: subscription.id, chain: subscription.chainId, - contractAddress: subscription.contractAddress ?? undefined, + tokenAddress: subscription.tokenAddress ?? undefined, walletAddress: subscription.walletAddress, config: balanceSubscriptionConfigZodSchema.parse(subscription.config), webhook: subscription.webhookId && subscription.webhook diff --git a/src/shared/db/balance-subscriptions/create-balance-subscription.ts b/src/shared/db/balance-subscriptions/create-balance-subscription.ts index 86a0f6261..2cdf728c2 100644 --- a/src/shared/db/balance-subscriptions/create-balance-subscription.ts +++ b/src/shared/db/balance-subscriptions/create-balance-subscription.ts @@ -4,7 +4,7 @@ import type { BalanceSubscriptionConfig } from "../../schemas/balance-subscripti interface CreateBalanceSubscriptionParams { chainId: string; - contractAddress?: string; + tokenAddress?: string; walletAddress: string; config: BalanceSubscriptionConfig; webhookId?: number; @@ -12,7 +12,7 @@ interface CreateBalanceSubscriptionParams { export async function createBalanceSubscription({ chainId, - contractAddress, + tokenAddress, walletAddress, config, webhookId, @@ -21,7 +21,7 @@ export async function createBalanceSubscription({ const existingSubscription = await prisma.balanceSubscriptions.findFirst({ where: { chainId, - contractAddress, + tokenAddress, walletAddress, deletedAt: null, }, @@ -47,7 +47,7 @@ export async function createBalanceSubscription({ return await prisma.balanceSubscriptions.create({ data: { chainId, - contractAddress, + tokenAddress, walletAddress, config: config as Prisma.InputJsonValue, webhookId, diff --git a/src/shared/db/balance-subscriptions/update-balance-subscription.ts b/src/shared/db/balance-subscriptions/update-balance-subscription.ts index 23683b28a..8d6d5bc02 100644 --- a/src/shared/db/balance-subscriptions/update-balance-subscription.ts +++ b/src/shared/db/balance-subscriptions/update-balance-subscription.ts @@ -5,7 +5,7 @@ import type { BalanceSubscriptionConfig } from "../../schemas/balance-subscripti interface UpdateBalanceSubscriptionParams { id: string; chainId?: string; - contractAddress?: string | null; + tokenAddress?: string | null; walletAddress?: string; config?: BalanceSubscriptionConfig; webhookId?: number | null; @@ -14,7 +14,7 @@ interface UpdateBalanceSubscriptionParams { export async function updateBalanceSubscription({ id, chainId, - contractAddress, + tokenAddress, walletAddress, config, webhookId, @@ -26,7 +26,7 @@ export async function updateBalanceSubscription({ }, data: { ...(chainId && { chainId }), - ...(contractAddress !== undefined && { contractAddress }), + ...(tokenAddress !== undefined && { tokenAddress }), ...(walletAddress && { walletAddress: walletAddress.toLowerCase() }), ...(config && { config: config as Prisma.InputJsonValue }), ...(webhookId !== undefined && { webhookId }), diff --git a/src/shared/schemas/webhooks.ts b/src/shared/schemas/webhooks.ts index 451aec806..f573111ea 100644 --- a/src/shared/schemas/webhooks.ts +++ b/src/shared/schemas/webhooks.ts @@ -25,7 +25,7 @@ export type BackendWalletBalanceWebhookParams = { export interface BalanceSubscriptionWebhookParams { subscriptionId: string; chainId: string; - contractAddress: string | null; + tokenAddress: string | null; walletAddress: string; balance: string; config: z.infer; diff --git a/src/worker/tasks/balance-subscription-worker.ts b/src/worker/tasks/balance-subscription-worker.ts index 762d98ba2..9a9dfebae 100644 --- a/src/worker/tasks/balance-subscription-worker.ts +++ b/src/worker/tasks/balance-subscription-worker.ts @@ -70,7 +70,7 @@ const handler: Processor = async (job: Job) => { const currentBalanceResponse = await getWalletBalance({ address: subscription.walletAddress, client: thirdwebClient, - tokenAddress: subscription.contractAddress ?? undefined, // get ERC20 balance if contract address is provided + tokenAddress: subscription.tokenAddress ?? undefined, // get ERC20 balance if token address is provided chain, }); @@ -92,7 +92,7 @@ const handler: Processor = async (job: Job) => { const webhookBody: BalanceSubscriptionWebhookParams = { subscriptionId: subscription.id, chainId: subscription.chainId, - contractAddress: subscription.contractAddress, + tokenAddress: subscription.tokenAddress, walletAddress: subscription.walletAddress, balance: currentBalance.toString(), config: parseBalanceSubscriptionConfig(subscription.config), From 81cfd54b048f0769bf3a7d4254fc3ee5e7707f1d Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Thu, 13 Feb 2025 06:12:42 +0530 Subject: [PATCH 7/8] change to wallet subscriptions pattern --- sdk/src/Engine.ts | 6 +- sdk/src/index.ts | 2 +- .../services/BalanceSubscriptionsService.ts | 273 --------------- .../services/WalletSubscriptionsService.ts | 323 ++++++++++++++++++ sdk/src/services/WebhooksService.ts | 4 +- .../migration.sql | 29 -- .../migration.sql | 25 ++ src/prisma/schema.prisma | 14 +- src/server/middleware/admin-routes.ts | 4 +- .../routes/balance-subscriptions/get-all.ts | 36 -- .../routes/balance-subscriptions/remove.ts | 53 --- .../configuration/wallet-subscriptions/get.ts | 42 +++ .../wallet-subscriptions/update.ts | 64 ++++ src/server/routes/index.ts | 18 +- .../add.ts | 73 ++-- .../routes/wallet-subscriptions/delete.ts | 50 +++ .../routes/wallet-subscriptions/get-all.ts | 47 +++ .../update.ts | 58 ++-- src/server/schemas/balance-subscription.ts | 52 --- src/server/schemas/wallet-subscription.ts | 56 +++ .../create-balance-subscription.ts | 59 ---- .../get-all-balance-subscriptions.ts | 18 - .../update-balance-subscription.ts | 38 --- .../create-wallet-subscription.ts | 49 +++ .../delete-wallet-subscription.ts} | 4 +- .../get-all-wallet-subscriptions.ts | 29 ++ .../update-wallet-subscription.ts | 53 +++ .../schemas/balance-subscription-config.ts | 33 -- .../schemas/wallet-subscription-conditions.ts | 53 +++ src/shared/schemas/webhooks.ts | 13 +- src/worker/index.ts | 5 +- src/worker/queues/send-webhook-queue.ts | 18 +- ...-queue.ts => wallet-subscription-queue.ts} | 6 +- .../tasks/balance-subscription-worker.ts | 124 ------- src/worker/tasks/send-webhook-worker.ts | 6 +- .../tasks/wallet-subscription-worker.ts | 168 +++++++++ .../balance-subscription-worker.test.ts | 159 --------- .../wallet-subscription-worker.test.ts | 222 ++++++++++++ 38 files changed, 1286 insertions(+), 1000 deletions(-) delete mode 100644 sdk/src/services/BalanceSubscriptionsService.ts create mode 100644 sdk/src/services/WalletSubscriptionsService.ts delete mode 100644 src/prisma/migrations/20250210192030_balance_subscriptions/migration.sql create mode 100644 src/prisma/migrations/20250212235511_wallet_subscriptions/migration.sql delete mode 100644 src/server/routes/balance-subscriptions/get-all.ts delete mode 100644 src/server/routes/balance-subscriptions/remove.ts create mode 100644 src/server/routes/configuration/wallet-subscriptions/get.ts create mode 100644 src/server/routes/configuration/wallet-subscriptions/update.ts rename src/server/routes/{balance-subscriptions => wallet-subscriptions}/add.ts (56%) create mode 100644 src/server/routes/wallet-subscriptions/delete.ts create mode 100644 src/server/routes/wallet-subscriptions/get-all.ts rename src/server/routes/{balance-subscriptions => wallet-subscriptions}/update.ts (52%) delete mode 100644 src/server/schemas/balance-subscription.ts create mode 100644 src/server/schemas/wallet-subscription.ts delete mode 100644 src/shared/db/balance-subscriptions/create-balance-subscription.ts delete mode 100644 src/shared/db/balance-subscriptions/get-all-balance-subscriptions.ts delete mode 100644 src/shared/db/balance-subscriptions/update-balance-subscription.ts create mode 100644 src/shared/db/wallet-subscriptions/create-wallet-subscription.ts rename src/shared/db/{balance-subscriptions/delete-balance-subscription.ts => wallet-subscriptions/delete-wallet-subscription.ts} (62%) create mode 100644 src/shared/db/wallet-subscriptions/get-all-wallet-subscriptions.ts create mode 100644 src/shared/db/wallet-subscriptions/update-wallet-subscription.ts delete mode 100644 src/shared/schemas/balance-subscription-config.ts create mode 100644 src/shared/schemas/wallet-subscription-conditions.ts rename src/worker/queues/{balance-subscription-queue.ts => wallet-subscription-queue.ts} (58%) delete mode 100644 src/worker/tasks/balance-subscription-worker.ts create mode 100644 src/worker/tasks/wallet-subscription-worker.ts delete mode 100644 tests/e2e/tests/workers/balance-subscription-worker.test.ts create mode 100644 tests/e2e/tests/workers/wallet-subscription-worker.test.ts diff --git a/sdk/src/Engine.ts b/sdk/src/Engine.ts index 25deb3149..dba4af76a 100644 --- a/sdk/src/Engine.ts +++ b/sdk/src/Engine.ts @@ -10,7 +10,6 @@ import { AccessTokensService } from './services/AccessTokensService'; import { AccountService } from './services/AccountService'; import { AccountFactoryService } from './services/AccountFactoryService'; import { BackendWalletService } from './services/BackendWalletService'; -import { BalanceSubscriptionsService } from './services/BalanceSubscriptionsService'; import { ChainService } from './services/ChainService'; import { ConfigurationService } from './services/ConfigurationService'; import { ContractService } from './services/ContractService'; @@ -32,6 +31,7 @@ import { PermissionsService } from './services/PermissionsService'; import { RelayerService } from './services/RelayerService'; import { TransactionService } from './services/TransactionService'; import { WalletCredentialsService } from './services/WalletCredentialsService'; +import { WalletSubscriptionsService } from './services/WalletSubscriptionsService'; import { WebhooksService } from './services/WebhooksService'; type HttpRequestConstructor = new (config: OpenAPIConfig) => BaseHttpRequest; @@ -42,7 +42,6 @@ class EngineLogic { public readonly account: AccountService; public readonly accountFactory: AccountFactoryService; public readonly backendWallet: BackendWalletService; - public readonly balanceSubscriptions: BalanceSubscriptionsService; public readonly chain: ChainService; public readonly configuration: ConfigurationService; public readonly contract: ContractService; @@ -64,6 +63,7 @@ class EngineLogic { public readonly relayer: RelayerService; public readonly transaction: TransactionService; public readonly walletCredentials: WalletCredentialsService; + public readonly walletSubscriptions: WalletSubscriptionsService; public readonly webhooks: WebhooksService; public readonly request: BaseHttpRequest; @@ -85,7 +85,6 @@ class EngineLogic { this.account = new AccountService(this.request); this.accountFactory = new AccountFactoryService(this.request); this.backendWallet = new BackendWalletService(this.request); - this.balanceSubscriptions = new BalanceSubscriptionsService(this.request); this.chain = new ChainService(this.request); this.configuration = new ConfigurationService(this.request); this.contract = new ContractService(this.request); @@ -107,6 +106,7 @@ class EngineLogic { this.relayer = new RelayerService(this.request); this.transaction = new TransactionService(this.request); this.walletCredentials = new WalletCredentialsService(this.request); + this.walletSubscriptions = new WalletSubscriptionsService(this.request); this.webhooks = new WebhooksService(this.request); } } diff --git a/sdk/src/index.ts b/sdk/src/index.ts index ff96cf3a3..992231be9 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -14,7 +14,6 @@ export { AccessTokensService } from './services/AccessTokensService'; export { AccountService } from './services/AccountService'; export { AccountFactoryService } from './services/AccountFactoryService'; export { BackendWalletService } from './services/BackendWalletService'; -export { BalanceSubscriptionsService } from './services/BalanceSubscriptionsService'; export { ChainService } from './services/ChainService'; export { ConfigurationService } from './services/ConfigurationService'; export { ContractService } from './services/ContractService'; @@ -36,4 +35,5 @@ export { PermissionsService } from './services/PermissionsService'; export { RelayerService } from './services/RelayerService'; export { TransactionService } from './services/TransactionService'; export { WalletCredentialsService } from './services/WalletCredentialsService'; +export { WalletSubscriptionsService } from './services/WalletSubscriptionsService'; export { WebhooksService } from './services/WebhooksService'; diff --git a/sdk/src/services/BalanceSubscriptionsService.ts b/sdk/src/services/BalanceSubscriptionsService.ts deleted file mode 100644 index 07589a86c..000000000 --- a/sdk/src/services/BalanceSubscriptionsService.ts +++ /dev/null @@ -1,273 +0,0 @@ -/* generated using openapi-typescript-codegen -- do no edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { CancelablePromise } from '../core/CancelablePromise'; -import type { BaseHttpRequest } from '../core/BaseHttpRequest'; - -export class BalanceSubscriptionsService { - - constructor(public readonly httpRequest: BaseHttpRequest) {} - - /** - * Get balance subscriptions - * Get all balance subscriptions. - * @returns any Default Response - * @throws ApiError - */ - public getAllBalanceSubscriptions(): CancelablePromise<{ - result: Array<{ - id: string; - /** - * A chain ID ("137") or slug ("polygon-amoy-testnet"). Chain ID is preferred. - */ - chain: string; - /** - * A contract or wallet address - */ - tokenAddress?: string; - /** - * A contract or wallet address - */ - walletAddress: string; - config: { - threshold?: { - /** - * Minimum balance threshold - */ - min?: string; - /** - * Maximum balance threshold - */ - max?: string; - }; - }; - webhook?: { - url: string; - }; - createdAt: string; - updatedAt: string; - }>; - }> { - return this.httpRequest.request({ - method: 'GET', - url: '/balance-subscriptions/get-all', - errors: { - 400: `Bad Request`, - 404: `Not Found`, - 500: `Internal Server Error`, - }, - }); - } - - /** - * Add balance subscription - * Subscribe to balance changes for a wallet. - * @param requestBody - * @returns any Default Response - * @throws ApiError - */ - public addBalanceSubscription( - requestBody?: ({ - /** - * A chain ID ("137") or slug ("polygon-amoy-testnet"). Chain ID is preferred. - */ - chain: string; - /** - * A contract or wallet address - */ - tokenAddress?: string; - /** - * A contract or wallet address - */ - walletAddress: string; - config: { - threshold?: { - /** - * Minimum balance threshold - */ - min?: string; - /** - * Maximum balance threshold - */ - max?: string; - }; - }; - } & ({ - /** - * Webhook URL to create a new webhook - */ - webhookUrl: string; - /** - * Optional label for the webhook when creating a new one - */ - webhookLabel?: string; - } | { - /** - * ID of an existing webhook to use - */ - webhookId: number; - })), - ): CancelablePromise<{ - result: { - id: string; - /** - * A chain ID ("137") or slug ("polygon-amoy-testnet"). Chain ID is preferred. - */ - chain: string; - /** - * A contract or wallet address - */ - tokenAddress?: string; - /** - * A contract or wallet address - */ - walletAddress: string; - config: { - threshold?: { - /** - * Minimum balance threshold - */ - min?: string; - /** - * Maximum balance threshold - */ - max?: string; - }; - }; - webhook?: { - url: string; - }; - createdAt: string; - updatedAt: string; - }; - }> { - return this.httpRequest.request({ - method: 'POST', - url: '/balance-subscriptions/add', - body: requestBody, - mediaType: 'application/json', - errors: { - 400: `Bad Request`, - 404: `Not Found`, - 500: `Internal Server Error`, - }, - }); - } - - /** - * Update balance subscription - * Update an existing balance subscription. - * @param requestBody - * @returns any Default Response - * @throws ApiError - */ - public updateBalanceSubscription( - requestBody: { - /** - * The ID of the balance subscription to update. - */ - balanceSubscriptionId: string; - /** - * A chain ID ("137") or slug ("polygon-amoy-testnet"). Chain ID is preferred. - */ - chain?: string; - tokenAddress?: (string | null); - /** - * A contract or wallet address - */ - walletAddress?: string; - config?: { - threshold?: { - /** - * Minimum balance threshold - */ - min?: string; - /** - * Maximum balance threshold - */ - max?: string; - }; - }; - webhookId?: (number | null); - }, - ): CancelablePromise<{ - result: { - id: string; - /** - * A chain ID ("137") or slug ("polygon-amoy-testnet"). Chain ID is preferred. - */ - chain: string; - /** - * A contract or wallet address - */ - tokenAddress?: string; - /** - * A contract or wallet address - */ - walletAddress: string; - config: { - threshold?: { - /** - * Minimum balance threshold - */ - min?: string; - /** - * Maximum balance threshold - */ - max?: string; - }; - }; - webhook?: { - url: string; - }; - createdAt: string; - updatedAt: string; - }; - }> { - return this.httpRequest.request({ - method: 'POST', - url: '/balance-subscriptions/update', - body: requestBody, - mediaType: 'application/json', - errors: { - 400: `Bad Request`, - 404: `Not Found`, - 500: `Internal Server Error`, - }, - }); - } - - /** - * Remove balance subscription - * Remove an existing balance subscription - * @param requestBody - * @returns any Default Response - * @throws ApiError - */ - public removeBalanceSubscription( - requestBody: { - /** - * The ID for an existing balance subscription. - */ - balanceSubscriptionId: string; - }, - ): CancelablePromise<{ - result: { - status: string; - }; - }> { - return this.httpRequest.request({ - method: 'POST', - url: '/balance-subscriptions/remove', - body: requestBody, - mediaType: 'application/json', - errors: { - 400: `Bad Request`, - 404: `Not Found`, - 500: `Internal Server Error`, - }, - }); - } - -} diff --git a/sdk/src/services/WalletSubscriptionsService.ts b/sdk/src/services/WalletSubscriptionsService.ts new file mode 100644 index 000000000..b699eb887 --- /dev/null +++ b/sdk/src/services/WalletSubscriptionsService.ts @@ -0,0 +1,323 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CancelablePromise } from '../core/CancelablePromise'; +import type { BaseHttpRequest } from '../core/BaseHttpRequest'; + +export class WalletSubscriptionsService { + + constructor(public readonly httpRequest: BaseHttpRequest) {} + + /** + * Get wallet subscriptions + * Get all wallet subscriptions. + * @param page Specify the page number. + * @param limit Specify the number of results to return per page. + * @returns any Default Response + * @throws ApiError + */ + public getAllWalletSubscriptions( + page: number = 1, + limit: number = 100, + ): CancelablePromise<{ + result: Array<{ + id: string; + /** + * The chain ID of the subscription. + */ + chainId: string; + /** + * A contract or wallet address + */ + walletAddress: string; + /** + * Array of conditions to monitor for this wallet + */ + conditions: Array<({ + type: 'token_balance_lt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + } | { + type: 'token_balance_gt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + })>; + webhook?: { + url: string; + }; + createdAt: string; + updatedAt: string; + }>; + }> { + return this.httpRequest.request({ + method: 'GET', + url: '/wallet-subscriptions/get-all', + path: { + 'page': page, + 'limit': limit, + }, + errors: { + 400: `Bad Request`, + 404: `Not Found`, + 500: `Internal Server Error`, + }, + }); + } + + /** + * Add wallet subscription + * Subscribe to wallet conditions. + * @param requestBody + * @returns any Default Response + * @throws ApiError + */ + public addWalletSubscription( + requestBody?: ({ + /** + * A chain ID ("137") or slug ("polygon-amoy-testnet"). Chain ID is preferred. + */ + chain: string; + /** + * A contract or wallet address + */ + walletAddress: string; + /** + * Array of conditions to monitor for this wallet + */ + conditions: Array<({ + type: 'token_balance_lt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + } | { + type: 'token_balance_gt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + })>; + } & ({ + /** + * Webhook URL to create a new webhook + */ + webhookUrl: string; + /** + * Optional label for the webhook when creating a new one + */ + webhookLabel?: string; + } | { + /** + * ID of an existing webhook to use + */ + webhookId: number; + })), + ): CancelablePromise<{ + result: { + id: string; + /** + * The chain ID of the subscription. + */ + chainId: string; + /** + * A contract or wallet address + */ + walletAddress: string; + /** + * Array of conditions to monitor for this wallet + */ + conditions: Array<({ + type: 'token_balance_lt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + } | { + type: 'token_balance_gt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + })>; + webhook?: { + url: string; + }; + createdAt: string; + updatedAt: string; + }; + }> { + return this.httpRequest.request({ + method: 'POST', + url: '/wallet-subscriptions', + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Bad Request`, + 404: `Not Found`, + 500: `Internal Server Error`, + }, + }); + } + + /** + * Update wallet subscription + * Update an existing wallet subscription. + * @param subscriptionId The ID of the wallet subscription to update. + * @param requestBody + * @returns any Default Response + * @throws ApiError + */ + public updateWalletSubscription( + subscriptionId: string, + requestBody?: { + /** + * A chain ID ("137") or slug ("polygon-amoy-testnet"). Chain ID is preferred. + */ + chain?: string; + /** + * A contract or wallet address + */ + walletAddress?: string; + /** + * Array of conditions to monitor for this wallet + */ + conditions?: Array<({ + type: 'token_balance_lt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + } | { + type: 'token_balance_gt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + })>; + webhookId?: (number | null); + }, + ): CancelablePromise<{ + result: { + id: string; + /** + * The chain ID of the subscription. + */ + chainId: string; + /** + * A contract or wallet address + */ + walletAddress: string; + /** + * Array of conditions to monitor for this wallet + */ + conditions: Array<({ + type: 'token_balance_lt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + } | { + type: 'token_balance_gt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + })>; + webhook?: { + url: string; + }; + createdAt: string; + updatedAt: string; + }; + }> { + return this.httpRequest.request({ + method: 'POST', + url: '/wallet-subscriptions/{subscriptionId}', + path: { + 'subscriptionId': subscriptionId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Bad Request`, + 404: `Not Found`, + 500: `Internal Server Error`, + }, + }); + } + + /** + * Delete wallet subscription + * Delete an existing wallet subscription. + * @param subscriptionId The ID of the wallet subscription to update. + * @returns any Default Response + * @throws ApiError + */ + public deleteWalletSubscription( + subscriptionId: string, + ): CancelablePromise<{ + result: { + id: string; + /** + * The chain ID of the subscription. + */ + chainId: string; + /** + * A contract or wallet address + */ + walletAddress: string; + /** + * Array of conditions to monitor for this wallet + */ + conditions: Array<({ + type: 'token_balance_lt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + } | { + type: 'token_balance_gt'; + tokenAddress: (string | 'native'); + /** + * The threshold value in wei + */ + value: string; + })>; + webhook?: { + url: string; + }; + createdAt: string; + updatedAt: string; + }; + }> { + return this.httpRequest.request({ + method: 'DELETE', + url: '/wallet-subscriptions/{subscriptionId}', + path: { + 'subscriptionId': subscriptionId, + }, + errors: { + 400: `Bad Request`, + 404: `Not Found`, + 500: `Internal Server Error`, + }, + }); + } + +} diff --git a/sdk/src/services/WebhooksService.ts b/sdk/src/services/WebhooksService.ts index e9aee46b3..d06835dbd 100644 --- a/sdk/src/services/WebhooksService.ts +++ b/sdk/src/services/WebhooksService.ts @@ -51,7 +51,7 @@ export class WebhooksService { */ url: string; name?: string; - eventType: ('queued_transaction' | 'sent_transaction' | 'mined_transaction' | 'errored_transaction' | 'cancelled_transaction' | 'all_transactions' | 'backend_wallet_balance' | 'auth' | 'contract_subscription' | 'balance_subscription'); + eventType: ('queued_transaction' | 'sent_transaction' | 'mined_transaction' | 'errored_transaction' | 'cancelled_transaction' | 'all_transactions' | 'backend_wallet_balance' | 'auth' | 'contract_subscription' | 'wallet_subscription'); }, ): CancelablePromise<{ result: { @@ -113,7 +113,7 @@ export class WebhooksService { * @throws ApiError */ public getEventTypes(): CancelablePromise<{ - result: Array<('queued_transaction' | 'sent_transaction' | 'mined_transaction' | 'errored_transaction' | 'cancelled_transaction' | 'all_transactions' | 'backend_wallet_balance' | 'auth' | 'contract_subscription' | 'balance_subscription')>; + result: Array<('queued_transaction' | 'sent_transaction' | 'mined_transaction' | 'errored_transaction' | 'cancelled_transaction' | 'all_transactions' | 'backend_wallet_balance' | 'auth' | 'contract_subscription' | 'wallet_subscription')>; }> { return this.httpRequest.request({ method: 'GET', diff --git a/src/prisma/migrations/20250210192030_balance_subscriptions/migration.sql b/src/prisma/migrations/20250210192030_balance_subscriptions/migration.sql deleted file mode 100644 index a5e2c82c3..000000000 --- a/src/prisma/migrations/20250210192030_balance_subscriptions/migration.sql +++ /dev/null @@ -1,29 +0,0 @@ --- AlterTable -ALTER TABLE "configuration" ADD COLUMN "balanceSubscriptionsCronSchedule" TEXT; - --- CreateTable -CREATE TABLE "balance_subscriptions" ( - "id" TEXT NOT NULL, - "chainId" TEXT NOT NULL, - "tokenAddress" TEXT, - "walletAddress" TEXT NOT NULL, - "config" JSONB NOT NULL, - "webhookId" INTEGER, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "deletedAt" TIMESTAMP(3), - - CONSTRAINT "balance_subscriptions_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "balance_subscriptions_chainId_idx" ON "balance_subscriptions"("chainId"); - --- CreateIndex -CREATE INDEX "balance_subscriptions_tokenAddress_idx" ON "balance_subscriptions"("tokenAddress"); - --- CreateIndex -CREATE INDEX "balance_subscriptions_walletAddress_idx" ON "balance_subscriptions"("walletAddress"); - --- AddForeignKey -ALTER TABLE "balance_subscriptions" ADD CONSTRAINT "balance_subscriptions_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "webhooks"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/prisma/migrations/20250212235511_wallet_subscriptions/migration.sql b/src/prisma/migrations/20250212235511_wallet_subscriptions/migration.sql new file mode 100644 index 000000000..92fdb940e --- /dev/null +++ b/src/prisma/migrations/20250212235511_wallet_subscriptions/migration.sql @@ -0,0 +1,25 @@ +-- AlterTable +ALTER TABLE "configuration" ADD COLUMN "walletSubscriptionsCronSchedule" TEXT; + +-- CreateTable +CREATE TABLE "wallet_subscriptions" ( + "id" TEXT NOT NULL, + "chainId" TEXT NOT NULL, + "walletAddress" TEXT NOT NULL, + "conditions" JSONB[], + "webhookId" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "wallet_subscriptions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "wallet_subscriptions_chainId_idx" ON "wallet_subscriptions"("chainId"); + +-- CreateIndex +CREATE INDEX "wallet_subscriptions_walletAddress_idx" ON "wallet_subscriptions"("walletAddress"); + +-- AddForeignKey +ALTER TABLE "wallet_subscriptions" ADD CONSTRAINT "wallet_subscriptions_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "webhooks"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 48bbf0813..602edf6a4 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -29,7 +29,7 @@ model Configuration { cursorDelaySeconds Int @default(2) @map("cursorDelaySeconds") contractSubscriptionsRetryDelaySeconds String @default("10") @map("contractSubscriptionsRetryDelaySeconds") - balanceSubscriptionsCronSchedule String? @map("balanceSubscriptionsCronSchedule") + walletSubscriptionsCronSchedule String? @map("walletSubscriptionsCronSchedule") // Wallet provider specific configurations, non-credential walletProviderConfigs Json @default("{}") @map("walletProviderConfigs") /// Eg: { "aws": { "defaultAwsRegion": "us-east-1" }, "gcp": { "defaultGcpKmsLocationId": "us-east1-b" } } @@ -223,7 +223,7 @@ model Webhooks { updatedAt DateTime @updatedAt @map("updatedAt") revokedAt DateTime? @map("revokedAt") ContractSubscriptions ContractSubscriptions[] - BalanceSubscriptions BalanceSubscriptions[] + WalletSubscriptions WalletSubscriptions[] @@map("webhooks") } @@ -289,13 +289,12 @@ model ContractEventLogs { @@map("contract_event_logs") } -model BalanceSubscriptions { - id String @id @default(uuid()) +model WalletSubscriptions { + id String @id @default(uuid()) chainId String - tokenAddress String? /// optional for ERC20 balances, if null then native balance walletAddress String - config Json + conditions Json[] // Array of condition objects with discriminated union type webhookId Int? webhook Webhooks? @relation(fields: [webhookId], references: [id], onDelete: SetNull) @@ -305,9 +304,8 @@ model BalanceSubscriptions { deletedAt DateTime? @@index([chainId]) - @@index([tokenAddress]) @@index([walletAddress]) - @@map("balance_subscriptions") + @@map("wallet_subscriptions") } model ContractTransactionReceipts { diff --git a/src/server/middleware/admin-routes.ts b/src/server/middleware/admin-routes.ts index e1c871ea0..4fd700ab2 100644 --- a/src/server/middleware/admin-routes.ts +++ b/src/server/middleware/admin-routes.ts @@ -15,7 +15,7 @@ import { ProcessTransactionReceiptsQueue } from "../../worker/queues/process-tra import { PruneTransactionsQueue } from "../../worker/queues/prune-transactions-queue"; import { SendTransactionQueue } from "../../worker/queues/send-transaction-queue"; import { SendWebhookQueue } from "../../worker/queues/send-webhook-queue"; -import { BalanceSubscriptionQueue } from "../../worker/queues/balance-subscription-queue"; +import { WalletSubscriptionQueue } from "../../worker/queues/wallet-subscription-queue"; export const ADMIN_QUEUES_BASEPATH = "/admin/queues"; const ADMIN_ROUTES_USERNAME = "admin"; @@ -32,7 +32,7 @@ const QUEUES: Queue[] = [ PruneTransactionsQueue.q, NonceResyncQueue.q, NonceHealthCheckQueue.q, - BalanceSubscriptionQueue.q, + WalletSubscriptionQueue.q, ]; export const withAdminRoutes = async (fastify: FastifyInstance) => { diff --git a/src/server/routes/balance-subscriptions/get-all.ts b/src/server/routes/balance-subscriptions/get-all.ts deleted file mode 100644 index 389f6dcd0..000000000 --- a/src/server/routes/balance-subscriptions/get-all.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { type Static, Type } from "@sinclair/typebox"; -import type { FastifyInstance } from "fastify"; -import { StatusCodes } from "http-status-codes"; -import { getAllBalanceSubscriptions } from "../../../shared/db/balance-subscriptions/get-all-balance-subscriptions"; -import { balanceSubscriptionSchema, toBalanceSubscriptionSchema } from "../../schemas/balance-subscription"; -import { standardResponseSchema } from "../../schemas/shared-api-schemas"; - -const responseSchema = Type.Object({ - result: Type.Array(balanceSubscriptionSchema), -}); - -export async function getAllBalanceSubscriptionsRoute(fastify: FastifyInstance) { - fastify.route<{ - Reply: Static; - }>({ - method: "GET", - url: "/balance-subscriptions/get-all", - schema: { - summary: "Get balance subscriptions", - description: "Get all balance subscriptions.", - tags: ["Balance-Subscriptions"], - operationId: "getAllBalanceSubscriptions", - response: { - ...standardResponseSchema, - [StatusCodes.OK]: responseSchema, - }, - }, - handler: async (_request, reply) => { - const balanceSubscriptions = await getAllBalanceSubscriptions(); - - reply.status(StatusCodes.OK).send({ - result: balanceSubscriptions.map(toBalanceSubscriptionSchema), - }); - }, - }); -} \ No newline at end of file diff --git a/src/server/routes/balance-subscriptions/remove.ts b/src/server/routes/balance-subscriptions/remove.ts deleted file mode 100644 index 500101d71..000000000 --- a/src/server/routes/balance-subscriptions/remove.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { type Static, Type } from "@sinclair/typebox"; -import type { FastifyInstance } from "fastify"; -import { StatusCodes } from "http-status-codes"; -import { deleteBalanceSubscription } from "../../../shared/db/balance-subscriptions/delete-balance-subscription"; -import { deleteWebhook } from "../../../shared/db/webhooks/revoke-webhook"; -import { standardResponseSchema } from "../../schemas/shared-api-schemas"; - -const requestBodySchema = Type.Object({ - balanceSubscriptionId: Type.String({ - description: "The ID for an existing balance subscription.", - }), -}); - -const responseSchema = Type.Object({ - result: Type.Object({ - status: Type.String(), - }), -}); - -export async function removeBalanceSubscriptionRoute(fastify: FastifyInstance) { - fastify.route<{ - Body: Static; - Reply: Static; - }>({ - method: "POST", - url: "/balance-subscriptions/remove", - schema: { - summary: "Remove balance subscription", - description: "Remove an existing balance subscription", - tags: ["Balance-Subscriptions"], - operationId: "removeBalanceSubscription", - body: requestBodySchema, - response: { - ...standardResponseSchema, - [StatusCodes.OK]: responseSchema, - }, - }, - handler: async (request, reply) => { - const { balanceSubscriptionId } = request.body; - - const balanceSubscription = await deleteBalanceSubscription(balanceSubscriptionId); - if (balanceSubscription.webhookId) { - await deleteWebhook(balanceSubscription.webhookId); - } - - reply.status(StatusCodes.OK).send({ - result: { - status: "success", - }, - }); - }, - }); -} \ No newline at end of file diff --git a/src/server/routes/configuration/wallet-subscriptions/get.ts b/src/server/routes/configuration/wallet-subscriptions/get.ts new file mode 100644 index 000000000..19d1932a5 --- /dev/null +++ b/src/server/routes/configuration/wallet-subscriptions/get.ts @@ -0,0 +1,42 @@ +import { type Static, Type } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { getConfig } from "../../../../shared/utils/cache/get-config"; +import { standardResponseSchema } from "../../../schemas/shared-api-schemas"; + +const responseBodySchema = Type.Object({ + result: Type.Object({ + walletSubscriptionsCronSchedule: Type.String(), + }), +}); + +export async function getWalletSubscriptionsConfiguration( + fastify: FastifyInstance, +) { + fastify.route<{ + Reply: Static; + }>({ + method: "GET", + url: "/configuration/wallet-subscriptions", + schema: { + summary: "Get wallet subscriptions configuration", + description: + "Get wallet subscriptions configuration including cron schedule", + tags: ["Configuration"], + operationId: "getWalletSubscriptionsConfiguration", + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseBodySchema, + }, + }, + handler: async (_req, res) => { + const config = await getConfig(false); + res.status(StatusCodes.OK).send({ + result: { + walletSubscriptionsCronSchedule: + config.walletSubscriptionsCronSchedule || "*/30 * * * * *", + }, + }); + }, + }); +} diff --git a/src/server/routes/configuration/wallet-subscriptions/update.ts b/src/server/routes/configuration/wallet-subscriptions/update.ts new file mode 100644 index 000000000..94f358130 --- /dev/null +++ b/src/server/routes/configuration/wallet-subscriptions/update.ts @@ -0,0 +1,64 @@ +import { type Static, Type } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { updateConfiguration } from "../../../../shared/db/configuration/update-configuration"; +import { getConfig } from "../../../../shared/utils/cache/get-config"; +import { isValidCron } from "../../../../shared/utils/cron/is-valid-cron"; +import { createCustomError } from "../../../middleware/error"; +import { standardResponseSchema } from "../../../schemas/shared-api-schemas"; + +const requestBodySchema = Type.Object({ + walletSubscriptionsCronSchedule: Type.String({ + description: + "Cron expression for wallet subscription checks. It should be in the format of 'ss mm hh * * *' where ss is seconds, mm is minutes and hh is hours. Seconds should not be '*' or less than 10", + default: "*/30 * * * * *", + }), +}); + +const responseBodySchema = Type.Object({ + result: Type.Object({ + walletSubscriptionsCronSchedule: Type.String(), + }), +}); + +export async function updateWalletSubscriptionsConfiguration( + fastify: FastifyInstance, +) { + fastify.route<{ + Body: Static; + }>({ + method: "POST", + url: "/configuration/wallet-subscriptions", + schema: { + summary: "Update wallet subscriptions configuration", + description: + "Update wallet subscriptions configuration including cron schedule", + tags: ["Configuration"], + operationId: "updateWalletSubscriptionsConfiguration", + body: requestBodySchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseBodySchema, + }, + }, + handler: async (req, res) => { + const { walletSubscriptionsCronSchedule } = req.body; + if (isValidCron(walletSubscriptionsCronSchedule) === false) { + throw createCustomError( + "Invalid cron expression.", + StatusCodes.BAD_REQUEST, + "INVALID_CRON", + ); + } + + await updateConfiguration({ walletSubscriptionsCronSchedule }); + const config = await getConfig(false); + res.status(StatusCodes.OK).send({ + result: { + walletSubscriptionsCronSchedule: + config.walletSubscriptionsCronSchedule, + }, + }); + }, + }); +} diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index f52c060dc..4a74394e1 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -117,10 +117,10 @@ import { createWalletCredentialRoute } from "./wallet-credentials/create"; import { getWalletCredentialRoute } from "./wallet-credentials/get"; import { getAllWalletCredentialsRoute } from "./wallet-credentials/get-all"; import { updateWalletCredentialRoute } from "./wallet-credentials/update"; -import { addBalanceSubscriptionRoute } from "./balance-subscriptions/add"; -import { getAllBalanceSubscriptionsRoute } from "./balance-subscriptions/get-all"; -import { removeBalanceSubscriptionRoute } from "./balance-subscriptions/remove"; -import { updateBalanceSubscriptionRoute } from "./balance-subscriptions/update"; +import { getAllWalletSubscriptionsRoute } from "./wallet-subscriptions/get-all"; +import { addWalletSubscriptionRoute } from "./wallet-subscriptions/add"; +import { updateWalletSubscriptionRoute } from "./wallet-subscriptions/update"; +import { deleteWalletSubscriptionRoute } from "./wallet-subscriptions/delete"; export async function withRoutes(fastify: FastifyInstance) { // Backend Wallets @@ -272,11 +272,11 @@ export async function withRoutes(fastify: FastifyInstance) { await fastify.register(getContractIndexedBlockRange); await fastify.register(getLatestBlock); - // Balance Subscriptions - await fastify.register(getAllBalanceSubscriptionsRoute); - await fastify.register(addBalanceSubscriptionRoute); - await fastify.register(updateBalanceSubscriptionRoute); - await fastify.register(removeBalanceSubscriptionRoute); + // Wallet Subscriptions + await fastify.register(getAllWalletSubscriptionsRoute); + await fastify.register(addWalletSubscriptionRoute); + await fastify.register(updateWalletSubscriptionRoute); + await fastify.register(deleteWalletSubscriptionRoute); // Contract Transactions // @deprecated diff --git a/src/server/routes/balance-subscriptions/add.ts b/src/server/routes/wallet-subscriptions/add.ts similarity index 56% rename from src/server/routes/balance-subscriptions/add.ts rename to src/server/routes/wallet-subscriptions/add.ts index 13898bc0b..9239011cf 100644 --- a/src/server/routes/balance-subscriptions/add.ts +++ b/src/server/routes/wallet-subscriptions/add.ts @@ -1,10 +1,9 @@ import { type Static, Type } from "@sinclair/typebox"; import type { FastifyInstance } from "fastify"; import { StatusCodes } from "http-status-codes"; -import { createBalanceSubscription } from "../../../shared/db/balance-subscriptions/create-balance-subscription"; +import { createWalletSubscription } from "../../../shared/db/wallet-subscriptions/create-wallet-subscription"; import { insertWebhook } from "../../../shared/db/webhooks/create-webhook"; import { getWebhook } from "../../../shared/db/webhooks/get-webhook"; -import { balanceSubscriptionConfigSchema } from "../../../shared/schemas/balance-subscription-config"; import { WebhooksEventTypes } from "../../../shared/schemas/webhooks"; import { createCustomError } from "../../middleware/error"; import { AddressSchema } from "../../schemas/address"; @@ -12,7 +11,11 @@ import { chainIdOrSlugSchema } from "../../schemas/chain"; import { standardResponseSchema } from "../../schemas/shared-api-schemas"; import { getChainIdFromChain } from "../../utils/chain"; import { isValidWebhookUrl } from "../../utils/validator"; -import { balanceSubscriptionSchema, toBalanceSubscriptionSchema } from "../../schemas/balance-subscription"; +import { + walletSubscriptionSchema, + toWalletSubscriptionSchema, +} from "../../schemas/wallet-subscription"; +import { WalletConditionsSchema } from "../../../shared/schemas/wallet-subscription-conditions"; const webhookUrlSchema = Type.Object({ webhookUrl: Type.String({ @@ -22,7 +25,7 @@ const webhookUrlSchema = Type.Object({ webhookLabel: Type.Optional( Type.String({ description: "Optional label for the webhook when creating a new one", - examples: ["My Balance Subscription Webhook"], + examples: ["My Wallet Subscription Webhook"], minLength: 3, }), ), @@ -37,29 +40,28 @@ const webhookIdSchema = Type.Object({ const requestBodySchema = Type.Intersect([ Type.Object({ chain: chainIdOrSlugSchema, - tokenAddress: Type.Optional(AddressSchema), walletAddress: AddressSchema, - config: balanceSubscriptionConfigSchema, + conditions: WalletConditionsSchema, }), - Type.Union([webhookUrlSchema, webhookIdSchema]), + Type.Optional(Type.Union([webhookUrlSchema, webhookIdSchema])), ]); const responseSchema = Type.Object({ - result: balanceSubscriptionSchema, + result: walletSubscriptionSchema, }); -export async function addBalanceSubscriptionRoute(fastify: FastifyInstance) { +export async function addWalletSubscriptionRoute(fastify: FastifyInstance) { fastify.route<{ Body: Static; Reply: Static; }>({ method: "POST", - url: "/balance-subscriptions/add", + url: "/wallet-subscriptions", schema: { - summary: "Add balance subscription", - description: "Subscribe to balance changes for a wallet.", - tags: ["Balance-Subscriptions"], - operationId: "addBalanceSubscription", + summary: "Add wallet subscription", + description: "Subscribe to wallet conditions.", + tags: ["Wallet-Subscriptions"], + operationId: "addWalletSubscription", body: requestBodySchema, response: { ...standardResponseSchema, @@ -67,15 +69,14 @@ export async function addBalanceSubscriptionRoute(fastify: FastifyInstance) { }, }, handler: async (request, reply) => { - const { chain, tokenAddress, walletAddress, config } = request.body; + const { chain, walletAddress, conditions } = request.body; const chainId = await getChainIdFromChain(chain); let finalWebhookId: number | undefined; - // Handle webhook creation or validation if ("webhookUrl" in request.body) { - // Create new webhook const { webhookUrl, webhookLabel } = request.body; + if (!isValidWebhookUrl(webhookUrl)) { throw createCustomError( "Invalid webhook URL. Make sure it starts with 'https://'.", @@ -85,51 +86,37 @@ export async function addBalanceSubscriptionRoute(fastify: FastifyInstance) { } const webhook = await insertWebhook({ - eventType: WebhooksEventTypes.BALANCE_SUBSCRIPTION, - name: webhookLabel || "(Auto-generated)", url: webhookUrl, + name: webhookLabel, + eventType: WebhooksEventTypes.WALLET_SUBSCRIPTION, }); + finalWebhookId = webhook.id; } else { - // Validate existing webhook const { webhookId } = request.body; const webhook = await getWebhook(webhookId); - if (!webhook) { - throw createCustomError( - `Webhook with ID ${webhookId} not found.`, - StatusCodes.NOT_FOUND, - "NOT_FOUND", - ); - } - if (webhook.eventType !== WebhooksEventTypes.BALANCE_SUBSCRIPTION) { - throw createCustomError( - `Webhook with ID ${webhookId} has incorrect event type. Expected '${WebhooksEventTypes.BALANCE_SUBSCRIPTION}' but got '${webhook.eventType}'.`, - StatusCodes.BAD_REQUEST, - "BAD_REQUEST", - ); - } - if (webhook.revokedAt) { + + if (!webhook || webhook.revokedAt) { throw createCustomError( - `Webhook with ID ${webhookId} has been revoked.`, + "Invalid webhook ID or webhook has been revoked.", StatusCodes.BAD_REQUEST, "BAD_REQUEST", ); } + finalWebhookId = webhookId; } - // Create the balance subscription - const balanceSubscription = await createBalanceSubscription({ + const subscription = await createWalletSubscription({ chainId: chainId.toString(), - tokenAddress: tokenAddress?.toLowerCase(), - walletAddress: walletAddress.toLowerCase(), - config, + walletAddress, + conditions, webhookId: finalWebhookId, }); reply.status(StatusCodes.OK).send({ - result: toBalanceSubscriptionSchema(balanceSubscription), + result: toWalletSubscriptionSchema(subscription), }); }, }); -} \ No newline at end of file +} diff --git a/src/server/routes/wallet-subscriptions/delete.ts b/src/server/routes/wallet-subscriptions/delete.ts new file mode 100644 index 000000000..947087107 --- /dev/null +++ b/src/server/routes/wallet-subscriptions/delete.ts @@ -0,0 +1,50 @@ +import { type Static, Type } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { deleteWalletSubscription } from "../../../shared/db/wallet-subscriptions/delete-wallet-subscription"; +import { + walletSubscriptionSchema, + toWalletSubscriptionSchema, +} from "../../schemas/wallet-subscription"; +import { standardResponseSchema } from "../../schemas/shared-api-schemas"; + +const responseSchema = Type.Object({ + result: walletSubscriptionSchema, +}); + +const paramsSchema = Type.Object({ + subscriptionId: Type.String({ + description: "The ID of the wallet subscription to update.", + }), +}); + + +export async function deleteWalletSubscriptionRoute(fastify: FastifyInstance) { + fastify.route<{ + Reply: Static; + Params: Static; + }>({ + method: "DELETE", + url: "/wallet-subscriptions/:subscriptionId", + schema: { + summary: "Delete wallet subscription", + description: "Delete an existing wallet subscription.", + tags: ["Wallet-Subscriptions"], + operationId: "deleteWalletSubscription", + params: paramsSchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseSchema, + }, + }, + handler: async (request, reply) => { + const { subscriptionId } = request.params; + + const subscription = await deleteWalletSubscription(subscriptionId); + + reply.status(StatusCodes.OK).send({ + result: toWalletSubscriptionSchema(subscription), + }); + }, + }); +} diff --git a/src/server/routes/wallet-subscriptions/get-all.ts b/src/server/routes/wallet-subscriptions/get-all.ts new file mode 100644 index 000000000..9f5621635 --- /dev/null +++ b/src/server/routes/wallet-subscriptions/get-all.ts @@ -0,0 +1,47 @@ +import { type Static, Type } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { getAllWalletSubscriptions } from "../../../shared/db/wallet-subscriptions/get-all-wallet-subscriptions"; +import { + walletSubscriptionSchema, + toWalletSubscriptionSchema, +} from "../../schemas/wallet-subscription"; +import { standardResponseSchema } from "../../schemas/shared-api-schemas"; +import { PaginationSchema } from "../../schemas/pagination"; + +const responseSchema = Type.Object({ + result: Type.Array(walletSubscriptionSchema), +}); + +export async function getAllWalletSubscriptionsRoute(fastify: FastifyInstance) { + fastify.route<{ + Reply: Static; + Params: Static; + }>({ + method: "GET", + url: "/wallet-subscriptions/get-all", + schema: { + params: PaginationSchema, + summary: "Get wallet subscriptions", + description: "Get all wallet subscriptions.", + tags: ["Wallet-Subscriptions"], + operationId: "getAllWalletSubscriptions", + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseSchema, + }, + }, + handler: async (request, reply) => { + const { page, limit } = request.params; + + const subscriptions = await getAllWalletSubscriptions({ + page, + limit, + }); + + reply.status(StatusCodes.OK).send({ + result: subscriptions.map(toWalletSubscriptionSchema), + }); + }, + }); +} diff --git a/src/server/routes/balance-subscriptions/update.ts b/src/server/routes/wallet-subscriptions/update.ts similarity index 52% rename from src/server/routes/balance-subscriptions/update.ts rename to src/server/routes/wallet-subscriptions/update.ts index ddf1d37b6..c4030dcbd 100644 --- a/src/server/routes/balance-subscriptions/update.ts +++ b/src/server/routes/wallet-subscriptions/update.ts @@ -1,25 +1,21 @@ import { type Static, Type } from "@sinclair/typebox"; import type { FastifyInstance } from "fastify"; import { StatusCodes } from "http-status-codes"; -import { updateBalanceSubscription } from "../../../shared/db/balance-subscriptions/update-balance-subscription"; -import { balanceSubscriptionConfigSchema } from "../../../shared/schemas/balance-subscription-config"; +import { updateWalletSubscription } from "../../../shared/db/wallet-subscriptions/update-wallet-subscription"; +import { WalletConditionsSchema } from "../../../shared/schemas/wallet-subscription-conditions"; import { AddressSchema } from "../../schemas/address"; import { chainIdOrSlugSchema } from "../../schemas/chain"; import { - balanceSubscriptionSchema, - toBalanceSubscriptionSchema, -} from "../../schemas/balance-subscription"; + walletSubscriptionSchema, + toWalletSubscriptionSchema, +} from "../../schemas/wallet-subscription"; import { standardResponseSchema } from "../../schemas/shared-api-schemas"; import { getChainIdFromChain } from "../../utils/chain"; const requestBodySchema = Type.Object({ - balanceSubscriptionId: Type.String({ - description: "The ID of the balance subscription to update.", - }), chain: Type.Optional(chainIdOrSlugSchema), - tokenAddress: Type.Optional(Type.Union([AddressSchema, Type.Null()])), walletAddress: Type.Optional(AddressSchema), - config: Type.Optional(balanceSubscriptionConfigSchema), + conditions: Type.Optional(WalletConditionsSchema), webhookId: Type.Optional( Type.Union([ Type.Integer({ @@ -30,22 +26,30 @@ const requestBodySchema = Type.Object({ ), }); +const paramsSchema = Type.Object({ + subscriptionId: Type.String({ + description: "The ID of the wallet subscription to update.", + }), +}); + const responseSchema = Type.Object({ - result: balanceSubscriptionSchema, + result: walletSubscriptionSchema, }); -export async function updateBalanceSubscriptionRoute(fastify: FastifyInstance) { +export async function updateWalletSubscriptionRoute(fastify: FastifyInstance) { fastify.route<{ Body: Static; Reply: Static; + Params: Static; }>({ method: "POST", - url: "/balance-subscriptions/update", + url: "/wallet-subscriptions/:subscriptionId", schema: { - summary: "Update balance subscription", - description: "Update an existing balance subscription.", - tags: ["Balance-Subscriptions"], - operationId: "updateBalanceSubscription", + params: paramsSchema, + summary: "Update wallet subscription", + description: "Update an existing wallet subscription.", + tags: ["Wallet-Subscriptions"], + operationId: "updateWalletSubscription", body: requestBodySchema, response: { ...standardResponseSchema, @@ -53,30 +57,24 @@ export async function updateBalanceSubscriptionRoute(fastify: FastifyInstance) { }, }, handler: async (request, reply) => { - const { - balanceSubscriptionId, - chain, - tokenAddress, - walletAddress, - config, - webhookId, - } = request.body; + const { subscriptionId } = request.params; + + const { chain, walletAddress, conditions, webhookId } = request.body; // Get chainId if chain is provided const chainId = chain ? await getChainIdFromChain(chain) : undefined; // Update the subscription - const balanceSubscription = await updateBalanceSubscription({ - id: balanceSubscriptionId, + const subscription = await updateWalletSubscription({ + id: subscriptionId, chainId: chainId?.toString(), - tokenAddress, walletAddress, - config, + conditions, webhookId, }); reply.status(StatusCodes.OK).send({ - result: toBalanceSubscriptionSchema(balanceSubscription), + result: toWalletSubscriptionSchema(subscription), }); }, }); diff --git a/src/server/schemas/balance-subscription.ts b/src/server/schemas/balance-subscription.ts deleted file mode 100644 index 4321bceeb..000000000 --- a/src/server/schemas/balance-subscription.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Type } from "@sinclair/typebox"; -import type { Prisma, Webhooks } from "@prisma/client"; -import { AddressSchema } from "./address"; -import { chainIdOrSlugSchema } from "./chain"; -import { balanceSubscriptionConfigSchema, balanceSubscriptionConfigZodSchema } from "../../shared/schemas/balance-subscription-config"; - -interface BalanceSubscriptionWithWebhook { - id: string; - chainId: string; - tokenAddress: string | null; - walletAddress: string; - config: Prisma.JsonValue; - webhookId: number | null; - webhook: Webhooks | null; - createdAt: Date; - updatedAt: Date; - deletedAt: Date | null; -} - -export const balanceSubscriptionSchema = Type.Object({ - id: Type.String(), - chain: chainIdOrSlugSchema, - tokenAddress: Type.Optional(AddressSchema), - walletAddress: AddressSchema, - config: balanceSubscriptionConfigSchema, - webhook: Type.Optional( - Type.Object({ - url: Type.String(), - }), - ), - createdAt: Type.String(), - updatedAt: Type.String(), -}); - -export type BalanceSubscriptionSchema = typeof balanceSubscriptionSchema; - -export function toBalanceSubscriptionSchema(subscription: BalanceSubscriptionWithWebhook) { - return { - id: subscription.id, - chain: subscription.chainId, - tokenAddress: subscription.tokenAddress ?? undefined, - walletAddress: subscription.walletAddress, - config: balanceSubscriptionConfigZodSchema.parse(subscription.config), - webhook: subscription.webhookId && subscription.webhook - ? { - url: subscription.webhook.url, - } - : undefined, - createdAt: subscription.createdAt.toISOString(), - updatedAt: subscription.updatedAt.toISOString(), - }; -} \ No newline at end of file diff --git a/src/server/schemas/wallet-subscription.ts b/src/server/schemas/wallet-subscription.ts new file mode 100644 index 000000000..8c944b856 --- /dev/null +++ b/src/server/schemas/wallet-subscription.ts @@ -0,0 +1,56 @@ +import { Type } from "@sinclair/typebox"; +import type { Prisma, Webhooks } from "@prisma/client"; +import { AddressSchema } from "./address"; +import { + WalletConditionsSchema, + validateConditions, +} from "../../shared/schemas/wallet-subscription-conditions"; + +interface WalletSubscriptionWithWebhook { + id: string; + chainId: string; + walletAddress: string; + conditions: Prisma.JsonValue[]; + webhookId: number | null; + webhook: Webhooks | null; + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; +} + +export const walletSubscriptionSchema = Type.Object({ + id: Type.String(), + chainId: Type.String({ + description: "The chain ID of the subscription.", + }), + walletAddress: AddressSchema, + conditions: WalletConditionsSchema, + webhook: Type.Optional( + Type.Object({ + url: Type.String(), + }), + ), + createdAt: Type.String(), + updatedAt: Type.String(), +}); + +export type WalletSubscriptionSchema = typeof walletSubscriptionSchema; + +export function toWalletSubscriptionSchema( + subscription: WalletSubscriptionWithWebhook, +) { + return { + id: subscription.id, + chainId: subscription.chainId, + walletAddress: subscription.walletAddress, + conditions: validateConditions(subscription.conditions), + webhook: + subscription.webhookId && subscription.webhook + ? { + url: subscription.webhook.url, + } + : undefined, + createdAt: subscription.createdAt.toISOString(), + updatedAt: subscription.updatedAt.toISOString(), + }; +} diff --git a/src/shared/db/balance-subscriptions/create-balance-subscription.ts b/src/shared/db/balance-subscriptions/create-balance-subscription.ts deleted file mode 100644 index 2cdf728c2..000000000 --- a/src/shared/db/balance-subscriptions/create-balance-subscription.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import { prisma } from "../client"; -import type { BalanceSubscriptionConfig } from "../../schemas/balance-subscription-config"; - -interface CreateBalanceSubscriptionParams { - chainId: string; - tokenAddress?: string; - walletAddress: string; - config: BalanceSubscriptionConfig; - webhookId?: number; -} - -export async function createBalanceSubscription({ - chainId, - tokenAddress, - walletAddress, - config, - webhookId, -}: CreateBalanceSubscriptionParams) { - // Check if a non-deleted subscription already exists - const existingSubscription = await prisma.balanceSubscriptions.findFirst({ - where: { - chainId, - tokenAddress, - walletAddress, - deletedAt: null, - }, - }); - - if (existingSubscription) { - // Update the existing subscription - return await prisma.balanceSubscriptions.update({ - where: { - id: existingSubscription.id, - }, - data: { - config: config as Prisma.InputJsonValue, - webhookId, - }, - include: { - webhook: true, - }, - }); - } - - // Create a new subscription - return await prisma.balanceSubscriptions.create({ - data: { - chainId, - tokenAddress, - walletAddress, - config: config as Prisma.InputJsonValue, - webhookId, - }, - include: { - webhook: true, - }, - }); -} \ No newline at end of file diff --git a/src/shared/db/balance-subscriptions/get-all-balance-subscriptions.ts b/src/shared/db/balance-subscriptions/get-all-balance-subscriptions.ts deleted file mode 100644 index 0988a21c0..000000000 --- a/src/shared/db/balance-subscriptions/get-all-balance-subscriptions.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { parseBalanceSubscriptionConfig } from "../../schemas/balance-subscription-config"; -import { prisma } from "../client"; - -export async function getAllBalanceSubscriptions() { - const subscriptions = await prisma.balanceSubscriptions.findMany({ - where: { - deletedAt: null, - }, - include: { - webhook: true, - }, - }); - - return subscriptions.map((subscription) => ({ - ...subscription, - config: parseBalanceSubscriptionConfig(subscription.config), - })); -} diff --git a/src/shared/db/balance-subscriptions/update-balance-subscription.ts b/src/shared/db/balance-subscriptions/update-balance-subscription.ts deleted file mode 100644 index 8d6d5bc02..000000000 --- a/src/shared/db/balance-subscriptions/update-balance-subscription.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Prisma } from "@prisma/client"; -import { prisma } from "../client"; -import type { BalanceSubscriptionConfig } from "../../schemas/balance-subscription-config"; - -interface UpdateBalanceSubscriptionParams { - id: string; - chainId?: string; - tokenAddress?: string | null; - walletAddress?: string; - config?: BalanceSubscriptionConfig; - webhookId?: number | null; -} - -export async function updateBalanceSubscription({ - id, - chainId, - tokenAddress, - walletAddress, - config, - webhookId, -}: UpdateBalanceSubscriptionParams) { - return await prisma.balanceSubscriptions.update({ - where: { - id, - deletedAt: null, - }, - data: { - ...(chainId && { chainId }), - ...(tokenAddress !== undefined && { tokenAddress }), - ...(walletAddress && { walletAddress: walletAddress.toLowerCase() }), - ...(config && { config: config as Prisma.InputJsonValue }), - ...(webhookId !== undefined && { webhookId }), - }, - include: { - webhook: true, - }, - }); -} \ No newline at end of file diff --git a/src/shared/db/wallet-subscriptions/create-wallet-subscription.ts b/src/shared/db/wallet-subscriptions/create-wallet-subscription.ts new file mode 100644 index 000000000..157549194 --- /dev/null +++ b/src/shared/db/wallet-subscriptions/create-wallet-subscription.ts @@ -0,0 +1,49 @@ +import type { Prisma } from "@prisma/client"; +import { prisma } from "../client"; +import type { WalletConditions } from "../../schemas/wallet-subscription-conditions"; +import { validateConditions } from "../../schemas/wallet-subscription-conditions"; +import { getWebhook } from "../webhooks/get-webhook"; +import { WebhooksEventTypes } from "../../schemas/webhooks"; + +interface CreateWalletSubscriptionParams { + chainId: string; + walletAddress: string; + conditions: WalletConditions; + webhookId?: number; +} + +export async function createWalletSubscription({ + chainId, + walletAddress, + conditions, + webhookId, +}: CreateWalletSubscriptionParams) { + // Validate conditions + const validatedConditions = validateConditions(conditions); + + if (webhookId) { + const webhook = await getWebhook(webhookId); + if (!webhook) { + throw new Error("Webhook not found"); + } + if (webhook.revokedAt) { + throw new Error("Webhook has been revoked"); + } + if (webhook.eventType !== WebhooksEventTypes.WALLET_SUBSCRIPTION) { + throw new Error("Webhook is not a wallet subscription webhook"); + } + } + + // Create a new subscription + return await prisma.walletSubscriptions.create({ + data: { + chainId, + walletAddress: walletAddress.toLowerCase(), + conditions: validatedConditions as Prisma.InputJsonValue[], + webhookId, + }, + include: { + webhook: true, + }, + }); +} diff --git a/src/shared/db/balance-subscriptions/delete-balance-subscription.ts b/src/shared/db/wallet-subscriptions/delete-wallet-subscription.ts similarity index 62% rename from src/shared/db/balance-subscriptions/delete-balance-subscription.ts rename to src/shared/db/wallet-subscriptions/delete-wallet-subscription.ts index 541bf863c..65e3ecd49 100644 --- a/src/shared/db/balance-subscriptions/delete-balance-subscription.ts +++ b/src/shared/db/wallet-subscriptions/delete-wallet-subscription.ts @@ -1,7 +1,7 @@ import { prisma } from "../client"; -export async function deleteBalanceSubscription(id: string) { - return await prisma.balanceSubscriptions.update({ +export async function deleteWalletSubscription(id: string) { + return await prisma.walletSubscriptions.update({ where: { id, deletedAt: null, diff --git a/src/shared/db/wallet-subscriptions/get-all-wallet-subscriptions.ts b/src/shared/db/wallet-subscriptions/get-all-wallet-subscriptions.ts new file mode 100644 index 000000000..bad4ec305 --- /dev/null +++ b/src/shared/db/wallet-subscriptions/get-all-wallet-subscriptions.ts @@ -0,0 +1,29 @@ +import { validateConditions } from "../../schemas/wallet-subscription-conditions"; +import { prisma } from "../client"; + +export async function getAllWalletSubscriptions({ + page, + limit, +}: { + page: number; + limit: number; +}) { + const subscriptions = await prisma.walletSubscriptions.findMany({ + where: { + deletedAt: null, + }, + include: { + webhook: true, + }, + skip: (page - 1) * limit, + take: limit, + orderBy: { + updatedAt: "desc", + }, + }); + + return subscriptions.map((subscription) => ({ + ...subscription, + conditions: validateConditions(subscription.conditions), + })); +} diff --git a/src/shared/db/wallet-subscriptions/update-wallet-subscription.ts b/src/shared/db/wallet-subscriptions/update-wallet-subscription.ts new file mode 100644 index 000000000..2082470d7 --- /dev/null +++ b/src/shared/db/wallet-subscriptions/update-wallet-subscription.ts @@ -0,0 +1,53 @@ +import type { Prisma } from "@prisma/client"; +import { prisma } from "../client"; +import type { WalletConditions } from "../../schemas/wallet-subscription-conditions"; +import { validateConditions } from "../../schemas/wallet-subscription-conditions"; +import { WebhooksEventTypes } from "../../schemas/webhooks"; +import { getWebhook } from "../webhooks/get-webhook"; + +interface UpdateWalletSubscriptionParams { + id: string; + chainId?: string; + walletAddress?: string; + conditions?: WalletConditions; + webhookId?: number | null; +} + +export async function updateWalletSubscription({ + id, + chainId, + walletAddress, + conditions, + webhookId, +}: UpdateWalletSubscriptionParams) { + if (webhookId) { + const webhook = await getWebhook(webhookId); + if (!webhook) { + throw new Error("Webhook not found"); + } + if (webhook.revokedAt) { + throw new Error("Webhook has been revoked"); + } + if (webhook.eventType !== WebhooksEventTypes.WALLET_SUBSCRIPTION) { + throw new Error("Webhook is not a wallet subscription webhook"); + } + } + + return await prisma.walletSubscriptions.update({ + where: { + id, + deletedAt: null, + }, + data: { + ...(chainId && { chainId }), + ...(walletAddress && { walletAddress: walletAddress.toLowerCase() }), + ...(conditions && { + conditions: validateConditions(conditions) as Prisma.InputJsonValue[], + }), + ...(webhookId !== undefined && { webhookId }), + }, + include: { + webhook: true, + }, + }); +} diff --git a/src/shared/schemas/balance-subscription-config.ts b/src/shared/schemas/balance-subscription-config.ts deleted file mode 100644 index c0f897482..000000000 --- a/src/shared/schemas/balance-subscription-config.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Type } from "@sinclair/typebox"; -import { z } from "zod"; - -// TypeBox schema for request validation -export const balanceSubscriptionConfigSchema = Type.Object({ - threshold: Type.Optional( - Type.Object({ - min: Type.Optional(Type.String({ - description: "Minimum balance threshold", - examples: ["1000000000000000000"], // 1 ETH in wei - })), - max: Type.Optional(Type.String({ - description: "Maximum balance threshold", - examples: ["10000000000000000000"], // 10 ETH in wei - })), - }), - ), -}); - -// Zod schema for response parsing -export const balanceSubscriptionConfigZodSchema = z.object({ - threshold: z.object({ - min: z.string().optional(), - max: z.string().optional(), - }).optional(), -}); - -export type BalanceSubscriptionConfig = z.infer; - -// Helper to ensure config is properly typed when creating -export function parseBalanceSubscriptionConfig(config: unknown): BalanceSubscriptionConfig { - return balanceSubscriptionConfigZodSchema.parse(config); -} \ No newline at end of file diff --git a/src/shared/schemas/wallet-subscription-conditions.ts b/src/shared/schemas/wallet-subscription-conditions.ts new file mode 100644 index 000000000..72e27f040 --- /dev/null +++ b/src/shared/schemas/wallet-subscription-conditions.ts @@ -0,0 +1,53 @@ +import { Type } from "@sinclair/typebox"; +import { z } from "zod"; +import { AddressSchema } from "../../server/schemas/address"; + +// TypeBox schemas for API validation +export const WalletConditionSchema = Type.Union([ + Type.Object({ + type: Type.Literal('token_balance_lt'), + tokenAddress: Type.Union([AddressSchema, Type.Literal('native')]), + value: Type.String({ + description: "The threshold value in wei", + examples: ["1000000000000000000"] // 1 ETH + }) + }), + Type.Object({ + type: Type.Literal('token_balance_gt'), + tokenAddress: Type.Union([AddressSchema, Type.Literal('native')]), + value: Type.String({ + description: "The threshold value in wei", + examples: ["1000000000000000000"] // 1 ETH + }) + }) +]); + +export const WalletConditionsSchema = Type.Array(WalletConditionSchema, { + maxItems: 100, + description: "Array of conditions to monitor for this wallet" +}); + +// Zod schemas for internal validation +export const WalletConditionZ = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('token_balance_lt'), + tokenAddress: z.union([z.string(), z.literal('native')]), + value: z.string() + }), + z.object({ + type: z.literal('token_balance_gt'), + tokenAddress: z.union([z.string(), z.literal('native')]), + value: z.string() + }) +]); + +export const WalletConditionsZ = z.array(WalletConditionZ).max(100); + +// Type exports +export type WalletCondition = z.infer; +export type WalletConditions = z.infer; + +// Helper to validate conditions +export function validateConditions(conditions: unknown): WalletConditions { + return WalletConditionsZ.parse(conditions); +} \ No newline at end of file diff --git a/src/shared/schemas/webhooks.ts b/src/shared/schemas/webhooks.ts index f573111ea..478168430 100644 --- a/src/shared/schemas/webhooks.ts +++ b/src/shared/schemas/webhooks.ts @@ -1,5 +1,4 @@ -import type { z } from "zod"; -import type { balanceSubscriptionConfigZodSchema } from "./balance-subscription-config"; +import type { WalletCondition } from "./wallet-subscription-conditions"; export enum WebhooksEventTypes { QUEUED_TX = "queued_transaction", @@ -11,7 +10,7 @@ export enum WebhooksEventTypes { BACKEND_WALLET_BALANCE = "backend_wallet_balance", AUTH = "auth", CONTRACT_SUBSCRIPTION = "contract_subscription", - BALANCE_SUBSCRIPTION = "balance_subscription", + WALLET_SUBSCRIPTION = "wallet_subscription", } export type BackendWalletBalanceWebhookParams = { @@ -21,12 +20,10 @@ export type BackendWalletBalanceWebhookParams = { chainId: number; message: string; }; - -export interface BalanceSubscriptionWebhookParams { +export interface WalletSubscriptionWebhookParams { subscriptionId: string; chainId: string; - tokenAddress: string | null; walletAddress: string; - balance: string; - config: z.infer; + condition: WalletCondition; + currentValue: string; } diff --git a/src/worker/index.ts b/src/worker/index.ts index 6fb16f071..41b3825d3 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -7,7 +7,6 @@ import { newWebhooksListener, updatedWebhooksListener, } from "./listeners/webhook-listener"; -import { initBalanceSubscriptionWorker } from "./tasks/balance-subscription-worker"; import { initCancelRecycledNoncesWorker } from "./tasks/cancel-recycled-nonces-worker"; import { initMineTransactionWorker } from "./tasks/mine-transaction-worker"; import { initNonceHealthCheckWorker } from "./tasks/nonce-health-check-worker"; @@ -17,6 +16,7 @@ import { initProcessTransactionReceiptsWorker } from "./tasks/process-transactio import { initPruneTransactionsWorker } from "./tasks/prune-transactions-worker"; import { initSendTransactionWorker } from "./tasks/send-transaction-worker"; import { initSendWebhookWorker } from "./tasks/send-webhook-worker"; +import { initWalletSubscriptionWorker } from "./tasks/wallet-subscription-worker"; export const initWorker = async () => { initCancelRecycledNoncesWorker(); @@ -26,11 +26,10 @@ export const initWorker = async () => { initSendTransactionWorker(); initMineTransactionWorker(); initSendWebhookWorker(); - initNonceHealthCheckWorker(); await initNonceResyncWorker(); - await initBalanceSubscriptionWorker(); + await initWalletSubscriptionWorker(); // Listen for new & updated configuration data. await newConfigurationListener(); diff --git a/src/worker/queues/send-webhook-queue.ts b/src/worker/queues/send-webhook-queue.ts index b5ae15a3c..6259670c4 100644 --- a/src/worker/queues/send-webhook-queue.ts +++ b/src/worker/queues/send-webhook-queue.ts @@ -8,7 +8,7 @@ import SuperJSON from "superjson"; import { WebhooksEventTypes, type BackendWalletBalanceWebhookParams, - type BalanceSubscriptionWebhookParams, + type WalletSubscriptionWebhookParams, } from "../../shared/schemas/webhooks"; import { getWebhooksByEventType } from "../../shared/utils/cache/get-webhook"; import { redis } from "../../shared/utils/redis/redis"; @@ -35,10 +35,10 @@ export type EnqueueLowBalanceWebhookData = { body: BackendWalletBalanceWebhookParams; }; -export type EnqueueBalanceSubscriptionWebhookData = { - type: WebhooksEventTypes.BALANCE_SUBSCRIPTION; +export type EnqueueWalletSubscriptionWebhookData = { + type: WebhooksEventTypes.WALLET_SUBSCRIPTION; webhook: Webhooks; - body: BalanceSubscriptionWebhookParams; + body: WalletSubscriptionWebhookParams; }; // Add other webhook event types here. @@ -46,7 +46,7 @@ type EnqueueWebhookData = | EnqueueContractSubscriptionWebhookData | EnqueueTransactionWebhookData | EnqueueLowBalanceWebhookData - | EnqueueBalanceSubscriptionWebhookData; + | EnqueueWalletSubscriptionWebhookData; export interface WebhookJob { data: EnqueueWebhookData; @@ -74,8 +74,8 @@ export class SendWebhookQueue { return this._enqueueTransactionWebhook(data); case WebhooksEventTypes.BACKEND_WALLET_BALANCE: return this._enqueueBackendWalletBalanceWebhook(data); - case WebhooksEventTypes.BALANCE_SUBSCRIPTION: - return this._enqueueBalanceSubscriptionWebhook(data); + case WebhooksEventTypes.WALLET_SUBSCRIPTION: + return this._enqueueWalletSubscriptionWebhook(data); } }; @@ -172,8 +172,8 @@ export class SendWebhookQueue { } }; - private static _enqueueBalanceSubscriptionWebhook = async ( - data: EnqueueBalanceSubscriptionWebhookData, + private static _enqueueWalletSubscriptionWebhook = async ( + data: EnqueueWalletSubscriptionWebhookData, ) => { const { type, webhook, body } = data; if (!webhook.revokedAt && type === webhook.eventType) { diff --git a/src/worker/queues/balance-subscription-queue.ts b/src/worker/queues/wallet-subscription-queue.ts similarity index 58% rename from src/worker/queues/balance-subscription-queue.ts rename to src/worker/queues/wallet-subscription-queue.ts index 60b65f0c3..15f4344ae 100644 --- a/src/worker/queues/balance-subscription-queue.ts +++ b/src/worker/queues/wallet-subscription-queue.ts @@ -2,13 +2,13 @@ import { Queue } from "bullmq"; import { redis } from "../../shared/utils/redis/redis"; import { defaultJobOptions } from "./queues"; -export class BalanceSubscriptionQueue { - static q = new Queue("balance-subscription", { +export class WalletSubscriptionQueue { + static q = new Queue("wallet-subscription", { connection: redis, defaultJobOptions, }); constructor() { - BalanceSubscriptionQueue.q.setGlobalConcurrency(1); + WalletSubscriptionQueue.q.setGlobalConcurrency(1); } } \ No newline at end of file diff --git a/src/worker/tasks/balance-subscription-worker.ts b/src/worker/tasks/balance-subscription-worker.ts deleted file mode 100644 index 9a9dfebae..000000000 --- a/src/worker/tasks/balance-subscription-worker.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { type Job, type Processor, Worker } from "bullmq"; -import { getAllBalanceSubscriptions } from "../../shared/db/balance-subscriptions/get-all-balance-subscriptions"; -import { getConfig } from "../../shared/utils/cache/get-config"; -import { logger } from "../../shared/utils/logger"; -import { redis } from "../../shared/utils/redis/redis"; -import { BalanceSubscriptionQueue } from "../queues/balance-subscription-queue"; -import { logWorkerExceptions } from "../queues/queues"; -import { SendWebhookQueue } from "../queues/send-webhook-queue"; -import { - WebhooksEventTypes, - type BalanceSubscriptionWebhookParams, -} from "../../shared/schemas/webhooks"; -import { parseBalanceSubscriptionConfig } from "../../shared/schemas/balance-subscription-config"; -import { getChain } from "../../shared/utils/chain"; -import { thirdwebClient } from "../../shared/utils/sdk"; -import { maxUint256 } from "thirdweb/utils"; -import { getWalletBalance } from "thirdweb/wallets"; - -// Split array into chunks of specified size -function chunk(arr: T[], size: number): T[][] { - return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => - arr.slice(i * size, i * size + size), - ); -} - -// Must be explicitly called for the worker to run on this host. -export const initBalanceSubscriptionWorker = async () => { - const config = await getConfig(); - const cronPattern = - config.balanceSubscriptionsCronSchedule || "*/2 * * * * *"; // Default to every 30 seconds - - logger({ - service: "worker", - level: "info", - message: `Initializing balance subscription worker with cron pattern: ${cronPattern}`, - }); - - BalanceSubscriptionQueue.q.add("cron", "", { - repeat: { pattern: cronPattern }, - jobId: "balance-subscription-cron", - }); - - const _worker = new Worker(BalanceSubscriptionQueue.q.name, handler, { - connection: redis, - concurrency: 1, - }); - logWorkerExceptions(_worker); -}; - -/** - * Process all balance subscriptions and notify webhooks of changes. - */ -const handler: Processor = async (job: Job) => { - // Get all active balance subscriptions - const subscriptions = await getAllBalanceSubscriptions(); - if (subscriptions.length === 0) { - return; - } - - // Process in batches of 50 - const batches = chunk(subscriptions, 50); - for (const batch of batches) { - await Promise.all( - batch.map(async (subscription) => { - try { - // Get the current balance - let currentBalance: bigint; - const chain = await getChain(Number.parseInt(subscription.chainId)); - - const currentBalanceResponse = await getWalletBalance({ - address: subscription.walletAddress, - client: thirdwebClient, - tokenAddress: subscription.tokenAddress ?? undefined, // get ERC20 balance if token address is provided - chain, - }); - - currentBalance = currentBalanceResponse.value; - - const max = subscription.config.threshold?.max - ? BigInt(subscription.config.threshold.max) - : 0n; // If no max set, use 0 (always below current) - const min = subscription.config.threshold?.min - ? BigInt(subscription.config.threshold.min) - : maxUint256; // If no min set, use max uint256 (always above current) - - if (currentBalance <= max && currentBalance >= min) { - return; - } - - // If there's a webhook, queue notification - if (subscription.webhookId && subscription.webhook) { - const webhookBody: BalanceSubscriptionWebhookParams = { - subscriptionId: subscription.id, - chainId: subscription.chainId, - tokenAddress: subscription.tokenAddress, - walletAddress: subscription.walletAddress, - balance: currentBalance.toString(), - config: parseBalanceSubscriptionConfig(subscription.config), - }; - - await SendWebhookQueue.enqueueWebhook({ - type: WebhooksEventTypes.BALANCE_SUBSCRIPTION, - webhook: subscription.webhook, - body: webhookBody, - }); - } - } catch (error) { - // Log error but continue processing other subscriptions - const message = - error instanceof Error ? error.message : String(error); - job.log( - `Error processing subscription ${subscription.id}: ${message}`, - ); - logger({ - service: "worker", - level: "error", - message: `Error processing balance subscription: ${message}`, - error: error as Error, - }); - } - }), - ); - } -}; diff --git a/src/worker/tasks/send-webhook-worker.ts b/src/worker/tasks/send-webhook-worker.ts index d390096e9..61649133f 100644 --- a/src/worker/tasks/send-webhook-worker.ts +++ b/src/worker/tasks/send-webhook-worker.ts @@ -5,7 +5,7 @@ import { TransactionDB } from "../../shared/db/transactions/db"; import { WebhooksEventTypes, type BackendWalletBalanceWebhookParams, - type BalanceSubscriptionWebhookParams, + type WalletSubscriptionWebhookParams, } from "../../shared/schemas/webhooks"; import { toEventLogSchema } from "../../server/schemas/event-log"; import { @@ -74,8 +74,8 @@ const handler: Processor = async (job: Job) => { break; } - case WebhooksEventTypes.BALANCE_SUBSCRIPTION: { - const webhookBody: BalanceSubscriptionWebhookParams = data.body; + case WebhooksEventTypes.WALLET_SUBSCRIPTION: { + const webhookBody: WalletSubscriptionWebhookParams = data.body; resp = await sendWebhookRequest( webhook, webhookBody as unknown as Record, diff --git a/src/worker/tasks/wallet-subscription-worker.ts b/src/worker/tasks/wallet-subscription-worker.ts new file mode 100644 index 000000000..937fcb0ad --- /dev/null +++ b/src/worker/tasks/wallet-subscription-worker.ts @@ -0,0 +1,168 @@ +import { type Job, type Processor, Worker } from "bullmq"; +import { getAllWalletSubscriptions } from "../../shared/db/wallet-subscriptions/get-all-wallet-subscriptions"; +import { getConfig } from "../../shared/utils/cache/get-config"; +import { logger } from "../../shared/utils/logger"; +import { redis } from "../../shared/utils/redis/redis"; +import { WalletSubscriptionQueue } from "../queues/wallet-subscription-queue"; +import { logWorkerExceptions } from "../queues/queues"; +import { SendWebhookQueue } from "../queues/send-webhook-queue"; +import { WebhooksEventTypes } from "../../shared/schemas/webhooks"; +import { getChain } from "../../shared/utils/chain"; +import { thirdwebClient } from "../../shared/utils/sdk"; +import { getWalletBalance } from "thirdweb/wallets"; +import type { Chain } from "thirdweb/chains"; +import type { WalletCondition } from "../../shared/schemas/wallet-subscription-conditions"; +import type { Webhooks } from "@prisma/client"; + +interface WalletSubscriptionWithWebhook { + id: string; + chainId: string; + walletAddress: string; + conditions: WalletCondition[]; + webhookId: number | null; + webhook: Webhooks | null; + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; +} + +// Split array into chunks of specified size +function chunk(arr: T[], size: number): T[][] { + return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => + arr.slice(i * size, i * size + size), + ); +} + +/** + * Verify if a condition is met for a given wallet + * Returns the current value if condition is met, undefined otherwise + */ +async function verifyCondition({ + condition, + walletAddress, + chain, +}: { + condition: WalletCondition; + walletAddress: string; + chain: Chain; +}): Promise { + switch (condition.type) { + case "token_balance_lt": + case "token_balance_gt": { + const currentBalanceResponse = await getWalletBalance({ + address: walletAddress, + client: thirdwebClient, + tokenAddress: + condition.tokenAddress === "native" + ? undefined + : condition.tokenAddress, + chain, + }); + + const currentBalance = currentBalanceResponse.value; + const threshold = BigInt(condition.value); + + const isConditionMet = + condition.type === "token_balance_lt" + ? currentBalance < threshold + : currentBalance > threshold; + + return isConditionMet ? currentBalance.toString() : undefined; + } + default: { + // For TypeScript exhaustiveness check + const _exhaustiveCheck: never = condition; + return undefined; + } + } +} + +/** + * Process a batch of subscriptions and trigger webhooks for any met conditions + */ +async function processSubscriptions( + subscriptions: WalletSubscriptionWithWebhook[], +) { + await Promise.all( + subscriptions.map(async (subscription) => { + try { + const chain = await getChain(Number.parseInt(subscription.chainId)); + + // Process each condition for the subscription + for (const condition of subscription.conditions) { + const currentValue = await verifyCondition({ + condition, + walletAddress: subscription.walletAddress, + chain, + }); + + if (currentValue && subscription.webhookId && subscription.webhook) { + await SendWebhookQueue.enqueueWebhook({ + type: WebhooksEventTypes.WALLET_SUBSCRIPTION, + webhook: subscription.webhook, + body: { + subscriptionId: subscription.id, + chainId: subscription.chainId, + walletAddress: subscription.walletAddress, + condition, + currentValue, + }, + }); + } + } + } catch (error) { + // Log error but continue processing other subscriptions + const message = error instanceof Error ? error.message : String(error); + logger({ + service: "worker", + level: "error", + message: `Error processing wallet subscription ${subscription.id}: ${message}`, + error: error as Error, + }); + } + }), + ); +} + +// Must be explicitly called for the worker to run on this host. +export const initWalletSubscriptionWorker = async () => { + const config = await getConfig(); + const cronPattern = config.walletSubscriptionsCronSchedule || "*/30 * * * * *"; // Default to every 30 seconds + + logger({ + service: "worker", + level: "info", + message: `Initializing wallet subscription worker with cron pattern: ${cronPattern}`, + }); + + WalletSubscriptionQueue.q.add("cron", "", { + repeat: { pattern: cronPattern }, + jobId: "wallet-subscription-cron", + }); + + const _worker = new Worker(WalletSubscriptionQueue.q.name, handler, { + connection: redis, + concurrency: 1, + }); + logWorkerExceptions(_worker); +}; + +/** + * Process all wallet subscriptions and notify webhooks when conditions are met. + */ +const handler: Processor = async (_job: Job) => { + // Get all active wallet subscriptions + const subscriptions = await getAllWalletSubscriptions({ + page: 1, + limit: 1000, // Process 1000 subscriptions at a time + }); + if (subscriptions.length === 0) { + return; + } + + // Process in batches of 50 + const batches = chunk(subscriptions, 50); + for (const batch of batches) { + await processSubscriptions(batch); + } +}; diff --git a/tests/e2e/tests/workers/balance-subscription-worker.test.ts b/tests/e2e/tests/workers/balance-subscription-worker.test.ts deleted file mode 100644 index 5c2b8e414..000000000 --- a/tests/e2e/tests/workers/balance-subscription-worker.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { beforeAll, afterAll, describe, expect, test } from "vitest"; -import Fastify, { type FastifyInstance } from "fastify"; -import { setup } from "../setup"; -import type { BalanceSubscriptionWebhookParams } from "../../../../src/shared/schemas/webhooks"; -import type { Engine } from "../../../../sdk/dist/thirdweb-dev-engine.cjs"; - -describe("Balance Subscription Worker", () => { - let testCallbackServer: FastifyInstance; - let engine: Engine; - // state to be updated by webhook callback - let lastWebhookPayload: BalanceSubscriptionWebhookParams | null = null; - - beforeAll(async () => { - engine = (await setup()).engine; - testCallbackServer = await createTempCallbackServer(); - }); - - afterAll(async () => { - await testCallbackServer.close(); - }); - - const createTempCallbackServer = async () => { - const tempServer = Fastify(); - - tempServer.post("/callback", async (request) => { - console.log(request.body); - lastWebhookPayload = request.body as BalanceSubscriptionWebhookParams; - return { success: true }; - }); - - await tempServer.listen({ port: 3006 }); - - return tempServer; - }; - - // helper to fetch updated state value after webhook callback - const waitForWebhookCallback = async (): Promise<{ - status: boolean; - payload?: BalanceSubscriptionWebhookParams; - }> => { - return await new Promise((res, rej) => { - // check for webhook payload update - const interval = setInterval(() => { - if (!lastWebhookPayload) return; - clearInterval(interval); - res({ status: true, payload: lastWebhookPayload }); - }, 250); - - // reject if its taking too long to update state - setTimeout(() => { - rej({ status: false }); - }, 1000 * 30); - }); - }; - - const testWithThreshold = async ( - minBalance: string, - maxBalance: string, - expectedToTrigger: boolean, - ) => { - // Reset webhook payload - lastWebhookPayload = null; - - // Create balance subscription - const subscription = ( - await engine.balanceSubscriptions.addBalanceSubscription({ - chain: "137", - walletAddress: "0xE52772e599b3fa747Af9595266b527A31611cebd", - config: { - threshold: { - min: minBalance, - max: maxBalance, - }, - }, - webhookUrl: "http://localhost:3006/callback", - }) - ).result; - - // Check if subscription is created correctly - expect(subscription.chain).toEqual("137"); - expect(subscription.walletAddress.toLowerCase()).toEqual( - "0xE52772e599b3fa747Af9595266b527A31611cebd".toLowerCase(), - ); - expect(subscription.config.threshold?.min).toEqual(minBalance); - expect(subscription.config.threshold?.max).toEqual(maxBalance); - expect(subscription.webhook?.url).toEqual("http://localhost:3006/callback"); - - let testStatus: string; - try { - const response = await waitForWebhookCallback(); - if (expectedToTrigger) { - expect(response.status).toEqual(true); - expect(response.payload).toBeDefined(); - expect(response.payload?.subscriptionId).toEqual(subscription.id); - expect(response.payload?.chainId).toEqual("137"); - expect(response.payload?.walletAddress.toLowerCase()).toEqual( - "0xE52772e599b3fa747Af9595266b527A31611cebd".toLowerCase(), - ); - expect(response.payload?.balance).toBeDefined(); - expect(response.payload?.config).toEqual(subscription.config); - testStatus = "completed"; - } else { - testStatus = "webhook not called"; - } - } catch (e) { - console.error(e); - testStatus = "webhook not called"; - } - - // Cleanup - await engine.balanceSubscriptions.removeBalanceSubscription({ - balanceSubscriptionId: subscription.id, - }); - - // Verify test outcome - expect(testStatus).toEqual( - expectedToTrigger ? "completed" : "webhook not called", - ); - }; - - test( - "should not trigger webhook when balance is within thresholds", - async () => { - // Set thresholds that the current balance should be between - await testWithThreshold( - "100000000000000000", // 0.1 ETH - "10000000000000000000", // 10 ETH - false, - ); - }, - 1000 * 60, // increase timeout - ); - - test( - "should trigger webhook when balance is below min threshold", - async () => { - // Set min threshold higher than current balance - await testWithThreshold( - "1000000000000000000000", // 1000 ETH - "10000000000000000000000", // 10000 ETH - true, - ); - }, - 1000 * 60, - ); - - test( - "should trigger webhook when balance is above max threshold", - async () => { - // Set max threshold lower than current balance - await testWithThreshold( - "10000000000000000", // 0.01 ETH - "100000000000000000", // 0.1 ETH - true, - ); - }, - 1000 * 60, - ); -}); diff --git a/tests/e2e/tests/workers/wallet-subscription-worker.test.ts b/tests/e2e/tests/workers/wallet-subscription-worker.test.ts new file mode 100644 index 000000000..5bdaec1fd --- /dev/null +++ b/tests/e2e/tests/workers/wallet-subscription-worker.test.ts @@ -0,0 +1,222 @@ +import { + beforeAll, + afterAll, + describe, + expect, + test, + beforeEach, + afterEach, +} from "vitest"; +import Fastify, { type FastifyInstance } from "fastify"; +import { setup } from "../setup"; +import type { WalletSubscriptionWebhookParams } from "../../../../src/shared/schemas/webhooks"; +import type { Engine } from "../../../../sdk/dist/thirdweb-dev-engine.cjs"; +import type { WalletCondition } from "../../../../src/shared/schemas/wallet-subscription-conditions"; +import { sleep } from "bun"; + +describe("Wallet Subscription Worker", () => { + let testCallbackServer: FastifyInstance; + let engine: Engine; + let webhookPayloads: WalletSubscriptionWebhookParams[] = []; + let webhookId: number; + + beforeAll(async () => { + engine = (await setup()).engine; + testCallbackServer = await createTempCallbackServer(); + + // Create a webhook that we'll reuse for all tests + const webhook = await engine.webhooks.create({ + url: "http://localhost:3006/callback", + eventType: "wallet_subscription", + }); + webhookId = webhook.result.id; + }); + + afterAll(async () => { + await testCallbackServer.close(); + }); + + beforeEach(() => { + // Clear webhook payloads before each test + webhookPayloads = []; + }); + + afterEach(async () => { + await sleep(5000); // wait for any unsent webhooks to be sent + }); + + const createTempCallbackServer = async () => { + const tempServer = Fastify(); + + tempServer.post("/callback", async (request) => { + const payload = request.body as WalletSubscriptionWebhookParams; + webhookPayloads.push(payload); + return { success: true }; + }); + + await tempServer.listen({ port: 3006 }); + return tempServer; + }; + + const waitForWebhookPayloads = async ( + timeoutMs = 5000, + ): Promise => { + // Wait for initial webhooks to come in + await new Promise((resolve) => setTimeout(resolve, timeoutMs)); + return webhookPayloads; + }; + + const createSubscription = async (conditions: WalletCondition[]) => { + const subscription = await engine.walletSubscriptions.addWalletSubscription( + { + chain: "137", + walletAddress: "0xE52772e599b3fa747Af9595266b527A31611cebd", + conditions, + webhookId, + }, + ); + + return subscription.result; + }; + + test("should create and validate wallet subscription", async () => { + const condition: WalletCondition = { + type: "token_balance_lt", + value: "100000000000000000", // 0.1 ETH + tokenAddress: "native", + }; + + const subscription = await createSubscription([condition]); + + expect(subscription.chainId).toBe("137"); + expect(subscription.walletAddress.toLowerCase()).toBe( + "0xE52772e599b3fa747Af9595266b527A31611cebd".toLowerCase(), + ); + expect(subscription.conditions).toEqual([condition]); + expect(subscription.webhook?.url).toBe("http://localhost:3006/callback"); + + // Cleanup + await engine.walletSubscriptions.deleteWalletSubscription(subscription.id); + }); + + test("should fire webhooks for token balance less than threshold", async () => { + const condition: WalletCondition = { + type: "token_balance_lt", + value: "1000000000000000000000", // 1000 ETH (high threshold to ensure trigger) + tokenAddress: "native", + }; + + const subscription = await createSubscription([condition]); + + try { + const payloads = await waitForWebhookPayloads(); + + // Verify we got webhooks + expect(payloads.length).toBeGreaterThan(0); + + // Verify webhook data is correct + for (const payload of payloads) { + expect(payload.subscriptionId).toBe(subscription.id); + expect(payload.chainId).toBe("137"); + expect(payload.walletAddress.toLowerCase()).toBe( + "0xE52772e599b3fa747Af9595266b527A31611cebd".toLowerCase(), + ); + expect(payload.condition).toEqual(condition); + expect(BigInt(payload.currentValue)).toBeLessThan( + BigInt(condition.value), + ); + } + } finally { + await engine.walletSubscriptions.deleteWalletSubscription( + subscription.id, + ); + } + }); + + test("should fire webhooks for token balance greater than threshold", async () => { + const condition: WalletCondition = { + type: "token_balance_gt", + value: "1000000000000", // Very small threshold to ensure trigger + tokenAddress: "native", + }; + + const subscription = await createSubscription([condition]); + + try { + const payloads = await waitForWebhookPayloads(); + + // Verify we got webhooks + expect(payloads.length).toBeGreaterThan(0); + + // Verify webhook data is correct + for (const payload of payloads) { + expect(payload.subscriptionId).toBe(subscription.id); + expect(payload.chainId).toBe("137"); + expect(payload.walletAddress.toLowerCase()).toBe( + "0xE52772e599b3fa747Af9595266b527A31611cebd".toLowerCase(), + ); + expect(payload.condition).toEqual(condition); + expect(BigInt(payload.currentValue)).toBeGreaterThan( + BigInt(condition.value), + ); + } + } finally { + await engine.walletSubscriptions.deleteWalletSubscription( + subscription.id, + ); + } + }); + + test("should fire webhooks for multiple conditions", async () => { + const conditions: WalletCondition[] = [ + { + type: "token_balance_gt", + value: "1000000000000", // Very small threshold to ensure trigger + tokenAddress: "native", + }, + { + type: "token_balance_lt", + value: "1000000000000000000000", // 1000 ETH (high threshold to ensure trigger) + tokenAddress: "native", + }, + ]; + + const subscription = await createSubscription(conditions); + + try { + const payloads = await waitForWebhookPayloads(); + + // Verify we got webhooks for both conditions + expect(payloads.length).toBeGreaterThan(1); + + // Verify we got webhooks for both conditions + const uniqueConditions = new Set(payloads.map((p) => p.condition.type)); + expect(uniqueConditions.size).toBe(2); + + // Verify each webhook has correct data + for (const payload of payloads) { + expect(payload.subscriptionId).toBe(subscription.id); + expect(payload.chainId).toBe("137"); + expect(payload.walletAddress.toLowerCase()).toBe( + "0xE52772e599b3fa747Af9595266b527A31611cebd".toLowerCase(), + ); + expect(payload.currentValue).toBeDefined(); + + // Verify the value satisfies the condition + if (payload.condition.type === "token_balance_gt") { + expect(BigInt(payload.currentValue)).toBeGreaterThan( + BigInt(payload.condition.value), + ); + } else { + expect(BigInt(payload.currentValue)).toBeLessThan( + BigInt(payload.condition.value), + ); + } + } + } finally { + await engine.walletSubscriptions.deleteWalletSubscription( + subscription.id, + ); + } + }); +}); From 88184f8b0fa01adf962039f44458688331cc6819 Mon Sep 17 00:00:00 2001 From: Prithvish Baidya Date: Tue, 18 Feb 2025 09:47:06 +0530 Subject: [PATCH 8/8] addressed review comments --- src/server/schemas/wallet-subscription.ts | 14 ++------ .../create-wallet-subscription.ts | 6 ++++ .../get-all-wallet-subscriptions.ts | 12 +++---- .../tasks/wallet-subscription-worker.ts | 33 ++++++------------- 4 files changed, 24 insertions(+), 41 deletions(-) diff --git a/src/server/schemas/wallet-subscription.ts b/src/server/schemas/wallet-subscription.ts index 8c944b856..8930c01d0 100644 --- a/src/server/schemas/wallet-subscription.ts +++ b/src/server/schemas/wallet-subscription.ts @@ -1,22 +1,14 @@ import { Type } from "@sinclair/typebox"; -import type { Prisma, Webhooks } from "@prisma/client"; +import type { WalletSubscriptions, Webhooks } from "@prisma/client"; import { AddressSchema } from "./address"; import { WalletConditionsSchema, validateConditions, } from "../../shared/schemas/wallet-subscription-conditions"; -interface WalletSubscriptionWithWebhook { - id: string; - chainId: string; - walletAddress: string; - conditions: Prisma.JsonValue[]; - webhookId: number | null; +type WalletSubscriptionWithWebhook = WalletSubscriptions & { webhook: Webhooks | null; - createdAt: Date; - updatedAt: Date; - deletedAt: Date | null; -} +}; export const walletSubscriptionSchema = Type.Object({ id: Type.String(), diff --git a/src/shared/db/wallet-subscriptions/create-wallet-subscription.ts b/src/shared/db/wallet-subscriptions/create-wallet-subscription.ts index 157549194..437833a4f 100644 --- a/src/shared/db/wallet-subscriptions/create-wallet-subscription.ts +++ b/src/shared/db/wallet-subscriptions/create-wallet-subscription.ts @@ -34,6 +34,12 @@ export async function createWalletSubscription({ } } + const existingSubscriptionsCount = await prisma.walletSubscriptions.count({}); + + if (existingSubscriptionsCount >= 1000) { + throw new Error("Maximum number of wallet subscriptions reached"); + } + // Create a new subscription return await prisma.walletSubscriptions.create({ data: { diff --git a/src/shared/db/wallet-subscriptions/get-all-wallet-subscriptions.ts b/src/shared/db/wallet-subscriptions/get-all-wallet-subscriptions.ts index bad4ec305..631254375 100644 --- a/src/shared/db/wallet-subscriptions/get-all-wallet-subscriptions.ts +++ b/src/shared/db/wallet-subscriptions/get-all-wallet-subscriptions.ts @@ -1,13 +1,11 @@ import { validateConditions } from "../../schemas/wallet-subscription-conditions"; import { prisma } from "../client"; -export async function getAllWalletSubscriptions({ - page, - limit, -}: { - page: number; - limit: number; +export async function getAllWalletSubscriptions(args?: { + page?: number; + limit?: number; }) { + const { page, limit } = args || {}; const subscriptions = await prisma.walletSubscriptions.findMany({ where: { deletedAt: null, @@ -15,7 +13,7 @@ export async function getAllWalletSubscriptions({ include: { webhook: true, }, - skip: (page - 1) * limit, + skip: page && limit ? (page - 1) * limit : undefined, take: limit, orderBy: { updatedAt: "desc", diff --git a/src/worker/tasks/wallet-subscription-worker.ts b/src/worker/tasks/wallet-subscription-worker.ts index 937fcb0ad..9ede42425 100644 --- a/src/worker/tasks/wallet-subscription-worker.ts +++ b/src/worker/tasks/wallet-subscription-worker.ts @@ -12,19 +12,13 @@ import { thirdwebClient } from "../../shared/utils/sdk"; import { getWalletBalance } from "thirdweb/wallets"; import type { Chain } from "thirdweb/chains"; import type { WalletCondition } from "../../shared/schemas/wallet-subscription-conditions"; -import type { Webhooks } from "@prisma/client"; +import type { WalletSubscriptions, Webhooks } from "@prisma/client"; +import { prettifyError } from "../../shared/utils/error"; -interface WalletSubscriptionWithWebhook { - id: string; - chainId: string; - walletAddress: string; +type WalletSubscriptionWithWebhook = WalletSubscriptions & { conditions: WalletCondition[]; - webhookId: number | null; webhook: Webhooks | null; - createdAt: Date; - updatedAt: Date; - deletedAt: Date | null; -} +}; // Split array into chunks of specified size function chunk(arr: T[], size: number): T[][] { @@ -45,7 +39,7 @@ async function verifyCondition({ condition: WalletCondition; walletAddress: string; chain: Chain; -}): Promise { +}): Promise { switch (condition.type) { case "token_balance_lt": case "token_balance_gt": { @@ -67,12 +61,7 @@ async function verifyCondition({ ? currentBalance < threshold : currentBalance > threshold; - return isConditionMet ? currentBalance.toString() : undefined; - } - default: { - // For TypeScript exhaustiveness check - const _exhaustiveCheck: never = condition; - return undefined; + return isConditionMet ? currentBalance.toString() : null; } } } @@ -112,7 +101,7 @@ async function processSubscriptions( } } catch (error) { // Log error but continue processing other subscriptions - const message = error instanceof Error ? error.message : String(error); + const message = prettifyError(error); logger({ service: "worker", level: "error", @@ -127,7 +116,8 @@ async function processSubscriptions( // Must be explicitly called for the worker to run on this host. export const initWalletSubscriptionWorker = async () => { const config = await getConfig(); - const cronPattern = config.walletSubscriptionsCronSchedule || "*/30 * * * * *"; // Default to every 30 seconds + const cronPattern = + config.walletSubscriptionsCronSchedule || "*/30 * * * * *"; // Default to every 30 seconds logger({ service: "worker", @@ -152,10 +142,7 @@ export const initWalletSubscriptionWorker = async () => { */ const handler: Processor = async (_job: Job) => { // Get all active wallet subscriptions - const subscriptions = await getAllWalletSubscriptions({ - page: 1, - limit: 1000, // Process 1000 subscriptions at a time - }); + const subscriptions = await getAllWalletSubscriptions(); if (subscriptions.length === 0) { return; }