Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 0 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ This is a **Bun monorepo** containing multiple sub-projects organized into apps,
### Apps

- **[apps/api](apps/api)** - Backend API service providing signature services, on/off-ramping flows, quote generation, and transaction state management
- **[apps/dashboard](apps/dashboard)** - Internal React dashboard for protected API client observability data
- **[apps/frontend](apps/frontend)** - React-based web application built with Vite for the Vortex user interface
- **[apps/rebalancer](apps/rebalancer)** - Service for automated liquidity rebalancing across chains
### Contracts
Expand Down Expand Up @@ -68,7 +67,6 @@ bun dev

This will start:
- **Frontend**: [http://127.0.0.1:5173/](http://127.0.0.1:5173)
- **Dashboard**: [http://127.0.0.1:5175/](http://127.0.0.1:5175)
- **Backend API**: [http://localhost:3000](http://localhost:3000)

#### Run Individual Projects
Expand All @@ -78,11 +76,6 @@ This will start:
bun dev:frontend
```

**Internal dashboard only:**
```bash
bun dev:dashboard
```

**Backend API only:**
```bash
bun dev:backend
Expand Down Expand Up @@ -110,9 +103,6 @@ bun build
# Build frontend
bun build:frontend

# Build internal dashboard
bun build:dashboard

# Build backend API
bun build:backend

Expand Down Expand Up @@ -188,24 +178,6 @@ bun start

See [apps/api/README.md](apps/api/README.md) for detailed API documentation.

### Internal Dashboard (apps/dashboard)

The internal dashboard displays sanitized API client observability events from `GET /v1/admin/api-client-events`. The backend endpoint requires `Authorization: Bearer <METRICS_DASHBOARD_SECRET>`, and the dashboard stores the entered token in browser session storage only. Use a different value than `ADMIN_SECRET` so a dashboard token compromise cannot access broader admin operations.

**Development:**
```bash
cd apps/dashboard
bun dev
```

**Build:**
```bash
cd apps/dashboard
bun build
```

For Netlify, set the build command to `bun build` from `apps/dashboard` or use the root script `bun build:dashboard`. `VITE_DASHBOARD_API_BASE` can override the API base URL; otherwise the app uses `http://localhost:3000` in development and the `/api/<environment>` Netlify redirects in deployed environments.

### Rebalancer (apps/rebalancer)

Service for automated liquidity rebalancing across chains.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it, mock } from "bun:test";
import { Transaction } from "sequelize";
import sequelize from "../../config/database";
import ApiClientEvent from "../../models/apiClientEvent.model";
import {
API_CLIENT_EVENT_RETENTION_DAYS,
ApiClientEventsRetentionService,
getApiClientEventRetentionCutoff
} from "./api-client-events-retention.service";

describe("getApiClientEventRetentionCutoff", () => {
it("keeps the current UTC calendar day and the previous six days", () => {
const cutoff = getApiClientEventRetentionCutoff(new Date("2026-06-09T18:30:00.000Z"));

expect(API_CLIENT_EVENT_RETENTION_DAYS).toBe(7);
expect(cutoff.toISOString()).toBe("2026-06-03T00:00:00.000Z");
});

it("uses the UTC calendar day instead of the local clock time", () => {
const cutoff = getApiClientEventRetentionCutoff(new Date("2026-01-01T00:30:00.000Z"));

expect(cutoff.toISOString()).toBe("2025-12-26T00:00:00.000Z");
});
});

describe("ApiClientEventsRetentionService", () => {
const originalTransaction = sequelize.transaction;
const originalQuery = sequelize.query;
const originalFindAll = ApiClientEvent.findAll;
const originalDestroy = ApiClientEvent.destroy;

afterEach(() => {
sequelize.transaction = originalTransaction;
sequelize.query = originalQuery;
ApiClientEvent.findAll = originalFindAll;
ApiClientEvent.destroy = originalDestroy;
});

it("deletes expired events until all batches are drained", async () => {
const transaction = {} as Transaction;
const transactionMock = mock(async <T>(callback: (transaction: Transaction) => Promise<T>) => callback(transaction));
const queryMock = mock(async () => [{ acquired: true }]);
let findAllCallCount = 0;
let destroyCallCount = 0;
const findAllMock = mock(async () => {
findAllCallCount += 1;
if (findAllCallCount === 1) {
return Array.from({ length: 10000 }, (_, index) => ({ id: `first-${index}` }));
}

return [{ id: "second-1" }, { id: "second-2" }];
});
const destroyMock = mock(async () => {
destroyCallCount += 1;
return destroyCallCount === 1 ? 10000 : 2;
});

sequelize.transaction = transactionMock as typeof sequelize.transaction;
sequelize.query = queryMock as unknown as typeof sequelize.query;
ApiClientEvent.findAll = findAllMock as unknown as typeof ApiClientEvent.findAll;
ApiClientEvent.destroy = destroyMock as typeof ApiClientEvent.destroy;

const deletedEventsCount = await new ApiClientEventsRetentionService().cleanupExpiredEvents(
new Date("2026-06-09T18:30:00.000Z")
);

expect(deletedEventsCount).toBe(10002);
expect(transactionMock).toHaveBeenCalledTimes(2);
expect(queryMock).toHaveBeenCalledTimes(2);
expect(findAllMock).toHaveBeenCalledTimes(2);
expect(destroyMock).toHaveBeenCalledTimes(2);
});

it("stops without deleting when another worker holds the cleanup lock", async () => {
const transaction = {} as Transaction;
const transactionMock = mock(async <T>(callback: (transaction: Transaction) => Promise<T>) => callback(transaction));
const queryMock = mock(async () => [{ acquired: false }]);
const findAllMock = mock(async () => [{ id: "expired-event" }]);
const destroyMock = mock(async () => 1);

sequelize.transaction = transactionMock as typeof sequelize.transaction;
sequelize.query = queryMock as unknown as typeof sequelize.query;
ApiClientEvent.findAll = findAllMock as unknown as typeof ApiClientEvent.findAll;
ApiClientEvent.destroy = destroyMock as typeof ApiClientEvent.destroy;

const deletedEventsCount = await new ApiClientEventsRetentionService().cleanupExpiredEvents();

expect(deletedEventsCount).toBe(0);
expect(transactionMock).toHaveBeenCalledTimes(1);
expect(queryMock).toHaveBeenCalledTimes(1);
expect(findAllMock).not.toHaveBeenCalled();
expect(destroyMock).not.toHaveBeenCalled();
});
});
83 changes: 83 additions & 0 deletions apps/api/src/api/services/api-client-events-retention.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Op, QueryTypes, Transaction } from "sequelize";
import sequelize from "../../config/database";
import logger from "../../config/logger";
import ApiClientEvent from "../../models/apiClientEvent.model";

export const API_CLIENT_EVENT_RETENTION_DAYS = 7;
const API_CLIENT_EVENT_DELETE_BATCH_SIZE = 10000;
const API_CLIENT_EVENT_CLEANUP_ADVISORY_LOCK_NAMESPACE = 918522;
const API_CLIENT_EVENT_CLEANUP_ADVISORY_LOCK_KEY = 1;

export function getApiClientEventRetentionCutoff(now = new Date()): Date {
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - (API_CLIENT_EVENT_RETENTION_DAYS - 1)));
}

