diff --git a/.github/workflows/dfx-api-dev.yaml b/.github/workflows/dfx-api-dev.yaml index 422f4878cb..86a940a546 100644 --- a/.github/workflows/dfx-api-dev.yaml +++ b/.github/workflows/dfx-api-dev.yaml @@ -1,11 +1,11 @@ name: DFX API DEV CI/CD on: - # Auto-deploy is disabled until the DFX server migration cutover. - # The API still runs on Azure (see api-dev.yaml). Server-side prerequisites - # (deploy.sh dfx-api case, authorized_keys, compose) are not yet in place, - # so every develop push failed at the SSH deploy step. Re-add the push - # trigger when the migration goes live. + push: + branches: [develop] + paths-ignore: + - '**.md' + - 'infrastructure/**' workflow_dispatch: env: @@ -39,6 +39,7 @@ jobs: push: true tags: ${{ env.DOCKER_TAGS }} platforms: linux/arm64 + build-args: GIT_COMMIT=${{ github.sha }} - name: Install cloudflared run: | diff --git a/.github/workflows/dfx-api-prd.yaml b/.github/workflows/dfx-api-prd.yaml index 9b2b4f7fc0..660fc72bf8 100644 --- a/.github/workflows/dfx-api-prd.yaml +++ b/.github/workflows/dfx-api-prd.yaml @@ -1,11 +1,8 @@ name: DFX API PRD CI/CD on: - # Auto-deploy is disabled until the DFX server migration cutover. - # The API still runs on Azure (see api-prd.yaml). Server-side prerequisites - # (deploy.sh dfx-api case, authorized_keys, compose) are not yet in place, - # so every main push failed at the SSH deploy step. Re-add the push - # trigger when the migration goes live. + # Auto-deploy re-enabled for DEV (dfxdev prerequisites are in place). + # PRD trigger stays disabled until the PRD cutover is complete. workflow_dispatch: env: @@ -39,6 +36,7 @@ jobs: push: true tags: ${{ env.DOCKER_TAGS }} platforms: linux/arm64 + build-args: GIT_COMMIT=${{ github.sha }} - name: Install cloudflared run: | diff --git a/Dockerfile b/Dockerfile index 908e17d2dd..286c722550 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,10 @@ RUN npm run build # the already-compiled native modules without needing python3 + g++. RUN npm prune --omit=dev +# Write git commit SHA into dist/ so /version endpoint can read it. +ARG GIT_COMMIT=unknown +RUN echo "$GIT_COMMIT" > dist/version.txt + FROM node:20-alpine @@ -31,7 +35,12 @@ COPY --from=builder /home/node/package.json /home/node/package-lock.json ./ COPY --from=builder /home/node/node_modules ./node_modules COPY --from=builder /home/node/dist ./dist COPY --from=builder /home/node/migration ./migration +# Runtime assets referenced by source path (not dist/) in the app config: +# - i18n translations: config.ts → join(process.cwd(), 'src/shared/i18n/') +# - notification templates: *.hbs files +COPY --from=builder /home/node/src/shared/i18n ./src/shared/i18n +COPY --from=builder /home/node/src/subdomains/supporting/notification/templates ./src/subdomains/supporting/notification/templates EXPOSE 3000 -CMD ["node", "dist/main.js"] +CMD ["npm", "run", "start:prod"] diff --git a/scripts/db-debug.sh b/scripts/db-debug.sh index e5304634d1..07a5d34076 100755 --- a/scripts/db-debug.sh +++ b/scripts/db-debug.sh @@ -18,6 +18,34 @@ # Requirements: # - curl # - jq (optional, for pretty output) +# +# Safety: +# - DEBUG_API_URL defaults to PRODUCTION. Use read-only SELECT statements only; +# never run writes or DDL through this tool. +# +# Writing custom SQL (PostgreSQL backend): +# Table names are snake_case (e.g. user_data, log, asset). Column names are camelCase and +# case-sensitive, so wrap them in double quotes ("errorMessage", "ruleId") -- unquoted +# identifiers are folded to lowercase by Postgres and fail with "column does not exist". +# System schemas (information_schema, pg_catalog) are blocked server-side; derive table and +# column names from the TypeORM entities, not from the catalog. +# +# Financial balance semantics (read before interpreting --balance / --anomalies / --stats): +# These query the FinancialDataLog, which records the whole book valued in CHF. In the +# balancesTotal object: totalBalanceChf = plusBalanceChf - minusBalanceChf, where plus = +# assets DFX holds and minus = liabilities owed to customers, each valued at its current +# priceChf. Customer flow is balance-neutral: a deposit raises plus AND minus equally, and +# completing the order lowers both again, leaving only the fee. So totalBalanceChf is +# effectively the operating equity of the flow business and moves ONLY due to: +# 1. operating profit / fees (gradual, positive, realised on order completion) +# 2. FX (plus and minus are different asset baskets, so their CHF marks drift +# independently while orders are open -- the normal intraday noise) +# 3. an error or a realised loss (a discrete, persisting step) +# A sudden step (especially negative) is therefore suspicious rather than customer activity. +# The `valid` column is false when the jump vs. the previous entry exceeds +# Config.financeLogTotalBalanceChangeLimit and that entry is under 15 minutes old (a larger +# gap suppresses the flag); --anomalies lists these valid=false rows. Full reference: the +# BalancesTotal type in src/subdomains/supporting/log/dto/log.dto.ts and LogJobService. set -e diff --git a/src/config/config.ts b/src/config/config.ts index c17539cf91..290d3faef5 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1253,6 +1253,9 @@ export class Configuration { }; } + // max recvWindow (60s) to tolerate MEXC round-trip latency spikes that otherwise reject signed requests with error 700003 + mexcRecvWindow = 60000; + get mexc(): ConstructorArgs { return { apiKey: process.env.MEXC_KEY, @@ -1260,6 +1263,7 @@ export class Configuration { withdrawKeys: splitWithdrawKeys(process.env.MEXC_WITHDRAW_KEYS), ...this.exchange, timeout: 30_000, + options: { recvWindow: this.mexcRecvWindow }, }; } diff --git a/src/integration/exchange/services/mexc.service.ts b/src/integration/exchange/services/mexc.service.ts index 04c3d0b4de..ecd67ca4c2 100644 --- a/src/integration/exchange/services/mexc.service.ts +++ b/src/integration/exchange/services/mexc.service.ts @@ -161,7 +161,19 @@ export class MexcService extends ExchangeService { private readonly baseUrl = 'https://api.mexc.com/api/v3'; private async request(method: Method, path: string, params: Record): Promise { + // retry once with a fresh timestamp if a latency spike pushed the request outside the recvWindow (error 700003) + return Util.retry( + () => this.signedRequest(method, path, { ...params }), + 2, + 0, + undefined, + (e) => e.message?.includes('700003'), + ); + } + + private async signedRequest(method: Method, path: string, params: Record): Promise { params.timestamp = Date.now().toString(); + params.recvWindow = `${Config.mexcRecvWindow}`; const searchParams = new URLSearchParams(params); diff --git a/src/subdomains/supporting/log/dto/log.dto.ts b/src/subdomains/supporting/log/dto/log.dto.ts index e08d607d9b..1d934070d7 100644 --- a/src/subdomains/supporting/log/dto/log.dto.ts +++ b/src/subdomains/supporting/log/dto/log.dto.ts @@ -16,6 +16,29 @@ export interface FinanceLog { balancesTotal: BalancesTotal; } +/** + * Aggregate CHF valuation of the whole book at log time, written by LogJobService. + * + * `totalBalanceChf = plusBalanceChf - minusBalanceChf`, where plus/minus are each + * asset's positive/negative balance valued at its current `priceChf`. Plus = assets + * DFX holds, minus = liabilities owed to customers. + * + * Invariant: customer flow is balance-neutral. A deposit raises plus AND minus by the + * same amount; completing the order lowers both again, leaving only the fee in plus. + * So `totalBalanceChf` never moves because a customer deposits or withdraws — it is + * effectively the operating equity of the flow business and only moves due to: + * 1. operating profit / fees — gradual, positive, realised on order completion + * 2. FX — plus and minus are different asset baskets, so their CHF marks drift + * independently while orders are open (the normal intraday noise) + * 3. an error or a realised loss — a discrete, persisting step + * + * A sudden step (especially negative) is therefore suspicious rather than customer + * activity. Two guardrails act on this signal in LogJobService: the entry is flagged + * `valid: false` when the jump vs. the previous entry exceeds + * `Config.financeLogTotalBalanceChangeLimit` and that entry is under 15 minutes old (a + * larger logging gap suppresses the flag), and safety mode is triggered when + * `totalBalanceChf` drops below the `minTotalBalanceChf` setting. + */ export interface BalancesTotal { plusBalanceChf: number; minusBalanceChf: number; diff --git a/src/subdomains/supporting/log/log-job.service.ts b/src/subdomains/supporting/log/log-job.service.ts index bb39225982..f1e992d053 100644 --- a/src/subdomains/supporting/log/log-job.service.ts +++ b/src/subdomains/supporting/log/log-job.service.ts @@ -112,7 +112,8 @@ export class LogJobService { // changes const changeLog = await this.getChangeLog(); - // total balances + // total balances — customer flow is balance-neutral, so totalBalanceChf moves only on + // operating profit, FX, or an error/realised loss (see BalancesTotal). Hence the guardrails below. const plusBalanceChf = Util.sumObjValue(Object.values(balancesByFinancialType), 'plusBalanceChf'); const minusBalanceChf = Util.sumObjValue(Object.values(balancesByFinancialType), 'minusBalanceChf');