diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d1180dd4..84c272ea 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -6,7 +6,7 @@ generator client { datasource db { provider = "postgresql" url = env("DATABASE_URL") - schemas = ["public", "smart_account"] + schemas = ["public", "smart_account", "treasure_account"] } // Partner within the Treasure ecosystem @@ -255,16 +255,25 @@ model TransactionErrorLog { view Transaction { id String @id + chain String chainId Int @map("chain_id") blockNumber Decimal @map("block_number") @db.Decimal blockTimestamp DateTime @map("block_timestamp") transactionHash String @map("transaction_hash") fromAddress String @map("from_address") toAddress String @map("to_address") - value Decimal? @db.Decimal + + value Decimal? @db.Decimal + transactionIndex Decimal? @map("transaction_index") @db.Decimal + receiptEffectiveGasPrice Decimal? @map("receipt_effective_gas_price") @db.Decimal + receiptStatus Decimal? @map("receipt_status") @db.Decimal + receiptGasUsed Decimal? @map("receipt_gas_used") @db.Decimal + gasUsed Decimal? @map("gas_used") @db.Decimal + receiptCumulativeGasUsed Decimal? @map("receipt_cumulative_gas_used") @db.Decimal + gas Decimal? @db.Decimal @@map("transactions") - @@schema("smart_account") + @@schema("treasure_account") } // Ignored models and views added here for diff parity @@ -285,3 +294,21 @@ model RawTreasureAccount { @@ignore @@schema("smart_account") } + +model RawTransactionTreasureAccount { + key String @id @map("_key") + body Json + + @@map("transactions_jsonb") + @@ignore + @@schema("treasure_account") +} + +model RawTreasureAccountTreasureAccount { + key String @id @map("_key") + body Json + + @@map("treasure_accounts_jsonb") + @@ignore + @@schema("treasure_account") +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 467cbba3..19ade550 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -25,6 +25,7 @@ import { withSwagger } from "./middleware/swagger"; import { authRoutes } from "./routes/auth"; import { harvestersRoutes } from "./routes/harvesters"; import { magicswapRoutes } from "./routes/magicswap"; +import { partnersRoutes } from "./routes/partners"; import { projectsRoutes } from "./routes/projects"; import { transactionsRoutes } from "./routes/transactions"; import { usersRoutes } from "./routes/users"; @@ -125,6 +126,7 @@ const main = async () => { app.register(transactionsRoutes(ctx)); app.register(harvestersRoutes(ctx)); app.register(magicswapRoutes(ctx)); + app.register(partnersRoutes(ctx)); app.get("/healthcheck", async (_, reply) => { try { diff --git a/apps/api/src/routes/partners.ts b/apps/api/src/routes/partners.ts new file mode 100644 index 00000000..4c18993c --- /dev/null +++ b/apps/api/src/routes/partners.ts @@ -0,0 +1,87 @@ +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"; +import { validateSegmentationRules } from "../utils/segmentation"; + +export const partnersRoutes = + ({ db }: TdkApiContext): FastifyPluginAsync => + async (app) => { + app.post<{ + Params: ValidateParams; + Body: ValidateBody; + Reply: ValidateReply | ErrorReply; + }>( + "/partners/:partnerId/gas-sponsorship", + { + schema: { + body: validateBodySchema, + response: { + 200: validateReplySchema, + }, + }, + }, + async (req, reply) => { + const { body, params } = req; + + const oneMonthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + + const lastMonthTransactions = await db.transaction.count({ + where: { + fromAddress: body.userOp.sender, + blockTimestamp: { + gte: oneMonthAgo, + }, + }, + }); + + const { + _sum: { gas: lastMonthGasRaw }, + } = await db.transaction.aggregate({ + where: { + fromAddress: body.userOp.sender, + blockTimestamp: { + gte: oneMonthAgo, + }, + }, + _sum: { + gas: true, + }, + }); + + const lastMonthGas = + lastMonthGasRaw?.toNumber() ?? Number.MAX_SAFE_INTEGER; + + const { isAllowed, reason } = validateSegmentationRules( + { + partner: { + ids: ["zeeverse", "smols"], + }, + user: { + maxTransactions: 100, + maxGas: 1000, + }, + }, + { + partner: { id: params.partnerId }, + user: { + transactionsCount: lastMonthTransactions, + gas: lastMonthGas, + }, + }, + ); + + 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; diff --git a/apps/api/src/utils/segmentation.ts b/apps/api/src/utils/segmentation.ts new file mode 100644 index 00000000..dc611220 --- /dev/null +++ b/apps/api/src/utils/segmentation.ts @@ -0,0 +1,63 @@ +type Segment = { + partner: { + ids: string[]; + }; + user: { + maxTransactions: number; + maxGas: number; + }; +}; + +type SegmentData = { + partner: { + id: string; + }; + user: { + transactionsCount: number; + gas: number; + }; +}; + +export const validateSegmentationRules = ( + segment: Segment, + data: SegmentData, +) => { + const { + partner: { id }, + user: { transactionsCount, gas }, + } = data; + + const isValidPartner = segment.partner.ids.includes(id); + const hasNotExceededTransactions = + transactionsCount <= segment.user.maxTransactions; + const hasNotExceededGas = gas <= segment.user.maxGas; + + const rules = [ + { + condition: isValidPartner, + reason: "partner is valid", + }, + { + condition: hasNotExceededTransactions, + reason: "not exceeded the maximum number of transactions", + }, + { + condition: hasNotExceededGas, + reason: "not exceeded the maximum gas", + }, + ]; + + const approvedRule = rules.find((rule) => rule.condition); + + if (approvedRule) { + return { + isAllowed: true, + reason: approvedRule.reason, + }; + } + + return { + isAllowed: false, + reason: "does not meet any rule", + }; +};