diff --git a/packages/intent-aggregator/drizzle.config.ts b/packages/intent-aggregator/drizzle.config.ts index 54163b0..d65d6b8 100644 --- a/packages/intent-aggregator/drizzle.config.ts +++ b/packages/intent-aggregator/drizzle.config.ts @@ -26,9 +26,9 @@ const getCredentials = () => { const prod = { driver: "d1-http", dbCredentials: { - databaseId: process.env.CLOUDFLARE_D1_ID, + databaseId: process.env.CLOUDFLARE_DATABASE_ID, accountId: process.env.CLOUDFLARE_ACCOUNT_ID, - token: process.env.CLOUDFLARE_API_TOKEN, + token: process.env.CLOUDFLARE_D1_TOKEN, }, }; @@ -43,8 +43,8 @@ const getCredentials = () => { export default defineConfig({ dialect: "sqlite", - schema: "./db/schema/index.ts", - out: "./db/migrations", + schema: "./src/db/schema/index.ts", + out: "./migrations", tablesFilter: ["/^(?!.*_cf_KV).*$/"], ...getCredentials(), }) satisfies Config; diff --git a/packages/intent-aggregator/migrations/0001_dazzling_deathstrike.sql b/packages/intent-aggregator/migrations/0001_dazzling_deathstrike.sql new file mode 100644 index 0000000..724828e --- /dev/null +++ b/packages/intent-aggregator/migrations/0001_dazzling_deathstrike.sql @@ -0,0 +1,2 @@ +ALTER TABLE `solutions` ADD `settlement_tx_hash` text;--> statement-breakpoint +ALTER TABLE `solutions` ADD `resolution_tx_hash` text; \ No newline at end of file diff --git a/packages/intent-aggregator/migrations/meta/0001_snapshot.json b/packages/intent-aggregator/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..5e11de4 --- /dev/null +++ b/packages/intent-aggregator/migrations/meta/0001_snapshot.json @@ -0,0 +1,246 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f3fbab36-02e8-413c-8991-deca90e9a2c0", + "prevId": "39865177-9e6c-482b-935b-9e4fd1bdc31f", + "tables": { + "intents": { + "name": "intents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payment_token": { + "name": "payment_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payment_token_amount": { + "name": "payment_token_amount", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rail_type": { + "name": "rail_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_address": { + "name": "recipient_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rail_amount": { + "name": "rail_amount", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_address": { + "name": "creator_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'CREATED'" + }, + "winning_solution_id": { + "name": "winning_solution_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolution_tx_hash": { + "name": "resolution_tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "creator_idx": { + "name": "creator_idx", + "columns": [ + "creator_address" + ], + "isUnique": false + }, + "state_idx": { + "name": "state_idx", + "columns": [ + "state" + ], + "isUnique": false + }, + "rail_idx": { + "name": "rail_idx", + "columns": [ + "rail_type" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "solutions": { + "name": "solutions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "intent_id": { + "name": "intent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "solver_address": { + "name": "solver_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_wei": { + "name": "amount_wei", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "commitment_tx_hash": { + "name": "commitment_tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settlement_tx_hash": { + "name": "settlement_tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolution_tx_hash": { + "name": "resolution_tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_metadata": { + "name": "payment_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "intent_solutions_idx": { + "name": "intent_solutions_idx", + "columns": [ + "intent_id" + ], + "isUnique": false + }, + "solver_idx": { + "name": "solver_idx", + "columns": [ + "solver_address" + ], + "isUnique": false + } + }, + "foreignKeys": { + "solutions_intent_id_intents_id_fk": { + "name": "solutions_intent_id_intents_id_fk", + "tableFrom": "solutions", + "tableTo": "intents", + "columnsFrom": [ + "intent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/intent-aggregator/migrations/meta/_journal.json b/packages/intent-aggregator/migrations/meta/_journal.json index e6142ae..50db088 100644 --- a/packages/intent-aggregator/migrations/meta/_journal.json +++ b/packages/intent-aggregator/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1733522405248, "tag": "0000_small_paibok", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1733551783982, + "tag": "0001_dazzling_deathstrike", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/intent-aggregator/package.json b/packages/intent-aggregator/package.json index d439cba..01939c0 100644 --- a/packages/intent-aggregator/package.json +++ b/packages/intent-aggregator/package.json @@ -3,11 +3,12 @@ "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy --minify", - "db:generate": "drizzle-kit generate", + "db:generate": "NODE_ENV=production drizzle-kit generate", "db:up": "drizzle-kit up", "db:up:prod": "NODE_ENV=production drizzle-kit up", "db:push": "drizzle-kit push", - "db:push:prod": "NODE_ENV=production drizzle-kit push" + "db:push:prod": "NODE_ENV=production drizzle-kit push", + "db:studio:prod": "NODE_ENV=production drizzle-kit studio" }, "dependencies": { "@hono/zod-openapi": "^0.18.3", diff --git a/packages/intent-aggregator/src/db/schema/index.ts b/packages/intent-aggregator/src/db/schema/index.ts index 364def6..42d10ce 100644 --- a/packages/intent-aggregator/src/db/schema/index.ts +++ b/packages/intent-aggregator/src/db/schema/index.ts @@ -1,34 +1,34 @@ -import { sql } from "drizzle-orm"; +import { relations, sql } from "drizzle-orm"; +import { z } from "@hono/zod-openapi"; + import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; -// Enums & Constants -export const PaymentRailType = { - UPI: "UPI", - BITCOIN: "BITCOIN", -} as const; +export const railTypeSchema = z.enum(["UPI", "BITCOIN"]); + +// CREATED -> SOLUTION_COMMITTED -> PAYMENT_CLAIMED -> SETTLED (Happy path) +// \-> RESOLVED (Dispute path) -export type PaymentRailType = - (typeof PaymentRailType)[keyof typeof PaymentRailType]; +export const intentStateSchema = z.enum([ + "CREATED", + "SOLUTION_COMMITTED", + "PAYMENT_CLAIMED", + "RESOLVED", + "SETTLED", +]); + +export type PaymentRailType = z.infer; +export type IntentStateType = z.infer; export const RAIL_CURRENCY = { - [PaymentRailType.UPI]: "INR", - [PaymentRailType.BITCOIN]: "BTC", + UPI: "INR", + BITCOIN: "BTC", } as const; export const RAIL_SMALLEST_UNIT = { - [PaymentRailType.UPI]: "paise", - [PaymentRailType.BITCOIN]: "sats", -} as const; - -export const IntentState = { - CREATED: "CREATED", - SOLUTION_COMMITTED: "SOLUTION_COMMITTED", - PAYMENT_CLAIMED: "PAYMENT_CLAIMED", - RESOLVED: "RESOLVED", + UPI: "paise", + BITCOIN: "sats", } as const; -export type IntentStateType = (typeof IntentState)[keyof typeof IntentState]; - // Tables export const intents = sqliteTable( "intents", @@ -49,10 +49,7 @@ export const intents = sqliteTable( .notNull() .default(sql`CURRENT_TIMESTAMP`), - state: text("state") - .notNull() - .$type() - .default(IntentState.CREATED), + state: text("state").notNull().$type().default("CREATED"), winningSolutionId: text("winning_solution_id"), resolutionTxHash: text("resolution_tx_hash"), }, @@ -81,6 +78,13 @@ export const solutions = sqliteTable( .default(sql`CURRENT_TIMESTAMP`), commitmentTxHash: text("commitment_tx_hash"), + + // Settlement is the optimistic path + settlementTxHash: text("settlement_tx_hash"), + + // Resolution is the dispute path + resolutionTxHash: text("resolution_tx_hash"), + paymentMetadata: text("payment_metadata"), }, (table) => ({ @@ -89,32 +93,9 @@ export const solutions = sqliteTable( }) ); -// Types for payment metadata -export type PaymentMetadata = { - transactionId: string; - timestamp: string; // ISO string - railSpecificData?: Record; // Flexible storage for rail-specific proof -}; - -// Helper functions -export function parsePaymentMetadata(json: string): PaymentMetadata { - const data = JSON.parse(json); - if (!data.transactionId || !data.timestamp) { - throw new Error("Invalid payment metadata format"); - } - return data as PaymentMetadata; -} - -export function formatPaymentMetadata(metadata: PaymentMetadata): string { - return JSON.stringify(metadata); -} - -export function validateWeiAmount(amount: string): boolean { - try { - if (amount.includes(".") || amount.includes("-")) return false; - BigInt(amount); - return true; - } catch { - return false; - } -} +export const solutionsRelations = relations(solutions, ({ one }) => ({ + intent: one(intents, { + fields: [solutions.intentId], + references: [intents.id], + }), +})); diff --git a/packages/intent-aggregator/src/db/utils.ts b/packages/intent-aggregator/src/db/utils.ts new file mode 100644 index 0000000..90fa95f --- /dev/null +++ b/packages/intent-aggregator/src/db/utils.ts @@ -0,0 +1,29 @@ +// Types for payment metadata +export type PaymentMetadata = { + transactionId: string; + timestamp: string; // ISO string + railSpecificData?: Record; // Flexible storage for rail-specific proof +}; + +// Helper functions +export function parsePaymentMetadata(json: string): PaymentMetadata { + const data = JSON.parse(json); + if (!data.transactionId || !data.timestamp) { + throw new Error("Invalid payment metadata format"); + } + return data as PaymentMetadata; +} + +export function formatPaymentMetadata(metadata: PaymentMetadata): string { + return JSON.stringify(metadata); +} + +export function validateWeiAmount(amount: string): boolean { + try { + if (amount.includes(".") || amount.includes("-")) return false; + BigInt(amount); + return true; + } catch { + return false; + } +} diff --git a/packages/intent-aggregator/src/index.ts b/packages/intent-aggregator/src/index.ts index c7ee47c..f6969e1 100644 --- a/packages/intent-aggregator/src/index.ts +++ b/packages/intent-aggregator/src/index.ts @@ -9,7 +9,7 @@ import * as schemas from "./schemas"; import { openApiConfiguration } from "./openapi"; import { Env } from "./types"; import { dbMiddleware } from "./db/middleware"; -import { intents, IntentState, solutions } from "./db/schema"; +import { intents, solutions } from "./db/schema"; import { createSelectSchema } from "drizzle-zod"; import { apiReference } from "@scalar/hono-api-reference"; import { eq } from "drizzle-orm"; @@ -43,16 +43,16 @@ const createIntent = createRoute({ }); api.openapi(createIntent, async (c) => { - const db = drizzle(c.env.DB); const body = c.req.valid("json"); - const intent = { - id: createId(), - ...body, - state: IntentState.CREATED, - }; - - await db.insert(intents).values(intent); + const [intent] = await c.var.db + .insert(intents) + .values({ + id: createId(), + ...body, + state: "CREATED", + }) + .returning(); return c.json({ intentId: intent.id }); }); @@ -259,7 +259,7 @@ api.openapi(acceptSolution, async (c) => { await c.var.db .update(intents) .set({ - state: IntentState.SOLUTION_COMMITTED, + state: "SOLUTION_COMMITTED", winningSolutionId: solution.id, }) .where(eq(intents.id, solution.intentId)); @@ -311,22 +311,200 @@ api.openapi(claimPayment, async (c) => { const body = c.req.valid("json"); - await c.var.db.transaction(async (tx) => { - // Update solution with payment metadata - await tx - .update(solutions) - .set({ - paymentMetadata: JSON.stringify(body.paymentMetadata), - }) - .where(eq(solutions.id, solution.id)); - - // Update intent state - await tx - .update(intents) - .set({ state: IntentState.PAYMENT_CLAIMED }) - .where(eq(intents.id, solution.intentId)); + // Update solution with payment metadata + await c.var.db + .update(solutions) + .set({ + paymentMetadata: JSON.stringify(body.paymentMetadata), + }) + .where(eq(solutions.id, solution.id)); + + // Update intent state + await c.var.db + .update(intents) + .set({ state: "PAYMENT_CLAIMED" }) + .where(eq(intents.id, solution.intentId)); + + return new Response(null, { status: 204 }); +}); + +const settleSolution = createRoute({ + method: "post", + path: "/api/solutions/:id/settle", + request: { + body: { + content: { + "application/json": { + schema: z.object({ + settlementTxHash: z + .string() + .regex(/^0x[a-fA-F0-9]{64}$/, "Invalid transaction hash format"), + }), + }, + }, + }, + }, + responses: { + 204: { + description: "Solution settled successfully", + }, + 404: { + content: { + "application/json": { + schema: z.object({ + error: z.object({ + code: z.literal("NOT_FOUND"), + message: z.string(), + }), + }), + }, + }, + description: "Solution not found", + }, + 400: { + content: { + "application/json": { + schema: z.object({ + error: z.object({ + code: z.literal("INVALID_STATE"), + message: z.string(), + }), + }), + }, + }, + description: "Invalid solution state for settlement", + }, + }, +}); + +api.openapi(settleSolution, async (c) => { + const solution = await c.var.db.query.solutions.findFirst({ + where: (solutions, { eq }) => eq(solutions.id, c.req.param("id")), + with: { + intent: true, + }, }); + if (!solution) { + return c.json(SOLUTION_NOT_FOUND_ERROR, 404); + } + + if (solution.intent.state !== "PAYMENT_CLAIMED") { + return c.json( + { + error: { + code: "INVALID_STATE", + message: "Solution must be in PAYMENT_CLAIMED state to settle", + }, + }, + 400 + ); + } + + const body = c.req.valid("json"); + + // Update solution with settlement tx + await c.var.db + .update(solutions) + .set({ + settlementTxHash: body.settlementTxHash, + }) + .where(eq(solutions.id, solution.id)); + + // Update intent state to reflect settlement + await c.var.db + .update(intents) + .set({ state: "SETTLED" }) + .where(eq(intents.id, solution.intentId)); + + return new Response(null, { status: 204 }); +}); + +const resolveSolution = createRoute({ + method: "post", + path: "/api/solutions/:id/resolve", + request: { + body: { + content: { + "application/json": { + schema: schemas.ResolveSolutionSchema, + }, + }, + }, + }, + responses: { + 204: { + description: "Solution resolved successfully through dispute resolution", + }, + 404: { + content: { + "application/json": { + schema: z.object({ + error: z.object({ + code: z.literal("NOT_FOUND"), + message: z.string(), + }), + }), + }, + }, + description: "Solution not found", + }, + 400: { + content: { + "application/json": { + schema: z.object({ + error: z.object({ + code: z.literal("INVALID_STATE"), + message: z.string(), + }), + }), + }, + }, + description: "Invalid solution state for resolution", + }, + }, +}); + +api.openapi(resolveSolution, async (c) => { + const solution = await c.var.db.query.solutions.findFirst({ + where: (solutions, { eq }) => eq(solutions.id, c.req.param("id")), + with: { + intent: true, + }, + }); + + if (!solution) { + return c.json(SOLUTION_NOT_FOUND_ERROR, 404); + } + + if (solution.intent.state !== "PAYMENT_CLAIMED") { + return c.json( + { + error: { + code: "INVALID_STATE", + message: + "Solution must be in PAYMENT_CLAIMED state to resolve via dispute", + }, + }, + 400 + ); + } + + const body = c.req.valid("json"); + + // Sequential updates - no transaction + await c.var.db + .update(solutions) + .set({ + resolutionTxHash: body.resolutionTxHash, + }) + .where(eq(solutions.id, solution.id)); + + await c.var.db + .update(intents) + .set({ state: "RESOLVED" }) + .where(eq(intents.id, solution.intentId)); + return new Response(null, { status: 204 }); }); diff --git a/packages/intent-aggregator/src/schemas.ts b/packages/intent-aggregator/src/schemas.ts index 5fe081b..f5cb0fa 100644 --- a/packages/intent-aggregator/src/schemas.ts +++ b/packages/intent-aggregator/src/schemas.ts @@ -1,6 +1,7 @@ // schemas.ts import { z } from "@hono/zod-openapi"; import { getAddress } from "thirdweb/utils"; +import { railTypeSchema } from "./db/schema"; // Custom Ethereum address refinement const addressSchema = z.string().transform((address, ctx) => { @@ -21,7 +22,7 @@ export type Address = z.infer; // Common schemas const tokenAmountSchema = z.string().regex(/^\d+$/); // Only positive integers as strings const chainIdSchema = z.number().int().positive(); -const railTypeSchema = z.enum(["UPI", "BITCOIN"]); + // Request schemas export const CreateIntentSchema = z.object({ @@ -44,13 +45,21 @@ export const AcceptSolutionSchema = z.object({ commitmentTxHash: z.string(), }); -export const ClaimPaymentSchema = z.object({ - paymentMetadata: z.object({ - transactionId: z.string(), - timestamp: z.string(), - railSpecificData: z.record(z.unknown()).optional(), - }), -}); +export const ClaimPaymentSchema = z + .object({ + paymentMetadata: z.object({ + transactionId: z.string(), + timestamp: z.coerce.date(), // Will accept ISO string and coerce to Date + railSpecificData: z.record(z.unknown()).optional(), + }), + }) + .transform((data) => ({ + paymentMetadata: { + ...data.paymentMetadata, + // Transform back to ISO string for storage + timestamp: data.paymentMetadata.timestamp.toISOString(), + }, + })); export const ResolveSolutionSchema = z.object({ resolutionTxHash: z.string(), diff --git a/packages/intent-aggregator/wrangler.toml b/packages/intent-aggregator/wrangler.toml index e29a8dd..2080c00 100644 --- a/packages/intent-aggregator/wrangler.toml +++ b/packages/intent-aggregator/wrangler.toml @@ -8,6 +8,9 @@ database_name = "zkrail-intent-aggregator-db" database_id = "8a0c78d4-f343-4fbc-be93-1f81523dfeff" migrations_dir = "migrations" +[observability.logs] +enabled = true + # compatibility_flags = [ "nodejs_compat" ] # [vars]