From 26a3aecde6bfcccba47bf27d4626152e312e09db Mon Sep 17 00:00:00 2001 From: tony Date: Mon, 2 Feb 2026 12:55:03 +0700 Subject: [PATCH 01/35] update docker --- docker-compose.yml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 199df26..3de5d5a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,14 +5,11 @@ services: build: context: . dockerfile: Dockerfile + network_mode: host environment: NODE_ENV: production NEXT_PUBLIC_NETWORK_ENV: TESTNET_PREVIEW + PORT: 3001 + expose: + - "3001" restart: unless-stopped - networks: - - dev-network - -networks: - dev-network: - name: dev-network - driver: bridge From b167fd11e303b5499c830f9119fc0d4d7dcc40b7 Mon Sep 17 00:00:00 2001 From: tony Date: Mon, 2 Feb 2026 12:57:35 +0700 Subject: [PATCH 02/35] update port --- Dockerfile | 2 +- docker-compose.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index fb2d662..286ec11 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,7 +40,7 @@ COPY --from=builder /app/apps/web ./apps/web RUN pnpm install --prod --frozen-lockfile # Expose port -EXPOSE 3000 +EXPOSE 3002 # Set environment to production ENV NODE_ENV=production diff --git a/docker-compose.yml b/docker-compose.yml index 3de5d5a..c7f9f1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,7 @@ services: environment: NODE_ENV: production NEXT_PUBLIC_NETWORK_ENV: TESTNET_PREVIEW - PORT: 3001 + PORT: 3002 expose: - - "3001" + - "3002" restart: unless-stopped From 075a6f5c2aa915c386977804a90b964c6257a864 Mon Sep 17 00:00:00 2001 From: tony Date: Mon, 2 Feb 2026 12:57:47 +0700 Subject: [PATCH 03/35] update port --- apps/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index c9e2d60..4043ced 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,7 +4,7 @@ "type": "module", "private": true, "scripts": { - "dev": "next dev --turbopack --port 3001", + "dev": "next dev --turbopack --port 3002", "build": "next build", "start": "next start", "lint": "next lint --max-warnings 0", From 1c57523131611aa4642474deffdaa92313255cf4 Mon Sep 17 00:00:00 2001 From: tony Date: Tue, 3 Feb 2026 16:14:46 +0700 Subject: [PATCH 04/35] init long-short server backend --- .claude/SKILL.MD | 139 ++ Dockerfile.backend | 50 + Dockerfile => Dockerfile.interface | 0 .../.config/kysely.config.ts | 29 + .../migrations/1770095322614_position.ts | 125 ++ .../migrations/1770098176165_market_config.ts | 30 + .../.config/seeds/market_config.ts | 34 + apps/long-short-backend/SPEC.md | 590 ++++++++ apps/long-short-backend/example/sign-data.ts | 50 + apps/long-short-backend/package.json | 58 + apps/long-short-backend/src/api/helper.ts | 37 + apps/long-short-backend/src/api/index.ts | 2 + .../src/api/routes/metadata.ts | 46 + .../src/api/routes/position.ts | 105 ++ apps/long-short-backend/src/api/schemas.ts | 104 ++ apps/long-short-backend/src/api/server.ts | 54 + apps/long-short-backend/src/config/index.ts | 1 + apps/long-short-backend/src/config/market.ts | 91 ++ apps/long-short-backend/src/constants.ts | 6 + apps/long-short-backend/src/database/db.d.ts | 84 + apps/long-short-backend/src/database/index.ts | 3 + .../src/database/postgres.ts | 77 + apps/long-short-backend/src/database/redis.ts | 16 + apps/long-short-backend/src/healthchecker.ts | 24 + apps/long-short-backend/src/index.ts | 50 + apps/long-short-backend/src/provider/index.ts | 1 + apps/long-short-backend/src/provider/kupo.ts | 352 +++++ .../src/repository/index.ts | 3 + .../src/repository/position-repository.ts | 199 +++ .../src/repository/redis-repo.ts | 61 + .../src/repository/repository.ts | 6 + apps/long-short-backend/src/services/index.ts | 1 + .../src/services/position-service.ts | 106 ++ apps/long-short-backend/src/utils/common.ts | 60 + .../src/utils/expiring-variable.ts | 22 + apps/long-short-backend/src/utils/index.ts | 5 + apps/long-short-backend/src/utils/lodash.ts | 36 + apps/long-short-backend/src/utils/logger.ts | 139 ++ .../long-short-backend/src/utils/signature.ts | 127 ++ .../long-short-backend/test/sign-data.test.ts | 200 +++ apps/long-short-backend/tsconfig.json | 28 + apps/long-short-backend/vitest.config.mts | 22 + docker-compose.yml | 96 +- pnpm-lock.yaml | 1348 ++++++++++++++++- 44 files changed, 4528 insertions(+), 89 deletions(-) create mode 100644 .claude/SKILL.MD create mode 100644 Dockerfile.backend rename Dockerfile => Dockerfile.interface (100%) create mode 100644 apps/long-short-backend/.config/kysely.config.ts create mode 100644 apps/long-short-backend/.config/migrations/1770095322614_position.ts create mode 100644 apps/long-short-backend/.config/migrations/1770098176165_market_config.ts create mode 100644 apps/long-short-backend/.config/seeds/market_config.ts create mode 100644 apps/long-short-backend/SPEC.md create mode 100644 apps/long-short-backend/example/sign-data.ts create mode 100644 apps/long-short-backend/package.json create mode 100644 apps/long-short-backend/src/api/helper.ts create mode 100644 apps/long-short-backend/src/api/index.ts create mode 100644 apps/long-short-backend/src/api/routes/metadata.ts create mode 100644 apps/long-short-backend/src/api/routes/position.ts create mode 100644 apps/long-short-backend/src/api/schemas.ts create mode 100644 apps/long-short-backend/src/api/server.ts create mode 100644 apps/long-short-backend/src/config/index.ts create mode 100644 apps/long-short-backend/src/config/market.ts create mode 100644 apps/long-short-backend/src/constants.ts create mode 100644 apps/long-short-backend/src/database/db.d.ts create mode 100644 apps/long-short-backend/src/database/index.ts create mode 100644 apps/long-short-backend/src/database/postgres.ts create mode 100644 apps/long-short-backend/src/database/redis.ts create mode 100644 apps/long-short-backend/src/healthchecker.ts create mode 100644 apps/long-short-backend/src/index.ts create mode 100644 apps/long-short-backend/src/provider/index.ts create mode 100644 apps/long-short-backend/src/provider/kupo.ts create mode 100644 apps/long-short-backend/src/repository/index.ts create mode 100644 apps/long-short-backend/src/repository/position-repository.ts create mode 100644 apps/long-short-backend/src/repository/redis-repo.ts create mode 100644 apps/long-short-backend/src/repository/repository.ts create mode 100644 apps/long-short-backend/src/services/index.ts create mode 100644 apps/long-short-backend/src/services/position-service.ts create mode 100644 apps/long-short-backend/src/utils/common.ts create mode 100644 apps/long-short-backend/src/utils/expiring-variable.ts create mode 100644 apps/long-short-backend/src/utils/index.ts create mode 100644 apps/long-short-backend/src/utils/lodash.ts create mode 100644 apps/long-short-backend/src/utils/logger.ts create mode 100644 apps/long-short-backend/src/utils/signature.ts create mode 100644 apps/long-short-backend/test/sign-data.test.ts create mode 100644 apps/long-short-backend/tsconfig.json create mode 100644 apps/long-short-backend/vitest.config.mts diff --git a/.claude/SKILL.MD b/.claude/SKILL.MD new file mode 100644 index 0000000..4b03a79 --- /dev/null +++ b/.claude/SKILL.MD @@ -0,0 +1,139 @@ +# Long-Short Backend Skills + +## Database Operations + +### Run Migrations +```bash +cd apps/long-short-backend +DATABASE_URL=postgres://postgres:JBNGlQ9wNFLlYWc2mG@localhost:5432/margin pnpm run:migrate +``` + +### Create New Migration +```bash +cd apps/long-short-backend +DATABASE_URL=postgres://postgres:JBNGlQ9wNFLlYWc2mG@localhost:5432/margin pnpm kysely migrate:make +``` + +### Run Seeds +```bash +cd apps/long-short-backend +DATABASE_URL=postgres://postgres:JBNGlQ9wNFLlYWc2mG@localhost:5432/margin pnpm run:seed +``` + +### Regenerate Database Types +```bash +cd apps/long-short-backend +DATABASE_URL=postgres://postgres:JBNGlQ9wNFLlYWc2mG@localhost:5432/margin pnpm codegen +``` + +## Development + +### Start Dev Server +```bash +cd apps/long-short-backend +DATABASE_URL=postgres://postgres:JBNGlQ9wNFLlYWc2mG@localhost:5432/margin pnpm dev +``` + +### Build +```bash +cd apps/long-short-backend +pnpm build +``` + +## Docker + +### Build Docker Image +```bash +docker compose build long-short-backend +``` + +### Start Services +```bash +docker compose up -d +``` + +### View Logs +```bash +docker compose logs -f long-short-backend +``` + +### Run Migration in Docker +```bash +docker compose exec long-short-backend pnpm --filter=long-short-backend run:migrate +``` + +### Run Seed in Docker +```bash +docker compose exec long-short-backend pnpm --filter=long-short-backend run:seed +``` + +## API Endpoints + +- `GET /health` - Health check +- `GET /metadata` - Get market configurations +- `POST /position/create` - Create a new position + +### Get Metadata Response +```json +{ + "success": true, + "data": { + "markets": [ + { + "market_id": "ADA-MIN", + "asset_a": "lovelace", + "asset_b": "29d222ce...", + "amm_lp_asset": "...", + "asset_a_q_token_ticker": "qADA", + "asset_a_q_token_raw": "...", + "asset_b_q_token_ticker": "qMIN", + "asset_b_q_token_raw": "...", + "collateral_market_id": "ADA", + "leverage": 2, + "min_collateral": "100000000" + } + ] + } +} +``` + +### Create Position Request +```json +{ + "data": { + "market": "ADA-MIN", + "side": "LONG" | "SHORT", + "amount": "100000000" + }, + "user_address": "addr1...", + "witness": { + "key": "", + "signature": "" + } +} +``` + +**Note:** The `witness` is the CIP-8 signed data of `JSON.stringify(data)`. The signature must be created by signing the hex-encoded JSON string of the `data` object. The `user_address` must match the address that signed the data. + +## Database Tables + +- `position` - User positions +- `order` - Trading orders +- `market_config` - Market configurations (loaded on startup) + +## Market Config Fields + +| Field | Type | Description | +|-------|------|-------------| +| market_id | string | Primary key (e.g., "ADA-MIN") | +| asset_a | string | First asset | +| asset_b | string | Second asset | +| amm_lp_asset | string | Minswap LP token | +| asset_a_q_token_ticker | string | Liqwid qToken ticker for asset A | +| asset_a_q_token_raw | string | Liqwid qToken raw asset for asset A | +| asset_b_q_token_ticker | string | Liqwid qToken ticker for asset B | +| asset_b_q_token_raw | string | Liqwid qToken raw asset for asset B | +| collateral_market_id | string | Liqwid market ID | +| leverage | number | Leverage multiplier | +| min_collateral | bigint | Minimum collateral in lovelace | +| enable | boolean | Market enabled | diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..769fbc6 --- /dev/null +++ b/Dockerfile.backend @@ -0,0 +1,50 @@ +# Stage 1: Base +FROM node:22-slim AS base +WORKDIR /usr/src/app + +# Install pnpm and curl (for healthcheck) +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* +RUN corepack enable && corepack prepare pnpm@9.0.0 --activate + +# Set pnpm global bin directory +ENV PNPM_HOME="/root/.local/share/pnpm" +ENV PATH="${PNPM_HOME}:${PATH}" + +# Stage 2: Prune / Extract package.json files +# This stage filters the repo to only the files needed for installation +FROM base AS cleaner +RUN pnpm add -g turbo +COPY . . +# Generate a pruned version of the repo +ARG APP_NAME=long-short-backend +RUN turbo prune ${APP_NAME} --docker + +# Stage 3: Install dependencies +FROM base AS installer +# Copy only the lockfile and the pruned package.json files from the cleaner stage +COPY --from=cleaner /usr/src/app/out/json/ . +COPY --from=cleaner /usr/src/app/out/pnpm-lock.yaml ./pnpm-lock.yaml + +# Install dependencies (heavily cached) +RUN pnpm install --frozen-lockfile + +# Stage 4: Build / Source +FROM base AS builder +COPY --from=installer /usr/src/app ./ +# Copy the actual source code (this is what changes often) +COPY --from=cleaner /usr/src/app/out/full/ . +COPY turbo.json turbo.json + +# Build the app +ARG APP_NAME=long-short-backend +RUN pnpm turbo build --filter=${APP_NAME} + +# Stage 5: Production release +FROM base AS release +COPY --from=builder /usr/src/app ./ + +# Expose API port +EXPOSE 9999 + +# Command is set in docker-compose.yml +CMD ["node", "--version"] diff --git a/Dockerfile b/Dockerfile.interface similarity index 100% rename from Dockerfile rename to Dockerfile.interface diff --git a/apps/long-short-backend/.config/kysely.config.ts b/apps/long-short-backend/.config/kysely.config.ts new file mode 100644 index 0000000..8bcd2cd --- /dev/null +++ b/apps/long-short-backend/.config/kysely.config.ts @@ -0,0 +1,29 @@ +import { PostgresAdapter, PostgresDriver, PostgresIntrospector, PostgresQueryCompiler } from "kysely"; +import { defineConfig } from "kysely-ctl"; +import { Pool } from "pg"; + +export default defineConfig({ + dialect: { + createAdapter() { + return new PostgresAdapter(); + }, + createDriver() { + const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + }); + return new PostgresDriver({ pool }); + }, + createIntrospector(db) { + return new PostgresIntrospector(db); + }, + createQueryCompiler() { + return new PostgresQueryCompiler(); + }, + }, + migrations: { + migrationFolder: "migrations", + }, + seeds: { + seedFolder: "seeds", + }, +}); diff --git a/apps/long-short-backend/.config/migrations/1770095322614_position.ts b/apps/long-short-backend/.config/migrations/1770095322614_position.ts new file mode 100644 index 0000000..51979fc --- /dev/null +++ b/apps/long-short-backend/.config/migrations/1770095322614_position.ts @@ -0,0 +1,125 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +export async function up(db: Kysely): Promise { + // Create position table + await db.schema + .createTable("position") + .addColumn("id", "bigserial", (col) => col.primaryKey()) + .addColumn("user_address", "varchar(128)", (col) => col.notNull()) + .addColumn("market", "varchar(128)", (col) => col.notNull()) + .addColumn("side", "varchar(8)", (col) => col.notNull()) // LONG, SHORT + .addColumn("status", "varchar(16)", (col) => col.notNull().defaultTo("OPEN")) // OPEN, CLOSED, LIQUIDATED + .addColumn("leverage", "numeric", (col) => col.notNull()) + .addColumn("collateral_asset", "varchar(128)", (col) => col.notNull()) + .addColumn("collateral_amount", "numeric", (col) => col.notNull()) + .addColumn("entry_price", "numeric", (col) => col.notNull()) + .addColumn("position_size", "numeric", (col) => col.notNull()) + .addColumn("borrowed_amount", "numeric", (col) => col.notNull()) + .addColumn("liquidation_price", "numeric", (col) => col.notNull()) + .addColumn("take_profit_price", "numeric") + .addColumn("stop_loss_price", "numeric") + .addColumn("realized_pnl", "numeric", (col) => col.notNull().defaultTo(0)) + .addColumn("unrealized_pnl", "numeric", (col) => col.notNull().defaultTo(0)) + .addColumn("funding_paid", "numeric", (col) => col.notNull().defaultTo(0)) + .addColumn("liqwid_supply_id", "varchar(128)") + .addColumn("liqwid_borrow_id", "varchar(128)") + .addColumn("created_at", "timestamp", (col) => col.notNull().defaultTo(sql`now()`)) + .addColumn("updated_at", "timestamp", (col) => col.notNull().defaultTo(sql`now()`)) + .addColumn("closed_at", "timestamp") + .execute(); + + // Create indexes for position table + await db.schema + .createIndex("idx_position_user_address") + .on("position") + .column("user_address") + .execute(); + + await db.schema + .createIndex("idx_position_market") + .on("position") + .column("market") + .execute(); + + await db.schema + .createIndex("idx_position_status") + .on("position") + .column("status") + .execute(); + + await db.schema + .createIndex("idx_position_user_status") + .on("position") + .columns(["user_address", "status"]) + .execute(); + + // Create order table + await db.schema + .createTable("order") + .addColumn("id", "bigserial", (col) => col.primaryKey()) + .addColumn("position_id", "bigint", (col) => col.references("position.id").onDelete("set null")) + .addColumn("user_address", "varchar(128)", (col) => col.notNull()) + .addColumn("market", "varchar(128)", (col) => col.notNull()) + .addColumn("order_type", "varchar(16)", (col) => col.notNull()) // MARKET, LIMIT, STOP_MARKET, STOP_LIMIT + .addColumn("side", "varchar(8)", (col) => col.notNull()) // LONG, SHORT + .addColumn("action", "varchar(16)", (col) => col.notNull()) // OPEN, CLOSE, INCREASE, DECREASE + .addColumn("status", "varchar(16)", (col) => col.notNull().defaultTo("PENDING")) // PENDING, FILLED, CANCELLED, EXPIRED + .addColumn("leverage", "numeric") + .addColumn("collateral_amount", "numeric") + .addColumn("size", "numeric", (col) => col.notNull()) + .addColumn("price", "numeric") // limit price + .addColumn("trigger_price", "numeric") // for stop orders + .addColumn("slippage_tolerance", "numeric") + .addColumn("tx_hash", "varchar(64)") + .addColumn("filled_price", "numeric") + .addColumn("filled_at", "timestamp") + .addColumn("expires_at", "timestamp") + .addColumn("created_at", "timestamp", (col) => col.notNull().defaultTo(sql`now()`)) + .execute(); + + // Create indexes for order table + await db.schema + .createIndex("idx_order_user_address") + .on("order") + .column("user_address") + .execute(); + + await db.schema + .createIndex("idx_order_position_id") + .on("order") + .column("position_id") + .execute(); + + await db.schema + .createIndex("idx_order_market") + .on("order") + .column("market") + .execute(); + + await db.schema + .createIndex("idx_order_status") + .on("order") + .column("status") + .execute(); + + await db.schema + .createIndex("idx_order_user_status") + .on("order") + .columns(["user_address", "status"]) + .execute(); + + await db.schema + .createIndex("idx_order_tx_hash") + .on("order") + .column("tx_hash") + .execute(); +} + +export async function down(db: Kysely): Promise { + // Drop order table first (has FK to position) + await db.schema.dropTable("order").ifExists().execute(); + + // Drop position table + await db.schema.dropTable("position").ifExists().execute(); +} diff --git a/apps/long-short-backend/.config/migrations/1770098176165_market_config.ts b/apps/long-short-backend/.config/migrations/1770098176165_market_config.ts new file mode 100644 index 0000000..68a1b50 --- /dev/null +++ b/apps/long-short-backend/.config/migrations/1770098176165_market_config.ts @@ -0,0 +1,30 @@ +import type { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable("market_config") + .addColumn("market_id", "varchar(64)", (col) => col.primaryKey()) + .addColumn("asset_a", "varchar(128)", (col) => col.notNull()) + .addColumn("asset_b", "varchar(128)", (col) => col.notNull()) + .addColumn("amm_lp_asset", "varchar(128)", (col) => col.notNull()) + .addColumn("asset_a_q_token_ticker", "varchar(32)", (col) => col.notNull()) + .addColumn("asset_a_q_token_raw", "varchar(128)", (col) => col.notNull()) + .addColumn("asset_b_q_token_ticker", "varchar(32)", (col) => col.notNull()) + .addColumn("asset_b_q_token_raw", "varchar(128)", (col) => col.notNull()) + .addColumn("collateral_market_id", "varchar(64)", (col) => col.notNull()) + .addColumn("leverage", "integer", (col) => col.notNull()) + .addColumn("min_collateral", "numeric", (col) => col.notNull()) + .addColumn("enable", "boolean", (col) => col.notNull().defaultTo(true)) + .execute(); + + // Create index on enable for filtering active markets + await db.schema + .createIndex("idx_market_config_enable") + .on("market_config") + .column("enable") + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable("market_config").ifExists().execute(); +} diff --git a/apps/long-short-backend/.config/seeds/market_config.ts b/apps/long-short-backend/.config/seeds/market_config.ts new file mode 100644 index 0000000..79a4bb7 --- /dev/null +++ b/apps/long-short-backend/.config/seeds/market_config.ts @@ -0,0 +1,34 @@ +import type { Kysely } from "kysely"; + +/** + * Seed data for market_config table + * + * Note: Update asset values with actual mainnet/testnet values before deployment + */ +export async function seed(db: Kysely): Promise { + // Clear existing data + await db.deleteFrom("market_config").execute(); + + // Insert seed data + await db + .insertInto("market_config") + .values([ + { + market_id: "ADA-MIN", + asset_a: "lovelace", // ADA + asset_b: "29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6.4d494e", // MIN + amm_lp_asset: "TODO_LP_ASSET", // Minswap ADA-MIN LP token + asset_a_q_token_ticker: "qADA", + asset_a_q_token_raw: "TODO_QADA_ASSET", // Liqwid qADA token + asset_b_q_token_ticker: "qMIN", + asset_b_q_token_raw: "TODO_QMIN_ASSET", // Liqwid qMIN token + collateral_market_id: "ADA", // Liqwid market ID for collateral + leverage: 2, + min_collateral: "100000000", // 100 ADA in lovelace + enable: true, + }, + ]) + .execute(); + + console.log("Seeded market_config table"); +} diff --git a/apps/long-short-backend/SPEC.md b/apps/long-short-backend/SPEC.md new file mode 100644 index 0000000..bd269f4 --- /dev/null +++ b/apps/long-short-backend/SPEC.md @@ -0,0 +1,590 @@ +# Isolated Margin Trading Backend - Specification + +## Overview + +An isolated-margin leveraged trading backend for Cardano DEX, integrated with Liqwid lending protocol. Each position has its own dedicated margin (collateral), isolating risk per trade. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ API Layer (HTTP/WS) │ +├─────────────────────────────────────────────────────────────────┤ +│ Service Layer │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Position │ │ Order │ │ Liquidation │ │ +│ │ Service │ │ Service │ │ Engine │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +├─────────────────────────────────────────────────────────────────┤ +│ Core Layer │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Margin │ │ Price │ │ Risk │ │ +│ │ Calculator │ │ Oracle │ │ Manager │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +├─────────────────────────────────────────────────────────────────┤ +│ Integration Layer │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Minswap │ │ Liqwid │ │ Blockchain │ │ +│ │ DEX V2 │ │ Lending │ │ Syncer │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +├─────────────────────────────────────────────────────────────────┤ +│ Data Layer │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ PostgreSQL │ │ Redis │ │ Ogmios │ │ +│ │ (Positions)│ │ (Cache) │ │ (Chain) │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Core Concepts + +### Isolated Margin +- Each position has its own dedicated collateral +- Losses are limited to the margin allocated to that specific position +- Positions cannot affect each other's margin + +### Position Types +- **Long**: Profit when price goes up (borrow quote asset, buy base asset) +- **Short**: Profit when price goes down (borrow base asset, sell for quote asset) + +### Leverage +- Supported leverage: 2x, 3x, 5x, 10x (configurable per market) +- Higher leverage = higher liquidation risk + +--- + +## Database Schema + +### Tables + +#### `position` +| Column | Type | Description | +|--------|------|-------------| +| `id` | BIGSERIAL | Primary key | +| `user_address` | VARCHAR(128) | User's Cardano address | +| `market` | VARCHAR(128) | Trading pair (e.g., "ADA/DJED") | +| `side` | VARCHAR(8) | "LONG" or "SHORT" | +| `status` | VARCHAR(16) | "OPEN", "CLOSED", "LIQUIDATED" | +| `leverage` | NUMERIC | Leverage multiplier | +| `collateral_asset` | VARCHAR(128) | Asset used as collateral | +| `collateral_amount` | NUMERIC | Amount of collateral | +| `entry_price` | NUMERIC | Average entry price | +| `position_size` | NUMERIC | Size of position in base asset | +| `borrowed_amount` | NUMERIC | Amount borrowed from Liqwid | +| `liquidation_price` | NUMERIC | Price at which position gets liquidated | +| `take_profit_price` | NUMERIC | Optional TP price | +| `stop_loss_price` | NUMERIC | Optional SL price | +| `realized_pnl` | NUMERIC | Realized profit/loss | +| `unrealized_pnl` | NUMERIC | Unrealized profit/loss | +| `funding_paid` | NUMERIC | Cumulative funding fees paid | +| `liqwid_supply_id` | VARCHAR(128) | Liqwid supply position reference | +| `liqwid_borrow_id` | VARCHAR(128) | Liqwid borrow position reference | +| `created_at` | TIMESTAMP | Position creation time | +| `updated_at` | TIMESTAMP | Last update time | +| `closed_at` | TIMESTAMP | Position close time | + +#### `order` +| Column | Type | Description | +|--------|------|-------------| +| `id` | BIGSERIAL | Primary key | +| `position_id` | BIGINT | Reference to position (nullable for new positions) | +| `user_address` | VARCHAR(128) | User's Cardano address | +| `market` | VARCHAR(128) | Trading pair | +| `order_type` | VARCHAR(16) | "MARKET", "LIMIT", "STOP_MARKET", "STOP_LIMIT" | +| `side` | VARCHAR(8) | "LONG" or "SHORT" | +| `action` | VARCHAR(16) | "OPEN", "CLOSE", "INCREASE", "DECREASE" | +| `status` | VARCHAR(16) | "PENDING", "FILLED", "CANCELLED", "EXPIRED" | +| `leverage` | NUMERIC | Leverage for new positions | +| `collateral_amount` | NUMERIC | Collateral amount | +| `size` | NUMERIC | Order size | +| `price` | NUMERIC | Limit price (for limit orders) | +| `trigger_price` | NUMERIC | Trigger price (for stop orders) | +| `slippage_tolerance` | NUMERIC | Max slippage % | +| `tx_hash` | VARCHAR(64) | On-chain transaction hash | +| `filled_price` | NUMERIC | Actual fill price | +| `filled_at` | TIMESTAMP | Fill timestamp | +| `expires_at` | TIMESTAMP | Order expiration | +| `created_at` | TIMESTAMP | Order creation time | + +#### `liquidation` +| Column | Type | Description | +|--------|------|-------------| +| `id` | BIGSERIAL | Primary key | +| `position_id` | BIGINT | Liquidated position | +| `liquidator_address` | VARCHAR(128) | Liquidator's address | +| `liquidation_price` | NUMERIC | Price at liquidation | +| `penalty_amount` | NUMERIC | Liquidation penalty | +| `remaining_collateral` | NUMERIC | Returned to user | +| `tx_hash` | VARCHAR(64) | Liquidation tx hash | +| `created_at` | TIMESTAMP | Liquidation time | + +#### `market_config` +| Column | Type | Description | +|--------|------|-------------| +| `market` | VARCHAR(128) | Primary key - trading pair | +| `base_asset` | VARCHAR(128) | Base asset | +| `quote_asset` | VARCHAR(128) | Quote asset | +| `lp_asset` | VARCHAR(128) | Minswap LP asset | +| `max_leverage` | NUMERIC | Maximum allowed leverage | +| `min_collateral` | NUMERIC | Minimum collateral | +| `maintenance_margin_rate` | NUMERIC | Maintenance margin % | +| `liquidation_fee_rate` | NUMERIC | Liquidation penalty % | +| `taker_fee_rate` | NUMERIC | Taker fee % | +| `maker_fee_rate` | NUMERIC | Maker fee % | +| `funding_rate_interval` | INTEGER | Funding rate interval (hours) | +| `enabled` | BOOLEAN | Market enabled | + +#### `price_history` +| Column | Type | Description | +|--------|------|-------------| +| `id` | BIGSERIAL | Primary key | +| `market` | VARCHAR(128) | Trading pair | +| `price` | NUMERIC | Price | +| `source` | VARCHAR(32) | "DEX", "ORACLE" | +| `slot` | BIGINT | Cardano slot | +| `timestamp` | TIMESTAMP | Time | + +--- + +## Services + +### 1. Position Service + +Manages position lifecycle. + +```typescript +interface PositionService { + // Open a new position + openPosition(params: { + userAddress: string; + market: string; + side: "LONG" | "SHORT"; + collateralAmount: bigint; + leverage: number; + slippageTolerance: number; + }): Promise; + + // Close an existing position + closePosition(params: { + positionId: bigint; + slippageTolerance: number; + }): Promise; + + // Increase position size + increasePosition(params: { + positionId: bigint; + additionalCollateral: bigint; + }): Promise; + + // Decrease position size (partial close) + decreasePosition(params: { + positionId: bigint; + closePercent: number; + }): Promise; + + // Add margin to position + addMargin(params: { + positionId: bigint; + amount: bigint; + }): Promise; + + // Get position details + getPosition(positionId: bigint): Promise; + + // Get user's open positions + getUserPositions(userAddress: string): Promise; + + // Calculate unrealized PnL + calculateUnrealizedPnL(position: Position, currentPrice: bigint): bigint; +} +``` + +### 2. Order Service + +Handles order placement and execution. + +```typescript +interface OrderService { + // Place a market order + placeMarketOrder(params: { + userAddress: string; + market: string; + side: "LONG" | "SHORT"; + action: "OPEN" | "CLOSE"; + size: bigint; + leverage?: number; + collateralAmount?: bigint; + }): Promise; + + // Place a limit order + placeLimitOrder(params: { + userAddress: string; + market: string; + side: "LONG" | "SHORT"; + action: "OPEN" | "CLOSE"; + size: bigint; + price: bigint; + leverage?: number; + collateralAmount?: bigint; + expiresAt?: Date; + }): Promise; + + // Cancel an order + cancelOrder(orderId: bigint): Promise; + + // Get order status + getOrder(orderId: bigint): Promise; + + // Get user's pending orders + getPendingOrders(userAddress: string): Promise; +} +``` + +### 3. Liquidation Engine + +Monitors and executes liquidations. + +```typescript +interface LiquidationEngine { + // Check if position should be liquidated + shouldLiquidate(position: Position, currentPrice: bigint): boolean; + + // Calculate liquidation price + calculateLiquidationPrice(position: Position): bigint; + + // Execute liquidation + liquidate(positionId: bigint): Promise; + + // Get liquidatable positions + getLiquidatablePositions(): Promise; + + // Start monitoring loop + startMonitoring(): void; + + // Stop monitoring + stopMonitoring(): void; +} +``` + +### 4. Margin Calculator + +Handles margin calculations. + +```typescript +interface MarginCalculator { + // Calculate initial margin required + calculateInitialMargin(params: { + positionSize: bigint; + entryPrice: bigint; + leverage: number; + }): bigint; + + // Calculate maintenance margin + calculateMaintenanceMargin(params: { + positionSize: bigint; + entryPrice: bigint; + maintenanceMarginRate: number; + }): bigint; + + // Calculate available margin + calculateAvailableMargin(position: Position): bigint; + + // Calculate margin ratio + calculateMarginRatio(position: Position, currentPrice: bigint): number; + + // Check if position is healthy + isPositionHealthy(position: Position, currentPrice: bigint): boolean; +} +``` + +### 5. Price Oracle + +Fetches and validates prices. + +```typescript +interface PriceOracle { + // Get current price from DEX + getCurrentPrice(market: string): Promise; + + // Get TWAP (Time-Weighted Average Price) + getTWAP(market: string, period: number): Promise; + + // Get price from external oracle (e.g., Charli3) + getOraclePrice(market: string): Promise; + + // Get validated price (combines DEX + oracle) + getValidatedPrice(market: string): Promise; + + // Subscribe to price updates + subscribePriceUpdates(market: string, callback: (price: bigint) => void): void; +} +``` + +### 6. Liqwid Integration + +Handles borrowing/lending through Liqwid. + +```typescript +interface LiqwidService { + // Supply collateral to Liqwid + supplyCollateral(params: { + userAddress: string; + asset: string; + amount: bigint; + }): Promise<{ supplyId: string; txHash: string }>; + + // Borrow from Liqwid + borrow(params: { + userAddress: string; + asset: string; + amount: bigint; + collateralSupplyId: string; + }): Promise<{ borrowId: string; txHash: string }>; + + // Repay borrowed amount + repay(params: { + borrowId: string; + amount: bigint; + }): Promise<{ txHash: string }>; + + // Withdraw collateral + withdrawCollateral(params: { + supplyId: string; + amount: bigint; + }): Promise<{ txHash: string }>; + + // Get borrow rate + getBorrowRate(asset: string): Promise; + + // Get supply rate + getSupplyRate(asset: string): Promise; +} +``` + +--- + +## API Endpoints + +### HTTP API + +#### Positions +``` +POST /api/v1/positions # Open new position +GET /api/v1/positions/:id # Get position by ID +GET /api/v1/positions # List user positions +POST /api/v1/positions/:id/close # Close position +POST /api/v1/positions/:id/margin # Add margin +DELETE /api/v1/positions/:id # Cancel pending position +``` + +#### Orders +``` +POST /api/v1/orders # Place order +GET /api/v1/orders/:id # Get order by ID +GET /api/v1/orders # List user orders +DELETE /api/v1/orders/:id # Cancel order +``` + +#### Markets +``` +GET /api/v1/markets # List all markets +GET /api/v1/markets/:market # Get market details +GET /api/v1/markets/:market/price # Get current price +GET /api/v1/markets/:market/depth # Get order book depth +``` + +#### Account +``` +GET /api/v1/account/balance # Get account balance +GET /api/v1/account/history # Get trade history +GET /api/v1/account/pnl # Get PnL summary +``` + +### WebSocket API + +```typescript +// Subscribe to price updates +{ "type": "subscribe", "channel": "price", "market": "ADA/DJED" } + +// Subscribe to position updates +{ "type": "subscribe", "channel": "positions", "address": "addr1..." } + +// Subscribe to order updates +{ "type": "subscribe", "channel": "orders", "address": "addr1..." } + +// Subscribe to liquidation events +{ "type": "subscribe", "channel": "liquidations" } +``` + +--- + +## Flow Diagrams + +### Open Long Position + +``` +User Backend Liqwid Minswap DEX + │ │ │ │ + │ Open Long ADA/DJED │ │ │ + │ Collateral: 100 DJED │ │ │ + │ Leverage: 3x │ │ │ + │─────────────────────>│ │ │ + │ │ │ │ + │ │ Supply 100 DJED │ │ + │ │────────────────────>│ │ + │ │ │ │ + │ │ Borrow 200 DJED │ │ + │ │────────────────────>│ │ + │ │ │ │ + │ │ │ Swap 300 DJED → ADA │ + │ │────────────────────────────────────────────>│ + │ │ │ │ + │ │ Position Created │ │ + │<─────────────────────│ │ │ + │ │ │ │ +``` + +### Close Long Position + +``` +User Backend Liqwid Minswap DEX + │ │ │ │ + │ Close Position │ │ │ + │─────────────────────>│ │ │ + │ │ │ │ + │ │ │ Swap ADA → DJED │ + │ │────────────────────────────────────────────>│ + │ │ │ │ + │ │ Repay 200 DJED │ │ + │ │ + Interest │ │ + │ │────────────────────>│ │ + │ │ │ │ + │ │ Withdraw Collateral │ │ + │ │────────────────────>│ │ + │ │ │ │ + │ │ Return profit/loss │ │ + │<─────────────────────│ to user │ │ + │ │ │ │ +``` + +### Liquidation Flow + +``` +Liquidator Backend Liqwid Minswap DEX + │ │ │ │ + │ │ Monitor Positions │ │ + │ │ (price < liq price) │ │ + │ │ │ │ + │ Trigger Liquidation │ │ │ + │─────────────────────>│ │ │ + │ │ │ │ + │ │ │ Swap ADA → DJED │ + │ │────────────────────────────────────────────>│ + │ │ │ │ + │ │ Repay Loan │ │ + │ │────────────────────>│ │ + │ │ │ │ + │ │ Liquidation penalty │ │ + │ Receive reward │ to liquidator │ │ + │<─────────────────────│ │ │ + │ │ │ │ + │ │ Remaining collateral│ │ + │ │ to user (if any) │ │ + │ │ │ │ +``` + +--- + +## Configuration + +### Environment Variables + +```env +# Database +DATABASE_URL=postgres://user:pass@localhost:5432/margin_trading + +# Redis +REDIS_URL=redis://localhost:6379 + +# Blockchain +OGMIOS_HOST=localhost:1337 +KUPO_URL=http://localhost:1442 +NETWORK_ENV=TESTNET_PREVIEW + +# Liqwid +LIQWID_API_URL=https://api.liqwid.finance +LIQWID_CONTRACT_ADDRESS=addr1... + +# Risk Management +MAX_LEVERAGE=10 +MAINTENANCE_MARGIN_RATE=0.05 +LIQUIDATION_FEE_RATE=0.025 + +# API +API_PORT=9999 +WS_PORT=9998 +``` + +--- + +## Implementation Phases + +### Phase 1: Core Infrastructure +- [ ] Database schema migrations +- [ ] Position & Order models +- [ ] Basic CRUD operations +- [ ] Price oracle integration (DEX prices) + +### Phase 2: Position Management +- [ ] Open position flow +- [ ] Close position flow +- [ ] Margin calculator +- [ ] PnL calculation + +### Phase 3: Liqwid Integration +- [ ] Supply collateral +- [ ] Borrow assets +- [ ] Repay loans +- [ ] Interest calculation + +### Phase 4: Liquidation Engine +- [ ] Liquidation price calculation +- [ ] Position health monitoring +- [ ] Automated liquidation +- [ ] Liquidation rewards + +### Phase 5: Advanced Features +- [ ] Limit orders +- [ ] Stop-loss / Take-profit +- [ ] Partial close +- [ ] Multi-collateral support + +### Phase 6: API & Monitoring +- [ ] REST API +- [ ] WebSocket API +- [ ] Health checks +- [ ] Metrics & alerts + +--- + +## Risk Parameters + +| Parameter | Value | Description | +|-----------|-------|-------------| +| Max Leverage | 10x | Maximum leverage allowed | +| Maintenance Margin | 5% | Minimum margin to avoid liquidation | +| Liquidation Fee | 2.5% | Penalty for liquidation | +| Min Collateral | 10 ADA | Minimum position collateral | +| Max Position Size | 100,000 ADA | Maximum single position | +| Funding Rate Interval | 8 hours | Funding rate calculation period | + +--- + +## Security Considerations + +1. **Signature Verification**: All user actions require valid Cardano signatures +2. **Rate Limiting**: API rate limits per address +3. **Slippage Protection**: Maximum slippage enforced on swaps +4. **Oracle Validation**: Price from DEX validated against external oracle +5. **Circuit Breakers**: Halt trading if price deviation > threshold +6. **Audit Trail**: All actions logged with timestamps diff --git a/apps/long-short-backend/example/sign-data.ts b/apps/long-short-backend/example/sign-data.ts new file mode 100644 index 0000000..6de50a7 --- /dev/null +++ b/apps/long-short-backend/example/sign-data.ts @@ -0,0 +1,50 @@ +import { baseAddressWalletFromSeed } from "@minswap/felis-cip"; +import { NetworkEnvironment } from "@minswap/felis-ledger-core"; +import { signData, verifySignData } from "../src/utils/signature"; +import { RustModule } from "@minswap/felis-ledger-utils"; +import invariant from "@minswap/tiny-invariant"; + +const main = async () => { + await RustModule.load(); + const seed = + "melt enemy surface feed kiss helmet suffer demise toilet insane human refuse park insect lawsuit custom inch spirit throw radio alarm creek chat symptom"; + const wallet = baseAddressWalletFromSeed( + seed, + NetworkEnvironment.TESTNET_PREVIEW, + ); + + const data = { + market: "ADA-MIN", + side: "LONG", + amount: "500000000", + }; + const message = Buffer.from(JSON.stringify(data)).toString("hex"); + console.log("json data", JSON.stringify(data)); + console.log(message); + // Sign the message + const { signature, key } = signData( + wallet.paymentKey, + wallet.address.bech32, + message, + ); + + const result = { + data, + user_address: wallet.address.bech32, + witness: { + key, + signature, + } + }; + console.log(JSON.stringify(result, null, 4)); + + const authenticated = verifySignData({ + message, + address: wallet.address.bech32, + key, + signature, + }); + invariant(authenticated, "Signature verification failed"); +}; + +main(); diff --git a/apps/long-short-backend/package.json b/apps/long-short-backend/package.json new file mode 100644 index 0000000..f9ac487 --- /dev/null +++ b/apps/long-short-backend/package.json @@ -0,0 +1,58 @@ +{ + "name": "long-short-backend", + "version": "0.1.0", + "type": "module", + "private": true, + "exports": { + ".": "./dist/index.js" + }, + "scripts": { + "build": "tsc", + "start": "node --import tsx src/index.ts", + "dev": "node --import tsx --watch src/index.ts", + "test": "vitest", + "run:migrate": "pnpm kysely migrate:latest", + "run:seed": "pnpm kysely seed:run", + "codegen": "kysely-codegen --exclude-pattern=\"*timescale*.*\" --out-file=src/database/db.d.ts" + }, + "devDependencies": { + "@cardano-ogmios/schema": "^6.11.0", + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@types/json-bigint": "^1.0.4", + "@types/node": "^22.15.3", + "@types/pg": "^8.16.0", + "dpdm": "^3.14.0", + "eslint": "^9.31.0", + "kysely-codegen": "^0.19.0", + "kysely-ctl": "^0.19.0", + "tsx": "^4.19.4", + "typescript": "5.8.2", + "vitest": "^3.2.4" + }, + "dependencies": { + "@cardano-ogmios/client": "^6.14.0", + "@emurgo/cardano-message-signing-nodejs": "^1.0.1", + "@fastify/cors": "^10.0.2", + "@minswap/felis-build-tx": "workspace:*", + "@minswap/felis-dex-v1": "workspace:*", + "@minswap/felis-dex-v2": "workspace:*", + "@minswap/felis-ledger-core": "workspace:*", + "@minswap/felis-ledger-utils": "workspace:*", + "@minswap/felis-tx-builder": "workspace:*", + "@minswap/felis-cip": "workspace:*", + "@minswap/tiny-invariant": "^1.2.0", + "@sinclair/typebox": "^0.34.33", + "@types/bun": "^1.3.5", + "bignumber.js": "^9.1.2", + "bip39": "^3.1.0", + "exponential-backoff": "^3.1.3", + "fastify": "^5.2.2", + "ioredis": "^5.9.0", + "kysely": "^0.28.11", + "p-timeout": "^7.0.1", + "pg": "^8.16.3", + "remeda": "^2.33.1", + "socket.io-client": "^4.8.3" + } +} diff --git a/apps/long-short-backend/src/api/helper.ts b/apps/long-short-backend/src/api/helper.ts new file mode 100644 index 0000000..adda41e --- /dev/null +++ b/apps/long-short-backend/src/api/helper.ts @@ -0,0 +1,37 @@ +import { verifySignData } from "../utils/signature"; +import type { SignedDataType } from "./schemas"; + +export namespace ApiHelper { + export type AuthenticateResult = + | { success: true } + | { success: false; error: string }; + + /** + * Authenticate a request by verifying the witness signature + * The witness must be the signed data of JSON.stringify(data) + * + * @param data - The data object that was signed + * @param userAddress - The user's Cardano address (bech32) + * @param witness - The signed data (COSEKey and COSESign1) + */ + export function authenticate( + data: object, + userAddress: string, + witness: SignedDataType, + ): AuthenticateResult { + const message = Buffer.from(JSON.stringify(data)).toString("hex"); + + const isValid = verifySignData({ + message, + address: userAddress, + key: witness.key, + signature: witness.signature, + }); + + if (!isValid) { + return { success: false, error: "Invalid authentication signature" }; + } + + return { success: true }; + } +} diff --git a/apps/long-short-backend/src/api/index.ts b/apps/long-short-backend/src/api/index.ts new file mode 100644 index 0000000..9440911 --- /dev/null +++ b/apps/long-short-backend/src/api/index.ts @@ -0,0 +1,2 @@ +export { createApiServer, type ApiServerOptions } from "./server"; +export * from "./schemas"; diff --git a/apps/long-short-backend/src/api/routes/metadata.ts b/apps/long-short-backend/src/api/routes/metadata.ts new file mode 100644 index 0000000..25880b9 --- /dev/null +++ b/apps/long-short-backend/src/api/routes/metadata.ts @@ -0,0 +1,46 @@ +import type { FastifyInstance } from "fastify"; +import { getEnabledMarketConfigs, type MarketConfig } from "../../config/market"; +import { type MarketConfigResponseType, MetadataResponseSchema, type MetadataResponseType } from "../schemas"; +import { API_ENDPOINTS } from "../../constants"; + +function marketConfigToResponse(config: MarketConfig): MarketConfigResponseType { + return { + market_id: config.marketId, + asset_a: config.assetA.toString(), + asset_b: config.assetB.toString(), + amm_lp_asset: config.ammLpAsset, + asset_a_q_token_ticker: config.assetAQTokenTicker, + asset_a_q_token_raw: config.assetAQTokenRaw, + asset_b_q_token_ticker: config.assetBQTokenTicker, + asset_b_q_token_raw: config.assetBQTokenRaw, + collateral_market_id: config.collateralMarketId, + leverage: config.leverage, + min_collateral: config.minCollateral.toString(), + }; +} + +export function registerMetadataRoutes(fastify: FastifyInstance): void { + // GET /metadata + fastify.get<{ + Reply: MetadataResponseType; + }>( + API_ENDPOINTS.METADATA, + { + schema: { + response: { + 200: MetadataResponseSchema, + }, + }, + }, + async (_request, reply) => { + const marketConfigs = getEnabledMarketConfigs(); + + return reply.status(200).send({ + success: true, + data: { + markets: marketConfigs.map(marketConfigToResponse), + }, + }); + }, + ); +} diff --git a/apps/long-short-backend/src/api/routes/position.ts b/apps/long-short-backend/src/api/routes/position.ts new file mode 100644 index 0000000..3b7ce88 --- /dev/null +++ b/apps/long-short-backend/src/api/routes/position.ts @@ -0,0 +1,105 @@ +import type { FastifyInstance } from "fastify"; +import { API_ENDPOINTS } from "../../constants"; +import type { Position } from "../../repository/position-repository"; +import type { PositionService } from "../../services/position-service"; +import { ApiHelper } from "../helper"; +import { + type AuthenCreatePositionBodyType, + AuthenCreatePositionBodyTypeSchema, + CreatePositionResponseSchema, + type CreatePositionResponseType, + ErrorResponseSchema, + type PositionResponseType, +} from "../schemas"; + +function positionToResponse(position: Position): PositionResponseType { + return { + id: position.id.toString(), + user_address: position.userAddress, + market: position.market, + side: position.side, + status: position.status, + leverage: position.leverage, + collateral_asset: position.collateralAsset, + collateral_amount: position.collateralAmount, + entry_price: position.entryPrice, + position_size: position.positionSize, + borrowed_amount: position.borrowedAmount, + liquidation_price: position.liquidationPrice, + take_profit_price: position.takeProfitPrice, + stop_loss_price: position.stopLossPrice, + realized_pnl: position.realizedPnl, + unrealized_pnl: position.unrealizedPnl, + funding_paid: position.fundingPaid, + created_at: position.createdAt.toISOString(), + updated_at: position.updatedAt.toISOString(), + closed_at: position.closedAt?.toISOString() ?? null, + }; +} + +export function registerPositionRoutes( + fastify: FastifyInstance, + positionService: PositionService, +): void { + // POST /position/create + fastify.post<{ + Body: AuthenCreatePositionBodyType; + Reply: CreatePositionResponseType; + }>( + API_ENDPOINTS.POSITION_CREATE, + { + schema: { + body: AuthenCreatePositionBodyTypeSchema, + response: { + 200: CreatePositionResponseSchema, + 400: ErrorResponseSchema, + 401: ErrorResponseSchema, + }, + }, + }, + async (request, reply) => { + const { data, user_address, witness } = request.body; + const { market, side, amount } = data; + + // Authenticate request + const authResult = ApiHelper.authenticate(data, user_address, witness); + if (!authResult.success) { + return reply.status(401).send({ + success: false, + error: authResult.error, + }); + } + + // Parse amount + let amountBigInt: bigint; + try { + amountBigInt = BigInt(amount); + } catch { + return reply.status(400).send({ + success: false, + error: "Invalid amount format", + }); + } + + // Create position + const result = await positionService.createPosition({ + userAddress: user_address, + market, + side, + amount: amountBigInt, + }); + + if (!result.success) { + return reply.status(400).send({ + success: false, + error: result.error, + }); + } + + return reply.status(200).send({ + success: true, + data: positionToResponse(result.position), + }); + }, + ); +} diff --git a/apps/long-short-backend/src/api/schemas.ts b/apps/long-short-backend/src/api/schemas.ts new file mode 100644 index 0000000..72628e0 --- /dev/null +++ b/apps/long-short-backend/src/api/schemas.ts @@ -0,0 +1,104 @@ +import { Type, type Static } from "@sinclair/typebox"; + +export const SignedDataSchema = Type.Object({ + key: Type.String({ minLength: 1, description: "COSEKey hex" }), + signature: Type.String({ minLength: 1, description: "COSESign1 hex" }), +}); + +export type SignedDataType = Static; + +// Common authenticated request schema +export const AuthenCommonSchema = >(dataSchema: T) => + Type.Object({ + data: dataSchema, + user_address: Type.String({ minLength: 1, description: "User's Cardano address (bech32)" }), + witness: SignedDataSchema, + }); + +export type AuthenCommonType = { + data: T; + user_address: string; + witness: SignedDataType; +}; + +// Position schemas +export const PositionSideSchema = Type.Union([Type.Literal("LONG"), Type.Literal("SHORT")]); + +export const CreatePositionDataSchema = Type.Object({ + market: Type.String({ minLength: 1, description: "Trading pair (e.g., ADA-MIN)" }), + side: PositionSideSchema, + amount: Type.String({ pattern: "^[0-9]+$", description: "Collateral amount in lovelace" }), +}); + +export type CreatePositionDataType = Static; + +export const AuthenCreatePositionBodyTypeSchema = AuthenCommonSchema(CreatePositionDataSchema); + +export type AuthenCreatePositionBodyType = AuthenCommonType; + +export const PositionResponseSchema = Type.Object({ + id: Type.String({ description: "Position ID" }), + user_address: Type.String(), + market: Type.String(), + side: PositionSideSchema, + status: Type.Union([Type.Literal("OPEN"), Type.Literal("CLOSED"), Type.Literal("LIQUIDATED")]), + leverage: Type.String(), + collateral_asset: Type.String(), + collateral_amount: Type.String(), + entry_price: Type.String(), + position_size: Type.String(), + borrowed_amount: Type.String(), + liquidation_price: Type.String(), + take_profit_price: Type.Union([Type.String(), Type.Null()]), + stop_loss_price: Type.Union([Type.String(), Type.Null()]), + realized_pnl: Type.String(), + unrealized_pnl: Type.String(), + funding_paid: Type.String(), + created_at: Type.String({ format: "date-time" }), + updated_at: Type.String({ format: "date-time" }), + closed_at: Type.Union([Type.String({ format: "date-time" }), Type.Null()]), +}); + +export type PositionResponseType = Static; + +export const CreatePositionResponseSchema = Type.Object({ + success: Type.Boolean(), + data: Type.Optional(PositionResponseSchema), + error: Type.Optional(Type.String()), +}); + +export type CreatePositionResponseType = Static; + +// Error response +export const ErrorResponseSchema = Type.Object({ + success: Type.Literal(false), + error: Type.String(), +}); + +export type ErrorResponseType = Static; + +// Market config schemas +export const MarketConfigResponseSchema = Type.Object({ + market_id: Type.String({ description: "Market identifier (e.g., ADA-MIN)" }), + asset_a: Type.String({ description: "First asset" }), + asset_b: Type.String({ description: "Second asset" }), + amm_lp_asset: Type.String({ description: "Minswap LP token" }), + asset_a_q_token_ticker: Type.String({ description: "Liqwid qToken ticker for asset A" }), + asset_a_q_token_raw: Type.String({ description: "Liqwid qToken raw asset for asset A" }), + asset_b_q_token_ticker: Type.String({ description: "Liqwid qToken ticker for asset B" }), + asset_b_q_token_raw: Type.String({ description: "Liqwid qToken raw asset for asset B" }), + collateral_market_id: Type.String({ description: "Liqwid market ID" }), + leverage: Type.Number({ description: "Leverage multiplier" }), + min_collateral: Type.String({ description: "Minimum collateral in lovelace" }), +}); + +export type MarketConfigResponseType = Static; + +export const MetadataResponseSchema = Type.Object({ + success: Type.Boolean(), + data: Type.Object({ + markets: Type.Array(MarketConfigResponseSchema), + }), +}); + +export type MetadataResponseType = Static; diff --git a/apps/long-short-backend/src/api/server.ts b/apps/long-short-backend/src/api/server.ts new file mode 100644 index 0000000..d40117b --- /dev/null +++ b/apps/long-short-backend/src/api/server.ts @@ -0,0 +1,54 @@ +import cors from "@fastify/cors"; +import Fastify, { type FastifyInstance } from "fastify"; +import type { Kysely } from "kysely"; +import type { DB } from "../database"; +import { PositionService } from "../services/position-service"; +import { logger } from "../utils"; +import { registerMetadataRoutes } from "./routes/metadata"; +import { registerPositionRoutes } from "./routes/position"; +import { API_ENDPOINTS } from "../constants"; + +export type ApiServerOptions = { + port: number; + host: string; + db: Kysely; +}; + +export async function createApiServer(options: ApiServerOptions): Promise { + const { port, host, db } = options; + + const fastify = Fastify({ + logger: { + level: "info", + }, + }); + + // Register CORS + await fastify.register(cors, { + origin: true, + methods: ["GET", "POST", "PUT", "DELETE"], + }); + + // Health check endpoint (disable logging to reduce noise) + fastify.get(API_ENDPOINTS.HEALTH, { logLevel: "silent" }, async () => { + return { status: "ok" }; + }); + + // Initialize services + const positionService = new PositionService(db); + + // Register routes + registerMetadataRoutes(fastify); + registerPositionRoutes(fastify, positionService); + + // Start server + try { + await fastify.listen({ port, host }); + logger.info(`API server listening on ${host}:${port}`); + } catch (err) { + logger.error("Failed to start API server", { error: err }); + throw err; + } + + return fastify; +} diff --git a/apps/long-short-backend/src/config/index.ts b/apps/long-short-backend/src/config/index.ts new file mode 100644 index 0000000..674239a --- /dev/null +++ b/apps/long-short-backend/src/config/index.ts @@ -0,0 +1 @@ +export * from "./market"; diff --git a/apps/long-short-backend/src/config/market.ts b/apps/long-short-backend/src/config/market.ts new file mode 100644 index 0000000..860df71 --- /dev/null +++ b/apps/long-short-backend/src/config/market.ts @@ -0,0 +1,91 @@ +import { Asset } from "@minswap/felis-ledger-core"; +import type { Kysely } from "kysely"; +import type { DB } from "../database"; + +/** + * Market configuration loaded from database + */ +export type MarketConfig = { + marketId: string; + assetA: Asset; + assetB: Asset; + ammLpAsset: string; + assetAQTokenTicker: string; + assetAQTokenRaw: string; + assetBQTokenTicker: string; + assetBQTokenRaw: string; + collateralMarketId: string; + leverage: number; + minCollateral: bigint; + enable: boolean; +}; + +/** + * In-memory cache for market configs + */ +let marketConfigCache: Map | null = null; + +/** + * Load all market configs from database + */ +export async function loadMarketConfigs(db: Kysely): Promise> { + const rows = await db.selectFrom("market_config").selectAll().execute(); + + const configs = new Map(); + for (const row of rows) { + configs.set(row.market_id, { + marketId: row.market_id, + assetA: Asset.fromString(row.asset_a), + assetB: Asset.fromString(row.asset_b), + ammLpAsset: row.amm_lp_asset, + assetAQTokenTicker: row.asset_a_q_token_ticker, + assetAQTokenRaw: row.asset_a_q_token_raw, + assetBQTokenTicker: row.asset_b_q_token_ticker, + assetBQTokenRaw: row.asset_b_q_token_raw, + collateralMarketId: row.collateral_market_id, + leverage: row.leverage, + minCollateral: BigInt(row.min_collateral), + enable: row.enable, + }); + } + + // Update cache + marketConfigCache = configs; + return configs; +} + +/** + * Get all enabled market configs from cache + * Call loadMarketConfigs first to populate cache + */ +export function getEnabledMarketConfigs(): MarketConfig[] { + if (!marketConfigCache) { + throw new Error("Market configs not loaded. Call loadMarketConfigs first."); + } + return Array.from(marketConfigCache.values()).filter((c) => c.enable); +} + +/** + * Get market config by market ID from cache + */ +export function getMarketConfig(marketId: string): MarketConfig | null { + if (!marketConfigCache) { + throw new Error("Market configs not loaded. Call loadMarketConfigs first."); + } + return marketConfigCache.get(marketId) ?? null; +} + +/** + * Check if market is supported and enabled + */ +export function isSupportedMarket(marketId: string): boolean { + const config = getMarketConfig(marketId); + return config !== null && config.enable; +} + +/** + * Reload market configs from database (for hot reload) + */ +export async function reloadMarketConfigs(db: Kysely): Promise { + await loadMarketConfigs(db); +} diff --git a/apps/long-short-backend/src/constants.ts b/apps/long-short-backend/src/constants.ts new file mode 100644 index 0000000..2566173 --- /dev/null +++ b/apps/long-short-backend/src/constants.ts @@ -0,0 +1,6 @@ +// MUST sort endpoints alphabetically +export const API_ENDPOINTS = { + HEALTH: "/health", + METADATA: "/metadata", + POSITION_CREATE: "/position/create", +}; diff --git a/apps/long-short-backend/src/database/db.d.ts b/apps/long-short-backend/src/database/db.d.ts new file mode 100644 index 0000000..6fd30ec --- /dev/null +++ b/apps/long-short-backend/src/database/db.d.ts @@ -0,0 +1,84 @@ +/** + * This file was generated by kysely-codegen. + * Please do not edit it manually. + */ + +import type { ColumnType } from "kysely"; + +export type Generated = T extends ColumnType + ? ColumnType + : ColumnType; + +export type Int8 = ColumnType; + +export type Numeric = ColumnType; + +export type Timestamp = ColumnType; + +export interface MarketConfig { + amm_lp_asset: string; + asset_a: string; + asset_a_q_token_raw: string; + asset_a_q_token_ticker: string; + asset_b: string; + asset_b_q_token_raw: string; + asset_b_q_token_ticker: string; + collateral_market_id: string; + enable: Generated; + leverage: number; + market_id: string; + min_collateral: Numeric; +} + +export interface Order { + action: string; + collateral_amount: Numeric | null; + created_at: Generated; + expires_at: Timestamp | null; + filled_at: Timestamp | null; + filled_price: Numeric | null; + id: Generated; + leverage: Numeric | null; + market: string; + order_type: string; + position_id: Int8 | null; + price: Numeric | null; + side: string; + size: Numeric; + slippage_tolerance: Numeric | null; + status: Generated; + trigger_price: Numeric | null; + tx_hash: string | null; + user_address: string; +} + +export interface Position { + borrowed_amount: Numeric; + closed_at: Timestamp | null; + collateral_amount: Numeric; + collateral_asset: string; + created_at: Generated; + entry_price: Numeric; + funding_paid: Generated; + id: Generated; + leverage: Numeric; + liquidation_price: Numeric; + liqwid_borrow_id: string | null; + liqwid_supply_id: string | null; + market: string; + position_size: Numeric; + realized_pnl: Generated; + side: string; + status: Generated; + stop_loss_price: Numeric | null; + take_profit_price: Numeric | null; + unrealized_pnl: Generated; + updated_at: Generated; + user_address: string; +} + +export interface DB { + market_config: MarketConfig; + order: Order; + position: Position; +} diff --git a/apps/long-short-backend/src/database/index.ts b/apps/long-short-backend/src/database/index.ts new file mode 100644 index 0000000..66a3de0 --- /dev/null +++ b/apps/long-short-backend/src/database/index.ts @@ -0,0 +1,3 @@ +export type { DB } from "./db"; +export * from "./postgres"; +export * from "./redis"; diff --git a/apps/long-short-backend/src/database/postgres.ts b/apps/long-short-backend/src/database/postgres.ts new file mode 100644 index 0000000..5d8c972 --- /dev/null +++ b/apps/long-short-backend/src/database/postgres.ts @@ -0,0 +1,77 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: skip file */ +import { Duration } from "@minswap/felis-ledger-utils"; +import * as Kysely from "kysely"; +import * as Pg from "pg"; +import { logger } from "../utils"; + +export const DEFAULT_DATABASE_TIMEOUT = Duration.newSeconds(30); + +export type PostgresOptions = { + logSQL: boolean; + logSlowSQL: Duration; + skipHealthcheck?: boolean; +}; + +const DEFAULT_POSTGRES_OPTIONS: PostgresOptions = { + logSQL: false, + logSlowSQL: Duration.newSeconds(1), + skipHealthcheck: true, +}; + +/** + * Precedence: + * 1. Env var + * 2. Options passed in code + * 3. Default options + */ +function parsePostgresOptions(userOptions?: Partial): PostgresOptions { + let finalOptions: PostgresOptions = DEFAULT_POSTGRES_OPTIONS; + if (userOptions) { + finalOptions = Object.assign(finalOptions, userOptions); + } + return finalOptions; +} + +export async function newKyselyClient( + url: string, + userOptions?: Partial, +): Promise> { + const { logSQL, logSlowSQL, skipHealthcheck } = parsePostgresOptions(userOptions); + + function formatEvent(e: Kysely.LogEvent): Record { + const ret: Record = { + sql: e.query.sql, + params: e.query.parameters, + duration: Duration.newMilliseconds(e.queryDurationMillis), + }; + if (e.level === "error") { + ret.error = e.error; + } + return ret; + } + + const dialect = new Kysely.PostgresDialect({ pool: await newPgPool(url, skipHealthcheck) }); + const client = new Kysely.Kysely({ + dialect, + log(event) { + if (event.level === "error") { + logger.error("kysely query fail", formatEvent(event)); + } else { + if (event.queryDurationMillis >= logSlowSQL.milliseconds) { + logger.warn("kysely slow query", formatEvent(event)); + } else if (logSQL) { + logger.info("kysely raw query", formatEvent(event)); + } + } + }, + }); + return client; +} + +export async function newPgPool(url: string, skipHealthcheck?: boolean): Promise { + const pool = new Pg.Pool({ connectionString: url }); + if (!skipHealthcheck) { + await pool.query("SELECT 1;"); // healthcheck + } + return pool; +} diff --git a/apps/long-short-backend/src/database/redis.ts b/apps/long-short-backend/src/database/redis.ts new file mode 100644 index 0000000..3be721a --- /dev/null +++ b/apps/long-short-backend/src/database/redis.ts @@ -0,0 +1,16 @@ +import { Redis, type RedisOptions } from "ioredis"; + +export function newRedis(url: string, connectionName: string, options?: RedisOptions): Redis { + const redis = new Redis(url, { + ...options, + db: 0, + connectionName, + enableReadyCheck: true, + maxRetriesPerRequest: 10, + showFriendlyErrorStack: false, + retryStrategy(times): number { + return Math.min(times * 10, 2000); + }, + }); + return redis; +} diff --git a/apps/long-short-backend/src/healthchecker.ts b/apps/long-short-backend/src/healthchecker.ts new file mode 100644 index 0000000..55bdb5e --- /dev/null +++ b/apps/long-short-backend/src/healthchecker.ts @@ -0,0 +1,24 @@ +const DEFAULT_HEALTH_PORT = 9999; + +type HealthCheckFn = () => Promise; + +// This is an /health API that K8s will check often, if service is unhealthy then K8s will restart it +export class HealthChecker { + private checkers: HealthCheckFn[] = []; + constructor(port: number = DEFAULT_HEALTH_PORT) { + Bun.serve({ + port, + routes: { + "/health": async () => { + await Promise.all(this.checkers.map((fn) => fn())); + return new Response("ok"); + }, + }, + }); + } + + // To report unhealthy, just throw error + public addChecker(fn: HealthCheckFn): void { + this.checkers.push(fn); + } +} diff --git a/apps/long-short-backend/src/index.ts b/apps/long-short-backend/src/index.ts new file mode 100644 index 0000000..43ac624 --- /dev/null +++ b/apps/long-short-backend/src/index.ts @@ -0,0 +1,50 @@ +import { RustModule } from "@minswap/felis-ledger-utils"; +import { createApiServer } from "./api/server"; +import { loadMarketConfigs } from "./config/market"; +import { newKyselyClient } from "./database/postgres"; +import type { DB } from "./database"; +import { logger } from "./utils"; + +const API_PORT = Number(process.env.API_PORT) || 9999; +const API_HOST = process.env.API_HOST || "0.0.0.0"; +const DATABASE_URL = process.env.DATABASE_URL; + +async function main() { + // Validate environment + if (!DATABASE_URL) { + throw new Error("DATABASE_URL environment variable is required"); + } + + // Load WASM modules + logger.info("Loading WASM modules..."); + await RustModule.load(); + logger.info("WASM modules loaded"); + + // Connect to database + logger.info("Connecting to database..."); + const db = await newKyselyClient(DATABASE_URL); + logger.info("Database connected"); + + // Load market configs from database + logger.info("Loading market configs..."); + const marketConfigs = await loadMarketConfigs(db); + logger.info(`Loaded ${marketConfigs.size} market configs`); + + // Start API server + logger.info("Starting API server..."); + await createApiServer({ + port: API_PORT, + host: API_HOST, + db, + }); + + logger.info("Long-Short Backend started successfully", { + port: API_PORT, + host: API_HOST, + }); +} + +main().catch((error) => { + logger.error("Failed to start application", { error }); + process.exit(1); +}); diff --git a/apps/long-short-backend/src/provider/index.ts b/apps/long-short-backend/src/provider/index.ts new file mode 100644 index 0000000..7840d0d --- /dev/null +++ b/apps/long-short-backend/src/provider/index.ts @@ -0,0 +1 @@ +export * from "./kupo"; diff --git a/apps/long-short-backend/src/provider/kupo.ts b/apps/long-short-backend/src/provider/kupo.ts new file mode 100644 index 0000000..fb8798d --- /dev/null +++ b/apps/long-short-backend/src/provider/kupo.ts @@ -0,0 +1,352 @@ +import { Address, type Asset, type Bytes, type KupoUtxo, type TxIn, Utxo, Value } from "@minswap/felis-ledger-core"; +import { type CborHex, type CSLTransactionUnspentOutput, Maybe } from "@minswap/felis-ledger-utils"; +import invariant from "@minswap/tiny-invariant"; +import * as R from "remeda"; +import { logger, uniq } from "../utils"; + +const KUPO_MAX_REQUEST = 100; + +const DATUM_REGEX = /^[0-9a-fA-F]{64}$/; + +export type KupoHealthResponse = { + connection_status: "connected" | "disconnected"; + most_recent_checkpoint: number | null; + most_recent_node_tip: number | null; + version: string; + network_synchronization?: number; // new from v2.11.0+de9c52 +}; + +export type KupoHealth = { + connectionStatus: "connected" | "disconnected"; + isHealthy: boolean; + latestSyncedSlot: number; + latestNodeSyncedSlot: number; + syncPercent: number; + version: string; +}; + +export type KupoUtxosResponse = { + transaction_id: string; + transaction_index: bigint; + output_index: bigint; + address: string; + value: { + coins: bigint; + assets?: Record; + }; + datum_hash: string | null; + datum_type?: "inline" | "hash" | null; + script_hash: string | null; + created_at: { + slot_no: bigint; + header_hash: string; + }; + spent_at: { + slot_no: bigint; + header_hash: string; + } | null; +}; + +export class KupoService { + static readonly MAX_REQUEST_THRESHOLD = 20; + kupoBaseUrl: string; + + constructor(kupoBaseUrl: string) { + this.kupoBaseUrl = kupoBaseUrl; + } + + static async new(kupoBaseUrl: string, healthCheck?: boolean): Promise { + const kupoService = new KupoService(kupoBaseUrl); + if (healthCheck) { + const health = await kupoService.health(); + logger.info("Kupo service health", { connection_status: health.connectionStatus }); + if (health.connectionStatus === "disconnected") { + throw new Error("Kupo is not connected"); + } else if (!health.isHealthy) { + throw new Error("Kupo is not healthy"); + } + } + return kupoService; + } + + async getCurrentSlot(): Promise { + const health = await this.health(); + return health.latestNodeSyncedSlot; + } + + async utxosByAddress(addresses: Address[]): Promise { + let utxos: KupoUtxo[] = []; + for (let i = 0; i < addresses.length; i += KUPO_MAX_REQUEST) { + const queryAddresses = addresses.slice(i, i + KUPO_MAX_REQUEST); + const tasks: Promise[] = []; + for (const address of queryAddresses) { + const matches = `${address.bech32}?unspent`; + tasks.push(this.getUtxosByMatches(matches)); + } + const queriedUtxos = await Promise.all(tasks); + utxos = utxos.concat(queriedUtxos.flat()); + } + return await this.parseUtxo(utxos); + } + + async utxosRawByAddress(addresses: Address): Promise[]> { + const utxos = await this.utxosByAddress([addresses]); + return utxos.map(Utxo.toHex); + } + + async utxosByTxIn(...txIns: TxIn[]): Promise { + const utxos = await this.kupoUtxosByTxIns(...txIns); + const sdkUtxos = await this.parseUtxo(utxos); + return sdkUtxos; + } + + async kupoUtxosByTxHash(txHash: string): Promise { + const matches = `*@${txHash}?unspent`; + return await this.getUtxosByMatches(matches); + } + + async kupoUtxosByTxIn(txIn: TxIn): Promise { + const matches = `${txIn.index}@${txIn.txId.hex}?unspent`; + return await this.getUtxosByMatches(matches); + } + + async kupoUtxosByTxIns(...txIns: TxIn[]): Promise { + const utxos: KupoUtxo[] = []; + for (let i = 0; i < txIns.length; i += KUPO_MAX_REQUEST) { + const queryTxIns = txIns.slice(i, i + KUPO_MAX_REQUEST); + const tasks: Promise[] = []; + for (const txIn of queryTxIns) { + tasks.push(this.kupoUtxosByTxIn(txIn)); + } + const queriedUtxos = await Promise.all(tasks); + utxos.push(...queriedUtxos.flat()); + } + return utxos; + } + + async kupoUtxosByAddress(address: Address): Promise { + const matches = `${address.bech32}?unspent`; + return await this.getUtxosByMatches(matches); + } + + async kupoUtxosByAddresses(addresses: Address[]): Promise { + if (addresses.length === 0) { + return []; + } + + let utxos: KupoUtxo[] = []; + for (let i = 0; i < addresses.length; i += KUPO_MAX_REQUEST) { + const queryAddresses = addresses.slice(i, i + KUPO_MAX_REQUEST); + const tasks: Promise[] = []; + for (const address of queryAddresses) { + tasks.push(this.kupoUtxosByAddress(address)); + } + const queriedUtxos = await Promise.all(tasks); + utxos = utxos.concat(queriedUtxos.flat()); + } + + return utxos; + } + + async kupoUtxosByAsset(asset: Asset): Promise { + try { + const matches = `${asset.currencySymbol.hex}.${asset.tokenName.hex}?unspent`; + return await this.getUtxosByMatches(matches); + } catch (_) { + logger.error(`fail to fetch utxos for asset ${asset.toString()}`); + return []; + } + } + + async kupoUtxosByAssets(assets: Asset[]): Promise { + if (assets.length === 0) { + return []; + } + const chunks = R.chunk(assets, KupoService.MAX_REQUEST_THRESHOLD); + let utxos: KupoUtxo[] = []; + for (const chunk of chunks) { + const inner = await Promise.all(chunk.map((a) => this.kupoUtxosByAsset(a))); + utxos = utxos.concat(inner.flat()); + } + return utxos; + } + + async kupoUtxosByPolicyID(policyID: Bytes): Promise { + const matches = `${policyID.hex}.*?unspent`; + return await this.getUtxosByMatches(matches); + } + + async health(): Promise { + const kupoUrl = `${this.kupoBaseUrl}/health`; + const fetchRes = await fetch(kupoUrl, { + headers: { Accept: "application/json;charset=utf-8" }, + }); + const response = (await fetchRes.json()) as KupoHealthResponse; + + // We assume slot won't exceed MAX_SAFE_INTEGER + const latestSyncedSlot = response.most_recent_checkpoint ?? 0; + const latestNodeSyncedSlot = response.most_recent_node_tip ?? 0; + const syncPercent = latestNodeSyncedSlot > 0 ? latestSyncedSlot / latestNodeSyncedSlot : 0; + const isHealthy = latestSyncedSlot === latestNodeSyncedSlot; + return { + connectionStatus: response.connection_status, + isHealthy: isHealthy, + latestSyncedSlot: latestSyncedSlot, + latestNodeSyncedSlot: latestNodeSyncedSlot, + syncPercent: syncPercent, + version: response.version, + }; + } + + async getUtxosByMatches(matches: string): Promise { + const kupoUrl = `${this.kupoBaseUrl}/matches/${matches}`; + const fetchRes = await fetch(kupoUrl); + const data = (await fetchRes.json()) as KupoUtxosResponse[]; + return data.map((d) => ({ + ...d, + transaction_index: Number(d.transaction_index.toString()), + output_index: Number(d.output_index.toString()), + })); + } + + async getUtxosByPaymentCredential(paymentCredential: Bytes): Promise { + const matches = `${paymentCredential.hex}/*?unspent`; + const kupoUtxos = await this.getUtxosByMatches(matches); + const sdkUtxos = await this.parseUtxo(kupoUtxos.flat()); + return sdkUtxos; + } + + async getKupoUtxosByPaymentCredential(paymentCredential: Bytes): Promise { + const matches = `${paymentCredential.hex}/*?unspent`; + return await this.getUtxosByMatches(matches); + } + + async getKupoUtxosByPaymentCredentials(paymentCredentials: Bytes[]): Promise { + if (paymentCredentials.length === 0) { + return []; + } + + let utxos: KupoUtxo[] = []; + for (let i = 0; i < paymentCredentials.length; i += KUPO_MAX_REQUEST) { + const queryCredentials = paymentCredentials.slice(i, i + KUPO_MAX_REQUEST); + const tasks: Promise[] = []; + for (const credential of queryCredentials) { + tasks.push(this.getKupoUtxosByPaymentCredential(credential)); + } + const queriedUtxos = await Promise.all(tasks); + utxos = utxos.concat(queriedUtxos.flat()); + } + + return utxos; + } + + private isValidDatumHash(hash: string): boolean { + // Check if the hash is a valid base16 string + return DATUM_REGEX.test(hash); + } + + async getDatum(datumHash: string): Promise { + if (!this.isValidDatumHash(datumHash)) { + return null; + } + const kupoUrl = `${this.kupoBaseUrl}/datums/${datumHash}`; + const fetchRes = await fetch(kupoUrl); + const data = (await fetchRes.json()) as { datum: string } | null; + return data ? data.datum : null; + } + + async getDatums(datumHashes: string[]): Promise> { + if (datumHashes.length === 0) { + return {}; + } + const uniqueDatumHashes = uniq(datumHashes); + const mapDatum: Record = {}; + for (let i = 0; i < uniqueDatumHashes.length; i += KUPO_MAX_REQUEST) { + const queryDatumHashes = uniqueDatumHashes.slice(i, i + KUPO_MAX_REQUEST); + const tasks: Promise[] = []; + for (const datumHash of queryDatumHashes) { + tasks.push(this.getDatum(datumHash)); + } + const datums: (string | null)[] = await Promise.all(tasks); + for (let i = 0; i < queryDatumHashes.length; i++) { + const datum = datums[i]; + if (datum) { + mapDatum[queryDatumHashes[i]] = datum; + } + } + } + return mapDatum; + } + + async getDatumHashByAssets(...assets: Asset[]): Promise> { + if (assets.length === 0) { + return {}; + } + const mapDatumHash: Record = {}; + for (let i = 0; i < assets.length; i += KUPO_MAX_REQUEST) { + const queryAssets = assets.slice(i, i + KUPO_MAX_REQUEST); + const tasks: Promise[] = []; + for (const asset of queryAssets) { + tasks.push(this.kupoUtxosByAsset(asset)); + } + const queriedUtxos = await Promise.all(tasks); + for (let i = 0; i < queryAssets.length; i++) { + const utxos = queriedUtxos[i]; + if (utxos.length > 0 && utxos[0].datum_hash) { + mapDatumHash[queryAssets[i].toString()] = utxos[0].datum_hash; + } + } + } + return mapDatumHash; + } + + private async parseUtxo(kupoUtxos: KupoUtxo[]): Promise { + const datumHashes = kupoUtxos.filter((u) => u.datum_hash && u.datum_type).map((u) => u.datum_hash) as string[]; + + if (!datumHashes.length) { + return kupoUtxos.map((u) => Utxo.fromKupo(u)); + } + const mapDatum: Record = await this.getDatums(datumHashes); + const utxos: Utxo[] = []; + for (const u of kupoUtxos) { + /** + * In Kupo, inline datums are not stored in the UTXO, so we need to fetch them separately. + */ + if (u.datum_hash && u.datum_type === "inline") { + const datumRaw = mapDatum[u.datum_hash]; + invariant(datumRaw, `InlineDatum requires a valid datum, not found datum for ${u.datum_hash}`); + utxos.push(Utxo.fromKupo(u, datumRaw)); + } else if (u.datum_hash && u.datum_type === "hash") { + utxos.push(Utxo.fromKupo(u, mapDatum[u.datum_hash])); + } else { + utxos.push(Utxo.fromKupo(u)); + } + } + return utxos; + } + + async getBalance(...addresses: Address[]): Promise { + return Utxo.sumValue(await this.utxosByAddress(addresses)); + } + + async getBalanceOfPubKeyAddress(address: string): Promise { + const matches = `${address}?unspent`; + const kupoUtxos = await this.getUtxosByMatches(matches); + const balance = new Value(); + for (const utxo of kupoUtxos) { + const address = Address.fromBech32(utxo.address); + if (Maybe.isJust(address.toScriptHash())) { + continue; + } + const utxoVal = Value.fromKupo(utxo.value); + balance.addAll(utxoVal); + } + return balance; + } + + async utxosAtWithAsset(address: Address, asset: Asset): Promise { + const matches = `${address.bech32}?unspent&policy_id=${asset.currencySymbol.hex}${asset.tokenName.hex ? `&asset_name=${asset.tokenName.hex}` : ""}`; + const utxos: KupoUtxo[] = await this.getUtxosByMatches(matches); + return await this.parseUtxo(utxos); + } +} diff --git a/apps/long-short-backend/src/repository/index.ts b/apps/long-short-backend/src/repository/index.ts new file mode 100644 index 0000000..55dc146 --- /dev/null +++ b/apps/long-short-backend/src/repository/index.ts @@ -0,0 +1,3 @@ +export * from "./position-repository"; +export * from "./redis-repo"; +export * from "./repository"; diff --git a/apps/long-short-backend/src/repository/position-repository.ts b/apps/long-short-backend/src/repository/position-repository.ts new file mode 100644 index 0000000..b8dd575 --- /dev/null +++ b/apps/long-short-backend/src/repository/position-repository.ts @@ -0,0 +1,199 @@ +import type { Kysely, Transaction } from "kysely"; +import type { DB } from "../database"; + +export type PositionStatus = "OPEN" | "CLOSED" | "LIQUIDATED"; +export type PositionSide = "LONG" | "SHORT"; + +export type CreatePositionParams = { + userAddress: string; + market: string; + side: PositionSide; + leverage: string; + collateralAsset: string; + collateralAmount: string; + entryPrice: string; + positionSize: string; + borrowedAmount: string; + liquidationPrice: string; + takeProfitPrice?: string; + stopLossPrice?: string; + liqwidSupplyId?: string; + liqwidBorrowId?: string; +}; + +export type Position = { + id: bigint; + userAddress: string; + market: string; + side: PositionSide; + status: PositionStatus; + leverage: string; + collateralAsset: string; + collateralAmount: string; + entryPrice: string; + positionSize: string; + borrowedAmount: string; + liquidationPrice: string; + takeProfitPrice: string | null; + stopLossPrice: string | null; + realizedPnl: string; + unrealizedPnl: string; + fundingPaid: string; + liqwidSupplyId: string | null; + liqwidBorrowId: string | null; + createdAt: Date; + updatedAt: Date; + closedAt: Date | null; +}; + +export namespace PositionRepository { + export async function createPosition( + db: Kysely | Transaction, + params: CreatePositionParams, + ): Promise { + const result = await db + .insertInto("position") + .values({ + user_address: params.userAddress, + market: params.market, + side: params.side, + status: "OPEN", + leverage: params.leverage, + collateral_asset: params.collateralAsset, + collateral_amount: params.collateralAmount, + entry_price: params.entryPrice, + position_size: params.positionSize, + borrowed_amount: params.borrowedAmount, + liquidation_price: params.liquidationPrice, + take_profit_price: params.takeProfitPrice ?? null, + stop_loss_price: params.stopLossPrice ?? null, + liqwid_supply_id: params.liqwidSupplyId ?? null, + liqwid_borrow_id: params.liqwidBorrowId ?? null, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + return mapPositionRow(result); + } + + export async function getPositionById( + db: Kysely | Transaction, + id: bigint, + ): Promise { + const result = await db + .selectFrom("position") + .selectAll() + .where("id", "=", id.toString()) + .executeTakeFirst(); + + return result ? mapPositionRow(result) : null; + } + + export async function getOpenPositionByUserAndMarket( + db: Kysely | Transaction, + userAddress: string, + market: string, + ): Promise { + const result = await db + .selectFrom("position") + .selectAll() + .where("user_address", "=", userAddress) + .where("market", "=", market) + .where("status", "=", "OPEN") + .executeTakeFirst(); + + return result ? mapPositionRow(result) : null; + } + + export async function getUserOpenPositions( + db: Kysely | Transaction, + userAddress: string, + ): Promise { + const results = await db + .selectFrom("position") + .selectAll() + .where("user_address", "=", userAddress) + .where("status", "=", "OPEN") + .orderBy("created_at", "desc") + .execute(); + + return results.map(mapPositionRow); + } + + export async function getUserPositions( + db: Kysely | Transaction, + userAddress: string, + options?: { status?: PositionStatus; limit?: number; offset?: number }, + ): Promise { + let query = db + .selectFrom("position") + .selectAll() + .where("user_address", "=", userAddress); + + if (options?.status) { + query = query.where("status", "=", options.status); + } + + query = query.orderBy("created_at", "desc"); + + if (options?.limit) { + query = query.limit(options.limit); + } + + if (options?.offset) { + query = query.offset(options.offset); + } + + const results = await query.execute(); + return results.map(mapPositionRow); + } + + export async function updatePositionStatus( + db: Kysely | Transaction, + id: bigint, + status: PositionStatus, + ): Promise { + const updates: Record = { + status, + updated_at: new Date(), + }; + + if (status === "CLOSED" || status === "LIQUIDATED") { + updates.closed_at = new Date(); + } + + await db + .updateTable("position") + .set(updates) + .where("id", "=", id.toString()) + .execute(); + } + + // biome-ignore lint/suspicious/noExplicitAny: DB row type + function mapPositionRow(row: any): Position { + return { + id: BigInt(row.id), + userAddress: row.user_address, + market: row.market, + side: row.side as PositionSide, + status: row.status as PositionStatus, + leverage: row.leverage, + collateralAsset: row.collateral_asset, + collateralAmount: row.collateral_amount, + entryPrice: row.entry_price, + positionSize: row.position_size, + borrowedAmount: row.borrowed_amount, + liquidationPrice: row.liquidation_price, + takeProfitPrice: row.take_profit_price, + stopLossPrice: row.stop_loss_price, + realizedPnl: row.realized_pnl, + unrealizedPnl: row.unrealized_pnl, + fundingPaid: row.funding_paid, + liqwidSupplyId: row.liqwid_supply_id, + liqwidBorrowId: row.liqwid_borrow_id, + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + closedAt: row.closed_at ? new Date(row.closed_at) : null, + }; + } +} diff --git a/apps/long-short-backend/src/repository/redis-repo.ts b/apps/long-short-backend/src/repository/redis-repo.ts new file mode 100644 index 0000000..a83967d --- /dev/null +++ b/apps/long-short-backend/src/repository/redis-repo.ts @@ -0,0 +1,61 @@ +import type * as OgmiosSchema from "@cardano-ogmios/schema"; +import type Redis from "ioredis"; + +export enum RedisKey { + INTERSECTION_CANDIDATES = "intersection_candidates", + LAST_SYNC_SLOT = "last_sync_slot", +} + +export namespace RedisRepo { + export const getIntersectionCandidates = async (redis: Redis) => { + const data = await redis.lrange(RedisKey.INTERSECTION_CANDIDATES, 0, -1); + if (!data) { + return []; + } + return data.map((d) => JSON.parse(d)); + }; + + export const rollbackIntersectionCandidates = async (redis: Redis, point: OgmiosSchema.PointOrOrigin) => { + if (point === "origin") { + await redis.del(RedisKey.INTERSECTION_CANDIDATES); + return; + } + const slot = point.slot; + const intersectionCandidates = await RedisRepo.getIntersectionCandidates(redis); + let pop_count = 0; + let foundIntersectionCandidate = false; + for (let index = 0; index < intersectionCandidates.length; index++) { + if (intersectionCandidates[index].slot <= slot) { + pop_count = index; + foundIntersectionCandidate = true; + break; + } + } + if (!foundIntersectionCandidate) { + await redis.del(RedisKey.INTERSECTION_CANDIDATES); + } else { + if (pop_count > 0) { + await redis.lpop(RedisKey.INTERSECTION_CANDIDATES, pop_count); + } + } + }; + + export const pushIntersectionCandidate = async (redis: Redis, block: { slot: number; id: string }) => { + const key = RedisKey.INTERSECTION_CANDIDATES; + const currentLength = await redis.llen(key); + const multi = redis.multi(); + if (currentLength >= 2160) { + multi.rpop(key); + } + const point: OgmiosSchema.Point = { + slot: block.slot, + id: block.id, + }; + multi.lpush(key, JSON.stringify(point)); + await multi.exec(); + }; + + export const set = (redis: Redis, key: RedisKey, value: string | number) => { + return redis.set(key, value); + }; +} diff --git a/apps/long-short-backend/src/repository/repository.ts b/apps/long-short-backend/src/repository/repository.ts new file mode 100644 index 0000000..6772dca --- /dev/null +++ b/apps/long-short-backend/src/repository/repository.ts @@ -0,0 +1,6 @@ +// This file is reserved for future blockchain syncer repository functions +// Currently, the long-short-backend only uses position-repository.ts + +export namespace Repository { + // Placeholder for future blockchain syncing functions +} diff --git a/apps/long-short-backend/src/services/index.ts b/apps/long-short-backend/src/services/index.ts new file mode 100644 index 0000000..99f4f45 --- /dev/null +++ b/apps/long-short-backend/src/services/index.ts @@ -0,0 +1 @@ +export { PositionService, type CreatePositionInput, type CreatePositionResult } from "./position-service"; diff --git a/apps/long-short-backend/src/services/position-service.ts b/apps/long-short-backend/src/services/position-service.ts new file mode 100644 index 0000000..ccc1e4e --- /dev/null +++ b/apps/long-short-backend/src/services/position-service.ts @@ -0,0 +1,106 @@ +import type { Kysely } from "kysely"; +import { getMarketConfig, isSupportedMarket, type MarketConfig } from "../config/market"; +import type { DB } from "../database"; +import { type Position, PositionRepository } from "../repository/position-repository"; + +export type CreatePositionInput = { + userAddress: string; + market: string; + side: "LONG" | "SHORT"; + amount: bigint; // collateral amount in lovelace +}; + +export type CreatePositionResult = + | { success: true; position: Position } + | { success: false; error: string }; + +export class PositionService { + constructor(private readonly db: Kysely) {} + + async createPosition(input: CreatePositionInput): Promise { + const { userAddress, market, side, amount } = input; + + // Validate market + if (!isSupportedMarket(market)) { + return { success: false, error: `Market "${market}" is not supported` }; + } + + const marketConfig = getMarketConfig(market); + if (!marketConfig) { + return { success: false, error: `Market "${market}" configuration not found` }; + } + + if (!marketConfig.enable) { + return { success: false, error: `Market "${market}" is currently disabled` }; + } + + // Validate minimum collateral + if (amount < marketConfig.minCollateral) { + const minLovelace = marketConfig.minCollateral; + return { + success: false, + error: `Minimum collateral is ${minLovelace} lovelace`, + }; + } + + // Check for existing open position in this market + const existingPosition = await PositionRepository.getOpenPositionByUserAndMarket( + this.db, + userAddress, + market, + ); + + if (existingPosition) { + return { + success: false, + error: `You already have an open position in market "${market}". Close it first or modify the existing position.`, + }; + } + + // For now, create a pending position + // In a full implementation, this would: + // 1. Calculate entry price from DEX + // 2. Calculate position size based on leverage + // 3. Interact with Liqwid to supply collateral and borrow + // 4. Execute swap on Minswap DEX + + const position = await PositionRepository.createPosition(this.db, { + userAddress, + market, + side, + leverage: marketConfig.leverage.toString(), + collateralAsset: marketConfig.assetA.toString(), + collateralAmount: amount.toString(), + entryPrice: "0", // TODO: Get from price oracle + positionSize: amount.toString(), // TODO: Calculate based on leverage + borrowedAmount: "0", // TODO: Calculate based on leverage + liquidationPrice: "0", // TODO: Calculate liquidation price + }); + + return { success: true, position }; + } + + async getPosition(positionId: bigint): Promise { + return PositionRepository.getPositionById(this.db, positionId); + } + + async getUserOpenPositions(userAddress: string): Promise { + return PositionRepository.getUserOpenPositions(this.db, userAddress); + } + + async getUserPositions( + userAddress: string, + options?: { status?: "OPEN" | "CLOSED" | "LIQUIDATED"; limit?: number; offset?: number }, + ): Promise { + return PositionRepository.getUserPositions(this.db, userAddress, options); + } + + async hasOpenPosition(userAddress: string, market: string): Promise { + const position = await PositionRepository.getOpenPositionByUserAndMarket( + this.db, + userAddress, + market, + ); + return position !== null; + } +} diff --git a/apps/long-short-backend/src/utils/common.ts b/apps/long-short-backend/src/utils/common.ts new file mode 100644 index 0000000..30423bd --- /dev/null +++ b/apps/long-short-backend/src/utils/common.ts @@ -0,0 +1,60 @@ +import type * as OgmiosSchema from "@cardano-ogmios/schema"; +import { parseIntSafe } from "@minswap/felis-ledger-utils"; +import invariant from "@minswap/tiny-invariant"; +import { logger } from "./logger"; + +export enum EnvKey { + DATABASE_URL = "DATABASE_URL", + OGMIOS_HOST = "OGMIOS_HOST", + REDIS_URL = "REDIS_URL", + SYNCER_START_POINT = "SYNCER_START_POINT", +} + +export function parseHostPort(env: string): { host: string; port: number } { + const parts = env.split(":"); + invariant(parts.length === 2, `expect format host:port, get ${env}`); + return { + host: parts[0], + port: parseIntSafe(parts[1]), + }; +} + +export function getEnvOr(key: string, defaultValue: string): string { + const val = process.env[key]; + if (!val) { + return defaultValue; + } + return val; +} + +export function getEnv(key: string): string { + const val = process.env[key]; + if (!val) { + logger.error(`Require environment variable ${key}`); + throw new Error("Server init error"); + } + return val; +} + +export function getEnvOptional(key: string): string | undefined { + return process.env[key]; +} + +export function parsePointOrOrigin(env: string): OgmiosSchema.PointOrOrigin { + if (env === "origin") { + return env; + } + return parsePoint(env); +} + +export function parsePoint(env: string): OgmiosSchema.Point { + try { + const parts = env.split("."); + return { + id: parts[0], + slot: parseIntSafe(parts[1]), + }; + } catch (_err) { + throw new Error(`fail to parse point, expect format $headerHash.$slot, got ${env}`); + } +} diff --git a/apps/long-short-backend/src/utils/expiring-variable.ts b/apps/long-short-backend/src/utils/expiring-variable.ts new file mode 100644 index 0000000..f6a178c --- /dev/null +++ b/apps/long-short-backend/src/utils/expiring-variable.ts @@ -0,0 +1,22 @@ +import type { Duration } from "@minswap/felis-ledger-utils"; + +type TimeoutType = ReturnType; + +export class ExpiringVariable { + private _value: T | null = null; + private expirationTimer?: TimeoutType; + + set(newVal: T, ttl: Duration): void { + this._value = newVal; + if (this.expirationTimer) { + clearTimeout(this.expirationTimer); + } + this.expirationTimer = setTimeout(() => { + this._value = null; + }, ttl.milliseconds); + } + + get value(): T | null { + return this._value; + } +} diff --git a/apps/long-short-backend/src/utils/index.ts b/apps/long-short-backend/src/utils/index.ts new file mode 100644 index 0000000..92aeeab --- /dev/null +++ b/apps/long-short-backend/src/utils/index.ts @@ -0,0 +1,5 @@ +export * from "./common"; +export * from "./expiring-variable"; +export * from "./lodash"; +export * from "./logger"; +export * from "./signature"; diff --git a/apps/long-short-backend/src/utils/lodash.ts b/apps/long-short-backend/src/utils/lodash.ts new file mode 100644 index 0000000..29355c5 --- /dev/null +++ b/apps/long-short-backend/src/utils/lodash.ts @@ -0,0 +1,36 @@ +export function uniq(arr: T[]): T[] { + const seen = new Set(); + const result: T[] = []; + + for (const item of arr) { + if (!seen.has(item)) { + seen.add(item); + result.push(item); + } + } + + return result; +} + +export function uniqBy(arr: T[], keySelector: ((item: T) => Key) | keyof T): T[] { + const seen = new Set(); + const result: T[] = []; + + let selector: (item: T) => Key; + if (typeof keySelector === "function") { + selector = keySelector; + } else { + selector = (v: T): Key => v[keySelector] as Key; + } + + for (const item of arr) { + const key = selector(item); + + if (!seen.has(key)) { + seen.add(key); + result.push(item); + } + } + + return result; +} diff --git a/apps/long-short-backend/src/utils/logger.ts b/apps/long-short-backend/src/utils/logger.ts new file mode 100644 index 0000000..67c6fbd --- /dev/null +++ b/apps/long-short-backend/src/utils/logger.ts @@ -0,0 +1,139 @@ +import util from "node:util"; + +enum LogLevel { + ERROR = "ERROR", + WARN = "WARN", + INFO = "INFO", +} + +// biome-ignore lint/suspicious/noExplicitAny: skip +type LogFunc = (message: string, extra?: Record | unknown) => void; + +type Logger = { + error: LogFunc; + warn: LogFunc; + info: LogFunc; + // biome-ignore lint/suspicious/noExplicitAny: skip + prettyJson: (...message: any) => void; +}; + +// Example: 2023-10-18 16:01:45.012 +function formatDateDevEnv(d: Date): string { + function pad0s(x: number, length = 2): string { + let s = x.toString(); + while (s.length < length) { + s = `0${s}`; + } + return s; + } + + return `${d.getFullYear()}-${pad0s(d.getMonth() + 1)}-${pad0s(d.getDate())} ${pad0s(d.getHours())}:${pad0s( + d.getMinutes(), + )}:${pad0s(d.getSeconds())}.${pad0s(d.getMilliseconds(), 3)}`; +} + +function formatLevelDevEnv(lvl: LogLevel): string { + const padLevel = lvl.padEnd(8); + let coloredLevel: string; + switch (lvl) { + case LogLevel.ERROR: + coloredLevel = `\x1b[31m${padLevel}\x1b[0m`; + break; + case LogLevel.WARN: + coloredLevel = `\x1b[33m${padLevel}\x1b[0m`; + break; + case LogLevel.INFO: + // default color + coloredLevel = padLevel; + break; + } + return coloredLevel; +} + +// biome-ignore lint/suspicious/noExplicitAny: skip +function formatLabelsDevEnv(labels: Record): string { + return Object.entries(labels) + .map(([k, v]) => ` ${k}=${v}`) + .join("\n"); +} + +// biome-ignore lint/suspicious/noExplicitAny: skip +export function shouldUseUtilInspectOnValue(val: any): boolean { + if (val instanceof Error) { + return true; + } + if (typeof val === "object" && val !== null && typeof val.toString === "function") { + const str = val.toString(); + if (typeof str === "string" && str.startsWith("[object")) { + return true; + } + } + return false; +} + +// biome-ignore lint/suspicious/noExplicitAny: skip +function log(level: LogLevel, _message: string, extra?: Record | unknown): void { + // build labels object + // biome-ignore lint/suspicious/noExplicitAny: skip + const labels: Record = { + time: new Date().toISOString(), + level, + message: _message.trim(), + }; + if (extra instanceof Error) { + labels.error = extra; + } else if (typeof extra === "object" && extra !== null) { + Object.assign(labels, extra); + } else if (extra !== null && extra !== undefined) { + labels.extra = extra; + } + + for (const key in labels) { + if (shouldUseUtilInspectOnValue(labels[key])) { + labels[key] = util.inspect(labels[key], { + colors: true, + numericSeparator: true, + }); + } + } + + // format log line + let str: string; + const printedLabels = formatLabelsDevEnv(labels); + const separator = labels.message && printedLabels ? "\n" : ""; + str = `${formatDateDevEnv(new Date())} ${formatLevelDevEnv(level)} ${labels.message}${separator}${printedLabels}`; + + switch (level) { + case LogLevel.ERROR: + case LogLevel.WARN: + // write to stderr + console.error(str); + break; + case LogLevel.INFO: + // write to stdout + console.info(str); + break; + } +} + +export const logger: Logger = { + error: (message, extra) => log(LogLevel.ERROR, message, extra), + warn: (message, extra) => log(LogLevel.WARN, message, extra), + info: (message, extra) => log(LogLevel.INFO, message, extra), + prettyJson(...message) { + for (const msg of message) { + if (typeof msg === "object" && msg !== null) { + const jsonObj = JSON.parse(JSON.stringify(msg)); + console.log( + util.inspect(jsonObj, { + colors: true, + numericSeparator: true, + depth: Number.POSITIVE_INFINITY, + }), + ); + } else { + console.log(msg); + } + } + }, +}; diff --git a/apps/long-short-backend/src/utils/signature.ts b/apps/long-short-backend/src/utils/signature.ts new file mode 100644 index 0000000..eac51ba --- /dev/null +++ b/apps/long-short-backend/src/utils/signature.ts @@ -0,0 +1,127 @@ +import { Address, PrivateKey } from "@minswap/felis-ledger-core"; +import { RustModule } from "@minswap/felis-ledger-utils"; +import * as CMS from "@emurgo/cardano-message-signing-nodejs"; + +export type VerifySignDataOptions = { + message: string; // The original message that was signed (hex encoded) + address: string; // User's Cardano address (bech32) + key: string; // CBOR hex of COSEKey + signature: string; // CBOR hex of COSESign1 +}; + +/** + * Verify a CIP-8/CIP-30 message signature + * + * Based on: + * - https://github.com/Emurgo/message-signing/blob/master/examples/rust/src/main.rs + * - https://github.com/input-output-hk/nami/blob/main/MessageSigning.md + * + * @returns true if signature is valid + */ +export function verifySignData(options: VerifySignDataOptions): boolean { + const { message, address, key, signature } = options; + + try { + const CSL = RustModule.getE; + + // 1. Parse the COSESign1 message + const coseSign1 = CMS.COSESign1.from_bytes(Buffer.from(signature, "hex")); + + // 2. Parse the COSEKey and extract public key using label -2 (COSE key identifier for EC2 x-coordinate) + const coseKey = CMS.COSEKey.from_bytes(Buffer.from(key, "hex")); + const pubKeyBytes = coseKey + .header(CMS.Label.new_int(CMS.Int.new_negative(CMS.BigNum.from_str("2")))) + ?.as_bytes(); + + if (!pubKeyBytes) { + return false; + } + + const publicKey = CSL.PublicKey.from_bytes(pubKeyBytes); + + // 3. Verify the payload matches the expected message + const payload = coseSign1.payload(); + if (!payload) { + return false; + } + + // Compare payload with expected message + const payloadHex = Buffer.from(payload).toString("hex"); + if (payloadHex !== message) { + return false; + } + + // 4. Verify the signature + // Get the SigStructure bytes that were originally signed + const signedData = coseSign1.signed_data(undefined, undefined).to_bytes(); + const sig = CSL.Ed25519Signature.from_bytes(coseSign1.signature()); + + const isValidSignature = publicKey.verify(signedData, sig); + if (!isValidSignature) { + return false; + } + + // 5. Verify the public key matches the address + const keyHash = publicKey.hash(); + const addressObj = Address.fromBech32(address); + const addressPubKeyHash = addressObj.toPubKeyHash(); + + if (!addressPubKeyHash) { + return false; + } + + if (keyHash.to_hex() !== addressPubKeyHash.keyHash.hex) { + return false; + } + + return true; + } catch { + return false; + } +} + +export function signData( + privateKey: PrivateKey, + address: string, + payload: string, +): { signature: string; key: string } { + const cslPrivateKey = privateKey.toECSL(); + const cslPublicKey = cslPrivateKey.to_public(); + + // Build protected header with address + const protectedHeaders = CMS.HeaderMap.new(); + protectedHeaders.set_algorithm_id(CMS.Label.from_algorithm_id(CMS.AlgorithmId.EdDSA)); + protectedHeaders.set_header( + CMS.Label.new_text("address"), + CMS.CBORValue.new_bytes(Buffer.from(address, "hex")), + ); + + const protectedSerialized = CMS.ProtectedHeaderMap.new(protectedHeaders); + const unprotectedHeaders = CMS.HeaderMap.new(); + const headers = CMS.Headers.new(protectedSerialized, unprotectedHeaders); + + // Build COSESign1 + const builder = CMS.COSESign1Builder.new(headers, Buffer.from(payload, "hex"), false); + const toSign = builder.make_data_to_sign().to_bytes(); + + // Sign with Ed25519 + const signedSigStructure = cslPrivateKey.sign(toSign).to_bytes(); + const coseSign1 = builder.build(signedSigStructure); + + // Build COSEKey with public key at label -2 + const coseKey = CMS.COSEKey.new(CMS.Label.from_key_type(CMS.KeyType.OKP)); + coseKey.set_algorithm_id(CMS.Label.from_algorithm_id(CMS.AlgorithmId.EdDSA)); + coseKey.set_header( + CMS.Label.new_int(CMS.Int.new_negative(CMS.BigNum.from_str("1"))), // crv = -1 + CMS.CBORValue.new_int(CMS.Int.new_i32(6)), // Ed25519 = 6 + ); + coseKey.set_header( + CMS.Label.new_int(CMS.Int.new_negative(CMS.BigNum.from_str("2"))), // x = -2 (public key) + CMS.CBORValue.new_bytes(cslPublicKey.as_bytes()), + ); + + return { + signature: Buffer.from(coseSign1.to_bytes()).toString("hex"), + key: Buffer.from(coseKey.to_bytes()).toString("hex"), + }; +} diff --git a/apps/long-short-backend/test/sign-data.test.ts b/apps/long-short-backend/test/sign-data.test.ts new file mode 100644 index 0000000..f51fd6f --- /dev/null +++ b/apps/long-short-backend/test/sign-data.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { RustModule } from "@minswap/felis-ledger-utils"; +import { baseAddressWalletFromSeed } from "@minswap/felis-cip"; +import { NetworkEnvironment, PrivateKey } from "@minswap/felis-ledger-core"; +import * as CMS from "@emurgo/cardano-message-signing-nodejs"; +import { verifySignData } from "../src/utils/signature"; + +function getSignMessage(address: string): string { + return Buffer.from(address).toString("hex"); +} + +/** + * Sign data using CIP-8/CIP-30 pattern + * Returns { signature, key } where: + * - signature: CBOR hex of COSESign1 + * - key: CBOR hex of COSEKey + */ +function signData( + privateKey: PrivateKey, + address: string, + payload: string, +): { signature: string; key: string } { + const cslPrivateKey = privateKey.toECSL(); + const cslPublicKey = cslPrivateKey.to_public(); + + // Build protected header with address + const protectedHeaders = CMS.HeaderMap.new(); + protectedHeaders.set_algorithm_id(CMS.Label.from_algorithm_id(CMS.AlgorithmId.EdDSA)); + protectedHeaders.set_header( + CMS.Label.new_text("address"), + CMS.CBORValue.new_bytes(Buffer.from(address, "hex")), + ); + + const protectedSerialized = CMS.ProtectedHeaderMap.new(protectedHeaders); + const unprotectedHeaders = CMS.HeaderMap.new(); + const headers = CMS.Headers.new(protectedSerialized, unprotectedHeaders); + + // Build COSESign1 + const builder = CMS.COSESign1Builder.new(headers, Buffer.from(payload, "hex"), false); + const toSign = builder.make_data_to_sign().to_bytes(); + + // Sign with Ed25519 + const signedSigStructure = cslPrivateKey.sign(toSign).to_bytes(); + const coseSign1 = builder.build(signedSigStructure); + + // Build COSEKey with public key at label -2 + const coseKey = CMS.COSEKey.new(CMS.Label.from_key_type(CMS.KeyType.OKP)); + coseKey.set_algorithm_id(CMS.Label.from_algorithm_id(CMS.AlgorithmId.EdDSA)); + coseKey.set_header( + CMS.Label.new_int(CMS.Int.new_negative(CMS.BigNum.from_str("1"))), // crv = -1 + CMS.CBORValue.new_int(CMS.Int.new_i32(6)), // Ed25519 = 6 + ); + coseKey.set_header( + CMS.Label.new_int(CMS.Int.new_negative(CMS.BigNum.from_str("2"))), // x = -2 (public key) + CMS.CBORValue.new_bytes(cslPublicKey.as_bytes()), + ); + + return { + signature: Buffer.from(coseSign1.to_bytes()).toString("hex"), + key: Buffer.from(coseKey.to_bytes()).toString("hex"), + }; +} + +describe("verifySignData", () => { + beforeAll(async () => { + await RustModule.load(); + }); + + it("should verify a valid signature", () => { + const seed = + "melt enemy surface feed kiss helmet suffer demise toilet insane human refuse park insect lawsuit custom inch spirit throw radio alarm creek chat symptom"; + const wallet = baseAddressWalletFromSeed(seed, NetworkEnvironment.TESTNET_PREVIEW); + + const data = { + "market": "ADA-MIN", + "side": "LONG", + "amount": 500000000 + }; + const message = Buffer.from(JSON.stringify(data)).toString("hex"); + + // Sign the message + const { signature, key } = signData(wallet.paymentKey, wallet.address.bech32, message); + console.log({ + user_address: wallet.address.bech32, + signature, + key, + }) + // Verify + const isValid = verifySignData({ + message, + address: wallet.address.bech32, + key, + signature, + }); + + expect(isValid).toBe(true); + }); + + it("should reject invalid signature", () => { + const seed = + "melt enemy surface feed kiss helmet suffer demise toilet insane human refuse park insect lawsuit custom inch spirit throw radio alarm creek chat symptom"; + const wallet = baseAddressWalletFromSeed(seed, NetworkEnvironment.TESTNET_PREVIEW); + + const message = getSignMessage(wallet.address.bech32); + const { signature, key } = signData(wallet.paymentKey, wallet.address.bech32, message); + + // Tamper with signature + const tamperedSignature = signature.slice(0, -4) + "0000"; + + const isValid = verifySignData({ + message, + address: wallet.address.bech32, + key, + signature: tamperedSignature, + }); + + expect(isValid).toBe(false); + }); + + it("should reject wrong address", () => { + const seed = + "melt enemy surface feed kiss helmet suffer demise toilet insane human refuse park insect lawsuit custom inch spirit throw radio alarm creek chat symptom"; + const wallet = baseAddressWalletFromSeed(seed, NetworkEnvironment.TESTNET_PREVIEW); + + // Use a different seed to get different address + const otherSeed = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; + const otherWallet = baseAddressWalletFromSeed(otherSeed, NetworkEnvironment.TESTNET_PREVIEW); + + const message = getSignMessage(wallet.address.bech32); + const { signature, key } = signData(wallet.paymentKey, wallet.address.bech32, message); + + // Verify with different address should fail + const isValid = verifySignData({ + message, + address: otherWallet.address.bech32, + key, + signature, + }); + + expect(isValid).toBe(false); + }); + + it("should reject wrong message", () => { + const seed = + "melt enemy surface feed kiss helmet suffer demise toilet insane human refuse park insect lawsuit custom inch spirit throw radio alarm creek chat symptom"; + const wallet = baseAddressWalletFromSeed(seed, NetworkEnvironment.TESTNET_PREVIEW); + + const message = getSignMessage(wallet.address.bech32); + const { signature, key } = signData(wallet.paymentKey, wallet.address.bech32, message); + + // Verify with different message should fail + const wrongMessage = Buffer.from("wrong message").toString("hex"); + const isValid = verifySignData({ + message: wrongMessage, + address: wallet.address.bech32, + key, + signature, + }); + + expect(isValid).toBe(false); + }); + + it("should work with authen_token format (signature:key)", () => { + const seed = + "melt enemy surface feed kiss helmet suffer demise toilet insane human refuse park insect lawsuit custom inch spirit throw radio alarm creek chat symptom"; + const wallet = baseAddressWalletFromSeed(seed, NetworkEnvironment.TESTNET_PREVIEW); + + const message = getSignMessage(wallet.address.bech32); + const { signature, key } = signData(wallet.paymentKey, wallet.address.bech32, message); + + // Create authen_token in the format used by the API + const authenToken = `${signature}:${key}`; + const [sig, k] = authenToken.split(":"); + + const isValid = verifySignData({ + message, + address: wallet.address.bech32, + key: k, + signature: sig, + }); + + expect(isValid).toBe(true); + }); + + it("wallet CIP-30 test", () => { + const message = `Hello! How are you?`; + const signedData = { + key: "a401010327200621582050ac53f148ce5ce4d4278db4d6c4187265da319984920c2b9e5e4029b5b7b1bb", + signature: "845846a2012767616464726573735839006f7f6cf2c50c559594c0bf8aecd22e7c6bc5df47c4ed7aa8ef87cb49ad7ffe3cda0cd3175a52bff1e5066a67785c47f3a786b434bdc998eea166686173686564f45348656c6c6f2120486f772061726520796f753f584093f6be1a792f5c1767d9e20ba767bf3787c8d6bb5e34d2b48130288e81f759270f9bf88acc5dd48feb7e8eadec98218ca33ff419cc3e2f65086ca99a4b8f510f", + }; + const isValid = verifySignData({ + message: Buffer.from(message).toString("hex"), + address: "addr_test1qphh7m8jc5x9t9v5czlc4mxj9e7xh3wlglzw674ga7rukjdd0llreksv6vt4554l78jsv6n80pwy0ua8s66rf0wfnrhq73a20h", + key: signedData.key, + signature: signedData.signature, + }); + expect(isValid).toBe(true); + }); +}); diff --git a/apps/long-short-backend/tsconfig.json b/apps/long-short-backend/tsconfig.json new file mode 100644 index 0000000..d640c41 --- /dev/null +++ b/apps/long-short-backend/tsconfig.json @@ -0,0 +1,28 @@ +{ + "ts-node": { + "transpileOnly": true, + "experimentalSpecifierResolution": "node" + }, + "compilerOptions": { + "outDir": "./dist", + "strict": true, + "forceConsistentCasingInFileNames": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ES2022", + "moduleResolution": "bundler", + "declaration": true, + "sourceMap": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "test", "dist", ".config"] +} diff --git a/apps/long-short-backend/vitest.config.mts b/apps/long-short-backend/vitest.config.mts new file mode 100644 index 0000000..c9906de --- /dev/null +++ b/apps/long-short-backend/vitest.config.mts @@ -0,0 +1,22 @@ +// vitest.config.mts +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", // use node env for vitest + include: ["test/**/*.{test,spec}.{ts,tsx}"], + exclude: ["node_modules", "dist"], + }, + define: { + // Force Vitest to think there's no window + "typeof window": '"undefined"', + }, + resolve: { + alias: { + "@minswap/cardano-serialization-lib-browser": "@minswap/cardano-serialization-lib-nodejs", + "@emurgo/cardano-serialization-lib-browser": "@emurgo/cardano-serialization-lib-nodejs", + "@repo/uplc-web": "@repo/uplc-node", + }, + }, +}); diff --git a/docker-compose.yml b/docker-compose.yml index c7f9f1b..c0f829a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,87 @@ -version: "3.8" +x-base-backend: &base-backend + build: + context: . + dockerfile: Dockerfile.backend + extra_hosts: + - "host.docker.internal:host-gateway" + restart: always + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + DATABASE_URL: postgres://postgres:JBNGlQ9wNFLlYWc2mG@postgres:5432/margin + REDIS_URL: redis://default:7obaQyYSDDLDk3ECYA@redis:6379 + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:9999/health || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s services: - web: - build: - context: . - dockerfile: Dockerfile - network_mode: host + # Long-Short Backend API Service + # long-short-backend: + # <<: *base-backend + # build: + # context: . + # dockerfile: Dockerfile.backend + # args: + # APP_NAME: long-short-backend + # ports: + # - "127.0.0.1:9999:9999" + # environment: + # DATABASE_URL: postgres://postgres:JBNGlQ9wNFLlYWc2mG@postgres:5432/margin + # REDIS_URL: redis://default:7obaQyYSDDLDk3ECYA@redis:6379 + # API_PORT: 9999 + # API_HOST: 0.0.0.0 + # command: ["pnpm", "--filter=long-short-backend", "start"] + + # web: + # build: + # context: . + # dockerfile: Dockerfile + # network_mode: host + # environment: + # NODE_ENV: production + # NEXT_PUBLIC_NETWORK_ENV: TESTNET_PREVIEW + # PORT: 3002 + # expose: + # - "3002" + # restart: unless-stopped + + redis: + image: redis:8 + restart: always + command: --requirepass 7obaQyYSDDLDk3ECYA + ports: + - "127.0.0.1:6379:6379" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "7obaQyYSDDLDk3ECYA", "--raw", "incr", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + postgres: + image: postgres:18 + restart: always + ports: + - "127.0.0.1:5432:5432" environment: - NODE_ENV: production - NEXT_PUBLIC_NETWORK_ENV: TESTNET_PREVIEW - PORT: 3002 - expose: - - "3002" - restart: unless-stopped + POSTGRES_PASSWORD: JBNGlQ9wNFLlYWc2mG + POSTGRES_DB: margin + command: ["-c", "max_connections=50"] + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + postgres-data: {} + redis-data: {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5096f5..d5fecd6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,10 +71,122 @@ importers: version: 3.14.0 eslint: specifier: ^9.31.0 - version: 9.31.0 + version: 9.31.0(jiti@2.6.1) + typescript: + specifier: 5.8.2 + version: 5.8.2 + + apps/long-short-backend: + dependencies: + '@cardano-ogmios/client': + specifier: ^6.14.0 + version: 6.14.0 + '@emurgo/cardano-message-signing-nodejs': + specifier: ^1.0.1 + version: 1.1.0 + '@fastify/cors': + specifier: ^10.0.2 + version: 10.1.0 + '@minswap/felis-build-tx': + specifier: workspace:* + version: link:../../packages/minswap-build-tx + '@minswap/felis-cip': + specifier: workspace:* + version: link:../../packages/cip + '@minswap/felis-dex-v1': + specifier: workspace:* + version: link:../../packages/minswap-dex-v1 + '@minswap/felis-dex-v2': + specifier: workspace:* + version: link:../../packages/minswap-dex-v2 + '@minswap/felis-ledger-core': + specifier: workspace:* + version: link:../../packages/ledger-core + '@minswap/felis-ledger-utils': + specifier: workspace:* + version: link:../../packages/ledger-utils + '@minswap/felis-tx-builder': + specifier: workspace:* + version: link:../../packages/tx-builder + '@minswap/tiny-invariant': + specifier: ^1.2.0 + version: 1.2.0 + '@sinclair/typebox': + specifier: ^0.34.33 + version: 0.34.48 + '@types/bun': + specifier: ^1.3.5 + version: 1.3.8 + bignumber.js: + specifier: ^9.1.2 + version: 9.3.1 + bip39: + specifier: ^3.1.0 + version: 3.1.0 + exponential-backoff: + specifier: ^3.1.3 + version: 3.1.3 + fastify: + specifier: ^5.2.2 + version: 5.7.4 + ioredis: + specifier: ^5.9.0 + version: 5.9.2 + kysely: + specifier: ^0.28.11 + version: 0.28.11 + p-timeout: + specifier: ^7.0.1 + version: 7.0.1 + pg: + specifier: ^8.16.3 + version: 8.18.0 + remeda: + specifier: ^2.33.1 + version: 2.33.4 + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 + devDependencies: + '@cardano-ogmios/schema': + specifier: ^6.11.0 + version: 6.14.0 + '@repo/eslint-config': + specifier: workspace:* + version: link:../../packages/eslint-config + '@repo/typescript-config': + specifier: workspace:* + version: link:../../packages/typescript-config + '@types/json-bigint': + specifier: ^1.0.4 + version: 1.0.4 + '@types/node': + specifier: ^22.15.3 + version: 22.15.3 + '@types/pg': + specifier: ^8.16.0 + version: 8.16.0 + dpdm: + specifier: ^3.14.0 + version: 3.14.0 + eslint: + specifier: ^9.31.0 + version: 9.31.0(jiti@2.6.1) + kysely-codegen: + specifier: ^0.19.0 + version: 0.19.0(kysely@0.28.11)(pg@8.18.0)(typescript@5.8.2) + kysely-ctl: + specifier: ^0.19.0 + version: 0.19.0(kysely@0.28.11)(typescript@5.8.2) + tsx: + specifier: ^4.19.4 + version: 4.20.3 typescript: specifier: 5.8.2 version: 5.8.2 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@22.15.3)(jiti@2.6.1)(tsx@4.20.3) apps/web: dependencies: @@ -144,7 +256,7 @@ importers: version: 19.2.3(@types/react@19.2.7) eslint: specifier: ^9.31.0 - version: 9.31.0 + version: 9.31.0(jiti@2.6.1) typescript: specifier: 5.8.2 version: 5.8.2 @@ -181,13 +293,13 @@ importers: version: 3.14.0 eslint: specifier: ^9.31.0 - version: 9.31.0 + version: 9.31.0(jiti@2.6.1) typescript: specifier: 5.8.2 version: 5.8.2 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@22.15.3)(tsx@4.20.3) + version: 3.2.4(@types/node@22.15.3)(jiti@2.6.1)(tsx@4.20.3) packages/eslint-config: devDependencies: @@ -199,22 +311,22 @@ importers: version: 15.4.2 eslint: specifier: ^9.31.0 - version: 9.31.0 + version: 9.31.0(jiti@2.6.1) eslint-config-prettier: specifier: ^10.1.1 - version: 10.1.1(eslint@9.31.0) + version: 10.1.1(eslint@9.31.0(jiti@2.6.1)) eslint-plugin-only-warn: specifier: ^1.1.0 version: 1.1.0 eslint-plugin-react: specifier: ^7.37.5 - version: 7.37.5(eslint@9.31.0) + version: 7.37.5(eslint@9.31.0(jiti@2.6.1)) eslint-plugin-react-hooks: specifier: ^5.2.0 - version: 5.2.0(eslint@9.31.0) + version: 5.2.0(eslint@9.31.0(jiti@2.6.1)) eslint-plugin-turbo: specifier: ^2.5.0 - version: 2.5.0(eslint@9.31.0)(turbo@2.5.5) + version: 2.5.0(eslint@9.31.0(jiti@2.6.1))(turbo@2.5.5) globals: specifier: ^16.3.0 version: 16.3.0 @@ -223,7 +335,7 @@ importers: version: 5.8.2 typescript-eslint: specifier: ^8.37.0 - version: 8.37.0(eslint@9.31.0)(typescript@5.8.2) + version: 8.37.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.2) packages/felis: dependencies: @@ -290,7 +402,7 @@ importers: version: 3.14.0 eslint: specifier: ^9.31.0 - version: 9.31.0 + version: 9.31.0(jiti@2.6.1) typescript: specifier: 5.8.2 version: 5.8.2 @@ -345,7 +457,7 @@ importers: version: 5.8.2 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@22.15.3)(tsx@4.20.3) + version: 3.2.4(@types/node@22.15.3)(jiti@2.6.1)(tsx@4.20.3) packages/ledger-utils: dependencies: @@ -403,7 +515,7 @@ importers: version: 5.8.2 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@22.15.3)(tsx@4.20.3) + version: 3.2.4(@types/node@22.15.3)(jiti@2.6.1)(tsx@4.20.3) packages/minswap-build-tx: dependencies: @@ -443,13 +555,13 @@ importers: version: 3.14.0 eslint: specifier: ^9.31.0 - version: 9.31.0 + version: 9.31.0(jiti@2.6.1) typescript: specifier: 5.8.2 version: 5.8.2 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@22.15.3)(tsx@4.20.3) + version: 3.2.4(@types/node@22.15.3)(jiti@2.6.1)(tsx@4.20.3) packages/minswap-dex-v1: dependencies: @@ -480,7 +592,7 @@ importers: version: 3.14.0 eslint: specifier: ^9.31.0 - version: 9.31.0 + version: 9.31.0(jiti@2.6.1) typescript: specifier: 5.8.2 version: 5.8.2 @@ -514,13 +626,13 @@ importers: version: 3.14.0 eslint: specifier: ^9.31.0 - version: 9.31.0 + version: 9.31.0(jiti@2.6.1) typescript: specifier: 5.8.2 version: 5.8.2 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@22.15.3)(tsx@4.20.3) + version: 3.2.4(@types/node@22.15.3)(jiti@2.6.1)(tsx@4.20.3) packages/minswap-lending-market: dependencies: @@ -575,7 +687,7 @@ importers: version: 3.14.0 eslint: specifier: ^9.31.0 - version: 9.31.0 + version: 9.31.0(jiti@2.6.1) typescript: specifier: 5.8.2 version: 5.8.2 @@ -627,7 +739,7 @@ importers: version: 3.14.0 eslint: specifier: ^9.31.0 - version: 9.31.0 + version: 9.31.0(jiti@2.6.1) fast-check: specifier: ^3.23.2 version: 3.23.2 @@ -636,7 +748,7 @@ importers: version: 5.8.2 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@22.15.3)(tsx@4.20.3) + version: 3.2.4(@types/node@22.15.3)(jiti@2.6.1)(tsx@4.20.3) packages/provider: dependencies: @@ -673,7 +785,7 @@ importers: version: 3.14.0 eslint: specifier: ^9.31.0 - version: 9.31.0 + version: 9.31.0(jiti@2.6.1) typescript: specifier: 5.8.2 version: 5.8.2 @@ -710,7 +822,7 @@ importers: version: 3.14.0 eslint: specifier: ^9.31.0 - version: 9.31.0 + version: 9.31.0(jiti@2.6.1) typescript: specifier: 5.8.2 version: 5.8.2 @@ -744,13 +856,13 @@ importers: version: 3.14.0 eslint: specifier: ^9.31.0 - version: 9.31.0 + version: 9.31.0(jiti@2.6.1) typescript: specifier: 5.8.2 version: 5.8.2 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@22.15.3)(tsx@4.20.3) + version: 3.2.4(@types/node@22.15.3)(jiti@2.6.1)(tsx@4.20.3) packages/sundaeswap-v3: dependencies: @@ -781,7 +893,7 @@ importers: version: 3.14.0 eslint: specifier: ^9.31.0 - version: 9.31.0 + version: 9.31.0(jiti@2.6.1) typescript: specifier: 5.8.2 version: 5.8.2 @@ -842,13 +954,13 @@ importers: version: 3.14.0 eslint: specifier: ^9.31.0 - version: 9.31.0 + version: 9.31.0(jiti@2.6.1) typescript: specifier: 5.8.2 version: 5.8.2 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@22.15.3)(tsx@4.20.3) + version: 3.2.4(@types/node@22.15.3)(jiti@2.6.1)(tsx@4.20.3) packages/tx-builder: dependencies: @@ -891,7 +1003,7 @@ importers: version: 3.14.0 eslint: specifier: ^9.31.0 - version: 9.31.0 + version: 9.31.0(jiti@2.6.1) typescript: specifier: 5.8.2 version: 5.8.2 @@ -924,7 +1036,7 @@ importers: version: 19.2.3(@types/react@19.2.7) eslint: specifier: ^9.31.0 - version: 9.31.0 + version: 9.31.0(jiti@2.6.1) typescript: specifier: 5.8.3 version: 5.8.3 @@ -962,7 +1074,7 @@ importers: version: 3.14.0 eslint: specifier: ^9.31.0 - version: 9.31.0 + version: 9.31.0(jiti@2.6.1) typescript: specifier: 5.8.2 version: 5.8.2 @@ -999,7 +1111,7 @@ importers: version: 3.14.0 eslint: specifier: ^9.31.0 - version: 9.31.0 + version: 9.31.0(jiti@2.6.1) typescript: specifier: 5.8.2 version: 5.8.2 @@ -1049,6 +1161,14 @@ packages: peerDependencies: react: '>=16.9.0' + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + '@babel/runtime@7.28.4': resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} @@ -1130,6 +1250,9 @@ packages: '@emotion/unitless@0.7.5': resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==} + '@emurgo/cardano-message-signing-nodejs@1.1.0': + resolution: {integrity: sha512-PQRc8K8wZshEdmQenNUzVtiI8oJNF/1uAnBhidee5C4o1l2mDLOW+ur46HWHIFKQ6x8mSJTllcjMscHgzju0gQ==} + '@emurgo/cardano-serialization-lib-browser@13.2.1': resolution: {integrity: sha512-7RfX1gI16Vj2DgCp/ZoXqyLAakWo6+X95ku/rYGbVzuS/1etrlSiJmdbmdm+eYmszMlGQjrtOJQeVLXoj4L/Ag==} @@ -1330,6 +1453,27 @@ packages: resolution: {integrity: sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/cors@10.1.0': + resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1487,6 +1631,9 @@ packages: cpu: [x64] os: [win32] + '@ioredis/commands@1.5.0': + resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1573,6 +1720,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1732,6 +1882,9 @@ packages: cpu: [x64] os: [win32] + '@sinclair/typebox@0.34.48': + resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} + '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} @@ -1747,6 +1900,9 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@types/bun@1.3.8': + resolution: {integrity: sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA==} + '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} @@ -1765,6 +1921,9 @@ packages: '@types/node@22.15.3': resolution: {integrity: sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==} + '@types/pg@8.16.0': + resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -1861,6 +2020,9 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1871,9 +2033,20 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1882,6 +2055,10 @@ packages: resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} engines: {node: '>=12'} + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -1935,10 +2112,17 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + avvio@9.1.0: + resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1982,6 +2166,17 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bun-types@1.3.8: + resolution: {integrity: sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q==} + + c12@3.3.3: + resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -2013,6 +2208,10 @@ packages: resolution: {integrity: sha512-48af6xm9gQK8rhIcOxWwdGzIervm8BVTin+yRp9HEvU20BtVZ2lBywlIJBzwaDtvo0FvjeL7QdCADoUoqIbV3A==} engines: {node: '>=18'} + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2021,6 +2220,16 @@ packages: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.0: + resolution: {integrity: sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA==} + classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} @@ -2043,10 +2252,20 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} @@ -2056,9 +2275,29 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + confbox@0.2.2: + resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + cross-fetch@3.2.0: resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} @@ -2114,18 +2353,48 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + diff@3.5.1: + resolution: {integrity: sha512-Z3u54A8qGyqFOSr2pk0ijYs8mOE9Qz8kTvtKeBI+upoG9j04Sq+oI7W8zAJiQybDcESET8/uIdHzs0p3k4fZlw==} + engines: {node: '>=0.3.1'} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + dotenv@16.0.3: resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} engines: {node: '>=12'} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + dpdm@3.14.0: resolution: {integrity: sha512-YJzsFSyEtj88q5eTELg3UWU7TVZkG1dpbF4JDQ3t1b07xuzXmdoGeSz9TKOke1mUuOpWlk4q+pBh+aHzD6GBTg==} hasBin: true @@ -2150,6 +2419,13 @@ packages: resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} engines: {node: '>=10.0.0'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-abstract@1.24.0: resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} engines: {node: '>= 0.4'} @@ -2194,6 +2470,10 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2275,10 +2555,19 @@ packages: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + fast-check@3.23.2: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2293,9 +2582,24 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-json-stringify@6.2.0: + resolution: {integrity: sha512-Eaf/KNIDwHkzfyeQFNfLXJnQ7cl1XQI3+zRqmPlvtkMigbXnAcasTrvJQmquBSxKfFGeRA6PFog8t+hFmpDoWw==} + fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.7.4: + resolution: {integrity: sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==} + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -2316,6 +2620,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-my-way@9.4.0: + resolution: {integrity: sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==} + engines: {node: '>=20'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2339,6 +2647,9 @@ packages: resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} engines: {node: '>=14.14'} + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2373,6 +2684,14 @@ packages: get-tsconfig@4.10.1: resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + + git-diff@2.0.6: + resolution: {integrity: sha512-/Iu4prUrydE3Pb3lCBMbcSNIf81tgGt0W1ZwknnyF62t3tHmtiJTRj0f+1ZIhp3+Rh0ktz1pJVoa7ZXUCskivA==} + engines: {node: '>= 4.8.0'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2385,6 +2704,10 @@ packages: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -2411,6 +2734,10 @@ packages: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2453,6 +2780,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2460,10 +2791,25 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + interpret@1.4.0: + resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} + engines: {node: '>= 0.10'} + + ioredis@5.9.2: + resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} + engines: {node: '>=12.22.0'} + + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-async-function@2.1.1: resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} engines: {node: '>= 0.4'} @@ -2590,6 +2936,10 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + jotai@2.15.1: resolution: {integrity: sha512-yHT1HAZ3ba2Q8wgaUQ+xfBzEtcS8ie687I8XVCBinfg4bNniyqLIN+utPXWKQE93LMF5fPbQSVRZqgpcN5yd6Q==} engines: {node: '>=12.20.0'} @@ -2624,9 +2974,18 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -2643,14 +3002,82 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kysely-codegen@0.19.0: + resolution: {integrity: sha512-ZpdQQnpfY0kh45CA6yPA9vdFsBE+b06Fx7QVcbL5rX//yjbA0yYGZGhnH7GTd4P4BY/HIv5uAfuOD83JVZf95w==} + engines: {node: '>=20.0.0'} + hasBin: true + peerDependencies: + '@libsql/kysely-libsql': '>=0.3.0 <0.5.0' + '@tediousjs/connection-string': '>=0.5.0 <0.6.0' + better-sqlite3: '>=7.6.2 <13.0.0' + kysely: '>=0.27.0 <1.0.0' + kysely-bun-sqlite: '>=0.3.2 <1.0.0' + kysely-bun-worker: '>=1.2.0 <2.0.0' + mysql2: '>=2.3.3 <4.0.0' + pg: '>=8.8.0 <9.0.0' + tarn: '>=3.0.0 <4.0.0' + tedious: '>=18.0.0 <20.0.0' + peerDependenciesMeta: + '@libsql/kysely-libsql': + optional: true + '@tediousjs/connection-string': + optional: true + better-sqlite3: + optional: true + kysely-bun-sqlite: + optional: true + kysely-bun-worker: + optional: true + mysql2: + optional: true + pg: + optional: true + tarn: + optional: true + tedious: + optional: true + + kysely-ctl@0.19.0: + resolution: {integrity: sha512-89hzOd1cy/H063jB2E9wYHq+uKYpaHv6Mb5RiNFpRZL6BYCah9ncsdl3x5b52eirxry4UyWSmGNN3sFv+gK+ig==} + engines: {node: '>=20'} + hasBin: true + peerDependencies: + kysely: '>=0.18.1 <0.29.0' + kysely-neon: ^2 + kysely-postgres-js: ^2 || ^3 + kysely-prisma-postgres: ^0.1 + peerDependenciesMeta: + kysely-neon: + optional: true + kysely-postgres-js: + optional: true + kysely-prisma-postgres: + optional: true + + kysely@0.28.11: + resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==} + engines: {node: '>=20.0.0'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -2661,6 +3088,10 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + loglevel@1.9.2: + resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} + engines: {node: '>= 0.6.0'} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -2697,10 +3128,16 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + mnemonist@0.40.0: + resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2733,6 +3170,9 @@ packages: sass: optional: true + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -2746,6 +3186,11 @@ packages: resolution: {integrity: sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==} engines: {node: '>=12.19'} + nypm@0.6.4: + resolution: {integrity: sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw==} + engines: {node: '>=18'} + hasBin: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2774,6 +3219,22 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obliterator@2.0.5: + resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -2798,6 +3259,10 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -2805,10 +3270,18 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -2827,6 +3300,43 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.11.0: + resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.11.0: + resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.11.0: + resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.18.0: + resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2838,6 +3348,23 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.0: + resolution: {integrity: sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==} + hasBin: true + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2850,10 +3377,32 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -2867,6 +3416,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + rc-cascader@3.34.0: resolution: {integrity: sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==} peerDependencies: @@ -3095,6 +3647,9 @@ packages: react: '>=16.9.0' react-dom: '>=16.9.0' + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + react-dom@19.2.3: resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} peerDependencies: @@ -3114,6 +3669,26 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + rechoir@0.6.2: + resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} + engines: {node: '>= 0.10'} + + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -3125,10 +3700,17 @@ packages: remeda@2.28.0: resolution: {integrity: sha512-943nIauNk4xBh/2DNcsZCc3DRpKncNXnNjtWlqfhM72FvD4bKuOXWSfuY0+k7yf8/Ob3+QzXtxLeobLoMn/INA==} + remeda@2.33.4: + resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resize-observer-polyfill@1.5.1: resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} @@ -3139,6 +3721,11 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + resolve@2.0.0-next.5: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true @@ -3147,10 +3734,17 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rollup@4.46.3: resolution: {integrity: sha512-RZn2XTjXb8t5g13f5YclGoilU/kwT696DIkY3sywjdZidNSi3+vseaQov7D7BZXVJCPv3pDWUN69C78GGbXsKw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -3174,12 +3768,22 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safe-regex2@5.0.0: + resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} scroll-into-view-if-needed@3.1.0: resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3194,6 +3798,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3221,6 +3828,15 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shelljs.exec@1.1.8: + resolution: {integrity: sha512-vFILCw+lzUtiwBAHV8/Ex8JsFjelFMdhONIsgKNLgTzeRckp2AOYRQtHJE/9LhNvdMmE27AGtzWx0+DHpwIwSw==} + engines: {node: '>= 4.0.0'} + + shelljs@0.8.5: + resolution: {integrity: sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==} + engines: {node: '>=4'} + hasBin: true + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -3255,13 +3871,23 @@ packages: resolution: {integrity: sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==} engines: {node: '>=10.0.0'} + sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} @@ -3333,6 +3959,10 @@ packages: stylis@4.3.6: resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -3341,6 +3971,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} + throttle-debounce@5.0.2: resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} engines: {node: '>=12.22'} @@ -3351,6 +3985,10 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.14: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} @@ -3371,6 +4009,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + toggle-selection@1.0.6: resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} @@ -3387,6 +4029,16 @@ packages: resolution: {integrity: sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==} engines: {node: '>=14.0.0'} + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -3470,6 +4122,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -3607,6 +4262,9 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@7.5.10: resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} engines: {node: '>=8.3.0'} @@ -3635,6 +4293,10 @@ packages: resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} engines: {node: '>=0.4.0'} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3651,6 +4313,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + snapshots: '@ant-design/colors@7.2.1': @@ -3710,6 +4375,14 @@ snapshots: resize-observer-polyfill: 1.5.1 throttle-debounce: 5.0.2 + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/runtime@7.28.4': {} '@biomejs/biome@2.1.4': @@ -3779,6 +4452,8 @@ snapshots: '@emotion/unitless@0.7.5': {} + '@emurgo/cardano-message-signing-nodejs@1.1.0': {} + '@emurgo/cardano-serialization-lib-browser@13.2.1': {} '@emurgo/cardano-serialization-lib-nodejs@13.2.1': {} @@ -3861,9 +4536,9 @@ snapshots: '@esbuild/win32-x64@0.25.8': optional: true - '@eslint-community/eslint-utils@4.7.0(eslint@9.31.0)': + '@eslint-community/eslint-utils@4.7.0(eslint@9.31.0(jiti@2.6.1))': dependencies: - eslint: 9.31.0 + eslint: 9.31.0(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} @@ -3905,6 +4580,34 @@ snapshots: '@eslint/core': 0.15.1 levn: 0.4.1 + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.1.0 + + '@fastify/cors@10.1.0': + dependencies: + fastify-plugin: 5.1.0 + mnemonist: 0.40.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.2.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.3.0 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -4015,6 +4718,8 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@ioredis/commands@1.5.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -4076,6 +4781,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -4207,6 +4914,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.46.3': optional: true + '@sinclair/typebox@0.34.48': {} + '@socket.io/component-emitter@3.1.2': {} '@stricahq/cbors@1.0.2': @@ -4233,6 +4942,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@types/bun@1.3.8': + dependencies: + bun-types: 1.3.8 + '@types/chai@5.2.2': dependencies: '@types/deep-eql': 4.0.2 @@ -4249,6 +4962,12 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pg@8.16.0': + dependencies: + '@types/node': 22.15.3 + pg-protocol: 1.11.0 + pg-types: 2.2.0 + '@types/react-dom@19.2.3(@types/react@19.2.7)': dependencies: '@types/react': 19.2.7 @@ -4257,15 +4976,15 @@ snapshots: dependencies: csstype: 3.2.3 - '@typescript-eslint/eslint-plugin@8.37.0(@typescript-eslint/parser@8.37.0(eslint@9.31.0)(typescript@5.8.2))(eslint@9.31.0)(typescript@5.8.2)': + '@typescript-eslint/eslint-plugin@8.37.0(@typescript-eslint/parser@8.37.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.2))(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.2)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.37.0(eslint@9.31.0)(typescript@5.8.2) + '@typescript-eslint/parser': 8.37.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.2) '@typescript-eslint/scope-manager': 8.37.0 - '@typescript-eslint/type-utils': 8.37.0(eslint@9.31.0)(typescript@5.8.2) - '@typescript-eslint/utils': 8.37.0(eslint@9.31.0)(typescript@5.8.2) + '@typescript-eslint/type-utils': 8.37.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.2) + '@typescript-eslint/utils': 8.37.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.2) '@typescript-eslint/visitor-keys': 8.37.0 - eslint: 9.31.0 + eslint: 9.31.0(jiti@2.6.1) graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 @@ -4274,14 +4993,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.37.0(eslint@9.31.0)(typescript@5.8.2)': + '@typescript-eslint/parser@8.37.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.2)': dependencies: '@typescript-eslint/scope-manager': 8.37.0 '@typescript-eslint/types': 8.37.0 '@typescript-eslint/typescript-estree': 8.37.0(typescript@5.8.2) '@typescript-eslint/visitor-keys': 8.37.0 debug: 4.4.1 - eslint: 9.31.0 + eslint: 9.31.0(jiti@2.6.1) typescript: 5.8.2 transitivePeerDependencies: - supports-color @@ -4304,13 +5023,13 @@ snapshots: dependencies: typescript: 5.8.2 - '@typescript-eslint/type-utils@8.37.0(eslint@9.31.0)(typescript@5.8.2)': + '@typescript-eslint/type-utils@8.37.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.2)': dependencies: '@typescript-eslint/types': 8.37.0 '@typescript-eslint/typescript-estree': 8.37.0(typescript@5.8.2) - '@typescript-eslint/utils': 8.37.0(eslint@9.31.0)(typescript@5.8.2) + '@typescript-eslint/utils': 8.37.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.2) debug: 4.4.1 - eslint: 9.31.0 + eslint: 9.31.0(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@5.8.2) typescript: 5.8.2 transitivePeerDependencies: @@ -4334,13 +5053,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.37.0(eslint@9.31.0)(typescript@5.8.2)': + '@typescript-eslint/utils@8.37.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.2)': dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.37.0 '@typescript-eslint/types': 8.37.0 '@typescript-eslint/typescript-estree': 8.37.0(typescript@5.8.2) - eslint: 9.31.0 + eslint: 9.31.0(jiti@2.6.1) typescript: 5.8.2 transitivePeerDependencies: - supports-color @@ -4358,13 +5077,13 @@ snapshots: chai: 5.3.1 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.1.3(@types/node@22.15.3)(tsx@4.20.3))': + '@vitest/mocker@3.2.4(vite@7.1.3(@types/node@22.15.3)(jiti@2.6.1)(tsx@4.20.3))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 7.1.3(@types/node@22.15.3)(tsx@4.20.3) + vite: 7.1.3(@types/node@22.15.3)(jiti@2.6.1)(tsx@4.20.3) '@vitest/pretty-format@3.2.4': dependencies: @@ -4392,12 +5111,18 @@ snapshots: loupe: 3.2.0 tinyrainbow: 2.0.0 + abstract-logging@2.0.1: {} + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 acorn@8.15.0: {} + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -4405,10 +5130,21 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-regex@5.0.1: {} ansi-regex@6.1.0: {} + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -4536,10 +5272,17 @@ snapshots: async-function@1.0.0: {} + atomic-sleep@1.0.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 + avvio@9.1.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.19.1 + balanced-match@1.0.2: {} base-x@4.0.1: {} @@ -4589,6 +5332,25 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bun-types@1.3.8: + dependencies: + '@types/node': 22.15.3 + + c12@3.3.3: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.2 + defu: 6.1.4 + dotenv: 17.2.3 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -4624,6 +5386,12 @@ snapshots: loupe: 3.2.0 pathval: 2.0.1 + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -4631,6 +5399,16 @@ snapshots: check-error@2.1.1: {} + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.0: {} + classnames@2.5.1: {} cli-cursor@3.1.0: @@ -4649,20 +5427,43 @@ snapshots: clone@1.0.4: {} + cluster-key-slot@1.1.2: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + color-convert@2.0.1: dependencies: color-name: 1.1.4 + color-name@1.1.3: {} + color-name@1.1.4: {} compute-scroll-into-view@3.1.1: {} concat-map@0.0.1: {} + confbox@0.2.2: {} + + consola@3.4.2: {} + + cookie@1.1.1: {} + copy-to-clipboard@3.3.3: dependencies: toggle-selection: 1.0.6 + cosmiconfig@9.0.0(typescript@5.8.2): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.8.2 + cross-fetch@3.2.0: dependencies: node-fetch: 2.7.0 @@ -4723,15 +5524,33 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defu@6.1.4: {} + + denque@2.1.0: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + detect-libc@2.1.2: optional: true + diff@3.5.1: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.6.1 + dotenv@16.0.3: {} + dotenv@16.6.1: {} + + dotenv@17.2.3: {} + dpdm@3.14.0: dependencies: chalk: 4.1.2 @@ -4768,6 +5587,12 @@ snapshots: engine.io-parser@5.2.3: {} + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + es-abstract@1.24.0: dependencies: array-buffer-byte-length: 1.0.2 @@ -4902,19 +5727,21 @@ snapshots: escalade@3.2.0: {} + escape-string-regexp@1.0.5: {} + escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.1(eslint@9.31.0): + eslint-config-prettier@10.1.1(eslint@9.31.0(jiti@2.6.1)): dependencies: - eslint: 9.31.0 + eslint: 9.31.0(jiti@2.6.1) eslint-plugin-only-warn@1.1.0: {} - eslint-plugin-react-hooks@5.2.0(eslint@9.31.0): + eslint-plugin-react-hooks@5.2.0(eslint@9.31.0(jiti@2.6.1)): dependencies: - eslint: 9.31.0 + eslint: 9.31.0(jiti@2.6.1) - eslint-plugin-react@7.37.5(eslint@9.31.0): + eslint-plugin-react@7.37.5(eslint@9.31.0(jiti@2.6.1)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -4922,7 +5749,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.1 - eslint: 9.31.0 + eslint: 9.31.0(jiti@2.6.1) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -4936,10 +5763,10 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-turbo@2.5.0(eslint@9.31.0)(turbo@2.5.5): + eslint-plugin-turbo@2.5.0(eslint@9.31.0(jiti@2.6.1))(turbo@2.5.5): dependencies: dotenv: 16.0.3 - eslint: 9.31.0 + eslint: 9.31.0(jiti@2.6.1) turbo: 2.5.5 eslint-scope@8.4.0: @@ -4951,9 +5778,9 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.31.0: + eslint@9.31.0(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.1 '@eslint/config-array': 0.21.0 '@eslint/config-helpers': 0.3.0 @@ -4988,6 +5815,8 @@ snapshots: minimatch: 3.1.2 natural-compare: 1.4.0 optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 transitivePeerDependencies: - supports-color @@ -5015,10 +5844,16 @@ snapshots: expect-type@1.2.2: {} + exponential-backoff@3.1.3: {} + + exsolve@1.0.8: {} + fast-check@3.23.2: dependencies: pure-rand: 6.1.0 + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -5039,8 +5874,43 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-json-stringify@6.2.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.1.0 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + fast-levenshtein@2.0.6: {} + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.0: {} + + fastify-plugin@5.1.0: {} + + fastify@5.7.4: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.1.0 + fast-json-stringify: 6.2.0 + find-my-way: 9.4.0 + light-my-request: 6.6.0 + pino: 10.3.0 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.3 + toad-cache: 3.7.0 + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -5057,6 +5927,12 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-my-way@9.4.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -5084,6 +5960,8 @@ snapshots: jsonfile: 6.1.0 universalify: 2.0.1 + fs.realpath@1.0.0: {} + fsevents@2.3.3: optional: true @@ -5129,7 +6007,23 @@ snapshots: get-tsconfig@4.10.1: dependencies: resolve-pkg-maps: 1.0.0 - optional: true + + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + node-fetch-native: 1.6.7 + nypm: 0.6.4 + pathe: 2.0.3 + + git-diff@2.0.6: + dependencies: + chalk: 2.4.2 + diff: 3.5.1 + loglevel: 1.9.2 + shelljs: 0.8.5 + shelljs.exec: 1.1.8 glob-parent@5.1.2: dependencies: @@ -5148,6 +6042,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + globals@14.0.0: {} globals@16.3.0: {} @@ -5165,6 +6068,8 @@ snapshots: has-bigints@1.1.0: {} + has-flag@3.0.0: {} + has-flag@4.0.0: {} has-property-descriptors@1.0.2: @@ -5198,6 +6103,11 @@ snapshots: imurmurhash@0.1.4: {} + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + inherits@2.0.4: {} internal-slot@1.1.0: @@ -5206,12 +6116,32 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + interpret@1.4.0: {} + + ioredis@5.9.2: + dependencies: + '@ioredis/commands': 1.5.0 + cluster-key-slot: 1.1.2 + debug: 4.4.1 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + + ipaddr.js@2.3.0: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-arrayish@0.2.1: {} + is-async-function@2.1.1: dependencies: async-function: 1.0.0 @@ -5342,6 +6272,8 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jiti@2.6.1: {} + jotai@2.15.1(@types/react@19.2.7)(react@19.2.3): optionalDependencies: '@types/react': 19.2.7 @@ -5361,8 +6293,16 @@ snapshots: json-buffer@3.0.1: {} + json-parse-even-better-errors@2.3.1: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json2mq@0.2.0: @@ -5386,15 +6326,64 @@ snapshots: dependencies: json-buffer: 3.0.1 + kysely-codegen@0.19.0(kysely@0.28.11)(pg@8.18.0)(typescript@5.8.2): + dependencies: + chalk: 4.1.2 + cosmiconfig: 9.0.0(typescript@5.8.2) + dotenv: 17.2.3 + dotenv-expand: 12.0.3 + git-diff: 2.0.6 + kysely: 0.28.11 + micromatch: 4.0.8 + minimist: 1.2.8 + pluralize: 8.0.0 + zod: 4.3.6 + optionalDependencies: + pg: 8.18.0 + transitivePeerDependencies: + - typescript + + kysely-ctl@0.19.0(kysely@0.28.11)(typescript@5.8.2): + dependencies: + c12: 3.3.3 + citty: 0.1.6 + confbox: 0.2.2 + consola: 3.4.2 + jiti: 2.6.1 + kysely: 0.28.11 + nypm: 0.6.4 + ofetch: 1.5.1 + pathe: 2.0.3 + pkg-types: 2.3.0 + std-env: 3.9.0 + tsconfck: 3.1.6(typescript@5.8.2) + transitivePeerDependencies: + - magicast + - typescript + + kysely@0.28.11: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lines-and-columns@1.2.4: {} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + lodash.merge@4.6.2: {} lodash@4.17.21: {} @@ -5404,6 +6393,8 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + loglevel@1.9.2: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -5435,8 +6426,14 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minimist@1.2.8: {} + minipass@7.1.2: {} + mnemonist@0.40.0: + dependencies: + obliterator: 2.0.5 + ms@2.1.3: {} nanoid@3.3.11: {} @@ -5466,12 +6463,20 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-fetch-native@1.6.7: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 nofilter@3.1.0: {} + nypm@0.6.4: + dependencies: + citty: 0.2.0 + pathe: 2.0.3 + tinyexec: 1.0.2 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -5508,6 +6513,22 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obliterator@2.0.5: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.3 + + ohash@2.0.11: {} + + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -5547,14 +6568,25 @@ snapshots: dependencies: p-limit: 3.1.0 + p-timeout@7.0.1: {} + package-json-from-dist@1.0.1: {} parent-module@1.0.1: dependencies: callsites: 3.1.0 + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + path-exists@4.0.0: {} + path-is-absolute@1.0.1: {} + path-key@3.1.1: {} path-parse@1.0.7: {} @@ -5568,12 +6600,77 @@ snapshots: pathval@2.0.1: {} + perfect-debounce@2.1.0: {} + + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.11.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.11.0(pg@8.18.0): + dependencies: + pg: 8.18.0 + + pg-protocol@1.11.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.18.0: + dependencies: + pg-connection-string: 2.11.0 + pg-pool: 3.11.0(pg@8.18.0) + pg-protocol: 1.11.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} picomatch@4.0.3: {} + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.0 + thread-stream: 4.0.0 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.2 + exsolve: 1.0.8 + pathe: 2.0.3 + + pluralize@8.0.0: {} + possible-typed-array-names@1.1.0: {} postcss@8.4.31: @@ -5588,8 +6685,22 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prelude-ls@1.2.1: {} + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -5602,6 +6713,8 @@ snapshots: queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + rc-cascader@3.34.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@babel/runtime': 7.28.4 @@ -5921,6 +7034,11 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + rc9@2.1.2: + dependencies: + defu: 6.1.4 + destr: 2.0.5 + react-dom@19.2.3(react@19.2.3): dependencies: react: 19.2.3 @@ -5938,6 +7056,20 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readdirp@5.0.0: {} + + real-require@0.2.0: {} + + rechoir@0.6.2: + dependencies: + resolve: 1.22.11 + + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -5962,14 +7094,23 @@ snapshots: dependencies: type-fest: 4.41.0 + remeda@2.33.4: {} + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resize-observer-polyfill@1.5.1: {} resolve-from@4.0.0: {} - resolve-pkg-maps@1.0.0: - optional: true + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 resolve@2.0.0-next.5: dependencies: @@ -5982,8 +7123,12 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + ret@0.5.0: {} + reusify@1.1.0: {} + rfdc@1.4.1: {} + rollup@4.46.3: dependencies: '@types/estree': 1.0.8 @@ -6035,18 +7180,27 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-regex2@5.0.0: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + scheduler@0.27.0: {} scroll-into-view-if-needed@3.1.0: dependencies: compute-scroll-into-view: 3.1.1 + secure-json-parse@4.1.0: {} + semver@6.3.1: {} semver@7.7.2: {} - semver@7.7.3: - optional: true + semver@7.7.3: {} + + set-cookie-parser@2.7.2: {} set-function-length@1.2.2: dependencies: @@ -6112,6 +7266,14 @@ snapshots: shebang-regex@3.0.0: {} + shelljs.exec@1.1.8: {} + + shelljs@0.8.5: + dependencies: + glob: 7.2.3 + interpret: 1.4.0 + rechoir: 0.6.2 + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -6164,10 +7326,18 @@ snapshots: transitivePeerDependencies: - supports-color + sonic-boom@4.2.0: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} + split2@4.2.0: {} + stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + std-env@3.9.0: {} stop-iteration-iterator@1.1.0: @@ -6258,18 +7428,28 @@ snapshots: stylis@4.3.6: {} + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 supports-preserve-symlinks-flag@1.0.0: {} + thread-stream@4.0.0: + dependencies: + real-require: 0.2.0 + throttle-debounce@5.0.2: {} tinybench@2.9.0: {} tinyexec@0.3.2: {} + tinyexec@1.0.2: {} + tinyglobby@0.2.14: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -6285,6 +7465,8 @@ snapshots: dependencies: is-number: 7.0.0 + toad-cache@3.7.0: {} + toggle-selection@1.0.6: {} tr46@0.0.3: {} @@ -6295,6 +7477,10 @@ snapshots: ts-custom-error@3.3.1: {} + tsconfck@3.1.6(typescript@5.8.2): + optionalDependencies: + typescript: 5.8.2 + tslib@2.8.1: {} tsx@4.20.3: @@ -6303,7 +7489,6 @@ snapshots: get-tsconfig: 4.10.1 optionalDependencies: fsevents: 2.3.3 - optional: true turbo-darwin-64@2.5.5: optional: true @@ -6371,13 +7556,13 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.37.0(eslint@9.31.0)(typescript@5.8.2): + typescript-eslint@8.37.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.2): dependencies: - '@typescript-eslint/eslint-plugin': 8.37.0(@typescript-eslint/parser@8.37.0(eslint@9.31.0)(typescript@5.8.2))(eslint@9.31.0)(typescript@5.8.2) - '@typescript-eslint/parser': 8.37.0(eslint@9.31.0)(typescript@5.8.2) + '@typescript-eslint/eslint-plugin': 8.37.0(@typescript-eslint/parser@8.37.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.2))(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.2) + '@typescript-eslint/parser': 8.37.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.2) '@typescript-eslint/typescript-estree': 8.37.0(typescript@5.8.2) - '@typescript-eslint/utils': 8.37.0(eslint@9.31.0)(typescript@5.8.2) - eslint: 9.31.0 + '@typescript-eslint/utils': 8.37.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.2) + eslint: 9.31.0(jiti@2.6.1) typescript: 5.8.2 transitivePeerDependencies: - supports-color @@ -6386,6 +7571,8 @@ snapshots: typescript@5.8.3: {} + ufo@1.6.3: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -6403,13 +7590,13 @@ snapshots: util-deprecate@1.0.2: {} - vite-node@3.2.4(@types/node@22.15.3)(tsx@4.20.3): + vite-node@3.2.4(@types/node@22.15.3)(jiti@2.6.1)(tsx@4.20.3): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.1.3(@types/node@22.15.3)(tsx@4.20.3) + vite: 7.1.3(@types/node@22.15.3)(jiti@2.6.1)(tsx@4.20.3) transitivePeerDependencies: - '@types/node' - jiti @@ -6424,7 +7611,7 @@ snapshots: - tsx - yaml - vite@7.1.3(@types/node@22.15.3)(tsx@4.20.3): + vite@7.1.3(@types/node@22.15.3)(jiti@2.6.1)(tsx@4.20.3): dependencies: esbuild: 0.25.8 fdir: 6.5.0(picomatch@4.0.3) @@ -6435,13 +7622,14 @@ snapshots: optionalDependencies: '@types/node': 22.15.3 fsevents: 2.3.3 + jiti: 2.6.1 tsx: 4.20.3 - vitest@3.2.4(@types/node@22.15.3)(tsx@4.20.3): + vitest@3.2.4(@types/node@22.15.3)(jiti@2.6.1)(tsx@4.20.3): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.3(@types/node@22.15.3)(tsx@4.20.3)) + '@vitest/mocker': 3.2.4(vite@7.1.3(@types/node@22.15.3)(jiti@2.6.1)(tsx@4.20.3)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -6459,8 +7647,8 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.3(@types/node@22.15.3)(tsx@4.20.3) - vite-node: 3.2.4(@types/node@22.15.3)(tsx@4.20.3) + vite: 7.1.3(@types/node@22.15.3)(jiti@2.6.1)(tsx@4.20.3) + vite-node: 3.2.4(@types/node@22.15.3)(jiti@2.6.1)(tsx@4.20.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.15.3 @@ -6553,12 +7741,16 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + wrappy@1.0.2: {} + ws@7.5.10: {} ws@8.18.3: {} xmlhttprequest-ssl@2.1.2: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yargs-parser@21.1.1: {} @@ -6574,3 +7766,5 @@ snapshots: yargs-parser: 21.1.1 yocto-queue@0.1.0: {} + + zod@4.3.6: {} From 2084e7708ab0e9abfd4a84f040ac1c6e7b77bf8a Mon Sep 17 00:00:00 2001 From: tony Date: Wed, 4 Feb 2026 15:00:45 +0700 Subject: [PATCH 05/35] fix typo --- apps/long-short-backend/package.json | 9 +- .../app/components/nitro-wallet-connector.tsx | 4 +- docker-compose.yml | 1 + packages/minswap-dex-v2/src/order.ts | 8 +- packages/minswap-dex-v2/src/pool.ts | 8 +- packages/minswap-dex-v2/src/utils.ts | 8 -- pnpm-lock.yaml | 16 +++ turbo.json | 133 +++++++++++++++--- 8 files changed, 153 insertions(+), 34 deletions(-) diff --git a/apps/long-short-backend/package.json b/apps/long-short-backend/package.json index f9ac487..ab515d6 100644 --- a/apps/long-short-backend/package.json +++ b/apps/long-short-backend/package.json @@ -1,15 +1,14 @@ { "name": "long-short-backend", "version": "0.1.0", - "type": "module", "private": true, "exports": { ".": "./dist/index.js" }, "scripts": { "build": "tsc", - "start": "node --import tsx src/index.ts", - "dev": "node --import tsx --watch src/index.ts", + "start": "node --import tsx src/cmd/run-api.ts", + "dev": "node --import tsx --watch src/cmd/run-api.ts", "test": "vitest", "run:migrate": "pnpm kysely migrate:latest", "run:seed": "pnpm kysely seed:run", @@ -19,6 +18,7 @@ "@cardano-ogmios/schema": "^6.11.0", "@repo/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", + "@types/crypto-js": "^4.2.2", "@types/json-bigint": "^1.0.4", "@types/node": "^22.15.3", "@types/pg": "^8.16.0", @@ -35,17 +35,18 @@ "@emurgo/cardano-message-signing-nodejs": "^1.0.1", "@fastify/cors": "^10.0.2", "@minswap/felis-build-tx": "workspace:*", + "@minswap/felis-cip": "workspace:*", "@minswap/felis-dex-v1": "workspace:*", "@minswap/felis-dex-v2": "workspace:*", "@minswap/felis-ledger-core": "workspace:*", "@minswap/felis-ledger-utils": "workspace:*", "@minswap/felis-tx-builder": "workspace:*", - "@minswap/felis-cip": "workspace:*", "@minswap/tiny-invariant": "^1.2.0", "@sinclair/typebox": "^0.34.33", "@types/bun": "^1.3.5", "bignumber.js": "^9.1.2", "bip39": "^3.1.0", + "crypto-js": "^4.2.0", "exponential-backoff": "^3.1.3", "fastify": "^5.2.2", "ioredis": "^5.9.0", diff --git a/apps/web/app/components/nitro-wallet-connector.tsx b/apps/web/app/components/nitro-wallet-connector.tsx index 06440bf..a52a730 100644 --- a/apps/web/app/components/nitro-wallet-connector.tsx +++ b/apps/web/app/components/nitro-wallet-connector.tsx @@ -1,12 +1,12 @@ "use client"; import { CopyOutlined, LogoutOutlined } from "@ant-design/icons"; +import { Address } from "@minswap/felis-ledger-core"; +import { NitroWallet } from "@minswap/felis-lending-market"; import invariant from "@minswap/tiny-invariant"; import { Alert, App, Button, Card, Col, Row, Space, Statistic, Tooltip } from "antd"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect, useState } from "react"; -import { Address } from "../../../../packages/ledger-core/dist/address"; -import { NitroWallet } from "../../../../packages/minswap-lending-market/dist/nitro-wallet"; import { type NitroWalletData, nitroBalanceAtom, diff --git a/docker-compose.yml b/docker-compose.yml index c0f829a..d8edc22 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ services: # ports: # - "127.0.0.1:9999:9999" # environment: + # NETWORK: mainnet # DATABASE_URL: postgres://postgres:JBNGlQ9wNFLlYWc2mG@postgres:5432/margin # REDIS_URL: redis://default:7obaQyYSDDLDk3ECYA@redis:6379 # API_PORT: 9999 diff --git a/packages/minswap-dex-v2/src/order.ts b/packages/minswap-dex-v2/src/order.ts index 8d264b8..f8a6a1a 100644 --- a/packages/minswap-dex-v2/src/order.ts +++ b/packages/minswap-dex-v2/src/order.ts @@ -22,7 +22,13 @@ import { type CborHex, type CSLPlutusData, Maybe, Result } from "@minswap/felis- import { getDexV2Configs, getDexV2OrderScriptHash } from "./constants"; import { InvalidOrder } from "./invalid-order"; import { DexVersion, OrderV2StepType } from "./order-step"; -import { normalizePair } from "./utils"; + +function normalizePair([a, b]: [Asset, Asset]): [Asset, Asset] { + if (a.compare(b) > 0) { + return [b, a]; + } + return [a, b]; +} export enum OrderV2AuthorizationMethodType { SIGNATURE = 0, diff --git a/packages/minswap-dex-v2/src/pool.ts b/packages/minswap-dex-v2/src/pool.ts index fc05281..77dadbb 100644 --- a/packages/minswap-dex-v2/src/pool.ts +++ b/packages/minswap-dex-v2/src/pool.ts @@ -24,7 +24,13 @@ import invariant from "@minswap/tiny-invariant"; import { DEX_V2_DEFAULT_POOL_ADA, getDexV2Configs } from "./constants"; import { OrderV2Direction } from "./order"; -import { normalizePair } from "./utils"; + +function normalizePair([a, b]: [Asset, Asset]): [Asset, Asset] { + if (a.compare(b) > 0) { + return [b, a]; + } + return [a, b]; +} export type PoolV2BaseFee = { feeANumerator: bigint; diff --git a/packages/minswap-dex-v2/src/utils.ts b/packages/minswap-dex-v2/src/utils.ts index 3ff8636..a5d4226 100644 --- a/packages/minswap-dex-v2/src/utils.ts +++ b/packages/minswap-dex-v2/src/utils.ts @@ -1,16 +1,8 @@ -import type { Asset } from "@minswap/felis-ledger-core"; import { type Maybe, Result } from "@minswap/felis-ledger-utils"; import { OrderV2Direction } from "./order"; import type { PoolV2BaseFee } from "./pool"; import { bigIntPow, sqrt } from "./sqrt"; -export function normalizePair([a, b]: [Asset, Asset]): [Asset, Asset] { - if (a.compare(b) > 0) { - return [b, a]; - } - return [a, b]; -} - export type PoolFee = { // Trading Fee is the total Fee that is taken from the Traders by the Liquidity Pool tradingFee: number; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5fecd6..6f5f037 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: bip39: specifier: ^3.1.0 version: 3.1.0 + crypto-js: + specifier: ^4.2.0 + version: 4.2.0 exponential-backoff: specifier: ^3.1.3 version: 3.1.3 @@ -157,6 +160,9 @@ importers: '@repo/typescript-config': specifier: workspace:* version: link:../../packages/typescript-config + '@types/crypto-js': + specifier: ^4.2.2 + version: 4.2.2 '@types/json-bigint': specifier: ^1.0.4 version: 1.0.4 @@ -1906,6 +1912,9 @@ packages: '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/crypto-js@4.2.2': + resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2305,6 +2314,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -4950,6 +4962,8 @@ snapshots: dependencies: '@types/deep-eql': 4.0.2 + '@types/crypto-js@4.2.2': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} @@ -5476,6 +5490,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-js@4.2.0: {} + csstype@3.1.3: {} csstype@3.2.3: {} diff --git a/turbo.json b/turbo.json index ff10a66..d76fc9d 100644 --- a/turbo.json +++ b/turbo.json @@ -7,51 +7,148 @@ "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": [".next/**", "!.next/cache/**", "dist/**"] }, - "@repo/ledger-core#build": { - "dependsOn": ["@repo/ledger-utils#build"], + "@minswap/felis-ledger-utils#build": { + "dependsOn": ["@minswap/felis-uplc-node#build", "@minswap/felis-uplc-web#build"], "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": ["dist/**"] }, - "@repo/tx-builder#build": { - "dependsOn": ["@repo/ledger-core#build", "@repo/cip#build"], + "@minswap/felis-ledger-core#build": { + "dependsOn": ["@minswap/felis-ledger-utils#build"], "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": ["dist/**"] }, - "@repo/cip#build": { - "dependsOn": ["@repo/ledger-core#build"], + "@minswap/felis-cip#build": { + "dependsOn": ["@minswap/felis-ledger-core#build"], "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": ["dist/**"] }, - "@repo/minswap-lending-market#build": { - "dependsOn": ["@repo/tx-builder#build", "@repo/minswap-build-tx#build"], + "@minswap/felis-tx-builder#build": { + "dependsOn": ["@minswap/felis-ledger-core#build", "@minswap/felis-cip#build"], "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": ["dist/**"] }, - "@repo/minswap-build-tx#build": { - "dependsOn": ["@repo/tx-builder#build"], + "@minswap/felis-dex-v1#build": { + "dependsOn": ["@minswap/felis-ledger-core#build", "@minswap/felis-ledger-utils#build"], "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": ["dist/**"] }, - "@repo/minswap-dex-v1#build": { - "dependsOn": ["@repo/ledger-core#build", "@repo/ledger-utils#build"], + "@minswap/felis-dex-v2#build": { + "dependsOn": ["@minswap/felis-ledger-core#build", "@minswap/felis-ledger-utils#build"], "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": ["dist/**"] }, - "@repo/minswap-dex-v2#build": { - "dependsOn": ["@repo/ledger-core#build", "@repo/ledger-utils#build"], + "@minswap/felis-provider#build": { + "dependsOn": ["@minswap/felis-ledger-core#build", "@minswap/felis-ledger-utils#build"], "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": ["dist/**"] }, - "@repo/minswap-stableswap#build": { - "dependsOn": ["@repo/minswap-dex-v2#build", "@repo/minswap-build-tx#build", "@repo/tx-builder#build"], + "@minswap/felis-splash#build": { + "dependsOn": ["@minswap/felis-ledger-core#build", "@minswap/felis-ledger-utils#build"], "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": ["dist/**"] }, - "@repo/syncer#build": { - "dependsOn": ["@repo/minswap-dex-v1#build", "@repo/minswap-dex-v2#build", "@repo/minswap-stableswap#build", "@repo/tx-builder#build"], + "@minswap/felis-sundaeswap-v1#build": { + "dependsOn": ["@minswap/felis-ledger-core#build", "@minswap/felis-ledger-utils#build"], "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": ["dist/**"] }, + "@minswap/felis-sundaeswap-v3#build": { + "dependsOn": ["@minswap/felis-ledger-core#build", "@minswap/felis-ledger-utils#build"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": ["dist/**"] + }, + "@minswap/felis-wingriders-v1#build": { + "dependsOn": ["@minswap/felis-ledger-core#build", "@minswap/felis-ledger-utils#build"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": ["dist/**"] + }, + "@minswap/felis-wingriders-v2#build": { + "dependsOn": ["@minswap/felis-ledger-core#build", "@minswap/felis-ledger-utils#build"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": ["dist/**"] + }, + "@minswap/felis-build-tx#build": { + "dependsOn": ["@minswap/felis-tx-builder#build", "@minswap/felis-dex-v2#build"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": ["dist/**"] + }, + "@minswap/felis-stableswap#build": { + "dependsOn": ["@minswap/felis-dex-v2#build", "@minswap/felis-build-tx#build", "@minswap/felis-tx-builder#build"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": ["dist/**"] + }, + "@minswap/felis-lending-market#build": { + "dependsOn": ["@minswap/felis-tx-builder#build", "@minswap/felis-build-tx#build"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": ["dist/**"] + }, + "@minswap/felis-syncer#build": { + "dependsOn": ["@minswap/felis-dex-v1#build", "@minswap/felis-dex-v2#build", "@minswap/felis-stableswap#build", "@minswap/felis-tx-builder#build"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": ["dist/**"] + }, + "@minswap/felis#build": { + "dependsOn": [ + "@minswap/felis-ledger-utils#build", + "@minswap/felis-ledger-core#build", + "@minswap/felis-cip#build", + "@minswap/felis-tx-builder#build", + "@minswap/felis-provider#build", + "@minswap/felis-dex-v1#build", + "@minswap/felis-dex-v2#build", + "@minswap/felis-build-tx#build", + "@minswap/felis-stableswap#build", + "@minswap/felis-lending-market#build", + "@minswap/felis-splash#build", + "@minswap/felis-sundaeswap-v1#build", + "@minswap/felis-sundaeswap-v3#build", + "@minswap/felis-wingriders-v1#build", + "@minswap/felis-wingriders-v2#build", + "@minswap/felis-syncer#build" + ], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": ["dist/**"] + }, + "long-short-backend#build": { + "dependsOn": [ + "@minswap/felis-build-tx#build", + "@minswap/felis-cip#build", + "@minswap/felis-dex-v1#build", + "@minswap/felis-dex-v2#build", + "@minswap/felis-ledger-core#build", + "@minswap/felis-ledger-utils#build", + "@minswap/felis-tx-builder#build" + ], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": ["dist/**"] + }, + "@apps/example#build": { + "dependsOn": [ + "@minswap/felis-ledger-core#build", + "@minswap/felis-ledger-utils#build", + "@minswap/felis-dex-v2#build", + "@minswap/felis-sundaeswap-v1#build", + "@minswap/felis-sundaeswap-v3#build", + "@minswap/felis-syncer#build", + "@minswap/felis-provider#build", + "@minswap/felis-tx-builder#build" + ], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": ["dist/**"] + }, + "web#build": { + "dependsOn": [ + "@minswap/felis-ledger-core#build", + "@minswap/felis-ledger-utils#build", + "@minswap/felis-tx-builder#build", + "@minswap/felis-cip#build", + "@minswap/felis-lending-market#build", + "@minswap/felis-dex-v2#build", + "@minswap/felis-build-tx#build" + ], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": [".next/**", "!.next/cache/**"] + }, "lint": { "dependsOn": ["^lint"] }, From 920cdbad6dc164898b0648711613e9563a653fcb Mon Sep 17 00:00:00 2001 From: tony Date: Wed, 4 Feb 2026 15:06:16 +0700 Subject: [PATCH 06/35] build long_buy --- apps/example/src/cardanoscan.ts | 3 +- .../1770117151271_update_position_table.ts | 60 ++++ .../1770117151272_update_order_table.ts | 38 +++ ...117151273_update_market_config_leverage.ts | 10 + .../1770117151274_order_nullable_tx_fields.ts | 12 + .../1770117151275_order_built_tx_fields.ts | 14 + apps/long-short-backend/src/api/helper.ts | 4 +- .../src/api/routes/position.ts | 140 +++++++-- apps/long-short-backend/src/api/schemas.ts | 76 +++-- apps/long-short-backend/src/api/server.ts | 12 +- .../src/api/state-machine.ts | 98 +++++++ .../src/{index.ts => cmd/run-api.ts} | 33 ++- apps/long-short-backend/src/config/market.ts | 2 +- apps/long-short-backend/src/constants.ts | 2 + apps/long-short-backend/src/database/db.d.ts | 48 +-- .../src/provider/cardanoscan.ts | 274 +++++++++++++++++ apps/long-short-backend/src/provider/index.ts | 1 + .../src/repository/order-repository.ts | 155 ++++++++++ .../src/repository/position-repository.ts | 121 +++----- .../src/services/position-service.ts | 275 ++++++++++++++---- apps/long-short-backend/src/utils/hash.ts | 7 + apps/long-short-backend/src/utils/index.ts | 1 + .../long-short-backend/test/sign-data.test.ts | 117 ++++++-- 23 files changed, 1252 insertions(+), 251 deletions(-) create mode 100644 apps/long-short-backend/.config/migrations/1770117151271_update_position_table.ts create mode 100644 apps/long-short-backend/.config/migrations/1770117151272_update_order_table.ts create mode 100644 apps/long-short-backend/.config/migrations/1770117151273_update_market_config_leverage.ts create mode 100644 apps/long-short-backend/.config/migrations/1770117151274_order_nullable_tx_fields.ts create mode 100644 apps/long-short-backend/.config/migrations/1770117151275_order_built_tx_fields.ts create mode 100644 apps/long-short-backend/src/api/state-machine.ts rename apps/long-short-backend/src/{index.ts => cmd/run-api.ts} (51%) create mode 100644 apps/long-short-backend/src/provider/cardanoscan.ts create mode 100644 apps/long-short-backend/src/repository/order-repository.ts create mode 100644 apps/long-short-backend/src/utils/hash.ts diff --git a/apps/example/src/cardanoscan.ts b/apps/example/src/cardanoscan.ts index b5bd710..1713959 100644 --- a/apps/example/src/cardanoscan.ts +++ b/apps/example/src/cardanoscan.ts @@ -2,8 +2,7 @@ import fs from "node:fs"; import { RustModule } from "@minswap/felis-ledger-utils"; import { MinswapStableswapSyncer, MinswapV1Syncer, MinswapV2Syncer, SplashSyncer, SundaeSwapV1Syncer, SundaeSwapV3Syncer, Transaction, WingridersV1Syncer, WingridersV2Syncer } from "@minswap/felis-syncer"; import socketIO from "socket.io-client"; -import { NetworkEnvironment } from "../../../packages/ledger-core/dist/network-id"; -import { Bytes } from "@minswap/felis-ledger-core"; +import { Bytes, NetworkEnvironment } from "@minswap/felis-ledger-core"; const main = async () => { await RustModule.load(); diff --git a/apps/long-short-backend/.config/migrations/1770117151271_update_position_table.ts b/apps/long-short-backend/.config/migrations/1770117151271_update_position_table.ts new file mode 100644 index 0000000..e862aa3 --- /dev/null +++ b/apps/long-short-backend/.config/migrations/1770117151271_update_position_table.ts @@ -0,0 +1,60 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +export async function up(db: Kysely): Promise { + // Step 1: Drop the FK constraint on order.position_id (keep as indexed column only) + await sql`ALTER TABLE "order" DROP CONSTRAINT IF EXISTS "order_position_id_fkey"`.execute(db); + + // Step 2: Drop existing position table + await db.schema.dropTable("position").ifExists().execute(); + + // Step 3: Create new position table + await db.schema + .createTable("position") + .addColumn("id", "bigserial", (col) => col.primaryKey()) + .addColumn("market_id", "varchar(64)", (col) => col.notNull().references("market_config.market_id")) + .addColumn("user_address", "varchar(128)", (col) => col.notNull()) + .addColumn("side", "varchar(8)", (col) => col.notNull()) // LONG | SHORT + .addColumn("status", "varchar(16)", (col) => col.notNull().defaultTo("PENDING")) // PENDING | OPEN | CLOSING | CLOSE + .addColumn("amount_in", "numeric", (col) => col.notNull()) + .addColumn("amount_borrow", "numeric", (col) => col.notNull()) + .addColumn("created_at", "timestamp", (col) => col.notNull().defaultTo(sql`now()`)) + .addColumn("closed_at", "timestamp") + .execute(); + + // Create index on market_id + await db.schema + .createIndex("idx_position_market_id") + .on("position") + .column("market_id") + .execute(); + + // Create index on user_address + await db.schema + .createIndex("idx_position_user_address") + .on("position") + .column("user_address") + .execute(); + + // Create index on closed_at + await db.schema + .createIndex("idx_position_closed_at") + .on("position") + .column("closed_at") + .execute(); + + // Create partial unique index for open positions (closed_at IS NULL) + // This ensures only one open position per user per market + await sql`CREATE UNIQUE INDEX idx_position_user_market_unique_open + ON position (user_address, market_id) + WHERE closed_at IS NULL`.execute(db); + + // Create unique constraint for closed positions + await sql`CREATE UNIQUE INDEX idx_position_user_market_closed_unique + ON position (user_address, market_id, closed_at) + WHERE closed_at IS NOT NULL`.execute(db); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable("position").ifExists().execute(); +} diff --git a/apps/long-short-backend/.config/migrations/1770117151272_update_order_table.ts b/apps/long-short-backend/.config/migrations/1770117151272_update_order_table.ts new file mode 100644 index 0000000..5bab067 --- /dev/null +++ b/apps/long-short-backend/.config/migrations/1770117151272_update_order_table.ts @@ -0,0 +1,38 @@ +import type { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + // Step 1: Drop existing order table + await db.schema.dropTable("order").ifExists().execute(); + + // Step 2: Create new order table + await db.schema + .createTable("order") + .addColumn("id", "bigserial", (col) => col.primaryKey()) + .addColumn("position_id", "bigint", (col) => col.notNull()) // ref to position.id (not FK) + .addColumn("order_type", "varchar(32)", (col) => col.notNull()) // OPEN | CLOSE | INCREASE | DECREASE + .addColumn("created_tx_id", "varchar(64)", (col) => col.notNull()) + .addColumn("created_tx_index", "integer", (col) => col.notNull()) + .addColumn("asset_in", "varchar(128)") + .addColumn("amount_in", "numeric") + .addColumn("asset_out", "varchar(128)") + .addColumn("amount_out", "numeric") + .execute(); + + // Create index on position_id + await db.schema + .createIndex("idx_order_position_id") + .on("order") + .column("position_id") + .execute(); + + // Create index on created_tx_id + await db.schema + .createIndex("idx_order_created_tx_id") + .on("order") + .column("created_tx_id") + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable("order").ifExists().execute(); +} diff --git a/apps/long-short-backend/.config/migrations/1770117151273_update_market_config_leverage.ts b/apps/long-short-backend/.config/migrations/1770117151273_update_market_config_leverage.ts new file mode 100644 index 0000000..31f2659 --- /dev/null +++ b/apps/long-short-backend/.config/migrations/1770117151273_update_market_config_leverage.ts @@ -0,0 +1,10 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "market_config" ALTER COLUMN "leverage" TYPE numeric USING leverage::numeric`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "market_config" ALTER COLUMN "leverage" TYPE integer USING leverage::integer`.execute(db); +} diff --git a/apps/long-short-backend/.config/migrations/1770117151274_order_nullable_tx_fields.ts b/apps/long-short-backend/.config/migrations/1770117151274_order_nullable_tx_fields.ts new file mode 100644 index 0000000..b01dffa --- /dev/null +++ b/apps/long-short-backend/.config/migrations/1770117151274_order_nullable_tx_fields.ts @@ -0,0 +1,12 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "order" ALTER COLUMN "created_tx_id" DROP NOT NULL`.execute(db); + await sql`ALTER TABLE "order" ALTER COLUMN "created_tx_index" DROP NOT NULL`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "order" ALTER COLUMN "created_tx_id" SET NOT NULL`.execute(db); + await sql`ALTER TABLE "order" ALTER COLUMN "created_tx_index" SET NOT NULL`.execute(db); +} diff --git a/apps/long-short-backend/.config/migrations/1770117151275_order_built_tx_fields.ts b/apps/long-short-backend/.config/migrations/1770117151275_order_built_tx_fields.ts new file mode 100644 index 0000000..476bae6 --- /dev/null +++ b/apps/long-short-backend/.config/migrations/1770117151275_order_built_tx_fields.ts @@ -0,0 +1,14 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "order" ADD COLUMN "built_tx_id" TEXT`.execute(db); + await sql`ALTER TABLE "order" ADD COLUMN "built_outputs_hash" TEXT`.execute(db); + await sql`ALTER TABLE "order" ADD COLUMN "built_valid_to" TIMESTAMPTZ`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "order" DROP COLUMN "built_valid_to"`.execute(db); + await sql`ALTER TABLE "order" DROP COLUMN "built_outputs_hash"`.execute(db); + await sql`ALTER TABLE "order" DROP COLUMN "built_tx_id"`.execute(db); +} diff --git a/apps/long-short-backend/src/api/helper.ts b/apps/long-short-backend/src/api/helper.ts index adda41e..2da6d89 100644 --- a/apps/long-short-backend/src/api/helper.ts +++ b/apps/long-short-backend/src/api/helper.ts @@ -1,3 +1,4 @@ +import { HashUtils } from "../utils"; import { verifySignData } from "../utils/signature"; import type { SignedDataType } from "./schemas"; @@ -20,9 +21,10 @@ export namespace ApiHelper { witness: SignedDataType, ): AuthenticateResult { const message = Buffer.from(JSON.stringify(data)).toString("hex"); + const hashMessage = HashUtils.sha256(message); const isValid = verifySignData({ - message, + message: hashMessage, address: userAddress, key: witness.key, signature: witness.signature, diff --git a/apps/long-short-backend/src/api/routes/position.ts b/apps/long-short-backend/src/api/routes/position.ts index 3b7ce88..9076768 100644 --- a/apps/long-short-backend/src/api/routes/position.ts +++ b/apps/long-short-backend/src/api/routes/position.ts @@ -1,46 +1,67 @@ import type { FastifyInstance } from "fastify"; import { API_ENDPOINTS } from "../../constants"; +import { PositionService } from "../../services/position-service"; import type { Position } from "../../repository/position-repository"; -import type { PositionService } from "../../services/position-service"; import { ApiHelper } from "../helper"; import { + type AuthenBuildTxBodyType, + AuthenBuildTxBodyTypeSchema, type AuthenCreatePositionBodyType, AuthenCreatePositionBodyTypeSchema, + BuildTxResponseSchema, + type BuildTxResponseType, CreatePositionResponseSchema, type CreatePositionResponseType, ErrorResponseSchema, + GetPositionQuerySchema, + type GetPositionQueryType, + GetPositionResponseSchema, + type GetPositionResponseType, type PositionResponseType, } from "../schemas"; function positionToResponse(position: Position): PositionResponseType { return { id: position.id.toString(), + market_id: position.marketId, user_address: position.userAddress, - market: position.market, side: position.side, status: position.status, - leverage: position.leverage, - collateral_asset: position.collateralAsset, - collateral_amount: position.collateralAmount, - entry_price: position.entryPrice, - position_size: position.positionSize, - borrowed_amount: position.borrowedAmount, - liquidation_price: position.liquidationPrice, - take_profit_price: position.takeProfitPrice, - stop_loss_price: position.stopLossPrice, - realized_pnl: position.realizedPnl, - unrealized_pnl: position.unrealizedPnl, - funding_paid: position.fundingPaid, + amount_in: position.amountIn, + amount_borrow: position.amountBorrow, created_at: position.createdAt.toISOString(), - updated_at: position.updatedAt.toISOString(), closed_at: position.closedAt?.toISOString() ?? null, }; } -export function registerPositionRoutes( - fastify: FastifyInstance, - positionService: PositionService, -): void { +export function registerPositionRoutes(fastify: FastifyInstance, positionService: PositionService): void { + // GET /position/get?user_address=... + fastify.get<{ + Querystring: GetPositionQueryType; + Reply: GetPositionResponseType; + }>( + API_ENDPOINTS.POSITION_GET, + { + schema: { + querystring: GetPositionQuerySchema, + response: { + 200: GetPositionResponseSchema, + 400: ErrorResponseSchema, + }, + }, + }, + async (request, reply) => { + const { user_address } = request.query; + + const position = await positionService.getOpenPositionByUser(user_address); + + return reply.status(200).send({ + success: true, + data: position ? positionToResponse(position) : null, + }); + }, + ); + // POST /position/create fastify.post<{ Body: AuthenCreatePositionBodyType; @@ -59,7 +80,7 @@ export function registerPositionRoutes( }, async (request, reply) => { const { data, user_address, witness } = request.body; - const { market, side, amount } = data; + const { market_id, side, amount_in } = data; // Authenticate request const authResult = ApiHelper.authenticate(data, user_address, witness); @@ -70,23 +91,62 @@ export function registerPositionRoutes( }); } - // Parse amount - let amountBigInt: bigint; - try { - amountBigInt = BigInt(amount); - } catch { + // Create position via service + const result = await positionService.createPosition({ + userAddress: user_address, + marketId: market_id, + side, + amountIn: BigInt(amount_in), + }); + + if (!result.success) { return reply.status(400).send({ success: false, - error: "Invalid amount format", + error: result.error, }); } - // Create position - const result = await positionService.createPosition({ + return reply.status(200).send({ + success: true, + data: positionToResponse(result.position), + }); + }, + ); + + // POST /position/build-tx + fastify.post<{ + Body: AuthenBuildTxBodyType; + Reply: BuildTxResponseType; + }>( + API_ENDPOINTS.POSITION_BUILD_TX, + { + schema: { + body: AuthenBuildTxBodyTypeSchema, + response: { + 200: BuildTxResponseSchema, + 400: ErrorResponseSchema, + 401: ErrorResponseSchema, + }, + }, + }, + async (request, reply) => { + const { data, user_address, witness } = request.body; + const { market_id, utxos } = data; + + // Authenticate request + const authResult = ApiHelper.authenticate(data, user_address, witness); + if (!authResult.success) { + return reply.status(401).send({ + success: false, + error: authResult.error, + }); + } + + // Build transaction via service + const result = await positionService.buildTx({ userAddress: user_address, - market, - side, - amount: amountBigInt, + marketId: market_id, + utxos, }); if (!result.success) { @@ -96,9 +156,25 @@ export function registerPositionRoutes( }); } + // Handle waiting state (transaction already built, waiting for confirmation) + if ("waiting" in result && result.waiting) { + return reply.status(200).send({ + success: true, + data: { + order_type: result.orderType, + waiting: true, + message: result.message, + }, + }); + } + + // Return newly built transaction return reply.status(200).send({ success: true, - data: positionToResponse(result.position), + data: { + tx_raw: result.txRaw, + order_type: result.orderType, + }, }); }, ); diff --git a/apps/long-short-backend/src/api/schemas.ts b/apps/long-short-backend/src/api/schemas.ts index 72628e0..854a20c 100644 --- a/apps/long-short-backend/src/api/schemas.ts +++ b/apps/long-short-backend/src/api/schemas.ts @@ -1,4 +1,5 @@ import { Type, type Static } from "@sinclair/typebox"; +import { StateMachine } from "./state-machine"; export const SignedDataSchema = Type.Object({ key: Type.String({ minLength: 1, description: "COSEKey hex" }), @@ -21,13 +22,18 @@ export type AuthenCommonType = { witness: SignedDataType; }; -// Position schemas -export const PositionSideSchema = Type.Union([Type.Literal("LONG"), Type.Literal("SHORT")]); +// Position schemas (derived from StateMachine enums) +export const PositionSideSchema = Type.Union( + Object.values(StateMachine.PositionSide).map((v) => Type.Literal(v)), +); +export const PositionStatusSchema = Type.Union( + Object.values(StateMachine.PositionStatus).map((v) => Type.Literal(v)), +); export const CreatePositionDataSchema = Type.Object({ - market: Type.String({ minLength: 1, description: "Trading pair (e.g., ADA-MIN)" }), - side: PositionSideSchema, - amount: Type.String({ pattern: "^[0-9]+$", description: "Collateral amount in lovelace" }), + market_id: Type.String({ minLength: 1, description: "Market ID (e.g., ADA-MIN)" }), + side: Type.Literal("LONG", { description: "Position side (only LONG supported)" }), + amount_in: Type.String({ pattern: "^[0-9]+$", description: "Collateral amount in lovelace" }), }); export type CreatePositionDataType = Static; @@ -38,24 +44,13 @@ export type AuthenCreatePositionBodyType = AuthenCommonType; +// Get position schemas +export const GetPositionQuerySchema = Type.Object({ + user_address: Type.String({ minLength: 1, description: "User's Cardano address (bech32)" }), +}); + +export type GetPositionQueryType = Static; + +export const GetPositionResponseSchema = Type.Object({ + success: Type.Boolean(), + data: Type.Union([PositionResponseSchema, Type.Null()]), +}); + +export type GetPositionResponseType = Static; + +// Build TX schemas +export const BuildTxDataSchema = Type.Object({ + market_id: Type.String({ description: "Market identifier (e.g., ADA-MIN)" }), + utxos: Type.Array(Type.String({ minLength: 1 }), { description: "User UTXOs (CBOR hex)" }), +}); + +export type BuildTxDataType = Static; + +export const AuthenBuildTxBodyTypeSchema = AuthenCommonSchema(BuildTxDataSchema); + +export type AuthenBuildTxBodyType = AuthenCommonType; + +export const BuildTxResponseSchema = Type.Object({ + success: Type.Boolean(), + data: Type.Optional( + Type.Object({ + tx_raw: Type.Optional(Type.String({ description: "Unsigned transaction CBOR hex" })), + order_type: Type.String({ description: "Order type being processed" }), + waiting: Type.Optional(Type.Boolean({ description: "True if transaction is waiting for confirmation" })), + message: Type.Optional(Type.String({ description: "Status message when waiting" })), + }), + ), + error: Type.Optional(Type.String()), +}); + +export type BuildTxResponseType = Static; + // Error response export const ErrorResponseSchema = Type.Object({ success: Type.Literal(false), diff --git a/apps/long-short-backend/src/api/server.ts b/apps/long-short-backend/src/api/server.ts index d40117b..790c212 100644 --- a/apps/long-short-backend/src/api/server.ts +++ b/apps/long-short-backend/src/api/server.ts @@ -1,21 +1,25 @@ import cors from "@fastify/cors"; import Fastify, { type FastifyInstance } from "fastify"; import type { Kysely } from "kysely"; +import type { NetworkEnvironment } from "@minswap/felis-ledger-core"; import type { DB } from "../database"; -import { PositionService } from "../services/position-service"; import { logger } from "../utils"; import { registerMetadataRoutes } from "./routes/metadata"; import { registerPositionRoutes } from "./routes/position"; +import { PositionService } from "../services/position-service"; import { API_ENDPOINTS } from "../constants"; +import { CardanoscanProvider } from "../provider"; export type ApiServerOptions = { port: number; host: string; db: Kysely; + cardanoscanProvider: CardanoscanProvider; + networkEnv: NetworkEnvironment; }; export async function createApiServer(options: ApiServerOptions): Promise { - const { port, host, db } = options; + const { port, host, db, networkEnv, cardanoscanProvider } = options; const fastify = Fastify({ logger: { @@ -34,8 +38,8 @@ export async function createApiServer(options: ApiServerOptions): Promise { + const { order, marketConfig, userAddress, networkEnv, utxos } = options; + invariant(order.order_type === LongOrderType.LONG_BUY, "Invalid order type for handleLongBuy"); + invariant(order.asset_in, "asset_in is required for LONG_BUY order"); + invariant(order.amount_in, "amount_in is required for LONG_BUY order"); + invariant(order.asset_out, "asset_out is required for LONG_BUY order"); + const walletUtxos: Utxo[] = utxos.map((u) => Utxo.fromHex(u)); + const sender = Address.fromBech32(userAddress); + + const txb = DEXOrderTransaction.createBulkOrdersTx({ + networkEnv, + sender, + orderOptions: [{ + lpAsset: Asset.fromString(marketConfig.amm_lp_asset), + version: DexVersion.DEX_V2, + type: OrderV2StepType.SWAP_EXACT_IN, + assetIn: Asset.fromString(marketConfig.asset_a), + amountIn: BigInt(order.amount_in.toString()), + minimumAmountOut: 1n, + direction: OrderV2Direction.A_TO_B, + killOnFailed: false, + isLimitOrder: false, + }], + }); + const validTo = new Date().getTime() + Duration.newMinutes(3).milliseconds; + txb.validToUnixTime(validTo); + + const {txComplete, txId, newUtxoState: { changeUtxos } } = await txb.completeUnsafeForTxChaining({ + coinSelectionAlgorithm: CoinSelectionAlgorithm.SPEND_ALL, + walletUtxos, + changeAddress: sender, + provider: new EmulatorProvider(networkEnv), + }); + const txRaw = txComplete.complete(); + const ECSL = RustModule.getE; + const eTx = ECSL.Transaction.from_hex(txRaw); + const outputsHash = HashUtils.sha256(changeUtxos.map((u) => Utxo.toHex(u)).join(",")); + + safeFreeRustObjects(eTx); + + return { + txRaw, + txId: txId, + outputsHash, + validTo, + } + }; +} diff --git a/apps/long-short-backend/src/index.ts b/apps/long-short-backend/src/cmd/run-api.ts similarity index 51% rename from apps/long-short-backend/src/index.ts rename to apps/long-short-backend/src/cmd/run-api.ts index 43ac624..204a9f8 100644 --- a/apps/long-short-backend/src/index.ts +++ b/apps/long-short-backend/src/cmd/run-api.ts @@ -1,28 +1,38 @@ import { RustModule } from "@minswap/felis-ledger-utils"; -import { createApiServer } from "./api/server"; -import { loadMarketConfigs } from "./config/market"; -import { newKyselyClient } from "./database/postgres"; -import type { DB } from "./database"; -import { logger } from "./utils"; +import { NetworkEnvironment } from "@minswap/felis-ledger-core"; +import { createApiServer } from "../api/server"; +import { loadMarketConfigs } from "../config/market"; +import { newKyselyClient } from "../database/postgres"; +import type { DB } from "../database"; +import { logger } from "../utils"; +import { CardanoscanProvider } from "../provider"; const API_PORT = Number(process.env.API_PORT) || 9999; const API_HOST = process.env.API_HOST || "0.0.0.0"; const DATABASE_URL = process.env.DATABASE_URL; +const NETWORK = process.env.NETWORK || "mainnet"; +const CARDANOSCAN_API_KEY = process.env.CARDANOSCAN_API_KEY; async function main() { // Validate environment if (!DATABASE_URL) { throw new Error("DATABASE_URL environment variable is required"); } + if (!CARDANOSCAN_API_KEY) { + throw new Error("CARDANOSCAN_API_KEY environment variable is required"); + } + + // Parse network environment + const networkEnv = NETWORK === "mainnet" ? NetworkEnvironment.MAINNET : NetworkEnvironment.TESTNET_PREVIEW; + logger.info(`Network environment: ${NETWORK}`); - // Load WASM modules logger.info("Loading WASM modules..."); await RustModule.load(); logger.info("WASM modules loaded"); // Connect to database logger.info("Connecting to database..."); - const db = await newKyselyClient(DATABASE_URL); + const db = await newKyselyClient(DATABASE_URL, { logSQL: true }); logger.info("Database connected"); // Load market configs from database @@ -30,17 +40,26 @@ async function main() { const marketConfigs = await loadMarketConfigs(db); logger.info(`Loaded ${marketConfigs.size} market configs`); + // Create Cardanoscan provider + const cardanoscanProvider = new CardanoscanProvider( + "https://api.cardanoscan.io/api/v1", + CARDANOSCAN_API_KEY, + ); + // Start API server logger.info("Starting API server..."); await createApiServer({ port: API_PORT, host: API_HOST, db, + networkEnv, + cardanoscanProvider, }); logger.info("Long-Short Backend started successfully", { port: API_PORT, host: API_HOST, + network: NETWORK, }); } diff --git a/apps/long-short-backend/src/config/market.ts b/apps/long-short-backend/src/config/market.ts index 860df71..1d1269e 100644 --- a/apps/long-short-backend/src/config/market.ts +++ b/apps/long-short-backend/src/config/market.ts @@ -43,7 +43,7 @@ export async function loadMarketConfigs(db: Kysely): Promise; - leverage: number; + leverage: Numeric; market_id: string; min_collateral: Numeric; } export interface Order { - action: string; - collateral_amount: Numeric | null; - created_at: Generated; - expires_at: Timestamp | null; - filled_at: Timestamp | null; - filled_price: Numeric | null; + amount_in: Numeric | null; + amount_out: Numeric | null; + asset_in: string | null; + asset_out: string | null; + built_outputs_hash: string | null; + built_tx_id: string | null; + built_valid_to: Timestamp | null; + created_tx_id: string | null; + created_tx_index: number | null; id: Generated; - leverage: Numeric | null; - market: string; order_type: string; - position_id: Int8 | null; - price: Numeric | null; - side: string; - size: Numeric; - slippage_tolerance: Numeric | null; - status: Generated; - trigger_price: Numeric | null; - tx_hash: string | null; - user_address: string; + position_id: Int8; } export interface Position { - borrowed_amount: Numeric; + amount_borrow: Numeric; + amount_in: Numeric; closed_at: Timestamp | null; - collateral_amount: Numeric; - collateral_asset: string; created_at: Generated; - entry_price: Numeric; - funding_paid: Generated; id: Generated; - leverage: Numeric; - liquidation_price: Numeric; - liqwid_borrow_id: string | null; - liqwid_supply_id: string | null; - market: string; - position_size: Numeric; - realized_pnl: Generated; + market_id: string; side: string; status: Generated; - stop_loss_price: Numeric | null; - take_profit_price: Numeric | null; - unrealized_pnl: Generated; - updated_at: Generated; user_address: string; } diff --git a/apps/long-short-backend/src/provider/cardanoscan.ts b/apps/long-short-backend/src/provider/cardanoscan.ts new file mode 100644 index 0000000..f3f52c8 --- /dev/null +++ b/apps/long-short-backend/src/provider/cardanoscan.ts @@ -0,0 +1,274 @@ +import { Address } from "@minswap/felis-ledger-core"; +import { logger } from "../utils"; +import { RustModule, safeFreeRustObjects } from "@minswap/felis-ledger-utils"; + +/** + * Transaction input/output structure from Cardanoscan API + */ +export type CardanoscanTxIO = { + address: string; + value: string; + tokens?: Array<{ + quantity: string; + unit: string; + }>; + datum?: string; + scriptRef?: string; +}; + +/** + * Transaction object from Cardanoscan API + */ +export type CardanoscanTransaction = { + hash: string; + blockHash: string; + fees: string; + slot: number; + epoch: number; + blockHeight: number; + timestamp: string; // ISO 8601 date-time + index: number; + inputs: CardanoscanTxIO[]; + outputs: CardanoscanTxIO[]; + collateral: CardanoscanTxIO[]; + certificates?: { + stakeDelegations?: Array<{ + stakeAddress: string; + poolId: string; + }>; + poolRegistrations?: Array>; + governanceActions?: Array>; + }; + withdrawals?: Array<{ + address: string; + amount: string; + }>; + metadata?: { + hash: string; + labels: Record; + }; + mint?: Array<{ + quantity: string; + unit: string; + }>; + redeemers?: Array<{ + index: number; + purpose: string; + scriptHash: string; + redeemerDataHash: string; + executionUnits: { + memory: number; + steps: number; + }; + }>; + status: boolean; + votingProcedures?: Array>; +}; + +/** + * Response from Cardanoscan transaction list API + */ +export type CardanoscanTransactionListResponse = { + pageNo: number; + limit: number; + transactions: CardanoscanTransaction[]; +}; + +/** + * Options for fetching transaction list + */ +export type GetTransactionListOptions = { + address: string; + pageNo: number; + limit?: number; // 1-50, default 20 + order?: "asc" | "desc"; // default "asc" +}; + +/** + * Cardanoscan API Provider + * Provides access to Cardano blockchain data via Cardanoscan API + */ +export class CardanoscanProvider { + private readonly baseUrl: string; + private readonly apiKey: string; + + constructor(baseUrl: string, apiKey: string) { + this.baseUrl = baseUrl.replace(/\/$/, ""); // Remove trailing slash + this.apiKey = apiKey; + } + + /** + * Get transaction list for a specific address + * @param options - Request options including address, pagination, and ordering + * @returns Transaction list response with pagination info + */ + async getTransactionList(options: GetTransactionListOptions): Promise { + const { address, pageNo, limit = 20, order = "asc" } = options; + + // Validate parameters + if (!address || address.length > 200) { + throw new Error("Address is required and must be max 200 characters"); + } + if (pageNo < 1) { + throw new Error("pageNo must be at least 1"); + } + if (limit && (limit < 1 || limit > 50)) { + throw new Error("limit must be between 1 and 50"); + } + if (order && !["asc", "desc"].includes(order)) { + throw new Error("order must be 'asc' or 'desc'"); + } + + const url = new URL(`${this.baseUrl}/transaction/list`); + url.searchParams.set("address", address); + url.searchParams.set("pageNo", pageNo.toString()); + url.searchParams.set("limit", limit.toString()); + url.searchParams.set("order", order); + + try { + const response = await fetch(url.toString(), { + method: "GET", + headers: { + Accept: "application/json", + "apiKey": this.apiKey, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error("Cardanoscan API error", { + status: response.status, + statusText: response.statusText, + body: errorText, + }); + throw new Error(`Cardanoscan API error: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as CardanoscanTransactionListResponse; + return data; + } catch (error) { + logger.error("Failed to fetch transaction list from Cardanoscan", error); + throw error; + } + } + + /** + * Get transaction list for an Address object + * @param address - Address to query + * @param pageNo - Page number (1-indexed) + * @param limit - Results per page (1-50, default 20) + * @param order - Sort order (asc or desc, default asc) + * @returns Transaction list response + */ + async getTransactionListByAddress( + address: Address, + pageNo: number, + limit?: number, + order?: "asc" | "desc", + ): Promise { + const ECSL = RustModule.getE; + const ea = ECSL.Address.from_bech32(address.bech32); + const ah = ea.to_hex(); + safeFreeRustObjects(ea); + return this.getTransactionList({ + address: ah, + pageNo, + limit, + order, + }); + } + + /** + * Get all transactions for an address (handles pagination automatically) + * Warning: This can make many API calls for addresses with many transactions + * @param address - Address to query + * @param maxPages - Maximum number of pages to fetch (default: no limit) + * @returns All transactions + */ + async getAllTransactionsByAddress( + address: Address, + maxPages?: number, + ): Promise { + const allTransactions: CardanoscanTransaction[] = []; + let pageNo = 1; + let hasMore = true; + + while (hasMore && (!maxPages || pageNo <= maxPages)) { + const response = await this.getTransactionListByAddress(address, pageNo, 50, "desc"); + allTransactions.push(...response.transactions); + + // If we got fewer transactions than the limit, we've reached the end + hasMore = response.transactions.length === response.limit; + pageNo++; + } + + logger.info(`Fetched ${allTransactions.length} transactions for ${address.bech32}`, { + pages: pageNo - 1, + }); + + return allTransactions; + } + + /** + * Get the most recent transaction for an address + * @param address - Address to query + * @returns Most recent transaction or null if no transactions found + */ + async getLatestTransaction(address: Address): Promise { + const response = await this.getTransactionListByAddress(address, 1, 1, "desc"); + return response.transactions[0] || null; + } + + /** + * Get transactions within a specific slot range + * @param address - Address to query + * @param minSlot - Minimum slot number (inclusive) + * @param maxSlot - Maximum slot number (inclusive) + * @returns Transactions within the slot range + */ + async getTransactionsBySlotRange( + address: Address, + minSlot: number, + maxSlot: number, + ): Promise { + const allTransactions = await this.getAllTransactionsByAddress(address); + return allTransactions.filter((tx) => tx.slot >= minSlot && tx.slot <= maxSlot); + } + + /** + * Check if a transaction exists for an address + * @param address - Address to query + * @param txHash - Transaction hash to find + * @returns The transaction if found, null otherwise + */ + async findTransactionByHash( + address: Address, + txHash: string, + pageSize?: number, + maxPage?: number, + ): Promise { + // Start from most recent and work backwards + let pageNo = 1; + let hasMore = true; + + while (hasMore) { + const response = await this.getTransactionListByAddress(address, pageNo, pageSize ?? 50, "desc"); + + const found = response.transactions.find((tx) => tx.hash === txHash); + if (found) { + return found; + } + + hasMore = response.transactions.length === response.limit; + pageNo++; + + // Safety limit to prevent infinite loops + if (pageNo > (maxPage ?? 100)) { + logger.warn(`Searched ${maxPage ?? 100} pages without finding transaction ${txHash}`); + break; + } + } + + return null; + } +} diff --git a/apps/long-short-backend/src/provider/index.ts b/apps/long-short-backend/src/provider/index.ts index 7840d0d..6916900 100644 --- a/apps/long-short-backend/src/provider/index.ts +++ b/apps/long-short-backend/src/provider/index.ts @@ -1 +1,2 @@ export * from "./kupo"; +export * from "./cardanoscan"; diff --git a/apps/long-short-backend/src/repository/order-repository.ts b/apps/long-short-backend/src/repository/order-repository.ts new file mode 100644 index 0000000..6b2af53 --- /dev/null +++ b/apps/long-short-backend/src/repository/order-repository.ts @@ -0,0 +1,155 @@ +import type { Kysely, Transaction } from "kysely"; +import type { DB } from "../database"; + +export type CreateOrderParams = { + positionId: bigint; + orderType: string; + createdTxId?: string | null; + createdTxIndex?: number | null; + assetIn?: string | null; + amountIn?: string | null; + assetOut?: string | null; + amountOut?: string | null; +}; + +export type Order = { + id: bigint; + positionId: bigint; + orderType: string; + createdTxId: string | null; + createdTxIndex: number | null; + assetIn: string | null; + amountIn: string | null; + assetOut: string | null; + amountOut: string | null; + builtTxId: string | null; + builtOutputsHash: string | null; + builtValidTo: Date | null; +}; + +export namespace OrderRepository { + export async function createOrder( + db: Kysely | Transaction, + params: CreateOrderParams, + ): Promise { + const result = await db + .insertInto("order") + .values({ + position_id: params.positionId.toString(), + order_type: params.orderType, + created_tx_id: params.createdTxId ?? null, + created_tx_index: params.createdTxIndex ?? null, + asset_in: params.assetIn ?? null, + amount_in: params.amountIn ?? null, + asset_out: params.assetOut ?? null, + amount_out: params.amountOut ?? null, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + return mapOrderRow(result); + } + + export async function createOrders( + db: Kysely | Transaction, + params: CreateOrderParams[], + ): Promise { + const results = await db + .insertInto("order") + .values( + params.map((p) => ({ + position_id: p.positionId.toString(), + order_type: p.orderType, + created_tx_id: p.createdTxId ?? null, + created_tx_index: p.createdTxIndex ?? null, + asset_in: p.assetIn ?? null, + amount_in: p.amountIn ?? null, + asset_out: p.assetOut ?? null, + amount_out: p.amountOut ?? null, + })), + ) + .returningAll() + .execute(); + + return results.map(mapOrderRow); + } + + export async function getOrdersByPositionId( + db: Kysely | Transaction, + positionId: bigint, + ): Promise { + const results = await db + .selectFrom("order") + .selectAll() + .where("position_id", "=", positionId.toString()) + .execute(); + + return results.map(mapOrderRow); + } + + /** + * Find the next unhandled order for a position. + * An order is ready to handle if it has asset_in, amount_in, asset_out and created_tx_id is null or empty. + */ + export async function getNextUnhandledOrder( + db: Kysely | Transaction, + positionId: bigint, + ): Promise { + const result = await db + .selectFrom("order") + .selectAll() + .where("position_id", "=", positionId.toString()) + .where((eb) => + eb.or([ + eb("created_tx_id", "is", null), + eb("created_tx_id", "=", ""), + ]), + ) + .where("asset_in", "is not", null) + .where("amount_in", "is not", null) + .where("asset_out", "is not", null) + .orderBy("id", "asc") + .executeTakeFirst(); + + return result ? mapOrderRow(result) : null; + } + + /** + * Update order built_tx fields after building transaction + */ + export async function updateOrderBuiltTx( + db: Kysely | Transaction, + orderId: bigint, + builtTxId: string, + builtOutputsHash: string, + builtValidTo: Date, + ): Promise { + await db + .updateTable("order") + .set({ + built_tx_id: builtTxId, + built_outputs_hash: builtOutputsHash, + built_valid_to: builtValidTo, + }) + .where("id", "=", orderId.toString()) + .execute(); + } + + // biome-ignore lint/suspicious/noExplicitAny: DB row type + function mapOrderRow(row: any): Order { + return { + id: BigInt(row.id), + positionId: BigInt(row.position_id), + orderType: row.order_type, + createdTxId: row.created_tx_id, + createdTxIndex: row.created_tx_index, + assetIn: row.asset_in, + amountIn: row.amount_in, + assetOut: row.asset_out, + amountOut: row.amount_out, + builtTxId: row.built_tx_id, + builtOutputsHash: row.built_outputs_hash, + builtValidTo: row.built_valid_to ? new Date(row.built_valid_to) : null, + }; + } +} diff --git a/apps/long-short-backend/src/repository/position-repository.ts b/apps/long-short-backend/src/repository/position-repository.ts index b8dd575..fead57a 100644 --- a/apps/long-short-backend/src/repository/position-repository.ts +++ b/apps/long-short-backend/src/repository/position-repository.ts @@ -1,48 +1,24 @@ import type { Kysely, Transaction } from "kysely"; import type { DB } from "../database"; - -export type PositionStatus = "OPEN" | "CLOSED" | "LIQUIDATED"; -export type PositionSide = "LONG" | "SHORT"; +import { StateMachine } from "../api/state-machine"; export type CreatePositionParams = { + marketId: string; userAddress: string; - market: string; - side: PositionSide; - leverage: string; - collateralAsset: string; - collateralAmount: string; - entryPrice: string; - positionSize: string; - borrowedAmount: string; - liquidationPrice: string; - takeProfitPrice?: string; - stopLossPrice?: string; - liqwidSupplyId?: string; - liqwidBorrowId?: string; + side: StateMachine.PositionSide; + amountIn: string; + amountBorrow: string; }; export type Position = { id: bigint; + marketId: string; userAddress: string; - market: string; - side: PositionSide; - status: PositionStatus; - leverage: string; - collateralAsset: string; - collateralAmount: string; - entryPrice: string; - positionSize: string; - borrowedAmount: string; - liquidationPrice: string; - takeProfitPrice: string | null; - stopLossPrice: string | null; - realizedPnl: string; - unrealizedPnl: string; - fundingPaid: string; - liqwidSupplyId: string | null; - liqwidBorrowId: string | null; + side: StateMachine.PositionSide; + status: StateMachine.PositionStatus; + amountIn: string; + amountBorrow: string; createdAt: Date; - updatedAt: Date; closedAt: Date | null; }; @@ -54,21 +30,12 @@ export namespace PositionRepository { const result = await db .insertInto("position") .values({ + market_id: params.marketId, user_address: params.userAddress, - market: params.market, side: params.side, - status: "OPEN", - leverage: params.leverage, - collateral_asset: params.collateralAsset, - collateral_amount: params.collateralAmount, - entry_price: params.entryPrice, - position_size: params.positionSize, - borrowed_amount: params.borrowedAmount, - liquidation_price: params.liquidationPrice, - take_profit_price: params.takeProfitPrice ?? null, - stop_loss_price: params.stopLossPrice ?? null, - liqwid_supply_id: params.liqwidSupplyId ?? null, - liqwid_borrow_id: params.liqwidBorrowId ?? null, + status: StateMachine.PositionStatus.PENDING, + amount_in: params.amountIn, + amount_borrow: params.amountBorrow, }) .returningAll() .executeTakeFirstOrThrow(); @@ -89,17 +56,31 @@ export namespace PositionRepository { return result ? mapPositionRow(result) : null; } + export async function getOpenPositionByUser( + db: Kysely | Transaction, + userAddress: string, + ): Promise { + const result = await db + .selectFrom("position") + .selectAll() + .where("user_address", "=", userAddress) + .where("closed_at", "is", null) + .executeTakeFirst(); + + return result ? mapPositionRow(result) : null; + } + export async function getOpenPositionByUserAndMarket( db: Kysely | Transaction, userAddress: string, - market: string, + marketId: string, ): Promise { const result = await db .selectFrom("position") .selectAll() .where("user_address", "=", userAddress) - .where("market", "=", market) - .where("status", "=", "OPEN") + .where("market_id", "=", marketId) + .where("closed_at", "is", null) .executeTakeFirst(); return result ? mapPositionRow(result) : null; @@ -113,7 +94,7 @@ export namespace PositionRepository { .selectFrom("position") .selectAll() .where("user_address", "=", userAddress) - .where("status", "=", "OPEN") + .where("closed_at", "is", null) .orderBy("created_at", "desc") .execute(); @@ -123,15 +104,15 @@ export namespace PositionRepository { export async function getUserPositions( db: Kysely | Transaction, userAddress: string, - options?: { status?: PositionStatus; limit?: number; offset?: number }, + options?: { includesClosed?: boolean; limit?: number; offset?: number }, ): Promise { let query = db .selectFrom("position") .selectAll() .where("user_address", "=", userAddress); - if (options?.status) { - query = query.where("status", "=", options.status); + if (!options?.includesClosed) { + query = query.where("closed_at", "is", null); } query = query.orderBy("created_at", "desc"); @@ -151,14 +132,11 @@ export namespace PositionRepository { export async function updatePositionStatus( db: Kysely | Transaction, id: bigint, - status: PositionStatus, + status: StateMachine.PositionStatus, ): Promise { - const updates: Record = { - status, - updated_at: new Date(), - }; + const updates: Record = { status }; - if (status === "CLOSED" || status === "LIQUIDATED") { + if (status === StateMachine.PositionStatus.CLOSED) { updates.closed_at = new Date(); } @@ -173,26 +151,13 @@ export namespace PositionRepository { function mapPositionRow(row: any): Position { return { id: BigInt(row.id), + marketId: row.market_id, userAddress: row.user_address, - market: row.market, - side: row.side as PositionSide, - status: row.status as PositionStatus, - leverage: row.leverage, - collateralAsset: row.collateral_asset, - collateralAmount: row.collateral_amount, - entryPrice: row.entry_price, - positionSize: row.position_size, - borrowedAmount: row.borrowed_amount, - liquidationPrice: row.liquidation_price, - takeProfitPrice: row.take_profit_price, - stopLossPrice: row.stop_loss_price, - realizedPnl: row.realized_pnl, - unrealizedPnl: row.unrealized_pnl, - fundingPaid: row.funding_paid, - liqwidSupplyId: row.liqwid_supply_id, - liqwidBorrowId: row.liqwid_borrow_id, + side: row.side as StateMachine.PositionSide, + status: row.status as StateMachine.PositionStatus, + amountIn: row.amount_in, + amountBorrow: row.amount_borrow, createdAt: new Date(row.created_at), - updatedAt: new Date(row.updated_at), closedAt: row.closed_at ? new Date(row.closed_at) : null, }; } diff --git a/apps/long-short-backend/src/services/position-service.ts b/apps/long-short-backend/src/services/position-service.ts index ccc1e4e..35af3e6 100644 --- a/apps/long-short-backend/src/services/position-service.ts +++ b/apps/long-short-backend/src/services/position-service.ts @@ -1,45 +1,65 @@ import type { Kysely } from "kysely"; -import { getMarketConfig, isSupportedMarket, type MarketConfig } from "../config/market"; +import { Address, type NetworkEnvironment } from "@minswap/felis-ledger-core"; +import { getMarketConfig, isSupportedMarket } from "../config/market"; import type { DB } from "../database"; import { type Position, PositionRepository } from "../repository/position-repository"; +import { OrderRepository } from "../repository/order-repository"; +import { StateMachine } from "../api/state-machine"; +import { logger } from "../utils"; +import { CardanoscanProvider } from "../provider"; export type CreatePositionInput = { userAddress: string; - market: string; - side: "LONG" | "SHORT"; - amount: bigint; // collateral amount in lovelace + marketId: string; + side: "LONG"; + amountIn: bigint; }; export type CreatePositionResult = | { success: true; position: Position } | { success: false; error: string }; +export type BuildTxInput = { + userAddress: string; + marketId: string; + utxos: string[]; +}; + +export type BuildTxResult = + | { success: true; txRaw: string; orderType: string } + | { success: true; waiting: true; orderType: string; message: string } + | { success: false; error: string }; + export class PositionService { - constructor(private readonly db: Kysely) {} + constructor( + private readonly db: Kysely, + private readonly networkEnv: NetworkEnvironment, + private readonly cardanoscanProvider: CardanoscanProvider, + ) {} async createPosition(input: CreatePositionInput): Promise { - const { userAddress, market, side, amount } = input; + const { userAddress, marketId, side, amountIn } = input; - // Validate market - if (!isSupportedMarket(market)) { - return { success: false, error: `Market "${market}" is not supported` }; + // Only LONG side is supported + if (side !== "LONG") { + return { success: false, error: "Only LONG side is supported" }; } - const marketConfig = getMarketConfig(market); - if (!marketConfig) { - return { success: false, error: `Market "${market}" configuration not found` }; + // Validate market + if (!isSupportedMarket(marketId)) { + return { success: false, error: `Market "${marketId}" is not supported or disabled` }; } - if (!marketConfig.enable) { - return { success: false, error: `Market "${market}" is currently disabled` }; + const marketConfig = getMarketConfig(marketId); + if (!marketConfig) { + return { success: false, error: `Market "${marketId}" configuration not found` }; } // Validate minimum collateral - if (amount < marketConfig.minCollateral) { - const minLovelace = marketConfig.minCollateral; + if (amountIn < marketConfig.minCollateral) { return { success: false, - error: `Minimum collateral is ${minLovelace} lovelace`, + error: `Minimum collateral is ${marketConfig.minCollateral} lovelace`, }; } @@ -47,60 +67,211 @@ export class PositionService { const existingPosition = await PositionRepository.getOpenPositionByUserAndMarket( this.db, userAddress, - market, + marketId, ); if (existingPosition) { return { success: false, - error: `You already have an open position in market "${market}". Close it first or modify the existing position.`, + error: "User already has an open position for this market", }; } - // For now, create a pending position - // In a full implementation, this would: - // 1. Calculate entry price from DEX - // 2. Calculate position size based on leverage - // 3. Interact with Liqwid to supply collateral and borrow - // 4. Execute swap on Minswap DEX + // Calculate amount_borrow = amount_in * (leverage - 1) + const amountBorrow = BigInt(Math.floor(Number(amountIn) * (marketConfig.leverage - 1))); - const position = await PositionRepository.createPosition(this.db, { - userAddress, - market, - side, - leverage: marketConfig.leverage.toString(), - collateralAsset: marketConfig.assetA.toString(), - collateralAmount: amount.toString(), - entryPrice: "0", // TODO: Get from price oracle - positionSize: amount.toString(), // TODO: Calculate based on leverage - borrowedAmount: "0", // TODO: Calculate based on leverage - liquidationPrice: "0", // TODO: Calculate liquidation price + // Execute transaction: create position + 4 orders + const position = await this.db.transaction().execute(async (trx) => { + const pos = await PositionRepository.createPosition(trx, { + marketId, + userAddress, + side: side as StateMachine.PositionSide, + amountIn: amountIn.toString(), + amountBorrow: amountBorrow.toString(), + }); + + // Create 4 LONG orders + await OrderRepository.createOrders(trx, [ + { + positionId: pos.id, + orderType: StateMachine.LongOrderType.LONG_BUY, + assetIn: marketConfig.assetA.toString(), + amountIn: pos.amountIn, + assetOut: marketConfig.assetB.toString(), + amountOut: "1", + }, + { + positionId: pos.id, + orderType: StateMachine.LongOrderType.LONG_SUPPLY, + }, + { + positionId: pos.id, + orderType: StateMachine.LongOrderType.LONG_BORROW, + }, + { + positionId: pos.id, + orderType: StateMachine.LongOrderType.LONG_BUY_MORE, + }, + ]); + + return pos; }); return { success: true, position }; } - async getPosition(positionId: bigint): Promise { - return PositionRepository.getPositionById(this.db, positionId); - } + async buildTx(input: BuildTxInput): Promise { + const { userAddress, marketId, utxos } = input; - async getUserOpenPositions(userAddress: string): Promise { - return PositionRepository.getUserOpenPositions(this.db, userAddress); - } - - async getUserPositions( - userAddress: string, - options?: { status?: "OPEN" | "CLOSED" | "LIQUIDATED"; limit?: number; offset?: number }, - ): Promise { - return PositionRepository.getUserPositions(this.db, userAddress, options); - } + // Validate market + if (!isSupportedMarket(marketId)) { + return { success: false, error: `Market "${marketId}" is not supported or disabled` }; + } - async hasOpenPosition(userAddress: string, market: string): Promise { + // Check if user has an open position for this market const position = await PositionRepository.getOpenPositionByUserAndMarket( this.db, userAddress, - market, + marketId, ); - return position !== null; + + if (!position) { + return { success: false, error: "No open position found for this market" }; + } + + // Find next unhandled order + const order = await OrderRepository.getNextUnhandledOrder(this.db, position.id); + if (!order) { + return { success: false, error: "No unhandled order found" }; + } + + try { + // Case 1: Order has built_tx_id not null => check if tx appears on chain + if (order.builtTxId) { + logger.info("Order has built_tx_id, checking transaction status", { + orderId: order.id, + builtTxId: order.builtTxId, + }); + + const address = Address.fromBech32(userAddress); + const txFound = await this.cardanoscanProvider.findTransactionByHash( + address, + order.builtTxId, + 50, // pageSize + 10, // maxPage - search up to 10 pages (500 transactions) + ); + + // Case 1a: Transaction found on chain => order is handled, move to next + if (txFound) { + logger.info("Transaction found on chain", { + orderId: order.id, + txHash: order.builtTxId, + }); + return { + success: false, + error: "Transaction already submitted and found on chain. This order is being processed.", + }; + } + + // Case 3: Transaction not found on chain + // Check if transaction has expired + const now = new Date(); + const validTo = order.builtValidTo; + + if (!validTo) { + logger.warn("Order has built_tx_id but no built_valid_to, rebuilding", { + orderId: order.id, + }); + // Fall through to rebuild + } else if (validTo < now) { + // Case 3a: Transaction expired => rebuild + logger.info("Transaction expired, rebuilding", { + orderId: order.id, + validTo: validTo.toISOString(), + now: now.toISOString(), + }); + // Fall through to rebuild + } else { + // Case 3b: Transaction not expired yet => wait + const remainingMs = validTo.getTime() - now.getTime(); + const remainingMinutes = Math.ceil(remainingMs / 1000 / 60); + logger.info("Transaction not yet expired, waiting", { + orderId: order.id, + validTo: validTo.toISOString(), + remainingMinutes, + }); + return { + success: true, + waiting: true, + orderType: order.orderType, + message: `Transaction already built and waiting for confirmation. Expires in ${remainingMinutes} minutes.`, + }; + } + } + + // Case 2: Order has no built_tx_id OR transaction expired => build new transaction + logger.info("Building new transaction", { + orderId: order.id, + orderType: order.orderType, + hasPreviousBuild: !!order.builtTxId, + }); + + // Fetch raw DB rows for StateMachine handlers + const orderRow = await this.db + .selectFrom("order") + .selectAll() + .where("id", "=", order.id.toString()) + .executeTakeFirstOrThrow(); + + const marketConfigRow = await this.db + .selectFrom("market_config") + .selectAll() + .where("market_id", "=", marketId) + .executeTakeFirstOrThrow(); + + // Build transaction based on order type + let txResult: { txRaw: string; txId: string; outputsHash: string; validTo: number }; + + switch (order.orderType) { + case StateMachine.LongOrderType.LONG_BUY: + txResult = await StateMachine.handleLongBuy({ + order: orderRow, + marketConfig: marketConfigRow, + userAddress, + networkEnv: this.networkEnv, + utxos, + }); + break; + default: + return { success: false, error: `Order type "${order.orderType}" is not implemented` }; + } + + // Update order built_tx fields + await OrderRepository.updateOrderBuiltTx( + this.db, + order.id, + txResult.txId, + txResult.outputsHash, + new Date(txResult.validTo), + ); + + logger.info("Transaction built successfully", { + orderId: order.id, + txId: txResult.txId, + validTo: new Date(txResult.validTo).toISOString(), + }); + + return { success: true, txRaw: txResult.txRaw, orderType: order.orderType }; + } catch (error) { + logger.error("error building tx", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to build transaction", + }; + } + } + + async getOpenPositionByUser(userAddress: string): Promise { + return PositionRepository.getOpenPositionByUser(this.db, userAddress); } } diff --git a/apps/long-short-backend/src/utils/hash.ts b/apps/long-short-backend/src/utils/hash.ts new file mode 100644 index 0000000..ceec7fd --- /dev/null +++ b/apps/long-short-backend/src/utils/hash.ts @@ -0,0 +1,7 @@ +import CryptoJS from "crypto-js"; + +export namespace HashUtils { + export function sha256(data: string): string { + return CryptoJS.SHA256(data).toString(); + } +} diff --git a/apps/long-short-backend/src/utils/index.ts b/apps/long-short-backend/src/utils/index.ts index 92aeeab..0b61367 100644 --- a/apps/long-short-backend/src/utils/index.ts +++ b/apps/long-short-backend/src/utils/index.ts @@ -1,5 +1,6 @@ export * from "./common"; export * from "./expiring-variable"; +export * from "./hash"; export * from "./lodash"; export * from "./logger"; export * from "./signature"; diff --git a/apps/long-short-backend/test/sign-data.test.ts b/apps/long-short-backend/test/sign-data.test.ts index f51fd6f..d07d8c1 100644 --- a/apps/long-short-backend/test/sign-data.test.ts +++ b/apps/long-short-backend/test/sign-data.test.ts @@ -1,9 +1,16 @@ import { describe, it, expect, beforeAll } from "vitest"; import { RustModule } from "@minswap/felis-ledger-utils"; import { baseAddressWalletFromSeed } from "@minswap/felis-cip"; -import { NetworkEnvironment, PrivateKey } from "@minswap/felis-ledger-core"; +import { + Address, + NetworkEnvironment, + PrivateKey, + XJSON, +} from "@minswap/felis-ledger-core"; import * as CMS from "@emurgo/cardano-message-signing-nodejs"; import { verifySignData } from "../src/utils/signature"; +import CryptoJS from "crypto-js"; +import { OrderV2Datum } from "@minswap/felis-dex-v2"; function getSignMessage(address: string): string { return Buffer.from(address).toString("hex"); @@ -25,7 +32,9 @@ function signData( // Build protected header with address const protectedHeaders = CMS.HeaderMap.new(); - protectedHeaders.set_algorithm_id(CMS.Label.from_algorithm_id(CMS.AlgorithmId.EdDSA)); + protectedHeaders.set_algorithm_id( + CMS.Label.from_algorithm_id(CMS.AlgorithmId.EdDSA), + ); protectedHeaders.set_header( CMS.Label.new_text("address"), CMS.CBORValue.new_bytes(Buffer.from(address, "hex")), @@ -36,7 +45,11 @@ function signData( const headers = CMS.Headers.new(protectedSerialized, unprotectedHeaders); // Build COSESign1 - const builder = CMS.COSESign1Builder.new(headers, Buffer.from(payload, "hex"), false); + const builder = CMS.COSESign1Builder.new( + headers, + Buffer.from(payload, "hex"), + false, + ); const toSign = builder.make_data_to_sign().to_bytes(); // Sign with Ed25519 @@ -69,22 +82,29 @@ describe("verifySignData", () => { it("should verify a valid signature", () => { const seed = "melt enemy surface feed kiss helmet suffer demise toilet insane human refuse park insect lawsuit custom inch spirit throw radio alarm creek chat symptom"; - const wallet = baseAddressWalletFromSeed(seed, NetworkEnvironment.TESTNET_PREVIEW); + const wallet = baseAddressWalletFromSeed( + seed, + NetworkEnvironment.TESTNET_PREVIEW, + ); const data = { - "market": "ADA-MIN", - "side": "LONG", - "amount": 500000000 + market: "ADA-MIN", + side: "LONG", + amount: 500000000, }; const message = Buffer.from(JSON.stringify(data)).toString("hex"); // Sign the message - const { signature, key } = signData(wallet.paymentKey, wallet.address.bech32, message); + const { signature, key } = signData( + wallet.paymentKey, + wallet.address.bech32, + message, + ); console.log({ user_address: wallet.address.bech32, signature, key, - }) + }); // Verify const isValid = verifySignData({ message, @@ -99,10 +119,17 @@ describe("verifySignData", () => { it("should reject invalid signature", () => { const seed = "melt enemy surface feed kiss helmet suffer demise toilet insane human refuse park insect lawsuit custom inch spirit throw radio alarm creek chat symptom"; - const wallet = baseAddressWalletFromSeed(seed, NetworkEnvironment.TESTNET_PREVIEW); + const wallet = baseAddressWalletFromSeed( + seed, + NetworkEnvironment.TESTNET_PREVIEW, + ); const message = getSignMessage(wallet.address.bech32); - const { signature, key } = signData(wallet.paymentKey, wallet.address.bech32, message); + const { signature, key } = signData( + wallet.paymentKey, + wallet.address.bech32, + message, + ); // Tamper with signature const tamperedSignature = signature.slice(0, -4) + "0000"; @@ -120,15 +147,25 @@ describe("verifySignData", () => { it("should reject wrong address", () => { const seed = "melt enemy surface feed kiss helmet suffer demise toilet insane human refuse park insect lawsuit custom inch spirit throw radio alarm creek chat symptom"; - const wallet = baseAddressWalletFromSeed(seed, NetworkEnvironment.TESTNET_PREVIEW); + const wallet = baseAddressWalletFromSeed( + seed, + NetworkEnvironment.TESTNET_PREVIEW, + ); // Use a different seed to get different address const otherSeed = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; - const otherWallet = baseAddressWalletFromSeed(otherSeed, NetworkEnvironment.TESTNET_PREVIEW); + const otherWallet = baseAddressWalletFromSeed( + otherSeed, + NetworkEnvironment.TESTNET_PREVIEW, + ); const message = getSignMessage(wallet.address.bech32); - const { signature, key } = signData(wallet.paymentKey, wallet.address.bech32, message); + const { signature, key } = signData( + wallet.paymentKey, + wallet.address.bech32, + message, + ); // Verify with different address should fail const isValid = verifySignData({ @@ -144,10 +181,17 @@ describe("verifySignData", () => { it("should reject wrong message", () => { const seed = "melt enemy surface feed kiss helmet suffer demise toilet insane human refuse park insect lawsuit custom inch spirit throw radio alarm creek chat symptom"; - const wallet = baseAddressWalletFromSeed(seed, NetworkEnvironment.TESTNET_PREVIEW); + const wallet = baseAddressWalletFromSeed( + seed, + NetworkEnvironment.TESTNET_PREVIEW, + ); const message = getSignMessage(wallet.address.bech32); - const { signature, key } = signData(wallet.paymentKey, wallet.address.bech32, message); + const { signature, key } = signData( + wallet.paymentKey, + wallet.address.bech32, + message, + ); // Verify with different message should fail const wrongMessage = Buffer.from("wrong message").toString("hex"); @@ -164,10 +208,17 @@ describe("verifySignData", () => { it("should work with authen_token format (signature:key)", () => { const seed = "melt enemy surface feed kiss helmet suffer demise toilet insane human refuse park insect lawsuit custom inch spirit throw radio alarm creek chat symptom"; - const wallet = baseAddressWalletFromSeed(seed, NetworkEnvironment.TESTNET_PREVIEW); + const wallet = baseAddressWalletFromSeed( + seed, + NetworkEnvironment.TESTNET_PREVIEW, + ); const message = getSignMessage(wallet.address.bech32); - const { signature, key } = signData(wallet.paymentKey, wallet.address.bech32, message); + const { signature, key } = signData( + wallet.paymentKey, + wallet.address.bech32, + message, + ); // Create authen_token in the format used by the API const authenToken = `${signature}:${key}`; @@ -187,14 +238,40 @@ describe("verifySignData", () => { const message = `Hello! How are you?`; const signedData = { key: "a401010327200621582050ac53f148ce5ce4d4278db4d6c4187265da319984920c2b9e5e4029b5b7b1bb", - signature: "845846a2012767616464726573735839006f7f6cf2c50c559594c0bf8aecd22e7c6bc5df47c4ed7aa8ef87cb49ad7ffe3cda0cd3175a52bff1e5066a67785c47f3a786b434bdc998eea166686173686564f45348656c6c6f2120486f772061726520796f753f584093f6be1a792f5c1767d9e20ba767bf3787c8d6bb5e34d2b48130288e81f759270f9bf88acc5dd48feb7e8eadec98218ca33ff419cc3e2f65086ca99a4b8f510f", + signature: + "845846a2012767616464726573735839006f7f6cf2c50c559594c0bf8aecd22e7c6bc5df47c4ed7aa8ef87cb49ad7ffe3cda0cd3175a52bff1e5066a67785c47f3a786b434bdc998eea166686173686564f45348656c6c6f2120486f772061726520796f753f584093f6be1a792f5c1767d9e20ba767bf3787c8d6bb5e34d2b48130288e81f759270f9bf88acc5dd48feb7e8eadec98218ca33ff419cc3e2f65086ca99a4b8f510f", }; const isValid = verifySignData({ message: Buffer.from(message).toString("hex"), - address: "addr_test1qphh7m8jc5x9t9v5czlc4mxj9e7xh3wlglzw674ga7rukjdd0llreksv6vt4554l78jsv6n80pwy0ua8s66rf0wfnrhq73a20h", + address: + "addr_test1qphh7m8jc5x9t9v5czlc4mxj9e7xh3wlglzw674ga7rukjdd0llreksv6vt4554l78jsv6n80pwy0ua8s66rf0wfnrhq73a20h", key: signedData.key, signature: signedData.signature, }); expect(isValid).toBe(true); }); + + it.skip("check hash", () => { + const message = `Hello! How are you?`; + const hash = CryptoJS.SHA256(message).toString(); + console.log(hash); + }); + + it.skip("parse order v2 datum", () => { + const oderV2Datum = XJSON.parse( + `{"author":{"canceller":{"type":0,"hash":{"$bytes":"fc56f17f54b4b9c0da5a18b10c9acb7bd6f10b66e242fb4d4717859e"}},"refundReceiver":{"$address":"addr1q879dutl2j6tnsx6tgvtzry6edaadugtvm3y976dgutct8hjzl6rta0nfkaxnqcdntdqzw6u9y9yamnq0qm3etj49x9sgrzg6p"},"refundReceiverDatum":{"type":0},"successReceiver":{"$address":"addr1q879dutl2j6tnsx6tgvtzry6edaadugtvm3y976dgutct8hjzl6rta0nfkaxnqcdntdqzw6u9y9yamnq0qm3etj49x9sgrzg6p"},"successReceiverDatum":{"type":0}},"step":{"type":0,"direction":1,"swapAmountOption":{"type":0,"swapAmount":{"$bigint":"100000000"}},"minimumReceived":{"$bigint":"1"},"killable":0},"lpAsset":{"$asset":"f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c.e74c52975908a612d5ce68327040d449aae99f8b463bb6de046a1b23c5713169"},"maxBatcherFee":{"$bigint":"700000"}}`, + ) as OrderV2Datum; + console.log(oderV2Datum); + const raw = OrderV2Datum.toDataHex(oderV2Datum); + console.log(raw); + console.log(OrderV2Datum.toPlutusJson(oderV2Datum )); + }); + + it("address to hex", () => { + const addr = Address.fromBech32("addr_test1qphh7m8jc5x9t9v5czlc4mxj9e7xh3wlglzw674ga7rukjdd0llreksv6vt4554l78jsv6n80pwy0ua8s66rf0wfnrhq73a20h"); + const ECSL = RustModule.getE; + const ea = ECSL.Address.from_bech32(addr.bech32); + const a1 = ea.to_hex(); + console.log(a1); + }); }); From e1917ac254cb0933110d40766b99ff0fd6b7cd46 Mon Sep 17 00:00:00 2001 From: tony Date: Wed, 4 Feb 2026 15:24:00 +0700 Subject: [PATCH 07/35] add fn address.to_hex() --- packages/ledger-core/src/address.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/ledger-core/src/address.ts b/packages/ledger-core/src/address.ts index 3b757b5..d00692e 100644 --- a/packages/ledger-core/src/address.ts +++ b/packages/ledger-core/src/address.ts @@ -343,6 +343,13 @@ export class Address { return ret; } + toHex(): CborHex { + const cslAddress = this.toCSL(); + const hex = cslAddress.to_hex(); + safeFreeRustObjects(cslAddress); + return hex; + } + toString(): string { return this.bech32; } From 0c95e5ee5532d19f37010c900926ee2cf983ee5b Mon Sep 17 00:00:00 2001 From: tony Date: Wed, 4 Feb 2026 16:35:16 +0700 Subject: [PATCH 08/35] final long_buy --- CLAUDE.md | 420 ++++++++++++++++++ .../1770117151276_order_waiting_column.ts | 10 + .../src/api/state-machine.ts | 84 ++++ apps/long-short-backend/src/cmd/run-api.ts | 2 +- apps/long-short-backend/src/database/db.d.ts | 1 + .../src/provider/cardanoscan.ts | 82 +++- .../src/repository/order-repository.ts | 77 ++++ .../src/services/position-service.ts | 141 +++++- 8 files changed, 786 insertions(+), 31 deletions(-) create mode 100644 apps/long-short-backend/.config/migrations/1770117151276_order_waiting_column.ts diff --git a/CLAUDE.md b/CLAUDE.md index d9fe158..82e29b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,3 +115,423 @@ beforeAll(async () => { - Turborepo + pnpm 9.0.0 - Vitest, Biome - Next.js, React 19, Jotai, Ant Design + +--- + +# Long-Short Backend Skills & Patterns + +## Database Migrations + +### Migration File Pattern +```typescript +// apps/long-short-backend/.config/migrations/{timestamp}_{description}.ts +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "table_name" ADD COLUMN "column_name" TYPE`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "table_name" DROP COLUMN "column_name"`.execute(db); +} +``` + +### Migration Best Practices +- Use sequential timestamps for ordering (e.g., `1770117151276_order_waiting_column.ts`) +- Always provide both `up` and `down` migrations +- Use `Generated` in database types for columns with defaults +- Update `src/database/db.d.ts` after migrations +- Test migrations in development before applying to production + +### Running Migrations +```bash +# Apply migrations +pnpm --filter=long-short-backend run migrate:latest + +# Rollback last migration +pnpm --filter=long-short-backend run migrate:down +``` + +## Order State Machine Pattern + +### Order Lifecycle States +```typescript +// Order progression for LONG positions: +// 1. LONG_BUY → Buy asset B with asset A (ADA) +// 2. LONG_SUPPLY → Supply asset B to lending protocol, get collateral +// 3. LONG_BORROW → Borrow asset A against collateral +// 4. LONG_BUY_MORE → Buy more asset B with borrowed asset A +``` + +### Transaction Lifecycle Tracking +```typescript +// Order fields track transaction progress: +// 1. built_tx_id - Transaction built and submitted (not yet on chain) +// 2. created_tx_id - Transaction confirmed on chain +// 3. waiting - true when confirmed, false when output is spent +``` + +### State Machine Handler Pattern +```typescript +// apps/long-short-backend/src/api/state-machine.ts +export namespace StateMachine { + // Handler for building transactions + export const handleOrderType = async (options: HandleOptions) => { + // Build transaction using DEXOrderTransaction + // Return: { txRaw, txId, outputsHash, validTo } + }; + + // Waiting function for checking if output is spent + export const waitingOrderType = async (options: WaitingOptions): Promise => { + // Check if order output has been spent on chain + // If spent, return next order details + // If not spent, return { isSpent: false } + }; +} +``` + +## Repository Pattern + +### Repository Organization +```typescript +// apps/long-short-backend/src/repository/{entity}-repository.ts +export namespace EntityRepository { + // Create operations + export async function createEntity(db: Kysely | Transaction, params: CreateParams): Promise + + // Read operations + export async function getEntityById(db: Kysely, id: bigint): Promise + export async function getEntitiesByFilter(db: Kysely, filter: Filter): Promise + + // Update operations + export async function updateEntity(db: Kysely, id: bigint, updates: Partial): Promise + + // Helper: Map DB row to domain type + function mapEntityRow(row: any): Entity { + return { + id: BigInt(row.id), + // Convert snake_case to camelCase + // Convert timestamps to Date objects + // Parse bigint fields + }; + } +} +``` + +### Repository Best Practices +- Use `Kysely | Transaction` for functions that need transaction support +- Always convert `id` fields to `bigint` with `BigInt(row.id)` +- Map `snake_case` database columns to `camelCase` domain types +- Return `null` for not-found queries (don't throw) +- Use `.executeTakeFirst()` for optional single results +- Use `.executeTakeFirstOrThrow()` when result must exist + +## Service Layer Pattern + +### Service Organization +```typescript +// apps/long-short-backend/src/services/{entity}-service.ts +export class EntityService { + constructor( + private readonly db: Kysely, + private readonly networkEnv: NetworkEnvironment, + private readonly cardanoscanProvider: CardanoscanProvider, + ) {} + + // Business logic methods + async createEntity(input: CreateInput): Promise + async processEntity(input: ProcessInput): Promise +} + +// Result types use discriminated unions +export type Result = + | { success: true; data: Data } + | { success: false; error: string }; +``` + +### Service Best Practices +- Inject dependencies through constructor +- Use discriminated union return types for error handling +- Validate input at service layer (market support, minimums, etc.) +- Use database transactions for multi-step operations +- Log important state transitions with structured logging +- Separate concerns: waiting logic vs transaction building + +## Transaction Building Pattern + +### BuildTx Flow (apps/long-short-backend/src/services/position-service.ts) +```typescript +async buildTx(input: BuildTxInput): Promise { + // STEP 1: Check for waiting orders (created_tx_id not null, waiting = true) + const waitingOrder = await OrderRepository.getWaitingOrder(db, positionId); + if (waitingOrder) { + // Call waiting function to check if output is spent + // If spent: update next order, set waiting = false + // If not spent: return waiting message + } + + // STEP 2: Find next unhandled order (asset_in not null, created_tx_id null) + const order = await OrderRepository.getNextUnhandledOrder(db, positionId); + if (!order) return { error: "No unhandled order" }; + + // STEP 3: Check if transaction already built + if (order.builtTxId) { + // If created_tx_id exists: already confirmed, return waiting + // Else: search on chain, update created_tx_id if found + // Check expiry, return waiting or fall through to rebuild + } + + // STEP 4: Build new transaction + const txResult = await StateMachine.handleOrderType(...); + await OrderRepository.updateOrderBuiltTx(db, order.id, txResult); + return { success: true, txRaw: txResult.txRaw }; +} +``` + +### Transaction Building Best Practices +- Separate waiting logic from transaction building +- Check waiting orders BEFORE finding unhandled orders +- Always update `built_tx_id` immediately after building +- Set `waiting = true` when transaction confirms on chain (via default column value) +- Set `waiting = false` only after output is spent +- Use transaction expiry (validTo) to determine when to rebuild + +## Cardanoscan Provider Pattern + +### Provider Usage +```typescript +// apps/long-short-backend/src/provider/cardanoscan.ts +const provider = new CardanoscanProvider(baseUrl, apiKey); + +// Find transaction by hash +const tx = await provider.findTransactionByHash(address, txHash, pageSize, maxPage); + +// Find transaction that spent a UTXO +const spendingTx = await provider.findTransactionHasSpent(address, txHash, outputIndex, pageSize, maxPage); +``` + +### Cardanoscan API Patterns +- Always use `address.toHex()` for API calls (not bech32) +- API header is `"apiKey"` (not `"api-key"` or `"Authorization"`) +- Paginate with `pageNo` (1-indexed) and `limit` (max 50) +- Use `order: "desc"` to search most recent transactions first +- Token format: use `asset.toBlockFrostString()` for matching +- Input format: `{ txId: string; index: number }` for UTXO references +- Output format: `{ address: string; value: string; tokens?: Array<{ value: string; assetId: string }> }` + +## API Route Pattern (Fastify + TypeBox) + +### Route Registration +```typescript +// apps/long-short-backend/src/api/routes/{entity}.ts +export function registerEntityRoutes(fastify: FastifyInstance, service: EntityService): void { + fastify.post<{ + Body: BodyType; + Reply: ReplyType; + }>( + API_ENDPOINTS.ENTITY_ACTION, + { + schema: { + body: BodySchema, + response: { + 200: SuccessResponseSchema, + 400: ErrorResponseSchema, + 401: ErrorResponseSchema, + }, + }, + }, + async (request, reply) => { + // Extract and validate input + const { data, user_address, witness } = request.body; + + // Authenticate (CIP-8 signature verification) + const authResult = ApiHelper.authenticate(data, user_address, witness); + if (!authResult.success) { + return reply.status(401).send({ success: false, error: authResult.error }); + } + + // Call service + const result = await service.processEntity(input); + if (!result.success) { + return reply.status(400).send({ success: false, error: result.error }); + } + + return reply.status(200).send({ success: true, data: result.data }); + }, + ); +} +``` + +### API Best Practices +- Define schemas with TypeBox for validation +- Use snake_case for API request/response fields (matches frontend) +- Use camelCase internally in TypeScript code +- Authenticate requests with CIP-8 signature verification +- Return consistent response format: `{ success: boolean; data?: T; error?: string }` +- Use appropriate HTTP status codes: 200 (success), 400 (bad request), 401 (unauthorized) +- Map domain types to response types with helper functions + +## Authentication Pattern (CIP-8) + +### Signature Verification +```typescript +// apps/long-short-backend/src/api/helper.ts +export namespace ApiHelper { + export function authenticate( + data: unknown, + userAddress: string, + witness: { signature: string; key: string }, + ): AuthResult { + // 1. Serialize data to CBOR hex + const dataHex = XJSON.stringify(data); + + // 2. Verify signature using CIP-8 + const isValid = verifySignature(dataHex, userAddress, witness); + + return isValid + ? { success: true } + : { success: false, error: "Invalid signature" }; + } +} +``` + +### Authentication Request Format +```typescript +{ + "data": { /* actual request payload */ }, + "user_address": "addr1...", + "witness": { + "signature": "84582aa201...", // CBOR-encoded CIP-8 signature + "key": "a401..." // CBOR-encoded public key + } +} +``` + +## Logging Pattern + +### Structured Logging +```typescript +// apps/long-short-backend/src/utils/logger.ts +import { logger } from "../utils"; + +// Info logging +logger.info("Description", { + orderId: order.id, + txHash: tx.hash, + // Include relevant context +}); + +// Error logging +logger.error("Error description", error); +logger.error("Error description", { context: value, error }); + +// Warning logging +logger.warn("Warning message", { context }); +``` + +### Logging Best Practices +- Use structured logging with context objects +- Log all important state transitions +- Log transaction IDs for traceability +- Log errors with full error objects +- Include order/position IDs in logs +- Use appropriate log levels: info (normal flow), warn (recoverable issues), error (failures) + +## Testing Patterns + +### Repository Tests +```typescript +// apps/long-short-backend/test/{entity}-repository.test.ts +import { describe, it, expect, beforeAll, afterEach } from "vitest"; + +describe("EntityRepository", () => { + let db: Kysely; + + beforeAll(async () => { + // Setup test database + db = await setupTestDb(); + }); + + afterEach(async () => { + // Clean up test data + await db.deleteFrom("entity").execute(); + }); + + it("should create entity", async () => { + const entity = await EntityRepository.createEntity(db, params); + expect(entity.id).toBeDefined(); + }); +}); +``` + +### Service Tests +```typescript +// apps/long-short-backend/test/{entity}-service.test.ts +describe("EntityService", () => { + let service: EntityService; + let mockProvider: CardanoscanProvider; + + beforeAll(async () => { + await RustModule.load(); + mockProvider = createMockProvider(); + service = new EntityService(db, networkEnv, mockProvider); + }); + + it("should process entity successfully", async () => { + const result = await service.processEntity(input); + expect(result.success).toBe(true); + }); +}); +``` + +## Environment Variables + +### Required Variables +```bash +# Database +DATABASE_URL="postgresql://user:pass@localhost:5432/dbname" + +# Network +NETWORK="mainnet" # or "testnet" + +# API +API_PORT=9999 +API_HOST="0.0.0.0" + +# Cardanoscan +CARDANOSCAN_API_KEY="your-api-key" +``` + +### Loading Environment +```typescript +// Environment variables are loaded automatically +// Validate required variables at startup +if (!process.env.DATABASE_URL) { + throw new Error("DATABASE_URL is required"); +} +``` + +## Common Patterns Summary + +### DO +- Use `bigint` for all Cardano amounts and IDs +- Convert database IDs with `BigInt(row.id)` +- Use discriminated unions for result types +- Separate waiting logic from transaction building +- Log all important state transitions +- Validate inputs at service layer +- Use transactions for multi-step database operations +- Map database snake_case to TypeScript camelCase +- Use `address.toHex()` for Cardanoscan API +- Authenticate requests with CIP-8 signatures + +### DON'T +- Don't use `number` for amounts or IDs +- Don't throw errors from repository layer (return `null`) +- Don't mix waiting and building logic in same function +- Don't skip input validation +- Don't use native `JSON.stringify` for BigInt values +- Don't use bech32 addresses for Cardanoscan API +- Don't skip authentication on protected endpoints +- Don't forget to update database types after migrations diff --git a/apps/long-short-backend/.config/migrations/1770117151276_order_waiting_column.ts b/apps/long-short-backend/.config/migrations/1770117151276_order_waiting_column.ts new file mode 100644 index 0000000..bc50593 --- /dev/null +++ b/apps/long-short-backend/.config/migrations/1770117151276_order_waiting_column.ts @@ -0,0 +1,10 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "order" ADD COLUMN "waiting" BOOLEAN NOT NULL DEFAULT true`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "order" DROP COLUMN "waiting"`.execute(db); +} diff --git a/apps/long-short-backend/src/api/state-machine.ts b/apps/long-short-backend/src/api/state-machine.ts index 706b924..f7a1d99 100644 --- a/apps/long-short-backend/src/api/state-machine.ts +++ b/apps/long-short-backend/src/api/state-machine.ts @@ -5,6 +5,8 @@ import { DexVersion, OrderV2Direction, OrderV2StepType } from "@minswap/felis-de import { CoinSelectionAlgorithm, EmulatorProvider } from "@minswap/felis-tx-builder"; import { Duration, RustModule, safeFreeRustObjects } from "@minswap/felis-ledger-utils"; import { HashUtils } from "../utils"; +import { CardanoscanProvider } from "../provider"; +import { MarketConfig } from "../config"; export namespace StateMachine { export enum PositionSide { @@ -91,8 +93,90 @@ export namespace StateMachine { return { txRaw, txId: txId, + orderOutputIndex: 0, outputsHash, validTo, } }; + + export type WaitingLongBuyOptions = { + marketConfig: MarketConfig; + txHash: string; + orderOutputIndex: number; + userAddress: Address; + assetOut: Asset; + cardanoscanProvider: CardanoscanProvider; + }; + + export type WaitingLongBuyResult = + | { isSpent: false } + | { + isSpent: true; + nextOrderType: LongOrderType; + assetIn: string; + amountIn: string; + assetOut: string; + }; + + /** + * Check if the order output has been spent (consumed by a subsequent transaction) + * and prepare the next order details + * @param options - Options containing transaction details and cardanoscan provider + * @returns Result with next order details if spent, or isSpent: false if not yet processed + */ + export const waitingLongBuy = async (options: WaitingLongBuyOptions): Promise => { + const { marketConfig, txHash, orderOutputIndex, userAddress, cardanoscanProvider, assetOut } = options; + + // Cache hex conversion of user address + const userAddressHex = userAddress.toHex(); + + // Search for the transaction that spent this UTXO + const spendingTx = await cardanoscanProvider.findTransactionHasSpent( + userAddress, + txHash, + orderOutputIndex, + 5, // pageSize - search 50 transactions per page + 10, // maxPage - search up to 10 pages (500 transactions total) + ); + + if (spendingTx) { + // Order output has been spent + // Now find the output that belongs to the user and contains the assetOut token + const assetOutUnit = assetOut.toBlockFrostString(); + + // Search through the spending transaction's outputs + for (const output of spendingTx.outputs) { + // Check if output address matches user address (in hex) + if (output.address === userAddressHex) { + // Check if output contains the assetOut token + if (output.tokens && output.tokens.length > 0) { + const matchingToken = output.tokens.find((token) => token.assetId === assetOutUnit); + if (matchingToken) { + // Found the output with the matching token + // Prepare next order: LONG_SUPPLY + const amountOut = BigInt(matchingToken.value); + return { + isSpent: true, + nextOrderType: LongOrderType.LONG_SUPPLY, + assetIn: assetOut.toString(), // The asset we received becomes input for next order + amountIn: amountOut.toString(), // The amount we received becomes input amount + assetOut: marketConfig.collateralMarketId, // Supply to get collateral token + }; + } + } + } + } + + // Transaction found but couldn't find matching output + // This is an error condition - the order was processed but we can't find the result + throw new Error( + `Order output spent (tx: ${spendingTx.hash}) but could not find matching output with asset ${assetOut.toString()}`, + ); + } + + // Order output has not been spent yet + return { + isSpent: false, + }; + }; } diff --git a/apps/long-short-backend/src/cmd/run-api.ts b/apps/long-short-backend/src/cmd/run-api.ts index 204a9f8..8f2aa75 100644 --- a/apps/long-short-backend/src/cmd/run-api.ts +++ b/apps/long-short-backend/src/cmd/run-api.ts @@ -32,7 +32,7 @@ async function main() { // Connect to database logger.info("Connecting to database..."); - const db = await newKyselyClient(DATABASE_URL, { logSQL: true }); + const db = await newKyselyClient(DATABASE_URL, { logSQL: false }); logger.info("Database connected"); // Load market configs from database diff --git a/apps/long-short-backend/src/database/db.d.ts b/apps/long-short-backend/src/database/db.d.ts index 2943d96..87a5190 100644 --- a/apps/long-short-backend/src/database/db.d.ts +++ b/apps/long-short-backend/src/database/db.d.ts @@ -43,6 +43,7 @@ export interface Order { id: Generated; order_type: string; position_id: Int8; + waiting: Generated; } export interface Position { diff --git a/apps/long-short-backend/src/provider/cardanoscan.ts b/apps/long-short-backend/src/provider/cardanoscan.ts index f3f52c8..9d2ab84 100644 --- a/apps/long-short-backend/src/provider/cardanoscan.ts +++ b/apps/long-short-backend/src/provider/cardanoscan.ts @@ -1,6 +1,5 @@ import { Address } from "@minswap/felis-ledger-core"; import { logger } from "../utils"; -import { RustModule, safeFreeRustObjects } from "@minswap/felis-ledger-utils"; /** * Transaction input/output structure from Cardanoscan API @@ -9,8 +8,8 @@ export type CardanoscanTxIO = { address: string; value: string; tokens?: Array<{ - quantity: string; - unit: string; + value: string; + assetId: string; }>; datum?: string; scriptRef?: string; @@ -28,7 +27,7 @@ export type CardanoscanTransaction = { blockHeight: number; timestamp: string; // ISO 8601 date-time index: number; - inputs: CardanoscanTxIO[]; + inputs: (CardanoscanTxIO & {txId: string; index: number; })[]; outputs: CardanoscanTxIO[]; collateral: CardanoscanTxIO[]; certificates?: { @@ -81,7 +80,7 @@ export type GetTransactionListOptions = { address: string; pageNo: number; limit?: number; // 1-50, default 20 - order?: "asc" | "desc"; // default "asc" + order: "asc" | "desc"; // default "desc" }; /** @@ -103,7 +102,7 @@ export class CardanoscanProvider { * @returns Transaction list response with pagination info */ async getTransactionList(options: GetTransactionListOptions): Promise { - const { address, pageNo, limit = 20, order = "asc" } = options; + const { address, pageNo, limit = 20, order } = options; // Validate parameters if (!address || address.length > 200) { @@ -157,21 +156,17 @@ export class CardanoscanProvider { * @param address - Address to query * @param pageNo - Page number (1-indexed) * @param limit - Results per page (1-50, default 20) - * @param order - Sort order (asc or desc, default asc) + * @param order - Sort order (asc or desc, default desc) * @returns Transaction list response */ async getTransactionListByAddress( address: Address, pageNo: number, - limit?: number, - order?: "asc" | "desc", + limit: number, + order: "asc" | "desc", ): Promise { - const ECSL = RustModule.getE; - const ea = ECSL.Address.from_bech32(address.bech32); - const ah = ea.to_hex(); - safeFreeRustObjects(ea); return this.getTransactionList({ - address: ah, + address: address.toHex(), pageNo, limit, order, @@ -271,4 +266,63 @@ export class CardanoscanProvider { return null; } + + /** + * Find the transaction that spent a specific UTXO + * @param address - Address to query + * @param txHash - Transaction hash of the UTXO + * @param index - Output index of the UTXO + * @param pageSize - Number of transactions per page (default: 50) + * @param maxPage - Maximum pages to search (default: 100) + * @returns The transaction that spent the UTXO, or null if not found + */ + async findTransactionHasSpent( + address: Address, + txHash: string, + index: number, + pageSize?: number, + maxPage?: number, + ): Promise { + // Start from most recent and work backwards + let pageNo = 1; + let hasMore = true; + + logger.info("Searching for transaction that spent UTXO", { + address: address.bech32, + txHash, + outputIndex: index, + }); + + while (hasMore) { + const response = await this.getTransactionListByAddress(address, pageNo, pageSize ?? 50, "desc"); + + // Check each transaction's inputs for the specified UTXO + for (const tx of response.transactions) { + const spentInput = tx.inputs.find( + (input) => input.txId === txHash && input.index === index, + ); + + if (spentInput) { + logger.info("Found transaction that spent UTXO", { + spendingTxHash: tx.hash, + utxoTxHash: txHash, + utxoIndex: index, + }); + return tx; + } + } + + hasMore = response.transactions.length === response.limit; + pageNo++; + + // Safety limit to prevent infinite loops + if (pageNo > (maxPage ?? 100)) { + logger.warn(`Searched ${maxPage ?? 100} pages without finding spending transaction for ${txHash}:${index}`); + break; + } + } + + logger.info("UTXO not spent yet", { txHash, outputIndex: index }); + return null; + } } diff --git a/apps/long-short-backend/src/repository/order-repository.ts b/apps/long-short-backend/src/repository/order-repository.ts index 6b2af53..1adae12 100644 --- a/apps/long-short-backend/src/repository/order-repository.ts +++ b/apps/long-short-backend/src/repository/order-repository.ts @@ -25,6 +25,7 @@ export type Order = { builtTxId: string | null; builtOutputsHash: string | null; builtValidTo: Date | null; + waiting: boolean; }; export namespace OrderRepository { @@ -135,6 +136,81 @@ export namespace OrderRepository { .execute(); } + /** + * Update order created_tx fields when transaction is found on chain + */ + export async function updateOrderCreatedTx( + db: Kysely | Transaction, + orderId: bigint, + createdTxId: string, + createdTxIndex: number, + ): Promise { + await db + .updateTable("order") + .set({ + created_tx_id: createdTxId, + created_tx_index: createdTxIndex, + }) + .where("id", "=", orderId.toString()) + .execute(); + } + + /** + * Update order with next order details when current order output is spent + */ + export async function updateOrderNextDetails( + db: Kysely | Transaction, + orderId: bigint, + assetIn: string, + amountIn: string, + assetOut: string, + ): Promise { + await db + .updateTable("order") + .set({ + asset_in: assetIn, + amount_in: amountIn, + asset_out: assetOut, + }) + .where("id", "=", orderId.toString()) + .execute(); + } + + /** + * Find an order that has created_tx_id not null and waiting = true + * This order has been confirmed on chain and is waiting for its output to be spent + */ + export async function getWaitingOrder( + db: Kysely | Transaction, + positionId: bigint, + ): Promise { + const result = await db + .selectFrom("order") + .selectAll() + .where("position_id", "=", positionId.toString()) + .where("created_tx_id", "is not", null) + .where("waiting", "=", true) + .orderBy("id", "asc") + .executeTakeFirst(); + + return result ? mapOrderRow(result) : null; + } + + /** + * Set order waiting flag to false + */ + export async function setOrderWaiting( + db: Kysely | Transaction, + orderId: bigint, + waiting: boolean, + ): Promise { + await db + .updateTable("order") + .set({ waiting }) + .where("id", "=", orderId.toString()) + .execute(); + } + // biome-ignore lint/suspicious/noExplicitAny: DB row type function mapOrderRow(row: any): Order { return { @@ -150,6 +226,7 @@ export namespace OrderRepository { builtTxId: row.built_tx_id, builtOutputsHash: row.built_outputs_hash, builtValidTo: row.built_valid_to ? new Date(row.built_valid_to) : null, + waiting: row.waiting, }; } } diff --git a/apps/long-short-backend/src/services/position-service.ts b/apps/long-short-backend/src/services/position-service.ts index 35af3e6..416c134 100644 --- a/apps/long-short-backend/src/services/position-service.ts +++ b/apps/long-short-backend/src/services/position-service.ts @@ -1,5 +1,5 @@ import type { Kysely } from "kysely"; -import { Address, type NetworkEnvironment } from "@minswap/felis-ledger-core"; +import { Address, Asset, XJSON, type NetworkEnvironment } from "@minswap/felis-ledger-core"; import { getMarketConfig, isSupportedMarket } from "../config/market"; import type { DB } from "../database"; import { type Position, PositionRepository } from "../repository/position-repository"; @@ -139,42 +139,151 @@ export class PositionService { return { success: false, error: "No open position found for this market" }; } - // Find next unhandled order - const order = await OrderRepository.getNextUnhandledOrder(this.db, position.id); - if (!order) { - return { success: false, error: "No unhandled order found" }; + const marketConfig = getMarketConfig(marketId); + if (!marketConfig) { + return { success: false, error: `Market "${marketId}" configuration not found` }; } try { - // Case 1: Order has built_tx_id not null => check if tx appears on chain + // STEP 1: Check if there's a waiting order (created_tx_id not null, waiting = true) + const waitingOrder = await OrderRepository.getWaitingOrder(this.db, position.id); + if (waitingOrder) { + logger.info("Found waiting order, checking if output is spent", { + orderId: waitingOrder.id, + orderType: waitingOrder.orderType, + createdTxId: waitingOrder.createdTxId, + }); + + const address = Address.fromBech32(userAddress); + + // Call appropriate waiting function based on order type + if (waitingOrder.orderType === StateMachine.LongOrderType.LONG_BUY) { + const waitingResult = await StateMachine.waitingLongBuy({ + marketConfig, + txHash: waitingOrder.createdTxId!, + orderOutputIndex: waitingOrder.createdTxIndex ?? 0, + userAddress: address, + assetOut: Asset.fromString(waitingOrder.assetOut!), + cardanoscanProvider: this.cardanoscanProvider, + }); + + if (waitingResult.isSpent) { + // Order output has been spent - find and update the next order + logger.info("Order output spent, updating next order", { + orderId: waitingOrder.id, + nextOrderType: waitingResult.nextOrderType, + }); + + // Find the order with the next order type + const nextOrder = await this.db + .selectFrom("order") + .selectAll() + .where("position_id", "=", waitingOrder.positionId.toString()) + .where("order_type", "=", waitingResult.nextOrderType) + .executeTakeFirst(); + + if (!nextOrder) { + logger.error("Next order not found", { + positionId: waitingOrder.positionId, + nextOrderType: waitingResult.nextOrderType, + }); + return { + success: false, + error: `Next order with type "${waitingResult.nextOrderType}" not found`, + }; + } + + // Update the next order with details and set current order waiting = false + await OrderRepository.updateOrderNextDetails( + this.db, + BigInt(nextOrder.id), + waitingResult.assetIn, + waitingResult.amountIn, + waitingResult.assetOut, + ); + await OrderRepository.setOrderWaiting(this.db, waitingOrder.id, false); + + logger.info("Next order updated, current order no longer waiting", { + currentOrderId: waitingOrder.id, + nextOrderId: nextOrder.id, + assetIn: waitingResult.assetIn, + amountIn: waitingResult.amountIn, + assetOut: waitingResult.assetOut, + }); + + return { + success: false, + error: "Order processed. Next order details updated. Call build-tx again to continue.", + }; + } else { + // Order output not spent yet + return { + success: false, + error: "Transaction confirmed on chain. Waiting for order to be processed.", + }; + } + } else { + // Other order types not implemented yet + return { + success: false, + error: `Waiting logic for order type "${waitingOrder.orderType}" is not implemented yet`, + }; + } + } + + // STEP 2: No waiting order, find next unhandled order + const order = await OrderRepository.getNextUnhandledOrder(this.db, position.id); + if (!order) { + return { success: false, error: "No unhandled order found" }; + } + + // STEP 3: Handle order - check if transaction already built if (order.builtTxId) { logger.info("Order has built_tx_id, checking transaction status", { orderId: order.id, builtTxId: order.builtTxId, + hasCreatedTxId: !!order.createdTxId, }); const address = Address.fromBech32(userAddress); - const txFound = await this.cardanoscanProvider.findTransactionByHash( + + // If order.createdTxId exists, transaction was already found on chain + if (order.createdTxId) { + logger.info("Transaction already confirmed on chain", { + orderId: order.id, + createdTxId: order.createdTxId, + }); + // Transaction is confirmed, waiting for it to be spent + return { + success: false, + error: "Transaction confirmed on chain. Waiting for order to be processed.", + }; + } + + // Search for transaction on chain + const txFoundOnChain = await this.cardanoscanProvider.findTransactionByHash( address, order.builtTxId, 50, // pageSize 10, // maxPage - search up to 10 pages (500 transactions) ); - // Case 1a: Transaction found on chain => order is handled, move to next - if (txFound) { + if (txFoundOnChain) { logger.info("Transaction found on chain", { orderId: order.id, - txHash: order.builtTxId, + txHash: txFoundOnChain.hash, }); + + // Update order with created_tx_id (this will set waiting = true by default) + await OrderRepository.updateOrderCreatedTx(this.db, order.id, txFoundOnChain.hash, 0); + return { success: false, - error: "Transaction already submitted and found on chain. This order is being processed.", + error: "Transaction confirmed on chain. Waiting for order to be processed.", }; } - // Case 3: Transaction not found on chain - // Check if transaction has expired + // Transaction not found on chain - check if expired const now = new Date(); const validTo = order.builtValidTo; @@ -184,7 +293,7 @@ export class PositionService { }); // Fall through to rebuild } else if (validTo < now) { - // Case 3a: Transaction expired => rebuild + // Transaction expired => rebuild logger.info("Transaction expired, rebuilding", { orderId: order.id, validTo: validTo.toISOString(), @@ -192,7 +301,7 @@ export class PositionService { }); // Fall through to rebuild } else { - // Case 3b: Transaction not expired yet => wait + // Transaction not expired yet => wait const remainingMs = validTo.getTime() - now.getTime(); const remainingMinutes = Math.ceil(remainingMs / 1000 / 60); logger.info("Transaction not yet expired, waiting", { @@ -209,7 +318,7 @@ export class PositionService { } } - // Case 2: Order has no built_tx_id OR transaction expired => build new transaction + // STEP 4: Build new transaction logger.info("Building new transaction", { orderId: order.id, orderType: order.orderType, From 1edd46ff560a44039d1a8298861f42e8a5e8e4ac Mon Sep 17 00:00:00 2001 From: tony Date: Wed, 4 Feb 2026 17:19:25 +0700 Subject: [PATCH 09/35] wip supply long --- apps/long-short-backend/package.json | 1 + .../src/api/state-machine.ts | 69 ++++++++++++++++++- .../src/services/position-service.ts | 8 +++ .../src/liqwid-provider.ts | 2 +- pnpm-lock.yaml | 3 + 5 files changed, 81 insertions(+), 2 deletions(-) diff --git a/apps/long-short-backend/package.json b/apps/long-short-backend/package.json index ab515d6..c410301 100644 --- a/apps/long-short-backend/package.json +++ b/apps/long-short-backend/package.json @@ -41,6 +41,7 @@ "@minswap/felis-ledger-core": "workspace:*", "@minswap/felis-ledger-utils": "workspace:*", "@minswap/felis-tx-builder": "workspace:*", + "@minswap/felis-lending-market": "workspace:*", "@minswap/tiny-invariant": "^1.2.0", "@sinclair/typebox": "^0.34.33", "@types/bun": "^1.3.5", diff --git a/apps/long-short-backend/src/api/state-machine.ts b/apps/long-short-backend/src/api/state-machine.ts index f7a1d99..cc19b19 100644 --- a/apps/long-short-backend/src/api/state-machine.ts +++ b/apps/long-short-backend/src/api/state-machine.ts @@ -3,7 +3,8 @@ import { DEXOrderTransaction } from "@minswap/felis-build-tx"; import { Address, Asset, NetworkEnvironment, Utxo, XJSON } from "@minswap/felis-ledger-core"; import { DexVersion, OrderV2Direction, OrderV2StepType } from "@minswap/felis-dex-v2"; import { CoinSelectionAlgorithm, EmulatorProvider } from "@minswap/felis-tx-builder"; -import { Duration, RustModule, safeFreeRustObjects } from "@minswap/felis-ledger-utils"; +import { blake2b256, Duration, Result, RustModule, safeFreeRustObjects } from "@minswap/felis-ledger-utils"; +import { LiqwidProvider } from "@minswap/felis-lending-market"; import { HashUtils } from "../utils"; import { CardanoscanProvider } from "../provider"; import { MarketConfig } from "../config"; @@ -99,6 +100,70 @@ export namespace StateMachine { } }; + export type HandleLongSupplyOptions = { + order: { + order_type: string; + asset_in: string | null; + amount_in: string | null; + asset_out: string | null; + }; + userAddress: string; + networkEnv: NetworkEnvironment; + utxos: string[]; + }; + + export const handleLongSupply = async (options: HandleLongSupplyOptions) => { + const { order, userAddress, networkEnv, utxos } = options; + invariant(order.order_type === LongOrderType.LONG_SUPPLY, "Invalid order type for handleLongSupply"); + invariant(order.asset_in, "asset_in is required for LONG_SUPPLY order"); + invariant(order.amount_in, "amount_in is required for LONG_SUPPLY order"); + invariant(order.asset_out, "asset_out is required for LONG_SUPPLY order"); + + // asset_out contains the lending market ID (collateral token qMIN or qADA) + // We need to extract the market ID from the asset_out + // For example: "186cd98a29585651c89f05807a876cf26cdf47a7f86f70be3b9e4cc0" -> "MIN" + const marketId = order.asset_out as LiqwidProvider.MarketId; + + const buildTxResult = await LiqwidProvider.getSupplyTransaction({ + marketId, + amount: Number(order.amount_in), + address: userAddress, + utxos, + networkEnv, + }); + + if (buildTxResult.type === "err") { + throw new Error(`Failed to build supply transaction: ${buildTxResult.error.message}`); + } + + const txRaw = buildTxResult.value; + + // Calculate transaction ID from transaction body hash + const ECSL = RustModule.getE; + const eTx = ECSL.Transaction.from_hex(txRaw); + const txBody = eTx.body(); + const txBodyBytes = txBody.to_bytes(); + const txBodyHashBytes = blake2b256(Buffer.from(txBodyBytes)); + const txBodyHash = ECSL.TransactionHash.from_bytes(Buffer.from(txBodyHashBytes)); + const txId = txBodyHash.to_hex(); + + // Calculate outputs hash for transaction chaining + const outputsHash = HashUtils.sha256(utxos.join(",")); + + // Set valid_to to 3 minutes from now + const validTo = new Date().getTime() + Duration.newMinutes(3).milliseconds; + + safeFreeRustObjects(eTx, txBody, txBodyHash); + + return { + txRaw, + txId, + orderOutputIndex: 0, + outputsHash, + validTo, + }; + }; + export type WaitingLongBuyOptions = { marketConfig: MarketConfig; txHash: string; @@ -179,4 +244,6 @@ export namespace StateMachine { isSpent: false, }; }; + + } diff --git a/apps/long-short-backend/src/services/position-service.ts b/apps/long-short-backend/src/services/position-service.ts index 416c134..9d3a379 100644 --- a/apps/long-short-backend/src/services/position-service.ts +++ b/apps/long-short-backend/src/services/position-service.ts @@ -351,6 +351,14 @@ export class PositionService { utxos, }); break; + case StateMachine.LongOrderType.LONG_SUPPLY: + txResult = await StateMachine.handleLongSupply({ + order: orderRow, + userAddress, + networkEnv: this.networkEnv, + utxos, + }); + break; default: return { success: false, error: `Order type "${order.orderType}" is not implemented` }; } diff --git a/packages/minswap-lending-market/src/liqwid-provider.ts b/packages/minswap-lending-market/src/liqwid-provider.ts index 7ff6bff..24df4df 100644 --- a/packages/minswap-lending-market/src/liqwid-provider.ts +++ b/packages/minswap-lending-market/src/liqwid-provider.ts @@ -3,7 +3,7 @@ import { blake2b256, Result, RustModule } from "@minswap/felis-ledger-utils"; import * as cbor from "cbor"; export namespace LiqwidProvider { - export type MarketId = "MIN" | "Ada"; + export type MarketId = "MIN" | "Ada" | "NIGHT"; export type CollateralMarket = | "Ada.186cd98a29585651c89f05807a876cf26cdf47a7f86f70be3b9e4cc0" | "MIN.50e015ec8204db83a4f57aa9ee40ce6ea157e3b7335a149fafe3f370"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f5f037..b0304b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: '@minswap/felis-ledger-utils': specifier: workspace:* version: link:../../packages/ledger-utils + '@minswap/felis-lending-market': + specifier: workspace:* + version: link:../../packages/minswap-lending-market '@minswap/felis-tx-builder': specifier: workspace:* version: link:../../packages/tx-builder From f289116f5e723b39992f54bc91aba7698ecdb290 Mon Sep 17 00:00:00 2001 From: tony Date: Thu, 5 Feb 2026 09:49:52 +0700 Subject: [PATCH 10/35] format --- apps/long-short-backend/src/api/helper.ts | 10 +--- apps/long-short-backend/src/api/index.ts | 2 +- .../src/api/routes/metadata.ts | 2 +- .../src/api/routes/position.ts | 4 +- apps/long-short-backend/src/api/schemas.ts | 10 ++-- apps/long-short-backend/src/api/server.ts | 8 +-- .../src/api/state-machine.ts | 50 ++++++++++--------- apps/long-short-backend/src/cmd/run-api.ts | 11 ++-- apps/long-short-backend/src/config/market.ts | 2 +- .../src/provider/cardanoscan.ts | 15 ++---- apps/long-short-backend/src/provider/index.ts | 2 +- .../src/repository/order-repository.ts | 39 +++------------ .../src/repository/position-repository.ts | 24 ++------- apps/long-short-backend/src/services/index.ts | 2 +- .../src/services/position-service.ts | 31 +++++------- .../long-short-backend/src/utils/signature.ts | 19 ++----- biome.json | 3 +- 17 files changed, 84 insertions(+), 150 deletions(-) diff --git a/apps/long-short-backend/src/api/helper.ts b/apps/long-short-backend/src/api/helper.ts index 2da6d89..c46d6a4 100644 --- a/apps/long-short-backend/src/api/helper.ts +++ b/apps/long-short-backend/src/api/helper.ts @@ -3,9 +3,7 @@ import { verifySignData } from "../utils/signature"; import type { SignedDataType } from "./schemas"; export namespace ApiHelper { - export type AuthenticateResult = - | { success: true } - | { success: false; error: string }; + export type AuthenticateResult = { success: true } | { success: false; error: string }; /** * Authenticate a request by verifying the witness signature @@ -15,11 +13,7 @@ export namespace ApiHelper { * @param userAddress - The user's Cardano address (bech32) * @param witness - The signed data (COSEKey and COSESign1) */ - export function authenticate( - data: object, - userAddress: string, - witness: SignedDataType, - ): AuthenticateResult { + export function authenticate(data: object, userAddress: string, witness: SignedDataType): AuthenticateResult { const message = Buffer.from(JSON.stringify(data)).toString("hex"); const hashMessage = HashUtils.sha256(message); diff --git a/apps/long-short-backend/src/api/index.ts b/apps/long-short-backend/src/api/index.ts index 9440911..e584dca 100644 --- a/apps/long-short-backend/src/api/index.ts +++ b/apps/long-short-backend/src/api/index.ts @@ -1,2 +1,2 @@ -export { createApiServer, type ApiServerOptions } from "./server"; export * from "./schemas"; +export { type ApiServerOptions, createApiServer } from "./server"; diff --git a/apps/long-short-backend/src/api/routes/metadata.ts b/apps/long-short-backend/src/api/routes/metadata.ts index 25880b9..4d6bda2 100644 --- a/apps/long-short-backend/src/api/routes/metadata.ts +++ b/apps/long-short-backend/src/api/routes/metadata.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from "fastify"; import { getEnabledMarketConfigs, type MarketConfig } from "../../config/market"; -import { type MarketConfigResponseType, MetadataResponseSchema, type MetadataResponseType } from "../schemas"; import { API_ENDPOINTS } from "../../constants"; +import { type MarketConfigResponseType, MetadataResponseSchema, type MetadataResponseType } from "../schemas"; function marketConfigToResponse(config: MarketConfig): MarketConfigResponseType { return { diff --git a/apps/long-short-backend/src/api/routes/position.ts b/apps/long-short-backend/src/api/routes/position.ts index 9076768..2e455a9 100644 --- a/apps/long-short-backend/src/api/routes/position.ts +++ b/apps/long-short-backend/src/api/routes/position.ts @@ -1,7 +1,8 @@ +import invariant from "@minswap/tiny-invariant"; import type { FastifyInstance } from "fastify"; import { API_ENDPOINTS } from "../../constants"; -import { PositionService } from "../../services/position-service"; import type { Position } from "../../repository/position-repository"; +import type { PositionService } from "../../services/position-service"; import { ApiHelper } from "../helper"; import { type AuthenBuildTxBodyType, @@ -169,6 +170,7 @@ export function registerPositionRoutes(fastify: FastifyInstance, positionService } // Return newly built transaction + invariant("txRaw" in result && result.txRaw, "type-safe"); return reply.status(200).send({ success: true, data: { diff --git a/apps/long-short-backend/src/api/schemas.ts b/apps/long-short-backend/src/api/schemas.ts index 854a20c..6c39185 100644 --- a/apps/long-short-backend/src/api/schemas.ts +++ b/apps/long-short-backend/src/api/schemas.ts @@ -1,4 +1,4 @@ -import { Type, type Static } from "@sinclair/typebox"; +import { type Static, Type } from "@sinclair/typebox"; import { StateMachine } from "./state-machine"; export const SignedDataSchema = Type.Object({ @@ -23,12 +23,8 @@ export type AuthenCommonType = { }; // Position schemas (derived from StateMachine enums) -export const PositionSideSchema = Type.Union( - Object.values(StateMachine.PositionSide).map((v) => Type.Literal(v)), -); -export const PositionStatusSchema = Type.Union( - Object.values(StateMachine.PositionStatus).map((v) => Type.Literal(v)), -); +export const PositionSideSchema = Type.Union(Object.values(StateMachine.PositionSide).map((v) => Type.Literal(v))); +export const PositionStatusSchema = Type.Union(Object.values(StateMachine.PositionStatus).map((v) => Type.Literal(v))); export const CreatePositionDataSchema = Type.Object({ market_id: Type.String({ minLength: 1, description: "Market ID (e.g., ADA-MIN)" }), diff --git a/apps/long-short-backend/src/api/server.ts b/apps/long-short-backend/src/api/server.ts index 790c212..61b013b 100644 --- a/apps/long-short-backend/src/api/server.ts +++ b/apps/long-short-backend/src/api/server.ts @@ -1,14 +1,14 @@ import cors from "@fastify/cors"; +import type { NetworkEnvironment } from "@minswap/felis-ledger-core"; import Fastify, { type FastifyInstance } from "fastify"; import type { Kysely } from "kysely"; -import type { NetworkEnvironment } from "@minswap/felis-ledger-core"; +import { API_ENDPOINTS } from "../constants"; import type { DB } from "../database"; +import type { CardanoscanProvider } from "../provider"; +import { PositionService } from "../services/position-service"; import { logger } from "../utils"; import { registerMetadataRoutes } from "./routes/metadata"; import { registerPositionRoutes } from "./routes/position"; -import { PositionService } from "../services/position-service"; -import { API_ENDPOINTS } from "../constants"; -import { CardanoscanProvider } from "../provider"; export type ApiServerOptions = { port: number; diff --git a/apps/long-short-backend/src/api/state-machine.ts b/apps/long-short-backend/src/api/state-machine.ts index cc19b19..95124df 100644 --- a/apps/long-short-backend/src/api/state-machine.ts +++ b/apps/long-short-backend/src/api/state-machine.ts @@ -1,13 +1,13 @@ -import invariant from "@minswap/tiny-invariant"; import { DEXOrderTransaction } from "@minswap/felis-build-tx"; -import { Address, Asset, NetworkEnvironment, Utxo, XJSON } from "@minswap/felis-ledger-core"; import { DexVersion, OrderV2Direction, OrderV2StepType } from "@minswap/felis-dex-v2"; -import { CoinSelectionAlgorithm, EmulatorProvider } from "@minswap/felis-tx-builder"; -import { blake2b256, Duration, Result, RustModule, safeFreeRustObjects } from "@minswap/felis-ledger-utils"; +import { Address, Asset, type NetworkEnvironment, Utxo } from "@minswap/felis-ledger-core"; +import { blake2b256, Duration, RustModule, safeFreeRustObjects } from "@minswap/felis-ledger-utils"; import { LiqwidProvider } from "@minswap/felis-lending-market"; +import { CoinSelectionAlgorithm, EmulatorProvider } from "@minswap/felis-tx-builder"; +import invariant from "@minswap/tiny-invariant"; +import type { MarketConfig } from "../config"; +import type { CardanoscanProvider } from "../provider"; import { HashUtils } from "../utils"; -import { CardanoscanProvider } from "../provider"; -import { MarketConfig } from "../config"; export namespace StateMachine { export enum PositionSide { @@ -63,22 +63,28 @@ export namespace StateMachine { const txb = DEXOrderTransaction.createBulkOrdersTx({ networkEnv, sender, - orderOptions: [{ - lpAsset: Asset.fromString(marketConfig.amm_lp_asset), - version: DexVersion.DEX_V2, - type: OrderV2StepType.SWAP_EXACT_IN, - assetIn: Asset.fromString(marketConfig.asset_a), - amountIn: BigInt(order.amount_in.toString()), - minimumAmountOut: 1n, - direction: OrderV2Direction.A_TO_B, - killOnFailed: false, - isLimitOrder: false, - }], + orderOptions: [ + { + lpAsset: Asset.fromString(marketConfig.amm_lp_asset), + version: DexVersion.DEX_V2, + type: OrderV2StepType.SWAP_EXACT_IN, + assetIn: Asset.fromString(marketConfig.asset_a), + amountIn: BigInt(order.amount_in.toString()), + minimumAmountOut: 1n, + direction: OrderV2Direction.A_TO_B, + killOnFailed: false, + isLimitOrder: false, + }, + ], }); - const validTo = new Date().getTime() + Duration.newMinutes(3).milliseconds; + const validTo = Date.now() + Duration.newMinutes(3).milliseconds; txb.validToUnixTime(validTo); - const {txComplete, txId, newUtxoState: { changeUtxos } } = await txb.completeUnsafeForTxChaining({ + const { + txComplete, + txId, + newUtxoState: { changeUtxos }, + } = await txb.completeUnsafeForTxChaining({ coinSelectionAlgorithm: CoinSelectionAlgorithm.SPEND_ALL, walletUtxos, changeAddress: sender, @@ -97,7 +103,7 @@ export namespace StateMachine { orderOutputIndex: 0, outputsHash, validTo, - } + }; }; export type HandleLongSupplyOptions = { @@ -151,7 +157,7 @@ export namespace StateMachine { const outputsHash = HashUtils.sha256(utxos.join(",")); // Set valid_to to 3 minutes from now - const validTo = new Date().getTime() + Duration.newMinutes(3).milliseconds; + const validTo = Date.now() + Duration.newMinutes(3).milliseconds; safeFreeRustObjects(eTx, txBody, txBodyHash); @@ -244,6 +250,4 @@ export namespace StateMachine { isSpent: false, }; }; - - } diff --git a/apps/long-short-backend/src/cmd/run-api.ts b/apps/long-short-backend/src/cmd/run-api.ts index 8f2aa75..e848b8b 100644 --- a/apps/long-short-backend/src/cmd/run-api.ts +++ b/apps/long-short-backend/src/cmd/run-api.ts @@ -1,11 +1,11 @@ -import { RustModule } from "@minswap/felis-ledger-utils"; import { NetworkEnvironment } from "@minswap/felis-ledger-core"; +import { RustModule } from "@minswap/felis-ledger-utils"; import { createApiServer } from "../api/server"; import { loadMarketConfigs } from "../config/market"; -import { newKyselyClient } from "../database/postgres"; import type { DB } from "../database"; -import { logger } from "../utils"; +import { newKyselyClient } from "../database/postgres"; import { CardanoscanProvider } from "../provider"; +import { logger } from "../utils"; const API_PORT = Number(process.env.API_PORT) || 9999; const API_HOST = process.env.API_HOST || "0.0.0.0"; @@ -41,10 +41,7 @@ async function main() { logger.info(`Loaded ${marketConfigs.size} market configs`); // Create Cardanoscan provider - const cardanoscanProvider = new CardanoscanProvider( - "https://api.cardanoscan.io/api/v1", - CARDANOSCAN_API_KEY, - ); + const cardanoscanProvider = new CardanoscanProvider("https://api.cardanoscan.io/api/v1", CARDANOSCAN_API_KEY); // Start API server logger.info("Starting API server..."); diff --git a/apps/long-short-backend/src/config/market.ts b/apps/long-short-backend/src/config/market.ts index 1d1269e..9ad6e3f 100644 --- a/apps/long-short-backend/src/config/market.ts +++ b/apps/long-short-backend/src/config/market.ts @@ -80,7 +80,7 @@ export function getMarketConfig(marketId: string): MarketConfig | null { */ export function isSupportedMarket(marketId: string): boolean { const config = getMarketConfig(marketId); - return config !== null && config.enable; + return config?.enable; } /** diff --git a/apps/long-short-backend/src/provider/cardanoscan.ts b/apps/long-short-backend/src/provider/cardanoscan.ts index 9d2ab84..cbbb580 100644 --- a/apps/long-short-backend/src/provider/cardanoscan.ts +++ b/apps/long-short-backend/src/provider/cardanoscan.ts @@ -1,4 +1,4 @@ -import { Address } from "@minswap/felis-ledger-core"; +import type { Address } from "@minswap/felis-ledger-core"; import { logger } from "../utils"; /** @@ -27,7 +27,7 @@ export type CardanoscanTransaction = { blockHeight: number; timestamp: string; // ISO 8601 date-time index: number; - inputs: (CardanoscanTxIO & {txId: string; index: number; })[]; + inputs: (CardanoscanTxIO & { txId: string; index: number })[]; outputs: CardanoscanTxIO[]; collateral: CardanoscanTxIO[]; certificates?: { @@ -129,7 +129,7 @@ export class CardanoscanProvider { method: "GET", headers: { Accept: "application/json", - "apiKey": this.apiKey, + apiKey: this.apiKey, }, }); @@ -180,10 +180,7 @@ export class CardanoscanProvider { * @param maxPages - Maximum number of pages to fetch (default: no limit) * @returns All transactions */ - async getAllTransactionsByAddress( - address: Address, - maxPages?: number, - ): Promise { + async getAllTransactionsByAddress(address: Address, maxPages?: number): Promise { const allTransactions: CardanoscanTransaction[] = []; let pageNo = 1; let hasMore = true; @@ -298,9 +295,7 @@ export class CardanoscanProvider { // Check each transaction's inputs for the specified UTXO for (const tx of response.transactions) { - const spentInput = tx.inputs.find( - (input) => input.txId === txHash && input.index === index, - ); + const spentInput = tx.inputs.find((input) => input.txId === txHash && input.index === index); if (spentInput) { logger.info("Found transaction that spent UTXO", { diff --git a/apps/long-short-backend/src/provider/index.ts b/apps/long-short-backend/src/provider/index.ts index 6916900..56af2ac 100644 --- a/apps/long-short-backend/src/provider/index.ts +++ b/apps/long-short-backend/src/provider/index.ts @@ -1,2 +1,2 @@ -export * from "./kupo"; export * from "./cardanoscan"; +export * from "./kupo"; diff --git a/apps/long-short-backend/src/repository/order-repository.ts b/apps/long-short-backend/src/repository/order-repository.ts index 1adae12..7deaa34 100644 --- a/apps/long-short-backend/src/repository/order-repository.ts +++ b/apps/long-short-backend/src/repository/order-repository.ts @@ -29,10 +29,7 @@ export type Order = { }; export namespace OrderRepository { - export async function createOrder( - db: Kysely | Transaction, - params: CreateOrderParams, - ): Promise { + export async function createOrder(db: Kysely | Transaction, params: CreateOrderParams): Promise { const result = await db .insertInto("order") .values({ @@ -51,10 +48,7 @@ export namespace OrderRepository { return mapOrderRow(result); } - export async function createOrders( - db: Kysely | Transaction, - params: CreateOrderParams[], - ): Promise { + export async function createOrders(db: Kysely | Transaction, params: CreateOrderParams[]): Promise { const results = await db .insertInto("order") .values( @@ -75,15 +69,8 @@ export namespace OrderRepository { return results.map(mapOrderRow); } - export async function getOrdersByPositionId( - db: Kysely | Transaction, - positionId: bigint, - ): Promise { - const results = await db - .selectFrom("order") - .selectAll() - .where("position_id", "=", positionId.toString()) - .execute(); + export async function getOrdersByPositionId(db: Kysely | Transaction, positionId: bigint): Promise { + const results = await db.selectFrom("order").selectAll().where("position_id", "=", positionId.toString()).execute(); return results.map(mapOrderRow); } @@ -100,12 +87,7 @@ export namespace OrderRepository { .selectFrom("order") .selectAll() .where("position_id", "=", positionId.toString()) - .where((eb) => - eb.or([ - eb("created_tx_id", "is", null), - eb("created_tx_id", "=", ""), - ]), - ) + .where((eb) => eb.or([eb("created_tx_id", "is", null), eb("created_tx_id", "=", "")])) .where("asset_in", "is not", null) .where("amount_in", "is not", null) .where("asset_out", "is not", null) @@ -180,10 +162,7 @@ export namespace OrderRepository { * Find an order that has created_tx_id not null and waiting = true * This order has been confirmed on chain and is waiting for its output to be spent */ - export async function getWaitingOrder( - db: Kysely | Transaction, - positionId: bigint, - ): Promise { + export async function getWaitingOrder(db: Kysely | Transaction, positionId: bigint): Promise { const result = await db .selectFrom("order") .selectAll() @@ -204,11 +183,7 @@ export namespace OrderRepository { orderId: bigint, waiting: boolean, ): Promise { - await db - .updateTable("order") - .set({ waiting }) - .where("id", "=", orderId.toString()) - .execute(); + await db.updateTable("order").set({ waiting }).where("id", "=", orderId.toString()).execute(); } // biome-ignore lint/suspicious/noExplicitAny: DB row type diff --git a/apps/long-short-backend/src/repository/position-repository.ts b/apps/long-short-backend/src/repository/position-repository.ts index fead57a..106658d 100644 --- a/apps/long-short-backend/src/repository/position-repository.ts +++ b/apps/long-short-backend/src/repository/position-repository.ts @@ -1,6 +1,6 @@ import type { Kysely, Transaction } from "kysely"; -import type { DB } from "../database"; import { StateMachine } from "../api/state-machine"; +import type { DB } from "../database"; export type CreatePositionParams = { marketId: string; @@ -43,15 +43,8 @@ export namespace PositionRepository { return mapPositionRow(result); } - export async function getPositionById( - db: Kysely | Transaction, - id: bigint, - ): Promise { - const result = await db - .selectFrom("position") - .selectAll() - .where("id", "=", id.toString()) - .executeTakeFirst(); + export async function getPositionById(db: Kysely | Transaction, id: bigint): Promise { + const result = await db.selectFrom("position").selectAll().where("id", "=", id.toString()).executeTakeFirst(); return result ? mapPositionRow(result) : null; } @@ -106,10 +99,7 @@ export namespace PositionRepository { userAddress: string, options?: { includesClosed?: boolean; limit?: number; offset?: number }, ): Promise { - let query = db - .selectFrom("position") - .selectAll() - .where("user_address", "=", userAddress); + let query = db.selectFrom("position").selectAll().where("user_address", "=", userAddress); if (!options?.includesClosed) { query = query.where("closed_at", "is", null); @@ -140,11 +130,7 @@ export namespace PositionRepository { updates.closed_at = new Date(); } - await db - .updateTable("position") - .set(updates) - .where("id", "=", id.toString()) - .execute(); + await db.updateTable("position").set(updates).where("id", "=", id.toString()).execute(); } // biome-ignore lint/suspicious/noExplicitAny: DB row type diff --git a/apps/long-short-backend/src/services/index.ts b/apps/long-short-backend/src/services/index.ts index 99f4f45..99da2e4 100644 --- a/apps/long-short-backend/src/services/index.ts +++ b/apps/long-short-backend/src/services/index.ts @@ -1 +1 @@ -export { PositionService, type CreatePositionInput, type CreatePositionResult } from "./position-service"; +export { type CreatePositionInput, type CreatePositionResult, PositionService } from "./position-service"; diff --git a/apps/long-short-backend/src/services/position-service.ts b/apps/long-short-backend/src/services/position-service.ts index 9d3a379..223664f 100644 --- a/apps/long-short-backend/src/services/position-service.ts +++ b/apps/long-short-backend/src/services/position-service.ts @@ -1,12 +1,13 @@ +import { Address, Asset, type NetworkEnvironment } from "@minswap/felis-ledger-core"; +import invariant from "@minswap/tiny-invariant"; import type { Kysely } from "kysely"; -import { Address, Asset, XJSON, type NetworkEnvironment } from "@minswap/felis-ledger-core"; +import { StateMachine } from "../api/state-machine"; import { getMarketConfig, isSupportedMarket } from "../config/market"; import type { DB } from "../database"; -import { type Position, PositionRepository } from "../repository/position-repository"; +import type { CardanoscanProvider } from "../provider"; import { OrderRepository } from "../repository/order-repository"; -import { StateMachine } from "../api/state-machine"; +import { type Position, PositionRepository } from "../repository/position-repository"; import { logger } from "../utils"; -import { CardanoscanProvider } from "../provider"; export type CreatePositionInput = { userAddress: string; @@ -15,9 +16,7 @@ export type CreatePositionInput = { amountIn: bigint; }; -export type CreatePositionResult = - | { success: true; position: Position } - | { success: false; error: string }; +export type CreatePositionResult = { success: true; position: Position } | { success: false; error: string }; export type BuildTxInput = { userAddress: string; @@ -64,11 +63,7 @@ export class PositionService { } // Check for existing open position in this market - const existingPosition = await PositionRepository.getOpenPositionByUserAndMarket( - this.db, - userAddress, - marketId, - ); + const existingPosition = await PositionRepository.getOpenPositionByUserAndMarket(this.db, userAddress, marketId); if (existingPosition) { return { @@ -129,11 +124,7 @@ export class PositionService { } // Check if user has an open position for this market - const position = await PositionRepository.getOpenPositionByUserAndMarket( - this.db, - userAddress, - marketId, - ); + const position = await PositionRepository.getOpenPositionByUserAndMarket(this.db, userAddress, marketId); if (!position) { return { success: false, error: "No open position found for this market" }; @@ -153,6 +144,8 @@ export class PositionService { orderType: waitingOrder.orderType, createdTxId: waitingOrder.createdTxId, }); + invariant(waitingOrder.assetOut, "Waiting order must have assetOut defined"); + invariant(waitingOrder.createdTxId, "Waiting order must have createdTxId defined"); const address = Address.fromBech32(userAddress); @@ -160,10 +153,10 @@ export class PositionService { if (waitingOrder.orderType === StateMachine.LongOrderType.LONG_BUY) { const waitingResult = await StateMachine.waitingLongBuy({ marketConfig, - txHash: waitingOrder.createdTxId!, + txHash: waitingOrder.createdTxId, orderOutputIndex: waitingOrder.createdTxIndex ?? 0, userAddress: address, - assetOut: Asset.fromString(waitingOrder.assetOut!), + assetOut: Asset.fromString(waitingOrder.assetOut), cardanoscanProvider: this.cardanoscanProvider, }); diff --git a/apps/long-short-backend/src/utils/signature.ts b/apps/long-short-backend/src/utils/signature.ts index eac51ba..3eb18d3 100644 --- a/apps/long-short-backend/src/utils/signature.ts +++ b/apps/long-short-backend/src/utils/signature.ts @@ -1,6 +1,6 @@ -import { Address, PrivateKey } from "@minswap/felis-ledger-core"; -import { RustModule } from "@minswap/felis-ledger-utils"; import * as CMS from "@emurgo/cardano-message-signing-nodejs"; +import { Address, type PrivateKey } from "@minswap/felis-ledger-core"; +import { RustModule } from "@minswap/felis-ledger-utils"; export type VerifySignDataOptions = { message: string; // The original message that was signed (hex encoded) @@ -29,9 +29,7 @@ export function verifySignData(options: VerifySignDataOptions): boolean { // 2. Parse the COSEKey and extract public key using label -2 (COSE key identifier for EC2 x-coordinate) const coseKey = CMS.COSEKey.from_bytes(Buffer.from(key, "hex")); - const pubKeyBytes = coseKey - .header(CMS.Label.new_int(CMS.Int.new_negative(CMS.BigNum.from_str("2")))) - ?.as_bytes(); + const pubKeyBytes = coseKey.header(CMS.Label.new_int(CMS.Int.new_negative(CMS.BigNum.from_str("2"))))?.as_bytes(); if (!pubKeyBytes) { return false; @@ -80,21 +78,14 @@ export function verifySignData(options: VerifySignDataOptions): boolean { } } -export function signData( - privateKey: PrivateKey, - address: string, - payload: string, -): { signature: string; key: string } { +export function signData(privateKey: PrivateKey, address: string, payload: string): { signature: string; key: string } { const cslPrivateKey = privateKey.toECSL(); const cslPublicKey = cslPrivateKey.to_public(); // Build protected header with address const protectedHeaders = CMS.HeaderMap.new(); protectedHeaders.set_algorithm_id(CMS.Label.from_algorithm_id(CMS.AlgorithmId.EdDSA)); - protectedHeaders.set_header( - CMS.Label.new_text("address"), - CMS.CBORValue.new_bytes(Buffer.from(address, "hex")), - ); + protectedHeaders.set_header(CMS.Label.new_text("address"), CMS.CBORValue.new_bytes(Buffer.from(address, "hex"))); const protectedSerialized = CMS.ProtectedHeaderMap.new(protectedHeaders); const unprotectedHeaders = CMS.HeaderMap.new(); diff --git a/biome.json b/biome.json index 7c52aaf..5b43dfe 100644 --- a/biome.json +++ b/biome.json @@ -15,7 +15,8 @@ "packages/minswap-dex-v2/**", "packages/minswap-lending-market/**", "packages/tx-builder/**", - "apps/web/**" + "apps/web/**", + "apps/long-short-backend/src/**" ], "experimentalScannerIgnores": ["**/uplc/**"] }, From ea486081e606a83bb524c1cf75051f9c88c10357 Mon Sep 17 00:00:00 2001 From: tony Date: Thu, 5 Feb 2026 10:50:25 +0700 Subject: [PATCH 11/35] add schema --- .../minswap-lending-market/src/schema.graphql | 1253 +++++++++++++++++ 1 file changed, 1253 insertions(+) create mode 100644 packages/minswap-lending-market/src/schema.graphql diff --git a/packages/minswap-lending-market/src/schema.graphql b/packages/minswap-lending-market/src/schema.graphql new file mode 100644 index 0000000..900f4cb --- /dev/null +++ b/packages/minswap-lending-market/src/schema.graphql @@ -0,0 +1,1253 @@ +directive @priceConversion on FIELD_DEFINITION + +directive @cost( + """Assumes the cost of the annotated component""" + weight: Int! +) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR + +""" +Indicates exactly one field must be supplied and this field must not be `null`. +""" +directive @oneOf on INPUT_OBJECT + +input AquafarmersInput { + assets: [String!]! +} + +"""Metadata for the aquafarmer""" +type AquafarmerOnchainMetadata { + hat: String + tier: String + name: String + image: String + github: [String!] + medium: [String!] + discord: [String!] + project: [String!] + twitter: [String!] + website: [String!] + mediaType: String + background: String + farmerHead: String + description: String + armMechanics: String + leftHandTool: String + farmerClothing: String + rightHandTool: String + farmerBodyColor: String + backgroundAccessories: String +} + +enum AgoraStakeSort { + CREATED_DATE + CREATED_DATE_DESC + MODIFIED_DATE + MODIFIED_DATE_DESC + AMOUNT + AMOUNT_DESC +} + +enum AgoraStakeFilter { + OWNED + DELEGATED +} + +input AgoraStakesInput { + paymentKeys: [String!]! + page: Int = 0 + perPage: Int = 20 + sorts: [AgoraStakeSort!] + filters: [AgoraStakeFilter!] +} + +enum AgoraStakeLockType { + Created + Cosigned + Voted +} + +type AgoraStakeLock { + proposalId: Float! + resultId: Int + votedAt: Float + type: AgoraStakeLockType! +} + +type AgoraStakePortion { + amount: Float! + endSlot: Float + endTimestamp: Float + startSlot: Float! + startTimestamp: Float! +} + +type AgoraStake { + txId: String! + owner: String! + delegatedTo: String + stakedAmount: Float! + lockedBy: [AgoraStakeLock!]! + substakes: [AgoraStakePortion!]! +} + +type AgoraStakePagination { + page: Int! + perPage: Int! + pagesCount: Int! + totalCount: Int! + results: [AgoraStake!]! +} + +type AquafarmerBoost { + id: String! + boost: Float! +} + +type Aquafarmer { + asset: String! + policyId: String! + assetName: String! + metadata: AquafarmerOnchainMetadata! +} + +type PublicDelegatee { + address: String! + name: String + discord: String + title: String! +} + +enum ProposalStatus { + Draft + Review + Voting + Locked + Finished + Executed +} + +type Cosigner { + address: String! + name: String + discord: String + title: String +} + +type ProposalTiming { + status: ProposalStatus! + startTimestamp: Float! + endTimestamp: Float +} + +type ProposalVote { + resultId: Float! + amount: Float! + percentage: Float! + name: String! + description: String! +} + +type Proposal { + id: Float! + status: ProposalStatus! + cosigners: [Cosigner!]! + timings: [ProposalTiming!]! + totalVotes: Float! + votes(sorts: [ProposalVoteSort!]): [ProposalVote!]! + title: String! + description: String! + ipfsUrl: String + minStakeVotingTime: Float! +} + +enum ProposalSort { + ID + ID_DESC + CREATED_DATE + CREATED_DATE_DESC + UPDATED_DATE + UPDATED_DATE_DESC + AMOUNT + AMOUNT_DESC +} + +enum ProposalVoteSort { + RESULT_ID + RESULT_ID_DESC + AMOUNT + AMOUNT_DESC +} + +enum ProposalFilter { + STATUS_DRAFT + STATUS_VOTING + STATUS_LOCKED + STATUS_FINISHED + STATUS_EXECUTED + VOTED_ON + COSIGNED +} + +input ProposalsInput { + page: Int = 0 + perPage: Int = 50 + paymentKeys: [String!] + sorts: [ProposalSort!] + filters: [ProposalFilter!] + search: String +} + +type ProposalsPagination { + page: Int! + perPage: Int! + pagesCount: Int! + totalCount: Int! + results: [Proposal!]! +} + +input ProposalInput { + id: Float! +} + +input UserAddressInput { + address: String! + changeAddress: String + otherAddresses: [String!] + utxos: [String!]! +} + +type AgoraData { + aquafarmersBoost: [AquafarmerBoost!]! + aquafarmers(input: AquafarmersInput!): [Aquafarmer!]! + stakes(input: AgoraStakesInput!): AgoraStakePagination! + publicDelegatees: [PublicDelegatee!]! + proposal(input: ProposalInput!): Proposal! + proposals(input: ProposalsInput): ProposalsPagination! + hasStakesAndAquafarmersInSameAddress(input: UserAddressInput!): Boolean! +} + +type AgoraQueries { + data: AgoraData! + transactions: AgoraTransactions! +} + +type Query { + agora: AgoraQueries! + meta: MetaQueries! + + """Get the exchange rate of a currency""" + currencyExchangeRate(input: CurrencyExchangeRateInput!): CurrencyExchangeRate! + liqwid: LiqwidQueries! + lq: LQQueries! + analytics: AnalyticsQueries! + historical: HistoricalQueries! +} + +enum Network { + MAINNET + PREVIEW +} + +type MetaQueries { + apiVersion: String! + network: Network! +} + +enum Currency { + """Euro €""" + EUR + + """US Dollar $""" + USD + + """British Pound £""" + GBP + + """Canadian Dollar C$""" + CAD + + """Brazilian Real R$""" + BRL + + """Japanese Yen ¥""" + JPY + + """Vietnamese Dong ₫""" + VND + + """Czech Koruna Kč""" + CZK + + """Australian Dollar A$""" + AUD + + """Singapore Dollar S$""" + SGD + + """Swiss Franc CHF""" + CHF +} + +type CurrencyExchangeRate { + source: String! + target: String! + value: Float! + timestamp: Float! +} + +input CurrencyExchangeRateInput { + source: String! + target: String! +} + +type LiqwidQueries { + data: LiqwidData! + transactions: LiqwidTransactions! + calculations: LiqwidCalculations! +} + +type LQQueries { + totalSupply: Float! + circulatingSupply: Float! + treasury: Float! + + """Amount of LQ staked""" + staked: Float! + price(input: InCurrencyInput): Float! + currencySymbol: String! +} + +type Transaction { + cbor: String! +} + +input SubmitTransactionInput { + transaction: String! + signature: String! +} + +type Mutation { + """Submit a CBOR transaction""" + submitTransaction(input: SubmitTransactionInput!): String! +} + +type AnalyticsQueries { + overview(startDate: String, endDate: String): AnalyticsOverview! + markets(startDate: String, endDate: String): [MarketAnalytics!]! + + """ + Query protocol revenue metrics for a date range. + Returns revenue breakdown including origination fees, liquidation profit, + ADA staking rewards, and DAO costs (dividends/POL interest). + """ + revenue(startDate: String, endDate: String): ProtocolRevenue! +} + +enum TransactionType { + SUPPLY + WITHDRAW + BORROW + BORROW_MORE + LOAN_MODIFICATION + REPAY + FULL_REPAY + LIQUIDATE + LIQUIDATOR +} + +type CustomFee { + """Wallet fee name""" + wallet: SupportedWallet! + displayName: String! + + """Amount taken in the asset being supplied or withdrawn""" + amount: Float! +} + +type HistoricalTransaction { + """txHash""" + id: String! + displayName: String! + logo: String! + time: String! + type: TransactionType! + oraclePrice: Float! + customFee: CustomFee + + """token amount for supply/withdraw""" + qAmount: Float + amount: Float + amountUSD: Float + + """qToken exchangeRate for supply/withdraw""" + exchangeRate: Float + principal: Float + principalUSD: Float + minInterest: Float + healthFactor: Float + loanOriginationFee: Float + + """Modification loan (borrow more/modify/repay)""" + beforeHealthFactor: Float + beforePrincipal: Float + beforePrincipalUSD: Float + totalCollateralUSD: Float + beforetotalCollateralUSD: Float + collaterals: [HistoryLoanCollateral!] +} + +type HistoryLoanCollateral { + """txHash + unit""" + id: String! + displayName: String! + logo: String! + oraclePrice: Float! + + """qToken exchangeRate""" + exchangeRate: Float! + qAmount: Float! + amount: Float! + amountUSD: Float! + healthFactor: Float! + + """Modification loan (borrow more/modify/repay)""" + beforeHealthFactor: Float + beforeQAmount: Float + beforeAmount: Float + beforeAmountUSD: Float +} + +type HistoricalTransactionPagination { + page: Int! + perPage: Int! + pagesCount: Int! + totalCount: Int! + results: [HistoricalTransaction!]! +} + +enum HistoricalTransactionSort { + DATE_ASC + DATE_DESC + TYPE_ASC + TYPE_DESC + DISPLAY_NAME_ASC + DISPLAY_NAME_DESC +} + +enum HistoricalTransactionFilter { + SUPPLY + WITHDRAW + BORROW + BORROW_MORE + LOAN_MODIFICATION + REPAY + FULL_REPAY + LIQUIDATE + LIQUIDATOR +} + +input HistoricalTransactionDate { + startTime: String! + endTime: String! +} + +input HistoricalTransactionInput { + addresses: [String!]! + sorts: [HistoricalTransactionSort!] + filters: [HistoricalTransactionFilter!] + date: HistoricalTransactionDate + page: Int = 0 + perPage: Int = 100 + disablePagination: Boolean +} + +input YieldEarnedDate { + startTime: String! + endTime: String! +} + +input YieldEarnedInput { + addresses: [String!]! + date: YieldEarnedDate +} + +type YieldEarned { + totalYieldEarned(input: InCurrencyInput): Float! + markets: [YieldEarnedMarket!]! +} + +type YieldEarnedMarket { + id: String! + displayName: String! + currencySymbol: String! + hexName: String! + amount: Float! + amountInCurrency(input: InCurrencyInput): Float! +} + +type HistoricalQueries { + transactions(input: HistoricalTransactionInput): HistoricalTransactionPagination! + yieldEarned(input: YieldEarnedInput!): YieldEarned! +} + +input AgoraCreateStakeTransactionInput { + address: String! + changeAddress: String + otherAddresses: [String!] + utxos: [String!]! + amount: Float! +} + +input AgoraUpdateStakeTransactionInput { + address: String! + changeAddress: String + otherAddresses: [String!] + utxos: [String!]! + txId: String! + amount: Float! +} + +input AgoraDestroyStakeTransactionInput { + address: String! + changeAddress: String + otherAddresses: [String!] + utxos: [String!]! + txId: String! +} + +input AgoraDelegateStakeTransactionInput { + address: String! + changeAddress: String + otherAddresses: [String!] + utxos: [String!]! + txId: String! + delegatee: String +} + +input AgoraUnlockStakesTransactionInput { + address: String! + changeAddress: String + otherAddresses: [String!] + utxos: [String!]! + txIds: [String!]! + proposalId: Int! +} + +input AgoraVoteProposalTransactionInput { + address: String! + changeAddress: String + otherAddresses: [String!] + utxos: [String!]! + txIds: [String!]! + proposalId: Int! + resultId: Int! +} + +input AgoraCosignProposalTransactionInput { + address: String! + changeAddress: String + otherAddresses: [String!] + utxos: [String!]! + txId: String! + proposalId: Int! +} + +type AgoraTransactions { + createStake(input: AgoraCreateStakeTransactionInput!): Transaction! + updateStake(input: AgoraUpdateStakeTransactionInput!): Transaction! + destroyStake(input: AgoraDestroyStakeTransactionInput!): Transaction! + delegateStake(input: AgoraDelegateStakeTransactionInput!): Transaction! + unlockStakes(input: AgoraUnlockStakesTransactionInput!): Transaction! + voteProposal(input: AgoraVoteProposalTransactionInput!): Transaction! + cosignProposal(input: AgoraCosignProposalTransactionInput!): Transaction! +} + +enum NetAPYFilter { + STABLECOIN + CNT + BRIDGED +} + +type LoanCalculation { + healthFactor: Float! + collateral(input: InCurrencyInput): Float! + collaterals: [LoanCollateral!]! + + """The maximum amount that can be borrowed with the current input""" + maxBorrow: Float! + + """The maximum amount that can be borrowed due to the borrow cap""" + maxBorrowCap: Float + protocolFee: Float! + protocolFeePercentage: Float! + + """The batching fee in ADA""" + batchingFee: Float! +} + +input LoanCalculationCollateralInput { + id: String! + amount: Float! +} + +input LoanCalculationInput { + market: String! + debt: Float! + collaterals: [LoanCalculationCollateralInput!]! +} + +input LiquidateCalculationInput { + txId: String! + amount: Float! +} + +type LiquidationCalculationCollateral { + id: String! + amount(input: InCurrencyInput): Float +} + +type LiquidateCalculation { + liquidationProfit(input: InCurrencyInput): Float + liquidationProfitPercent: Float + + """Collaterals seized in the liquidation with the current input""" + collaterals: [LiquidationCalculationCollateral!] + + """Health factor with the current input""" + healthFactor: Float! +} + +input SupplyCalculationInput { + amount: Float! + marketId: String! + wallet: SupportedWallet +} + +type SupplyCalculation { + """The batching fee in ADA""" + batchingFee: Float! + + """The maximum amount that can be supplied due to the supply cap""" + supplyCap: Float + + """ + The fee applied when using a certain wallet in the current market (currently, only ETERNL charges fees when using their Dapp directly) + """ + walletFee: Float! +} + +input WithdrawCalculationInput { + amount: Float! + marketId: String! + wallet: SupportedWallet +} + +type WithdrawCalculation { + """The batching fee in ADA""" + batchingFee: Float! + + """ + The fee applied when using a certain wallet in the current market (currently, only ETERNL charges fees when using their Dapp directly) + """ + walletFee: Float! + + """The maximum amount that can be withdrawn due to the borrow cap""" + withdrawCap: Float! +} + +type netApyCalculation { + """ + The net APY represents the effective annual yield after considering both supply and borrow rates + """ + netApy: Float! + + """The net APY including LQ rewards""" + netApyLqRewards: Float! + + """The borrow APY is the rate charged on borrowed assets.""" + borrowApy: Float! + + """The total amount of assets borrowed across all positions""" + totalBorrow: Float! + + """The supply APY is the rate earned on supplied assets.""" + supplyApy: Float! + + """The total amount of assets supplied""" + totalSupply: Float! +} + +input NetApySupplyInput { + marketId: String! + amount: Float! +} + +input NetApyInput { + filters: [NetAPYFilter!] + supplies: [NetApySupplyInput!]! + paymentKeys: [String!]! + currency: Currency +} + +type LiqwidCalculations { + supply(input: SupplyCalculationInput!): SupplyCalculation! + withdraw(input: WithdrawCalculationInput!): WithdrawCalculation! + loan(input: LoanCalculationInput!): LoanCalculation! + liquidate(input: LiquidateCalculationInput!): LiquidateCalculation! + + """Calculation of a user's net APY""" + netAPY(input: NetApyInput!): netApyCalculation! +} + +input InCurrencyInput { + currency: Currency +} + +type LiqwidData { + """ADA staking APY""" + proofOfStakeApy: Float! + asset(input: AssetInput!): Asset + assets(input: AssetsInput): AssetPagination! + market(input: MarketInput!): Market + markets(input: MarketsInput): MarketPagination! + loans(input: LoansInput): LoanPagination! + supply(input: InCurrencyInput): Float! + borrow(input: InCurrencyInput): Float! + liquidity(input: InCurrencyInput): Float! + interpolatedDeposits(input: InCurrencyInput): Float! +} + +input CustomOutput { + address: String! + inlineDatum: String +} + +input ModifyBorrowTransactionInputCollateral { + id: String! + tokenName: String + amount: Float! +} + +input ModifyBorrowTransactionInput { + txId: String! + address: String! + changeAddress: String + otherAddresses: [String!] + amount: Float! + collaterals: [ModifyBorrowTransactionInputCollateral!]! + redeemCollateral: Boolean + utxos: [String!]! + loanPrincipalAndCollateralDeltasDestination: CustomOutput +} + +type ModifyBorrowTransaction { + cbor: String! +} + +input MintPreviewTokensTransactionInput { + address: String! + changeAddress: String + otherAddresses: [String!] + utxos: [String!]! +} + +type MintPreviewTokensTransaction { + cbor: String! +} + +input LiquidationTransactionInput { + txId: String! + amount: Float! + address: String! + changeAddress: String + otherAddresses: [String!] + utxos: [String!]! +} + +enum SupportedWallet { + ETERNL + BEGIN +} + +input SupplyTransactionInput { + address: String! + changeAddress: String + otherAddresses: [String!] + amount: Float! + marketId: String! + utxos: [String!]! + wallet: SupportedWallet + mintedQTokensDestination: CustomOutput +} + +type SupplyTransaction { + cbor: String! +} + +input WithdrawTransactionInput { + address: String! + changeAddress: String + otherAddresses: [String!] + amount: Float! + marketId: String! + utxos: [String!]! + wallet: SupportedWallet + withdrawnUnderlyingDestination: CustomOutput +} + +type WithdrawTransaction { + cbor: String! +} + +input BorrowTransactionInputCollateral { + id: String! + tokenName: String + amount: Float! +} + +input BorrowTransactionInput { + marketId: String! + address: String! + changeAddress: String + otherAddresses: [String!] + amount: Float! + collaterals: [BorrowTransactionInputCollateral!]! + utxos: [String!]! + principalDestination: CustomOutput +} + +type BorrowTransaction { + cbor: String! +} + +type LiqwidTransactions { + liquidation(input: LiquidationTransactionInput!): Transaction! + supply(input: SupplyTransactionInput!): Transaction! + withdraw(input: WithdrawTransactionInput!): Transaction! + borrow(input: BorrowTransactionInput!): Transaction! + modifyBorrow(input: ModifyBorrowTransactionInput!): Transaction! + mintPreviewTokens(input: MintPreviewTokensTransactionInput!): Transaction! +} + +type MarketAnalyticsPeriod { + supply: Float! + supplyInUsd: Float! + borrow: Float! + borrowInUsd: Float! + supplyPercentage: Float! + borrowPercentage: Float! + utilizationPercentage: Float! + supplyApy: Float! + borrowApy: Float! +} + +type MarketAnalytics { + marketId: String! + displayName: String! + logo: String! + current: MarketAnalyticsPeriod! + previous: MarketAnalyticsPeriod! +} + +type AnalyticsOverviewPeriod { + fromDate: String! + toDate: String! + supplyInUsd: Float! + stablecoinSupplyInUsd: Float! + borrowInUsd: Float! + stablecoinBorrowInUsd: Float! + debtRepaidInUsd: Float! + interestRepaidInUsd: Float! + interestAccruedInUsd: Float! +} + +type AnalyticsOverview { + current: AnalyticsOverviewPeriod! + previous: AnalyticsOverviewPeriod! +} + +type ProtocolRevenuePeriod { + fromDate: String! + toDate: String! + loanOriginationFeesInUsd: Float! + loanOriginationFeesMinAdaInUsd: Float! + liquidationProfitInUsd: Float! + adaStakingRewards: Int! + adaStakingRewardsInUsd: Float! + revenueFromRepaidInterestInUsd: Float! + dividendsFromRepaidInterestInUsd: Float! + polLoanInterestAccruedInUsd: Float! +} + +type ProtocolRevenue { + current: ProtocolRevenuePeriod! + previous: ProtocolRevenuePeriod! +} + +type LoanCollateral { + id: String! + tokenName: String + qTokenName: String + market: Market + asset: Asset! + + """The amount of the collateral in the loan, by default in USD""" + amount(input: InCurrencyInput): Float! + amountUSD: Float! + qTokenAmount: Float! + LTV: Float! + healthFactor: Float! +} + +type Loan { + id: String! + transactionId: String! + transactionIndex: Int! + marketId: String! + publicKey: String! + market: Market! + asset: Asset! + amount(input: InCurrencyInput): Float! + + """ + Adjusted debt amount by removing the initial minimum interest set by the protocol + """ + adjustedAmount(input: InCurrencyInput): Float! + + """The amount of the collateral in the loan, by default in USD""" + collateral(input: InCurrencyInput): Float! + interest: Float! + APY: Float! + LTV: Float! + healthFactor: Float! + collaterals: [LoanCollateral!]! + time: Float! +} + +enum LoanSort { + MARKET_ID + MARKET_ID_DESC + DEBT + DEBT_DESC + COLLATERAL_IN_CURRENCY + COLLATERAL_IN_CURRENCY_DESC + HEALTH_FACTOR + HEALTH_FACTOR_DESC + APY + APY_DESC +} + +enum LoanFilter { + STABLECOIN + CNT + BRIDGED + PRIME + HAS_DEBT + NO_DEBT + HAS_COLLATERAL + NO_COLLATERAL + CAN_BE_LIQUIDATED +} + +input LoansInput { + page: Int = 0 + perPage: Int = 50 + paymentKeys: [String!] + sorts: [LoanSort!] + filters: [LoanFilter!] + marketIds: [String!] + search: String +} + +type LoanPagination { + page: Int! + perPage: Int! + pagesCount: Int! + totalCount: Int! + results: [Loan!]! +} + +enum MarketSort { + ID + ID_DESC + SUPPLY + SUPPLY_DESC + SUPPLY_IN_CURRENCY + SUPPLY_IN_CURRENCY_DESC + BORROW + BORROW_DESC + BORROW_IN_CURRENCY + BORROW_IN_CURRENCY_DESC + LIQUIDITY + LIQUIDITY_DESC + LIQUIDITY_IN_CURRENCY + LIQUIDITY_IN_CURRENCY_DESC + SUPPLY_APY + SUPPLY_APY_DESC + BORROW_APY + BORROW_APY_DESC +} + +enum MarketFilter { + STABLECOIN + CNT + BRIDGED + PRIME + PUBLIC + PRIVATE +} + +type MarketCollateralParameters { + id: String! + collateral: Collateral! + maxLoanToValue: Float! + weightedMaxLoanToValue: Float! + liquidationThreshold: Float! + weightedLiquidationThreshold: Float! + liquidationPenalty: Float + liquidationProfitability: Float + collateralWeight: Float +} + +type MarketInterestModelParameters { + baseRate: Float + kinkRate: Float + utilMultiplier: Float + utilMultiplierJump: Float +} + +type MarketIncomeParameters { + """Protocol""" + reserve: Float + supplier: Float + staker: Float + + """DAO""" + treasury: Float +} + +type MarketParameters { + id: String! + collateralParameters: [MarketCollateralParameters] + + """The income parameters in percentage""" + incomeParameters: MarketIncomeParameters! + interestModelParameters: MarketInterestModelParameters! + + """The maximum percentage of the market value that can be borrowed""" + borrowCap: Float + + """The maximum number of tokens that can be supplied to the market""" + supplyCap: Float + + """The minimum amount required for each action""" + minValue: Float! + + """The minimum health factor allowed for the market""" + minHealthFactor: Float! + + """The number of actions that can be performed simultaneously""" + actionCount: Int! + + """The maximum number of collateral that can be used for a single loan""" + maxCollateralCount: Int! + maxBatchTime: Float! + minBatchSize: Float! + minBatchTime: Float! +} + +type UtilizationApy { + supplyMap: [Float!]! + borrowMap: [Float!]! + supplyApy: Float! + borrowApy: Float! +} + +type MarketRegistry { + actionScriptHash: String! +} + +type Market { + id: String! + displayName: String! + symbol: String! + + """The asset eligible for use as collateral in the market""" + collaterals: [Collateral!]! + + """The asset used as collateral in the market""" + collateralInMarkets: [Market!]! + asset: Asset! + receiptAsset: Asset! + + """The total amount of supply in the market""" + supply(input: InCurrencyInput): Float! + + """The total amount of borrow in the market""" + borrow(input: InCurrencyInput): Float! + + """The total liquidity in the market""" + liquidity(input: InCurrencyInput): Float! + supplyAPY: Float! + borrowAPY: Float! + lqSupplyAPY: Float! + + """Market utilization in percentage""" + utilization: Float! + + """Exchange rate between the qToken and the asset""" + exchangeRate: Float! + + """The market parameters""" + parameters: MarketParameters! + + """Boolean indicating if the market is batching""" + batching: Boolean! + batchExpired: Boolean! + + """Boolean indiciating if the market is paused due to an epoch transition""" + batchEpochPause: Boolean! + + """The timestamp of the most recent batch""" + lastBatch: String! + frozen: Boolean! + + """ + If this field is true, the market is private and cannot be accessed by users + """ + private: Boolean! + delisting: Boolean! + + """ + Loan origination fee percentage charged when taking out a loan from this market (decimilal representation, e.g., 1% = 0.01) + """ + loanOriginationFeePercentage: Float! + + """The market is considered Prime when it's a direct lending""" + prime: Boolean! + + """ + Array of supply/borrow APY based on utilization rate (0-100%), incremented 1% at a time + Useful for plotting the APY curve + """ + utilizationApy: UtilizationApy! + registry: MarketRegistry! + + """The timestamp of the last update update""" + updatedAt: String! +} + +type MarketPagination { + page: Int! + perPage: Int! + pagesCount: Int! + totalCount: Int! + results: [Market!]! +} + +input MarketInput { + id: String! +} + +input MarketsInput { + ids: [String!] + page: Int = 0 + perPage: Int = 20 + sorts: [MarketSort!] + filters: [MarketFilter!] + search: String +} + +type Collateral { + id: String! + displayName: String! + symbol: String! + market: Market + asset: Asset! + name: String! + currencySymbol: String +} + +enum AssetSort { + ID + ID_DESC +} + +enum AssetFilter { + STABLECOIN + CNT + BRIDGED +} + +input AssetInput { + id: String! +} + +input AssetsInput { + ids: [String] + page: Int = 0 + perPage: Int = 20 + sorts: [AssetSort!] + filters: [AssetFilter!] + search: String +} + +"""Information about the asset""" +type AssetExtra { + bridge: Bridge + maxSupply: Float + totalSupply: Float + circulatingSupply: Float + discord: String + twitter: String + facebook: String + website: String + whitepaper: String + github: String + reddit: String + medium: String + telegram: String + coingecko: String + explorer: String +} + +type Bridge { + name: String! + url: String! +} + +type Hardcap { + low: Float + high: Float +} + +type Asset { + id: String! + name: String! + symbol: String! + displayName: String! + decimals: Int! + + """The PolicyId of the asset""" + currencySymbol: String! + policyId: String! + hexName: String! + + """Path to access the asset logo in the API""" + logo: String + price(input: InCurrencyInput): Float! + priceUpdatedAt: String! + + """Price boundaries beyond which the asset's oracle price cannot move""" + hardcap: Hardcap + markets: [Market!]! + extra: AssetExtra +} + +type AssetPagination { + page: Int! + perPage: Int! + pagesCount: Int! + totalCount: Int! + results: [Asset!]! +} From a28e3e1da20b5ff655c03c3019f58eaf5e4055f4 Mon Sep 17 00:00:00 2001 From: tony Date: Thu, 5 Feb 2026 10:50:39 +0700 Subject: [PATCH 12/35] remove console.log --- apps/long-short-backend/test/sign-data.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/long-short-backend/test/sign-data.test.ts b/apps/long-short-backend/test/sign-data.test.ts index d07d8c1..958614e 100644 --- a/apps/long-short-backend/test/sign-data.test.ts +++ b/apps/long-short-backend/test/sign-data.test.ts @@ -100,11 +100,6 @@ describe("verifySignData", () => { wallet.address.bech32, message, ); - console.log({ - user_address: wallet.address.bech32, - signature, - key, - }); // Verify const isValid = verifySignData({ message, @@ -272,6 +267,5 @@ describe("verifySignData", () => { const ECSL = RustModule.getE; const ea = ECSL.Address.from_bech32(addr.bech32); const a1 = ea.to_hex(); - console.log(a1); }); }); From 2148da281a8e16e46ae18ebf002d0cff5506a337 Mon Sep 17 00:00:00 2001 From: tony Date: Thu, 5 Feb 2026 11:12:01 +0700 Subject: [PATCH 13/35] liqwid submit --- .../src/api/routes/liqwid.ts | 94 ++++++ apps/long-short-backend/src/api/schemas.ts | 24 ++ apps/long-short-backend/src/api/server.ts | 2 + .../src/api/state-machine.ts | 38 ++- apps/long-short-backend/src/constants.ts | 1 + .../src/repository/order-repository.ts | 2 +- .../src/services/position-service.ts | 2 +- .../minswap-lending-market/src/schema.graphql | 276 +++++++++++++----- 8 files changed, 347 insertions(+), 92 deletions(-) create mode 100644 apps/long-short-backend/src/api/routes/liqwid.ts diff --git a/apps/long-short-backend/src/api/routes/liqwid.ts b/apps/long-short-backend/src/api/routes/liqwid.ts new file mode 100644 index 0000000..62ae379 --- /dev/null +++ b/apps/long-short-backend/src/api/routes/liqwid.ts @@ -0,0 +1,94 @@ +import type { NetworkEnvironment } from "@minswap/felis-ledger-core"; +import { LiqwidProvider } from "@minswap/felis-lending-market"; +import type { FastifyInstance } from "fastify"; +import { API_ENDPOINTS } from "../../constants"; +import { logger } from "../../utils"; +import { ApiHelper } from "../helper"; +import { + type AuthenLiqwidSubmitBodyType, + AuthenLiqwidSubmitBodyTypeSchema, + ErrorResponseSchema, + LiqwidSubmitResponseSchema, + type LiqwidSubmitResponseType, +} from "../schemas"; + +export function registerLiqwidRoutes(fastify: FastifyInstance, networkEnv: NetworkEnvironment): void { + // POST /liqwid/submit + fastify.post<{ + Body: AuthenLiqwidSubmitBodyType; + Reply: LiqwidSubmitResponseType; + }>( + API_ENDPOINTS.LIQWID_SUBMIT, + { + schema: { + body: AuthenLiqwidSubmitBodyTypeSchema, + response: { + 200: LiqwidSubmitResponseSchema, + 400: ErrorResponseSchema, + 401: ErrorResponseSchema, + }, + }, + }, + async (request, reply) => { + const { data, user_address, witness } = request.body; + const { raw_tx, witness_set } = data; + + // Authenticate request + const authResult = ApiHelper.authenticate(data, user_address, witness); + if (!authResult.success) { + return reply.status(401).send({ + success: false, + error: authResult.error, + }); + } + + logger.info("Submitting Liqwid transaction", { + userAddress: user_address, + rawTxLength: raw_tx.length, + witnessSetLength: witness_set.length, + }); + + try { + // Submit transaction to Liqwid + const submitResult = await LiqwidProvider.submitTransaction({ + transaction: raw_tx, + signature: witness_set, + networkEnv, + }); + + if (submitResult.type === "err") { + logger.error("Failed to submit Liqwid transaction", { + error: submitResult.error.message, + userAddress: user_address, + }); + return reply.status(400).send({ + success: false, + error: submitResult.error.message, + }); + } + + const txHash = submitResult.value; + logger.info("Liqwid transaction submitted successfully", { + txHash, + userAddress: user_address, + }); + + return reply.status(200).send({ + success: true, + data: { + tx_hash: txHash, + }, + }); + } catch (error) { + logger.error("Exception submitting Liqwid transaction", { + error, + userAddress: user_address, + }); + return reply.status(400).send({ + success: false, + error: error instanceof Error ? error.message : "Failed to submit transaction", + }); + } + }, + ); +} diff --git a/apps/long-short-backend/src/api/schemas.ts b/apps/long-short-backend/src/api/schemas.ts index 6c39185..7fe8eb9 100644 --- a/apps/long-short-backend/src/api/schemas.ts +++ b/apps/long-short-backend/src/api/schemas.ts @@ -109,6 +109,30 @@ export const ErrorResponseSchema = Type.Object({ export type ErrorResponseType = Static; +// Liqwid submit schemas +export const LiqwidSubmitDataSchema = Type.Object({ + raw_tx: Type.String({ minLength: 1, description: "Raw transaction CBOR hex" }), + witness_set: Type.String({ minLength: 1, description: "Witness set CBOR hex" }), +}); + +export type LiqwidSubmitDataType = Static; + +export const AuthenLiqwidSubmitBodyTypeSchema = AuthenCommonSchema(LiqwidSubmitDataSchema); + +export type AuthenLiqwidSubmitBodyType = AuthenCommonType; + +export const LiqwidSubmitResponseSchema = Type.Object({ + success: Type.Boolean(), + data: Type.Optional( + Type.Object({ + tx_hash: Type.String({ description: "Submitted transaction hash" }), + }), + ), + error: Type.Optional(Type.String()), +}); + +export type LiqwidSubmitResponseType = Static; + // Market config schemas export const MarketConfigResponseSchema = Type.Object({ market_id: Type.String({ description: "Market identifier (e.g., ADA-MIN)" }), diff --git a/apps/long-short-backend/src/api/server.ts b/apps/long-short-backend/src/api/server.ts index 61b013b..3b3de03 100644 --- a/apps/long-short-backend/src/api/server.ts +++ b/apps/long-short-backend/src/api/server.ts @@ -7,6 +7,7 @@ import type { DB } from "../database"; import type { CardanoscanProvider } from "../provider"; import { PositionService } from "../services/position-service"; import { logger } from "../utils"; +import { registerLiqwidRoutes } from "./routes/liqwid"; import { registerMetadataRoutes } from "./routes/metadata"; import { registerPositionRoutes } from "./routes/position"; @@ -42,6 +43,7 @@ export async function createApiServer(options: ApiServerOptions): Promise { + export const handleLongBuy = async (options: HandleLongBuyOptions): Promise => { const { order, marketConfig, userAddress, networkEnv, utxos } = options; invariant(order.order_type === LongOrderType.LONG_BUY, "Invalid order type for handleLongBuy"); invariant(order.asset_in, "asset_in is required for LONG_BUY order"); @@ -100,7 +107,6 @@ export namespace StateMachine { return { txRaw, txId: txId, - orderOutputIndex: 0, outputsHash, validTo, }; @@ -118,7 +124,7 @@ export namespace StateMachine { utxos: string[]; }; - export const handleLongSupply = async (options: HandleLongSupplyOptions) => { + export const handleLongSupply = async (options: HandleLongSupplyOptions): Promise => { const { order, userAddress, networkEnv, utxos } = options; invariant(order.order_type === LongOrderType.LONG_SUPPLY, "Invalid order type for handleLongSupply"); invariant(order.asset_in, "asset_in is required for LONG_SUPPLY order"); @@ -143,30 +149,20 @@ export namespace StateMachine { } const txRaw = buildTxResult.value; - - // Calculate transaction ID from transaction body hash const ECSL = RustModule.getE; const eTx = ECSL.Transaction.from_hex(txRaw); const txBody = eTx.body(); - const txBodyBytes = txBody.to_bytes(); - const txBodyHashBytes = blake2b256(Buffer.from(txBodyBytes)); - const txBodyHash = ECSL.TransactionHash.from_bytes(Buffer.from(txBodyHashBytes)); - const txId = txBodyHash.to_hex(); - - // Calculate outputs hash for transaction chaining - const outputsHash = HashUtils.sha256(utxos.join(",")); + const ttl = txBody.ttl(); + invariant(Maybe.isJust(ttl), "TTL must be set in the transaction body"); + safeFreeRustObjects(eTx, txBody); - // Set valid_to to 3 minutes from now - const validTo = Date.now() + Duration.newMinutes(3).milliseconds; - - safeFreeRustObjects(eTx, txBody, txBodyHash); + const validTo = getTimeFromSlotMagic(networkEnv, ttl); + const txId = LiqwidProvider.getLiqwidTxHash(txRaw); return { txRaw, txId, - orderOutputIndex: 0, - outputsHash, - validTo, + validTo: validTo.getTime(), }; }; diff --git a/apps/long-short-backend/src/constants.ts b/apps/long-short-backend/src/constants.ts index 6ce4e17..1eeb875 100644 --- a/apps/long-short-backend/src/constants.ts +++ b/apps/long-short-backend/src/constants.ts @@ -1,6 +1,7 @@ // MUST sort endpoints alphabetically export const API_ENDPOINTS = { HEALTH: "/health", + LIQWID_SUBMIT: "/liqwid/submit", METADATA: "/metadata", POSITION_BUILD_TX: "/position/build-tx", POSITION_CREATE: "/position/create", diff --git a/apps/long-short-backend/src/repository/order-repository.ts b/apps/long-short-backend/src/repository/order-repository.ts index 7deaa34..2d2a973 100644 --- a/apps/long-short-backend/src/repository/order-repository.ts +++ b/apps/long-short-backend/src/repository/order-repository.ts @@ -104,7 +104,7 @@ export namespace OrderRepository { db: Kysely | Transaction, orderId: bigint, builtTxId: string, - builtOutputsHash: string, + builtOutputsHash: string | null | undefined, builtValidTo: Date, ): Promise { await db diff --git a/apps/long-short-backend/src/services/position-service.ts b/apps/long-short-backend/src/services/position-service.ts index 223664f..8df4e63 100644 --- a/apps/long-short-backend/src/services/position-service.ts +++ b/apps/long-short-backend/src/services/position-service.ts @@ -332,7 +332,7 @@ export class PositionService { .executeTakeFirstOrThrow(); // Build transaction based on order type - let txResult: { txRaw: string; txId: string; outputsHash: string; validTo: number }; + let txResult: StateMachine.BuiltResult; switch (order.orderType) { case StateMachine.LongOrderType.LONG_BUY: diff --git a/packages/minswap-lending-market/src/schema.graphql b/packages/minswap-lending-market/src/schema.graphql index 900f4cb..331217e 100644 --- a/packages/minswap-lending-market/src/schema.graphql +++ b/packages/minswap-lending-market/src/schema.graphql @@ -1,7 +1,9 @@ directive @priceConversion on FIELD_DEFINITION directive @cost( - """Assumes the cost of the annotated component""" + """ + Assumes the cost of the annotated component + """ weight: Int! ) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR @@ -14,7 +16,9 @@ input AquafarmersInput { assets: [String!]! } -"""Metadata for the aquafarmer""" +""" +Metadata for the aquafarmer +""" type AquafarmerOnchainMetadata { hat: String tier: String @@ -235,7 +239,9 @@ type Query { agora: AgoraQueries! meta: MetaQueries! - """Get the exchange rate of a currency""" + """ + Get the exchange rate of a currency + """ currencyExchangeRate(input: CurrencyExchangeRateInput!): CurrencyExchangeRate! liqwid: LiqwidQueries! lq: LQQueries! @@ -254,37 +260,59 @@ type MetaQueries { } enum Currency { - """Euro €""" + """ + Euro € + """ EUR - """US Dollar $""" + """ + US Dollar $ + """ USD - """British Pound £""" + """ + British Pound £ + """ GBP - """Canadian Dollar C$""" + """ + Canadian Dollar C$ + """ CAD - """Brazilian Real R$""" + """ + Brazilian Real R$ + """ BRL - """Japanese Yen ¥""" + """ + Japanese Yen ¥ + """ JPY - """Vietnamese Dong ₫""" + """ + Vietnamese Dong ₫ + """ VND - """Czech Koruna Kč""" + """ + Czech Koruna Kč + """ CZK - """Australian Dollar A$""" + """ + Australian Dollar A$ + """ AUD - """Singapore Dollar S$""" + """ + Singapore Dollar S$ + """ SGD - """Swiss Franc CHF""" + """ + Swiss Franc CHF + """ CHF } @@ -311,7 +339,9 @@ type LQQueries { circulatingSupply: Float! treasury: Float! - """Amount of LQ staked""" + """ + Amount of LQ staked + """ staked: Float! price(input: InCurrencyInput): Float! currencySymbol: String! @@ -327,7 +357,9 @@ input SubmitTransactionInput { } type Mutation { - """Submit a CBOR transaction""" + """ + Submit a CBOR transaction + """ submitTransaction(input: SubmitTransactionInput!): String! } @@ -356,16 +388,22 @@ enum TransactionType { } type CustomFee { - """Wallet fee name""" + """ + Wallet fee name + """ wallet: SupportedWallet! displayName: String! - """Amount taken in the asset being supplied or withdrawn""" + """ + Amount taken in the asset being supplied or withdrawn + """ amount: Float! } type HistoricalTransaction { - """txHash""" + """ + txHash + """ id: String! displayName: String! logo: String! @@ -374,12 +412,16 @@ type HistoricalTransaction { oraclePrice: Float! customFee: CustomFee - """token amount for supply/withdraw""" + """ + token amount for supply/withdraw + """ qAmount: Float amount: Float amountUSD: Float - """qToken exchangeRate for supply/withdraw""" + """ + qToken exchangeRate for supply/withdraw + """ exchangeRate: Float principal: Float principalUSD: Float @@ -387,7 +429,9 @@ type HistoricalTransaction { healthFactor: Float loanOriginationFee: Float - """Modification loan (borrow more/modify/repay)""" + """ + Modification loan (borrow more/modify/repay) + """ beforeHealthFactor: Float beforePrincipal: Float beforePrincipalUSD: Float @@ -397,20 +441,26 @@ type HistoricalTransaction { } type HistoryLoanCollateral { - """txHash + unit""" + """ + txHash + unit + """ id: String! displayName: String! logo: String! oraclePrice: Float! - """qToken exchangeRate""" + """ + qToken exchangeRate + """ exchangeRate: Float! qAmount: Float! amount: Float! amountUSD: Float! healthFactor: Float! - """Modification loan (borrow more/modify/repay)""" + """ + Modification loan (borrow more/modify/repay) + """ beforeHealthFactor: Float beforeQAmount: Float beforeAmount: Float @@ -573,15 +623,21 @@ type LoanCalculation { collateral(input: InCurrencyInput): Float! collaterals: [LoanCollateral!]! - """The maximum amount that can be borrowed with the current input""" + """ + The maximum amount that can be borrowed with the current input + """ maxBorrow: Float! - """The maximum amount that can be borrowed due to the borrow cap""" + """ + The maximum amount that can be borrowed due to the borrow cap + """ maxBorrowCap: Float protocolFee: Float! protocolFeePercentage: Float! - """The batching fee in ADA""" + """ + The batching fee in ADA + """ batchingFee: Float! } @@ -610,10 +666,14 @@ type LiquidateCalculation { liquidationProfit(input: InCurrencyInput): Float liquidationProfitPercent: Float - """Collaterals seized in the liquidation with the current input""" + """ + Collaterals seized in the liquidation with the current input + """ collaterals: [LiquidationCalculationCollateral!] - """Health factor with the current input""" + """ + Health factor with the current input + """ healthFactor: Float! } @@ -624,10 +684,14 @@ input SupplyCalculationInput { } type SupplyCalculation { - """The batching fee in ADA""" + """ + The batching fee in ADA + """ batchingFee: Float! - """The maximum amount that can be supplied due to the supply cap""" + """ + The maximum amount that can be supplied due to the supply cap + """ supplyCap: Float """ @@ -643,7 +707,9 @@ input WithdrawCalculationInput { } type WithdrawCalculation { - """The batching fee in ADA""" + """ + The batching fee in ADA + """ batchingFee: Float! """ @@ -651,7 +717,9 @@ type WithdrawCalculation { """ walletFee: Float! - """The maximum amount that can be withdrawn due to the borrow cap""" + """ + The maximum amount that can be withdrawn due to the borrow cap + """ withdrawCap: Float! } @@ -661,19 +729,29 @@ type netApyCalculation { """ netApy: Float! - """The net APY including LQ rewards""" + """ + The net APY including LQ rewards + """ netApyLqRewards: Float! - """The borrow APY is the rate charged on borrowed assets.""" + """ + The borrow APY is the rate charged on borrowed assets. + """ borrowApy: Float! - """The total amount of assets borrowed across all positions""" + """ + The total amount of assets borrowed across all positions + """ totalBorrow: Float! - """The supply APY is the rate earned on supplied assets.""" + """ + The supply APY is the rate earned on supplied assets. + """ supplyApy: Float! - """The total amount of assets supplied""" + """ + The total amount of assets supplied + """ totalSupply: Float! } @@ -695,7 +773,9 @@ type LiqwidCalculations { loan(input: LoanCalculationInput!): LoanCalculation! liquidate(input: LiquidateCalculationInput!): LiquidateCalculation! - """Calculation of a user's net APY""" + """ + Calculation of a user's net APY + """ netAPY(input: NetApyInput!): netApyCalculation! } @@ -704,7 +784,9 @@ input InCurrencyInput { } type LiqwidData { - """ADA staking APY""" + """ + ADA staking APY + """ proofOfStakeApy: Float! asset(input: AssetInput!): Asset assets(input: AssetsInput): AssetPagination! @@ -891,7 +973,9 @@ type LoanCollateral { market: Market asset: Asset! - """The amount of the collateral in the loan, by default in USD""" + """ + The amount of the collateral in the loan, by default in USD + """ amount(input: InCurrencyInput): Float! amountUSD: Float! qTokenAmount: Float! @@ -914,7 +998,9 @@ type Loan { """ adjustedAmount(input: InCurrencyInput): Float! - """The amount of the collateral in the loan, by default in USD""" + """ + The amount of the collateral in the loan, by default in USD + """ collateral(input: InCurrencyInput): Float! interest: Float! APY: Float! @@ -1017,12 +1103,16 @@ type MarketInterestModelParameters { } type MarketIncomeParameters { - """Protocol""" + """ + Protocol + """ reserve: Float supplier: Float staker: Float - """DAO""" + """ + DAO + """ treasury: Float } @@ -1030,26 +1120,40 @@ type MarketParameters { id: String! collateralParameters: [MarketCollateralParameters] - """The income parameters in percentage""" + """ + The income parameters in percentage + """ incomeParameters: MarketIncomeParameters! interestModelParameters: MarketInterestModelParameters! - """The maximum percentage of the market value that can be borrowed""" + """ + The maximum percentage of the market value that can be borrowed + """ borrowCap: Float - """The maximum number of tokens that can be supplied to the market""" + """ + The maximum number of tokens that can be supplied to the market + """ supplyCap: Float - """The minimum amount required for each action""" + """ + The minimum amount required for each action + """ minValue: Float! - """The minimum health factor allowed for the market""" + """ + The minimum health factor allowed for the market + """ minHealthFactor: Float! - """The number of actions that can be performed simultaneously""" + """ + The number of actions that can be performed simultaneously + """ actionCount: Int! - """The maximum number of collateral that can be used for a single loan""" + """ + The maximum number of collateral that can be used for a single loan + """ maxCollateralCount: Int! maxBatchTime: Float! minBatchSize: Float! @@ -1072,43 +1176,65 @@ type Market { displayName: String! symbol: String! - """The asset eligible for use as collateral in the market""" + """ + The asset eligible for use as collateral in the market + """ collaterals: [Collateral!]! - """The asset used as collateral in the market""" + """ + The asset used as collateral in the market + """ collateralInMarkets: [Market!]! asset: Asset! receiptAsset: Asset! - """The total amount of supply in the market""" + """ + The total amount of supply in the market + """ supply(input: InCurrencyInput): Float! - """The total amount of borrow in the market""" + """ + The total amount of borrow in the market + """ borrow(input: InCurrencyInput): Float! - """The total liquidity in the market""" + """ + The total liquidity in the market + """ liquidity(input: InCurrencyInput): Float! supplyAPY: Float! borrowAPY: Float! lqSupplyAPY: Float! - """Market utilization in percentage""" + """ + Market utilization in percentage + """ utilization: Float! - """Exchange rate between the qToken and the asset""" + """ + Exchange rate between the qToken and the asset + """ exchangeRate: Float! - """The market parameters""" + """ + The market parameters + """ parameters: MarketParameters! - """Boolean indicating if the market is batching""" + """ + Boolean indicating if the market is batching + """ batching: Boolean! batchExpired: Boolean! - """Boolean indiciating if the market is paused due to an epoch transition""" + """ + Boolean indiciating if the market is paused due to an epoch transition + """ batchEpochPause: Boolean! - """The timestamp of the most recent batch""" + """ + The timestamp of the most recent batch + """ lastBatch: String! frozen: Boolean! @@ -1123,7 +1249,9 @@ type Market { """ loanOriginationFeePercentage: Float! - """The market is considered Prime when it's a direct lending""" + """ + The market is considered Prime when it's a direct lending + """ prime: Boolean! """ @@ -1133,7 +1261,9 @@ type Market { utilizationApy: UtilizationApy! registry: MarketRegistry! - """The timestamp of the last update update""" + """ + The timestamp of the last update update + """ updatedAt: String! } @@ -1192,7 +1322,9 @@ input AssetsInput { search: String } -"""Information about the asset""" +""" +Information about the asset +""" type AssetExtra { bridge: Bridge maxSupply: Float @@ -1228,17 +1360,23 @@ type Asset { displayName: String! decimals: Int! - """The PolicyId of the asset""" + """ + The PolicyId of the asset + """ currencySymbol: String! policyId: String! hexName: String! - """Path to access the asset logo in the API""" + """ + Path to access the asset logo in the API + """ logo: String price(input: InCurrencyInput): Float! priceUpdatedAt: String! - """Price boundaries beyond which the asset's oracle price cannot move""" + """ + Price boundaries beyond which the asset's oracle price cannot move + """ hardcap: Hardcap markets: [Market!]! extra: AssetExtra From 768b9f786d6f73ccf2f4cbd0b249633742880cec Mon Sep 17 00:00:00 2001 From: tony Date: Thu, 5 Feb 2026 11:46:33 +0700 Subject: [PATCH 14/35] final long supply --- .../src/api/routes/position.ts | 3 +- apps/long-short-backend/src/api/schemas.ts | 1 + .../src/api/state-machine.ts | 73 +++++++++++++++++++ .../src/services/position-service.ts | 69 +++++++++++++++++- 4 files changed, 143 insertions(+), 3 deletions(-) diff --git a/apps/long-short-backend/src/api/routes/position.ts b/apps/long-short-backend/src/api/routes/position.ts index 2e455a9..b6317cf 100644 --- a/apps/long-short-backend/src/api/routes/position.ts +++ b/apps/long-short-backend/src/api/routes/position.ts @@ -170,11 +170,12 @@ export function registerPositionRoutes(fastify: FastifyInstance, positionService } // Return newly built transaction - invariant("txRaw" in result && result.txRaw, "type-safe"); + invariant("txRaw" in result && result.txRaw && "txId" in result && result.txId, "type-safe"); return reply.status(200).send({ success: true, data: { tx_raw: result.txRaw, + tx_id: result.txId, order_type: result.orderType, }, }); diff --git a/apps/long-short-backend/src/api/schemas.ts b/apps/long-short-backend/src/api/schemas.ts index 7fe8eb9..1a8a1d2 100644 --- a/apps/long-short-backend/src/api/schemas.ts +++ b/apps/long-short-backend/src/api/schemas.ts @@ -91,6 +91,7 @@ export const BuildTxResponseSchema = Type.Object({ data: Type.Optional( Type.Object({ tx_raw: Type.Optional(Type.String({ description: "Unsigned transaction CBOR hex" })), + tx_id: Type.Optional(Type.String({ description: "Transaction ID (hash)" })), order_type: Type.String({ description: "Order type being processed" }), waiting: Type.Optional(Type.Boolean({ description: "True if transaction is waiting for confirmation" })), message: Type.Optional(Type.String({ description: "Status message when waiting" })), diff --git a/apps/long-short-backend/src/api/state-machine.ts b/apps/long-short-backend/src/api/state-machine.ts index 34dec52..683df2a 100644 --- a/apps/long-short-backend/src/api/state-machine.ts +++ b/apps/long-short-backend/src/api/state-machine.ts @@ -246,4 +246,77 @@ export namespace StateMachine { isSpent: false, }; }; + + export type WaitingLongSupplyOptions = { + marketConfig: MarketConfig; + txHash: string; + userAddress: Address; + cardanoscanProvider: CardanoscanProvider; + }; + + export type WaitingLongSupplyResult = + | { isConfirmed: false } + | { + isConfirmed: true; + nextOrderType: LongOrderType; + assetIn: string; + amountIn: string; + assetOut: string; + }; + + /** + * Wait for LONG_SUPPLY transaction to be confirmed and prepare the next LONG_BORROW order + * @param options - Options containing transaction details and cardanoscan provider + * @returns Result with next order details if confirmed, or isConfirmed: false if not yet confirmed + */ + export const waitingLongSupply = async (options: WaitingLongSupplyOptions): Promise => { + const { marketConfig, txHash, userAddress, cardanoscanProvider } = options; + + // Search for the transaction to confirm it's on chain + const txFoundOnChain = await cardanoscanProvider.findTransactionByHash( + userAddress, + txHash, + 50, // pageSize + 10, // maxPage + ); + + if (txFoundOnChain) { + // Transaction is confirmed on chain + // Find the output that belongs to the user and contains the qToken (asset_b_q_token_raw) + const userAddressHex = userAddress.toHex(); + const qTokenAsset = Asset.fromString(marketConfig.assetBQTokenRaw); + const qTokenUnit = qTokenAsset.toBlockFrostString(); + + // Search through the transaction outputs + for (const output of txFoundOnChain.outputs) { + if (output.address === userAddressHex) { + if (output.tokens && output.tokens.length > 0) { + const matchingToken = output.tokens.find((token) => token.assetId === qTokenUnit); + if (matchingToken) { + // Found the output with the qToken + // Prepare next order: LONG_BORROW + const amountReceived = BigInt(matchingToken.value); + return { + isConfirmed: true, + nextOrderType: LongOrderType.LONG_BORROW, + assetIn: marketConfig.assetBQTokenRaw, // qToken (qMIN) becomes input for borrow + amountIn: amountReceived.toString(), + assetOut: marketConfig.assetA.toString(), // Borrow asset A (ADA) + }; + } + } + } + } + + // Transaction found but couldn't find matching qToken output + throw new Error( + `LONG_SUPPLY tx confirmed (${txHash}) but could not find output with qToken ${marketConfig.assetBQTokenRaw}`, + ); + } + + // Transaction not yet confirmed + return { + isConfirmed: false, + }; + }; } diff --git a/apps/long-short-backend/src/services/position-service.ts b/apps/long-short-backend/src/services/position-service.ts index 8df4e63..6956349 100644 --- a/apps/long-short-backend/src/services/position-service.ts +++ b/apps/long-short-backend/src/services/position-service.ts @@ -25,7 +25,7 @@ export type BuildTxInput = { }; export type BuildTxResult = - | { success: true; txRaw: string; orderType: string } + | { success: true; txRaw: string; txId: string; orderType: string } | { success: true; waiting: true; orderType: string; message: string } | { success: false; error: string }; @@ -215,6 +215,71 @@ export class PositionService { error: "Transaction confirmed on chain. Waiting for order to be processed.", }; } + } else if (waitingOrder.orderType === StateMachine.LongOrderType.LONG_SUPPLY) { + // LONG_SUPPLY is simpler - just wait for tx confirmation (already confirmed since we're here) + // Then check for qToken output and prepare LONG_BORROW + const waitingResult = await StateMachine.waitingLongSupply({ + marketConfig, + txHash: waitingOrder.createdTxId, + userAddress: address, + cardanoscanProvider: this.cardanoscanProvider, + }); + + if (waitingResult.isConfirmed) { + // Transaction is confirmed - update next order + logger.info("LONG_SUPPLY confirmed, updating next LONG_BORROW order", { + orderId: waitingOrder.id, + nextOrderType: waitingResult.nextOrderType, + }); + + // Find the LONG_BORROW order + const nextOrder = await this.db + .selectFrom("order") + .selectAll() + .where("position_id", "=", waitingOrder.positionId.toString()) + .where("order_type", "=", waitingResult.nextOrderType) + .executeTakeFirst(); + + if (!nextOrder) { + logger.error("Next order not found", { + positionId: waitingOrder.positionId, + nextOrderType: waitingResult.nextOrderType, + }); + return { + success: false, + error: `Next order with type "${waitingResult.nextOrderType}" not found`, + }; + } + + // Update the next order with details and set current order waiting = false + await OrderRepository.updateOrderNextDetails( + this.db, + BigInt(nextOrder.id), + waitingResult.assetIn, + waitingResult.amountIn, + waitingResult.assetOut, + ); + await OrderRepository.setOrderWaiting(this.db, waitingOrder.id, false); + + logger.info("LONG_BORROW order updated, LONG_SUPPLY no longer waiting", { + currentOrderId: waitingOrder.id, + nextOrderId: nextOrder.id, + assetIn: waitingResult.assetIn, + amountIn: waitingResult.amountIn, + assetOut: waitingResult.assetOut, + }); + + return { + success: false, + error: "LONG_SUPPLY completed. LONG_BORROW order ready. Call build-tx again to continue.", + }; + } else { + // Transaction not confirmed yet (shouldn't happen since we check waiting=true) + return { + success: false, + error: "LONG_SUPPLY transaction not yet confirmed on chain.", + }; + } } else { // Other order types not implemented yet return { @@ -371,7 +436,7 @@ export class PositionService { validTo: new Date(txResult.validTo).toISOString(), }); - return { success: true, txRaw: txResult.txRaw, orderType: order.orderType }; + return { success: true, txRaw: txResult.txRaw, txId: txResult.txId, orderType: order.orderType }; } catch (error) { logger.error("error building tx", error); return { From 1fd27ceaad53ebe272364b56b78c2028031b20b9 Mon Sep 17 00:00:00 2001 From: tony Date: Thu, 5 Feb 2026 11:55:32 +0700 Subject: [PATCH 15/35] fix build --- apps/long-short-backend/src/config/market.ts | 2 +- packages/minswap-lending-market/src/lending-market.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/long-short-backend/src/config/market.ts b/apps/long-short-backend/src/config/market.ts index 9ad6e3f..d17f77d 100644 --- a/apps/long-short-backend/src/config/market.ts +++ b/apps/long-short-backend/src/config/market.ts @@ -80,7 +80,7 @@ export function getMarketConfig(marketId: string): MarketConfig | null { */ export function isSupportedMarket(marketId: string): boolean { const config = getMarketConfig(marketId); - return config?.enable; + return config ? config.enable : false; } /** diff --git a/packages/minswap-lending-market/src/lending-market.ts b/packages/minswap-lending-market/src/lending-market.ts index 1d36876..94f0c9c 100644 --- a/packages/minswap-lending-market/src/lending-market.ts +++ b/packages/minswap-lending-market/src/lending-market.ts @@ -257,6 +257,7 @@ export namespace LendingMarket { const mapMarket: Record = { Ada: qAdaToken, MIN: qMinToken, + NIGHT: Asset.fromString("c45fa8aefc662c003a32be67f6a4652d8ce56bd9e54d7696efd40c86"), }; const collaterals: LiqwidProvider.LoanCalculationInput["collaterals"] = []; const buildTxCollaterals: LiqwidProvider.BorrowCollateral[] = []; From 36b1418b45feb2a9eda4cc61bb50fa9e3d940a7a Mon Sep 17 00:00:00 2001 From: tony Date: Thu, 5 Feb 2026 14:31:28 +0700 Subject: [PATCH 16/35] update market_config --- .../1770117151277_market_config_borrow_market_ids.ts | 12 ++++++++++++ apps/long-short-backend/example/sign-data.ts | 6 ++++-- apps/long-short-backend/src/config/market.ts | 4 ++++ apps/long-short-backend/src/database/db.d.ts | 2 ++ 4 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 apps/long-short-backend/.config/migrations/1770117151277_market_config_borrow_market_ids.ts diff --git a/apps/long-short-backend/.config/migrations/1770117151277_market_config_borrow_market_ids.ts b/apps/long-short-backend/.config/migrations/1770117151277_market_config_borrow_market_ids.ts new file mode 100644 index 0000000..53eb3cf --- /dev/null +++ b/apps/long-short-backend/.config/migrations/1770117151277_market_config_borrow_market_ids.ts @@ -0,0 +1,12 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "market_config" ADD COLUMN "borrow_market_id_long" TEXT NOT NULL DEFAULT ''`.execute(db); + await sql`ALTER TABLE "market_config" ADD COLUMN "borrow_market_id_short" TEXT NOT NULL DEFAULT ''`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "market_config" DROP COLUMN "borrow_market_id_long"`.execute(db); + await sql`ALTER TABLE "market_config" DROP COLUMN "borrow_market_id_short"`.execute(db); +} diff --git a/apps/long-short-backend/example/sign-data.ts b/apps/long-short-backend/example/sign-data.ts index 6de50a7..f138d23 100644 --- a/apps/long-short-backend/example/sign-data.ts +++ b/apps/long-short-backend/example/sign-data.ts @@ -3,6 +3,7 @@ import { NetworkEnvironment } from "@minswap/felis-ledger-core"; import { signData, verifySignData } from "../src/utils/signature"; import { RustModule } from "@minswap/felis-ledger-utils"; import invariant from "@minswap/tiny-invariant"; +import { HashUtils } from "../src/utils"; const main = async () => { await RustModule.load(); @@ -19,13 +20,14 @@ const main = async () => { amount: "500000000", }; const message = Buffer.from(JSON.stringify(data)).toString("hex"); + const hashMessage = HashUtils.sha256(message); console.log("json data", JSON.stringify(data)); console.log(message); // Sign the message const { signature, key } = signData( wallet.paymentKey, wallet.address.bech32, - message, + hashMessage, ); const result = { @@ -39,7 +41,7 @@ const main = async () => { console.log(JSON.stringify(result, null, 4)); const authenticated = verifySignData({ - message, + message: hashMessage, address: wallet.address.bech32, key, signature, diff --git a/apps/long-short-backend/src/config/market.ts b/apps/long-short-backend/src/config/market.ts index d17f77d..73c4b85 100644 --- a/apps/long-short-backend/src/config/market.ts +++ b/apps/long-short-backend/src/config/market.ts @@ -15,6 +15,8 @@ export type MarketConfig = { assetBQTokenTicker: string; assetBQTokenRaw: string; collateralMarketId: string; + borrowMarketIdLong: string; + borrowMarketIdShort: string; leverage: number; minCollateral: bigint; enable: boolean; @@ -43,6 +45,8 @@ export async function loadMarketConfigs(db: Kysely): Promise; + borrow_market_id_short: Generated; collateral_market_id: string; enable: Generated; leverage: Numeric; From ea5bcf83223dffac4d2599ac8f9950162a3fb961 Mon Sep 17 00:00:00 2001 From: tony Date: Thu, 5 Feb 2026 14:49:15 +0700 Subject: [PATCH 17/35] refactor --- .../src/api/state-machine.ts | 71 ++++++++----------- .../repository/market-config-repository.ts | 32 +++++++++ .../src/repository/order-repository.ts | 27 +++++++ .../src/services/position-service.ts | 52 ++++++-------- 4 files changed, 111 insertions(+), 71 deletions(-) create mode 100644 apps/long-short-backend/src/repository/market-config-repository.ts diff --git a/apps/long-short-backend/src/api/state-machine.ts b/apps/long-short-backend/src/api/state-machine.ts index 683df2a..f173baa 100644 --- a/apps/long-short-backend/src/api/state-machine.ts +++ b/apps/long-short-backend/src/api/state-machine.ts @@ -40,30 +40,29 @@ export namespace StateMachine { outputsHash?: string; }; - export type HandleLongBuyOptions = { - order: { - order_type: string; - asset_in: string | null; - amount_in: string | null; - asset_out: string | null; - }; - marketConfig: { - market_id: string; - asset_a: string; - asset_b: string; - amm_lp_asset: string; - }; + // Common order data type for all Handle functions + export type OrderData = { + orderType: string; + assetIn: string | null; + amountIn: string | null; + assetOut: string | null; + }; + + // Common options for all Handle functions + export type HandleBuildTxOptions = { + order: OrderData; + marketConfig: MarketConfig; userAddress: string; networkEnv: NetworkEnvironment; utxos: string[]; }; - export const handleLongBuy = async (options: HandleLongBuyOptions): Promise => { + export const handleLongBuy = async (options: HandleBuildTxOptions): Promise => { const { order, marketConfig, userAddress, networkEnv, utxos } = options; - invariant(order.order_type === LongOrderType.LONG_BUY, "Invalid order type for handleLongBuy"); - invariant(order.asset_in, "asset_in is required for LONG_BUY order"); - invariant(order.amount_in, "amount_in is required for LONG_BUY order"); - invariant(order.asset_out, "asset_out is required for LONG_BUY order"); + invariant(order.orderType === LongOrderType.LONG_BUY, "Invalid order type for handleLongBuy"); + invariant(order.assetIn, "assetIn is required for LONG_BUY order"); + invariant(order.amountIn, "amountIn is required for LONG_BUY order"); + invariant(order.assetOut, "assetOut is required for LONG_BUY order"); const walletUtxos: Utxo[] = utxos.map((u) => Utxo.fromHex(u)); const sender = Address.fromBech32(userAddress); @@ -72,11 +71,11 @@ export namespace StateMachine { sender, orderOptions: [ { - lpAsset: Asset.fromString(marketConfig.amm_lp_asset), + lpAsset: Asset.fromString(marketConfig.ammLpAsset), version: DexVersion.DEX_V2, type: OrderV2StepType.SWAP_EXACT_IN, - assetIn: Asset.fromString(marketConfig.asset_a), - amountIn: BigInt(order.amount_in.toString()), + assetIn: marketConfig.assetA, + amountIn: BigInt(order.amountIn), minimumAmountOut: 1n, direction: OrderV2Direction.A_TO_B, killOnFailed: false, @@ -112,33 +111,21 @@ export namespace StateMachine { }; }; - export type HandleLongSupplyOptions = { - order: { - order_type: string; - asset_in: string | null; - amount_in: string | null; - asset_out: string | null; - }; - userAddress: string; - networkEnv: NetworkEnvironment; - utxos: string[]; - }; - - export const handleLongSupply = async (options: HandleLongSupplyOptions): Promise => { + export const handleLongSupply = async (options: HandleBuildTxOptions): Promise => { const { order, userAddress, networkEnv, utxos } = options; - invariant(order.order_type === LongOrderType.LONG_SUPPLY, "Invalid order type for handleLongSupply"); - invariant(order.asset_in, "asset_in is required for LONG_SUPPLY order"); - invariant(order.amount_in, "amount_in is required for LONG_SUPPLY order"); - invariant(order.asset_out, "asset_out is required for LONG_SUPPLY order"); + invariant(order.orderType === LongOrderType.LONG_SUPPLY, "Invalid order type for handleLongSupply"); + invariant(order.assetIn, "assetIn is required for LONG_SUPPLY order"); + invariant(order.amountIn, "amountIn is required for LONG_SUPPLY order"); + invariant(order.assetOut, "assetOut is required for LONG_SUPPLY order"); - // asset_out contains the lending market ID (collateral token qMIN or qADA) - // We need to extract the market ID from the asset_out + // assetOut contains the lending market ID (collateral token qMIN or qADA) + // We need to extract the market ID from the assetOut // For example: "186cd98a29585651c89f05807a876cf26cdf47a7f86f70be3b9e4cc0" -> "MIN" - const marketId = order.asset_out as LiqwidProvider.MarketId; + const marketId = order.assetOut as LiqwidProvider.MarketId; const buildTxResult = await LiqwidProvider.getSupplyTransaction({ marketId, - amount: Number(order.amount_in), + amount: Number(order.amountIn), address: userAddress, utxos, networkEnv, diff --git a/apps/long-short-backend/src/repository/market-config-repository.ts b/apps/long-short-backend/src/repository/market-config-repository.ts new file mode 100644 index 0000000..cdefa3b --- /dev/null +++ b/apps/long-short-backend/src/repository/market-config-repository.ts @@ -0,0 +1,32 @@ +import type { Kysely, Selectable, Transaction } from "kysely"; +import type { DB } from "../database"; + +export type MarketConfigRow = Selectable; + +export namespace MarketConfigRepository { + /** + * Get market config row by market ID + */ + export async function getMarketConfigRowById( + db: Kysely | Transaction, + marketId: string, + ): Promise { + const result = await db + .selectFrom("market_config") + .selectAll() + .where("market_id", "=", marketId) + .executeTakeFirst(); + + return result ?? null; + } + + /** + * Get market config row by market ID (throws if not found) + */ + export async function getMarketConfigRowByIdOrThrow( + db: Kysely | Transaction, + marketId: string, + ): Promise { + return db.selectFrom("market_config").selectAll().where("market_id", "=", marketId).executeTakeFirstOrThrow(); + } +} diff --git a/apps/long-short-backend/src/repository/order-repository.ts b/apps/long-short-backend/src/repository/order-repository.ts index 2d2a973..9d8b9e9 100644 --- a/apps/long-short-backend/src/repository/order-repository.ts +++ b/apps/long-short-backend/src/repository/order-repository.ts @@ -186,6 +186,33 @@ export namespace OrderRepository { await db.updateTable("order").set({ waiting }).where("id", "=", orderId.toString()).execute(); } + /** + * Get order by position ID and order type + */ + export async function getOrderByPositionAndType( + db: Kysely | Transaction, + positionId: bigint, + orderType: string, + ): Promise { + const result = await db + .selectFrom("order") + .selectAll() + .where("position_id", "=", positionId.toString()) + .where("order_type", "=", orderType) + .executeTakeFirst(); + + return result ? mapOrderRow(result) : null; + } + + /** + * Get order by ID + */ + export async function getOrderById(db: Kysely | Transaction, orderId: bigint): Promise { + const result = await db.selectFrom("order").selectAll().where("id", "=", orderId.toString()).executeTakeFirst(); + + return result ? mapOrderRow(result) : null; + } + // biome-ignore lint/suspicious/noExplicitAny: DB row type function mapOrderRow(row: any): Order { return { diff --git a/apps/long-short-backend/src/services/position-service.ts b/apps/long-short-backend/src/services/position-service.ts index 6956349..0398054 100644 --- a/apps/long-short-backend/src/services/position-service.ts +++ b/apps/long-short-backend/src/services/position-service.ts @@ -168,12 +168,11 @@ export class PositionService { }); // Find the order with the next order type - const nextOrder = await this.db - .selectFrom("order") - .selectAll() - .where("position_id", "=", waitingOrder.positionId.toString()) - .where("order_type", "=", waitingResult.nextOrderType) - .executeTakeFirst(); + const nextOrder = await OrderRepository.getOrderByPositionAndType( + this.db, + waitingOrder.positionId, + waitingResult.nextOrderType, + ); if (!nextOrder) { logger.error("Next order not found", { @@ -189,7 +188,7 @@ export class PositionService { // Update the next order with details and set current order waiting = false await OrderRepository.updateOrderNextDetails( this.db, - BigInt(nextOrder.id), + nextOrder.id, waitingResult.assetIn, waitingResult.amountIn, waitingResult.assetOut, @@ -233,12 +232,11 @@ export class PositionService { }); // Find the LONG_BORROW order - const nextOrder = await this.db - .selectFrom("order") - .selectAll() - .where("position_id", "=", waitingOrder.positionId.toString()) - .where("order_type", "=", waitingResult.nextOrderType) - .executeTakeFirst(); + const nextOrder = await OrderRepository.getOrderByPositionAndType( + this.db, + waitingOrder.positionId, + waitingResult.nextOrderType, + ); if (!nextOrder) { logger.error("Next order not found", { @@ -254,7 +252,7 @@ export class PositionService { // Update the next order with details and set current order waiting = false await OrderRepository.updateOrderNextDetails( this.db, - BigInt(nextOrder.id), + nextOrder.id, waitingResult.assetIn, waitingResult.amountIn, waitingResult.assetOut, @@ -383,18 +381,13 @@ export class PositionService { hasPreviousBuild: !!order.builtTxId, }); - // Fetch raw DB rows for StateMachine handlers - const orderRow = await this.db - .selectFrom("order") - .selectAll() - .where("id", "=", order.id.toString()) - .executeTakeFirstOrThrow(); - - const marketConfigRow = await this.db - .selectFrom("market_config") - .selectAll() - .where("market_id", "=", marketId) - .executeTakeFirstOrThrow(); + // Convert Order to OrderData for StateMachine handlers + const orderData: StateMachine.OrderData = { + orderType: order.orderType, + assetIn: order.assetIn, + amountIn: order.amountIn, + assetOut: order.assetOut, + }; // Build transaction based on order type let txResult: StateMachine.BuiltResult; @@ -402,8 +395,8 @@ export class PositionService { switch (order.orderType) { case StateMachine.LongOrderType.LONG_BUY: txResult = await StateMachine.handleLongBuy({ - order: orderRow, - marketConfig: marketConfigRow, + order: orderData, + marketConfig, userAddress, networkEnv: this.networkEnv, utxos, @@ -411,7 +404,8 @@ export class PositionService { break; case StateMachine.LongOrderType.LONG_SUPPLY: txResult = await StateMachine.handleLongSupply({ - order: orderRow, + order: orderData, + marketConfig, userAddress, networkEnv: this.networkEnv, utxos, From ea1e51828a5a7eca70db4a16184eb00a15f0151d Mon Sep 17 00:00:00 2001 From: tony Date: Thu, 5 Feb 2026 15:49:30 +0700 Subject: [PATCH 18/35] long borrow --- .../src/api/state-machine.ts | 197 +++-- .../src/repository/order-repository.ts | 50 ++ .../src/services/position-service.ts | 229 ++--- packages/minswap-lending-market/src/index.ts | 1 + .../src/liqwid-provider-v2.ts | 804 ++++++++++++++++++ 5 files changed, 1044 insertions(+), 237 deletions(-) create mode 100644 packages/minswap-lending-market/src/liqwid-provider-v2.ts diff --git a/apps/long-short-backend/src/api/state-machine.ts b/apps/long-short-backend/src/api/state-machine.ts index f173baa..3fcfe8c 100644 --- a/apps/long-short-backend/src/api/state-machine.ts +++ b/apps/long-short-backend/src/api/state-machine.ts @@ -2,7 +2,7 @@ import { DEXOrderTransaction } from "@minswap/felis-build-tx"; import { DexVersion, OrderV2Direction, OrderV2StepType } from "@minswap/felis-dex-v2"; import { Address, Asset, getTimeFromSlotMagic, type NetworkEnvironment, Utxo } from "@minswap/felis-ledger-core"; import { Duration, Maybe, RustModule, safeFreeRustObjects } from "@minswap/felis-ledger-utils"; -import { LiqwidProvider } from "@minswap/felis-lending-market"; +import { LiqwidProvider, LiqwidProviderV2 } from "@minswap/felis-lending-market"; import { CoinSelectionAlgorithm, EmulatorProvider } from "@minswap/felis-tx-builder"; import invariant from "@minswap/tiny-invariant"; import type { MarketConfig } from "../config"; @@ -55,6 +55,8 @@ export namespace StateMachine { userAddress: string; networkEnv: NetworkEnvironment; utxos: string[]; + /** Amount to borrow (used for LONG_BORROW) */ + amountBorrow?: string; }; export const handleLongBuy = async (options: HandleBuildTxOptions): Promise => { @@ -153,35 +155,90 @@ export namespace StateMachine { }; }; - export type WaitingLongBuyOptions = { - marketConfig: MarketConfig; - txHash: string; - orderOutputIndex: number; - userAddress: Address; - assetOut: Asset; - cardanoscanProvider: CardanoscanProvider; + export const handleLongBorrow = async (options: HandleBuildTxOptions): Promise => { + const { order, marketConfig, userAddress, networkEnv, utxos, amountBorrow } = options; + invariant(order.orderType === LongOrderType.LONG_BORROW, "Invalid order type for handleLongBorrow"); + invariant(order.assetIn, "assetIn is required for LONG_BORROW order"); + invariant(order.amountIn, "amountIn is required for LONG_BORROW order"); + invariant(amountBorrow, "amountBorrow is required for LONG_BORROW order"); + + const apiConfig = LiqwidProviderV2.createConfig(networkEnv); + + const buildTxResult = await LiqwidProviderV2.Transactions.borrow(apiConfig, { + address: userAddress, + utxos, + marketId: marketConfig.borrowMarketIdLong as LiqwidProviderV2.MarketId, + amount: Number(amountBorrow), + collaterals: [ + { + id: marketConfig.assetBQTokenTicker, + amount: Number(order.amountIn), + }, + ], + }); + + if (buildTxResult.type === "err") { + throw new Error(`Failed to build borrow transaction: ${buildTxResult.error.message}`); + } + + const txRaw = buildTxResult.value; + const ECSL = RustModule.getE; + const eTx = ECSL.Transaction.from_hex(txRaw); + const txBody = eTx.body(); + const ttl = txBody.ttl(); + invariant(Maybe.isJust(ttl), "TTL must be set in the transaction body"); + safeFreeRustObjects(eTx, txBody); + + const validTo = getTimeFromSlotMagic(networkEnv, ttl); + const txId = LiqwidProviderV2.getTxHash(txRaw); + + return { + txRaw, + txId, + validTo: validTo.getTime(), + }; + }; + + /** Map of order types to their build transaction functions */ + export const MAP_BUILD_TX_FN: Record Promise> = { + [LongOrderType.LONG_BUY]: handleLongBuy, + [LongOrderType.LONG_SUPPLY]: handleLongSupply, + [LongOrderType.LONG_BORROW]: handleLongBorrow, }; - export type WaitingLongBuyResult = - | { isSpent: false } + // Common waiting result type for all waiting functions + export type WaitingResult = + | { isConfirmed: false } | { - isSpent: true; + isConfirmed: true; nextOrderType: LongOrderType; assetIn: string; amountIn: string; assetOut: string; }; + export type WaitingOptions = { + marketConfig: MarketConfig; + txHash: string; + userAddress: Address; + cardanoscanProvider: CardanoscanProvider; + /** Order output index (used for LONG_BUY to check if output is spent) */ + orderOutputIndex?: number; + /** Asset out from order (used for LONG_BUY to find the received token) */ + assetOut?: Asset; + /** Position amount_in (used for LONG_BORROW to calculate borrow amount) */ + positionAmountIn?: string; + }; + /** - * Check if the order output has been spent (consumed by a subsequent transaction) - * and prepare the next order details - * @param options - Options containing transaction details and cardanoscan provider - * @returns Result with next order details if spent, or isSpent: false if not yet processed + * Wait for LONG_BUY order output to be spent (consumed by DEX) + * and prepare the next LONG_SUPPLY order details */ - export const waitingLongBuy = async (options: WaitingLongBuyOptions): Promise => { + export const waitingLongBuy = async (options: WaitingOptions): Promise => { const { marketConfig, txHash, orderOutputIndex, userAddress, cardanoscanProvider, assetOut } = options; + invariant(orderOutputIndex !== undefined, "orderOutputIndex is required for waitingLongBuy"); + invariant(assetOut, "assetOut is required for waitingLongBuy"); - // Cache hex conversion of user address const userAddressHex = userAddress.toHex(); // Search for the transaction that spent this UTXO @@ -189,121 +246,109 @@ export namespace StateMachine { userAddress, txHash, orderOutputIndex, - 5, // pageSize - search 50 transactions per page - 10, // maxPage - search up to 10 pages (500 transactions total) + 5, // pageSize + 10, // maxPage ); if (spendingTx) { - // Order output has been spent - // Now find the output that belongs to the user and contains the assetOut token const assetOutUnit = assetOut.toBlockFrostString(); - // Search through the spending transaction's outputs for (const output of spendingTx.outputs) { - // Check if output address matches user address (in hex) if (output.address === userAddressHex) { - // Check if output contains the assetOut token if (output.tokens && output.tokens.length > 0) { const matchingToken = output.tokens.find((token) => token.assetId === assetOutUnit); if (matchingToken) { - // Found the output with the matching token - // Prepare next order: LONG_SUPPLY const amountOut = BigInt(matchingToken.value); return { - isSpent: true, + isConfirmed: true, nextOrderType: LongOrderType.LONG_SUPPLY, - assetIn: assetOut.toString(), // The asset we received becomes input for next order - amountIn: amountOut.toString(), // The amount we received becomes input amount - assetOut: marketConfig.collateralMarketId, // Supply to get collateral token + assetIn: assetOut.toString(), + amountIn: amountOut.toString(), + assetOut: marketConfig.collateralMarketId, }; } } } } - // Transaction found but couldn't find matching output - // This is an error condition - the order was processed but we can't find the result throw new Error( `Order output spent (tx: ${spendingTx.hash}) but could not find matching output with asset ${assetOut.toString()}`, ); } - // Order output has not been spent yet - return { - isSpent: false, - }; - }; - - export type WaitingLongSupplyOptions = { - marketConfig: MarketConfig; - txHash: string; - userAddress: Address; - cardanoscanProvider: CardanoscanProvider; + return { isConfirmed: false }; }; - export type WaitingLongSupplyResult = - | { isConfirmed: false } - | { - isConfirmed: true; - nextOrderType: LongOrderType; - assetIn: string; - amountIn: string; - assetOut: string; - }; - /** - * Wait for LONG_SUPPLY transaction to be confirmed and prepare the next LONG_BORROW order - * @param options - Options containing transaction details and cardanoscan provider - * @returns Result with next order details if confirmed, or isConfirmed: false if not yet confirmed + * Wait for LONG_SUPPLY transaction to be confirmed + * and prepare the next LONG_BORROW order details */ - export const waitingLongSupply = async (options: WaitingLongSupplyOptions): Promise => { + export const waitingLongSupply = async (options: WaitingOptions): Promise => { const { marketConfig, txHash, userAddress, cardanoscanProvider } = options; - // Search for the transaction to confirm it's on chain - const txFoundOnChain = await cardanoscanProvider.findTransactionByHash( - userAddress, - txHash, - 50, // pageSize - 10, // maxPage - ); + const txFoundOnChain = await cardanoscanProvider.findTransactionByHash(userAddress, txHash, 50, 10); if (txFoundOnChain) { - // Transaction is confirmed on chain - // Find the output that belongs to the user and contains the qToken (asset_b_q_token_raw) const userAddressHex = userAddress.toHex(); const qTokenAsset = Asset.fromString(marketConfig.assetBQTokenRaw); const qTokenUnit = qTokenAsset.toBlockFrostString(); - // Search through the transaction outputs for (const output of txFoundOnChain.outputs) { if (output.address === userAddressHex) { if (output.tokens && output.tokens.length > 0) { const matchingToken = output.tokens.find((token) => token.assetId === qTokenUnit); if (matchingToken) { - // Found the output with the qToken - // Prepare next order: LONG_BORROW const amountReceived = BigInt(matchingToken.value); return { isConfirmed: true, nextOrderType: LongOrderType.LONG_BORROW, - assetIn: marketConfig.assetBQTokenRaw, // qToken (qMIN) becomes input for borrow + assetIn: marketConfig.assetBQTokenRaw, amountIn: amountReceived.toString(), - assetOut: marketConfig.assetA.toString(), // Borrow asset A (ADA) + assetOut: marketConfig.assetA.toString(), }; } } } } - // Transaction found but couldn't find matching qToken output throw new Error( `LONG_SUPPLY tx confirmed (${txHash}) but could not find output with qToken ${marketConfig.assetBQTokenRaw}`, ); } - // Transaction not yet confirmed - return { - isConfirmed: false, - }; + return { isConfirmed: false }; + }; + + /** + * Wait for LONG_BORROW transaction to be confirmed + * and prepare the next LONG_BUY_MORE order details + */ + export const waitingLongBorrow = async (options: WaitingOptions): Promise => { + const { marketConfig, txHash, userAddress, cardanoscanProvider, positionAmountIn } = options; + invariant(positionAmountIn, "positionAmountIn is required for waitingLongBorrow"); + + const txFoundOnChain = await cardanoscanProvider.findTransactionByHash(userAddress, txHash, 50, 10); + + if (txFoundOnChain) { + // Calculate borrow amount: position.amount_in * (market_config.leverage - 1) + const amountBorrow = BigInt(Math.floor(Number(positionAmountIn) * (marketConfig.leverage - 1))); + + return { + isConfirmed: true, + nextOrderType: LongOrderType.LONG_BUY_MORE, + assetIn: marketConfig.assetA.toString(), + amountIn: amountBorrow.toString(), + assetOut: marketConfig.assetB.toString(), + }; + } + + return { isConfirmed: false }; + }; + + /** Map of order types to their waiting functions */ + export const MAP_WAITING_FN: Record Promise> = { + [LongOrderType.LONG_BUY]: waitingLongBuy, + [LongOrderType.LONG_SUPPLY]: waitingLongSupply, + [LongOrderType.LONG_BORROW]: waitingLongBorrow, }; } diff --git a/apps/long-short-backend/src/repository/order-repository.ts b/apps/long-short-backend/src/repository/order-repository.ts index 9d8b9e9..2a64949 100644 --- a/apps/long-short-backend/src/repository/order-repository.ts +++ b/apps/long-short-backend/src/repository/order-repository.ts @@ -213,6 +213,56 @@ export namespace OrderRepository { return result ? mapOrderRow(result) : null; } + export type TransitionToNextOrderParams = { + currentOrderId: bigint; + positionId: bigint; + nextOrderType: string; + assetIn: string; + amountIn: string; + assetOut: string; + }; + + export type TransitionToNextOrderResult = { success: true; nextOrder: Order } | { success: false; error: string }; + + /** + * Transition from current order to next order: + * 1. Find the next order by position and type + * 2. Update next order with assetIn, amountIn, assetOut + * 3. Set current order waiting = false + */ + export async function transitionToNextOrder( + db: Kysely | Transaction, + params: TransitionToNextOrderParams, + ): Promise { + const { currentOrderId, positionId, nextOrderType, assetIn, amountIn, assetOut } = params; + + // Find the next order + const nextOrder = await getOrderByPositionAndType(db, positionId, nextOrderType); + if (!nextOrder) { + return { + success: false, + error: `Next order with type "${nextOrderType}" not found`, + }; + } + + // Update next order details + await updateOrderNextDetails(db, nextOrder.id, assetIn, amountIn, assetOut); + + // Set current order waiting = false + await setOrderWaiting(db, currentOrderId, false); + + // Return updated next order + return { + success: true, + nextOrder: { + ...nextOrder, + assetIn, + amountIn, + assetOut, + }, + }; + } + // biome-ignore lint/suspicious/noExplicitAny: DB row type function mapOrderRow(row: any): Order { return { diff --git a/apps/long-short-backend/src/services/position-service.ts b/apps/long-short-backend/src/services/position-service.ts index 0398054..a2e4e1d 100644 --- a/apps/long-short-backend/src/services/position-service.ts +++ b/apps/long-short-backend/src/services/position-service.ts @@ -73,8 +73,10 @@ export class PositionService { } // Calculate amount_borrow = amount_in * (leverage - 1) - const amountBorrow = BigInt(Math.floor(Number(amountIn) * (marketConfig.leverage - 1))); - + let amountBorrow = BigInt(Math.floor(Number(amountIn) * (marketConfig.leverage - 1))); + if (side === StateMachine.PositionSide.LONG) { + amountBorrow += 4_000_000n; //extra ada for fee + } // Execute transaction: create position + 4 orders const position = await this.db.transaction().execute(async (trx) => { const pos = await PositionRepository.createPosition(trx, { @@ -139,7 +141,7 @@ export class PositionService { // STEP 1: Check if there's a waiting order (created_tx_id not null, waiting = true) const waitingOrder = await OrderRepository.getWaitingOrder(this.db, position.id); if (waitingOrder) { - logger.info("Found waiting order, checking if output is spent", { + logger.info("Found waiting order, checking status", { orderId: waitingOrder.id, orderType: waitingOrder.orderType, createdTxId: waitingOrder.createdTxId, @@ -147,142 +149,58 @@ export class PositionService { invariant(waitingOrder.assetOut, "Waiting order must have assetOut defined"); invariant(waitingOrder.createdTxId, "Waiting order must have createdTxId defined"); - const address = Address.fromBech32(userAddress); + // Get the waiting function for this order type + const waitingFn = StateMachine.MAP_WAITING_FN[waitingOrder.orderType]; + if (!waitingFn) { + return { + success: false, + error: `Waiting logic for order type "${waitingOrder.orderType}" is not implemented yet`, + }; + } - // Call appropriate waiting function based on order type - if (waitingOrder.orderType === StateMachine.LongOrderType.LONG_BUY) { - const waitingResult = await StateMachine.waitingLongBuy({ - marketConfig, - txHash: waitingOrder.createdTxId, - orderOutputIndex: waitingOrder.createdTxIndex ?? 0, - userAddress: address, - assetOut: Asset.fromString(waitingOrder.assetOut), - cardanoscanProvider: this.cardanoscanProvider, + // Build common waiting options + const waitingOptions: StateMachine.WaitingOptions = { + marketConfig, + txHash: waitingOrder.createdTxId, + userAddress: Address.fromBech32(userAddress), + cardanoscanProvider: this.cardanoscanProvider, + orderOutputIndex: waitingOrder.createdTxIndex ?? 0, + assetOut: Asset.fromString(waitingOrder.assetOut), + positionAmountIn: position.amountIn, + }; + + const waitingResult = await waitingFn(waitingOptions); + + if (waitingResult.isConfirmed) { + const transitionResult = await OrderRepository.transitionToNextOrder(this.db, { + currentOrderId: waitingOrder.id, + positionId: waitingOrder.positionId, + nextOrderType: waitingResult.nextOrderType, + assetIn: waitingResult.assetIn, + amountIn: waitingResult.amountIn, + assetOut: waitingResult.assetOut, }); - if (waitingResult.isSpent) { - // Order output has been spent - find and update the next order - logger.info("Order output spent, updating next order", { - orderId: waitingOrder.id, - nextOrderType: waitingResult.nextOrderType, - }); - - // Find the order with the next order type - const nextOrder = await OrderRepository.getOrderByPositionAndType( - this.db, - waitingOrder.positionId, - waitingResult.nextOrderType, - ); - - if (!nextOrder) { - logger.error("Next order not found", { - positionId: waitingOrder.positionId, - nextOrderType: waitingResult.nextOrderType, - }); - return { - success: false, - error: `Next order with type "${waitingResult.nextOrderType}" not found`, - }; - } - - // Update the next order with details and set current order waiting = false - await OrderRepository.updateOrderNextDetails( - this.db, - nextOrder.id, - waitingResult.assetIn, - waitingResult.amountIn, - waitingResult.assetOut, - ); - await OrderRepository.setOrderWaiting(this.db, waitingOrder.id, false); - - logger.info("Next order updated, current order no longer waiting", { - currentOrderId: waitingOrder.id, - nextOrderId: nextOrder.id, - assetIn: waitingResult.assetIn, - amountIn: waitingResult.amountIn, - assetOut: waitingResult.assetOut, - }); - - return { - success: false, - error: "Order processed. Next order details updated. Call build-tx again to continue.", - }; - } else { - // Order output not spent yet - return { - success: false, - error: "Transaction confirmed on chain. Waiting for order to be processed.", - }; + if (!transitionResult.success) { + logger.error("Failed to transition to next order", { error: transitionResult.error }); + return { success: false, error: transitionResult.error }; } - } else if (waitingOrder.orderType === StateMachine.LongOrderType.LONG_SUPPLY) { - // LONG_SUPPLY is simpler - just wait for tx confirmation (already confirmed since we're here) - // Then check for qToken output and prepare LONG_BORROW - const waitingResult = await StateMachine.waitingLongSupply({ - marketConfig, - txHash: waitingOrder.createdTxId, - userAddress: address, - cardanoscanProvider: this.cardanoscanProvider, + + logger.info("Order completed, transitioned to next order", { + currentOrderId: waitingOrder.id, + currentOrderType: waitingOrder.orderType, + nextOrderId: transitionResult.nextOrder.id, + nextOrderType: waitingResult.nextOrderType, }); - if (waitingResult.isConfirmed) { - // Transaction is confirmed - update next order - logger.info("LONG_SUPPLY confirmed, updating next LONG_BORROW order", { - orderId: waitingOrder.id, - nextOrderType: waitingResult.nextOrderType, - }); - - // Find the LONG_BORROW order - const nextOrder = await OrderRepository.getOrderByPositionAndType( - this.db, - waitingOrder.positionId, - waitingResult.nextOrderType, - ); - - if (!nextOrder) { - logger.error("Next order not found", { - positionId: waitingOrder.positionId, - nextOrderType: waitingResult.nextOrderType, - }); - return { - success: false, - error: `Next order with type "${waitingResult.nextOrderType}" not found`, - }; - } - - // Update the next order with details and set current order waiting = false - await OrderRepository.updateOrderNextDetails( - this.db, - nextOrder.id, - waitingResult.assetIn, - waitingResult.amountIn, - waitingResult.assetOut, - ); - await OrderRepository.setOrderWaiting(this.db, waitingOrder.id, false); - - logger.info("LONG_BORROW order updated, LONG_SUPPLY no longer waiting", { - currentOrderId: waitingOrder.id, - nextOrderId: nextOrder.id, - assetIn: waitingResult.assetIn, - amountIn: waitingResult.amountIn, - assetOut: waitingResult.assetOut, - }); - - return { - success: false, - error: "LONG_SUPPLY completed. LONG_BORROW order ready. Call build-tx again to continue.", - }; - } else { - // Transaction not confirmed yet (shouldn't happen since we check waiting=true) - return { - success: false, - error: "LONG_SUPPLY transaction not yet confirmed on chain.", - }; - } + return { + success: false, + error: `${waitingOrder.orderType} completed. ${waitingResult.nextOrderType} order ready. Call build-tx again to continue.`, + }; } else { - // Other order types not implemented yet return { success: false, - error: `Waiting logic for order type "${waitingOrder.orderType}" is not implemented yet`, + error: `${waitingOrder.orderType} transaction not yet confirmed on chain.`, }; } } @@ -381,39 +299,28 @@ export class PositionService { hasPreviousBuild: !!order.builtTxId, }); - // Convert Order to OrderData for StateMachine handlers - const orderData: StateMachine.OrderData = { - orderType: order.orderType, - assetIn: order.assetIn, - amountIn: order.amountIn, - assetOut: order.assetOut, + // Get the build function for this order type + const buildFn = StateMachine.MAP_BUILD_TX_FN[order.orderType]; + if (!buildFn) { + return { success: false, error: `Order type "${order.orderType}" is not implemented` }; + } + + // Build common options + const buildOptions: StateMachine.HandleBuildTxOptions = { + order: { + orderType: order.orderType, + assetIn: order.assetIn, + amountIn: order.amountIn, + assetOut: order.assetOut, + }, + marketConfig, + userAddress, + networkEnv: this.networkEnv, + utxos, + amountBorrow: position.amountBorrow, }; - // Build transaction based on order type - let txResult: StateMachine.BuiltResult; - - switch (order.orderType) { - case StateMachine.LongOrderType.LONG_BUY: - txResult = await StateMachine.handleLongBuy({ - order: orderData, - marketConfig, - userAddress, - networkEnv: this.networkEnv, - utxos, - }); - break; - case StateMachine.LongOrderType.LONG_SUPPLY: - txResult = await StateMachine.handleLongSupply({ - order: orderData, - marketConfig, - userAddress, - networkEnv: this.networkEnv, - utxos, - }); - break; - default: - return { success: false, error: `Order type "${order.orderType}" is not implemented` }; - } + const txResult = await buildFn(buildOptions); // Update order built_tx fields await OrderRepository.updateOrderBuiltTx( diff --git a/packages/minswap-lending-market/src/index.ts b/packages/minswap-lending-market/src/index.ts index bb0eaf1..9bcd8b0 100644 --- a/packages/minswap-lending-market/src/index.ts +++ b/packages/minswap-lending-market/src/index.ts @@ -1,3 +1,4 @@ export * from "./lending-market"; export * from "./liqwid-provider"; +export * from "./liqwid-provider-v2"; export * from "./nitro-wallet"; diff --git a/packages/minswap-lending-market/src/liqwid-provider-v2.ts b/packages/minswap-lending-market/src/liqwid-provider-v2.ts new file mode 100644 index 0000000..1ca9901 --- /dev/null +++ b/packages/minswap-lending-market/src/liqwid-provider-v2.ts @@ -0,0 +1,804 @@ +import { NetworkEnvironment, type PrivateKey, XJSON } from "@minswap/felis-ledger-core"; +import { blake2b256, Result, RustModule } from "@minswap/felis-ledger-utils"; +import * as cbor from "cbor"; + +/** + * Liqwid Protocol Provider V2 + * + * A cleaner, more type-safe implementation for interacting with the Liqwid Finance API. + * Based on the Liqwid GraphQL schema. + */ +export namespace LiqwidProviderV2 { + // ============================================================================ + // Common Types + // ============================================================================ + + export type Currency = "EUR" | "USD" | "GBP" | "CAD" | "BRL" | "JPY" | "VND" | "CZK" | "AUD" | "SGD" | "CHF"; + + export type SupportedWallet = "ETERNL" | "BEGIN"; + + export type Network = "MAINNET" | "PREVIEW"; + + /** Market IDs for different assets */ + export type MarketId = "Ada" | "MIN" | "DJED" | "iUSD" | "SHEN" | "LQ" | "HUNT" | "WMT" | "LENFI" | "NIGHT"; + + /** Collateral market identifiers (format: MarketId.PolicyId) */ + export type CollateralId = `${MarketId}.${string}`; + + // ============================================================================ + // API Configuration + // ============================================================================ + + const API_URLS: Record = { + [NetworkEnvironment.MAINNET]: "https://v2.api.liqwid.finance/graphql", + [NetworkEnvironment.TESTNET_PREPROD]: "https://v2.api.preprod.liqwid.dev/graphql", + [NetworkEnvironment.TESTNET_PREVIEW]: "https://v2.api.preview.liqwid.dev/graphql", + }; + + export type ApiConfig = { + networkEnv: NetworkEnvironment; + /** Optional client-side endpoint override (for browser proxying) */ + clientEndpoint?: string; + }; + + // ============================================================================ + // GraphQL Types - Inputs + // ============================================================================ + + export type UserAddressInput = { + address: string; + changeAddress?: string; + otherAddresses?: string[]; + utxos: string[]; + }; + + export type CustomOutput = { + address: string; + inlineDatum?: string; + }; + + export type InCurrencyInput = { + currency?: Currency; + }; + + // Transaction Inputs + export type SupplyTransactionInput = UserAddressInput & { + marketId: MarketId; + amount: number; + wallet?: SupportedWallet; + mintedQTokensDestination?: CustomOutput; + }; + + export type WithdrawTransactionInput = UserAddressInput & { + marketId: MarketId; + amount: number; + wallet?: SupportedWallet; + withdrawnUnderlyingDestination?: CustomOutput; + }; + + export type BorrowCollateralInput = { + id: string; + tokenName?: string; + amount: number; + }; + + export type BorrowTransactionInput = UserAddressInput & { + marketId: MarketId; + amount: number; + collaterals: BorrowCollateralInput[]; + principalDestination?: CustomOutput; + }; + + export type ModifyBorrowTransactionInput = UserAddressInput & { + txId: string; + amount: number; + collaterals: BorrowCollateralInput[]; + redeemCollateral?: boolean; + loanPrincipalAndCollateralDeltasDestination?: CustomOutput; + }; + + export type SubmitTransactionInput = { + transaction: string; + signature: string; + }; + + // Calculation Inputs + export type LoanCalculationCollateralInput = { + id: string; + amount: number; + }; + + export type LoanCalculationInput = { + market: MarketId; + debt: number; + collaterals: LoanCalculationCollateralInput[]; + }; + + export type SupplyCalculationInput = { + marketId: MarketId; + amount: number; + wallet?: SupportedWallet; + }; + + export type WithdrawCalculationInput = { + marketId: MarketId; + amount: number; + wallet?: SupportedWallet; + }; + + export type NetApySupplyInput = { + marketId: MarketId; + amount: number; + }; + + export type NetApyInput = { + paymentKeys: string[]; + supplies: NetApySupplyInput[]; + currency?: Currency; + }; + + // Query Inputs + export type LoansInput = { + paymentKeys?: string[]; + marketIds?: string[]; + sorts?: LoanSort[]; + filters?: LoanFilter[]; + page?: number; + perPage?: number; + search?: string; + }; + + export type MarketsInput = { + ids?: string[]; + sorts?: MarketSort[]; + filters?: MarketFilter[]; + page?: number; + perPage?: number; + search?: string; + }; + + export type YieldEarnedInput = { + addresses: string[]; + date?: { + startTime: string; + endTime: string; + }; + }; + + // ============================================================================ + // GraphQL Types - Enums + // ============================================================================ + + export type LoanSort = + | "MARKET_ID" + | "MARKET_ID_DESC" + | "DEBT" + | "DEBT_DESC" + | "COLLATERAL_IN_CURRENCY" + | "COLLATERAL_IN_CURRENCY_DESC" + | "HEALTH_FACTOR" + | "HEALTH_FACTOR_DESC" + | "APY" + | "APY_DESC"; + + export type LoanFilter = + | "STABLECOIN" + | "CNT" + | "BRIDGED" + | "PRIME" + | "HAS_DEBT" + | "NO_DEBT" + | "HAS_COLLATERAL" + | "NO_COLLATERAL" + | "CAN_BE_LIQUIDATED"; + + export type MarketSort = + | "ID" + | "ID_DESC" + | "SUPPLY" + | "SUPPLY_DESC" + | "SUPPLY_IN_CURRENCY" + | "SUPPLY_IN_CURRENCY_DESC" + | "BORROW" + | "BORROW_DESC" + | "BORROW_IN_CURRENCY" + | "BORROW_IN_CURRENCY_DESC" + | "LIQUIDITY" + | "LIQUIDITY_DESC" + | "LIQUIDITY_IN_CURRENCY" + | "LIQUIDITY_IN_CURRENCY_DESC" + | "SUPPLY_APY" + | "SUPPLY_APY_DESC" + | "BORROW_APY" + | "BORROW_APY_DESC"; + + export type MarketFilter = "STABLECOIN" | "CNT" | "BRIDGED" | "PRIME" | "PUBLIC" | "PRIVATE"; + + // ============================================================================ + // GraphQL Types - Outputs + // ============================================================================ + + export type Transaction = { + cbor: string; + }; + + // Calculation Results + export type LoanCalculationResult = { + healthFactor: number; + maxBorrow: number; + maxBorrowCap: number | null; + batchingFee: number; + protocolFee: number; + protocolFeePercentage: number; + collateral: number; + collaterals: Array<{ + id: string; + amount: number; + LTV: number; + healthFactor: number; + }>; + }; + + export type SupplyCalculationResult = { + batchingFee: number; + supplyCap: number | null; + walletFee: number; + }; + + export type WithdrawCalculationResult = { + batchingFee: number; + walletFee: number; + withdrawCap: number; + }; + + export type NetApyResult = { + netApy: number; + netApyLqRewards: number; + borrowApy: number; + totalBorrow: number; + supplyApy: number; + totalSupply: number; + }; + + // Data Types + export type Asset = { + id: string; + name: string; + symbol: string; + displayName: string; + decimals: number; + currencySymbol: string; + policyId: string; + hexName: string; + logo: string | null; + price: number; + priceUpdatedAt: string; + }; + + export type Market = { + id: string; + displayName: string; + symbol: string; + supply: number; + borrow: number; + liquidity: number; + supplyAPY: number; + borrowAPY: number; + lqSupplyAPY: number; + utilization: number; + exchangeRate: number; + batching: boolean; + batchExpired: boolean; + frozen: boolean; + private: boolean; + delisting: boolean; + prime: boolean; + loanOriginationFeePercentage: number; + asset: Asset; + receiptAsset: Asset; + }; + + export type LoanCollateral = { + id: string; + tokenName: string | null; + qTokenName: string | null; + amount: number; + qTokenAmount: number; + LTV: number; + healthFactor: number; + market: { + id: string; + displayName: string; + exchangeRate: number; + delisting: boolean; + } | null; + asset: Asset; + }; + + export type Loan = { + id: string; + transactionId: string; + transactionIndex: number; + marketId: string; + publicKey: string; + amount: number; + adjustedAmount: number; + collateral: number; + interest: number; + APY: number; + LTV: number; + healthFactor: number; + time: number; + collaterals: LoanCollateral[]; + market: Market; + asset: Asset; + }; + + export type YieldEarnedMarket = { + id: string; + displayName: string; + currencySymbol: string; + hexName: string; + amount: number; + amountInCurrency: number; + }; + + export type YieldEarnedResult = { + totalYieldEarned: number; + markets: YieldEarnedMarket[]; + }; + + // Pagination + export type Pagination = { + page: number; + perPage: number; + pagesCount: number; + totalCount: number; + results: T[]; + }; + + // ============================================================================ + // API Client + // ============================================================================ + + type GraphQLResponse = { + data?: T; + errors?: Array<{ message: string }>; + }; + + const getApiUrl = (config: ApiConfig): string => { + if (typeof window !== "undefined" && config.clientEndpoint) { + return config.clientEndpoint; + } + return API_URLS[config.networkEnv]; + }; + + const executeQuery = async ( + config: ApiConfig, + operationName: string, + query: string, + variables: TVariables, + ): Promise> => { + try { + const response = await fetch(getApiUrl(config), { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + }, + body: JSON.stringify({ operationName, query, variables }), + }); + + if (!response.ok) { + return Result.err(new Error(`Liqwid API error: ${response.status} ${response.statusText}`)); + } + + const json = (await response.json()) as GraphQLResponse; + + if (json.errors?.length) { + return Result.err(new Error(`GraphQL error: ${XJSON.stringify(json.errors)}`)); + } + + if (!json.data) { + return Result.err(new Error("No data returned from Liqwid API")); + } + + return Result.ok(json.data); + } catch (error) { + return Result.err( + new Error(`${operationName} failed: ${error instanceof Error ? error.message : String(error)}`), + ); + } + }; + + // ============================================================================ + // Transactions API + // ============================================================================ + + export namespace Transactions { + const SUPPLY_QUERY = ` + query Supply($input: SupplyTransactionInput!) { + liqwid { transactions { supply(input: $input) { cbor } } } + } + `; + + const WITHDRAW_QUERY = ` + query Withdraw($input: WithdrawTransactionInput!) { + liqwid { transactions { withdraw(input: $input) { cbor } } } + } + `; + + const BORROW_QUERY = ` + query Borrow($input: BorrowTransactionInput!) { + liqwid { transactions { borrow(input: $input) { cbor } } } + } + `; + + const MODIFY_BORROW_QUERY = ` + query ModifyBorrow($input: ModifyBorrowTransactionInput!) { + liqwid { transactions { modifyBorrow(input: $input) { cbor } } } + } + `; + + const SUBMIT_MUTATION = ` + mutation Submit($input: SubmitTransactionInput!) { + submitTransaction(input: $input) + } + `; + + type SupplyResponse = { liqwid: { transactions: { supply: Transaction } } }; + type WithdrawResponse = { liqwid: { transactions: { withdraw: Transaction } } }; + type BorrowResponse = { liqwid: { transactions: { borrow: Transaction } } }; + type ModifyBorrowResponse = { liqwid: { transactions: { modifyBorrow: Transaction } } }; + type SubmitResponse = { submitTransaction: string }; + + /** Build a supply transaction */ + export const supply = async (config: ApiConfig, input: SupplyTransactionInput): Promise> => { + const result = await executeQuery( + config, + "Supply", + SUPPLY_QUERY, + { + input: { + ...input, + changeAddress: input.changeAddress ?? input.address, + otherAddresses: input.otherAddresses ?? [input.address], + }, + }, + ); + return result.type === "ok" ? Result.ok(result.value.liqwid.transactions.supply.cbor) : result; + }; + + /** Build a withdraw transaction */ + export const withdraw = async ( + config: ApiConfig, + input: WithdrawTransactionInput, + ): Promise> => { + const result = await executeQuery( + config, + "Withdraw", + WITHDRAW_QUERY, + { + input: { + ...input, + changeAddress: input.changeAddress ?? input.address, + otherAddresses: input.otherAddresses ?? [input.address], + }, + }, + ); + return result.type === "ok" ? Result.ok(result.value.liqwid.transactions.withdraw.cbor) : result; + }; + + /** Build a borrow transaction */ + export const borrow = async (config: ApiConfig, input: BorrowTransactionInput): Promise> => { + const result = await executeQuery( + config, + "Borrow", + BORROW_QUERY, + { + input: { + ...input, + changeAddress: input.changeAddress ?? input.address, + otherAddresses: input.otherAddresses ?? [input.address], + }, + }, + ); + return result.type === "ok" ? Result.ok(result.value.liqwid.transactions.borrow.cbor) : result; + }; + + /** Build a modify borrow (repay/borrow more) transaction */ + export const modifyBorrow = async ( + config: ApiConfig, + input: ModifyBorrowTransactionInput, + ): Promise> => { + const result = await executeQuery( + config, + "ModifyBorrow", + MODIFY_BORROW_QUERY, + { + input: { + ...input, + changeAddress: input.changeAddress ?? input.address, + otherAddresses: input.otherAddresses ?? [input.address], + }, + }, + ); + return result.type === "ok" ? Result.ok(result.value.liqwid.transactions.modifyBorrow.cbor) : result; + }; + + /** Submit a signed transaction */ + export const submit = async (config: ApiConfig, input: SubmitTransactionInput): Promise> => { + const result = await executeQuery( + config, + "Submit", + SUBMIT_MUTATION, + { input }, + ); + return result.type === "ok" ? Result.ok(result.value.submitTransaction) : result; + }; + } + + // ============================================================================ + // Calculations API + // ============================================================================ + + export namespace Calculations { + const LOAN_QUERY = ` + query LoanCalc($input: LoanCalculationInput!, $currency: InCurrencyInput) { + liqwid { + calculations { + loan(input: $input) { + healthFactor maxBorrow maxBorrowCap batchingFee + protocolFee protocolFeePercentage + collateral(input: $currency) + collaterals { id amount LTV healthFactor } + } + } + } + } + `; + + const SUPPLY_QUERY = ` + query SupplyCalc($input: SupplyCalculationInput!) { + liqwid { calculations { supply(input: $input) { batchingFee supplyCap walletFee } } } + } + `; + + const WITHDRAW_QUERY = ` + query WithdrawCalc($input: WithdrawCalculationInput!) { + liqwid { calculations { withdraw(input: $input) { batchingFee walletFee withdrawCap } } } + } + `; + + const NET_APY_QUERY = ` + query NetApy($input: NetApyInput!) { + liqwid { + calculations { + netAPY(input: $input) { + netApy netApyLqRewards borrowApy totalBorrow supplyApy totalSupply + } + } + } + } + `; + + type LoanResponse = { liqwid: { calculations: { loan: LoanCalculationResult } } }; + type SupplyResponse = { liqwid: { calculations: { supply: SupplyCalculationResult } } }; + type WithdrawResponse = { liqwid: { calculations: { withdraw: WithdrawCalculationResult } } }; + type NetApyResponse = { liqwid: { calculations: { netAPY: NetApyResult } } }; + + /** Calculate loan parameters (health factor, max borrow, fees) */ + export const loan = async ( + config: ApiConfig, + input: LoanCalculationInput, + currency: Currency = "USD", + ): Promise> => { + const result = await executeQuery( + config, + "LoanCalc", + LOAN_QUERY, + { input, currency: { currency } }, + ); + return result.type === "ok" ? Result.ok(result.value.liqwid.calculations.loan) : result; + }; + + /** Calculate supply parameters */ + export const supply = async ( + config: ApiConfig, + input: SupplyCalculationInput, + ): Promise> => { + const result = await executeQuery( + config, + "SupplyCalc", + SUPPLY_QUERY, + { input }, + ); + return result.type === "ok" ? Result.ok(result.value.liqwid.calculations.supply) : result; + }; + + /** Calculate withdraw parameters */ + export const withdraw = async ( + config: ApiConfig, + input: WithdrawCalculationInput, + ): Promise> => { + const result = await executeQuery( + config, + "WithdrawCalc", + WITHDRAW_QUERY, + { input }, + ); + return result.type === "ok" ? Result.ok(result.value.liqwid.calculations.withdraw) : result; + }; + + /** Calculate net APY for a portfolio */ + export const netApy = async (config: ApiConfig, input: NetApyInput): Promise> => { + const result = await executeQuery(config, "NetApy", NET_APY_QUERY, { + input, + }); + return result.type === "ok" ? Result.ok(result.value.liqwid.calculations.netAPY) : result; + }; + } + + // ============================================================================ + // Data API + // ============================================================================ + + export namespace Data { + const MARKETS_QUERY = ` + query Markets($input: MarketsInput, $currency: InCurrencyInput) { + liqwid { + data { + markets(input: $input) { + page perPage pagesCount totalCount + results { + id displayName symbol + supply(input: $currency) borrow(input: $currency) liquidity(input: $currency) + supplyAPY borrowAPY lqSupplyAPY utilization exchangeRate + batching batchExpired frozen private delisting prime + loanOriginationFeePercentage + asset { id name symbol displayName decimals currencySymbol policyId hexName logo price(input: $currency) priceUpdatedAt } + receiptAsset { id name symbol displayName decimals currencySymbol policyId hexName logo price(input: $currency) priceUpdatedAt } + } + } + } + } + } + `; + + const LOANS_QUERY = ` + query Loans($input: LoansInput, $currency: InCurrencyInput) { + liqwid { + data { + loans(input: $input) { + page perPage pagesCount totalCount + results { + id transactionId transactionIndex marketId publicKey + amount(input: $currency) adjustedAmount(input: $currency) collateral(input: $currency) + interest APY LTV healthFactor time + collaterals { + id tokenName qTokenName amount(input: $currency) qTokenAmount LTV healthFactor + market { id displayName exchangeRate delisting } + asset { id name symbol displayName decimals currencySymbol policyId hexName logo price(input: $currency) priceUpdatedAt } + } + market { id displayName symbol supplyAPY borrowAPY exchangeRate } + asset { id name symbol displayName decimals currencySymbol policyId hexName logo price(input: $currency) priceUpdatedAt } + } + } + } + } + } + `; + + const YIELD_QUERY = ` + query Yield($input: YieldEarnedInput!, $currency: InCurrencyInput) { + historical { + yieldEarned(input: $input) { + totalYieldEarned(input: $currency) + markets { id displayName currencySymbol hexName amount amountInCurrency(input: $currency) } + } + } + } + `; + + type MarketsResponse = { liqwid: { data: { markets: Pagination } } }; + type LoansResponse = { liqwid: { data: { loans: Pagination } } }; + type YieldResponse = { historical: { yieldEarned: YieldEarnedResult } }; + + /** Get markets data */ + export const markets = async ( + config: ApiConfig, + input?: MarketsInput, + currency: Currency = "USD", + ): Promise, Error>> => { + const result = await executeQuery( + config, + "Markets", + MARKETS_QUERY, + { input: input ?? { perPage: 50 }, currency: { currency } }, + ); + return result.type === "ok" ? Result.ok(result.value.liqwid.data.markets) : result; + }; + + /** Get loans (borrow positions) data */ + export const loans = async ( + config: ApiConfig, + input: LoansInput, + currency: Currency = "USD", + ): Promise, Error>> => { + const result = await executeQuery( + config, + "Loans", + LOANS_QUERY, + { input, currency: { currency } }, + ); + return result.type === "ok" ? Result.ok(result.value.liqwid.data.loans) : result; + }; + + /** Get yield earned for addresses */ + export const yieldEarned = async ( + config: ApiConfig, + input: YieldEarnedInput, + currency: Currency = "USD", + ): Promise> => { + const result = await executeQuery( + config, + "Yield", + YIELD_QUERY, + { input, currency: { currency } }, + ); + return result.type === "ok" ? Result.ok(result.value.historical.yieldEarned) : result; + }; + + /** Get a single market by ID */ + export const market = async ( + config: ApiConfig, + marketId: MarketId, + currency: Currency = "USD", + ): Promise> => { + const result = await markets(config, { ids: [marketId], perPage: 1 }, currency); + return result.type === "ok" ? Result.ok(result.value.results[0] ?? null) : result; + }; + + /** Get loans for specific payment keys */ + export const loansForUser = async ( + config: ApiConfig, + paymentKeys: string[], + currency: Currency = "USD", + ): Promise> => { + const result = await loans(config, { paymentKeys, perPage: 100 }, currency); + return result.type === "ok" ? Result.ok(result.value.results) : result; + }; + } + + // ============================================================================ + // Utilities + // ============================================================================ + + /** Get the transaction hash from a CBOR-encoded transaction */ + export const getTxHash = (txCborHex: string): string => { + const decoded = cbor.decode(Buffer.from(txCborHex, "hex")); + const body = decoded[0]; + const bodyHex = Buffer.from(cbor.encode(body)).toString("hex"); + return blake2b256(Buffer.from(bodyHex, "hex")); + }; + + /** Sign a Liqwid transaction with a private key */ + export const signTx = (txCborHex: string, privateKey: PrivateKey): string => { + const txHash = getTxHash(txCborHex); + const ECSL = RustModule.getE; + const witnessSet = ECSL.TransactionWitnessSet.new(); + const vkeyWitnesses = ECSL.Vkeywitnesses.new(); + const pKey = privateKey.toECSL(); + const cslTxHash = ECSL.TransactionHash.from_hex(txHash); + const vKey = ECSL.make_vkey_witness(cslTxHash, pKey); + vkeyWitnesses.add(vKey); + witnessSet.set_vkeys(vkeyWitnesses); + return witnessSet.to_hex(); + }; + + /** Create an API config helper */ + export const createConfig = (networkEnv: NetworkEnvironment, clientEndpoint?: string): ApiConfig => ({ + networkEnv, + clientEndpoint, + }); +} From 5d8e8eb4e6d71a5546cb124269b1d5ae59224dcc Mon Sep 17 00:00:00 2001 From: tony Date: Thu, 5 Feb 2026 15:55:10 +0700 Subject: [PATCH 19/35] buy more --- .../src/api/state-machine.ts | 32 +++++++++-- .../src/services/position-service.ts | 55 +++++++++++++------ 2 files changed, 67 insertions(+), 20 deletions(-) diff --git a/apps/long-short-backend/src/api/state-machine.ts b/apps/long-short-backend/src/api/state-machine.ts index 3fcfe8c..bfd7105 100644 --- a/apps/long-short-backend/src/api/state-machine.ts +++ b/apps/long-short-backend/src/api/state-machine.ts @@ -61,7 +61,10 @@ export namespace StateMachine { export const handleLongBuy = async (options: HandleBuildTxOptions): Promise => { const { order, marketConfig, userAddress, networkEnv, utxos } = options; - invariant(order.orderType === LongOrderType.LONG_BUY, "Invalid order type for handleLongBuy"); + invariant( + order.orderType === LongOrderType.LONG_BUY || order.orderType === LongOrderType.LONG_BUY_MORE, + "Invalid order type for handleLongBuy", + ); invariant(order.assetIn, "assetIn is required for LONG_BUY order"); invariant(order.amountIn, "amountIn is required for LONG_BUY order"); invariant(order.assetOut, "assetOut is required for LONG_BUY order"); @@ -204,6 +207,7 @@ export namespace StateMachine { [LongOrderType.LONG_BUY]: handleLongBuy, [LongOrderType.LONG_SUPPLY]: handleLongSupply, [LongOrderType.LONG_BORROW]: handleLongBorrow, + [LongOrderType.LONG_BUY_MORE]: handleLongBuy, // Reuse handleLongBuy }; // Common waiting result type for all waiting functions @@ -215,6 +219,11 @@ export namespace StateMachine { assetIn: string; amountIn: string; assetOut: string; + } + | { + isConfirmed: true; + isFinal: true; + positionStatus: PositionStatus; }; export type WaitingOptions = { @@ -222,6 +231,8 @@ export namespace StateMachine { txHash: string; userAddress: Address; cardanoscanProvider: CardanoscanProvider; + /** Current order type being waited on */ + orderType: string; /** Order output index (used for LONG_BUY to check if output is spent) */ orderOutputIndex?: number; /** Asset out from order (used for LONG_BUY to find the received token) */ @@ -231,11 +242,12 @@ export namespace StateMachine { }; /** - * Wait for LONG_BUY order output to be spent (consumed by DEX) - * and prepare the next LONG_SUPPLY order details + * Wait for LONG_BUY or LONG_BUY_MORE order output to be spent (consumed by DEX) + * - For LONG_BUY: prepare the next LONG_SUPPLY order details + * - For LONG_BUY_MORE: this is the final step, position becomes OPEN */ export const waitingLongBuy = async (options: WaitingOptions): Promise => { - const { marketConfig, txHash, orderOutputIndex, userAddress, cardanoscanProvider, assetOut } = options; + const { marketConfig, txHash, orderOutputIndex, userAddress, cardanoscanProvider, assetOut, orderType } = options; invariant(orderOutputIndex !== undefined, "orderOutputIndex is required for waitingLongBuy"); invariant(assetOut, "assetOut is required for waitingLongBuy"); @@ -259,6 +271,17 @@ export namespace StateMachine { const matchingToken = output.tokens.find((token) => token.assetId === assetOutUnit); if (matchingToken) { const amountOut = BigInt(matchingToken.value); + + // For LONG_BUY_MORE, this is the final step - position becomes OPEN + if (orderType === LongOrderType.LONG_BUY_MORE) { + return { + isConfirmed: true, + isFinal: true, + positionStatus: PositionStatus.OPEN, + }; + } + + // For LONG_BUY, transition to LONG_SUPPLY return { isConfirmed: true, nextOrderType: LongOrderType.LONG_SUPPLY, @@ -350,5 +373,6 @@ export namespace StateMachine { [LongOrderType.LONG_BUY]: waitingLongBuy, [LongOrderType.LONG_SUPPLY]: waitingLongSupply, [LongOrderType.LONG_BORROW]: waitingLongBorrow, + [LongOrderType.LONG_BUY_MORE]: waitingLongBuy, // Reuse waitingLongBuy }; } diff --git a/apps/long-short-backend/src/services/position-service.ts b/apps/long-short-backend/src/services/position-service.ts index a2e4e1d..ace020e 100644 --- a/apps/long-short-backend/src/services/position-service.ts +++ b/apps/long-short-backend/src/services/position-service.ts @@ -164,6 +164,7 @@ export class PositionService { txHash: waitingOrder.createdTxId, userAddress: Address.fromBech32(userAddress), cardanoscanProvider: this.cardanoscanProvider, + orderType: waitingOrder.orderType, orderOutputIndex: waitingOrder.createdTxIndex ?? 0, assetOut: Asset.fromString(waitingOrder.assetOut), positionAmountIn: position.amountIn, @@ -172,30 +173,52 @@ export class PositionService { const waitingResult = await waitingFn(waitingOptions); if (waitingResult.isConfirmed) { - const transitionResult = await OrderRepository.transitionToNextOrder(this.db, { - currentOrderId: waitingOrder.id, - positionId: waitingOrder.positionId, - nextOrderType: waitingResult.nextOrderType, - assetIn: waitingResult.assetIn, - amountIn: waitingResult.amountIn, - assetOut: waitingResult.assetOut, - }); - - if (!transitionResult.success) { - logger.error("Failed to transition to next order", { error: transitionResult.error }); - return { success: false, error: transitionResult.error }; + // Check if this is a transition state (has nextOrderType) + if ("nextOrderType" in waitingResult) { + const transitionResult = await OrderRepository.transitionToNextOrder(this.db, { + currentOrderId: waitingOrder.id, + positionId: waitingOrder.positionId, + nextOrderType: waitingResult.nextOrderType, + assetIn: waitingResult.assetIn, + amountIn: waitingResult.amountIn, + assetOut: waitingResult.assetOut, + }); + + if (!transitionResult.success) { + logger.error("Failed to transition to next order", { error: transitionResult.error }); + return { success: false, error: transitionResult.error }; + } + + logger.info("Order completed, transitioned to next order", { + currentOrderId: waitingOrder.id, + currentOrderType: waitingOrder.orderType, + nextOrderId: transitionResult.nextOrder.id, + nextOrderType: waitingResult.nextOrderType, + }); + + return { + success: false, + error: `${waitingOrder.orderType} completed. ${waitingResult.nextOrderType} order ready. Call build-tx again to continue.`, + }; } - logger.info("Order completed, transitioned to next order", { + // This is the final state (no more orders to process) + // Update position status to OPEN + await PositionRepository.updatePositionStatus(this.db, position.id, waitingResult.positionStatus); + + // Set current order waiting = false + await OrderRepository.setOrderWaiting(this.db, waitingOrder.id, false); + + logger.info("Position completed, status updated to OPEN", { + positionId: position.id, currentOrderId: waitingOrder.id, currentOrderType: waitingOrder.orderType, - nextOrderId: transitionResult.nextOrder.id, - nextOrderType: waitingResult.nextOrderType, + newStatus: waitingResult.positionStatus, }); return { success: false, - error: `${waitingOrder.orderType} completed. ${waitingResult.nextOrderType} order ready. Call build-tx again to continue.`, + error: `${waitingOrder.orderType} completed. Position is now ${waitingResult.positionStatus}.`, }; } else { return { From 63afdc4ff1d4c741e1b6235bc5536e0c9d253f16 Mon Sep 17 00:00:00 2001 From: tony Date: Fri, 6 Feb 2026 09:57:35 +0700 Subject: [PATCH 20/35] adding position/close --- .../src/api/routes/position.ts | 53 +++++++++ apps/long-short-backend/src/api/schemas.ts | 19 ++++ .../src/api/state-machine.ts | 11 +- apps/long-short-backend/src/constants.ts | 1 + .../src/repository/order-repository.ts | 38 ++++++- .../src/services/position-service.ts | 101 ++++++++++++++++-- 6 files changed, 212 insertions(+), 11 deletions(-) diff --git a/apps/long-short-backend/src/api/routes/position.ts b/apps/long-short-backend/src/api/routes/position.ts index b6317cf..a933c85 100644 --- a/apps/long-short-backend/src/api/routes/position.ts +++ b/apps/long-short-backend/src/api/routes/position.ts @@ -7,10 +7,14 @@ import { ApiHelper } from "../helper"; import { type AuthenBuildTxBodyType, AuthenBuildTxBodyTypeSchema, + type AuthenClosePositionBodyType, + AuthenClosePositionBodyTypeSchema, type AuthenCreatePositionBodyType, AuthenCreatePositionBodyTypeSchema, BuildTxResponseSchema, type BuildTxResponseType, + ClosePositionResponseSchema, + type ClosePositionResponseType, CreatePositionResponseSchema, type CreatePositionResponseType, ErrorResponseSchema, @@ -181,4 +185,53 @@ export function registerPositionRoutes(fastify: FastifyInstance, positionService }); }, ); + + // POST /position/close + fastify.post<{ + Body: AuthenClosePositionBodyType; + Reply: ClosePositionResponseType; + }>( + API_ENDPOINTS.POSITION_CLOSE, + { + schema: { + body: AuthenClosePositionBodyTypeSchema, + response: { + 200: ClosePositionResponseSchema, + 400: ErrorResponseSchema, + 401: ErrorResponseSchema, + }, + }, + }, + async (request, reply) => { + const { data, user_address, witness } = request.body; + const { market_id } = data; + + // Authenticate request + const authResult = ApiHelper.authenticate(data, user_address, witness); + if (!authResult.success) { + return reply.status(401).send({ + success: false, + error: authResult.error, + }); + } + + // Close position via service + const result = await positionService.closePosition({ + userAddress: user_address, + marketId: market_id, + }); + + if (!result.success) { + return reply.status(400).send({ + success: false, + error: result.error, + }); + } + + return reply.status(200).send({ + success: true, + data: positionToResponse(result.position), + }); + }, + ); } diff --git a/apps/long-short-backend/src/api/schemas.ts b/apps/long-short-backend/src/api/schemas.ts index 1a8a1d2..4d2b40b 100644 --- a/apps/long-short-backend/src/api/schemas.ts +++ b/apps/long-short-backend/src/api/schemas.ts @@ -102,6 +102,25 @@ export const BuildTxResponseSchema = Type.Object({ export type BuildTxResponseType = Static; +// Close position schemas +export const ClosePositionDataSchema = Type.Object({ + market_id: Type.String({ minLength: 1, description: "Market ID (e.g., ADA-MIN)" }), +}); + +export type ClosePositionDataType = Static; + +export const AuthenClosePositionBodyTypeSchema = AuthenCommonSchema(ClosePositionDataSchema); + +export type AuthenClosePositionBodyType = AuthenCommonType; + +export const ClosePositionResponseSchema = Type.Object({ + success: Type.Boolean(), + data: Type.Optional(PositionResponseSchema), + error: Type.Optional(Type.String()), +}); + +export type ClosePositionResponseType = Static; + // Error response export const ErrorResponseSchema = Type.Object({ success: Type.Literal(false), diff --git a/apps/long-short-backend/src/api/state-machine.ts b/apps/long-short-backend/src/api/state-machine.ts index bfd7105..8faece7 100644 --- a/apps/long-short-backend/src/api/state-machine.ts +++ b/apps/long-short-backend/src/api/state-machine.ts @@ -27,10 +27,9 @@ export namespace StateMachine { LONG_SUPPLY = "LONG_SUPPLY", LONG_BORROW = "LONG_BORROW", LONG_BUY_MORE = "LONG_BUY_MORE", - LONG_SUPPLY_MORE = "LONG_SUPPLY_MORE", - LONG_WITHDRAW = "LONG_WITHDRAW", LONG_SELL = "LONG_SELL", LONG_REPAY = "LONG_REPAY", + LONG_WITHDRAW = "LONG_WITHDRAW", } export type BuiltResult = { @@ -219,11 +218,15 @@ export namespace StateMachine { assetIn: string; amountIn: string; assetOut: string; + /** Amount received from this order (to update order.amount_out) */ + amountOut: string; } | { isConfirmed: true; isFinal: true; positionStatus: PositionStatus; + /** Amount received from this order (to update order.amount_out) */ + amountOut: string; }; export type WaitingOptions = { @@ -278,6 +281,7 @@ export namespace StateMachine { isConfirmed: true, isFinal: true, positionStatus: PositionStatus.OPEN, + amountOut: amountOut.toString(), }; } @@ -288,6 +292,7 @@ export namespace StateMachine { assetIn: assetOut.toString(), amountIn: amountOut.toString(), assetOut: marketConfig.collateralMarketId, + amountOut: amountOut.toString(), }; } } @@ -328,6 +333,7 @@ export namespace StateMachine { assetIn: marketConfig.assetBQTokenRaw, amountIn: amountReceived.toString(), assetOut: marketConfig.assetA.toString(), + amountOut: amountReceived.toString(), }; } } @@ -362,6 +368,7 @@ export namespace StateMachine { assetIn: marketConfig.assetA.toString(), amountIn: amountBorrow.toString(), assetOut: marketConfig.assetB.toString(), + amountOut: amountBorrow.toString(), }; } diff --git a/apps/long-short-backend/src/constants.ts b/apps/long-short-backend/src/constants.ts index 1eeb875..242c6a3 100644 --- a/apps/long-short-backend/src/constants.ts +++ b/apps/long-short-backend/src/constants.ts @@ -4,6 +4,7 @@ export const API_ENDPOINTS = { LIQWID_SUBMIT: "/liqwid/submit", METADATA: "/metadata", POSITION_BUILD_TX: "/position/build-tx", + POSITION_CLOSE: "/position/close", POSITION_CREATE: "/position/create", POSITION_GET: "/position/get", }; diff --git a/apps/long-short-backend/src/repository/order-repository.ts b/apps/long-short-backend/src/repository/order-repository.ts index 2a64949..08650d4 100644 --- a/apps/long-short-backend/src/repository/order-repository.ts +++ b/apps/long-short-backend/src/repository/order-repository.ts @@ -186,6 +186,32 @@ export namespace OrderRepository { await db.updateTable("order").set({ waiting }).where("id", "=", orderId.toString()).execute(); } + /** + * Update order amount_out after transaction is confirmed + */ + export async function updateOrderAmountOut( + db: Kysely | Transaction, + orderId: bigint, + amountOut: string, + ): Promise { + await db.updateTable("order").set({ amount_out: amountOut }).where("id", "=", orderId.toString()).execute(); + } + + /** + * Complete an order: set amount_out and waiting = false + */ + export async function completeOrder( + db: Kysely | Transaction, + orderId: bigint, + amountOut: string, + ): Promise { + await db + .updateTable("order") + .set({ amount_out: amountOut, waiting: false }) + .where("id", "=", orderId.toString()) + .execute(); + } + /** * Get order by position ID and order type */ @@ -220,6 +246,8 @@ export namespace OrderRepository { assetIn: string; amountIn: string; assetOut: string; + /** Amount out of current order (to update order.amount_out) */ + amountOut: string; }; export type TransitionToNextOrderResult = { success: true; nextOrder: Order } | { success: false; error: string }; @@ -227,14 +255,15 @@ export namespace OrderRepository { /** * Transition from current order to next order: * 1. Find the next order by position and type - * 2. Update next order with assetIn, amountIn, assetOut - * 3. Set current order waiting = false + * 2. Update current order amount_out (= next order amountIn) + * 3. Update next order with assetIn, amountIn, assetOut + * 4. Set current order waiting = false */ export async function transitionToNextOrder( db: Kysely | Transaction, params: TransitionToNextOrderParams, ): Promise { - const { currentOrderId, positionId, nextOrderType, assetIn, amountIn, assetOut } = params; + const { currentOrderId, positionId, nextOrderType, assetIn, amountIn, assetOut, amountOut } = params; // Find the next order const nextOrder = await getOrderByPositionAndType(db, positionId, nextOrderType); @@ -245,6 +274,9 @@ export namespace OrderRepository { }; } + // Update current order amount_out + await updateOrderAmountOut(db, currentOrderId, amountOut); + // Update next order details await updateOrderNextDetails(db, nextOrder.id, assetIn, amountIn, assetOut); diff --git a/apps/long-short-backend/src/services/position-service.ts b/apps/long-short-backend/src/services/position-service.ts index ace020e..5033d59 100644 --- a/apps/long-short-backend/src/services/position-service.ts +++ b/apps/long-short-backend/src/services/position-service.ts @@ -29,6 +29,13 @@ export type BuildTxResult = | { success: true; waiting: true; orderType: string; message: string } | { success: false; error: string }; +export type ClosePositionInput = { + userAddress: string; + marketId: string; +}; + +export type ClosePositionResult = { success: true; position: Position } | { success: false; error: string }; + export class PositionService { constructor( private readonly db: Kysely, @@ -95,7 +102,6 @@ export class PositionService { assetIn: marketConfig.assetA.toString(), amountIn: pos.amountIn, assetOut: marketConfig.assetB.toString(), - amountOut: "1", }, { positionId: pos.id, @@ -182,6 +188,7 @@ export class PositionService { assetIn: waitingResult.assetIn, amountIn: waitingResult.amountIn, assetOut: waitingResult.assetOut, + amountOut: waitingResult.amountOut, }); if (!transitionResult.success) { @@ -203,17 +210,19 @@ export class PositionService { } // This is the final state (no more orders to process) - // Update position status to OPEN - await PositionRepository.updatePositionStatus(this.db, position.id, waitingResult.positionStatus); - - // Set current order waiting = false - await OrderRepository.setOrderWaiting(this.db, waitingOrder.id, false); + await this.db.transaction().execute(async (trx) => { + // Update position status + await PositionRepository.updatePositionStatus(trx, position.id, waitingResult.positionStatus); + // Complete order: set amount_out and waiting = false + await OrderRepository.completeOrder(trx, waitingOrder.id, waitingResult.amountOut); + }); logger.info("Position completed, status updated to OPEN", { positionId: position.id, currentOrderId: waitingOrder.id, currentOrderType: waitingOrder.orderType, newStatus: waitingResult.positionStatus, + amountOut: waitingResult.amountOut, }); return { @@ -373,4 +382,84 @@ export class PositionService { async getOpenPositionByUser(userAddress: string): Promise { return PositionRepository.getOpenPositionByUser(this.db, userAddress); } + + async closePosition(input: ClosePositionInput): Promise { + const { userAddress, marketId } = input; + + // Validate market + if (!isSupportedMarket(marketId)) { + return { success: false, error: `Market "${marketId}" is not supported or disabled` }; + } + + const marketConfig = getMarketConfig(marketId); + if (!marketConfig) { + return { success: false, error: `Market "${marketId}" configuration not found` }; + } + + // Check if user has an open position for this market + const position = await PositionRepository.getOpenPositionByUserAndMarket(this.db, userAddress, marketId); + + if (!position) { + return { success: false, error: "No open position found for this market" }; + } + + // Check if position is OPEN (only OPEN positions can be closed) + if (position.status !== StateMachine.PositionStatus.OPEN) { + return { + success: false, + error: `Position is in "${position.status}" status. Only OPEN positions can be closed.`, + }; + } + + // Get the LONG_BUY_MORE order to get the amountOut (total asset B received) + const longBuyMoreOrder = await OrderRepository.getOrderByPositionAndType( + this.db, + position.id, + StateMachine.LongOrderType.LONG_BUY_MORE, + ); + if (!longBuyMoreOrder || !longBuyMoreOrder.amountOut) { + return { success: false, error: "LONG_BUY_MORE order not found or amountOut not set" }; + } + + // Execute transaction: update position status + create 3 closing orders + const updatedPosition = await this.db.transaction().execute(async (trx) => { + // Update position status to CLOSING + await PositionRepository.updatePositionStatus(trx, position.id, StateMachine.PositionStatus.CLOSING); + + // Create 3 LONG closing orders: LONG_SELL, LONG_REPAY, LONG_WITHDRAW + await OrderRepository.createOrders(trx, [ + { + positionId: position.id, + orderType: StateMachine.LongOrderType.LONG_SELL, + assetIn: marketConfig.assetB.toString(), + amountIn: longBuyMoreOrder.amountOut, + assetOut: marketConfig.assetA.toString(), + }, + { + positionId: position.id, + orderType: StateMachine.LongOrderType.LONG_REPAY, + }, + { + positionId: position.id, + orderType: StateMachine.LongOrderType.LONG_WITHDRAW, + }, + ]); + + // Return updated position + return { + ...position, + status: StateMachine.PositionStatus.CLOSING, + }; + }); + + logger.info("Position close initiated", { + positionId: position.id, + userAddress, + marketId, + previousStatus: position.status, + newStatus: StateMachine.PositionStatus.CLOSING, + }); + + return { success: true, position: updatedPosition }; + } } From b1196f7395f23d2fd8b4ac3a18ee523c4bc3745e Mon Sep 17 00:00:00 2001 From: tony Date: Fri, 6 Feb 2026 11:31:27 +0700 Subject: [PATCH 21/35] complete long process --- .../src/api/state-machine.ts | 336 +++++++++++++++++- .../src/services/position-service.ts | 35 ++ .../src/liqwid-provider-v2.ts | 31 ++ 3 files changed, 396 insertions(+), 6 deletions(-) diff --git a/apps/long-short-backend/src/api/state-machine.ts b/apps/long-short-backend/src/api/state-machine.ts index 8faece7..33f30a2 100644 --- a/apps/long-short-backend/src/api/state-machine.ts +++ b/apps/long-short-backend/src/api/state-machine.ts @@ -30,6 +30,7 @@ export namespace StateMachine { LONG_SELL = "LONG_SELL", LONG_REPAY = "LONG_REPAY", LONG_WITHDRAW = "LONG_WITHDRAW", + LONG_SELL_ALL = "LONG_SELL_ALL", } export type BuiltResult = { @@ -56,6 +57,14 @@ export namespace StateMachine { utxos: string[]; /** Amount to borrow (used for LONG_BORROW) */ amountBorrow?: string; + /** Loan transaction ID (used for LONG_REPAY to identify the loan) */ + loanTxId?: string; + /** Loan output index (used for LONG_REPAY, format: "{txHash}-{outputIndex}") */ + loanOutputIndex?: number; + /** Collateral qToken amount (used for LONG_REPAY to redeem collateral) */ + collateralAmount?: string; + /** Supply amountOut from LONG_SUPPLY order (used for LONG_WITHDRAW) */ + supplyAmountOut?: string; }; export const handleLongBuy = async (options: HandleBuildTxOptions): Promise => { @@ -201,12 +210,170 @@ export namespace StateMachine { }; }; - /** Map of order types to their build transaction functions */ - export const MAP_BUILD_TX_FN: Record Promise> = { - [LongOrderType.LONG_BUY]: handleLongBuy, - [LongOrderType.LONG_SUPPLY]: handleLongSupply, - [LongOrderType.LONG_BORROW]: handleLongBorrow, - [LongOrderType.LONG_BUY_MORE]: handleLongBuy, // Reuse handleLongBuy + /** + * Build LONG_SELL or LONG_SELL_ALL transaction: Sell asset B for asset A (B_TO_A swap via DEX) + */ + export const handleLongSell = async (options: HandleBuildTxOptions): Promise => { + const { order, marketConfig, userAddress, networkEnv, utxos } = options; + invariant( + order.orderType === LongOrderType.LONG_SELL || order.orderType === LongOrderType.LONG_SELL_ALL, + "Invalid order type for handleLongSell", + ); + invariant(order.assetIn, "assetIn is required for LONG_SELL order"); + invariant(order.amountIn, "amountIn is required for LONG_SELL order"); + invariant(order.assetOut, "assetOut is required for LONG_SELL order"); + + const walletUtxos: Utxo[] = utxos.map((u) => Utxo.fromHex(u)); + const sender = Address.fromBech32(userAddress); + + const txb = DEXOrderTransaction.createBulkOrdersTx({ + networkEnv, + sender, + orderOptions: [ + { + lpAsset: Asset.fromString(marketConfig.ammLpAsset), + version: DexVersion.DEX_V2, + type: OrderV2StepType.SWAP_EXACT_IN, + assetIn: marketConfig.assetB, + amountIn: BigInt(order.amountIn), + minimumAmountOut: 1n, + direction: OrderV2Direction.B_TO_A, + killOnFailed: false, + isLimitOrder: false, + }, + ], + }); + const validTo = Date.now() + Duration.newMinutes(3).milliseconds; + txb.validToUnixTime(validTo); + + const { + txComplete, + txId, + newUtxoState: { changeUtxos }, + } = await txb.completeUnsafeForTxChaining({ + coinSelectionAlgorithm: CoinSelectionAlgorithm.SPEND_ALL, + walletUtxos, + changeAddress: sender, + provider: new EmulatorProvider(networkEnv), + }); + const txRaw = txComplete.complete(); + const ECSL = RustModule.getE; + const eTx = ECSL.Transaction.from_hex(txRaw); + const outputsHash = HashUtils.sha256(changeUtxos.map((u) => Utxo.toHex(u)).join(",")); + + safeFreeRustObjects(eTx); + + return { + txRaw, + txId: txId, + outputsHash, + validTo, + }; + }; + + /** + * Build LONG_REPAY transaction: Repay loan to Liqwid and redeem collateral + * Uses repayLoan API with loanUtxoId format: "{txHash}-{outputIndex}" + */ + export const handleLongRepay = async (options: HandleBuildTxOptions): Promise => { + const { order, marketConfig, userAddress, networkEnv, utxos, loanTxId, loanOutputIndex, collateralAmount } = + options; + invariant(order.orderType === LongOrderType.LONG_REPAY, "Invalid order type for handleLongRepay"); + invariant(order.assetIn, "assetIn is required for LONG_REPAY order"); + invariant(order.amountIn, "amountIn is required for LONG_REPAY order"); + invariant(loanTxId, "loanTxId is required for LONG_REPAY order"); + invariant(loanOutputIndex !== undefined, "loanOutputIndex is required for LONG_REPAY order"); + invariant(collateralAmount, "collateralAmount is required for LONG_REPAY order"); + + const apiConfig = LiqwidProviderV2.createConfig(networkEnv); + + // Format loanUtxoId as "{txHash}-{outputIndex}" + const loanUtxoId = `${loanTxId}-${loanOutputIndex}`; + + // Format collateral ID as "{MarketId}.{policyId}" + // assetBQTokenTicker is the market ID (e.g., "MIN") + // We need the policy ID from assetBQTokenRaw (format: "policyId.assetName" or "policyId") + const qTokenParts = marketConfig.assetBQTokenRaw.split("."); + const qTokenPolicyId = qTokenParts[0]; + const collateralId = `${marketConfig.borrowMarketIdLong}.${qTokenPolicyId}`; + + console.log("collateralId", collateralId); + console.log("amount colla", collateralAmount); + + const buildTxResult = await LiqwidProviderV2.Transactions.repayLoan(apiConfig, { + address: userAddress, + utxos, + loanUtxoId, + collaterals: [ + { + id: collateralId, + amount: Number(collateralAmount), + }, + ], + }); + + if (buildTxResult.type === "err") { + throw new Error(`Failed to build repay transaction: ${buildTxResult.error.message}`); + } + + const txRaw = buildTxResult.value; + const ECSL = RustModule.getE; + const eTx = ECSL.Transaction.from_hex(txRaw); + const txBody = eTx.body(); + const ttl = txBody.ttl(); + invariant(Maybe.isJust(ttl), "TTL must be set in the transaction body"); + safeFreeRustObjects(eTx, txBody); + + const validTo = getTimeFromSlotMagic(networkEnv, ttl); + const txId = LiqwidProviderV2.getTxHash(txRaw); + + return { + txRaw, + txId, + validTo: validTo.getTime(), + }; + }; + + /** + * Build LONG_WITHDRAW transaction: Withdraw underlying asset from Liqwid + * Uses the amountOut from LONG_SUPPLY order as the withdraw amount + */ + export const handleLongWithdraw = async (options: HandleBuildTxOptions): Promise => { + const { order, marketConfig, userAddress, networkEnv, utxos, supplyAmountOut } = options; + invariant(order.orderType === LongOrderType.LONG_WITHDRAW, "Invalid order type for handleLongWithdraw"); + invariant(order.assetIn, "assetIn is required for LONG_WITHDRAW order"); + invariant(order.amountIn, "amountIn is required for LONG_WITHDRAW order"); + invariant(supplyAmountOut, "supplyAmountOut is required for LONG_WITHDRAW order"); + + const apiConfig = LiqwidProviderV2.createConfig(networkEnv); + + const buildTxResult = await LiqwidProviderV2.Transactions.withdraw(apiConfig, { + address: userAddress, + utxos, + marketId: marketConfig.collateralMarketId as LiqwidProviderV2.MarketId, + amount: Number(supplyAmountOut), + }); + + if (buildTxResult.type === "err") { + throw new Error(`Failed to build withdraw transaction: ${buildTxResult.error.message}`); + } + + const txRaw = buildTxResult.value; + const ECSL = RustModule.getE; + const eTx = ECSL.Transaction.from_hex(txRaw); + const txBody = eTx.body(); + const ttl = txBody.ttl(); + invariant(Maybe.isJust(ttl), "TTL must be set in the transaction body"); + safeFreeRustObjects(eTx, txBody); + + const validTo = getTimeFromSlotMagic(networkEnv, ttl); + const txId = LiqwidProviderV2.getTxHash(txRaw); + + return { + txRaw, + txId, + validTo: validTo.getTime(), + }; }; // Common waiting result type for all waiting functions @@ -242,6 +409,8 @@ export namespace StateMachine { assetOut?: Asset; /** Position amount_in (used for LONG_BORROW to calculate borrow amount) */ positionAmountIn?: string; + /** Loan transaction ID (used for LONG_REPAY to identify the loan) */ + loanTxId?: string; }; /** @@ -375,11 +544,166 @@ export namespace StateMachine { return { isConfirmed: false }; }; + /** + * Wait for LONG_SELL or LONG_SELL_ALL order output to be spent (consumed by DEX) + * - For LONG_SELL: prepare the next LONG_REPAY order details + * - For LONG_SELL_ALL: this is the final step, position becomes CLOSED + */ + export const waitingLongSell = async (options: WaitingOptions): Promise => { + const { marketConfig, txHash, orderOutputIndex, userAddress, cardanoscanProvider, orderType } = options; + invariant(orderOutputIndex !== undefined, "orderOutputIndex is required for waitingLongSell"); + + const userAddressHex = userAddress.toHex(); + + // Search for the transaction that spent this UTXO + const spendingTx = await cardanoscanProvider.findTransactionHasSpent( + userAddress, + txHash, + orderOutputIndex, + 5, // pageSize + 10, // maxPage + ); + + if (spendingTx) { + // For LONG_SELL/LONG_SELL_ALL, assetOut is asset A (ADA), so we look for ADA in outputs + // ADA is represented as "lovelace" in the output value + for (const output of spendingTx.outputs) { + if (output.address === userAddressHex) { + // For ADA, the value is in the output.value field directly + const amountOut = BigInt(output.value); + + // For LONG_SELL_ALL, this is the final step - position becomes CLOSED + if (orderType === LongOrderType.LONG_SELL_ALL) { + return { + isConfirmed: true, + isFinal: true, + positionStatus: PositionStatus.CLOSED, + amountOut: amountOut.toString(), + }; + } + + // For LONG_SELL, transition to LONG_REPAY + return { + isConfirmed: true, + nextOrderType: LongOrderType.LONG_REPAY, + assetIn: marketConfig.assetA.toString(), + amountIn: amountOut.toString(), + assetOut: marketConfig.assetBQTokenRaw, // qToken to be redeemed + amountOut: amountOut.toString(), + }; + } + } + + throw new Error(`Order output spent (tx: ${spendingTx.hash}) but could not find matching output for user`); + } + + return { isConfirmed: false }; + }; + + /** + * Wait for LONG_REPAY transaction to be confirmed + * and prepare the next LONG_WITHDRAW order details + */ + export const waitingLongRepay = async (options: WaitingOptions): Promise => { + const { marketConfig, txHash, userAddress, cardanoscanProvider } = options; + + const txFoundOnChain = await cardanoscanProvider.findTransactionByHash(userAddress, txHash, 50, 10); + + if (txFoundOnChain) { + const userAddressHex = userAddress.toHex(); + const qTokenAsset = Asset.fromString(marketConfig.assetBQTokenRaw); + const qTokenUnit = qTokenAsset.toBlockFrostString(); + + // Find qToken received after repaying (collateral redeemed) + for (const output of txFoundOnChain.outputs) { + if (output.address === userAddressHex) { + if (output.tokens && output.tokens.length > 0) { + const matchingToken = output.tokens.find((token) => token.assetId === qTokenUnit); + if (matchingToken) { + const qTokenAmount = BigInt(matchingToken.value); + return { + isConfirmed: true, + nextOrderType: LongOrderType.LONG_WITHDRAW, + assetIn: marketConfig.assetBQTokenRaw, + amountIn: qTokenAmount.toString(), + assetOut: marketConfig.assetB.toString(), + amountOut: qTokenAmount.toString(), + }; + } + } + } + } + + throw new Error( + `LONG_REPAY tx confirmed (${txHash}) but could not find output with qToken ${marketConfig.assetBQTokenRaw}`, + ); + } + + return { isConfirmed: false }; + }; + + /** + * Wait for LONG_WITHDRAW transaction to be confirmed + * and prepare the next LONG_SELL_ALL order details + */ + export const waitingLongWithdraw = async (options: WaitingOptions): Promise => { + const { marketConfig, txHash, userAddress, cardanoscanProvider } = options; + + const txFoundOnChain = await cardanoscanProvider.findTransactionByHash(userAddress, txHash, 50, 10); + + if (txFoundOnChain) { + const userAddressHex = userAddress.toHex(); + const assetBUnit = marketConfig.assetB.toBlockFrostString(); + + // Find asset B received after withdraw + for (const output of txFoundOnChain.outputs) { + if (output.address === userAddressHex) { + if (output.tokens && output.tokens.length > 0) { + const matchingToken = output.tokens.find((token) => token.assetId === assetBUnit); + if (matchingToken) { + const amountOut = BigInt(matchingToken.value); + return { + isConfirmed: true, + nextOrderType: LongOrderType.LONG_SELL_ALL, + assetIn: marketConfig.assetB.toString(), + amountIn: amountOut.toString(), + assetOut: marketConfig.assetA.toString(), + amountOut: amountOut.toString(), + }; + } + } + } + } + + throw new Error( + `LONG_WITHDRAW tx confirmed (${txHash}) but could not find output with asset ${marketConfig.assetB.toString()}`, + ); + } + + return { isConfirmed: false }; + }; + + /** Map of order types to their build transaction functions */ + export const MAP_BUILD_TX_FN: Record Promise> = { + [LongOrderType.LONG_BUY]: handleLongBuy, + [LongOrderType.LONG_SUPPLY]: handleLongSupply, + [LongOrderType.LONG_BORROW]: handleLongBorrow, + [LongOrderType.LONG_BUY_MORE]: handleLongBuy, // Reuse handleLongBuy + [LongOrderType.LONG_SELL]: handleLongSell, + [LongOrderType.LONG_REPAY]: handleLongRepay, + [LongOrderType.LONG_WITHDRAW]: handleLongWithdraw, + [LongOrderType.LONG_SELL_ALL]: handleLongSell, // Reuse handleLongSell + }; + /** Map of order types to their waiting functions */ export const MAP_WAITING_FN: Record Promise> = { [LongOrderType.LONG_BUY]: waitingLongBuy, [LongOrderType.LONG_SUPPLY]: waitingLongSupply, [LongOrderType.LONG_BORROW]: waitingLongBorrow, [LongOrderType.LONG_BUY_MORE]: waitingLongBuy, // Reuse waitingLongBuy + [LongOrderType.LONG_SELL]: waitingLongSell, + [LongOrderType.LONG_REPAY]: waitingLongRepay, + [LongOrderType.LONG_WITHDRAW]: waitingLongWithdraw, + [LongOrderType.LONG_SELL_ALL]: waitingLongSell, // Reuse waitingLongSell }; } diff --git a/apps/long-short-backend/src/services/position-service.ts b/apps/long-short-backend/src/services/position-service.ts index 5033d59..696508f 100644 --- a/apps/long-short-backend/src/services/position-service.ts +++ b/apps/long-short-backend/src/services/position-service.ts @@ -352,6 +352,37 @@ export class PositionService { amountBorrow: position.amountBorrow, }; + // For LONG_REPAY, we need the loan transaction ID, output index, and collateral amount from LONG_BORROW + if (order.orderType === StateMachine.LongOrderType.LONG_REPAY) { + const borrowOrder = await OrderRepository.getOrderByPositionAndType( + this.db, + position.id, + StateMachine.LongOrderType.LONG_BORROW, + ); + if (!borrowOrder?.createdTxId) { + return { success: false, error: "LONG_BORROW order not found or not confirmed yet" }; + } + if (!borrowOrder.amountIn) { + return { success: false, error: "LONG_BORROW order amountIn (collateral amount) not set" }; + } + buildOptions.loanTxId = borrowOrder.createdTxId; + buildOptions.loanOutputIndex = borrowOrder.createdTxIndex ?? 0; + buildOptions.collateralAmount = borrowOrder.amountIn; // qToken amount used as collateral + } + + // For LONG_WITHDRAW, we need the amountOut from LONG_SUPPLY order + if (order.orderType === StateMachine.LongOrderType.LONG_WITHDRAW) { + const supplyOrder = await OrderRepository.getOrderByPositionAndType( + this.db, + position.id, + StateMachine.LongOrderType.LONG_SUPPLY, + ); + if (!supplyOrder?.amountOut) { + return { success: false, error: "LONG_SUPPLY order not found or amountOut not set" }; + } + buildOptions.supplyAmountOut = supplyOrder.amountOut; + } + const txResult = await buildFn(buildOptions); // Update order built_tx fields @@ -443,6 +474,10 @@ export class PositionService { positionId: position.id, orderType: StateMachine.LongOrderType.LONG_WITHDRAW, }, + { + positionId: position.id, + orderType: StateMachine.LongOrderType.LONG_SELL_ALL, + }, ]); // Return updated position diff --git a/packages/minswap-lending-market/src/liqwid-provider-v2.ts b/packages/minswap-lending-market/src/liqwid-provider-v2.ts index 1ca9901..bd8e3b5 100644 --- a/packages/minswap-lending-market/src/liqwid-provider-v2.ts +++ b/packages/minswap-lending-market/src/liqwid-provider-v2.ts @@ -97,6 +97,17 @@ export namespace LiqwidProviderV2 { loanPrincipalAndCollateralDeltasDestination?: CustomOutput; }; + /** + * Input for repaying a loan (full repay) + * Based on Liqwid's GetRepayTransactionInput + */ + export type RepayLoanTransactionInput = UserAddressInput & { + /** Loan transaction ID with output index, format: "{txHash}-{outputIndex}" */ + loanUtxoId: string; + /** Collaterals to redeem after repaying, format: [{id: "MarketId.policyId", amount: qTokenAmount}] */ + collaterals: BorrowCollateralInput[]; + }; + export type SubmitTransactionInput = { transaction: string; signature: string; @@ -526,6 +537,26 @@ export namespace LiqwidProviderV2 { return result.type === "ok" ? Result.ok(result.value.liqwid.transactions.modifyBorrow.cbor) : result; }; + /** + * Build a repay loan transaction (full repay with collateral redemption) + * Uses modifyBorrow internally with amount=0 to trigger full repay + */ + export const repayLoan = async ( + config: ApiConfig, + input: RepayLoanTransactionInput, + ): Promise> => { + const modifyBorrowInput: ModifyBorrowTransactionInput = { + address: input.address, + changeAddress: input.changeAddress, + otherAddresses: input.otherAddresses, + utxos: input.utxos, + txId: input.loanUtxoId, + amount: 0, // Full repay + collaterals: input.collaterals, + }; + return modifyBorrow(config, modifyBorrowInput); + }; + /** Submit a signed transaction */ export const submit = async (config: ApiConfig, input: SubmitTransactionInput): Promise> => { const result = await executeQuery( From e3e7079105216610aa3e93c890494d0899cdb849 Mon Sep 17 00:00:00 2001 From: tony Date: Tue, 10 Feb 2026 18:13:29 +0700 Subject: [PATCH 22/35] update config --- .claude/specs/felis-build-tx.md | 100 +++++++ .claude/specs/felis-cip.md | 51 ++++ .claude/specs/felis-dex-v1.md | 57 ++++ .claude/specs/felis-dex-v2.md | 110 ++++++++ .claude/specs/felis-ledger-core.md | 153 +++++++++++ .claude/specs/felis-ledger-utils.md | 76 ++++++ .claude/specs/felis-lending-market.md | 165 +++++++++++ .claude/specs/felis-tx-builder.md | 94 +++++++ .claude/specs/long-short-backend.md | 258 ++++++++++++++++++ ...0117151278_market_config_short_leverage.ts | 12 + .../.config/seeds/market_config.ts | 3 +- .../src/api/routes/metadata.ts | 3 +- apps/long-short-backend/src/api/schemas.ts | 3 +- .../src/api/state-machine.ts | 4 +- apps/long-short-backend/src/config/market.ts | 6 +- apps/long-short-backend/src/database/db.d.ts | 3 +- .../src/services/position-service.ts | 3 +- 17 files changed, 1092 insertions(+), 9 deletions(-) create mode 100644 .claude/specs/felis-build-tx.md create mode 100644 .claude/specs/felis-cip.md create mode 100644 .claude/specs/felis-dex-v1.md create mode 100644 .claude/specs/felis-dex-v2.md create mode 100644 .claude/specs/felis-ledger-core.md create mode 100644 .claude/specs/felis-ledger-utils.md create mode 100644 .claude/specs/felis-lending-market.md create mode 100644 .claude/specs/felis-tx-builder.md create mode 100644 .claude/specs/long-short-backend.md create mode 100644 apps/long-short-backend/.config/migrations/1770117151278_market_config_short_leverage.ts diff --git a/.claude/specs/felis-build-tx.md b/.claude/specs/felis-build-tx.md new file mode 100644 index 0000000..877dde3 --- /dev/null +++ b/.claude/specs/felis-build-tx.md @@ -0,0 +1,100 @@ +# @minswap/felis-build-tx + +DEX transaction builder. Depends on `felis-ledger-core`, `felis-ledger-utils`, `felis-tx-builder`, `felis-dex-v2`. + +**Location:** `packages/minswap-build-tx` + +## DEXOrderTransaction — DEX Order Building + +### Main Entry Point +```typescript +DEXOrderTransaction.createBulkOrdersTx(options: BulkOrdersOption): TxBuilder + +type BulkOrdersOption = { + networkEnv: NetworkEnvironment + sender: Address + orderOptions: MultiDEXOrderOptions[] // Array of orders to batch + outerTxb?: TxBuilder // Reuse existing builder + receiver?: Address // Optional alternate receiver +} +``` + +### Order Option Types +```typescript +type V2SwapExactInOptions = { + lpAsset: Asset; version: DexVersion.DEX_V2; + type: OrderV2StepType.SWAP_EXACT_IN; + assetIn: Asset; amountIn: bigint; + minimumAmountOut: bigint; direction: OrderV2Direction; + killOnFailed: boolean; isLimitOrder: boolean; +} + +// Also: V2SwapExactOutOptions, V2DepositOptions, V2WithdrawOptions, +// V2StopOptions, V2OCOOptions, V2ZapOutOptions, V2PartialSwapOptions, +// V2WithdrawImbalanceOptions, V2MultiRoutingOptions +``` + +### Helper Functions +```typescript +DEXOrderTransaction.buildOrderValue(option): Value // Calculate UTxO value needed +DEXOrderTransaction.buildV2OrderStep(option): OrderV2Step // Convert to Plutus step +DEXOrderTransaction.getOrderMetadata(option): string // Transaction label +``` + +## Djed — Stablecoin Protocol + +```typescript +namespace Djed { + getConfig(networkEnv): Config // Lazy singleton + getPoolData(poolUtxo): PoolData // ADA reserve, DJED/SHEN circulation + getOracleData(oracleUtxo): OracleData // Exchange rate, price bounds + + estimateMintShen(options): EstimateResult // Calculate with slippage + mintShen(options): TxBuilder // Build mint transaction + + namespace Rate { + shenAdaRate(params): BigNumber + shen2ada(options): BigNumber + ada2shen(options): BigNumber + } + + namespace DexFee { + getFee(amount, networkEnv): bigint // min(max(ceil(amount * pct), min), max) + } +} +``` + +## MetadataMessage — Transaction Labels + +```typescript +// DEX: DEX_MARKET_ORDER, DEX_LIMIT_ORDER, DEX_STOP_ORDER, DEX_OCO_ORDER +// Liquidity: DEX_DEPOSIT_ORDER, DEX_WITHDRAW_ORDER, DEX_ZAP_IN_ORDER +// Advanced: DEX_PARTIAL_SWAP_ORDER, DEX_ROUTING_ORDER, DEX_MIXED_ORDERS +// Farming: STAKE_LIQUIDITY_V2, HARVEST_V2 +``` + +## Usage Example +```typescript +const txb = DEXOrderTransaction.createBulkOrdersTx({ + networkEnv: NetworkEnvironment.MAINNET, + sender: Address.fromBech32("addr1..."), + orderOptions: [{ + lpAsset: Asset.fromString("..."), + version: DexVersion.DEX_V2, + type: OrderV2StepType.SWAP_EXACT_IN, + assetIn: ADA, + amountIn: 100_000_000n, + minimumAmountOut: 1n, + direction: OrderV2Direction.A_TO_B, + killOnFailed: false, + isLimitOrder: false, + }], +}); + +const result = await txb.complete({ + changeAddress: sender, + provider, + walletUtxos, + coinSelectionAlgorithm: CoinSelectionAlgorithm.MINSWAP, +}); +``` diff --git a/.claude/specs/felis-cip.md b/.claude/specs/felis-cip.md new file mode 100644 index 0000000..b5cefc7 --- /dev/null +++ b/.claude/specs/felis-cip.md @@ -0,0 +1,51 @@ +# @minswap/felis-cip + +Cardano Improvement Proposals implementation. Depends on `felis-ledger-core`, `felis-ledger-utils`. + +**Location:** `packages/cip` + +## Modules + +### Bip32 — HD Wallet Key Derivation +```typescript +namespace Bip32 { + deriveAddress({ bip32PublicKeyHex, deriveOffsets, networkEnv }): Address[] + genPubKeyHashes(accountKey): Set + filterUtxos(publicKey, utxos): Utxo[] + extractPublicKey(seed): CSLBip32PublicKey + extractBip32PrivateKey(seed): string + extractPrivateKey(options): PrivateKey +} +``` + +### Bip39 — Mnemonic Wallet Creation +```typescript +type BaseAddressWallet = { address: Address; rewardAddress: RewardAddress; paymentKey: PrivateKey; stakeKey: PrivateKey } +type EnterpriseAddressWallet = { address: Address; paymentKey: PrivateKey } + +baseAddressWalletFromSeed(seed, networkEnv, options?): BaseAddressWallet +enterpriseAddressWalletFromSeed(seed, networkEnv, options?): EnterpriseAddressWallet +baseWalletFromEntropy(entropyHex, networkId): BaseAddressWallet +``` + +### CIP-25 — NFT Metadata Standard +```typescript +type CIP25NFT = { asset: Asset; name: string; image: string; mediaType?: string; files?: CIP25File[] } +type CIP25Metadata = { [policyId: string]: { [assetName: string]: Omit } } +``` + +### CIP-68 — Token Standard (Reference NFTs) +```typescript +enum Cip68UserTokenLabel { NFT = "000de140", FT = "0014df10" } + +namespace CIP68 { + isRefNFT(asset): boolean + isNFT(asset): boolean + isFT(asset): boolean + isCip68(assetNameHex): boolean + fromDataHex(datum, label): Cip68UserTokenAsset + toDataHex(metadata): string + mintCip68Token(options): Cip68MintTokenResult + buildFTFromRefNFT(refNft): Maybe +} +``` diff --git a/.claude/specs/felis-dex-v1.md b/.claude/specs/felis-dex-v1.md new file mode 100644 index 0000000..989f6ec --- /dev/null +++ b/.claude/specs/felis-dex-v1.md @@ -0,0 +1,57 @@ +# @minswap/felis-dex-v1 + +Minswap DEX V1 protocol types. Depends on `felis-ledger-core`, `felis-ledger-utils`. + +**Location:** `packages/minswap-dex-v1` + +## Purpose +Handles DEX V1 order parsing and validation. V1 is the legacy DEX protocol — mainly used for backward compatibility and syncing historical orders. + +## Key Exports + +### Order +```typescript +class Order { + datum: OrderDatum + orderInfo: OrderInfo // { type: "SWAP", swapAsset, swapAmount, toAsset } + + static fromUtxo(utxo, datum, networkEnv): Result +} + +type OrderDatum = { + sender: Address + receiver: Address + step: OrderStep + batcherFee: bigint + outputADA: bigint +} +``` + +### StepType +```typescript +enum StepType { + SWAP_EXACT_IN + SWAP_EXACT_OUT + DEPOSIT + WITHDRAW + ZAP_IN + // ... others +} +``` + +### Scripts +Contains compiled Plutus V1 scripts for mainnet and testnet (order validators, vesting scripts). + +### Constants +DEX V1 configuration data for mainnet/testnet: +- Script hashes, addresses +- Factory tokens, pool tokens +- Batcher fee configurations + +## Usage +Primarily consumed by the syncer package for parsing V1 swap orders from blockchain transactions. + +```typescript +import { Order, StepType } from "@minswap/felis-dex-v1"; +const orderResult = Order.fromUtxo(utxo, datum, networkEnv); +``` diff --git a/.claude/specs/felis-dex-v2.md b/.claude/specs/felis-dex-v2.md new file mode 100644 index 0000000..ab400e4 --- /dev/null +++ b/.claude/specs/felis-dex-v2.md @@ -0,0 +1,110 @@ +# @minswap/felis-dex-v2 + +Minswap DEX V2 protocol types and calculations. Depends on `felis-ledger-core`, `felis-ledger-utils`. + +**Location:** `packages/minswap-dex-v2` + +## OrderV2 — DEX Orders + +### Order Types (OrderV2StepType) +```typescript +enum OrderV2StepType { + SWAP_EXACT_IN=0, SWAP_EXACT_OUT=1, STOP_LOSS=2, OCO=3, + DEPOSIT=4, WITHDRAW=5, ZAP_OUT=6, PARTIAL_SWAP=7, + WITHDRAW_IMBALANCE=8, SWAP_MULTI_ROUTING=9, DONATION=10 +} +enum OrderV2Direction { A_TO_B, B_TO_A } +enum DexVersion { DEX_V1, DEX_V2, STABLESWAP } +``` + +### OrderV2 Class +```typescript +class OrderV2 extends BaseUtxoModel { + static new(constr): Result + static fromUtxo(utxo): Result + owner: Address + lpAsset: Asset + canceller: Address + isExpired(currentSlot): boolean + getSwapAmount() / getDepositAmount() / getWithdrawAmount() +} +``` + +### OrderV2Datum (Plutus Serialization) +```typescript +type OrderV2Datum = { + author: OrderV2Author // canceller, refundReceiver, successReceiver + lpAsset: Asset + step: OrderV2Step // Discriminated union of 11 types + maxBatcherFee: bigint + expiredOptions?: OrderV2ExpirySetting +} +namespace OrderV2Datum { + fromPlutusJson(d) / toPlutusJson(d) + fromDataHex(hex, networkEnv) / toDataHex(d) +} +``` + +## PoolV2 — Liquidity Pools + +```typescript +class PoolV2 extends BaseUtxoModel { + static fromUtxo(utxo): Result + assetA: Asset; assetB: Asset; lpAsset: Asset + totalLiquidity: bigint + datumReserveA/B: bigint; valueReserveA/B: bigint + baseFee: { feeANumerator: bigint; feeBNumerator: bigint } // denominator=10000 + getDirectionByAssetIn(asset): OrderV2Direction + cloneNewPoolState(newReserves): PoolV2 + static computeLpAsset(assetA, assetB): Asset // SHA3 derived +} +``` + +## DexV2Calculation — Math Engine + +```typescript +namespace DexV2Calculation { + // Swaps + calculateSwapExactIn(options): { amountOut, newReserves, volume, fee } + calculateSwapExactOut(options): Result<{ necessaryAmountIn, ... }, Error> + calculateAmountOut({reserveIn, reserveOut, amountIn, tradingFeeNum}): bigint + calculateAmountIn({reserveIn, reserveOut, amountOut, tradingFeeNum}): bigint + + // Liquidity + calculateInitialLiquidity(amountA, amountB): bigint // sqrt(a*b) + calculateDeposit(options): { lpAmount, ... } + calculateWithdraw(options): { amountA, amountB } + calculateWithdrawAmount(options): { withdrawnA, withdrawnB } + + // Advanced + calculateZapOut(options): { swapAmount, amountOut } + calculatePartialSwap(options): Result<{ swapableAmount, amountOut }, Error> + calculateSwapMultiRouting(options): { amountOut, midPrice } + + // Analytics + calculatePriceImpact(options): Result // Percentage + calculateEarnedFeeIn(options): bigint +} +``` + +## Configuration + +```typescript +getDexV2Configs(networkEnv): DexV2Config +getDexV2PoolAddresses(networkEnv): string[] +getDefaultDexV2OrderAddress(networkEnv): string +getDexV2OrderScriptHash(networkEnv): string +buildDexV2OrderAddress(networkEnv, stakeCredential): string +``` + +## Batcher Fees +```typescript +BATCHER_FEE_DEX_V2: Record +// Swaps: 700_000, Deposits: 750_000, Routing: 900_000, etc. +``` + +## Error Handling +```typescript +class InvalidOrder { txIn; address; owner; error: OrderError } +enum ErrorCode { MISSING_DATUM_HASH, INVALID_PARAMETER, EXPIRED, ... } +``` diff --git a/.claude/specs/felis-ledger-core.md b/.claude/specs/felis-ledger-core.md new file mode 100644 index 0000000..362b043 --- /dev/null +++ b/.claude/specs/felis-ledger-core.md @@ -0,0 +1,153 @@ +# @minswap/felis-ledger-core + +Cardano blockchain primitives. Depends on `felis-ledger-utils`. + +**Location:** `packages/ledger-core` + +## Core Types + +### Address +```typescript +class Address { + bech32: string + static fromBech32(s): Address + static fromHex(s: CborHex): Address + toHex(): CborHex + toStakeAddress(): RewardAddress | null + toPubKeyHash(): Maybe + toPlutusJson(): PlutusData + static fromPlutusJson(d, networkEnv): Address + equals(other): boolean +} + +class RewardAddress extends Address { + isPubKey(): boolean + isScript(): boolean +} +``` + +### Asset +```typescript +class Asset { + currencySymbol: Bytes // 28-byte policy ID + tokenName: Bytes // 0-32 bytes + static fromString(s): Asset // "policyID.tokenName" or "lovelace" + static fromBlockFrostString(s): Asset + toBlockFrostString(): string + toString(): string + equals(other): boolean + compare(other): number +} +const ADA: Asset // lovelace sentinel +``` + +### Value (Multi-Asset) +```typescript +class Value { + get(asset): bigint + coin(): bigint // ADA amount + set(asset, x): Value + add(asset, x): Value + subtract(asset, x): Value + addAll(other): Value + subtractAll(other): Value + has(asset): boolean + assets(): Asset[] + canCover(other): boolean + isAdaOnly(): boolean + toHex(): CborHex + static fromHex(input): Value + getMinimumLovelace(isScript, networkEnv): bigint +} +``` + +### UTXO / TxIn / TxOut +```typescript +type Utxo = { input: TxIn; output: TxOut } +type TxIn = { txId: Bytes; index: number } +namespace TxIn { + fromString(s): TxIn // "txId#index" + toString(txIn): string + compare(a, b): number + equals(a, b): boolean + toPlutusJson / fromPlutusJson +} + +class TxOut { + address: Address + value: Value + datumSource: Maybe + scriptRef: Maybe + static newPubKeyOut({address, value}): TxOut + static newScriptOut({address, value, datumSource}): TxOut + getMinimumADA(networkEnv): bigint + addMinimumADAIfRequired(networkEnv): TxOut + getInlineDatum(): Result + toHex() / fromHex() +} + +enum DatumSourceType { + DATUM_HASH // Plutus V1 + OUTLINE_DATUM // Hash + datum in witness + INLINE_DATUM // Plutus V2+ (inline) +} +``` + +### Transaction +```typescript +type TxBody = { + inputs: Utxo[]; outputs: TxOut[]; fee: bigint; + mint: Value; withdrawals: Withdrawals; + validity?: ValidityRange; referenceInputs: Utxo[]; + requireSigners: PublicKeyHash[]; +} +type Transaction = { body: TxBody; witness: Witness; metadata: Record } +``` + +### Crypto +```typescript +class PrivateKey { toPublic(): PublicKey; toCSL(); toECSL() } +class PublicKey { key: Bytes; toPublicKeyHash(): PublicKeyHash } +class PublicKeyHash { keyHash: Bytes; equals(other): boolean } +``` + +### Bytes +```typescript +class Bytes { + hex: string; bytes: Uint8Array + static fromHex(s) / fromString(s) / fromBase64(s) / fromPlutusJson(d) + toHex() / toString() / toPlutusJson() + equals(other) / compare(other) / concat(other) +} +``` + +### PlutusData (Serialization) +```typescript +type PlutusData = PlutusConstr | PlutusList | PlutusMap | PlutusInt | PlutusBytes +PlutusConstr.unwrap(d, constraints): T +PlutusInt.unwrapToBigInt(d): bigint +PlutusBytes.unwrap(d): string // hex +``` + +### NetworkEnvironment +```typescript +enum NetworkEnvironment { MAINNET=764824073, TESTNET_PREVIEW=2, TESTNET_PREPROD=1 } +``` + +### XJSON — Type-Preserving JSON +```typescript +XJSON.stringify(a): string // Preserves bigint, BigNumber, Date, Bytes, Asset, Address, Value +XJSON.parse(s): T +``` + +### Slot/Time Conversion +```typescript +getTimeFromSlotMagic(network, slot): Date +getSlotFromTimeMagic(network, time): number +``` + +### Protocol Parameters +```typescript +DEFAULT_STABLE_PROTOCOL_PARAMS[networkEnv]: StableProtocolParams +// txFeeFixed, txFeePerByte, utxoCostPerByte, maxTxSize, etc. +``` diff --git a/.claude/specs/felis-ledger-utils.md b/.claude/specs/felis-ledger-utils.md new file mode 100644 index 0000000..c666997 --- /dev/null +++ b/.claude/specs/felis-ledger-utils.md @@ -0,0 +1,76 @@ +# @minswap/felis-ledger-utils + +Foundation utility library. All other packages depend on this. + +**Location:** `packages/ledger-utils` + +## Key Exports + +### Result — Error Handling +```typescript +Result.ok(value) // Create success +Result.err(error) // Create error +Result.isOk(r) // Type guard +Result.isError(r) // Type guard +Result.unwrap(r) // Extract or throw +Result.flatten(r) // [T, null] | [null, E] +``` + +### Maybe — Optional Values +```typescript +Maybe.isNothing(a) // null | undefined check +Maybe.isJust(a) // Value exists +Maybe.map(a, f) // Apply if exists +Maybe.unwrap(a, errMsg) // Extract or throw +``` + +### Duration — Time Handling +```typescript +Duration.newSeconds(x) / .newMinutes(x) / .newHours(x) / .newDays(x) +Duration.before(date, d) / .after(date, d) / .between(d1, d2) +``` + +### Crypto +```typescript +blake2b256(buffer): string // Blake2b-256 hash (hex) +blake2b224(buffer): string // Blake2b-224 hash (hex) +sha3(hex): string // SHA3-256 hash +``` + +### Bech32 +```typescript +encodeBech32(hrp, data): string +decodeBech32(s): { hrp, data } +``` + +### Hex Validation +```typescript +isValidHex(s): boolean +isValidBase64(s): boolean +``` + +### WASM Module Loader +```typescript +await RustModule.load() // Must call before any WASM ops +RustModule.get // Minswap CSL +RustModule.getE // Emurgo CSL (v13) +RustModule.getU // UPLC module +``` + +### Rust Object Management +```typescript +safeFreeRustObjects(...objs) // Safe cleanup (handles double-free) +unwrapRustVec(vec) // RustVec → T[] +unwrapRustMap(map) // RustMap → [K,V][] +``` + +### Branded Type +```typescript +type CborHex<_> = string // Phantom type for CBOR hex strings +``` + +### Error Utilities +```typescript +getErrorMessage(error): string // Safe stringify (handles BigInt) +parseIntSafe(str): number // Throws on NaN +``` diff --git a/.claude/specs/felis-lending-market.md b/.claude/specs/felis-lending-market.md new file mode 100644 index 0000000..a7ff340 --- /dev/null +++ b/.claude/specs/felis-lending-market.md @@ -0,0 +1,165 @@ +# @minswap/felis-lending-market + +Liqwid Finance lending protocol integration. Depends on `felis-ledger-core`, `felis-ledger-utils`. + +**Location:** `packages/minswap-lending-market` + +## LiqwidProviderV2 — Liqwid GraphQL API Client + +Type-safe namespace wrapping the Liqwid Finance V2 GraphQL API. All functions return `Result`. + +### Configuration +```typescript +type ApiConfig = { + networkEnv: NetworkEnvironment; + clientEndpoint?: string; // Browser proxy override +} + +// API endpoints: +// MAINNET: "https://v2.api.liqwid.finance/graphql" +// PREPROD: "https://v2.api.preprod.liqwid.dev/graphql" +// PREVIEW: "https://v2.api.preview.liqwid.dev/graphql" + +const config = LiqwidProviderV2.createConfig(NetworkEnvironment.MAINNET); +``` + +### Common Types +```typescript +type MarketId = "Ada" | "MIN" | "DJED" | "iUSD" | "SHEN" | "LQ" | "HUNT" | "WMT" | "LENFI" | "NIGHT" +type CollateralId = `${MarketId}.${string}` // e.g. "Ada.policyId..." +type Currency = "EUR" | "USD" | "GBP" | "CAD" | "BRL" | "JPY" | "VND" | "CZK" | "AUD" | "SGD" | "CHF" +type SupportedWallet = "ETERNL" | "BEGIN" + +type UserAddressInput = { + address: string; changeAddress?: string; + otherAddresses?: string[]; utxos: string[]; +} + +type BorrowCollateralInput = { id: string; tokenName?: string; amount: number } + +type Pagination = { + page: number; perPage: number; + pagesCount: number; totalCount: number; + results: T[]; +} +``` + +### Transactions Namespace +Builds unsigned transaction CBOR via GraphQL. Returns `Result` (CBOR hex). + +```typescript +namespace Transactions { + // Supply tokens to a lending market + supply(config, input: SupplyTransactionInput): Promise> + // SupplyTransactionInput = UserAddressInput & { marketId, amount, wallet?, mintedQTokensDestination? } + + // Withdraw tokens from a lending market + withdraw(config, input: WithdrawTransactionInput): Promise> + // WithdrawTransactionInput = UserAddressInput & { marketId, amount, wallet?, withdrawnUnderlyingDestination? } + + // Borrow against collateral (creates new loan) + borrow(config, input: BorrowTransactionInput): Promise> + // BorrowTransactionInput = UserAddressInput & { marketId, amount, collaterals[], principalDestination? } + + // Modify existing loan (borrow more or partial repay) + modifyBorrow(config, input: ModifyBorrowTransactionInput): Promise> + // ModifyBorrowTransactionInput = UserAddressInput & { txId, amount, collaterals[], redeemCollateral? } + + // Full repay loan (internally calls modifyBorrow with amount=0) + repayLoan(config, input: RepayLoanTransactionInput): Promise> + // RepayLoanTransactionInput = UserAddressInput & { loanUtxoId: "{txHash}-{outputIndex}", collaterals[] } + + // Submit signed transaction to Liqwid + submit(config, input: { transaction: string; signature: string }): Promise> +} +``` + +### Calculations Namespace +Pre-flight calculations for fee estimation and health factors. + +```typescript +namespace Calculations { + loan(config, input: LoanCalculationInput, currency?): Promise> + // Input: { market: MarketId, debt: number, collaterals: [{id, amount}] } + // Result: { healthFactor, maxBorrow, maxBorrowCap, batchingFee, protocolFee, + // protocolFeePercentage, collateral, collaterals: [{id, amount, LTV, healthFactor}] } + + supply(config, input: SupplyCalculationInput): Promise> + // Input: { marketId, amount, wallet? } + // Result: { batchingFee, supplyCap, walletFee } + + withdraw(config, input: WithdrawCalculationInput): Promise> + // Input: { marketId, amount, wallet? } + // Result: { batchingFee, walletFee, withdrawCap } + + netApy(config, input: NetApyInput): Promise> + // Input: { paymentKeys[], supplies: [{marketId, amount}], currency? } + // Result: { netApy, netApyLqRewards, borrowApy, totalBorrow, supplyApy, totalSupply } +} +``` + +### Data Namespace +Query market and loan data. + +```typescript +namespace Data { + markets(config, input?: MarketsInput, currency?): Promise, Error>> + // Market: { id, displayName, symbol, supply, borrow, liquidity, supplyAPY, borrowAPY, + // lqSupplyAPY, utilization, exchangeRate, batching, frozen, private, delisting, + // prime, loanOriginationFeePercentage, asset: Asset, receiptAsset: Asset } + + loans(config, input: LoansInput, currency?): Promise, Error>> + // Loan: { id, transactionId, transactionIndex, marketId, publicKey, amount, + // adjustedAmount, collateral, interest, APY, LTV, healthFactor, time, + // collaterals: LoanCollateral[], market: Market, asset: Asset } + + yieldEarned(config, input: YieldEarnedInput, currency?): Promise> + // Input: { addresses[], date?: { startTime, endTime } } + + market(config, marketId: MarketId, currency?): Promise> + loansForUser(config, paymentKeys: string[], currency?): Promise> +} +``` + +### Utilities +```typescript +// Get tx hash from CBOR-encoded transaction (blake2b256 of body) +getTxHash(txCborHex: string): string + +// Sign Liqwid transaction with private key, returns witness set hex +signTx(txCborHex: string, privateKey: PrivateKey): string + +// Create API config helper +createConfig(networkEnv: NetworkEnvironment, clientEndpoint?: string): ApiConfig +``` + +## Usage Example +```typescript +import { LiqwidProviderV2 } from "@minswap/felis-lending-market"; + +const config = LiqwidProviderV2.createConfig(NetworkEnvironment.MAINNET); + +// Supply ADA to lending market +const txResult = await LiqwidProviderV2.Transactions.supply(config, { + address: "addr1...", + utxos: ["utxoCbor1", "utxoCbor2"], + marketId: "Ada", + amount: 100_000_000, +}); + +if (txResult.type === "ok") { + const txCbor = txResult.value; + const signature = LiqwidProviderV2.signTx(txCbor, privateKey); + await LiqwidProviderV2.Transactions.submit(config, { transaction: txCbor, signature }); +} + +// Query user loans +const loansResult = await LiqwidProviderV2.Data.loansForUser(config, [paymentKeyHash]); + +// Calculate borrow health factor +const calcResult = await LiqwidProviderV2.Calculations.loan(config, { + market: "Ada", + debt: 50_000_000, + collaterals: [{ id: "Ada.policyId...", amount: 100 }], +}); +``` diff --git a/.claude/specs/felis-tx-builder.md b/.claude/specs/felis-tx-builder.md new file mode 100644 index 0000000..ae28a7c --- /dev/null +++ b/.claude/specs/felis-tx-builder.md @@ -0,0 +1,94 @@ +# @minswap/felis-tx-builder + +High-level Cardano transaction composition. Depends on `felis-ledger-core`, `felis-ledger-utils`, `felis-cip`. + +**Location:** `packages/tx-builder` + +## TxBuilder — Fluent Transaction Builder + +```typescript +const txb = new TxBuilder(networkEnv); + +// Inputs +txb.readFrom(...utxos) // Reference inputs (read-only) +txb.collectFromPubKey(...utxos) // Spend from pubkey +txb.collectFromPlutusContract(utxos, redeemer, datum?) // Spend from script + +// Outputs +txb.payTo(...outputs) // Payment outputs +txb.addSigner(address) / addSignerKey(keyHash) // Required signers + +// Minting +txb.mintAssets(value, redeemer?) // Mint/burn tokens + +// Time +txb.validFrom(slot) / validTo(slot) +txb.validFromUnixTime(ts) / validToUnixTime(ts) + +// Scripts +txb.attachValidator(validator) // Native/PlutusV1/V2/V3 + +// Metadata +txb.addMessageMetadata("msg", data) + +// Build +const result = await txb.complete({ + changeAddress, provider, walletUtxos, + coinSelectionAlgorithm: CoinSelectionAlgorithm.MINSWAP, +}); +``` + +## TxComplete — Signing & Assembly +```typescript +txComplete.signWithPrivateKey(...privateKeys) // Sign and assemble +txComplete.partialSignWithPrivateKey(...keys) // Get partial witness +txComplete.assemble(witnesses) // Assemble external witnesses +``` + +## Build Options +```typescript +type TxBuilderBuildOptions = { + changeAddress: Address; + provider: ITxBuilderProvider; // getUnstableProtocolParams() + walletUtxos: Utxo[]; + walletCollaterals?: Utxo[]; + coinSelectionAlgorithm: CoinSelectionAlgorithm; + extraFee?: bigint; +} +``` + +## CoinSelectionAlgorithm +```typescript +enum CoinSelectionAlgorithm { + MINSWAP // Smart selection + change splitting + SPEND_ALL // Single change output + SPEND_ALL_V2 // Enhanced spend-all +} +``` + +## Utilities +```typescript +// UTXO Selection +UtxoSelection.selectUtxos(required, available, splitChange, changeAddr, networkEnv) +UtxoSelection.selectCollaterals({walletCollaterals, walletUtxos, ...}) + +// Fee Calculation +TxBuilderUtils.maxTxSizeFee(networkEnv): bigint +TxBuilderUtils.calContractFee(networkEnv, exUnit): bigint +TxBuilderUtils.calReferenceInputsFee({inputs, referenceInputs, referenceFeeCfg}): bigint + +// Change Management +ChangeOutputBuilder.buildChangeOut({networkEnv, txDraft, changeAddress, walletUtxos, protocolParams}) + +// Transaction Chaining +TxDraft.extractUtxoState({txId, txDraft, changeAddress, walletUtxos}): UtxoState +``` + +## EmulatorProvider +Off-chain provider for testing (implements ITxBuilderProvider without blockchain). + +## Key Constants +``` +MAX_TOKEN_BUNDLE_SIZE = 20 +DEFAULT_COLLATERAL_AMOUNT = 5_000_000n (5 ADA) +``` diff --git a/.claude/specs/long-short-backend.md b/.claude/specs/long-short-backend.md new file mode 100644 index 0000000..96be50e --- /dev/null +++ b/.claude/specs/long-short-backend.md @@ -0,0 +1,258 @@ +# long-short-backend + +Leveraged long/short trading API. Integrates Minswap DEX with Liqwid lending protocol. + +**Location:** `apps/long-short-backend` + +## Database Schema + +### position +```sql +id bigserial PK +market_id varchar -- FK to market_config +user_address varchar -- Cardano bech32 address +side varchar -- "LONG" | "SHORT" +status varchar -- "PENDING" | "OPEN" | "CLOSING" | "CLOSED" +amount_in numeric -- Initial collateral amount +amount_borrow numeric -- Amount borrowed from Liqwid +created_at timestamp +closed_at timestamp? -- Set when CLOSED +-- Unique: one open position per user per market (closed_at IS NULL) +``` + +### order +```sql +id bigserial PK +position_id bigint -- References position.id +order_type varchar -- LONG_BUY, LONG_SUPPLY, etc. +asset_in varchar? -- Input asset (set when order is ready) +amount_in numeric? +asset_out varchar? +amount_out numeric? -- Set after output is consumed +created_tx_id varchar? -- Transaction hash confirmed on chain +created_tx_index integer? -- Output index +built_tx_id varchar? -- Transaction hash when built (not yet confirmed) +built_outputs_hash varchar? -- Hash of change outputs +built_valid_to timestamp? -- Transaction expiry +waiting boolean -- True when confirmed, waiting for output spend +``` + +### market_config +```sql +market_id varchar PK -- e.g. "ADA-MIN" +asset_a / asset_b varchar -- Trading pair assets +amm_lp_asset varchar -- Minswap LP token +asset_a_q_token_ticker varchar -- Liqwid qToken ticker (e.g. "Ada") +asset_a_q_token_raw varchar -- Liqwid qToken raw asset string +asset_b_q_token_ticker varchar -- e.g. "MIN" +asset_b_q_token_raw varchar +collateral_market_id varchar -- Liqwid CollateralId for supply +borrow_market_id_long varchar -- Liqwid MarketId for long borrow +borrow_market_id_short varchar -- Liqwid MarketId for short borrow +leverage numeric -- Leverage multiplier +min_collateral numeric -- Minimum collateral required +enable boolean +``` + +## Order State Machine + +### Long Position — Open (4 orders) +``` +LONG_BUY → Buy asset B with ADA via DEX swap +LONG_SUPPLY → Supply asset B to Liqwid, receive qToken +LONG_BORROW → Borrow ADA against qToken collateral +LONG_BUY_MORE → Buy more asset B with borrowed ADA → position OPEN +``` + +### Long Position — Close (4 orders) +``` +LONG_SELL → Sell asset B for ADA via DEX swap +LONG_REPAY → Repay loan to Liqwid, redeem qToken collateral +LONG_WITHDRAW → Withdraw underlying asset B from Liqwid +LONG_SELL_ALL → Sell all remaining asset B for ADA → position CLOSED +``` + +### Transaction Lifecycle +``` +1. Build tx → save built_tx_id, built_outputs_hash, built_valid_to +2. User signs & submits externally +3. Search chain → update created_tx_id, created_tx_index, waiting = true +4. Wait for output to be spent (DEX batcher or Liqwid) +5. Extract output amount → transition to next order, waiting = false +``` + +## API Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/health` | No | Health check | +| GET | `/metadata` | No | Get enabled market configs | +| GET | `/position/get?user_address=` | No | Get user's open positions | +| POST | `/position/create` | CIP-8 | Create new leveraged position | +| POST | `/position/build-tx` | CIP-8 | Build next transaction in order chain | +| POST | `/position/close` | CIP-8 | Close an open position | +| POST | `/liqwid/submit` | CIP-8 | Submit signed Liqwid transaction | + +### Authentication (CIP-8) +```typescript +// Request format for authenticated endpoints +{ + data: { /* payload */ }, + user_address: "addr1...", + witness: { + key: "a401...", // CBOR-encoded COSEKey + signature: "84582a..." // CBOR-encoded COSESign1 + } +} +// Backend SHA256-hashes JSON.stringify(data), verifies against signature +``` + +### Create Position Request +```typescript +{ data: { market_id: string; amount_in: string }, user_address, witness } +// Validates: market supported, amount >= min_collateral, no existing open position +// Creates position + 4 opening orders +// amount_borrow = amount_in * (leverage - 1) + 4_000_000n (fee buffer) +``` + +### Build Tx Request +```typescript +{ data: { position_id: string }, user_address, witness } +// Returns: { tx_raw: string; tx_id: string } or error message +// Handles: waiting check → unhandled order → chain search → build/rebuild +``` + +### Close Position Request +```typescript +{ data: { position_id: string }, user_address, witness } +// Validates: position exists, status OPEN, user owns it +// Creates 4 closing orders, sets status CLOSING +``` + +## State Machine Build Functions + +### DEX Orders (LONG_BUY, LONG_BUY_MORE, LONG_SELL, LONG_SELL_ALL) +```typescript +// Uses DEXOrderTransaction.createBulkOrdersTx() +// Direction: A_TO_B for buy, B_TO_A for sell +// Returns: { txRaw, txId, outputsHash, validTo } +``` + +### LONG_SUPPLY +```typescript +// Uses LiqwidProvider.getSupplyTransaction() (V1 for supply) +// Returns: { txRaw, txId, validTo } +``` + +### LONG_BORROW +```typescript +// Uses LiqwidProviderV2.Transactions.borrow() +// Collateral: qToken from LONG_SUPPLY step +// Returns: { txRaw, txId, validTo } +``` + +### LONG_REPAY +```typescript +// Uses LiqwidProviderV2.Transactions.repayLoan() +// loanUtxoId format: "{txHash}-{outputIndex}" +// Redeems qToken collateral +// Returns: { txRaw, txId, validTo } +``` + +### LONG_WITHDRAW +```typescript +// Uses LiqwidProviderV2.Transactions.withdraw() +// Amount: supplyAmountOut from LONG_SUPPLY order +// Returns: { txRaw, txId, validTo } +``` + +## Waiting Functions + +### DEX Order Waiting (LONG_BUY, LONG_SELL, etc.) +```typescript +// Uses CardanoscanProvider.findTransactionHasSpent(address, txHash, outputIndex) +// Extracts received token amount from spending transaction outputs +// Transition: completes current order, prepares next order with asset/amount +``` + +### Liqwid Order Waiting (LONG_SUPPLY, LONG_BORROW, etc.) +```typescript +// Uses CardanoscanProvider.findTransactionByHash(address, txHash) +// Extracts relevant output (qToken, borrowed amount, etc.) +// Transition: completes current order, prepares next order +``` + +## Repository Layer + +### PositionRepository +```typescript +createPosition(db, params): Promise +getPositionById(db, id): Promise +getOpenPositionByUser(db, address): Promise +getOpenPositionByUserAndMarket(db, address, marketId): Promise +getUserOpenPositions(db, address): Promise +getUserPositions(db, address, opts): Promise +updatePositionStatus(db, id, status): Promise // sets closed_at if CLOSED +``` + +### OrderRepository +```typescript +createOrder(db, params) / createOrders(db, params[]) +getOrdersByPositionId(db, positionId): Promise +getNextUnhandledOrder(db, positionId): Promise // asset_in != null, created_tx_id == null +getWaitingOrder(db, positionId): Promise // created_tx_id != null, waiting == true +updateOrderBuiltTx(db, id, { builtTxId, outputsHash, validTo }) +updateOrderCreatedTx(db, id, { txId, txIndex }) +transitionToNextOrder(db, currentId, nextId, { amountOut, assetIn, amountIn }) +completeOrder(db, id, amountOut) +``` + +### MarketConfigRepository +```typescript +getMarketConfigRowById(db, id): Promise +getMarketConfigRowByIdOrThrow(db, id): Promise +``` + +## Provider Layer + +### CardanoscanProvider +```typescript +constructor(baseUrl: string, apiKey: string) +findTransactionByHash(address, txHash, pageSize?, maxPage?): Promise +findTransactionHasSpent(address, txHash, outputIndex, pageSize?, maxPage?): Promise +getTransactionList(addressHex, pageNo, limit): Promise +// Uses address.toHex() for API, apiKey header, pageNo 1-indexed, limit max 50 +``` + +## Configuration + +### Environment Variables +``` +DATABASE_URL PostgreSQL connection (required) +CARDANOSCAN_API_KEY API key (required) +API_PORT Default: 9999 +API_HOST Default: "0.0.0.0" +NETWORK "mainnet" | "testnet" (default: "mainnet") +``` + +### Market Config Cache +```typescript +loadMarketConfigs(db) // Load from DB at startup +getEnabledMarketConfigs() // Get cached enabled markets +getMarketConfig(marketId) // Get single market config +isSupportedMarket(marketId) // Check if supported and enabled +reloadMarketConfigs(db) // Hot reload +``` + +## Key Files +``` +src/api/state-machine.ts -- Build/waiting functions per order type +src/services/position-service.ts -- Core business logic (create, buildTx, close) +src/api/routes/position.ts -- API route handlers +src/api/schemas.ts -- TypeBox request/response schemas +src/api/helper.ts -- CIP-8 authentication +src/repository/ -- Database access layer +src/provider/cardanoscan.ts -- On-chain transaction search +src/config/market.ts -- Market config cache +src/cmd/run-api.ts -- Entry point +``` diff --git a/apps/long-short-backend/.config/migrations/1770117151278_market_config_short_leverage.ts b/apps/long-short-backend/.config/migrations/1770117151278_market_config_short_leverage.ts new file mode 100644 index 0000000..387948d --- /dev/null +++ b/apps/long-short-backend/.config/migrations/1770117151278_market_config_short_leverage.ts @@ -0,0 +1,12 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "market_config" RENAME COLUMN "leverage" TO "long_leverage"`.execute(db); + await sql`ALTER TABLE "market_config" ADD COLUMN "short_leverage" NUMERIC NOT NULL DEFAULT 0`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "market_config" DROP COLUMN "short_leverage"`.execute(db); + await sql`ALTER TABLE "market_config" RENAME COLUMN "long_leverage" TO "leverage"`.execute(db); +} diff --git a/apps/long-short-backend/.config/seeds/market_config.ts b/apps/long-short-backend/.config/seeds/market_config.ts index 79a4bb7..3ceb2e8 100644 --- a/apps/long-short-backend/.config/seeds/market_config.ts +++ b/apps/long-short-backend/.config/seeds/market_config.ts @@ -23,7 +23,8 @@ export async function seed(db: Kysely): Promise { asset_b_q_token_ticker: "qMIN", asset_b_q_token_raw: "TODO_QMIN_ASSET", // Liqwid qMIN token collateral_market_id: "ADA", // Liqwid market ID for collateral - leverage: 2, + long_leverage: 1.5, + short_leverage: 0.5, min_collateral: "100000000", // 100 ADA in lovelace enable: true, }, diff --git a/apps/long-short-backend/src/api/routes/metadata.ts b/apps/long-short-backend/src/api/routes/metadata.ts index 4d6bda2..97554eb 100644 --- a/apps/long-short-backend/src/api/routes/metadata.ts +++ b/apps/long-short-backend/src/api/routes/metadata.ts @@ -14,7 +14,8 @@ function marketConfigToResponse(config: MarketConfig): MarketConfigResponseType asset_b_q_token_ticker: config.assetBQTokenTicker, asset_b_q_token_raw: config.assetBQTokenRaw, collateral_market_id: config.collateralMarketId, - leverage: config.leverage, + long_leverage: config.longLeverage, + short_leverage: config.shortLeverage, min_collateral: config.minCollateral.toString(), }; } diff --git a/apps/long-short-backend/src/api/schemas.ts b/apps/long-short-backend/src/api/schemas.ts index 4d2b40b..ec9ed09 100644 --- a/apps/long-short-backend/src/api/schemas.ts +++ b/apps/long-short-backend/src/api/schemas.ts @@ -164,7 +164,8 @@ export const MarketConfigResponseSchema = Type.Object({ asset_b_q_token_ticker: Type.String({ description: "Liqwid qToken ticker for asset B" }), asset_b_q_token_raw: Type.String({ description: "Liqwid qToken raw asset for asset B" }), collateral_market_id: Type.String({ description: "Liqwid market ID" }), - leverage: Type.Number({ description: "Leverage multiplier" }), + long_leverage: Type.Number({ description: "Long leverage multiplier" }), + short_leverage: Type.Number({ description: "Short leverage multiplier" }), min_collateral: Type.String({ description: "Minimum collateral in lovelace" }), }); diff --git a/apps/long-short-backend/src/api/state-machine.ts b/apps/long-short-backend/src/api/state-machine.ts index 33f30a2..7d16b1c 100644 --- a/apps/long-short-backend/src/api/state-machine.ts +++ b/apps/long-short-backend/src/api/state-machine.ts @@ -528,8 +528,8 @@ export namespace StateMachine { const txFoundOnChain = await cardanoscanProvider.findTransactionByHash(userAddress, txHash, 50, 10); if (txFoundOnChain) { - // Calculate borrow amount: position.amount_in * (market_config.leverage - 1) - const amountBorrow = BigInt(Math.floor(Number(positionAmountIn) * (marketConfig.leverage - 1))); + // Calculate borrow amount: position.amount_in * (leverage - 1) + const amountBorrow = BigInt(Math.floor(Number(positionAmountIn) * (marketConfig.longLeverage - 1))); return { isConfirmed: true, diff --git a/apps/long-short-backend/src/config/market.ts b/apps/long-short-backend/src/config/market.ts index 73c4b85..30d903e 100644 --- a/apps/long-short-backend/src/config/market.ts +++ b/apps/long-short-backend/src/config/market.ts @@ -17,7 +17,8 @@ export type MarketConfig = { collateralMarketId: string; borrowMarketIdLong: string; borrowMarketIdShort: string; - leverage: number; + longLeverage: number; + shortLeverage: number; minCollateral: bigint; enable: boolean; }; @@ -47,7 +48,8 @@ export async function loadMarketConfigs(db: Kysely): Promise; collateral_market_id: string; enable: Generated; - leverage: Numeric; + long_leverage: Numeric; market_id: string; min_collateral: Numeric; + short_leverage: Generated; } export interface Order { diff --git a/apps/long-short-backend/src/services/position-service.ts b/apps/long-short-backend/src/services/position-service.ts index 696508f..9bdb995 100644 --- a/apps/long-short-backend/src/services/position-service.ts +++ b/apps/long-short-backend/src/services/position-service.ts @@ -80,7 +80,8 @@ export class PositionService { } // Calculate amount_borrow = amount_in * (leverage - 1) - let amountBorrow = BigInt(Math.floor(Number(amountIn) * (marketConfig.leverage - 1))); + const leverage = side === StateMachine.PositionSide.LONG ? marketConfig.longLeverage : marketConfig.shortLeverage; + let amountBorrow = BigInt(Math.floor(Number(amountIn) * (leverage - 1))); if (side === StateMachine.PositionSide.LONG) { amountBorrow += 4_000_000n; //extra ada for fee } From 33d0e5b158a41b8221be5fe421f5bc507547c9bd Mon Sep 17 00:00:00 2001 From: tony Date: Wed, 11 Feb 2026 09:40:52 +0700 Subject: [PATCH 23/35] wip short --- apps/long-short-backend/src/api/schemas.ts | 2 +- .../src/api/state-machine.ts | 583 +++++++++++++++++- .../src/services/position-service.ts | 203 ++++-- 3 files changed, 718 insertions(+), 70 deletions(-) diff --git a/apps/long-short-backend/src/api/schemas.ts b/apps/long-short-backend/src/api/schemas.ts index ec9ed09..860ed01 100644 --- a/apps/long-short-backend/src/api/schemas.ts +++ b/apps/long-short-backend/src/api/schemas.ts @@ -28,7 +28,7 @@ export const PositionStatusSchema = Type.Union(Object.values(StateMachine.Positi export const CreatePositionDataSchema = Type.Object({ market_id: Type.String({ minLength: 1, description: "Market ID (e.g., ADA-MIN)" }), - side: Type.Literal("LONG", { description: "Position side (only LONG supported)" }), + side: Type.Union([Type.Literal("LONG"), Type.Literal("SHORT")], { description: "Position side" }), amount_in: Type.String({ pattern: "^[0-9]+$", description: "Collateral amount in lovelace" }), }); diff --git a/apps/long-short-backend/src/api/state-machine.ts b/apps/long-short-backend/src/api/state-machine.ts index 7d16b1c..9e31308 100644 --- a/apps/long-short-backend/src/api/state-machine.ts +++ b/apps/long-short-backend/src/api/state-machine.ts @@ -33,6 +33,15 @@ export namespace StateMachine { LONG_SELL_ALL = "LONG_SELL_ALL", } + export enum ShortOrderType { + SHORT_SUPPLY = "SHORT_SUPPLY", + SHORT_BORROW = "SHORT_BORROW", + SHORT_SELL = "SHORT_SELL", + SHORT_BUY = "SHORT_BUY", + SHORT_REPAY = "SHORT_REPAY", + SHORT_WITHDRAW = "SHORT_WITHDRAW", + } + export type BuiltResult = { txRaw: string; txId: string; @@ -55,15 +64,15 @@ export namespace StateMachine { userAddress: string; networkEnv: NetworkEnvironment; utxos: string[]; - /** Amount to borrow (used for LONG_BORROW) */ + /** Amount to borrow (used for LONG_BORROW / SHORT_BORROW) */ amountBorrow?: string; - /** Loan transaction ID (used for LONG_REPAY to identify the loan) */ + /** Loan transaction ID (used for LONG_REPAY / SHORT_REPAY to identify the loan) */ loanTxId?: string; - /** Loan output index (used for LONG_REPAY, format: "{txHash}-{outputIndex}") */ + /** Loan output index (used for LONG_REPAY / SHORT_REPAY) */ loanOutputIndex?: number; - /** Collateral qToken amount (used for LONG_REPAY to redeem collateral) */ + /** Collateral qToken amount (used for LONG_REPAY / SHORT_REPAY to redeem collateral) */ collateralAmount?: string; - /** Supply amountOut from LONG_SUPPLY order (used for LONG_WITHDRAW) */ + /** Supply amountOut from SUPPLY order (used for LONG_WITHDRAW / SHORT_WITHDRAW) */ supplyAmountOut?: string; }; @@ -376,12 +385,322 @@ export namespace StateMachine { }; }; + // ============================================================================ + // SHORT Build Functions + // ============================================================================ + + /** + * Build SHORT_SUPPLY transaction: Supply asset A (ADA) to Liqwid, receive qADA + */ + export const handleShortSupply = async (options: HandleBuildTxOptions): Promise => { + const { order, userAddress, networkEnv, utxos } = options; + invariant(order.orderType === ShortOrderType.SHORT_SUPPLY, "Invalid order type for handleShortSupply"); + invariant(order.assetIn, "assetIn is required for SHORT_SUPPLY order"); + invariant(order.amountIn, "amountIn is required for SHORT_SUPPLY order"); + invariant(order.assetOut, "assetOut is required for SHORT_SUPPLY order"); + + // assetOut contains the lending market ID (e.g. "Ada") + const marketId = order.assetOut as LiqwidProvider.MarketId; + + const buildTxResult = await LiqwidProvider.getSupplyTransaction({ + marketId, + amount: Number(order.amountIn), + address: userAddress, + utxos, + networkEnv, + }); + + if (buildTxResult.type === "err") { + throw new Error(`Failed to build supply transaction: ${buildTxResult.error.message}`); + } + + const txRaw = buildTxResult.value; + const ECSL = RustModule.getE; + const eTx = ECSL.Transaction.from_hex(txRaw); + const txBody = eTx.body(); + const ttl = txBody.ttl(); + invariant(Maybe.isJust(ttl), "TTL must be set in the transaction body"); + safeFreeRustObjects(eTx, txBody); + + const validTo = getTimeFromSlotMagic(networkEnv, ttl); + const txId = LiqwidProvider.getLiqwidTxHash(txRaw); + + return { + txRaw, + txId, + validTo: validTo.getTime(), + }; + }; + + /** + * Build SHORT_BORROW transaction: Borrow asset B using qADA as collateral + */ + export const handleShortBorrow = async (options: HandleBuildTxOptions): Promise => { + const { order, marketConfig, userAddress, networkEnv, utxos, amountBorrow } = options; + invariant(order.orderType === ShortOrderType.SHORT_BORROW, "Invalid order type for handleShortBorrow"); + invariant(order.assetIn, "assetIn is required for SHORT_BORROW order"); + invariant(order.amountIn, "amountIn is required for SHORT_BORROW order"); + invariant(amountBorrow, "amountBorrow is required for SHORT_BORROW order"); + + const apiConfig = LiqwidProviderV2.createConfig(networkEnv); + + const buildTxResult = await LiqwidProviderV2.Transactions.borrow(apiConfig, { + address: userAddress, + utxos, + marketId: marketConfig.borrowMarketIdShort as LiqwidProviderV2.MarketId, + amount: Number(amountBorrow), + collaterals: [ + { + id: marketConfig.assetAQTokenTicker, + amount: Number(order.amountIn), + }, + ], + }); + + if (buildTxResult.type === "err") { + throw new Error(`Failed to build borrow transaction: ${buildTxResult.error.message}`); + } + + const txRaw = buildTxResult.value; + const ECSL = RustModule.getE; + const eTx = ECSL.Transaction.from_hex(txRaw); + const txBody = eTx.body(); + const ttl = txBody.ttl(); + invariant(Maybe.isJust(ttl), "TTL must be set in the transaction body"); + safeFreeRustObjects(eTx, txBody); + + const validTo = getTimeFromSlotMagic(networkEnv, ttl); + const txId = LiqwidProviderV2.getTxHash(txRaw); + + return { + txRaw, + txId, + validTo: validTo.getTime(), + }; + }; + + /** + * Build SHORT_SELL transaction: Sell asset B for asset A via DEX (B_TO_A swap) + * Reuses the same DEX swap pattern as handleLongSell + */ + export const handleShortSell = async (options: HandleBuildTxOptions): Promise => { + const { order, marketConfig, userAddress, networkEnv, utxos } = options; + invariant(order.orderType === ShortOrderType.SHORT_SELL, "Invalid order type for handleShortSell"); + invariant(order.assetIn, "assetIn is required for SHORT_SELL order"); + invariant(order.amountIn, "amountIn is required for SHORT_SELL order"); + invariant(order.assetOut, "assetOut is required for SHORT_SELL order"); + + const walletUtxos: Utxo[] = utxos.map((u) => Utxo.fromHex(u)); + const sender = Address.fromBech32(userAddress); + + const txb = DEXOrderTransaction.createBulkOrdersTx({ + networkEnv, + sender, + orderOptions: [ + { + lpAsset: Asset.fromString(marketConfig.ammLpAsset), + version: DexVersion.DEX_V2, + type: OrderV2StepType.SWAP_EXACT_IN, + assetIn: marketConfig.assetB, + amountIn: BigInt(order.amountIn), + minimumAmountOut: 1n, + direction: OrderV2Direction.B_TO_A, + killOnFailed: false, + isLimitOrder: false, + }, + ], + }); + const validTo = Date.now() + Duration.newMinutes(3).milliseconds; + txb.validToUnixTime(validTo); + + const { + txComplete, + txId, + newUtxoState: { changeUtxos }, + } = await txb.completeUnsafeForTxChaining({ + coinSelectionAlgorithm: CoinSelectionAlgorithm.SPEND_ALL, + walletUtxos, + changeAddress: sender, + provider: new EmulatorProvider(networkEnv), + }); + const txRaw = txComplete.complete(); + const ECSL = RustModule.getE; + const eTx = ECSL.Transaction.from_hex(txRaw); + const outputsHash = HashUtils.sha256(changeUtxos.map((u) => Utxo.toHex(u)).join(",")); + + safeFreeRustObjects(eTx); + + return { + txRaw, + txId: txId, + outputsHash, + validTo, + }; + }; + + /** + * Build SHORT_BUY transaction: Buy asset B with asset A via DEX (A_TO_B swap) + * Reuses the same DEX swap pattern as handleLongBuy + */ + export const handleShortBuy = async (options: HandleBuildTxOptions): Promise => { + const { order, marketConfig, userAddress, networkEnv, utxos } = options; + invariant(order.orderType === ShortOrderType.SHORT_BUY, "Invalid order type for handleShortBuy"); + invariant(order.assetIn, "assetIn is required for SHORT_BUY order"); + invariant(order.amountIn, "amountIn is required for SHORT_BUY order"); + invariant(order.assetOut, "assetOut is required for SHORT_BUY order"); + + const walletUtxos: Utxo[] = utxos.map((u) => Utxo.fromHex(u)); + const sender = Address.fromBech32(userAddress); + + const txb = DEXOrderTransaction.createBulkOrdersTx({ + networkEnv, + sender, + orderOptions: [ + { + lpAsset: Asset.fromString(marketConfig.ammLpAsset), + version: DexVersion.DEX_V2, + type: OrderV2StepType.SWAP_EXACT_IN, + assetIn: marketConfig.assetA, + amountIn: BigInt(order.amountIn), + minimumAmountOut: 1n, + direction: OrderV2Direction.A_TO_B, + killOnFailed: false, + isLimitOrder: false, + }, + ], + }); + const validTo = Date.now() + Duration.newMinutes(3).milliseconds; + txb.validToUnixTime(validTo); + + const { + txComplete, + txId, + newUtxoState: { changeUtxos }, + } = await txb.completeUnsafeForTxChaining({ + coinSelectionAlgorithm: CoinSelectionAlgorithm.SPEND_ALL, + walletUtxos, + changeAddress: sender, + provider: new EmulatorProvider(networkEnv), + }); + const txRaw = txComplete.complete(); + const ECSL = RustModule.getE; + const eTx = ECSL.Transaction.from_hex(txRaw); + const outputsHash = HashUtils.sha256(changeUtxos.map((u) => Utxo.toHex(u)).join(",")); + + safeFreeRustObjects(eTx); + + return { + txRaw, + txId: txId, + outputsHash, + validTo, + }; + }; + + /** + * Build SHORT_REPAY transaction: Repay asset B loan to Liqwid and redeem qADA collateral + */ + export const handleShortRepay = async (options: HandleBuildTxOptions): Promise => { + const { order, marketConfig, userAddress, networkEnv, utxos, loanTxId, loanOutputIndex, collateralAmount } = + options; + invariant(order.orderType === ShortOrderType.SHORT_REPAY, "Invalid order type for handleShortRepay"); + invariant(order.assetIn, "assetIn is required for SHORT_REPAY order"); + invariant(order.amountIn, "amountIn is required for SHORT_REPAY order"); + invariant(loanTxId, "loanTxId is required for SHORT_REPAY order"); + invariant(loanOutputIndex !== undefined, "loanOutputIndex is required for SHORT_REPAY order"); + invariant(collateralAmount, "collateralAmount is required for SHORT_REPAY order"); + + const apiConfig = LiqwidProviderV2.createConfig(networkEnv); + + // Format loanUtxoId as "{txHash}-{outputIndex}" + const loanUtxoId = `${loanTxId}-${loanOutputIndex}`; + + // Format collateral ID: use assetAQTokenRaw (qADA) for SHORT + const qTokenParts = marketConfig.assetAQTokenRaw.split("."); + const qTokenPolicyId = qTokenParts[0]; + const collateralId = `${marketConfig.borrowMarketIdShort}.${qTokenPolicyId}`; + + const buildTxResult = await LiqwidProviderV2.Transactions.repayLoan(apiConfig, { + address: userAddress, + utxos, + loanUtxoId, + collaterals: [ + { + id: collateralId, + amount: Number(collateralAmount), + }, + ], + }); + + if (buildTxResult.type === "err") { + throw new Error(`Failed to build repay transaction: ${buildTxResult.error.message}`); + } + + const txRaw = buildTxResult.value; + const ECSL = RustModule.getE; + const eTx = ECSL.Transaction.from_hex(txRaw); + const txBody = eTx.body(); + const ttl = txBody.ttl(); + invariant(Maybe.isJust(ttl), "TTL must be set in the transaction body"); + safeFreeRustObjects(eTx, txBody); + + const validTo = getTimeFromSlotMagic(networkEnv, ttl); + const txId = LiqwidProviderV2.getTxHash(txRaw); + + return { + txRaw, + txId, + validTo: validTo.getTime(), + }; + }; + + /** + * Build SHORT_WITHDRAW transaction: Withdraw asset A (ADA) from Liqwid + */ + export const handleShortWithdraw = async (options: HandleBuildTxOptions): Promise => { + const { order, marketConfig, userAddress, networkEnv, utxos, supplyAmountOut } = options; + invariant(order.orderType === ShortOrderType.SHORT_WITHDRAW, "Invalid order type for handleShortWithdraw"); + invariant(order.assetIn, "assetIn is required for SHORT_WITHDRAW order"); + invariant(order.amountIn, "amountIn is required for SHORT_WITHDRAW order"); + invariant(supplyAmountOut, "supplyAmountOut is required for SHORT_WITHDRAW order"); + + const apiConfig = LiqwidProviderV2.createConfig(networkEnv); + + const buildTxResult = await LiqwidProviderV2.Transactions.withdraw(apiConfig, { + address: userAddress, + utxos, + marketId: marketConfig.collateralMarketId as LiqwidProviderV2.MarketId, + amount: Number(supplyAmountOut), + }); + + if (buildTxResult.type === "err") { + throw new Error(`Failed to build withdraw transaction: ${buildTxResult.error.message}`); + } + + const txRaw = buildTxResult.value; + const ECSL = RustModule.getE; + const eTx = ECSL.Transaction.from_hex(txRaw); + const txBody = eTx.body(); + const ttl = txBody.ttl(); + invariant(Maybe.isJust(ttl), "TTL must be set in the transaction body"); + safeFreeRustObjects(eTx, txBody); + + const validTo = getTimeFromSlotMagic(networkEnv, ttl); + const txId = LiqwidProviderV2.getTxHash(txRaw); + + return { + txRaw, + txId, + validTo: validTo.getTime(), + }; + }; + // Common waiting result type for all waiting functions export type WaitingResult = | { isConfirmed: false } | { isConfirmed: true; - nextOrderType: LongOrderType; + nextOrderType: LongOrderType | ShortOrderType; assetIn: string; amountIn: string; assetOut: string; @@ -683,16 +1002,256 @@ export namespace StateMachine { return { isConfirmed: false }; }; + // ============================================================================ + // SHORT Waiting Functions + // ============================================================================ + + /** + * Wait for SHORT_SUPPLY transaction to be confirmed + * Extract qADA amount and transition to SHORT_BORROW + */ + export const waitingShortSupply = async (options: WaitingOptions): Promise => { + const { marketConfig, txHash, userAddress, cardanoscanProvider } = options; + + const txFoundOnChain = await cardanoscanProvider.findTransactionByHash(userAddress, txHash, 50, 10); + + if (txFoundOnChain) { + const userAddressHex = userAddress.toHex(); + const qTokenAsset = Asset.fromString(marketConfig.assetAQTokenRaw); + const qTokenUnit = qTokenAsset.toBlockFrostString(); + + for (const output of txFoundOnChain.outputs) { + if (output.address === userAddressHex) { + if (output.tokens && output.tokens.length > 0) { + const matchingToken = output.tokens.find((token) => token.assetId === qTokenUnit); + if (matchingToken) { + const amountReceived = BigInt(matchingToken.value); + return { + isConfirmed: true, + nextOrderType: ShortOrderType.SHORT_BORROW, + assetIn: marketConfig.assetAQTokenRaw, + amountIn: amountReceived.toString(), + assetOut: marketConfig.assetB.toString(), + amountOut: amountReceived.toString(), + }; + } + } + } + } + + throw new Error( + `SHORT_SUPPLY tx confirmed (${txHash}) but could not find output with qToken ${marketConfig.assetAQTokenRaw}`, + ); + } + + return { isConfirmed: false }; + }; + + /** + * Wait for SHORT_BORROW transaction to be confirmed + * Calculate borrowed amount and transition to SHORT_SELL + */ + export const waitingShortBorrow = async (options: WaitingOptions): Promise => { + const { marketConfig, txHash, userAddress, cardanoscanProvider, positionAmountIn } = options; + invariant(positionAmountIn, "positionAmountIn is required for waitingShortBorrow"); + + const txFoundOnChain = await cardanoscanProvider.findTransactionByHash(userAddress, txHash, 50, 10); + + if (txFoundOnChain) { + const userAddressHex = userAddress.toHex(); + const assetBUnit = marketConfig.assetB.toBlockFrostString(); + + // Find asset B (borrowed token) in outputs + for (const output of txFoundOnChain.outputs) { + if (output.address === userAddressHex) { + if (output.tokens && output.tokens.length > 0) { + const matchingToken = output.tokens.find((token) => token.assetId === assetBUnit); + if (matchingToken) { + const amountBorrowed = BigInt(matchingToken.value); + return { + isConfirmed: true, + nextOrderType: ShortOrderType.SHORT_SELL, + assetIn: marketConfig.assetB.toString(), + amountIn: amountBorrowed.toString(), + assetOut: marketConfig.assetA.toString(), + amountOut: amountBorrowed.toString(), + }; + } + } + } + } + + throw new Error( + `SHORT_BORROW tx confirmed (${txHash}) but could not find output with asset ${marketConfig.assetB.toString()}`, + ); + } + + return { isConfirmed: false }; + }; + + /** + * Wait for SHORT_SELL order output to be spent (consumed by DEX) + * This is the final opening step — position becomes OPEN + */ + export const waitingShortSell = async (options: WaitingOptions): Promise => { + const { txHash, orderOutputIndex, userAddress, cardanoscanProvider } = options; + invariant(orderOutputIndex !== undefined, "orderOutputIndex is required for waitingShortSell"); + + const userAddressHex = userAddress.toHex(); + + const spendingTx = await cardanoscanProvider.findTransactionHasSpent(userAddress, txHash, orderOutputIndex, 5, 10); + + if (spendingTx) { + // SHORT_SELL sells asset B for ADA, so we look for ADA in outputs + for (const output of spendingTx.outputs) { + if (output.address === userAddressHex) { + const amountOut = BigInt(output.value); + return { + isConfirmed: true, + isFinal: true, + positionStatus: PositionStatus.OPEN, + amountOut: amountOut.toString(), + }; + } + } + + throw new Error(`Order output spent (tx: ${spendingTx.hash}) but could not find matching output for user`); + } + + return { isConfirmed: false }; + }; + + /** + * Wait for SHORT_BUY order output to be spent (consumed by DEX) + * Extract asset B received and transition to SHORT_REPAY + */ + export const waitingShortBuy = async (options: WaitingOptions): Promise => { + const { marketConfig, txHash, orderOutputIndex, userAddress, cardanoscanProvider, assetOut } = options; + invariant(orderOutputIndex !== undefined, "orderOutputIndex is required for waitingShortBuy"); + invariant(assetOut, "assetOut is required for waitingShortBuy"); + + const userAddressHex = userAddress.toHex(); + + const spendingTx = await cardanoscanProvider.findTransactionHasSpent(userAddress, txHash, orderOutputIndex, 5, 10); + + if (spendingTx) { + const assetOutUnit = assetOut.toBlockFrostString(); + + for (const output of spendingTx.outputs) { + if (output.address === userAddressHex) { + if (output.tokens && output.tokens.length > 0) { + const matchingToken = output.tokens.find((token) => token.assetId === assetOutUnit); + if (matchingToken) { + const amountOut = BigInt(matchingToken.value); + return { + isConfirmed: true, + nextOrderType: ShortOrderType.SHORT_REPAY, + assetIn: assetOut.toString(), + amountIn: amountOut.toString(), + assetOut: marketConfig.assetAQTokenRaw, // qADA to be redeemed + amountOut: amountOut.toString(), + }; + } + } + } + } + + throw new Error( + `Order output spent (tx: ${spendingTx.hash}) but could not find matching output with asset ${assetOut.toString()}`, + ); + } + + return { isConfirmed: false }; + }; + + /** + * Wait for SHORT_REPAY transaction to be confirmed + * Extract redeemed qADA and transition to SHORT_WITHDRAW + */ + export const waitingShortRepay = async (options: WaitingOptions): Promise => { + const { marketConfig, txHash, userAddress, cardanoscanProvider } = options; + + const txFoundOnChain = await cardanoscanProvider.findTransactionByHash(userAddress, txHash, 50, 10); + + if (txFoundOnChain) { + const userAddressHex = userAddress.toHex(); + const qTokenAsset = Asset.fromString(marketConfig.assetAQTokenRaw); + const qTokenUnit = qTokenAsset.toBlockFrostString(); + + for (const output of txFoundOnChain.outputs) { + if (output.address === userAddressHex) { + if (output.tokens && output.tokens.length > 0) { + const matchingToken = output.tokens.find((token) => token.assetId === qTokenUnit); + if (matchingToken) { + const qTokenAmount = BigInt(matchingToken.value); + return { + isConfirmed: true, + nextOrderType: ShortOrderType.SHORT_WITHDRAW, + assetIn: marketConfig.assetAQTokenRaw, + amountIn: qTokenAmount.toString(), + assetOut: marketConfig.assetA.toString(), + amountOut: qTokenAmount.toString(), + }; + } + } + } + } + + throw new Error( + `SHORT_REPAY tx confirmed (${txHash}) but could not find output with qToken ${marketConfig.assetAQTokenRaw}`, + ); + } + + return { isConfirmed: false }; + }; + + /** + * Wait for SHORT_WITHDRAW transaction to be confirmed + * This is the final closing step — position becomes CLOSED + */ + export const waitingShortWithdraw = async (options: WaitingOptions): Promise => { + const { txHash, userAddress, cardanoscanProvider } = options; + + const txFoundOnChain = await cardanoscanProvider.findTransactionByHash(userAddress, txHash, 50, 10); + + if (txFoundOnChain) { + const userAddressHex = userAddress.toHex(); + + // For SHORT_WITHDRAW, we withdraw ADA — look for ADA value in outputs + for (const output of txFoundOnChain.outputs) { + if (output.address === userAddressHex) { + const amountOut = BigInt(output.value); + return { + isConfirmed: true, + isFinal: true, + positionStatus: PositionStatus.CLOSED, + amountOut: amountOut.toString(), + }; + } + } + + throw new Error(`SHORT_WITHDRAW tx confirmed (${txHash}) but could not find output for user ${userAddressHex}`); + } + + return { isConfirmed: false }; + }; + /** Map of order types to their build transaction functions */ export const MAP_BUILD_TX_FN: Record Promise> = { [LongOrderType.LONG_BUY]: handleLongBuy, [LongOrderType.LONG_SUPPLY]: handleLongSupply, [LongOrderType.LONG_BORROW]: handleLongBorrow, - [LongOrderType.LONG_BUY_MORE]: handleLongBuy, // Reuse handleLongBuy + [LongOrderType.LONG_BUY_MORE]: handleLongBuy, [LongOrderType.LONG_SELL]: handleLongSell, [LongOrderType.LONG_REPAY]: handleLongRepay, [LongOrderType.LONG_WITHDRAW]: handleLongWithdraw, - [LongOrderType.LONG_SELL_ALL]: handleLongSell, // Reuse handleLongSell + [LongOrderType.LONG_SELL_ALL]: handleLongSell, + [ShortOrderType.SHORT_SUPPLY]: handleShortSupply, + [ShortOrderType.SHORT_BORROW]: handleShortBorrow, + [ShortOrderType.SHORT_SELL]: handleShortSell, + [ShortOrderType.SHORT_BUY]: handleShortBuy, + [ShortOrderType.SHORT_REPAY]: handleShortRepay, + [ShortOrderType.SHORT_WITHDRAW]: handleShortWithdraw, }; /** Map of order types to their waiting functions */ @@ -704,6 +1263,12 @@ export namespace StateMachine { [LongOrderType.LONG_SELL]: waitingLongSell, [LongOrderType.LONG_REPAY]: waitingLongRepay, [LongOrderType.LONG_WITHDRAW]: waitingLongWithdraw, - [LongOrderType.LONG_SELL_ALL]: waitingLongSell, // Reuse waitingLongSell + [LongOrderType.LONG_SELL_ALL]: waitingLongSell, + [ShortOrderType.SHORT_SUPPLY]: waitingShortSupply, + [ShortOrderType.SHORT_BORROW]: waitingShortBorrow, + [ShortOrderType.SHORT_SELL]: waitingShortSell, + [ShortOrderType.SHORT_BUY]: waitingShortBuy, + [ShortOrderType.SHORT_REPAY]: waitingShortRepay, + [ShortOrderType.SHORT_WITHDRAW]: waitingShortWithdraw, }; } diff --git a/apps/long-short-backend/src/services/position-service.ts b/apps/long-short-backend/src/services/position-service.ts index 9bdb995..7155f8a 100644 --- a/apps/long-short-backend/src/services/position-service.ts +++ b/apps/long-short-backend/src/services/position-service.ts @@ -12,7 +12,7 @@ import { logger } from "../utils"; export type CreatePositionInput = { userAddress: string; marketId: string; - side: "LONG"; + side: "LONG" | "SHORT"; amountIn: bigint; }; @@ -46,9 +46,9 @@ export class PositionService { async createPosition(input: CreatePositionInput): Promise { const { userAddress, marketId, side, amountIn } = input; - // Only LONG side is supported - if (side !== "LONG") { - return { success: false, error: "Only LONG side is supported" }; + // Validate side + if (side !== "LONG" && side !== "SHORT") { + return { success: false, error: "Side must be LONG or SHORT" }; } // Validate market @@ -85,7 +85,7 @@ export class PositionService { if (side === StateMachine.PositionSide.LONG) { amountBorrow += 4_000_000n; //extra ada for fee } - // Execute transaction: create position + 4 orders + // Execute transaction: create position + orders const position = await this.db.transaction().execute(async (trx) => { const pos = await PositionRepository.createPosition(trx, { marketId, @@ -95,28 +95,49 @@ export class PositionService { amountBorrow: amountBorrow.toString(), }); - // Create 4 LONG orders - await OrderRepository.createOrders(trx, [ - { - positionId: pos.id, - orderType: StateMachine.LongOrderType.LONG_BUY, - assetIn: marketConfig.assetA.toString(), - amountIn: pos.amountIn, - assetOut: marketConfig.assetB.toString(), - }, - { - positionId: pos.id, - orderType: StateMachine.LongOrderType.LONG_SUPPLY, - }, - { - positionId: pos.id, - orderType: StateMachine.LongOrderType.LONG_BORROW, - }, - { - positionId: pos.id, - orderType: StateMachine.LongOrderType.LONG_BUY_MORE, - }, - ]); + if (side === "LONG") { + // Create 4 LONG opening orders + await OrderRepository.createOrders(trx, [ + { + positionId: pos.id, + orderType: StateMachine.LongOrderType.LONG_BUY, + assetIn: marketConfig.assetA.toString(), + amountIn: pos.amountIn, + assetOut: marketConfig.assetB.toString(), + }, + { + positionId: pos.id, + orderType: StateMachine.LongOrderType.LONG_SUPPLY, + }, + { + positionId: pos.id, + orderType: StateMachine.LongOrderType.LONG_BORROW, + }, + { + positionId: pos.id, + orderType: StateMachine.LongOrderType.LONG_BUY_MORE, + }, + ]); + } else { + // Create 3 SHORT opening orders: supply ADA → borrow asset B → sell asset B + await OrderRepository.createOrders(trx, [ + { + positionId: pos.id, + orderType: StateMachine.ShortOrderType.SHORT_SUPPLY, + assetIn: marketConfig.assetA.toString(), + amountIn: pos.amountIn, + assetOut: marketConfig.assetAQTokenTicker, + }, + { + positionId: pos.id, + orderType: StateMachine.ShortOrderType.SHORT_BORROW, + }, + { + positionId: pos.id, + orderType: StateMachine.ShortOrderType.SHORT_SELL, + }, + ]); + } return pos; }); @@ -384,6 +405,37 @@ export class PositionService { buildOptions.supplyAmountOut = supplyOrder.amountOut; } + // For SHORT_REPAY, we need the loan transaction ID, output index, and collateral amount from SHORT_BORROW + if (order.orderType === StateMachine.ShortOrderType.SHORT_REPAY) { + const borrowOrder = await OrderRepository.getOrderByPositionAndType( + this.db, + position.id, + StateMachine.ShortOrderType.SHORT_BORROW, + ); + if (!borrowOrder?.createdTxId) { + return { success: false, error: "SHORT_BORROW order not found or not confirmed yet" }; + } + if (!borrowOrder.amountIn) { + return { success: false, error: "SHORT_BORROW order amountIn (collateral amount) not set" }; + } + buildOptions.loanTxId = borrowOrder.createdTxId; + buildOptions.loanOutputIndex = borrowOrder.createdTxIndex ?? 0; + buildOptions.collateralAmount = borrowOrder.amountIn; // qADA amount used as collateral + } + + // For SHORT_WITHDRAW, we need the amountOut from SHORT_SUPPLY order + if (order.orderType === StateMachine.ShortOrderType.SHORT_WITHDRAW) { + const supplyOrder = await OrderRepository.getOrderByPositionAndType( + this.db, + position.id, + StateMachine.ShortOrderType.SHORT_SUPPLY, + ); + if (!supplyOrder?.amountOut) { + return { success: false, error: "SHORT_SUPPLY order not found or amountOut not set" }; + } + buildOptions.supplyAmountOut = supplyOrder.amountOut; + } + const txResult = await buildFn(buildOptions); // Update order built_tx fields @@ -443,43 +495,74 @@ export class PositionService { }; } - // Get the LONG_BUY_MORE order to get the amountOut (total asset B received) - const longBuyMoreOrder = await OrderRepository.getOrderByPositionAndType( - this.db, - position.id, - StateMachine.LongOrderType.LONG_BUY_MORE, - ); - if (!longBuyMoreOrder || !longBuyMoreOrder.amountOut) { - return { success: false, error: "LONG_BUY_MORE order not found or amountOut not set" }; - } - - // Execute transaction: update position status + create 3 closing orders + // Execute transaction: update position status + create closing orders const updatedPosition = await this.db.transaction().execute(async (trx) => { // Update position status to CLOSING await PositionRepository.updatePositionStatus(trx, position.id, StateMachine.PositionStatus.CLOSING); - // Create 3 LONG closing orders: LONG_SELL, LONG_REPAY, LONG_WITHDRAW - await OrderRepository.createOrders(trx, [ - { - positionId: position.id, - orderType: StateMachine.LongOrderType.LONG_SELL, - assetIn: marketConfig.assetB.toString(), - amountIn: longBuyMoreOrder.amountOut, - assetOut: marketConfig.assetA.toString(), - }, - { - positionId: position.id, - orderType: StateMachine.LongOrderType.LONG_REPAY, - }, - { - positionId: position.id, - orderType: StateMachine.LongOrderType.LONG_WITHDRAW, - }, - { - positionId: position.id, - orderType: StateMachine.LongOrderType.LONG_SELL_ALL, - }, - ]); + if (position.side === StateMachine.PositionSide.LONG) { + // Get the LONG_BUY_MORE order to get the amountOut (total asset B received) + const longBuyMoreOrder = await OrderRepository.getOrderByPositionAndType( + this.db, + position.id, + StateMachine.LongOrderType.LONG_BUY_MORE, + ); + if (!longBuyMoreOrder || !longBuyMoreOrder.amountOut) { + throw new Error("LONG_BUY_MORE order not found or amountOut not set"); + } + + // Create 4 LONG closing orders + await OrderRepository.createOrders(trx, [ + { + positionId: position.id, + orderType: StateMachine.LongOrderType.LONG_SELL, + assetIn: marketConfig.assetB.toString(), + amountIn: longBuyMoreOrder.amountOut, + assetOut: marketConfig.assetA.toString(), + }, + { + positionId: position.id, + orderType: StateMachine.LongOrderType.LONG_REPAY, + }, + { + positionId: position.id, + orderType: StateMachine.LongOrderType.LONG_WITHDRAW, + }, + { + positionId: position.id, + orderType: StateMachine.LongOrderType.LONG_SELL_ALL, + }, + ]); + } else { + // Get the SHORT_SELL order to get the amountOut (ADA received from selling) + const shortSellOrder = await OrderRepository.getOrderByPositionAndType( + this.db, + position.id, + StateMachine.ShortOrderType.SHORT_SELL, + ); + if (!shortSellOrder || !shortSellOrder.amountOut) { + throw new Error("SHORT_SELL order not found or amountOut not set"); + } + + // Create 3 SHORT closing orders: buy asset B → repay loan → withdraw ADA + await OrderRepository.createOrders(trx, [ + { + positionId: position.id, + orderType: StateMachine.ShortOrderType.SHORT_BUY, + assetIn: marketConfig.assetA.toString(), + amountIn: shortSellOrder.amountOut, + assetOut: marketConfig.assetB.toString(), + }, + { + positionId: position.id, + orderType: StateMachine.ShortOrderType.SHORT_REPAY, + }, + { + positionId: position.id, + orderType: StateMachine.ShortOrderType.SHORT_WITHDRAW, + }, + ]); + } // Return updated position return { From 86bfbeb44c3cca39aa08942e35443822d920c1cc Mon Sep 17 00:00:00 2001 From: tony Date: Wed, 11 Feb 2026 09:48:03 +0700 Subject: [PATCH 24/35] update docker --- docker-compose.yml | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d8edc22..911af0f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,10 @@ x-base-backend: &base-backend environment: DATABASE_URL: postgres://postgres:JBNGlQ9wNFLlYWc2mG@postgres:5432/margin REDIS_URL: redis://default:7obaQyYSDDLDk3ECYA@redis:6379 + API_PORT: 9999 + API_HOST: 0.0.0.0 + NETWORK: testnet-preview + CARDANOSCAN_API_KEY: ${CARDANOSCAN_API_KEY} healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:9999/health || exit 1"] interval: 10s @@ -21,23 +25,17 @@ x-base-backend: &base-backend start_period: 10s services: - # Long-Short Backend API Service - # long-short-backend: - # <<: *base-backend - # build: - # context: . - # dockerfile: Dockerfile.backend - # args: - # APP_NAME: long-short-backend - # ports: - # - "127.0.0.1:9999:9999" - # environment: - # NETWORK: mainnet - # DATABASE_URL: postgres://postgres:JBNGlQ9wNFLlYWc2mG@postgres:5432/margin - # REDIS_URL: redis://default:7obaQyYSDDLDk3ECYA@redis:6379 - # API_PORT: 9999 - # API_HOST: 0.0.0.0 - # command: ["pnpm", "--filter=long-short-backend", "start"] + long-short-backend: + container_name: margin-api + <<: *base-backend + build: + context: . + dockerfile: Dockerfile.backend + args: + APP_NAME: long-short-backend + ports: + - "9999:9999" + command: ["pnpm", "--filter=long-short-backend", "start"] # web: # build: @@ -54,10 +52,11 @@ services: redis: image: redis:8 + container_name: margin-redis restart: always command: --requirepass 7obaQyYSDDLDk3ECYA ports: - - "127.0.0.1:6379:6379" + - "6380:6379" volumes: - redis-data:/data healthcheck: @@ -68,9 +67,10 @@ services: postgres: image: postgres:18 + container_name: margin-postgres restart: always ports: - - "127.0.0.1:5432:5432" + - "5433:5432" environment: POSTGRES_PASSWORD: JBNGlQ9wNFLlYWc2mG POSTGRES_DB: margin From 1b2f3fad971b2233b2531b7ac3739a7dee26fce2 Mon Sep 17 00:00:00 2001 From: tony Date: Wed, 11 Feb 2026 09:53:34 +0700 Subject: [PATCH 25/35] fix --- turbo.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/turbo.json b/turbo.json index d76fc9d..f63ac3f 100644 --- a/turbo.json +++ b/turbo.json @@ -117,7 +117,8 @@ "@minswap/felis-dex-v2#build", "@minswap/felis-ledger-core#build", "@minswap/felis-ledger-utils#build", - "@minswap/felis-tx-builder#build" + "@minswap/felis-tx-builder#build", + "@minswap/felis-lending-market" ], "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": ["dist/**"] From 709d8ee84eb253d3f6d55b7dc3cc4b43ade41844 Mon Sep 17 00:00:00 2001 From: tony Date: Wed, 11 Feb 2026 09:54:21 +0700 Subject: [PATCH 26/35] fix --- turbo.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/turbo.json b/turbo.json index f63ac3f..70801e2 100644 --- a/turbo.json +++ b/turbo.json @@ -118,7 +118,7 @@ "@minswap/felis-ledger-core#build", "@minswap/felis-ledger-utils#build", "@minswap/felis-tx-builder#build", - "@minswap/felis-lending-market" + "@minswap/felis-lending-market#build" ], "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": ["dist/**"] From 73d53cc3c5c9ca8b191c5e76826fca736afa04c7 Mon Sep 17 00:00:00 2001 From: tony Date: Wed, 11 Feb 2026 10:01:49 +0700 Subject: [PATCH 27/35] fix --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 911af0f..9737e03 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -76,7 +76,7 @@ services: POSTGRES_DB: margin command: ["-c", "max_connections=50"] volumes: - - postgres-data:/var/lib/postgresql/data + - postgres-data:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s From b95b2cce87157408c346aa1de18fe11e85550cfa Mon Sep 17 00:00:00 2001 From: tony Date: Wed, 11 Feb 2026 10:04:00 +0700 Subject: [PATCH 28/35] fix --- docker-compose.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 9737e03..c609bc9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,11 +28,6 @@ services: long-short-backend: container_name: margin-api <<: *base-backend - build: - context: . - dockerfile: Dockerfile.backend - args: - APP_NAME: long-short-backend ports: - "9999:9999" command: ["pnpm", "--filter=long-short-backend", "start"] From 95f212df5972e5de393a96d73b64e4ff264aa2fb Mon Sep 17 00:00:00 2001 From: tony Date: Wed, 11 Feb 2026 16:35:15 +0700 Subject: [PATCH 29/35] fix + test SHORT --- ...279_market_config_collateral_market_ids.ts | 14 ++++ .../.config/seeds/market_config.ts | 3 +- .../src/api/routes/metadata.ts | 3 +- apps/long-short-backend/src/api/schemas.ts | 3 +- apps/long-short-backend/src/api/server.ts | 5 +- .../src/api/state-machine.ts | 13 ++-- apps/long-short-backend/src/config/market.ts | 6 +- apps/long-short-backend/src/database/db.d.ts | 3 +- apps/long-short-backend/src/provider/index.ts | 1 + .../src/provider/minswap-aggregator.ts | 76 +++++++++++++++++++ .../src/services/position-service.ts | 31 +++++--- .../src/liqwid-provider-v2.ts | 6 +- 12 files changed, 137 insertions(+), 27 deletions(-) create mode 100644 apps/long-short-backend/.config/migrations/1770117151279_market_config_collateral_market_ids.ts create mode 100644 apps/long-short-backend/src/provider/minswap-aggregator.ts diff --git a/apps/long-short-backend/.config/migrations/1770117151279_market_config_collateral_market_ids.ts b/apps/long-short-backend/.config/migrations/1770117151279_market_config_collateral_market_ids.ts new file mode 100644 index 0000000..a3fce18 --- /dev/null +++ b/apps/long-short-backend/.config/migrations/1770117151279_market_config_collateral_market_ids.ts @@ -0,0 +1,14 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "market_config" RENAME COLUMN "collateral_market_id" TO "long_collateral_market_id"`.execute(db); + await sql`ALTER TABLE "market_config" ADD COLUMN "short_collateral_market_id" VARCHAR(64) NOT NULL DEFAULT ''`.execute( + db, + ); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "market_config" DROP COLUMN "short_collateral_market_id"`.execute(db); + await sql`ALTER TABLE "market_config" RENAME COLUMN "long_collateral_market_id" TO "collateral_market_id"`.execute(db); +} diff --git a/apps/long-short-backend/.config/seeds/market_config.ts b/apps/long-short-backend/.config/seeds/market_config.ts index 3ceb2e8..01e06bb 100644 --- a/apps/long-short-backend/.config/seeds/market_config.ts +++ b/apps/long-short-backend/.config/seeds/market_config.ts @@ -22,7 +22,8 @@ export async function seed(db: Kysely): Promise { asset_a_q_token_raw: "TODO_QADA_ASSET", // Liqwid qADA token asset_b_q_token_ticker: "qMIN", asset_b_q_token_raw: "TODO_QMIN_ASSET", // Liqwid qMIN token - collateral_market_id: "ADA", // Liqwid market ID for collateral + long_collateral_market_id: "ADA", // Liqwid market ID for long collateral + short_collateral_market_id: "ADA", // Liqwid market ID for short collateral long_leverage: 1.5, short_leverage: 0.5, min_collateral: "100000000", // 100 ADA in lovelace diff --git a/apps/long-short-backend/src/api/routes/metadata.ts b/apps/long-short-backend/src/api/routes/metadata.ts index 97554eb..bc3feac 100644 --- a/apps/long-short-backend/src/api/routes/metadata.ts +++ b/apps/long-short-backend/src/api/routes/metadata.ts @@ -13,7 +13,8 @@ function marketConfigToResponse(config: MarketConfig): MarketConfigResponseType asset_a_q_token_raw: config.assetAQTokenRaw, asset_b_q_token_ticker: config.assetBQTokenTicker, asset_b_q_token_raw: config.assetBQTokenRaw, - collateral_market_id: config.collateralMarketId, + long_collateral_market_id: config.longCollateralMarketId, + short_collateral_market_id: config.shortCollateralMarketId, long_leverage: config.longLeverage, short_leverage: config.shortLeverage, min_collateral: config.minCollateral.toString(), diff --git a/apps/long-short-backend/src/api/schemas.ts b/apps/long-short-backend/src/api/schemas.ts index 860ed01..3e1137a 100644 --- a/apps/long-short-backend/src/api/schemas.ts +++ b/apps/long-short-backend/src/api/schemas.ts @@ -163,7 +163,8 @@ export const MarketConfigResponseSchema = Type.Object({ asset_a_q_token_raw: Type.String({ description: "Liqwid qToken raw asset for asset A" }), asset_b_q_token_ticker: Type.String({ description: "Liqwid qToken ticker for asset B" }), asset_b_q_token_raw: Type.String({ description: "Liqwid qToken raw asset for asset B" }), - collateral_market_id: Type.String({ description: "Liqwid market ID" }), + long_collateral_market_id: Type.String({ description: "Liqwid market ID for long collateral" }), + short_collateral_market_id: Type.String({ description: "Liqwid market ID for short collateral" }), long_leverage: Type.Number({ description: "Long leverage multiplier" }), short_leverage: Type.Number({ description: "Short leverage multiplier" }), min_collateral: Type.String({ description: "Minimum collateral in lovelace" }), diff --git a/apps/long-short-backend/src/api/server.ts b/apps/long-short-backend/src/api/server.ts index 3b3de03..4f8df1a 100644 --- a/apps/long-short-backend/src/api/server.ts +++ b/apps/long-short-backend/src/api/server.ts @@ -4,7 +4,7 @@ import Fastify, { type FastifyInstance } from "fastify"; import type { Kysely } from "kysely"; import { API_ENDPOINTS } from "../constants"; import type { DB } from "../database"; -import type { CardanoscanProvider } from "../provider"; +import { type CardanoscanProvider, MinswapAggregatorProvider } from "../provider"; import { PositionService } from "../services/position-service"; import { logger } from "../utils"; import { registerLiqwidRoutes } from "./routes/liqwid"; @@ -40,7 +40,8 @@ export async function createApiServer(options: ApiServerOptions): Promise => { - const { order, userAddress, networkEnv, utxos } = options; + const { order, marketConfig, userAddress, networkEnv, utxos } = options; invariant(order.orderType === ShortOrderType.SHORT_SUPPLY, "Invalid order type for handleShortSupply"); invariant(order.assetIn, "assetIn is required for SHORT_SUPPLY order"); invariant(order.amountIn, "amountIn is required for SHORT_SUPPLY order"); - invariant(order.assetOut, "assetOut is required for SHORT_SUPPLY order"); - - // assetOut contains the lending market ID (e.g. "Ada") - const marketId = order.assetOut as LiqwidProvider.MarketId; + const marketId = marketConfig.shortCollateralMarketId as LiqwidProvider.MarketId; const buildTxResult = await LiqwidProvider.getSupplyTransaction({ marketId, amount: Number(order.amountIn), @@ -669,7 +666,7 @@ export namespace StateMachine { const buildTxResult = await LiqwidProviderV2.Transactions.withdraw(apiConfig, { address: userAddress, utxos, - marketId: marketConfig.collateralMarketId as LiqwidProviderV2.MarketId, + marketId: marketConfig.shortCollateralMarketId as LiqwidProviderV2.MarketId, amount: Number(supplyAmountOut), }); @@ -779,7 +776,7 @@ export namespace StateMachine { nextOrderType: LongOrderType.LONG_SUPPLY, assetIn: assetOut.toString(), amountIn: amountOut.toString(), - assetOut: marketConfig.collateralMarketId, + assetOut: marketConfig.longCollateralMarketId, amountOut: amountOut.toString(), }; } diff --git a/apps/long-short-backend/src/config/market.ts b/apps/long-short-backend/src/config/market.ts index 30d903e..bdb11f5 100644 --- a/apps/long-short-backend/src/config/market.ts +++ b/apps/long-short-backend/src/config/market.ts @@ -14,7 +14,8 @@ export type MarketConfig = { assetAQTokenRaw: string; assetBQTokenTicker: string; assetBQTokenRaw: string; - collateralMarketId: string; + longCollateralMarketId: string; + shortCollateralMarketId: string; borrowMarketIdLong: string; borrowMarketIdShort: string; longLeverage: number; @@ -45,7 +46,8 @@ export async function loadMarketConfigs(db: Kysely): Promise; borrow_market_id_short: Generated; - collateral_market_id: string; enable: Generated; + long_collateral_market_id: string; long_leverage: Numeric; market_id: string; min_collateral: Numeric; + short_collateral_market_id: Generated; short_leverage: Generated; } diff --git a/apps/long-short-backend/src/provider/index.ts b/apps/long-short-backend/src/provider/index.ts index 56af2ac..c5adcdf 100644 --- a/apps/long-short-backend/src/provider/index.ts +++ b/apps/long-short-backend/src/provider/index.ts @@ -1,2 +1,3 @@ export * from "./cardanoscan"; export * from "./kupo"; +export * from "./minswap-aggregator"; diff --git a/apps/long-short-backend/src/provider/minswap-aggregator.ts b/apps/long-short-backend/src/provider/minswap-aggregator.ts new file mode 100644 index 0000000..ce8190e --- /dev/null +++ b/apps/long-short-backend/src/provider/minswap-aggregator.ts @@ -0,0 +1,76 @@ +import { NetworkEnvironment } from "@minswap/felis-ledger-core"; +import { logger } from "../utils"; + +const BASE_URLS: Record = { + [NetworkEnvironment.MAINNET]: "https://monorepo-mainnet-prod.minswap.org", + [NetworkEnvironment.TESTNET_PREVIEW]: "https://aggr.dev-3.minswap.org", + [NetworkEnvironment.TESTNET_PREPROD]: "https://aggr.dev-3.minswap.org", +}; + +export type EstimateRequest = { + /** Amount to swap (as string, in smallest unit) */ + amount: string; + /** Input token ID (e.g. "lovelace" or concatenated policyId + tokenName) */ + tokenIn: string; + /** Output token ID (e.g. concatenated policyId + tokenName) */ + tokenOut: string; + /** Slippage tolerance in percent (default: 1) */ + slippage?: number; +}; + +export type EstimateResponse = { + amountIn: string; + amountOut: string; + minAmountOut: string; + avgPriceImpact: number; +}; + +export class MinswapAggregatorProvider { + private readonly baseUrl: string; + + constructor(networkEnv: NetworkEnvironment) { + this.baseUrl = BASE_URLS[networkEnv]; + } + + /** + * Estimate swap output amount via Minswap aggregator. + * Token IDs use concatenated format (no dot): policyId + tokenName, or "lovelace". + */ + async estimate(request: EstimateRequest): Promise { + const { amount, tokenIn, tokenOut, slippage = 1 } = request; + + const url = `${this.baseUrl}/aggregator/estimate`; + const body = JSON.stringify({ + amount, + token_in: tokenIn, + token_out: tokenOut, + slippage, + exclude_protocols: ["MuesliSwap"], + allow_multi_hops: true, + }); + + const response = await fetch(url, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + }, + body, + }); + + if (!response.ok) { + const text = await response.text(); + logger.error("Minswap aggregator estimate failed", { status: response.status, body: text }); + throw new Error(`Minswap aggregator estimate failed: ${response.status} ${text}`); + } + + const data = await response.json(); + + return { + amountIn: data.amount_in, + amountOut: data.amount_out, + minAmountOut: data.min_amount_out, + avgPriceImpact: data.avg_price_impact, + }; + } +} diff --git a/apps/long-short-backend/src/services/position-service.ts b/apps/long-short-backend/src/services/position-service.ts index 7155f8a..9c0e87b 100644 --- a/apps/long-short-backend/src/services/position-service.ts +++ b/apps/long-short-backend/src/services/position-service.ts @@ -4,7 +4,7 @@ import type { Kysely } from "kysely"; import { StateMachine } from "../api/state-machine"; import { getMarketConfig, isSupportedMarket } from "../config/market"; import type { DB } from "../database"; -import type { CardanoscanProvider } from "../provider"; +import type { CardanoscanProvider, MinswapAggregatorProvider } from "../provider"; import { OrderRepository } from "../repository/order-repository"; import { type Position, PositionRepository } from "../repository/position-repository"; import { logger } from "../utils"; @@ -41,6 +41,7 @@ export class PositionService { private readonly db: Kysely, private readonly networkEnv: NetworkEnvironment, private readonly cardanoscanProvider: CardanoscanProvider, + private readonly aggregatorProvider: MinswapAggregatorProvider, ) {} async createPosition(input: CreatePositionInput): Promise { @@ -79,11 +80,21 @@ export class PositionService { }; } - // Calculate amount_borrow = amount_in * (leverage - 1) - const leverage = side === StateMachine.PositionSide.LONG ? marketConfig.longLeverage : marketConfig.shortLeverage; - let amountBorrow = BigInt(Math.floor(Number(amountIn) * (leverage - 1))); + // Calculate amount_borrow + let amountBorrow: bigint; if (side === StateMachine.PositionSide.LONG) { - amountBorrow += 4_000_000n; //extra ada for fee + // LONG: borrow ADA = amountIn * (leverage - 1) + fee + amountBorrow = BigInt(Math.floor(Number(amountIn) * (marketConfig.longLeverage - 1))) + 4_000_000n; + } else { + // SHORT: borrow asset B equivalent to amountIn * shortLeverage ADA + // e.g. short 600 ADA with leverage 0.5 => estimate 300 ADA worth of asset B + const adaAmountToEstimate = BigInt(Math.floor(Number(amountIn) * marketConfig.shortLeverage)); + const estimate = await this.aggregatorProvider.estimate({ + amount: adaAmountToEstimate.toString(), + tokenIn: marketConfig.assetA.toBlockFrostString(), + tokenOut: marketConfig.assetB.toBlockFrostString(), + }); + amountBorrow = BigInt(estimate.amountOut); } // Execute transaction: create position + orders const position = await this.db.transaction().execute(async (trx) => { @@ -126,7 +137,7 @@ export class PositionService { orderType: StateMachine.ShortOrderType.SHORT_SUPPLY, assetIn: marketConfig.assetA.toString(), amountIn: pos.amountIn, - assetOut: marketConfig.assetAQTokenTicker, + assetOut: marketConfig.assetAQTokenRaw, }, { positionId: pos.id, @@ -423,17 +434,17 @@ export class PositionService { buildOptions.collateralAmount = borrowOrder.amountIn; // qADA amount used as collateral } - // For SHORT_WITHDRAW, we need the amountOut from SHORT_SUPPLY order + // For SHORT_WITHDRAW, we need the amountIn from SHORT_SUPPLY order (original ADA supplied, not qADA) if (order.orderType === StateMachine.ShortOrderType.SHORT_WITHDRAW) { const supplyOrder = await OrderRepository.getOrderByPositionAndType( this.db, position.id, StateMachine.ShortOrderType.SHORT_SUPPLY, ); - if (!supplyOrder?.amountOut) { - return { success: false, error: "SHORT_SUPPLY order not found or amountOut not set" }; + if (!supplyOrder?.amountIn) { + return { success: false, error: "SHORT_SUPPLY order not found or amountIn not set" }; } - buildOptions.supplyAmountOut = supplyOrder.amountOut; + buildOptions.supplyAmountOut = supplyOrder.amountIn; } const txResult = await buildFn(buildOptions); diff --git a/packages/minswap-lending-market/src/liqwid-provider-v2.ts b/packages/minswap-lending-market/src/liqwid-provider-v2.ts index bd8e3b5..77c5f2f 100644 --- a/packages/minswap-lending-market/src/liqwid-provider-v2.ts +++ b/packages/minswap-lending-market/src/liqwid-provider-v2.ts @@ -391,13 +391,16 @@ export namespace LiqwidProviderV2 { variables: TVariables, ): Promise> => { try { + const requestBody = JSON.stringify({ operationName, query, variables }); + // console.log(`[LiqwidProviderV2] ${operationName} request:`, requestBody); + const response = await fetch(getApiUrl(config), { method: "POST", headers: { accept: "application/json", "content-type": "application/json", }, - body: JSON.stringify({ operationName, query, variables }), + body: requestBody, }); if (!response.ok) { @@ -405,6 +408,7 @@ export namespace LiqwidProviderV2 { } const json = (await response.json()) as GraphQLResponse; + // console.log(`[LiqwidProviderV2] ${operationName} response:`, JSON.stringify(json)); if (json.errors?.length) { return Result.err(new Error(`GraphQL error: ${XJSON.stringify(json.errors)}`)); From a888b9e8a07bbb9bae6dba3d50f8da7516fc618b Mon Sep 17 00:00:00 2001 From: tony Date: Wed, 11 Feb 2026 19:29:26 +0700 Subject: [PATCH 30/35] drop built_output_hash --- .../1770117151280_drop_built_outputs_hash.ts | 10 +++++ .../src/api/state-machine.ts | 39 ++----------------- apps/long-short-backend/src/database/db.d.ts | 1 - .../src/repository/order-repository.ts | 4 -- .../src/services/position-service.ts | 1 - 5 files changed, 14 insertions(+), 41 deletions(-) create mode 100644 apps/long-short-backend/.config/migrations/1770117151280_drop_built_outputs_hash.ts diff --git a/apps/long-short-backend/.config/migrations/1770117151280_drop_built_outputs_hash.ts b/apps/long-short-backend/.config/migrations/1770117151280_drop_built_outputs_hash.ts new file mode 100644 index 0000000..60e83c4 --- /dev/null +++ b/apps/long-short-backend/.config/migrations/1770117151280_drop_built_outputs_hash.ts @@ -0,0 +1,10 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "order" DROP COLUMN "built_outputs_hash"`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "order" ADD COLUMN "built_outputs_hash" TEXT`.execute(db); +} diff --git a/apps/long-short-backend/src/api/state-machine.ts b/apps/long-short-backend/src/api/state-machine.ts index c3479d5..5e3cb7b 100644 --- a/apps/long-short-backend/src/api/state-machine.ts +++ b/apps/long-short-backend/src/api/state-machine.ts @@ -7,8 +7,6 @@ import { CoinSelectionAlgorithm, EmulatorProvider } from "@minswap/felis-tx-buil import invariant from "@minswap/tiny-invariant"; import type { MarketConfig } from "../config"; import type { CardanoscanProvider } from "../provider"; -import { HashUtils } from "../utils"; - export namespace StateMachine { export enum PositionSide { LONG = "LONG", @@ -46,7 +44,6 @@ export namespace StateMachine { txRaw: string; txId: string; validTo: number; - outputsHash?: string; }; // Common order data type for all Handle functions @@ -108,11 +105,7 @@ export namespace StateMachine { const validTo = Date.now() + Duration.newMinutes(3).milliseconds; txb.validToUnixTime(validTo); - const { - txComplete, - txId, - newUtxoState: { changeUtxos }, - } = await txb.completeUnsafeForTxChaining({ + const { txComplete, txId } = await txb.completeUnsafeForTxChaining({ coinSelectionAlgorithm: CoinSelectionAlgorithm.SPEND_ALL, walletUtxos, changeAddress: sender, @@ -121,14 +114,11 @@ export namespace StateMachine { const txRaw = txComplete.complete(); const ECSL = RustModule.getE; const eTx = ECSL.Transaction.from_hex(txRaw); - const outputsHash = HashUtils.sha256(changeUtxos.map((u) => Utxo.toHex(u)).join(",")); - safeFreeRustObjects(eTx); return { txRaw, txId: txId, - outputsHash, validTo, }; }; @@ -255,11 +245,7 @@ export namespace StateMachine { const validTo = Date.now() + Duration.newMinutes(3).milliseconds; txb.validToUnixTime(validTo); - const { - txComplete, - txId, - newUtxoState: { changeUtxos }, - } = await txb.completeUnsafeForTxChaining({ + const { txComplete, txId } = await txb.completeUnsafeForTxChaining({ coinSelectionAlgorithm: CoinSelectionAlgorithm.SPEND_ALL, walletUtxos, changeAddress: sender, @@ -268,14 +254,11 @@ export namespace StateMachine { const txRaw = txComplete.complete(); const ECSL = RustModule.getE; const eTx = ECSL.Transaction.from_hex(txRaw); - const outputsHash = HashUtils.sha256(changeUtxos.map((u) => Utxo.toHex(u)).join(",")); - safeFreeRustObjects(eTx); return { txRaw, txId: txId, - outputsHash, validTo, }; }; @@ -510,11 +493,7 @@ export namespace StateMachine { const validTo = Date.now() + Duration.newMinutes(3).milliseconds; txb.validToUnixTime(validTo); - const { - txComplete, - txId, - newUtxoState: { changeUtxos }, - } = await txb.completeUnsafeForTxChaining({ + const { txComplete, txId } = await txb.completeUnsafeForTxChaining({ coinSelectionAlgorithm: CoinSelectionAlgorithm.SPEND_ALL, walletUtxos, changeAddress: sender, @@ -523,14 +502,11 @@ export namespace StateMachine { const txRaw = txComplete.complete(); const ECSL = RustModule.getE; const eTx = ECSL.Transaction.from_hex(txRaw); - const outputsHash = HashUtils.sha256(changeUtxos.map((u) => Utxo.toHex(u)).join(",")); - safeFreeRustObjects(eTx); return { txRaw, txId: txId, - outputsHash, validTo, }; }; @@ -569,11 +545,7 @@ export namespace StateMachine { const validTo = Date.now() + Duration.newMinutes(3).milliseconds; txb.validToUnixTime(validTo); - const { - txComplete, - txId, - newUtxoState: { changeUtxos }, - } = await txb.completeUnsafeForTxChaining({ + const { txComplete, txId } = await txb.completeUnsafeForTxChaining({ coinSelectionAlgorithm: CoinSelectionAlgorithm.SPEND_ALL, walletUtxos, changeAddress: sender, @@ -582,14 +554,11 @@ export namespace StateMachine { const txRaw = txComplete.complete(); const ECSL = RustModule.getE; const eTx = ECSL.Transaction.from_hex(txRaw); - const outputsHash = HashUtils.sha256(changeUtxos.map((u) => Utxo.toHex(u)).join(",")); - safeFreeRustObjects(eTx); return { txRaw, txId: txId, - outputsHash, validTo, }; }; diff --git a/apps/long-short-backend/src/database/db.d.ts b/apps/long-short-backend/src/database/db.d.ts index 31ad4df..a966096 100644 --- a/apps/long-short-backend/src/database/db.d.ts +++ b/apps/long-short-backend/src/database/db.d.ts @@ -39,7 +39,6 @@ export interface Order { amount_out: Numeric | null; asset_in: string | null; asset_out: string | null; - built_outputs_hash: string | null; built_tx_id: string | null; built_valid_to: Timestamp | null; created_tx_id: string | null; diff --git a/apps/long-short-backend/src/repository/order-repository.ts b/apps/long-short-backend/src/repository/order-repository.ts index 08650d4..1f228c3 100644 --- a/apps/long-short-backend/src/repository/order-repository.ts +++ b/apps/long-short-backend/src/repository/order-repository.ts @@ -23,7 +23,6 @@ export type Order = { assetOut: string | null; amountOut: string | null; builtTxId: string | null; - builtOutputsHash: string | null; builtValidTo: Date | null; waiting: boolean; }; @@ -104,14 +103,12 @@ export namespace OrderRepository { db: Kysely | Transaction, orderId: bigint, builtTxId: string, - builtOutputsHash: string | null | undefined, builtValidTo: Date, ): Promise { await db .updateTable("order") .set({ built_tx_id: builtTxId, - built_outputs_hash: builtOutputsHash, built_valid_to: builtValidTo, }) .where("id", "=", orderId.toString()) @@ -308,7 +305,6 @@ export namespace OrderRepository { assetOut: row.asset_out, amountOut: row.amount_out, builtTxId: row.built_tx_id, - builtOutputsHash: row.built_outputs_hash, builtValidTo: row.built_valid_to ? new Date(row.built_valid_to) : null, waiting: row.waiting, }; diff --git a/apps/long-short-backend/src/services/position-service.ts b/apps/long-short-backend/src/services/position-service.ts index 9c0e87b..4823224 100644 --- a/apps/long-short-backend/src/services/position-service.ts +++ b/apps/long-short-backend/src/services/position-service.ts @@ -454,7 +454,6 @@ export class PositionService { this.db, order.id, txResult.txId, - txResult.outputsHash, new Date(txResult.validTo), ); From a70a4f2959efc447a004adf6b24fd57d13fc2528 Mon Sep 17 00:00:00 2001 From: tony Date: Wed, 11 Feb 2026 19:32:08 +0700 Subject: [PATCH 31/35] fix --- apps/long-short-backend/src/api/state-machine.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/long-short-backend/src/api/state-machine.ts b/apps/long-short-backend/src/api/state-machine.ts index 5e3cb7b..d50cddd 100644 --- a/apps/long-short-backend/src/api/state-machine.ts +++ b/apps/long-short-backend/src/api/state-machine.ts @@ -745,7 +745,7 @@ export namespace StateMachine { nextOrderType: LongOrderType.LONG_SUPPLY, assetIn: assetOut.toString(), amountIn: amountOut.toString(), - assetOut: marketConfig.longCollateralMarketId, + assetOut: marketConfig.assetBQTokenRaw, amountOut: amountOut.toString(), }; } From ed306a998e88db624b7ef2e42c01e06595df9254 Mon Sep 17 00:00:00 2001 From: tony Date: Thu, 12 Feb 2026 10:01:15 +0700 Subject: [PATCH 32/35] add docs --- .../.config/seeds/market_config.ts | 25 +- apps/long-short-backend/README.md | 372 +++++++++++ apps/long-short-backend/SPEC.md | 590 ------------------ 3 files changed, 386 insertions(+), 601 deletions(-) create mode 100644 apps/long-short-backend/README.md delete mode 100644 apps/long-short-backend/SPEC.md diff --git a/apps/long-short-backend/.config/seeds/market_config.ts b/apps/long-short-backend/.config/seeds/market_config.ts index 01e06bb..4d7040c 100644 --- a/apps/long-short-backend/.config/seeds/market_config.ts +++ b/apps/long-short-backend/.config/seeds/market_config.ts @@ -14,19 +14,22 @@ export async function seed(db: Kysely): Promise { .insertInto("market_config") .values([ { - market_id: "ADA-MIN", - asset_a: "lovelace", // ADA - asset_b: "29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6.4d494e", // MIN - amm_lp_asset: "TODO_LP_ASSET", // Minswap ADA-MIN LP token - asset_a_q_token_ticker: "qADA", - asset_a_q_token_raw: "TODO_QADA_ASSET", // Liqwid qADA token - asset_b_q_token_ticker: "qMIN", - asset_b_q_token_raw: "TODO_QMIN_ASSET", // Liqwid qMIN token - long_collateral_market_id: "ADA", // Liqwid market ID for long collateral - short_collateral_market_id: "ADA", // Liqwid market ID for short collateral + market_id: "ADA-NIGHT", + asset_a: "lovelace", + asset_b: "0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa.4e49474854", // NIGHT + amm_lp_asset: + "f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c.e74c52975908a612d5ce68327040d449aae99f8b463bb6de046a1b23c5713169", + asset_a_q_token_ticker: "qAda", + asset_a_q_token_raw: "a04ce7a52545e5e33c2867e148898d9e667a69602285f6a1298f9d68", + asset_b_q_token_ticker: "qNIGHT", + asset_b_q_token_raw: "c45fa8aefc662c003a32be67f6a4652d8ce56bd9e54d7696efd40c86", + long_collateral_market_id: "NIGHT", + short_collateral_market_id: "Ada", + borrow_market_id_long: "Ada", + borrow_market_id_short: "NIGHT", long_leverage: 1.5, short_leverage: 0.5, - min_collateral: "100000000", // 100 ADA in lovelace + min_collateral: "200000000", // 200 ADA in lovelace enable: true, }, ]) diff --git a/apps/long-short-backend/README.md b/apps/long-short-backend/README.md new file mode 100644 index 0000000..0302de5 --- /dev/null +++ b/apps/long-short-backend/README.md @@ -0,0 +1,372 @@ +# Long-Short Backend + +Cardano leveraged long/short trading backend. Users supply collateral, borrow via [Liqwid](https://liqwid.finance/) lending, and swap via [Minswap](https://minswap.org/) DEX to create leveraged positions. + +## Tech Stack + +- **Fastify** - HTTP server +- **Kysely** - PostgreSQL query builder (type-safe) +- **TypeBox** - Request/response schema validation +- **CIP-8** - Cardano message signing for authentication +- **Workspace packages**: `@repo/felis-ledger-core`, `@repo/felis-ledger-utils`, `@repo/felis-tx-builder`, `@repo/minswap-build-tx`, `@repo/minswap-lending-market` + +## Quick Start + +### Prerequisites + +- PostgreSQL (Docker or local) +- Node.js >= 22 +- pnpm 9+ + +### Setup + +```bash +# From monorepo root +pnpm install +pnpm build + +# Set environment variables (see below) + +# Run migrations +pnpm --filter=long-short-backend run run:migrate + +# Seed market config +pnpm --filter=long-short-backend run run:seed + +# Start dev server +pnpm --filter=long-short-backend dev +``` + +### Docker (PostgreSQL + Redis) + +```bash +# From monorepo root +docker compose up -d +``` + +## Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `DATABASE_URL` | Yes | - | PostgreSQL connection string | +| `CARDANOSCAN_API_KEY` | Yes | - | Cardanoscan API key | +| `NETWORK` | No | `mainnet` | `mainnet` or `testnet_preview` | +| `API_PORT` | No | `9999` | HTTP server port | +| `API_HOST` | No | `0.0.0.0` | HTTP server host | + +## Database Schema + +Three tables: `position`, `order`, `market_config`. + +### position + +| Column | Type | Description | +|--------|------|-------------| +| `id` | bigserial | Primary key | +| `market_id` | varchar | Market identifier (e.g. `ADA-NIGHT`) | +| `user_address` | varchar | Cardano bech32 address | +| `side` | varchar | `LONG` or `SHORT` | +| `status` | varchar | `PENDING` / `OPEN` / `CLOSING` / `CLOSED` | +| `amount_in` | numeric | Collateral amount (lovelace) | +| `amount_borrow` | numeric | Amount to borrow | +| `created_at` | timestamp | Position creation time | +| `closed_at` | timestamp | When position was closed (null if open) | + +Constraint: only one open position per user per market. + +### order + +Each position has a sequence of orders that execute in order. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | bigserial | Primary key (also determines execution order) | +| `position_id` | bigint | FK to position | +| `order_type` | varchar | e.g. `LONG_BUY`, `SHORT_SUPPLY` | +| `asset_in` | varchar | Input asset (nullable, filled by prior order's waiting fn) | +| `amount_in` | numeric | Input amount (nullable) | +| `asset_out` | varchar | Output asset (nullable) | +| `amount_out` | numeric | Output amount (set after confirmation) | +| `built_tx_id` | varchar | Transaction hash after local build | +| `built_valid_to` | timestamp | Transaction expiry time | +| `created_tx_id` | varchar | Transaction hash when confirmed on-chain | +| `created_tx_index` | int | Output index in confirmed tx | +| `waiting` | boolean | `true` while waiting for output to be spent | + +### market_config + +| Column | Type | Description | +|--------|------|-------------| +| `market_id` | varchar | PK, e.g. `ADA-NIGHT` | +| `asset_a` | varchar | Base asset (`lovelace` for ADA) | +| `asset_b` | varchar | Quote asset (policyId.tokenName) | +| `amm_lp_asset` | varchar | Minswap LP token | +| `asset_a_q_token_ticker` | varchar | Liqwid qToken ticker for A (e.g. `qAda`) | +| `asset_a_q_token_raw` | varchar | Liqwid qToken policyId for A | +| `asset_b_q_token_ticker` | varchar | Liqwid qToken ticker for B (e.g. `qNIGHT`) | +| `asset_b_q_token_raw` | varchar | Liqwid qToken policyId for B | +| `long_collateral_market_id` | varchar | Liqwid market ID for LONG collateral | +| `short_collateral_market_id` | varchar | Liqwid market ID for SHORT collateral | +| `borrow_market_id_long` | varchar | Liqwid market ID for LONG borrow | +| `borrow_market_id_short` | varchar | Liqwid market ID for SHORT borrow | +| `long_leverage` | numeric | LONG leverage multiplier (e.g. 1.5) | +| `short_leverage` | numeric | SHORT leverage multiplier (e.g. 0.5) | +| `min_collateral` | numeric | Minimum collateral in lovelace | +| `enable` | boolean | Enable/disable market | + +## API Endpoints + +### Authentication + +All POST endpoints require CIP-8 signature authentication: + +```json +{ + "data": { "market_id": "ADA-NIGHT", "..." : "..." }, + "user_address": "addr1q...", + "witness": { + "key": "a40101...", + "signature": "844da2..." + } +} +``` + +The client signs `JSON.stringify(data)` using their wallet's payment key (CIP-8 format). The server verifies the signature matches the `user_address`. + +### Endpoints + +#### `GET /health` +Health check. Returns `{ status: "ok" }`. + +#### `GET /metadata` +Returns all enabled market configurations. No auth required. + +#### `POST /position/create` +Create a new leveraged position. + +```json +// Request data +{ "market_id": "ADA-NIGHT", "side": "SHORT", "amount_in": "600000000" } + +// Response +{ "success": true, "data": { "id": "1", "market_id": "ADA-NIGHT", "side": "SHORT", "status": "PENDING", ... } } +``` + +#### `GET /position/get?user_address=addr1q...` +Get user's open position. No auth required. + +#### `POST /position/build-tx` +Build the next transaction in the order sequence. Call repeatedly until position reaches target status. + +```json +// Request data +{ "market_id": "ADA-NIGHT", "utxos": ["828258..."] } + +// Response (transaction ready) +{ "success": true, "data": { "tx_raw": "84a4...", "tx_id": "abc123...", "order_type": "SHORT_SUPPLY" } } + +// Response (waiting for confirmation) +{ "success": true, "data": { "waiting": true, "order_type": "SHORT_SUPPLY", "message": "..." } } +``` + +#### `POST /position/close` +Initiate closing of an OPEN position. + +```json +// Request data +{ "market_id": "ADA-NIGHT" } +``` + +#### `POST /liqwid/submit` +Submit a signed Liqwid transaction. + +```json +// Request data +{ "raw_tx": "84a4...", "witness_set": "a100..." } +``` + +## Architecture + +``` +src/ +├── cmd/run-api.ts # Entry point: init WASM, DB, providers, start server +├── api/ +│ ├── server.ts # Fastify setup, route registration +│ ├── routes/ # HTTP endpoint handlers +│ ├── schemas.ts # TypeBox request/response schemas +│ ├── helper.ts # CIP-8 authentication +│ └── state-machine.ts # Order build & waiting logic (core) +├── config/market.ts # Market config loading & cache +├── database/ +│ ├── db.d.ts # Generated Kysely types +│ └── postgres.ts # DB connection +├── provider/ +│ ├── cardanoscan.ts # On-chain tx queries +│ ├── kupo.ts # UTXO indexer +│ └── minswap-aggregator.ts # Swap price estimation +├── repository/ # Data access layer +│ ├── position-repository.ts +│ └── order-repository.ts +├── services/ +│ └── position-service.ts # Business logic orchestration +└── utils/ # Logger, signature, hashing +``` + +### Layer Flow + +``` +HTTP Request → Route Handler → Authentication (CIP-8) + → PositionService (business logic) + → OrderRepository / PositionRepository (data access) + → StateMachine (tx building / waiting) + → Providers (Cardanoscan, Liqwid, Minswap Aggregator) +``` + +## State Machine + +The state machine is the core concept. Each position is a chain of orders that execute sequentially. The client calls `build-tx` repeatedly to advance through the chain. + +### Position Lifecycle + +``` +PENDING → OPEN → CLOSING → CLOSED +``` + +- **PENDING**: Orders created, waiting for all opening orders to complete +- **OPEN**: All opening orders complete, position is active +- **CLOSING**: Close requested, closing orders in progress +- **CLOSED**: All closing orders complete + +### LONG Order Flow + +**Opening** (4 orders): +``` +LONG_BUY → DEX swap: ADA → Asset B +LONG_SUPPLY → Supply Asset B to Liqwid → receive qB +LONG_BORROW → Borrow ADA using qB collateral +LONG_BUY_MORE → DEX swap: borrowed ADA → Asset B → position OPEN +``` + +**Closing** (4 orders): +``` +LONG_SELL → DEX swap: Asset B → ADA +LONG_REPAY → Repay ADA loan, redeem qB collateral +LONG_WITHDRAW → Withdraw Asset B from Liqwid +LONG_SELL_ALL → DEX swap: remaining Asset B → ADA → position CLOSED +``` + +### SHORT Order Flow + +**Opening** (3 orders): +``` +SHORT_SUPPLY → Supply ADA to Liqwid → receive qADA +SHORT_BORROW → Borrow Asset B using qADA collateral +SHORT_SELL → DEX swap: Asset B → ADA → position OPEN +``` + +**Closing** (3 orders): +``` +SHORT_BUY → DEX swap: ADA → Asset B (buy back) +SHORT_REPAY → Repay Asset B loan, redeem qADA collateral +SHORT_WITHDRAW → Withdraw ADA from Liqwid → position CLOSED +``` + +### Transaction Lifecycle + +Each order goes through this lifecycle: + +``` +1. build-tx called → StateMachine builds tx → built_tx_id set +2. Client signs & submits tx to chain +3. Next build-tx call → finds tx on-chain → created_tx_id set, waiting = true +4. Next build-tx call → waiting function checks if output is spent + → If spent: extract amounts, transition to next order (or complete position) + → If not spent: return "waiting" message +``` + +### How `buildTx()` Works + +``` +1. Check for waiting order (created_tx_id set, waiting = true) + → Call waiting function + → If confirmed: transition to next order or complete position + → If not confirmed: return waiting message + +2. Find next unhandled order (has assetIn/amountIn/assetOut, no created_tx_id) + +3. If order has built_tx_id: + → Search for it on-chain + → If found: set created_tx_id, return waiting + → If not found & expired: rebuild + → If not found & not expired: return waiting with remaining time + +4. Build new transaction using StateMachine handler + → Set built_tx_id and built_valid_to + → Return tx_raw for client to sign +``` + +## Market Configuration + +### Liqwid Integration Fields + +- **qToken ticker** (`asset_a_q_token_ticker`): Used as `marketId` when calling Liqwid supply API (e.g. `"qAda"`, `"qNIGHT"`) +- **qToken raw** (`asset_a_q_token_raw`): The on-chain policyId of the qToken, used for matching UTXOs and as `assetOut` in orders +- **Collateral market ID** (`long_collateral_market_id`): Liqwid market ID for withdraw (e.g. `"NIGHT"`, `"Ada"`) +- **Borrow market ID** (`borrow_market_id_long`): Liqwid market ID for borrow (e.g. `"Ada"`, `"NIGHT"`) + +### Leverage + +- **LONG**: `amount_borrow = amount_in * (leverage - 1) + 4 ADA fee` + - Example: 600 ADA at 1.5x → borrow 304 ADA +- **SHORT**: `amount_borrow` = aggregator estimate of `amount_in * leverage` ADA worth of Asset B + - Example: 600 ADA at 0.5x → estimate 300 ADA worth of NIGHT → borrow that amount + +## Development Guide + +### Scripts + +```bash +pnpm --filter=long-short-backend dev # Start with watch mode +pnpm --filter=long-short-backend build # TypeScript compile +pnpm --filter=long-short-backend test # Run tests +pnpm --filter=long-short-backend run run:migrate # Apply migrations +pnpm --filter=long-short-backend run run:seed # Seed market_config +pnpm --filter=long-short-backend run codegen # Regenerate db.d.ts from schema +``` + +### Adding a New Market + +1. Insert a row into `market_config` table (or add to seed file) +2. All Liqwid market IDs and qToken values must match the Liqwid protocol exactly (case-sensitive) +3. Restart server to reload market config cache + +### Running Migrations + +```bash +# Create new migration +# File: .config/migrations/{timestamp}_{description}.ts + +# Apply +pnpm --filter=long-short-backend run run:migrate + +# After migration, regenerate types +pnpm --filter=long-short-backend run codegen +``` + +### Key Files (in order of importance) + +1. **`src/api/state-machine.ts`** - Core order build & waiting logic +2. **`src/services/position-service.ts`** - Business logic orchestration +3. **`src/repository/order-repository.ts`** - Order data access and transitions +4. **`src/api/routes/position.ts`** - HTTP endpoint handlers +5. **`src/config/market.ts`** - Market config loading +6. **`src/provider/cardanoscan.ts`** - On-chain transaction queries +7. **`src/cmd/run-api.ts`** - App initialization sequence + +### Common Patterns + +- All Cardano amounts use `bigint` (lovelace), but Liqwid API uses `number` +- Repository functions accept `Kysely | Transaction` for transaction support +- Database columns are `snake_case`, TypeScript types are `camelCase` +- Addresses use `.toHex()` for Cardanoscan API, bech32 for Liqwid API +- Asset format: `policyId.tokenName` internally, `policyId + tokenName` (no dot) for Minswap aggregator, `"lovelace"` for ADA diff --git a/apps/long-short-backend/SPEC.md b/apps/long-short-backend/SPEC.md deleted file mode 100644 index bd269f4..0000000 --- a/apps/long-short-backend/SPEC.md +++ /dev/null @@ -1,590 +0,0 @@ -# Isolated Margin Trading Backend - Specification - -## Overview - -An isolated-margin leveraged trading backend for Cardano DEX, integrated with Liqwid lending protocol. Each position has its own dedicated margin (collateral), isolating risk per trade. - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ API Layer (HTTP/WS) │ -├─────────────────────────────────────────────────────────────────┤ -│ Service Layer │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ Position │ │ Order │ │ Liquidation │ │ -│ │ Service │ │ Service │ │ Engine │ │ -│ └─────────────┘ └─────────────┘ └─────────────┘ │ -├─────────────────────────────────────────────────────────────────┤ -│ Core Layer │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ Margin │ │ Price │ │ Risk │ │ -│ │ Calculator │ │ Oracle │ │ Manager │ │ -│ └─────────────┘ └─────────────┘ └─────────────┘ │ -├─────────────────────────────────────────────────────────────────┤ -│ Integration Layer │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ Minswap │ │ Liqwid │ │ Blockchain │ │ -│ │ DEX V2 │ │ Lending │ │ Syncer │ │ -│ └─────────────┘ └─────────────┘ └─────────────┘ │ -├─────────────────────────────────────────────────────────────────┤ -│ Data Layer │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ PostgreSQL │ │ Redis │ │ Ogmios │ │ -│ │ (Positions)│ │ (Cache) │ │ (Chain) │ │ -│ └─────────────┘ └─────────────┘ └─────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -## Core Concepts - -### Isolated Margin -- Each position has its own dedicated collateral -- Losses are limited to the margin allocated to that specific position -- Positions cannot affect each other's margin - -### Position Types -- **Long**: Profit when price goes up (borrow quote asset, buy base asset) -- **Short**: Profit when price goes down (borrow base asset, sell for quote asset) - -### Leverage -- Supported leverage: 2x, 3x, 5x, 10x (configurable per market) -- Higher leverage = higher liquidation risk - ---- - -## Database Schema - -### Tables - -#### `position` -| Column | Type | Description | -|--------|------|-------------| -| `id` | BIGSERIAL | Primary key | -| `user_address` | VARCHAR(128) | User's Cardano address | -| `market` | VARCHAR(128) | Trading pair (e.g., "ADA/DJED") | -| `side` | VARCHAR(8) | "LONG" or "SHORT" | -| `status` | VARCHAR(16) | "OPEN", "CLOSED", "LIQUIDATED" | -| `leverage` | NUMERIC | Leverage multiplier | -| `collateral_asset` | VARCHAR(128) | Asset used as collateral | -| `collateral_amount` | NUMERIC | Amount of collateral | -| `entry_price` | NUMERIC | Average entry price | -| `position_size` | NUMERIC | Size of position in base asset | -| `borrowed_amount` | NUMERIC | Amount borrowed from Liqwid | -| `liquidation_price` | NUMERIC | Price at which position gets liquidated | -| `take_profit_price` | NUMERIC | Optional TP price | -| `stop_loss_price` | NUMERIC | Optional SL price | -| `realized_pnl` | NUMERIC | Realized profit/loss | -| `unrealized_pnl` | NUMERIC | Unrealized profit/loss | -| `funding_paid` | NUMERIC | Cumulative funding fees paid | -| `liqwid_supply_id` | VARCHAR(128) | Liqwid supply position reference | -| `liqwid_borrow_id` | VARCHAR(128) | Liqwid borrow position reference | -| `created_at` | TIMESTAMP | Position creation time | -| `updated_at` | TIMESTAMP | Last update time | -| `closed_at` | TIMESTAMP | Position close time | - -#### `order` -| Column | Type | Description | -|--------|------|-------------| -| `id` | BIGSERIAL | Primary key | -| `position_id` | BIGINT | Reference to position (nullable for new positions) | -| `user_address` | VARCHAR(128) | User's Cardano address | -| `market` | VARCHAR(128) | Trading pair | -| `order_type` | VARCHAR(16) | "MARKET", "LIMIT", "STOP_MARKET", "STOP_LIMIT" | -| `side` | VARCHAR(8) | "LONG" or "SHORT" | -| `action` | VARCHAR(16) | "OPEN", "CLOSE", "INCREASE", "DECREASE" | -| `status` | VARCHAR(16) | "PENDING", "FILLED", "CANCELLED", "EXPIRED" | -| `leverage` | NUMERIC | Leverage for new positions | -| `collateral_amount` | NUMERIC | Collateral amount | -| `size` | NUMERIC | Order size | -| `price` | NUMERIC | Limit price (for limit orders) | -| `trigger_price` | NUMERIC | Trigger price (for stop orders) | -| `slippage_tolerance` | NUMERIC | Max slippage % | -| `tx_hash` | VARCHAR(64) | On-chain transaction hash | -| `filled_price` | NUMERIC | Actual fill price | -| `filled_at` | TIMESTAMP | Fill timestamp | -| `expires_at` | TIMESTAMP | Order expiration | -| `created_at` | TIMESTAMP | Order creation time | - -#### `liquidation` -| Column | Type | Description | -|--------|------|-------------| -| `id` | BIGSERIAL | Primary key | -| `position_id` | BIGINT | Liquidated position | -| `liquidator_address` | VARCHAR(128) | Liquidator's address | -| `liquidation_price` | NUMERIC | Price at liquidation | -| `penalty_amount` | NUMERIC | Liquidation penalty | -| `remaining_collateral` | NUMERIC | Returned to user | -| `tx_hash` | VARCHAR(64) | Liquidation tx hash | -| `created_at` | TIMESTAMP | Liquidation time | - -#### `market_config` -| Column | Type | Description | -|--------|------|-------------| -| `market` | VARCHAR(128) | Primary key - trading pair | -| `base_asset` | VARCHAR(128) | Base asset | -| `quote_asset` | VARCHAR(128) | Quote asset | -| `lp_asset` | VARCHAR(128) | Minswap LP asset | -| `max_leverage` | NUMERIC | Maximum allowed leverage | -| `min_collateral` | NUMERIC | Minimum collateral | -| `maintenance_margin_rate` | NUMERIC | Maintenance margin % | -| `liquidation_fee_rate` | NUMERIC | Liquidation penalty % | -| `taker_fee_rate` | NUMERIC | Taker fee % | -| `maker_fee_rate` | NUMERIC | Maker fee % | -| `funding_rate_interval` | INTEGER | Funding rate interval (hours) | -| `enabled` | BOOLEAN | Market enabled | - -#### `price_history` -| Column | Type | Description | -|--------|------|-------------| -| `id` | BIGSERIAL | Primary key | -| `market` | VARCHAR(128) | Trading pair | -| `price` | NUMERIC | Price | -| `source` | VARCHAR(32) | "DEX", "ORACLE" | -| `slot` | BIGINT | Cardano slot | -| `timestamp` | TIMESTAMP | Time | - ---- - -## Services - -### 1. Position Service - -Manages position lifecycle. - -```typescript -interface PositionService { - // Open a new position - openPosition(params: { - userAddress: string; - market: string; - side: "LONG" | "SHORT"; - collateralAmount: bigint; - leverage: number; - slippageTolerance: number; - }): Promise; - - // Close an existing position - closePosition(params: { - positionId: bigint; - slippageTolerance: number; - }): Promise; - - // Increase position size - increasePosition(params: { - positionId: bigint; - additionalCollateral: bigint; - }): Promise; - - // Decrease position size (partial close) - decreasePosition(params: { - positionId: bigint; - closePercent: number; - }): Promise; - - // Add margin to position - addMargin(params: { - positionId: bigint; - amount: bigint; - }): Promise; - - // Get position details - getPosition(positionId: bigint): Promise; - - // Get user's open positions - getUserPositions(userAddress: string): Promise; - - // Calculate unrealized PnL - calculateUnrealizedPnL(position: Position, currentPrice: bigint): bigint; -} -``` - -### 2. Order Service - -Handles order placement and execution. - -```typescript -interface OrderService { - // Place a market order - placeMarketOrder(params: { - userAddress: string; - market: string; - side: "LONG" | "SHORT"; - action: "OPEN" | "CLOSE"; - size: bigint; - leverage?: number; - collateralAmount?: bigint; - }): Promise; - - // Place a limit order - placeLimitOrder(params: { - userAddress: string; - market: string; - side: "LONG" | "SHORT"; - action: "OPEN" | "CLOSE"; - size: bigint; - price: bigint; - leverage?: number; - collateralAmount?: bigint; - expiresAt?: Date; - }): Promise; - - // Cancel an order - cancelOrder(orderId: bigint): Promise; - - // Get order status - getOrder(orderId: bigint): Promise; - - // Get user's pending orders - getPendingOrders(userAddress: string): Promise; -} -``` - -### 3. Liquidation Engine - -Monitors and executes liquidations. - -```typescript -interface LiquidationEngine { - // Check if position should be liquidated - shouldLiquidate(position: Position, currentPrice: bigint): boolean; - - // Calculate liquidation price - calculateLiquidationPrice(position: Position): bigint; - - // Execute liquidation - liquidate(positionId: bigint): Promise; - - // Get liquidatable positions - getLiquidatablePositions(): Promise; - - // Start monitoring loop - startMonitoring(): void; - - // Stop monitoring - stopMonitoring(): void; -} -``` - -### 4. Margin Calculator - -Handles margin calculations. - -```typescript -interface MarginCalculator { - // Calculate initial margin required - calculateInitialMargin(params: { - positionSize: bigint; - entryPrice: bigint; - leverage: number; - }): bigint; - - // Calculate maintenance margin - calculateMaintenanceMargin(params: { - positionSize: bigint; - entryPrice: bigint; - maintenanceMarginRate: number; - }): bigint; - - // Calculate available margin - calculateAvailableMargin(position: Position): bigint; - - // Calculate margin ratio - calculateMarginRatio(position: Position, currentPrice: bigint): number; - - // Check if position is healthy - isPositionHealthy(position: Position, currentPrice: bigint): boolean; -} -``` - -### 5. Price Oracle - -Fetches and validates prices. - -```typescript -interface PriceOracle { - // Get current price from DEX - getCurrentPrice(market: string): Promise; - - // Get TWAP (Time-Weighted Average Price) - getTWAP(market: string, period: number): Promise; - - // Get price from external oracle (e.g., Charli3) - getOraclePrice(market: string): Promise; - - // Get validated price (combines DEX + oracle) - getValidatedPrice(market: string): Promise; - - // Subscribe to price updates - subscribePriceUpdates(market: string, callback: (price: bigint) => void): void; -} -``` - -### 6. Liqwid Integration - -Handles borrowing/lending through Liqwid. - -```typescript -interface LiqwidService { - // Supply collateral to Liqwid - supplyCollateral(params: { - userAddress: string; - asset: string; - amount: bigint; - }): Promise<{ supplyId: string; txHash: string }>; - - // Borrow from Liqwid - borrow(params: { - userAddress: string; - asset: string; - amount: bigint; - collateralSupplyId: string; - }): Promise<{ borrowId: string; txHash: string }>; - - // Repay borrowed amount - repay(params: { - borrowId: string; - amount: bigint; - }): Promise<{ txHash: string }>; - - // Withdraw collateral - withdrawCollateral(params: { - supplyId: string; - amount: bigint; - }): Promise<{ txHash: string }>; - - // Get borrow rate - getBorrowRate(asset: string): Promise; - - // Get supply rate - getSupplyRate(asset: string): Promise; -} -``` - ---- - -## API Endpoints - -### HTTP API - -#### Positions -``` -POST /api/v1/positions # Open new position -GET /api/v1/positions/:id # Get position by ID -GET /api/v1/positions # List user positions -POST /api/v1/positions/:id/close # Close position -POST /api/v1/positions/:id/margin # Add margin -DELETE /api/v1/positions/:id # Cancel pending position -``` - -#### Orders -``` -POST /api/v1/orders # Place order -GET /api/v1/orders/:id # Get order by ID -GET /api/v1/orders # List user orders -DELETE /api/v1/orders/:id # Cancel order -``` - -#### Markets -``` -GET /api/v1/markets # List all markets -GET /api/v1/markets/:market # Get market details -GET /api/v1/markets/:market/price # Get current price -GET /api/v1/markets/:market/depth # Get order book depth -``` - -#### Account -``` -GET /api/v1/account/balance # Get account balance -GET /api/v1/account/history # Get trade history -GET /api/v1/account/pnl # Get PnL summary -``` - -### WebSocket API - -```typescript -// Subscribe to price updates -{ "type": "subscribe", "channel": "price", "market": "ADA/DJED" } - -// Subscribe to position updates -{ "type": "subscribe", "channel": "positions", "address": "addr1..." } - -// Subscribe to order updates -{ "type": "subscribe", "channel": "orders", "address": "addr1..." } - -// Subscribe to liquidation events -{ "type": "subscribe", "channel": "liquidations" } -``` - ---- - -## Flow Diagrams - -### Open Long Position - -``` -User Backend Liqwid Minswap DEX - │ │ │ │ - │ Open Long ADA/DJED │ │ │ - │ Collateral: 100 DJED │ │ │ - │ Leverage: 3x │ │ │ - │─────────────────────>│ │ │ - │ │ │ │ - │ │ Supply 100 DJED │ │ - │ │────────────────────>│ │ - │ │ │ │ - │ │ Borrow 200 DJED │ │ - │ │────────────────────>│ │ - │ │ │ │ - │ │ │ Swap 300 DJED → ADA │ - │ │────────────────────────────────────────────>│ - │ │ │ │ - │ │ Position Created │ │ - │<─────────────────────│ │ │ - │ │ │ │ -``` - -### Close Long Position - -``` -User Backend Liqwid Minswap DEX - │ │ │ │ - │ Close Position │ │ │ - │─────────────────────>│ │ │ - │ │ │ │ - │ │ │ Swap ADA → DJED │ - │ │────────────────────────────────────────────>│ - │ │ │ │ - │ │ Repay 200 DJED │ │ - │ │ + Interest │ │ - │ │────────────────────>│ │ - │ │ │ │ - │ │ Withdraw Collateral │ │ - │ │────────────────────>│ │ - │ │ │ │ - │ │ Return profit/loss │ │ - │<─────────────────────│ to user │ │ - │ │ │ │ -``` - -### Liquidation Flow - -``` -Liquidator Backend Liqwid Minswap DEX - │ │ │ │ - │ │ Monitor Positions │ │ - │ │ (price < liq price) │ │ - │ │ │ │ - │ Trigger Liquidation │ │ │ - │─────────────────────>│ │ │ - │ │ │ │ - │ │ │ Swap ADA → DJED │ - │ │────────────────────────────────────────────>│ - │ │ │ │ - │ │ Repay Loan │ │ - │ │────────────────────>│ │ - │ │ │ │ - │ │ Liquidation penalty │ │ - │ Receive reward │ to liquidator │ │ - │<─────────────────────│ │ │ - │ │ │ │ - │ │ Remaining collateral│ │ - │ │ to user (if any) │ │ - │ │ │ │ -``` - ---- - -## Configuration - -### Environment Variables - -```env -# Database -DATABASE_URL=postgres://user:pass@localhost:5432/margin_trading - -# Redis -REDIS_URL=redis://localhost:6379 - -# Blockchain -OGMIOS_HOST=localhost:1337 -KUPO_URL=http://localhost:1442 -NETWORK_ENV=TESTNET_PREVIEW - -# Liqwid -LIQWID_API_URL=https://api.liqwid.finance -LIQWID_CONTRACT_ADDRESS=addr1... - -# Risk Management -MAX_LEVERAGE=10 -MAINTENANCE_MARGIN_RATE=0.05 -LIQUIDATION_FEE_RATE=0.025 - -# API -API_PORT=9999 -WS_PORT=9998 -``` - ---- - -## Implementation Phases - -### Phase 1: Core Infrastructure -- [ ] Database schema migrations -- [ ] Position & Order models -- [ ] Basic CRUD operations -- [ ] Price oracle integration (DEX prices) - -### Phase 2: Position Management -- [ ] Open position flow -- [ ] Close position flow -- [ ] Margin calculator -- [ ] PnL calculation - -### Phase 3: Liqwid Integration -- [ ] Supply collateral -- [ ] Borrow assets -- [ ] Repay loans -- [ ] Interest calculation - -### Phase 4: Liquidation Engine -- [ ] Liquidation price calculation -- [ ] Position health monitoring -- [ ] Automated liquidation -- [ ] Liquidation rewards - -### Phase 5: Advanced Features -- [ ] Limit orders -- [ ] Stop-loss / Take-profit -- [ ] Partial close -- [ ] Multi-collateral support - -### Phase 6: API & Monitoring -- [ ] REST API -- [ ] WebSocket API -- [ ] Health checks -- [ ] Metrics & alerts - ---- - -## Risk Parameters - -| Parameter | Value | Description | -|-----------|-------|-------------| -| Max Leverage | 10x | Maximum leverage allowed | -| Maintenance Margin | 5% | Minimum margin to avoid liquidation | -| Liquidation Fee | 2.5% | Penalty for liquidation | -| Min Collateral | 10 ADA | Minimum position collateral | -| Max Position Size | 100,000 ADA | Maximum single position | -| Funding Rate Interval | 8 hours | Funding rate calculation period | - ---- - -## Security Considerations - -1. **Signature Verification**: All user actions require valid Cardano signatures -2. **Rate Limiting**: API rate limits per address -3. **Slippage Protection**: Maximum slippage enforced on swaps -4. **Oracle Validation**: Price from DEX validated against external oracle -5. **Circuit Breakers**: Halt trading if price deviation > threshold -6. **Audit Trail**: All actions logged with timestamps From 40d2d36240665a4a025d03c93f31a8b12cfafa4a Mon Sep 17 00:00:00 2001 From: tony Date: Wed, 25 Feb 2026 10:54:29 +0700 Subject: [PATCH 33/35] update cardanoscan --- apps/long-short-backend/src/cmd/run-api.ts | 3 ++- apps/long-short-backend/src/provider/cardanoscan.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/long-short-backend/src/cmd/run-api.ts b/apps/long-short-backend/src/cmd/run-api.ts index e848b8b..ed05ae9 100644 --- a/apps/long-short-backend/src/cmd/run-api.ts +++ b/apps/long-short-backend/src/cmd/run-api.ts @@ -41,7 +41,8 @@ async function main() { logger.info(`Loaded ${marketConfigs.size} market configs`); // Create Cardanoscan provider - const cardanoscanProvider = new CardanoscanProvider("https://api.cardanoscan.io/api/v1", CARDANOSCAN_API_KEY); + const cardanoScanBaseUrl = networkEnv === NetworkEnvironment.MAINNET ? CardanoscanProvider.MAINNET_URL : CardanoscanProvider.PREVIEW_URL; + const cardanoscanProvider = new CardanoscanProvider(cardanoScanBaseUrl, CARDANOSCAN_API_KEY); // Start API server logger.info("Starting API server..."); diff --git a/apps/long-short-backend/src/provider/cardanoscan.ts b/apps/long-short-backend/src/provider/cardanoscan.ts index cbbb580..0c751a4 100644 --- a/apps/long-short-backend/src/provider/cardanoscan.ts +++ b/apps/long-short-backend/src/provider/cardanoscan.ts @@ -88,6 +88,9 @@ export type GetTransactionListOptions = { * Provides access to Cardano blockchain data via Cardanoscan API */ export class CardanoscanProvider { + public static readonly MAINNET_URL = "https://api.cardanoscan.io/api/v1"; + public static readonly PREVIEW_URL = "https://api-preview.cardanoscan.io/api/v1"; + private readonly baseUrl: string; private readonly apiKey: string; From 1c1db13f7dfa201f59d0a760ceeda519b63a9118 Mon Sep 17 00:00:00 2001 From: tony Date: Fri, 27 Feb 2026 14:43:40 +0700 Subject: [PATCH 34/35] update agent .md files --- .claude/SKILL.MD | 124 +----- .claude/specs/felis-build-tx.md | 99 +---- .claude/specs/felis-cip.md | 52 +-- .claude/specs/felis-dex-v1.md | 57 +-- .claude/specs/felis-dex-v2.md | 113 +----- .claude/specs/felis-ledger-core.md | 159 +------- .claude/specs/felis-ledger-utils.md | 79 +--- .claude/specs/felis-lending-market.md | 173 +------- .claude/specs/felis-tx-builder.md | 94 +---- .claude/specs/long-short-backend.md | 274 ++----------- CLAUDE.md | 542 ++------------------------ 11 files changed, 127 insertions(+), 1639 deletions(-) diff --git a/.claude/SKILL.MD b/.claude/SKILL.MD index 4b03a79..45991a9 100644 --- a/.claude/SKILL.MD +++ b/.claude/SKILL.MD @@ -2,138 +2,40 @@ ## Database Operations -### Run Migrations ```bash cd apps/long-short-backend + +# Run migrations DATABASE_URL=postgres://postgres:JBNGlQ9wNFLlYWc2mG@localhost:5432/margin pnpm run:migrate -``` -### Create New Migration -```bash -cd apps/long-short-backend +# Create new migration DATABASE_URL=postgres://postgres:JBNGlQ9wNFLlYWc2mG@localhost:5432/margin pnpm kysely migrate:make -``` -### Run Seeds -```bash -cd apps/long-short-backend +# Run seeds DATABASE_URL=postgres://postgres:JBNGlQ9wNFLlYWc2mG@localhost:5432/margin pnpm run:seed -``` -### Regenerate Database Types -```bash -cd apps/long-short-backend +# Regenerate database types DATABASE_URL=postgres://postgres:JBNGlQ9wNFLlYWc2mG@localhost:5432/margin pnpm codegen ``` ## Development -### Start Dev Server ```bash cd apps/long-short-backend + +# Start dev server DATABASE_URL=postgres://postgres:JBNGlQ9wNFLlYWc2mG@localhost:5432/margin pnpm dev -``` -### Build -```bash -cd apps/long-short-backend +# Build pnpm build ``` ## Docker -### Build Docker Image ```bash -docker compose build long-short-backend +docker compose build long-short-backend # Build image +docker compose up -d # Start services +docker compose logs -f long-short-backend # View logs +docker compose exec long-short-backend pnpm --filter=long-short-backend run:migrate # Migrate in Docker +docker compose exec long-short-backend pnpm --filter=long-short-backend run:seed # Seed in Docker ``` - -### Start Services -```bash -docker compose up -d -``` - -### View Logs -```bash -docker compose logs -f long-short-backend -``` - -### Run Migration in Docker -```bash -docker compose exec long-short-backend pnpm --filter=long-short-backend run:migrate -``` - -### Run Seed in Docker -```bash -docker compose exec long-short-backend pnpm --filter=long-short-backend run:seed -``` - -## API Endpoints - -- `GET /health` - Health check -- `GET /metadata` - Get market configurations -- `POST /position/create` - Create a new position - -### Get Metadata Response -```json -{ - "success": true, - "data": { - "markets": [ - { - "market_id": "ADA-MIN", - "asset_a": "lovelace", - "asset_b": "29d222ce...", - "amm_lp_asset": "...", - "asset_a_q_token_ticker": "qADA", - "asset_a_q_token_raw": "...", - "asset_b_q_token_ticker": "qMIN", - "asset_b_q_token_raw": "...", - "collateral_market_id": "ADA", - "leverage": 2, - "min_collateral": "100000000" - } - ] - } -} -``` - -### Create Position Request -```json -{ - "data": { - "market": "ADA-MIN", - "side": "LONG" | "SHORT", - "amount": "100000000" - }, - "user_address": "addr1...", - "witness": { - "key": "", - "signature": "" - } -} -``` - -**Note:** The `witness` is the CIP-8 signed data of `JSON.stringify(data)`. The signature must be created by signing the hex-encoded JSON string of the `data` object. The `user_address` must match the address that signed the data. - -## Database Tables - -- `position` - User positions -- `order` - Trading orders -- `market_config` - Market configurations (loaded on startup) - -## Market Config Fields - -| Field | Type | Description | -|-------|------|-------------| -| market_id | string | Primary key (e.g., "ADA-MIN") | -| asset_a | string | First asset | -| asset_b | string | Second asset | -| amm_lp_asset | string | Minswap LP token | -| asset_a_q_token_ticker | string | Liqwid qToken ticker for asset A | -| asset_a_q_token_raw | string | Liqwid qToken raw asset for asset A | -| asset_b_q_token_ticker | string | Liqwid qToken ticker for asset B | -| asset_b_q_token_raw | string | Liqwid qToken raw asset for asset B | -| collateral_market_id | string | Liqwid market ID | -| leverage | number | Leverage multiplier | -| min_collateral | bigint | Minimum collateral in lovelace | -| enable | boolean | Market enabled | diff --git a/.claude/specs/felis-build-tx.md b/.claude/specs/felis-build-tx.md index 877dde3..a3817ad 100644 --- a/.claude/specs/felis-build-tx.md +++ b/.claude/specs/felis-build-tx.md @@ -4,97 +4,8 @@ DEX transaction builder. Depends on `felis-ledger-core`, `felis-ledger-utils`, ` **Location:** `packages/minswap-build-tx` -## DEXOrderTransaction — DEX Order Building - -### Main Entry Point -```typescript -DEXOrderTransaction.createBulkOrdersTx(options: BulkOrdersOption): TxBuilder - -type BulkOrdersOption = { - networkEnv: NetworkEnvironment - sender: Address - orderOptions: MultiDEXOrderOptions[] // Array of orders to batch - outerTxb?: TxBuilder // Reuse existing builder - receiver?: Address // Optional alternate receiver -} -``` - -### Order Option Types -```typescript -type V2SwapExactInOptions = { - lpAsset: Asset; version: DexVersion.DEX_V2; - type: OrderV2StepType.SWAP_EXACT_IN; - assetIn: Asset; amountIn: bigint; - minimumAmountOut: bigint; direction: OrderV2Direction; - killOnFailed: boolean; isLimitOrder: boolean; -} - -// Also: V2SwapExactOutOptions, V2DepositOptions, V2WithdrawOptions, -// V2StopOptions, V2OCOOptions, V2ZapOutOptions, V2PartialSwapOptions, -// V2WithdrawImbalanceOptions, V2MultiRoutingOptions -``` - -### Helper Functions -```typescript -DEXOrderTransaction.buildOrderValue(option): Value // Calculate UTxO value needed -DEXOrderTransaction.buildV2OrderStep(option): OrderV2Step // Convert to Plutus step -DEXOrderTransaction.getOrderMetadata(option): string // Transaction label -``` - -## Djed — Stablecoin Protocol - -```typescript -namespace Djed { - getConfig(networkEnv): Config // Lazy singleton - getPoolData(poolUtxo): PoolData // ADA reserve, DJED/SHEN circulation - getOracleData(oracleUtxo): OracleData // Exchange rate, price bounds - - estimateMintShen(options): EstimateResult // Calculate with slippage - mintShen(options): TxBuilder // Build mint transaction - - namespace Rate { - shenAdaRate(params): BigNumber - shen2ada(options): BigNumber - ada2shen(options): BigNumber - } - - namespace DexFee { - getFee(amount, networkEnv): bigint // min(max(ceil(amount * pct), min), max) - } -} -``` - -## MetadataMessage — Transaction Labels - -```typescript -// DEX: DEX_MARKET_ORDER, DEX_LIMIT_ORDER, DEX_STOP_ORDER, DEX_OCO_ORDER -// Liquidity: DEX_DEPOSIT_ORDER, DEX_WITHDRAW_ORDER, DEX_ZAP_IN_ORDER -// Advanced: DEX_PARTIAL_SWAP_ORDER, DEX_ROUTING_ORDER, DEX_MIXED_ORDERS -// Farming: STAKE_LIQUIDITY_V2, HARVEST_V2 -``` - -## Usage Example -```typescript -const txb = DEXOrderTransaction.createBulkOrdersTx({ - networkEnv: NetworkEnvironment.MAINNET, - sender: Address.fromBech32("addr1..."), - orderOptions: [{ - lpAsset: Asset.fromString("..."), - version: DexVersion.DEX_V2, - type: OrderV2StepType.SWAP_EXACT_IN, - assetIn: ADA, - amountIn: 100_000_000n, - minimumAmountOut: 1n, - direction: OrderV2Direction.A_TO_B, - killOnFailed: false, - isLimitOrder: false, - }], -}); - -const result = await txb.complete({ - changeAddress: sender, - provider, - walletUtxos, - coinSelectionAlgorithm: CoinSelectionAlgorithm.MINSWAP, -}); -``` +## What's here +- `DEXOrderTransaction.createBulkOrdersTx()` — main entry point, batches multiple DEX orders into one tx +- Order option types for each V2 step (SwapExactIn, SwapExactOut, Deposit, Withdraw, etc.) +- `Djed` — stablecoin protocol (mint/rate calculations) +- `MetadataMessage` — transaction label constants (DEX_MARKET_ORDER, DEX_LIMIT_ORDER, etc.) diff --git a/.claude/specs/felis-cip.md b/.claude/specs/felis-cip.md index b5cefc7..17579bf 100644 --- a/.claude/specs/felis-cip.md +++ b/.claude/specs/felis-cip.md @@ -1,51 +1,11 @@ # @minswap/felis-cip -Cardano Improvement Proposals implementation. Depends on `felis-ledger-core`, `felis-ledger-utils`. +Cardano Improvement Proposals. Depends on `felis-ledger-core`, `felis-ledger-utils`. **Location:** `packages/cip` -## Modules - -### Bip32 — HD Wallet Key Derivation -```typescript -namespace Bip32 { - deriveAddress({ bip32PublicKeyHex, deriveOffsets, networkEnv }): Address[] - genPubKeyHashes(accountKey): Set - filterUtxos(publicKey, utxos): Utxo[] - extractPublicKey(seed): CSLBip32PublicKey - extractBip32PrivateKey(seed): string - extractPrivateKey(options): PrivateKey -} -``` - -### Bip39 — Mnemonic Wallet Creation -```typescript -type BaseAddressWallet = { address: Address; rewardAddress: RewardAddress; paymentKey: PrivateKey; stakeKey: PrivateKey } -type EnterpriseAddressWallet = { address: Address; paymentKey: PrivateKey } - -baseAddressWalletFromSeed(seed, networkEnv, options?): BaseAddressWallet -enterpriseAddressWalletFromSeed(seed, networkEnv, options?): EnterpriseAddressWallet -baseWalletFromEntropy(entropyHex, networkId): BaseAddressWallet -``` - -### CIP-25 — NFT Metadata Standard -```typescript -type CIP25NFT = { asset: Asset; name: string; image: string; mediaType?: string; files?: CIP25File[] } -type CIP25Metadata = { [policyId: string]: { [assetName: string]: Omit } } -``` - -### CIP-68 — Token Standard (Reference NFTs) -```typescript -enum Cip68UserTokenLabel { NFT = "000de140", FT = "0014df10" } - -namespace CIP68 { - isRefNFT(asset): boolean - isNFT(asset): boolean - isFT(asset): boolean - isCip68(assetNameHex): boolean - fromDataHex(datum, label): Cip68UserTokenAsset - toDataHex(metadata): string - mintCip68Token(options): Cip68MintTokenResult - buildFTFromRefNFT(refNft): Maybe -} -``` +## What's here +- `Bip32` — HD wallet key derivation, address generation, UTXO filtering +- `Bip39` — mnemonic seed to `BaseAddressWallet` / `EnterpriseAddressWallet` +- `CIP-25` — NFT metadata standard types +- `CIP-68` — reference NFT token standard (isRefNFT, isFT, isNFT, mint helpers) diff --git a/.claude/specs/felis-dex-v1.md b/.claude/specs/felis-dex-v1.md index 989f6ec..cba57f3 100644 --- a/.claude/specs/felis-dex-v1.md +++ b/.claude/specs/felis-dex-v1.md @@ -1,57 +1,10 @@ # @minswap/felis-dex-v1 -Minswap DEX V1 protocol types. Depends on `felis-ledger-core`, `felis-ledger-utils`. +Legacy DEX V1 protocol types. Depends on `felis-ledger-core`, `felis-ledger-utils`. **Location:** `packages/minswap-dex-v1` -## Purpose -Handles DEX V1 order parsing and validation. V1 is the legacy DEX protocol — mainly used for backward compatibility and syncing historical orders. - -## Key Exports - -### Order -```typescript -class Order { - datum: OrderDatum - orderInfo: OrderInfo // { type: "SWAP", swapAsset, swapAmount, toAsset } - - static fromUtxo(utxo, datum, networkEnv): Result -} - -type OrderDatum = { - sender: Address - receiver: Address - step: OrderStep - batcherFee: bigint - outputADA: bigint -} -``` - -### StepType -```typescript -enum StepType { - SWAP_EXACT_IN - SWAP_EXACT_OUT - DEPOSIT - WITHDRAW - ZAP_IN - // ... others -} -``` - -### Scripts -Contains compiled Plutus V1 scripts for mainnet and testnet (order validators, vesting scripts). - -### Constants -DEX V1 configuration data for mainnet/testnet: -- Script hashes, addresses -- Factory tokens, pool tokens -- Batcher fee configurations - -## Usage -Primarily consumed by the syncer package for parsing V1 swap orders from blockchain transactions. - -```typescript -import { Order, StepType } from "@minswap/felis-dex-v1"; -const orderResult = Order.fromUtxo(utxo, datum, networkEnv); -``` +## What's here +- `Order` — V1 order parsing from UTXOs (SWAP_EXACT_IN, DEPOSIT, WITHDRAW, etc.) +- Compiled Plutus V1 scripts for mainnet/testnet +- Primarily consumed by the syncer for historical order parsing diff --git a/.claude/specs/felis-dex-v2.md b/.claude/specs/felis-dex-v2.md index ab400e4..16089d0 100644 --- a/.claude/specs/felis-dex-v2.md +++ b/.claude/specs/felis-dex-v2.md @@ -1,110 +1,13 @@ # @minswap/felis-dex-v2 -Minswap DEX V2 protocol types and calculations. Depends on `felis-ledger-core`, `felis-ledger-utils`. +DEX V2 protocol types and calculations. Depends on `felis-ledger-core`, `felis-ledger-utils`. **Location:** `packages/minswap-dex-v2` -## OrderV2 — DEX Orders - -### Order Types (OrderV2StepType) -```typescript -enum OrderV2StepType { - SWAP_EXACT_IN=0, SWAP_EXACT_OUT=1, STOP_LOSS=2, OCO=3, - DEPOSIT=4, WITHDRAW=5, ZAP_OUT=6, PARTIAL_SWAP=7, - WITHDRAW_IMBALANCE=8, SWAP_MULTI_ROUTING=9, DONATION=10 -} -enum OrderV2Direction { A_TO_B, B_TO_A } -enum DexVersion { DEX_V1, DEX_V2, STABLESWAP } -``` - -### OrderV2 Class -```typescript -class OrderV2 extends BaseUtxoModel { - static new(constr): Result - static fromUtxo(utxo): Result - owner: Address - lpAsset: Asset - canceller: Address - isExpired(currentSlot): boolean - getSwapAmount() / getDepositAmount() / getWithdrawAmount() -} -``` - -### OrderV2Datum (Plutus Serialization) -```typescript -type OrderV2Datum = { - author: OrderV2Author // canceller, refundReceiver, successReceiver - lpAsset: Asset - step: OrderV2Step // Discriminated union of 11 types - maxBatcherFee: bigint - expiredOptions?: OrderV2ExpirySetting -} -namespace OrderV2Datum { - fromPlutusJson(d) / toPlutusJson(d) - fromDataHex(hex, networkEnv) / toDataHex(d) -} -``` - -## PoolV2 — Liquidity Pools - -```typescript -class PoolV2 extends BaseUtxoModel { - static fromUtxo(utxo): Result - assetA: Asset; assetB: Asset; lpAsset: Asset - totalLiquidity: bigint - datumReserveA/B: bigint; valueReserveA/B: bigint - baseFee: { feeANumerator: bigint; feeBNumerator: bigint } // denominator=10000 - getDirectionByAssetIn(asset): OrderV2Direction - cloneNewPoolState(newReserves): PoolV2 - static computeLpAsset(assetA, assetB): Asset // SHA3 derived -} -``` - -## DexV2Calculation — Math Engine - -```typescript -namespace DexV2Calculation { - // Swaps - calculateSwapExactIn(options): { amountOut, newReserves, volume, fee } - calculateSwapExactOut(options): Result<{ necessaryAmountIn, ... }, Error> - calculateAmountOut({reserveIn, reserveOut, amountIn, tradingFeeNum}): bigint - calculateAmountIn({reserveIn, reserveOut, amountOut, tradingFeeNum}): bigint - - // Liquidity - calculateInitialLiquidity(amountA, amountB): bigint // sqrt(a*b) - calculateDeposit(options): { lpAmount, ... } - calculateWithdraw(options): { amountA, amountB } - calculateWithdrawAmount(options): { withdrawnA, withdrawnB } - - // Advanced - calculateZapOut(options): { swapAmount, amountOut } - calculatePartialSwap(options): Result<{ swapableAmount, amountOut }, Error> - calculateSwapMultiRouting(options): { amountOut, midPrice } - - // Analytics - calculatePriceImpact(options): Result // Percentage - calculateEarnedFeeIn(options): bigint -} -``` - -## Configuration - -```typescript -getDexV2Configs(networkEnv): DexV2Config -getDexV2PoolAddresses(networkEnv): string[] -getDefaultDexV2OrderAddress(networkEnv): string -getDexV2OrderScriptHash(networkEnv): string -buildDexV2OrderAddress(networkEnv, stakeCredential): string -``` - -## Batcher Fees -```typescript -BATCHER_FEE_DEX_V2: Record -// Swaps: 700_000, Deposits: 750_000, Routing: 900_000, etc. -``` - -## Error Handling -```typescript -class InvalidOrder { txIn; address; owner; error: OrderError } -enum ErrorCode { MISSING_DATUM_HASH, INVALID_PARAMETER, EXPIRED, ... } -``` +## What's here +- `OrderV2` — 11 step types (SWAP_EXACT_IN, STOP_LOSS, OCO, DEPOSIT, WITHDRAW, PARTIAL_SWAP, MULTI_ROUTING, etc.) +- `PoolV2` — liquidity pool state, reserves, fees (denominator=10000) +- `DexV2Calculation` — swap/deposit/withdraw math, price impact, multi-routing +- `OrderV2Datum` — Plutus serialization (fromPlutusJson/toPlutusJson, fromDataHex/toDataHex) +- Config helpers: `getDexV2Configs()`, `getDexV2PoolAddresses()`, `buildDexV2OrderAddress()` +- `BATCHER_FEE_DEX_V2` — fee schedule per step type diff --git a/.claude/specs/felis-ledger-core.md b/.claude/specs/felis-ledger-core.md index 362b043..fc5dc15 100644 --- a/.claude/specs/felis-ledger-core.md +++ b/.claude/specs/felis-ledger-core.md @@ -4,150 +4,15 @@ Cardano blockchain primitives. Depends on `felis-ledger-utils`. **Location:** `packages/ledger-core` -## Core Types - -### Address -```typescript -class Address { - bech32: string - static fromBech32(s): Address - static fromHex(s: CborHex): Address - toHex(): CborHex - toStakeAddress(): RewardAddress | null - toPubKeyHash(): Maybe - toPlutusJson(): PlutusData - static fromPlutusJson(d, networkEnv): Address - equals(other): boolean -} - -class RewardAddress extends Address { - isPubKey(): boolean - isScript(): boolean -} -``` - -### Asset -```typescript -class Asset { - currencySymbol: Bytes // 28-byte policy ID - tokenName: Bytes // 0-32 bytes - static fromString(s): Asset // "policyID.tokenName" or "lovelace" - static fromBlockFrostString(s): Asset - toBlockFrostString(): string - toString(): string - equals(other): boolean - compare(other): number -} -const ADA: Asset // lovelace sentinel -``` - -### Value (Multi-Asset) -```typescript -class Value { - get(asset): bigint - coin(): bigint // ADA amount - set(asset, x): Value - add(asset, x): Value - subtract(asset, x): Value - addAll(other): Value - subtractAll(other): Value - has(asset): boolean - assets(): Asset[] - canCover(other): boolean - isAdaOnly(): boolean - toHex(): CborHex - static fromHex(input): Value - getMinimumLovelace(isScript, networkEnv): bigint -} -``` - -### UTXO / TxIn / TxOut -```typescript -type Utxo = { input: TxIn; output: TxOut } -type TxIn = { txId: Bytes; index: number } -namespace TxIn { - fromString(s): TxIn // "txId#index" - toString(txIn): string - compare(a, b): number - equals(a, b): boolean - toPlutusJson / fromPlutusJson -} - -class TxOut { - address: Address - value: Value - datumSource: Maybe - scriptRef: Maybe - static newPubKeyOut({address, value}): TxOut - static newScriptOut({address, value, datumSource}): TxOut - getMinimumADA(networkEnv): bigint - addMinimumADAIfRequired(networkEnv): TxOut - getInlineDatum(): Result - toHex() / fromHex() -} - -enum DatumSourceType { - DATUM_HASH // Plutus V1 - OUTLINE_DATUM // Hash + datum in witness - INLINE_DATUM // Plutus V2+ (inline) -} -``` - -### Transaction -```typescript -type TxBody = { - inputs: Utxo[]; outputs: TxOut[]; fee: bigint; - mint: Value; withdrawals: Withdrawals; - validity?: ValidityRange; referenceInputs: Utxo[]; - requireSigners: PublicKeyHash[]; -} -type Transaction = { body: TxBody; witness: Witness; metadata: Record } -``` - -### Crypto -```typescript -class PrivateKey { toPublic(): PublicKey; toCSL(); toECSL() } -class PublicKey { key: Bytes; toPublicKeyHash(): PublicKeyHash } -class PublicKeyHash { keyHash: Bytes; equals(other): boolean } -``` - -### Bytes -```typescript -class Bytes { - hex: string; bytes: Uint8Array - static fromHex(s) / fromString(s) / fromBase64(s) / fromPlutusJson(d) - toHex() / toString() / toPlutusJson() - equals(other) / compare(other) / concat(other) -} -``` - -### PlutusData (Serialization) -```typescript -type PlutusData = PlutusConstr | PlutusList | PlutusMap | PlutusInt | PlutusBytes -PlutusConstr.unwrap(d, constraints): T -PlutusInt.unwrapToBigInt(d): bigint -PlutusBytes.unwrap(d): string // hex -``` - -### NetworkEnvironment -```typescript -enum NetworkEnvironment { MAINNET=764824073, TESTNET_PREVIEW=2, TESTNET_PREPROD=1 } -``` - -### XJSON — Type-Preserving JSON -```typescript -XJSON.stringify(a): string // Preserves bigint, BigNumber, Date, Bytes, Asset, Address, Value -XJSON.parse(s): T -``` - -### Slot/Time Conversion -```typescript -getTimeFromSlotMagic(network, slot): Date -getSlotFromTimeMagic(network, time): number -``` - -### Protocol Parameters -```typescript -DEFAULT_STABLE_PROTOCOL_PARAMS[networkEnv]: StableProtocolParams -// txFeeFixed, txFeePerByte, utxoCostPerByte, maxTxSize, etc. -``` +## What's here +- `Address` — bech32/hex conversion, stake address extraction, PlutusJson serialization +- `Asset` — policy ID + token name, `ADA` constant for lovelace +- `Value` — multi-asset container (get/set/add/subtract/canCover) +- `TxIn` / `TxOut` / `Utxo` — transaction inputs/outputs +- `Transaction` / `TxBody` — full transaction types +- `PrivateKey` / `PublicKey` / `PublicKeyHash` — crypto keys +- `Bytes` — hex/string/base64 wrapper +- `PlutusData` — Plutus serialization (Constr, List, Map, Int, Bytes) +- `NetworkEnvironment` — MAINNET (764824073), TESTNET_PREVIEW (2), TESTNET_PREPROD (1) +- `XJSON` — type-preserving JSON (bigint, Date, Bytes, Asset, Address, Value) +- `getTimeFromSlotMagic` / `getSlotFromTimeMagic` — slot/time conversion diff --git a/.claude/specs/felis-ledger-utils.md b/.claude/specs/felis-ledger-utils.md index c666997..de3431a 100644 --- a/.claude/specs/felis-ledger-utils.md +++ b/.claude/specs/felis-ledger-utils.md @@ -4,73 +4,12 @@ Foundation utility library. All other packages depend on this. **Location:** `packages/ledger-utils` -## Key Exports - -### Result — Error Handling -```typescript -Result.ok(value) // Create success -Result.err(error) // Create error -Result.isOk(r) // Type guard -Result.isError(r) // Type guard -Result.unwrap(r) // Extract or throw -Result.flatten(r) // [T, null] | [null, E] -``` - -### Maybe — Optional Values -```typescript -Maybe.isNothing(a) // null | undefined check -Maybe.isJust(a) // Value exists -Maybe.map(a, f) // Apply if exists -Maybe.unwrap(a, errMsg) // Extract or throw -``` - -### Duration — Time Handling -```typescript -Duration.newSeconds(x) / .newMinutes(x) / .newHours(x) / .newDays(x) -Duration.before(date, d) / .after(date, d) / .between(d1, d2) -``` - -### Crypto -```typescript -blake2b256(buffer): string // Blake2b-256 hash (hex) -blake2b224(buffer): string // Blake2b-224 hash (hex) -sha3(hex): string // SHA3-256 hash -``` - -### Bech32 -```typescript -encodeBech32(hrp, data): string -decodeBech32(s): { hrp, data } -``` - -### Hex Validation -```typescript -isValidHex(s): boolean -isValidBase64(s): boolean -``` - -### WASM Module Loader -```typescript -await RustModule.load() // Must call before any WASM ops -RustModule.get // Minswap CSL -RustModule.getE // Emurgo CSL (v13) -RustModule.getU // UPLC module -``` - -### Rust Object Management -```typescript -safeFreeRustObjects(...objs) // Safe cleanup (handles double-free) -unwrapRustVec(vec) // RustVec → T[] -unwrapRustMap(map) // RustMap → [K,V][] -``` - -### Branded Type -```typescript -type CborHex<_> = string // Phantom type for CBOR hex strings -``` - -### Error Utilities -```typescript -getErrorMessage(error): string // Safe stringify (handles BigInt) -parseIntSafe(str): number // Throws on NaN -``` +## What's here +- `Result` / `Maybe` — error handling and optionals +- `Duration` — time arithmetic +- `blake2b256`, `blake2b224`, `sha3` — crypto hashes +- `encodeBech32` / `decodeBech32` +- `RustModule` — WASM loader (must call `await RustModule.load()` before any WASM ops) +- `CborHex` — branded type for CBOR hex strings +- `getErrorMessage(error)` — safe stringify that handles BigInt +- `safeFreeRustObjects()` — prevents double-free on WASM objects diff --git a/.claude/specs/felis-lending-market.md b/.claude/specs/felis-lending-market.md index a7ff340..986b708 100644 --- a/.claude/specs/felis-lending-market.md +++ b/.claude/specs/felis-lending-market.md @@ -4,162 +4,17 @@ Liqwid Finance lending protocol integration. Depends on `felis-ledger-core`, `fe **Location:** `packages/minswap-lending-market` -## LiqwidProviderV2 — Liqwid GraphQL API Client - -Type-safe namespace wrapping the Liqwid Finance V2 GraphQL API. All functions return `Result`. - -### Configuration -```typescript -type ApiConfig = { - networkEnv: NetworkEnvironment; - clientEndpoint?: string; // Browser proxy override -} - -// API endpoints: -// MAINNET: "https://v2.api.liqwid.finance/graphql" -// PREPROD: "https://v2.api.preprod.liqwid.dev/graphql" -// PREVIEW: "https://v2.api.preview.liqwid.dev/graphql" - -const config = LiqwidProviderV2.createConfig(NetworkEnvironment.MAINNET); -``` - -### Common Types -```typescript -type MarketId = "Ada" | "MIN" | "DJED" | "iUSD" | "SHEN" | "LQ" | "HUNT" | "WMT" | "LENFI" | "NIGHT" -type CollateralId = `${MarketId}.${string}` // e.g. "Ada.policyId..." -type Currency = "EUR" | "USD" | "GBP" | "CAD" | "BRL" | "JPY" | "VND" | "CZK" | "AUD" | "SGD" | "CHF" -type SupportedWallet = "ETERNL" | "BEGIN" - -type UserAddressInput = { - address: string; changeAddress?: string; - otherAddresses?: string[]; utxos: string[]; -} - -type BorrowCollateralInput = { id: string; tokenName?: string; amount: number } - -type Pagination = { - page: number; perPage: number; - pagesCount: number; totalCount: number; - results: T[]; -} -``` - -### Transactions Namespace -Builds unsigned transaction CBOR via GraphQL. Returns `Result` (CBOR hex). - -```typescript -namespace Transactions { - // Supply tokens to a lending market - supply(config, input: SupplyTransactionInput): Promise> - // SupplyTransactionInput = UserAddressInput & { marketId, amount, wallet?, mintedQTokensDestination? } - - // Withdraw tokens from a lending market - withdraw(config, input: WithdrawTransactionInput): Promise> - // WithdrawTransactionInput = UserAddressInput & { marketId, amount, wallet?, withdrawnUnderlyingDestination? } - - // Borrow against collateral (creates new loan) - borrow(config, input: BorrowTransactionInput): Promise> - // BorrowTransactionInput = UserAddressInput & { marketId, amount, collaterals[], principalDestination? } - - // Modify existing loan (borrow more or partial repay) - modifyBorrow(config, input: ModifyBorrowTransactionInput): Promise> - // ModifyBorrowTransactionInput = UserAddressInput & { txId, amount, collaterals[], redeemCollateral? } - - // Full repay loan (internally calls modifyBorrow with amount=0) - repayLoan(config, input: RepayLoanTransactionInput): Promise> - // RepayLoanTransactionInput = UserAddressInput & { loanUtxoId: "{txHash}-{outputIndex}", collaterals[] } - - // Submit signed transaction to Liqwid - submit(config, input: { transaction: string; signature: string }): Promise> -} -``` - -### Calculations Namespace -Pre-flight calculations for fee estimation and health factors. - -```typescript -namespace Calculations { - loan(config, input: LoanCalculationInput, currency?): Promise> - // Input: { market: MarketId, debt: number, collaterals: [{id, amount}] } - // Result: { healthFactor, maxBorrow, maxBorrowCap, batchingFee, protocolFee, - // protocolFeePercentage, collateral, collaterals: [{id, amount, LTV, healthFactor}] } - - supply(config, input: SupplyCalculationInput): Promise> - // Input: { marketId, amount, wallet? } - // Result: { batchingFee, supplyCap, walletFee } - - withdraw(config, input: WithdrawCalculationInput): Promise> - // Input: { marketId, amount, wallet? } - // Result: { batchingFee, walletFee, withdrawCap } - - netApy(config, input: NetApyInput): Promise> - // Input: { paymentKeys[], supplies: [{marketId, amount}], currency? } - // Result: { netApy, netApyLqRewards, borrowApy, totalBorrow, supplyApy, totalSupply } -} -``` - -### Data Namespace -Query market and loan data. - -```typescript -namespace Data { - markets(config, input?: MarketsInput, currency?): Promise, Error>> - // Market: { id, displayName, symbol, supply, borrow, liquidity, supplyAPY, borrowAPY, - // lqSupplyAPY, utilization, exchangeRate, batching, frozen, private, delisting, - // prime, loanOriginationFeePercentage, asset: Asset, receiptAsset: Asset } - - loans(config, input: LoansInput, currency?): Promise, Error>> - // Loan: { id, transactionId, transactionIndex, marketId, publicKey, amount, - // adjustedAmount, collateral, interest, APY, LTV, healthFactor, time, - // collaterals: LoanCollateral[], market: Market, asset: Asset } - - yieldEarned(config, input: YieldEarnedInput, currency?): Promise> - // Input: { addresses[], date?: { startTime, endTime } } - - market(config, marketId: MarketId, currency?): Promise> - loansForUser(config, paymentKeys: string[], currency?): Promise> -} -``` - -### Utilities -```typescript -// Get tx hash from CBOR-encoded transaction (blake2b256 of body) -getTxHash(txCborHex: string): string - -// Sign Liqwid transaction with private key, returns witness set hex -signTx(txCborHex: string, privateKey: PrivateKey): string - -// Create API config helper -createConfig(networkEnv: NetworkEnvironment, clientEndpoint?: string): ApiConfig -``` - -## Usage Example -```typescript -import { LiqwidProviderV2 } from "@minswap/felis-lending-market"; - -const config = LiqwidProviderV2.createConfig(NetworkEnvironment.MAINNET); - -// Supply ADA to lending market -const txResult = await LiqwidProviderV2.Transactions.supply(config, { - address: "addr1...", - utxos: ["utxoCbor1", "utxoCbor2"], - marketId: "Ada", - amount: 100_000_000, -}); - -if (txResult.type === "ok") { - const txCbor = txResult.value; - const signature = LiqwidProviderV2.signTx(txCbor, privateKey); - await LiqwidProviderV2.Transactions.submit(config, { transaction: txCbor, signature }); -} - -// Query user loans -const loansResult = await LiqwidProviderV2.Data.loansForUser(config, [paymentKeyHash]); - -// Calculate borrow health factor -const calcResult = await LiqwidProviderV2.Calculations.loan(config, { - market: "Ada", - debt: 50_000_000, - collaterals: [{ id: "Ada.policyId...", amount: 100 }], -}); -``` +## What's here +- `LiqwidProviderV2` — namespace wrapping Liqwid V2 GraphQL API + +### Namespaces +- `Transactions` — supply, withdraw, borrow, modifyBorrow, repayLoan, submit (returns CBOR hex) +- `Calculations` — loan health factor, supply/withdraw caps, net APY +- `Data` — query markets, loans, user loans, yield earned +- `signTx()` / `getTxHash()` — sign and hash Liqwid transactions + +## Gotchas +- Liqwid V2 API uses `number` for amounts (not `bigint` like the rest of the codebase) +- `MarketId` is a string like "Ada", "MIN", "DJED" (not the market_id from our DB) +- `loanUtxoId` format is `"{txHash}-{outputIndex}"` (dash separator, not hash) +- API endpoints differ per network: `v2.api.liqwid.finance` (mainnet), `v2.api.preprod.liqwid.dev` (preprod), `v2.api.preview.liqwid.dev` (preview) diff --git a/.claude/specs/felis-tx-builder.md b/.claude/specs/felis-tx-builder.md index ae28a7c..810d46e 100644 --- a/.claude/specs/felis-tx-builder.md +++ b/.claude/specs/felis-tx-builder.md @@ -4,91 +4,9 @@ High-level Cardano transaction composition. Depends on `felis-ledger-core`, `fel **Location:** `packages/tx-builder` -## TxBuilder — Fluent Transaction Builder - -```typescript -const txb = new TxBuilder(networkEnv); - -// Inputs -txb.readFrom(...utxos) // Reference inputs (read-only) -txb.collectFromPubKey(...utxos) // Spend from pubkey -txb.collectFromPlutusContract(utxos, redeemer, datum?) // Spend from script - -// Outputs -txb.payTo(...outputs) // Payment outputs -txb.addSigner(address) / addSignerKey(keyHash) // Required signers - -// Minting -txb.mintAssets(value, redeemer?) // Mint/burn tokens - -// Time -txb.validFrom(slot) / validTo(slot) -txb.validFromUnixTime(ts) / validToUnixTime(ts) - -// Scripts -txb.attachValidator(validator) // Native/PlutusV1/V2/V3 - -// Metadata -txb.addMessageMetadata("msg", data) - -// Build -const result = await txb.complete({ - changeAddress, provider, walletUtxos, - coinSelectionAlgorithm: CoinSelectionAlgorithm.MINSWAP, -}); -``` - -## TxComplete — Signing & Assembly -```typescript -txComplete.signWithPrivateKey(...privateKeys) // Sign and assemble -txComplete.partialSignWithPrivateKey(...keys) // Get partial witness -txComplete.assemble(witnesses) // Assemble external witnesses -``` - -## Build Options -```typescript -type TxBuilderBuildOptions = { - changeAddress: Address; - provider: ITxBuilderProvider; // getUnstableProtocolParams() - walletUtxos: Utxo[]; - walletCollaterals?: Utxo[]; - coinSelectionAlgorithm: CoinSelectionAlgorithm; - extraFee?: bigint; -} -``` - -## CoinSelectionAlgorithm -```typescript -enum CoinSelectionAlgorithm { - MINSWAP // Smart selection + change splitting - SPEND_ALL // Single change output - SPEND_ALL_V2 // Enhanced spend-all -} -``` - -## Utilities -```typescript -// UTXO Selection -UtxoSelection.selectUtxos(required, available, splitChange, changeAddr, networkEnv) -UtxoSelection.selectCollaterals({walletCollaterals, walletUtxos, ...}) - -// Fee Calculation -TxBuilderUtils.maxTxSizeFee(networkEnv): bigint -TxBuilderUtils.calContractFee(networkEnv, exUnit): bigint -TxBuilderUtils.calReferenceInputsFee({inputs, referenceInputs, referenceFeeCfg}): bigint - -// Change Management -ChangeOutputBuilder.buildChangeOut({networkEnv, txDraft, changeAddress, walletUtxos, protocolParams}) - -// Transaction Chaining -TxDraft.extractUtxoState({txId, txDraft, changeAddress, walletUtxos}): UtxoState -``` - -## EmulatorProvider -Off-chain provider for testing (implements ITxBuilderProvider without blockchain). - -## Key Constants -``` -MAX_TOKEN_BUNDLE_SIZE = 20 -DEFAULT_COLLATERAL_AMOUNT = 5_000_000n (5 ADA) -``` +## What's here +- `TxBuilder` — fluent API: collectFrom, payTo, mintAssets, validFrom/To, attachValidator, complete() +- `TxComplete` — signing (signWithPrivateKey, partialSign, assemble) +- `CoinSelectionAlgorithm` — MINSWAP (smart + change splitting), SPEND_ALL, SPEND_ALL_V2 +- `UtxoSelection` — UTXO selection and collateral selection +- `EmulatorProvider` — off-chain provider for testing diff --git a/.claude/specs/long-short-backend.md b/.claude/specs/long-short-backend.md index 96be50e..e9738ed 100644 --- a/.claude/specs/long-short-backend.md +++ b/.claude/specs/long-short-backend.md @@ -1,258 +1,30 @@ # long-short-backend -Leveraged long/short trading API. Integrates Minswap DEX with Liqwid lending protocol. +Leveraged long/short trading API. Integrates Minswap DEX with Liqwid lending. **Location:** `apps/long-short-backend` -## Database Schema - -### position -```sql -id bigserial PK -market_id varchar -- FK to market_config -user_address varchar -- Cardano bech32 address -side varchar -- "LONG" | "SHORT" -status varchar -- "PENDING" | "OPEN" | "CLOSING" | "CLOSED" -amount_in numeric -- Initial collateral amount -amount_borrow numeric -- Amount borrowed from Liqwid -created_at timestamp -closed_at timestamp? -- Set when CLOSED --- Unique: one open position per user per market (closed_at IS NULL) -``` - -### order -```sql -id bigserial PK -position_id bigint -- References position.id -order_type varchar -- LONG_BUY, LONG_SUPPLY, etc. -asset_in varchar? -- Input asset (set when order is ready) -amount_in numeric? -asset_out varchar? -amount_out numeric? -- Set after output is consumed -created_tx_id varchar? -- Transaction hash confirmed on chain -created_tx_index integer? -- Output index -built_tx_id varchar? -- Transaction hash when built (not yet confirmed) -built_outputs_hash varchar? -- Hash of change outputs -built_valid_to timestamp? -- Transaction expiry -waiting boolean -- True when confirmed, waiting for output spend -``` - -### market_config -```sql -market_id varchar PK -- e.g. "ADA-MIN" -asset_a / asset_b varchar -- Trading pair assets -amm_lp_asset varchar -- Minswap LP token -asset_a_q_token_ticker varchar -- Liqwid qToken ticker (e.g. "Ada") -asset_a_q_token_raw varchar -- Liqwid qToken raw asset string -asset_b_q_token_ticker varchar -- e.g. "MIN" -asset_b_q_token_raw varchar -collateral_market_id varchar -- Liqwid CollateralId for supply -borrow_market_id_long varchar -- Liqwid MarketId for long borrow -borrow_market_id_short varchar -- Liqwid MarketId for short borrow -leverage numeric -- Leverage multiplier -min_collateral numeric -- Minimum collateral required -enable boolean -``` - -## Order State Machine - -### Long Position — Open (4 orders) -``` -LONG_BUY → Buy asset B with ADA via DEX swap -LONG_SUPPLY → Supply asset B to Liqwid, receive qToken -LONG_BORROW → Borrow ADA against qToken collateral -LONG_BUY_MORE → Buy more asset B with borrowed ADA → position OPEN -``` - -### Long Position — Close (4 orders) -``` -LONG_SELL → Sell asset B for ADA via DEX swap -LONG_REPAY → Repay loan to Liqwid, redeem qToken collateral -LONG_WITHDRAW → Withdraw underlying asset B from Liqwid -LONG_SELL_ALL → Sell all remaining asset B for ADA → position CLOSED -``` - -### Transaction Lifecycle -``` -1. Build tx → save built_tx_id, built_outputs_hash, built_valid_to -2. User signs & submits externally -3. Search chain → update created_tx_id, created_tx_index, waiting = true -4. Wait for output to be spent (DEX batcher or Liqwid) -5. Extract output amount → transition to next order, waiting = false -``` - -## API Endpoints - -| Method | Path | Auth | Description | -|--------|------|------|-------------| -| GET | `/health` | No | Health check | -| GET | `/metadata` | No | Get enabled market configs | -| GET | `/position/get?user_address=` | No | Get user's open positions | -| POST | `/position/create` | CIP-8 | Create new leveraged position | -| POST | `/position/build-tx` | CIP-8 | Build next transaction in order chain | -| POST | `/position/close` | CIP-8 | Close an open position | -| POST | `/liqwid/submit` | CIP-8 | Submit signed Liqwid transaction | - -### Authentication (CIP-8) -```typescript -// Request format for authenticated endpoints -{ - data: { /* payload */ }, - user_address: "addr1...", - witness: { - key: "a401...", // CBOR-encoded COSEKey - signature: "84582a..." // CBOR-encoded COSESign1 - } -} -// Backend SHA256-hashes JSON.stringify(data), verifies against signature -``` - -### Create Position Request -```typescript -{ data: { market_id: string; amount_in: string }, user_address, witness } -// Validates: market supported, amount >= min_collateral, no existing open position -// Creates position + 4 opening orders -// amount_borrow = amount_in * (leverage - 1) + 4_000_000n (fee buffer) -``` - -### Build Tx Request -```typescript -{ data: { position_id: string }, user_address, witness } -// Returns: { tx_raw: string; tx_id: string } or error message -// Handles: waiting check → unhandled order → chain search → build/rebuild -``` - -### Close Position Request -```typescript -{ data: { position_id: string }, user_address, witness } -// Validates: position exists, status OPEN, user owns it -// Creates 4 closing orders, sets status CLOSING -``` - -## State Machine Build Functions - -### DEX Orders (LONG_BUY, LONG_BUY_MORE, LONG_SELL, LONG_SELL_ALL) -```typescript -// Uses DEXOrderTransaction.createBulkOrdersTx() -// Direction: A_TO_B for buy, B_TO_A for sell -// Returns: { txRaw, txId, outputsHash, validTo } -``` - -### LONG_SUPPLY -```typescript -// Uses LiqwidProvider.getSupplyTransaction() (V1 for supply) -// Returns: { txRaw, txId, validTo } +## Key files ``` - -### LONG_BORROW -```typescript -// Uses LiqwidProviderV2.Transactions.borrow() -// Collateral: qToken from LONG_SUPPLY step -// Returns: { txRaw, txId, validTo } -``` - -### LONG_REPAY -```typescript -// Uses LiqwidProviderV2.Transactions.repayLoan() -// loanUtxoId format: "{txHash}-{outputIndex}" -// Redeems qToken collateral -// Returns: { txRaw, txId, validTo } -``` - -### LONG_WITHDRAW -```typescript -// Uses LiqwidProviderV2.Transactions.withdraw() -// Amount: supplyAmountOut from LONG_SUPPLY order -// Returns: { txRaw, txId, validTo } -``` - -## Waiting Functions - -### DEX Order Waiting (LONG_BUY, LONG_SELL, etc.) -```typescript -// Uses CardanoscanProvider.findTransactionHasSpent(address, txHash, outputIndex) -// Extracts received token amount from spending transaction outputs -// Transition: completes current order, prepares next order with asset/amount -``` - -### Liqwid Order Waiting (LONG_SUPPLY, LONG_BORROW, etc.) -```typescript -// Uses CardanoscanProvider.findTransactionByHash(address, txHash) -// Extracts relevant output (qToken, borrowed amount, etc.) -// Transition: completes current order, prepares next order -``` - -## Repository Layer - -### PositionRepository -```typescript -createPosition(db, params): Promise -getPositionById(db, id): Promise -getOpenPositionByUser(db, address): Promise -getOpenPositionByUserAndMarket(db, address, marketId): Promise -getUserOpenPositions(db, address): Promise -getUserPositions(db, address, opts): Promise -updatePositionStatus(db, id, status): Promise // sets closed_at if CLOSED -``` - -### OrderRepository -```typescript -createOrder(db, params) / createOrders(db, params[]) -getOrdersByPositionId(db, positionId): Promise -getNextUnhandledOrder(db, positionId): Promise // asset_in != null, created_tx_id == null -getWaitingOrder(db, positionId): Promise // created_tx_id != null, waiting == true -updateOrderBuiltTx(db, id, { builtTxId, outputsHash, validTo }) -updateOrderCreatedTx(db, id, { txId, txIndex }) -transitionToNextOrder(db, currentId, nextId, { amountOut, assetIn, amountIn }) -completeOrder(db, id, amountOut) -``` - -### MarketConfigRepository -```typescript -getMarketConfigRowById(db, id): Promise -getMarketConfigRowByIdOrThrow(db, id): Promise -``` - -## Provider Layer - -### CardanoscanProvider -```typescript -constructor(baseUrl: string, apiKey: string) -findTransactionByHash(address, txHash, pageSize?, maxPage?): Promise -findTransactionHasSpent(address, txHash, outputIndex, pageSize?, maxPage?): Promise -getTransactionList(addressHex, pageNo, limit): Promise -// Uses address.toHex() for API, apiKey header, pageNo 1-indexed, limit max 50 -``` - -## Configuration - -### Environment Variables -``` -DATABASE_URL PostgreSQL connection (required) -CARDANOSCAN_API_KEY API key (required) -API_PORT Default: 9999 -API_HOST Default: "0.0.0.0" -NETWORK "mainnet" | "testnet" (default: "mainnet") -``` - -### Market Config Cache -```typescript -loadMarketConfigs(db) // Load from DB at startup -getEnabledMarketConfigs() // Get cached enabled markets -getMarketConfig(marketId) // Get single market config -isSupportedMarket(marketId) // Check if supported and enabled -reloadMarketConfigs(db) // Hot reload -``` - -## Key Files -``` -src/api/state-machine.ts -- Build/waiting functions per order type +src/api/state-machine.ts -- Build/waiting functions per order type src/services/position-service.ts -- Core business logic (create, buildTx, close) -src/api/routes/position.ts -- API route handlers -src/api/schemas.ts -- TypeBox request/response schemas -src/api/helper.ts -- CIP-8 authentication -src/repository/ -- Database access layer -src/provider/cardanoscan.ts -- On-chain transaction search -src/config/market.ts -- Market config cache -src/cmd/run-api.ts -- Entry point -``` +src/api/routes/position.ts -- API route handlers +src/api/schemas.ts -- TypeBox request/response schemas +src/api/helper.ts -- CIP-8 authentication +src/repository/ -- Database access layer +src/provider/cardanoscan.ts -- On-chain transaction search +src/config/market.ts -- Market config cache +src/cmd/run-api.ts -- Entry point +``` + +## Order state machine (non-obvious flow) +**Open LONG:** LONG_BUY → LONG_SUPPLY → LONG_BORROW → LONG_BUY_MORE +**Close LONG:** LONG_SELL → LONG_REPAY → LONG_WITHDRAW → LONG_SELL_ALL + +Each order goes through: build tx → user signs → confirm on chain → wait for output spend → next order. + +## Gotchas +- LONG_SUPPLY uses LiqwidProvider V1, all other Liqwid ops use V2 +- `amount_borrow = amount_in * (leverage - 1) + 4_000_000n` (fee buffer) +- Check waiting orders BEFORE finding unhandled orders in buildTx flow +- `built_valid_to` determines when to rebuild an expired transaction diff --git a/CLAUDE.md b/CLAUDE.md index 82e29b5..f8176ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,537 +1,47 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +The role of this file is to describe common mistake and confusion points that agents might encounter as they work in this project. If you ever encounter something in the project that surprises you, please alert the developer working with you and indicate that this is the case in the CLAUDE.md file to help prevent future agents from having the same issue. + +> **Principle:** If the info is in the codebase, it probably doesn't need to be in this file. Keep this lean. ## Project Overview -Minswap Felis is a monorepo implementing Cardano DEX infrastructure with leveraged long/short trading positions integrated with Liqwid lending protocol. Built with TypeScript, Turborepo, and pnpm workspaces. +Minswap Felis - Cardano DEX monorepo with leveraged long/short trading via Liqwid lending. TypeScript, Turborepo, pnpm workspaces. ## Commands ```bash -# Build & Development -pnpm build # Build all packages (respects turbo dependency graph) -pnpm dev # Watch mode across all packages -pnpm check-types # Run tsc --noEmit on all packages - -# Testing -pnpm test # Run all tests via vitest -pnpm test:watch # Watch mode for tests -pnpm --filter=@repo/ledger-core test # Run tests for single package - -# Linting & Formatting -pnpm format-and-lint # Check with biome (no changes) +pnpm build # Build all packages +pnpm check-types # Type-check all packages +pnpm test # Run all tests (vitest) +pnpm --filter=@repo/ledger-core test # Single package tests pnpm format-and-lint:fix # Auto-fix with biome -pnpm exec turbo run check-circular-imports # Detect circular imports - -# Web App pnpm --filter=web dev # Next.js dev server (port 3001) -``` - -## Architecture - -### Package Dependency Graph -``` -ledger-utils (foundation: Result type, crypto, hex, bech32) - ↓ -ledger-core (Address, Tx, UTXO, Scripts, Protocol) - ↓ - ├→ cip (BIP32/39, CIP-25, CIP-68) - │ ↓ - └───┴→ tx-builder (high-level transaction composition) - ↓ - minswap-build-tx (DEX transaction builder) - ↓ - minswap-lending-market (Liqwid SDK, Nitro Wallet) - ↓ - web (Next.js app) - -minswap-dex-v2 (DEX V2 protocol types) → depends on ledger-core, ledger-utils -``` - -### Key Packages -- **ledger-utils**: `Result` error handling, crypto utilities, `CborHex` branded types -- **ledger-core**: Cardano primitives (Address, Tx, UTXO), `NetworkEnvironment` discrimination -- **tx-builder**: Transaction composition with UTXO selection -- **minswap-dex-v2**: DEX V2 protocol types and order handling -- **minswap-lending-market**: Liqwid provider SDK, Nitro Wallet (password-less signing) - -## Code Patterns - -### Error Handling -Use `Result` from ledger-utils: -```typescript -Result.ok(value), Result.err(error), Result.isOk() -``` -Use `getErrorMessage(error)` for safe error stringification (handles BigInt). - -### Class Design -- Protected constructors with static factory methods -- Immutable `readonly` properties -- Bidirectional serialization: `toHex()`, `fromHex()`, `toPlutusJson()`, `fromPlutusJson()` - -### Type Conventions -- `bigint` for all amounts, slots, values (never numbers) -- `CborHex` branded types for CBOR serialization -- `NetworkEnvironment` required for address/protocol operations (mainnet vs testnet) -- Type-only imports: `import type { Type } from "..."` -- Workspace imports: Always use `@repo/*` scope - -### WASM Initialization -```typescript -import { RustModule } from "@repo/ledger-utils"; -beforeAll(async () => { - await RustModule.load(); -}); -``` - -## Key Dependencies - -- **@emurgo/cardano-serialization-lib (CSL)**: Cardano primitives via Rust/WASM -- **@stricahq/cbors**: CBOR encoding (Cardano serialization format) -- **json-bigint**: Preserve BigInt precision in JSON (don't use native JSON for amounts) -- **remeda**: Functional utility library (like lodash/fp) -- **@minswap/tiny-invariant**: Development-only assertions -- **dpdm**: Circular import detection (run via `check-circular-imports` script) - -## Testing - -- Framework: Vitest with `vitest.config.mts` per package -- Test location: `{package}/test/**/*.{test,spec}.ts` -- Property-based testing: `fast-check` available -- Environment: Node (Vitest aliases browser WASM to Node variants) - -## Code Style (Biome) - -- Line width: 120 -- Quotes: Double -- Semicolons: Always -- Trailing commas: All -- Arrow parens: Always `(x) => x` - -## Tech Stack - -- TypeScript 5.8, Node.js >= 22 -- Turborepo + pnpm 9.0.0 -- Vitest, Biome -- Next.js, React 19, Jotai, Ant Design - ---- - -# Long-Short Backend Skills & Patterns - -## Database Migrations - -### Migration File Pattern -```typescript -// apps/long-short-backend/.config/migrations/{timestamp}_{description}.ts -import type { Kysely } from "kysely"; -import { sql } from "kysely"; - -export async function up(db: Kysely): Promise { - await sql`ALTER TABLE "table_name" ADD COLUMN "column_name" TYPE`.execute(db); -} - -export async function down(db: Kysely): Promise { - await sql`ALTER TABLE "table_name" DROP COLUMN "column_name"`.execute(db); -} -``` - -### Migration Best Practices -- Use sequential timestamps for ordering (e.g., `1770117151276_order_waiting_column.ts`) -- Always provide both `up` and `down` migrations -- Use `Generated` in database types for columns with defaults -- Update `src/database/db.d.ts` after migrations -- Test migrations in development before applying to production - -### Running Migrations -```bash -# Apply migrations -pnpm --filter=long-short-backend run migrate:latest - -# Rollback last migration -pnpm --filter=long-short-backend run migrate:down -``` - -## Order State Machine Pattern - -### Order Lifecycle States -```typescript -// Order progression for LONG positions: -// 1. LONG_BUY → Buy asset B with asset A (ADA) -// 2. LONG_SUPPLY → Supply asset B to lending protocol, get collateral -// 3. LONG_BORROW → Borrow asset A against collateral -// 4. LONG_BUY_MORE → Buy more asset B with borrowed asset A -``` - -### Transaction Lifecycle Tracking -```typescript -// Order fields track transaction progress: -// 1. built_tx_id - Transaction built and submitted (not yet on chain) -// 2. created_tx_id - Transaction confirmed on chain -// 3. waiting - true when confirmed, false when output is spent -``` - -### State Machine Handler Pattern -```typescript -// apps/long-short-backend/src/api/state-machine.ts -export namespace StateMachine { - // Handler for building transactions - export const handleOrderType = async (options: HandleOptions) => { - // Build transaction using DEXOrderTransaction - // Return: { txRaw, txId, outputsHash, validTo } - }; - - // Waiting function for checking if output is spent - export const waitingOrderType = async (options: WaitingOptions): Promise => { - // Check if order output has been spent on chain - // If spent, return next order details - // If not spent, return { isSpent: false } - }; -} -``` - -## Repository Pattern - -### Repository Organization -```typescript -// apps/long-short-backend/src/repository/{entity}-repository.ts -export namespace EntityRepository { - // Create operations - export async function createEntity(db: Kysely | Transaction, params: CreateParams): Promise - - // Read operations - export async function getEntityById(db: Kysely, id: bigint): Promise - export async function getEntitiesByFilter(db: Kysely, filter: Filter): Promise - - // Update operations - export async function updateEntity(db: Kysely, id: bigint, updates: Partial): Promise - - // Helper: Map DB row to domain type - function mapEntityRow(row: any): Entity { - return { - id: BigInt(row.id), - // Convert snake_case to camelCase - // Convert timestamps to Date objects - // Parse bigint fields - }; - } -} -``` - -### Repository Best Practices -- Use `Kysely | Transaction` for functions that need transaction support -- Always convert `id` fields to `bigint` with `BigInt(row.id)` -- Map `snake_case` database columns to `camelCase` domain types -- Return `null` for not-found queries (don't throw) -- Use `.executeTakeFirst()` for optional single results -- Use `.executeTakeFirstOrThrow()` when result must exist -## Service Layer Pattern - -### Service Organization -```typescript -// apps/long-short-backend/src/services/{entity}-service.ts -export class EntityService { - constructor( - private readonly db: Kysely, - private readonly networkEnv: NetworkEnvironment, - private readonly cardanoscanProvider: CardanoscanProvider, - ) {} - - // Business logic methods - async createEntity(input: CreateInput): Promise - async processEntity(input: ProcessInput): Promise -} - -// Result types use discriminated unions -export type Result = - | { success: true; data: Data } - | { success: false; error: string }; -``` - -### Service Best Practices -- Inject dependencies through constructor -- Use discriminated union return types for error handling -- Validate input at service layer (market support, minimums, etc.) -- Use database transactions for multi-step operations -- Log important state transitions with structured logging -- Separate concerns: waiting logic vs transaction building - -## Transaction Building Pattern - -### BuildTx Flow (apps/long-short-backend/src/services/position-service.ts) -```typescript -async buildTx(input: BuildTxInput): Promise { - // STEP 1: Check for waiting orders (created_tx_id not null, waiting = true) - const waitingOrder = await OrderRepository.getWaitingOrder(db, positionId); - if (waitingOrder) { - // Call waiting function to check if output is spent - // If spent: update next order, set waiting = false - // If not spent: return waiting message - } - - // STEP 2: Find next unhandled order (asset_in not null, created_tx_id null) - const order = await OrderRepository.getNextUnhandledOrder(db, positionId); - if (!order) return { error: "No unhandled order" }; - - // STEP 3: Check if transaction already built - if (order.builtTxId) { - // If created_tx_id exists: already confirmed, return waiting - // Else: search on chain, update created_tx_id if found - // Check expiry, return waiting or fall through to rebuild - } - - // STEP 4: Build new transaction - const txResult = await StateMachine.handleOrderType(...); - await OrderRepository.updateOrderBuiltTx(db, order.id, txResult); - return { success: true, txRaw: txResult.txRaw }; -} +# Long-short backend +pnpm --filter=long-short-backend run migrate:latest # Apply DB migrations +pnpm --filter=long-short-backend run migrate:down # Rollback last migration ``` -### Transaction Building Best Practices -- Separate waiting logic from transaction building -- Check waiting orders BEFORE finding unhandled orders -- Always update `built_tx_id` immediately after building -- Set `waiting = true` when transaction confirms on chain (via default column value) -- Set `waiting = false` only after output is spent -- Use transaction expiry (validTo) to determine when to rebuild - -## Cardanoscan Provider Pattern +## Package Dependency Graph -### Provider Usage -```typescript -// apps/long-short-backend/src/provider/cardanoscan.ts -const provider = new CardanoscanProvider(baseUrl, apiKey); - -// Find transaction by hash -const tx = await provider.findTransactionByHash(address, txHash, pageSize, maxPage); - -// Find transaction that spent a UTXO -const spendingTx = await provider.findTransactionHasSpent(address, txHash, outputIndex, pageSize, maxPage); ``` - -### Cardanoscan API Patterns -- Always use `address.toHex()` for API calls (not bech32) -- API header is `"apiKey"` (not `"api-key"` or `"Authorization"`) -- Paginate with `pageNo` (1-indexed) and `limit` (max 50) -- Use `order: "desc"` to search most recent transactions first -- Token format: use `asset.toBlockFrostString()` for matching -- Input format: `{ txId: string; index: number }` for UTXO references -- Output format: `{ address: string; value: string; tokens?: Array<{ value: string; assetId: string }> }` - -## API Route Pattern (Fastify + TypeBox) - -### Route Registration -```typescript -// apps/long-short-backend/src/api/routes/{entity}.ts -export function registerEntityRoutes(fastify: FastifyInstance, service: EntityService): void { - fastify.post<{ - Body: BodyType; - Reply: ReplyType; - }>( - API_ENDPOINTS.ENTITY_ACTION, - { - schema: { - body: BodySchema, - response: { - 200: SuccessResponseSchema, - 400: ErrorResponseSchema, - 401: ErrorResponseSchema, - }, - }, - }, - async (request, reply) => { - // Extract and validate input - const { data, user_address, witness } = request.body; - - // Authenticate (CIP-8 signature verification) - const authResult = ApiHelper.authenticate(data, user_address, witness); - if (!authResult.success) { - return reply.status(401).send({ success: false, error: authResult.error }); - } - - // Call service - const result = await service.processEntity(input); - if (!result.success) { - return reply.status(400).send({ success: false, error: result.error }); - } - - return reply.status(200).send({ success: true, data: result.data }); - }, - ); -} -``` - -### API Best Practices -- Define schemas with TypeBox for validation -- Use snake_case for API request/response fields (matches frontend) -- Use camelCase internally in TypeScript code -- Authenticate requests with CIP-8 signature verification -- Return consistent response format: `{ success: boolean; data?: T; error?: string }` -- Use appropriate HTTP status codes: 200 (success), 400 (bad request), 401 (unauthorized) -- Map domain types to response types with helper functions - -## Authentication Pattern (CIP-8) - -### Signature Verification -```typescript -// apps/long-short-backend/src/api/helper.ts -export namespace ApiHelper { - export function authenticate( - data: unknown, - userAddress: string, - witness: { signature: string; key: string }, - ): AuthResult { - // 1. Serialize data to CBOR hex - const dataHex = XJSON.stringify(data); - - // 2. Verify signature using CIP-8 - const isValid = verifySignature(dataHex, userAddress, witness); - - return isValid - ? { success: true } - : { success: false, error: "Invalid signature" }; - } -} -``` - -### Authentication Request Format -```typescript -{ - "data": { /* actual request payload */ }, - "user_address": "addr1...", - "witness": { - "signature": "84582aa201...", // CBOR-encoded CIP-8 signature - "key": "a401..." // CBOR-encoded public key - } -} +ledger-utils → ledger-core → cip → tx-builder → minswap-build-tx → minswap-lending-market → web +minswap-dex-v2 → ledger-core, ledger-utils ``` -## Logging Pattern - -### Structured Logging -```typescript -// apps/long-short-backend/src/utils/logger.ts -import { logger } from "../utils"; - -// Info logging -logger.info("Description", { - orderId: order.id, - txHash: tx.hash, - // Include relevant context -}); - -// Error logging -logger.error("Error description", error); -logger.error("Error description", { context: value, error }); - -// Warning logging -logger.warn("Warning message", { context }); -``` - -### Logging Best Practices -- Use structured logging with context objects -- Log all important state transitions -- Log transaction IDs for traceability -- Log errors with full error objects -- Include order/position IDs in logs -- Use appropriate log levels: info (normal flow), warn (recoverable issues), error (failures) - -## Testing Patterns - -### Repository Tests -```typescript -// apps/long-short-backend/test/{entity}-repository.test.ts -import { describe, it, expect, beforeAll, afterEach } from "vitest"; - -describe("EntityRepository", () => { - let db: Kysely; - - beforeAll(async () => { - // Setup test database - db = await setupTestDb(); - }); - - afterEach(async () => { - // Clean up test data - await db.deleteFrom("entity").execute(); - }); - - it("should create entity", async () => { - const entity = await EntityRepository.createEntity(db, params); - expect(entity.id).toBeDefined(); - }); -}); -``` - -### Service Tests -```typescript -// apps/long-short-backend/test/{entity}-service.test.ts -describe("EntityService", () => { - let service: EntityService; - let mockProvider: CardanoscanProvider; - - beforeAll(async () => { - await RustModule.load(); - mockProvider = createMockProvider(); - service = new EntityService(db, networkEnv, mockProvider); - }); - - it("should process entity successfully", async () => { - const result = await service.processEntity(input); - expect(result.success).toBe(true); - }); -}); -``` - -## Environment Variables - -### Required Variables -```bash -# Database -DATABASE_URL="postgresql://user:pass@localhost:5432/dbname" - -# Network -NETWORK="mainnet" # or "testnet" - -# API -API_PORT=9999 -API_HOST="0.0.0.0" - -# Cardanoscan -CARDANOSCAN_API_KEY="your-api-key" -``` - -### Loading Environment -```typescript -// Environment variables are loaded automatically -// Validate required variables at startup -if (!process.env.DATABASE_URL) { - throw new Error("DATABASE_URL is required"); -} -``` +## Conventions -## Common Patterns Summary +- `bigint` for all Cardano amounts, slots, IDs (never `number`) +- `Result` from ledger-utils for error handling +- `import type` for type-only imports +- `@repo/*` scope for workspace imports +- `await RustModule.load()` required before crypto/WASM operations in tests +- `json-bigint` for JSON with BigInt values (never native `JSON.stringify`) -### DO -- Use `bigint` for all Cardano amounts and IDs -- Convert database IDs with `BigInt(row.id)` -- Use discriminated unions for result types -- Separate waiting logic from transaction building -- Log all important state transitions -- Validate inputs at service layer -- Use transactions for multi-step database operations -- Map database snake_case to TypeScript camelCase -- Use `address.toHex()` for Cardanoscan API -- Authenticate requests with CIP-8 signatures +## Gotchas -### DON'T -- Don't use `number` for amounts or IDs -- Don't throw errors from repository layer (return `null`) -- Don't mix waiting and building logic in same function -- Don't skip input validation -- Don't use native `JSON.stringify` for BigInt values -- Don't use bech32 addresses for Cardanoscan API -- Don't skip authentication on protected endpoints -- Don't forget to update database types after migrations +- Cardanoscan API: use `address.toHex()` (not bech32), header is `"apiKey"` +- DB types: update `src/database/db.d.ts` after migrations, use `Generated` for defaults +- DB IDs: always `BigInt(row.id)` when mapping rows +- API fields: snake_case externally, camelCase internally From e3e80facb0346f1a5856fc5b2545a2552aa74ce6 Mon Sep 17 00:00:00 2001 From: tony Date: Fri, 27 Feb 2026 14:43:57 +0700 Subject: [PATCH 35/35] update APY for borrow, supply --- .../src/api/routes/metadata.ts | 33 +++++++++++++++++-- apps/long-short-backend/src/api/schemas.ts | 12 +++++++ apps/long-short-backend/src/api/server.ts | 2 +- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/apps/long-short-backend/src/api/routes/metadata.ts b/apps/long-short-backend/src/api/routes/metadata.ts index bc3feac..d96f2e7 100644 --- a/apps/long-short-backend/src/api/routes/metadata.ts +++ b/apps/long-short-backend/src/api/routes/metadata.ts @@ -1,9 +1,17 @@ +import type { NetworkEnvironment } from "@minswap/felis-ledger-core"; +import { LiqwidProviderV2 } from "@minswap/felis-lending-market"; import type { FastifyInstance } from "fastify"; import { getEnabledMarketConfigs, type MarketConfig } from "../../config/market"; import { API_ENDPOINTS } from "../../constants"; +import { logger } from "../../utils"; import { type MarketConfigResponseType, MetadataResponseSchema, type MetadataResponseType } from "../schemas"; -function marketConfigToResponse(config: MarketConfig): MarketConfigResponseType { +type LiqwidMarketApyMap = Map; + +function marketConfigToResponse(config: MarketConfig, liqwidApys: LiqwidMarketApyMap): MarketConfigResponseType { + const assetAApys = liqwidApys.get(config.borrowMarketIdLong); + const assetBApys = liqwidApys.get(config.borrowMarketIdShort); + return { market_id: config.marketId, asset_a: config.assetA.toString(), @@ -18,10 +26,28 @@ function marketConfigToResponse(config: MarketConfig): MarketConfigResponseType long_leverage: config.longLeverage, short_leverage: config.shortLeverage, min_collateral: config.minCollateral.toString(), + asset_a_borrow_apy: assetAApys?.borrowAPY ?? null, + asset_a_supply_apy: assetAApys?.supplyAPY ?? null, + asset_b_borrow_apy: assetBApys?.borrowAPY ?? null, + asset_b_supply_apy: assetBApys?.supplyAPY ?? null, }; } -export function registerMetadataRoutes(fastify: FastifyInstance): void { +async function fetchLiqwidMarketApys(networkEnv: NetworkEnvironment): Promise { + const apyMap: LiqwidMarketApyMap = new Map(); + const config = LiqwidProviderV2.createConfig(networkEnv); + const result = await LiqwidProviderV2.Data.markets(config); + if (result.type === "ok") { + for (const market of result.value.results) { + apyMap.set(market.id, { borrowAPY: market.borrowAPY, supplyAPY: market.supplyAPY }); + } + } else { + logger.error("Failed to fetch Liqwid market data", { error: result.error }); + } + return apyMap; +} + +export function registerMetadataRoutes(fastify: FastifyInstance, networkEnv: NetworkEnvironment): void { // GET /metadata fastify.get<{ Reply: MetadataResponseType; @@ -36,11 +62,12 @@ export function registerMetadataRoutes(fastify: FastifyInstance): void { }, async (_request, reply) => { const marketConfigs = getEnabledMarketConfigs(); + const liqwidApys = await fetchLiqwidMarketApys(networkEnv); return reply.status(200).send({ success: true, data: { - markets: marketConfigs.map(marketConfigToResponse), + markets: marketConfigs.map((c) => marketConfigToResponse(c, liqwidApys)), }, }); }, diff --git a/apps/long-short-backend/src/api/schemas.ts b/apps/long-short-backend/src/api/schemas.ts index 3e1137a..1f4e295 100644 --- a/apps/long-short-backend/src/api/schemas.ts +++ b/apps/long-short-backend/src/api/schemas.ts @@ -168,6 +168,18 @@ export const MarketConfigResponseSchema = Type.Object({ long_leverage: Type.Number({ description: "Long leverage multiplier" }), short_leverage: Type.Number({ description: "Short leverage multiplier" }), min_collateral: Type.String({ description: "Minimum collateral in lovelace" }), + asset_a_borrow_apy: Type.Union([Type.Number(), Type.Null()], { + description: "Liqwid borrow APY for asset A", + }), + asset_a_supply_apy: Type.Union([Type.Number(), Type.Null()], { + description: "Liqwid supply APY for asset A", + }), + asset_b_borrow_apy: Type.Union([Type.Number(), Type.Null()], { + description: "Liqwid borrow APY for asset B", + }), + asset_b_supply_apy: Type.Union([Type.Number(), Type.Null()], { + description: "Liqwid supply APY for asset B", + }), }); export type MarketConfigResponseType = Static; diff --git a/apps/long-short-backend/src/api/server.ts b/apps/long-short-backend/src/api/server.ts index 4f8df1a..7170851 100644 --- a/apps/long-short-backend/src/api/server.ts +++ b/apps/long-short-backend/src/api/server.ts @@ -45,7 +45,7 @@ export async function createApiServer(options: ApiServerOptions): Promise