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/.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/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/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 98% rename from Dockerfile rename to Dockerfile.interface index fb2d662..286ec11 100644 --- a/Dockerfile +++ b/Dockerfile.interface @@ -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/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/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/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/.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/.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/.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/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 new file mode 100644 index 0000000..01e06bb --- /dev/null +++ b/apps/long-short-backend/.config/seeds/market_config.ts @@ -0,0 +1,36 @@ +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 + 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 + 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..f138d23 --- /dev/null +++ b/apps/long-short-backend/example/sign-data.ts @@ -0,0 +1,52 @@ +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"; +import { HashUtils } from "../src/utils"; + +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"); + 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, + hashMessage, + ); + + const result = { + data, + user_address: wallet.address.bech32, + witness: { + key, + signature, + } + }; + console.log(JSON.stringify(result, null, 4)); + + const authenticated = verifySignData({ + message: hashMessage, + 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..c410301 --- /dev/null +++ b/apps/long-short-backend/package.json @@ -0,0 +1,60 @@ +{ + "name": "long-short-backend", + "version": "0.1.0", + "private": true, + "exports": { + ".": "./dist/index.js" + }, + "scripts": { + "build": "tsc", + "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", + "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/crypto-js": "^4.2.2", + "@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-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-lending-market": "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", + "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..c46d6a4 --- /dev/null +++ b/apps/long-short-backend/src/api/helper.ts @@ -0,0 +1,33 @@ +import { HashUtils } from "../utils"; +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 hashMessage = HashUtils.sha256(message); + + const isValid = verifySignData({ + message: hashMessage, + 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..e584dca --- /dev/null +++ b/apps/long-short-backend/src/api/index.ts @@ -0,0 +1,2 @@ +export * from "./schemas"; +export { type ApiServerOptions, createApiServer } from "./server"; 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/routes/metadata.ts b/apps/long-short-backend/src/api/routes/metadata.ts new file mode 100644 index 0000000..bc3feac --- /dev/null +++ b/apps/long-short-backend/src/api/routes/metadata.ts @@ -0,0 +1,48 @@ +import type { FastifyInstance } from "fastify"; +import { getEnabledMarketConfigs, type MarketConfig } from "../../config/market"; +import { API_ENDPOINTS } from "../../constants"; +import { type MarketConfigResponseType, MetadataResponseSchema, type MetadataResponseType } from "../schemas"; + +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, + 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(), + }; +} + +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..a933c85 --- /dev/null +++ b/apps/long-short-backend/src/api/routes/position.ts @@ -0,0 +1,237 @@ +import invariant from "@minswap/tiny-invariant"; +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 AuthenBuildTxBodyType, + AuthenBuildTxBodyTypeSchema, + type AuthenClosePositionBodyType, + AuthenClosePositionBodyTypeSchema, + type AuthenCreatePositionBodyType, + AuthenCreatePositionBodyTypeSchema, + BuildTxResponseSchema, + type BuildTxResponseType, + ClosePositionResponseSchema, + type ClosePositionResponseType, + 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, + side: position.side, + status: position.status, + amount_in: position.amountIn, + amount_borrow: position.amountBorrow, + created_at: position.createdAt.toISOString(), + closed_at: position.closedAt?.toISOString() ?? null, + }; +} + +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; + 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_id, side, amount_in } = data; + + // Authenticate request + const authResult = ApiHelper.authenticate(data, user_address, witness); + if (!authResult.success) { + return reply.status(401).send({ + success: false, + error: authResult.error, + }); + } + + // 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: result.error, + }); + } + + 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, + marketId: market_id, + utxos, + }); + + if (!result.success) { + return reply.status(400).send({ + success: false, + error: result.error, + }); + } + + // 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 + 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, + }, + }); + }, + ); + + // 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 new file mode 100644 index 0000000..3e1137a --- /dev/null +++ b/apps/long-short-backend/src/api/schemas.ts @@ -0,0 +1,182 @@ +import { type Static, Type } from "@sinclair/typebox"; +import { StateMachine } from "./state-machine"; + +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 (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_id: Type.String({ minLength: 1, description: "Market ID (e.g., ADA-MIN)" }), + side: Type.Union([Type.Literal("LONG"), Type.Literal("SHORT")], { description: "Position side" }), + amount_in: 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" }), + market_id: Type.String(), + user_address: Type.String(), + side: PositionSideSchema, + status: PositionStatusSchema, + amount_in: Type.String(), + amount_borrow: Type.String(), + created_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; + +// 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" })), + 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" })), + }), + ), + error: Type.Optional(Type.String()), +}); + +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), + error: Type.String(), +}); + +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)" }), + 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" }), + 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" }), +}); + +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..4f8df1a --- /dev/null +++ b/apps/long-short-backend/src/api/server.ts @@ -0,0 +1,61 @@ +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 { API_ENDPOINTS } from "../constants"; +import type { DB } from "../database"; +import { type CardanoscanProvider, MinswapAggregatorProvider } 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"; + +export type ApiServerOptions = { + port: number; + host: string; + db: Kysely; + cardanoscanProvider: CardanoscanProvider; + networkEnv: NetworkEnvironment; +}; + +export async function createApiServer(options: ApiServerOptions): Promise { + const { port, host, db, networkEnv, cardanoscanProvider } = 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" }; + }); + + // Create services + const aggregatorProvider = new MinswapAggregatorProvider(networkEnv); + const positionService = new PositionService(db, networkEnv, cardanoscanProvider, aggregatorProvider); + + // Register routes + registerLiqwidRoutes(fastify, networkEnv); + 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/api/state-machine.ts b/apps/long-short-backend/src/api/state-machine.ts new file mode 100644 index 0000000..c3479d5 --- /dev/null +++ b/apps/long-short-backend/src/api/state-machine.ts @@ -0,0 +1,1271 @@ +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, 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"; +import type { CardanoscanProvider } from "../provider"; +import { HashUtils } from "../utils"; + +export namespace StateMachine { + export enum PositionSide { + LONG = "LONG", + SHORT = "SHORT", + } + + export enum PositionStatus { + PENDING = "PENDING", + OPEN = "OPEN", + CLOSING = "CLOSING", + CLOSED = "CLOSED", + } + + export enum LongOrderType { + LONG_BUY = "LONG_BUY", + LONG_SUPPLY = "LONG_SUPPLY", + LONG_BORROW = "LONG_BORROW", + LONG_BUY_MORE = "LONG_BUY_MORE", + LONG_SELL = "LONG_SELL", + LONG_REPAY = "LONG_REPAY", + LONG_WITHDRAW = "LONG_WITHDRAW", + 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; + validTo: number; + outputsHash?: 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[]; + /** Amount to borrow (used for LONG_BORROW / SHORT_BORROW) */ + amountBorrow?: string; + /** Loan transaction ID (used for LONG_REPAY / SHORT_REPAY to identify the loan) */ + loanTxId?: string; + /** Loan output index (used for LONG_REPAY / SHORT_REPAY) */ + loanOutputIndex?: number; + /** Collateral qToken amount (used for LONG_REPAY / SHORT_REPAY to redeem collateral) */ + collateralAmount?: string; + /** Supply amountOut from SUPPLY order (used for LONG_WITHDRAW / SHORT_WITHDRAW) */ + supplyAmountOut?: string; + }; + + export const handleLongBuy = async (options: HandleBuildTxOptions): Promise => { + const { order, marketConfig, userAddress, networkEnv, utxos } = options; + 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"); + 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, + }; + }; + + export const handleLongSupply = async (options: HandleBuildTxOptions): Promise => { + const { order, userAddress, networkEnv, utxos } = options; + 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"); + + // 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.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(), + }; + }; + + 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(), + }; + }; + + /** + * 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.longCollateralMarketId 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(), + }; + }; + + // ============================================================================ + // SHORT Build Functions + // ============================================================================ + + /** + * Build SHORT_SUPPLY transaction: Supply asset A (ADA) to Liqwid, receive qADA + */ + export const handleShortSupply = async (options: HandleBuildTxOptions): Promise => { + 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"); + + const marketId = marketConfig.shortCollateralMarketId 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.shortCollateralMarketId 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 | ShortOrderType; + 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 = { + marketConfig: MarketConfig; + 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) */ + 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; + }; + + /** + * 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, orderType } = options; + invariant(orderOutputIndex !== undefined, "orderOutputIndex is required for waitingLongBuy"); + invariant(assetOut, "assetOut is required for waitingLongBuy"); + + 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) { + 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); + + // 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, + amountOut: amountOut.toString(), + }; + } + + // For LONG_BUY, transition to LONG_SUPPLY + return { + isConfirmed: true, + nextOrderType: LongOrderType.LONG_SUPPLY, + assetIn: assetOut.toString(), + amountIn: amountOut.toString(), + assetOut: marketConfig.longCollateralMarketId, + 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 LONG_SUPPLY transaction to be confirmed + * and prepare the next LONG_BORROW order details + */ + export const waitingLongSupply = 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(); + + 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: LongOrderType.LONG_BORROW, + assetIn: marketConfig.assetBQTokenRaw, + amountIn: amountReceived.toString(), + assetOut: marketConfig.assetA.toString(), + amountOut: amountReceived.toString(), + }; + } + } + } + } + + throw new Error( + `LONG_SUPPLY tx confirmed (${txHash}) but could not find output with qToken ${marketConfig.assetBQTokenRaw}`, + ); + } + + 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 * (leverage - 1) + const amountBorrow = BigInt(Math.floor(Number(positionAmountIn) * (marketConfig.longLeverage - 1))); + + return { + isConfirmed: true, + nextOrderType: LongOrderType.LONG_BUY_MORE, + assetIn: marketConfig.assetA.toString(), + amountIn: amountBorrow.toString(), + assetOut: marketConfig.assetB.toString(), + amountOut: amountBorrow.toString(), + }; + } + + 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 }; + }; + + // ============================================================================ + // 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, + [LongOrderType.LONG_SELL]: handleLongSell, + [LongOrderType.LONG_REPAY]: handleLongRepay, + [LongOrderType.LONG_WITHDRAW]: handleLongWithdraw, + [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 */ + 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, + [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/cmd/run-api.ts b/apps/long-short-backend/src/cmd/run-api.ts new file mode 100644 index 0000000..e848b8b --- /dev/null +++ b/apps/long-short-backend/src/cmd/run-api.ts @@ -0,0 +1,66 @@ +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 type { DB } from "../database"; +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"; +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}`); + + 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, { logSQL: false }); + 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`); + + // 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, + }); +} + +main().catch((error) => { + logger.error("Failed to start application", { error }); + process.exit(1); +}); 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..bdb11f5 --- /dev/null +++ b/apps/long-short-backend/src/config/market.ts @@ -0,0 +1,99 @@ +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; + longCollateralMarketId: string; + shortCollateralMarketId: string; + borrowMarketIdLong: string; + borrowMarketIdShort: string; + longLeverage: number; + shortLeverage: 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, + longCollateralMarketId: row.long_collateral_market_id, + shortCollateralMarketId: row.short_collateral_market_id, + borrowMarketIdLong: row.borrow_market_id_long, + borrowMarketIdShort: row.borrow_market_id_short, + longLeverage: Number(row.long_leverage), + shortLeverage: Number(row.short_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 ? config.enable : false; +} + +/** + * 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..242c6a3 --- /dev/null +++ b/apps/long-short-backend/src/constants.ts @@ -0,0 +1,10 @@ +// MUST sort endpoints alphabetically +export const API_ENDPOINTS = { + HEALTH: "/health", + 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/database/db.d.ts b/apps/long-short-backend/src/database/db.d.ts new file mode 100644 index 0000000..31ad4df --- /dev/null +++ b/apps/long-short-backend/src/database/db.d.ts @@ -0,0 +1,69 @@ +/** + * 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; + borrow_market_id_long: Generated; + borrow_market_id_short: Generated; + enable: Generated; + long_collateral_market_id: string; + long_leverage: Numeric; + market_id: string; + min_collateral: Numeric; + short_collateral_market_id: Generated; + short_leverage: Generated; +} + +export interface Order { + 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; + order_type: string; + position_id: Int8; + waiting: Generated; +} + +export interface Position { + amount_borrow: Numeric; + amount_in: Numeric; + closed_at: Timestamp | null; + created_at: Generated; + id: Generated; + market_id: string; + side: string; + status: 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/provider/cardanoscan.ts b/apps/long-short-backend/src/provider/cardanoscan.ts new file mode 100644 index 0000000..cbbb580 --- /dev/null +++ b/apps/long-short-backend/src/provider/cardanoscan.ts @@ -0,0 +1,323 @@ +import type { Address } from "@minswap/felis-ledger-core"; +import { logger } from "../utils"; + +/** + * Transaction input/output structure from Cardanoscan API + */ +export type CardanoscanTxIO = { + address: string; + value: string; + tokens?: Array<{ + value: string; + assetId: 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 & { txId: string; index: number })[]; + 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 "desc" +}; + +/** + * 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 } = 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 desc) + * @returns Transaction list response + */ + async getTransactionListByAddress( + address: Address, + pageNo: number, + limit: number, + order: "asc" | "desc", + ): Promise { + return this.getTransactionList({ + address: address.toHex(), + 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; + } + + /** + * 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/provider/index.ts b/apps/long-short-backend/src/provider/index.ts new file mode 100644 index 0000000..c5adcdf --- /dev/null +++ b/apps/long-short-backend/src/provider/index.ts @@ -0,0 +1,3 @@ +export * from "./cardanoscan"; +export * from "./kupo"; +export * from "./minswap-aggregator"; 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/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/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/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 new file mode 100644 index 0000000..08650d4 --- /dev/null +++ b/apps/long-short-backend/src/repository/order-repository.ts @@ -0,0 +1,316 @@ +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; + waiting: boolean; +}; + +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 | 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()) + .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(); + } + + /** + * 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 + */ + 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; + } + + export type TransitionToNextOrderParams = { + currentOrderId: bigint; + positionId: bigint; + nextOrderType: string; + 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 }; + + /** + * Transition from current order to next order: + * 1. Find the next order by position and type + * 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, amountOut } = 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 current order amount_out + await updateOrderAmountOut(db, currentOrderId, amountOut); + + // 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 { + 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, + waiting: row.waiting, + }; + } +} 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..106658d --- /dev/null +++ b/apps/long-short-backend/src/repository/position-repository.ts @@ -0,0 +1,150 @@ +import type { Kysely, Transaction } from "kysely"; +import { StateMachine } from "../api/state-machine"; +import type { DB } from "../database"; + +export type CreatePositionParams = { + marketId: string; + userAddress: string; + side: StateMachine.PositionSide; + amountIn: string; + amountBorrow: string; +}; + +export type Position = { + id: bigint; + marketId: string; + userAddress: string; + side: StateMachine.PositionSide; + status: StateMachine.PositionStatus; + amountIn: string; + amountBorrow: string; + createdAt: Date; + closedAt: Date | null; +}; + +export namespace PositionRepository { + export async function createPosition( + db: Kysely | Transaction, + params: CreatePositionParams, + ): Promise { + const result = await db + .insertInto("position") + .values({ + market_id: params.marketId, + user_address: params.userAddress, + side: params.side, + status: StateMachine.PositionStatus.PENDING, + amount_in: params.amountIn, + amount_borrow: params.amountBorrow, + }) + .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 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, + marketId: string, + ): Promise { + const result = await db + .selectFrom("position") + .selectAll() + .where("user_address", "=", userAddress) + .where("market_id", "=", marketId) + .where("closed_at", "is", null) + .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("closed_at", "is", null) + .orderBy("created_at", "desc") + .execute(); + + return results.map(mapPositionRow); + } + + export async function getUserPositions( + db: Kysely | Transaction, + userAddress: string, + options?: { includesClosed?: boolean; limit?: number; offset?: number }, + ): Promise { + let query = db.selectFrom("position").selectAll().where("user_address", "=", userAddress); + + if (!options?.includesClosed) { + query = query.where("closed_at", "is", null); + } + + 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: StateMachine.PositionStatus, + ): Promise { + const updates: Record = { status }; + + if (status === StateMachine.PositionStatus.CLOSED) { + 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), + marketId: row.market_id, + userAddress: row.user_address, + 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), + 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..99da2e4 --- /dev/null +++ b/apps/long-short-backend/src/services/index.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..9c0e87b --- /dev/null +++ b/apps/long-short-backend/src/services/position-service.ts @@ -0,0 +1,595 @@ +import { Address, Asset, type NetworkEnvironment } from "@minswap/felis-ledger-core"; +import invariant from "@minswap/tiny-invariant"; +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, MinswapAggregatorProvider } from "../provider"; +import { OrderRepository } from "../repository/order-repository"; +import { type Position, PositionRepository } from "../repository/position-repository"; +import { logger } from "../utils"; + +export type CreatePositionInput = { + userAddress: string; + marketId: string; + side: "LONG" | "SHORT"; + 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; txId: string; orderType: string } + | { 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, + private readonly networkEnv: NetworkEnvironment, + private readonly cardanoscanProvider: CardanoscanProvider, + private readonly aggregatorProvider: MinswapAggregatorProvider, + ) {} + + async createPosition(input: CreatePositionInput): Promise { + const { userAddress, marketId, side, amountIn } = input; + + // Validate side + if (side !== "LONG" && side !== "SHORT") { + return { success: false, error: "Side must be LONG or SHORT" }; + } + + // 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` }; + } + + // Validate minimum collateral + if (amountIn < marketConfig.minCollateral) { + return { + success: false, + error: `Minimum collateral is ${marketConfig.minCollateral} lovelace`, + }; + } + + // Check for existing open position in this market + const existingPosition = await PositionRepository.getOpenPositionByUserAndMarket(this.db, userAddress, marketId); + + if (existingPosition) { + return { + success: false, + error: "User already has an open position for this market", + }; + } + + // Calculate amount_borrow + let amountBorrow: bigint; + if (side === StateMachine.PositionSide.LONG) { + // 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) => { + const pos = await PositionRepository.createPosition(trx, { + marketId, + userAddress, + side: side as StateMachine.PositionSide, + amountIn: amountIn.toString(), + amountBorrow: amountBorrow.toString(), + }); + + 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.assetAQTokenRaw, + }, + { + positionId: pos.id, + orderType: StateMachine.ShortOrderType.SHORT_BORROW, + }, + { + positionId: pos.id, + orderType: StateMachine.ShortOrderType.SHORT_SELL, + }, + ]); + } + + return pos; + }); + + return { success: true, position }; + } + + async buildTx(input: BuildTxInput): Promise { + const { userAddress, marketId, utxos } = input; + + // Validate market + if (!isSupportedMarket(marketId)) { + return { success: false, error: `Market "${marketId}" is not supported or disabled` }; + } + + // 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" }; + } + + const marketConfig = getMarketConfig(marketId); + if (!marketConfig) { + return { success: false, error: `Market "${marketId}" configuration not found` }; + } + + try { + // 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 status", { + orderId: waitingOrder.id, + orderType: waitingOrder.orderType, + createdTxId: waitingOrder.createdTxId, + }); + invariant(waitingOrder.assetOut, "Waiting order must have assetOut defined"); + invariant(waitingOrder.createdTxId, "Waiting order must have createdTxId defined"); + + // 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`, + }; + } + + // Build common waiting options + const waitingOptions: StateMachine.WaitingOptions = { + marketConfig, + 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, + }; + + const waitingResult = await waitingFn(waitingOptions); + + if (waitingResult.isConfirmed) { + // 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, + amountOut: waitingResult.amountOut, + }); + + 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.`, + }; + } + + // This is the final state (no more orders to process) + 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 { + success: false, + error: `${waitingOrder.orderType} completed. Position is now ${waitingResult.positionStatus}.`, + }; + } else { + return { + success: false, + error: `${waitingOrder.orderType} transaction not yet confirmed on chain.`, + }; + } + } + + // 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); + + // 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) + ); + + if (txFoundOnChain) { + logger.info("Transaction found on chain", { + orderId: order.id, + 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 confirmed on chain. Waiting for order to be processed.", + }; + } + + // Transaction not found on chain - check if 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) { + // Transaction expired => rebuild + logger.info("Transaction expired, rebuilding", { + orderId: order.id, + validTo: validTo.toISOString(), + now: now.toISOString(), + }); + // Fall through to rebuild + } else { + // 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.`, + }; + } + } + + // STEP 4: Build new transaction + logger.info("Building new transaction", { + orderId: order.id, + orderType: order.orderType, + hasPreviousBuild: !!order.builtTxId, + }); + + // 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, + }; + + // 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; + } + + // 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 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?.amountIn) { + return { success: false, error: "SHORT_SUPPLY order not found or amountIn not set" }; + } + buildOptions.supplyAmountOut = supplyOrder.amountIn; + } + + const txResult = await buildFn(buildOptions); + + // 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, txId: txResult.txId, 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); + } + + 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.`, + }; + } + + // 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); + + 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 { + ...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 }; + } +} 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/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 new file mode 100644 index 0000000..0b61367 --- /dev/null +++ b/apps/long-short-backend/src/utils/index.ts @@ -0,0 +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/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..3eb18d3 --- /dev/null +++ b/apps/long-short-backend/src/utils/signature.ts @@ -0,0 +1,118 @@ +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) + 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..958614e --- /dev/null +++ b/apps/long-short-backend/test/sign-data.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { RustModule } from "@minswap/felis-ledger-utils"; +import { baseAddressWalletFromSeed } from "@minswap/felis-cip"; +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"); +} + +/** + * 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, + ); + // 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); + }); + + 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(); + }); +}); 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/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/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", 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/**"] }, diff --git a/docker-compose.yml b/docker-compose.yml index 199df26..c609bc9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,83 @@ -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 + 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 + timeout: 5s + retries: 5 + start_period: 10s services: - web: - build: - context: . - dockerfile: Dockerfile + long-short-backend: + container_name: margin-api + <<: *base-backend + ports: + - "9999:9999" + 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 + container_name: margin-redis + restart: always + command: --requirepass 7obaQyYSDDLDk3ECYA + ports: + - "6380: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 + container_name: margin-postgres + restart: always + ports: + - "5433:5432" environment: - NODE_ENV: production - NEXT_PUBLIC_NETWORK_ENV: TESTNET_PREVIEW - restart: unless-stopped - networks: - - dev-network + POSTGRES_PASSWORD: JBNGlQ9wNFLlYWc2mG + POSTGRES_DB: margin + command: ["-c", "max_connections=50"] + volumes: + - postgres-data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 -networks: - dev-network: - name: dev-network - driver: bridge +volumes: + postgres-data: {} + redis-data: {} 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; } 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/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/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[] = []; 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..77c5f2f --- /dev/null +++ b/packages/minswap-lending-market/src/liqwid-provider-v2.ts @@ -0,0 +1,839 @@ +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; + }; + + /** + * 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; + }; + + // 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 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: requestBody, + }); + + if (!response.ok) { + return Result.err(new Error(`Liqwid API error: ${response.status} ${response.statusText}`)); + } + + 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)}`)); + } + + 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; + }; + + /** + * 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( + 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, + }); +} 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/packages/minswap-lending-market/src/schema.graphql b/packages/minswap-lending-market/src/schema.graphql new file mode 100644 index 0000000..331217e --- /dev/null +++ b/packages/minswap-lending-market/src/schema.graphql @@ -0,0 +1,1391 @@ +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!]! +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5096f5..b0304b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,11 +71,132 @@ 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-lending-market': + specifier: workspace:* + version: link:../../packages/minswap-lending-market + '@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 + crypto-js: + specifier: ^4.2.0 + version: 4.2.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/crypto-js': + specifier: ^4.2.2 + version: 4.2.2 + '@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: '@ant-design/icons': @@ -144,7 +265,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 +302,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 +320,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 +344,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 +411,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 +466,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 +524,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 +564,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 +601,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 +635,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 +696,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 +748,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 +757,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 +794,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 +831,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 +865,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 +902,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 +963,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 +1012,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 +1045,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 +1083,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 +1120,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 +1170,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 +1259,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 +1462,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 +1640,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 +1729,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 +1891,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,9 +1909,15 @@ 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==} + '@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==} @@ -1765,6 +1933,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 +2032,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 +2045,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 +2067,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 +2124,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 +2178,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 +2220,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 +2232,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 +2264,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 +2287,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==} @@ -2066,6 +2317,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==} @@ -2114,18 +2368,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 +2434,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 +2485,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 +2570,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 +2597,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 +2635,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 +2662,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 +2699,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 +2719,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 +2749,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 +2795,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 +2806,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 +2951,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 +2989,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 +3017,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 +3103,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 +3143,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 +3185,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 +3201,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 +3234,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 +3274,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 +3285,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 +3315,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 +3363,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 +3392,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 +3431,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 +3662,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 +3684,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 +3715,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 +3736,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 +3749,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 +3783,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 +3813,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 +3843,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 +3886,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 +3974,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 +3986,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 +4000,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 +4024,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 +4044,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 +4137,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 +4277,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 +4308,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 +4328,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 +4390,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 +4467,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 +4551,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 +4595,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 +4733,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 +4796,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 +4929,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,10 +4957,16 @@ 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 + '@types/crypto-js@4.2.2': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} @@ -4249,6 +4979,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 +4993,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 +5010,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 +5040,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 +5070,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 +5094,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 +5128,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 +5147,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 +5289,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 +5349,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 +5403,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 +5416,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 +5444,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 @@ -4675,6 +5493,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-js@4.2.0: {} + csstype@3.1.3: {} csstype@3.2.3: {} @@ -4723,15 +5543,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 +5606,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 +5746,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 +5768,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 +5782,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 +5797,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 +5834,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 +5863,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 +5893,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 +5946,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 +5979,8 @@ snapshots: jsonfile: 6.1.0 universalify: 2.0.1 + fs.realpath@1.0.0: {} + fsevents@2.3.3: optional: true @@ -5129,7 +6026,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 +6061,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 +6087,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 +6122,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 +6135,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 +6291,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 +6312,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 +6345,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 +6412,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 +6445,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 +6482,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 +6532,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 +6587,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 +6619,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 +6704,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 +6732,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 +7053,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 +7075,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 +7113,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 +7142,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 +7199,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 +7285,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 +7345,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 +7447,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 +7484,8 @@ snapshots: dependencies: is-number: 7.0.0 + toad-cache@3.7.0: {} + toggle-selection@1.0.6: {} tr46@0.0.3: {} @@ -6295,6 +7496,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 +7508,6 @@ snapshots: get-tsconfig: 4.10.1 optionalDependencies: fsevents: 2.3.3 - optional: true turbo-darwin-64@2.5.5: optional: true @@ -6371,13 +7575,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 +7590,8 @@ snapshots: typescript@5.8.3: {} + ufo@1.6.3: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -6403,13 +7609,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 +7630,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 +7641,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 +7666,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 +7760,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 +7785,5 @@ snapshots: yargs-parser: 21.1.1 yocto-queue@0.1.0: {} + + zod@4.3.6: {} diff --git a/turbo.json b/turbo.json index ff10a66..70801e2 100644 --- a/turbo.json +++ b/turbo.json @@ -7,51 +7,149 @@ "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", + "@minswap/felis-lending-market#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"] },