diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 467cbba3..add2bc55 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -23,6 +23,7 @@ import { withCors } from "./middleware/cors"; import { withErrorHandler } from "./middleware/error"; import { withSwagger } from "./middleware/swagger"; import { authRoutes } from "./routes/auth"; +import { gasSponsorshipRoutes } from "./routes/gas-sponsorship"; import { harvestersRoutes } from "./routes/harvesters"; import { magicswapRoutes } from "./routes/magicswap"; import { projectsRoutes } from "./routes/projects"; @@ -125,6 +126,7 @@ const main = async () => { app.register(transactionsRoutes(ctx)); app.register(harvestersRoutes(ctx)); app.register(magicswapRoutes(ctx)); + app.register(gasSponsorshipRoutes(ctx)); app.get("/healthcheck", async (_, reply) => { try { diff --git a/apps/api/src/routes/gas-sponsorship.ts b/apps/api/src/routes/gas-sponsorship.ts new file mode 100644 index 00000000..c2f84fd2 --- /dev/null +++ b/apps/api/src/routes/gas-sponsorship.ts @@ -0,0 +1,74 @@ +import type { FastifyPluginAsync } from "fastify"; + +import type { ErrorReply } from "../schema"; +import { + type ValidateBody, + type ValidateParams, + type ValidateReply, + validateBodySchema, + validateReplySchema, +} from "../schema/gas-sponsorship"; +import type { TdkApiContext } from "../types"; + +// TODO: Replace with actual sponsored partner IDs or logic to fetch them from another service like TMC +const fullySponsoredPatnerIds = new Set(["zeeverse", "smols"]); + +export const gasSponsorshipRoutes = + ({ db }: TdkApiContext): FastifyPluginAsync => + async (app) => { + app.post<{ + Params: ValidateParams; + Body: ValidateBody; + Reply: ValidateReply | ErrorReply; + }>( + "/gas-sponsorship/:partnerId/validate", + { + schema: { + body: validateBodySchema, + response: { + 200: validateReplySchema, + }, + }, + }, + async (req, reply) => { + const { body, params } = req; + + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); + + const last24hTransactions = await db.transaction.count({ + where: { + fromAddress: body.userOp.sender, + blockTimestamp: { + gte: yesterday, + }, + }, + }); + + const [isPartnerFullySponsored, hasLessThan10Transactions] = [ + fullySponsoredPatnerIds.has(params.partnerId), + last24hTransactions < 10, + ]; + + let reason = "does not meet the criteria"; + + switch (true) { + case isPartnerFullySponsored: + reason = "partner is fully sponsored"; + break; + case hasLessThan10Transactions: + reason = "less than 10 transactions in the last 24 hours"; + break; + + default: + break; + } + + const isAllowed = isPartnerFullySponsored || hasLessThan10Transactions; + + reply.send({ + isAllowed, + reason, + }); + }, + ); + }; diff --git a/apps/api/src/schema/gas-sponsorship.ts b/apps/api/src/schema/gas-sponsorship.ts new file mode 100644 index 00000000..43f292aa --- /dev/null +++ b/apps/api/src/schema/gas-sponsorship.ts @@ -0,0 +1,30 @@ +import { type Static, Type } from "@sinclair/typebox"; + +export const validateBodySchema = Type.Object({ + clientId: Type.String(), + chainId: Type.Number(), + userOp: Type.Object({ + sender: Type.String(), + targets: Type.Array(Type.String()), + gasLimit: Type.String(), + gasPrice: Type.String(), + data: Type.Object({ + targets: Type.Array(Type.String()), + callDatas: Type.Array(Type.String()), + values: Type.Array(Type.String()), + }), + }), +}); + +export const validateReplySchema = Type.Object({ + isAllowed: Type.Boolean(), + reason: Type.Optional(Type.String()), +}); + +const validateParamsSchema = Type.Object({ + partnerId: Type.String(), +}); + +export type ValidateParams = Static; +export type ValidateReply = Static; +export type ValidateBody = Static;