export class ApiClientEventsRetentionService {
public async cleanupExpiredEvents(now = new Date()): Promise<number> {
const cutoff = getApiClientEventRetentionCutoff(now);
let deletedEventsCount = 0;

while (true) {
const deletedBatchCount = await this.cleanupExpiredEventsBatch(cutoff);
deletedEventsCount += deletedBatchCount;

if (deletedBatchCount < API_CLIENT_EVENT_DELETE_BATCH_SIZE) {
return deletedEventsCount;
}
}
}

private async cleanupExpiredEventsBatch(cutoff: Date): Promise<number> {
return sequelize.transaction(async transaction => {
const [lock] = await sequelize.query<{ acquired: boolean }>(
"SELECT pg_try_advisory_xact_lock(:namespace, :key) AS acquired",
{
replacements: {
key: API_CLIENT_EVENT_CLEANUP_ADVISORY_LOCK_KEY,
namespace: API_CLIENT_EVENT_CLEANUP_ADVISORY_LOCK_NAMESPACE
},
transaction,
type: QueryTypes.SELECT
}
);

if (!lock?.acquired) {
logger.info("Skipping API client events cleanup because another worker holds the cleanup lock");
return 0;
}

return this.cleanupExpiredEventsWithLock(cutoff, transaction);
});
}

private async cleanupExpiredEventsWithLock(cutoff: Date, transaction: Transaction): Promise<number> {
const expiredEventBatch = await ApiClientEvent.findAll({
attributes: ["id"],
limit: API_CLIENT_EVENT_DELETE_BATCH_SIZE,
order: [["createdAt", "ASC"]],
transaction,
where: {
createdAt: {
[Op.lt]: cutoff
}
}
});

const expiredEventIds = expiredEventBatch.map(event => event.id);
if (expiredEventIds.length === 0) {
return 0;
}

return ApiClientEvent.destroy({
transaction,
where: {
createdAt: {
[Op.lt]: cutoff
},
id: {
[Op.in]: expiredEventIds
}
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export class DestinationTransferHandler extends BasePhaseHandler {
}

const { txData: destinationTransfer } = this.getPresignedTransaction(state, "destinationTransfer");
const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals).toString();
const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals).toFixed(0, 0);
const destinationNetwork = quote.network as EvmNetworks; // We can assert this type due to checks before
const { destinationTransferTxHash, destinationAddress } = state.state as StateMetadata;

Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/api/services/quote/core/squidrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface EvmBridgeQuoteRequest {

export interface EvmBridgeResult {
finalGrossOutputAmountDecimal: Big; // Final amount after Squidrouter
finalGrossOutputAmountRaw: string; // Final amount in destination token raw units
networkFeeUSD: string; // Squidrouter specific fee
finalEffectiveExchangeRate?: string;
outputTokenDecimals: number;
Expand Down Expand Up @@ -261,6 +262,7 @@ export async function calculateEvmBridgeAndNetworkFee(request: EvmBridgeRequest)
return {
finalEffectiveExchangeRate,
finalGrossOutputAmountDecimal,
finalGrossOutputAmountRaw: finalGrossOutputAmount,
networkFeeUSD,
outputTokenDecimals
};
Expand Down
46 changes: 46 additions & 0 deletions apps/api/src/api/services/quote/engines/finalize/onramp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {describe, expect, it} from "bun:test";
import {EvmToken, FiatToken, Networks, RampDirection} from "@vortexfi/shared";
import Big from "big.js";
import {OnRampFinalizeEngine} from "./onramp";

class TestOnRampFinalizeEngine extends OnRampFinalizeEngine {
compute(ctx: Parameters<OnRampFinalizeEngine["execute"]>[0]) {
return this.computeOutput(ctx);
}
}

describe("OnRampFinalizeEngine", () => {
it("uses destination EVM token decimals for BRL onramp output precision", async () => {
const result = await new TestOnRampFinalizeEngine().compute({
evmToEvm: {
outputAmountDecimal: new Big("4817.805726163073314321")
},
request: {
inputCurrency: FiatToken.BRL,
outputCurrency: EvmToken.USDT,
rampType: RampDirection.BUY,
to: Networks.BSC
}
} as never);

expect(result.decimals).toBe(18);
expect(result.amount.toFixed(result.decimals, 0)).toBe("4817.805726163073314321");
});

it("uses destination EVM token decimals for Alfredpay routed onramp output precision", async () => {
const result = await new TestOnRampFinalizeEngine().compute({
evmToEvm: {
outputAmountDecimal: new Big("4817.805726163073314321")
},
request: {
inputCurrency: FiatToken.USD,
outputCurrency: EvmToken.USDT,
rampType: RampDirection.BUY,
to: Networks.BSC
}
} as never);

expect(result.decimals).toBe(18);
expect(result.amount.toFixed(result.decimals, 0)).toBe("4817.805726163073314321");
});
});
10 changes: 8 additions & 2 deletions apps/api/src/api/services/quote/engines/finalize/onramp.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { AssetHubToken, FiatToken, RampDirection } from "@vortexfi/shared";
import { AssetHubToken, FiatToken, OnChainToken, RampDirection } from "@vortexfi/shared";
import Big from "big.js";
import httpStatus from "http-status";
import { APIError } from "../../../../errors/api-error";
import { getTokenDetailsForEvmDestination } from "../../core/squidrouter";
import { QuoteContext } from "../../core/types";
import { applyAlfredpayLimits, validateAmountLimits } from "../../core/validation-helpers";
import { BaseFinalizeEngine, FinalizeComputation } from ".";
Expand All @@ -17,6 +18,7 @@ export class OnRampFinalizeEngine extends BaseFinalizeEngine {
const { request } = ctx;

let finalOutputAmountDecimal: Big;
let finalOutputDecimals = 6;
if (request.to === "assethub") {
if (request.outputCurrency === AssetHubToken.USDC) {
const output = ctx.pendulumToAssethubXcm?.outputAmountDecimal;
Expand Down Expand Up @@ -46,6 +48,7 @@ export class OnRampFinalizeEngine extends BaseFinalizeEngine {
});
}
finalOutputAmountDecimal = new Big(output);
finalOutputDecimals = getTokenDetailsForEvmDestination(request.outputCurrency as OnChainToken, request.to).decimals;
} else if (
request.inputCurrency === FiatToken.USD ||
request.inputCurrency === FiatToken.MXN ||
Expand All @@ -62,6 +65,9 @@ export class OnRampFinalizeEngine extends BaseFinalizeEngine {
});
}
let amount = new Big(output);
if (ctx.evmToEvm) {
finalOutputDecimals = getTokenDetailsForEvmDestination(request.outputCurrency as OnChainToken, request.to).decimals;
}
if (!ctx.evmToEvm && ctx.alfredpayMint) {
const usdFees = ctx.fees?.usd;
const feesToDeduct = usdFees ? new Big(usdFees.vortex).plus(usdFees.partnerMarkup) : new Big(0);
Expand All @@ -88,7 +94,7 @@ export class OnRampFinalizeEngine extends BaseFinalizeEngine {

return {
amount: finalOutputAmountDecimal,
decimals: 6
decimals: finalOutputDecimals
};
}

Expand Down
Loading
Loading