Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ffd79ae
Add append-only double-entry ledger (monitoring-only) (#3880)
TaprootFreak Jul 15, 2026
82e1e09
fix(log): include Scrypt and MEXC fees in the financial change log (#…
TaprootFreak Jul 15, 2026
9c281c5
feat(gs): expose aktionariat_registration on the /gs/debug allowlist …
TaprootFreak Jul 15, 2026
d26ae43
fix(realunit): gate buy quotes on the confirmed registration email an…
TaprootFreak Jul 15, 2026
3c2cd50
feat(log): expose the fx pnl component in the financial data log (#4218)
TaprootFreak Jul 15, 2026
34120cd
fix(bank-tx): count bank account fee bookings in the monthly bank cos…
TaprootFreak Jul 15, 2026
2931730
fix(referral): wrap LiquidityManagementModule import in forwardRef to…
Danswar Jul 15, 2026
507d685
fix(payout): fail-closed on empty post-broadcast tx ids (Lightning, C…
TaprootFreak Jul 16, 2026
81c8855
fix(docker): run node under tini instead of npm to silence shutdown n…
Danswar Jul 16, 2026
d799008
fix(pricing): widen FIRO cross-check tolerance to 2% (#4223)
Danswar Jul 16, 2026
6df728f
fix(logging): keep secrets out of lnbits access logs and fee-exceptio…
Danswar Jul 16, 2026
59eb81e
feat(payin): log node outages at their edges instead of every polling…
Danswar Jul 16, 2026
04d918c
fix(payment): skip the Solana balance check when no Tatum API key is …
Danswar Jul 16, 2026
5ecbe35
fix(pricing): validate asset prices before storing and guard the DEX …
Danswar Jul 16, 2026
97c34ba
fix(realunit): log Aktionariat confirm-link rejections at warn instea…
Danswar Jul 16, 2026
849c59b
chore(deps): override websocket-driver to 0.7.5 (#4234)
TaprootFreak Jul 16, 2026
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
10 changes: 9 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ RUN echo "$GIT_COMMIT" > dist/version.txt

FROM node:20-alpine

# tini as PID 1: forwards SIGTERM to node so stops behave exactly as they did
# under npm (immediate exit), without npm's 5-line error block on every stop.
# Bare node as PID 1 would IGNORE SIGTERM (no handler + PID-1 semantics) and
# get SIGKILLed after the grace period instead.
RUN apk add --no-cache tini

USER node
WORKDIR /home/node

Expand All @@ -45,4 +51,6 @@ COPY --from=builder /home/node/assets ./assets

EXPOSE 3000

CMD ["npm", "run", "start:prod"]
ENTRYPOINT ["/sbin/tini", "--"]
# Mirrors package.json start:prod — keep the two in sync.
CMD ["node", "--max-old-space-size=6144", "dist/src/main"]
80 changes: 80 additions & 0 deletions migration/1781235331368-AddLedgerTables.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* Creates the append-only double-entry ledger tables (ledger_account / ledger_tx / ledger_leg).
* New tables only — no ALTER/INSERT on existing tables (CoA bootstrap + cutover run as code jobs).
* Integer-cent columns are PostgreSQL `bigint`: int4 caps at ±2'147'483'647 cents (~21.4M CHF) per row,
* which a single large LM transfer or an aggregate cutover opening can exceed → the INSERT would fail and
* stall the source chain permanently. The entity ValueTransformer maps the driver's bigint string back to
* a JS number and fails loud above Number.MAX_SAFE_INTEGER (never silently rounds). The single-row balance
* gate is a CHECK("amountChfSum" = 0) on ledger_tx, and `sourceId` is character varying(64).
*
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddLedgerTables1781235331368 {
name = 'AddLedgerTables1781235331368';

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(
`CREATE TABLE "ledger_account" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "name" character varying(256) NOT NULL, "type" character varying(32) NOT NULL, "currency" character varying(16) NOT NULL, "active" boolean NOT NULL DEFAULT true, "assetId" integer, CONSTRAINT "UQ_b4080ce191f8cc161d447e6f76d" UNIQUE ("name"), CONSTRAINT "PK_34640393ff83dad2b4627d7ae5f" PRIMARY KEY ("id"))`,
);
await queryRunner.query(`CREATE INDEX "IDX_ab36f1dc36f9ec0a1857633190" ON "ledger_account" ("type") `);
await queryRunner.query(`CREATE INDEX "IDX_6793efdea5c47073f6b5d2af34" ON "ledger_account" ("assetId") `);

await queryRunner.query(
`CREATE TABLE "ledger_tx" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "bookingDate" TIMESTAMP NOT NULL, "valueDate" TIMESTAMP NOT NULL, "description" character varying(512), "sourceType" character varying(64) NOT NULL, "sourceId" character varying(64) NOT NULL, "seq" integer NOT NULL DEFAULT 0, "amountChfSum" bigint NOT NULL DEFAULT 0, "reversalOfId" integer, CONSTRAINT "UQ_86a66bea626f9a32e1d26a7b136" UNIQUE ("sourceType", "sourceId", "seq"), CONSTRAINT "CHK_357a2fc90abae910ef69d3822e" CHECK ("amountChfSum" = 0), CONSTRAINT "PK_2a5f197e0dbaa656731fee263d8" PRIMARY KEY ("id"))`,
);
await queryRunner.query(`CREATE INDEX "IDX_e27c60c70525be037830f579b4" ON "ledger_tx" ("bookingDate") `);
await queryRunner.query(`CREATE INDEX "IDX_42c53a01650aaa5e88bb9a3470" ON "ledger_tx" ("reversalOfId") `);

await queryRunner.query(
`CREATE TABLE "ledger_leg" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "amount" double precision NOT NULL, "priceChf" double precision, "amountChf" double precision, "amountChfCents" bigint NOT NULL DEFAULT 0, "needsMark" boolean NOT NULL DEFAULT false, "txId" integer NOT NULL, "accountId" integer NOT NULL, CONSTRAINT "PK_6566e1943c692f0caad604015d0" PRIMARY KEY ("id"))`,
);
await queryRunner.query(`CREATE INDEX "IDX_7c939d7bfcc9cc3f71bb3eddd9" ON "ledger_leg" ("txId") `);
await queryRunner.query(`CREATE INDEX "IDX_b8d0b654d708ff1255a49b7e6e" ON "ledger_leg" ("accountId") `);
await queryRunner.query(`CREATE INDEX "IDX_91e1f2192fbd0e1681e461eadb" ON "ledger_leg" ("needsMark") `);

await queryRunner.query(
`ALTER TABLE "ledger_account" ADD CONSTRAINT "FK_6793efdea5c47073f6b5d2af349" FOREIGN KEY ("assetId") REFERENCES "asset"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "ledger_tx" ADD CONSTRAINT "FK_42c53a01650aaa5e88bb9a34700" FOREIGN KEY ("reversalOfId") REFERENCES "ledger_tx"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "ledger_leg" ADD CONSTRAINT "FK_7c939d7bfcc9cc3f71bb3eddd90" FOREIGN KEY ("txId") REFERENCES "ledger_tx"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "ledger_leg" ADD CONSTRAINT "FK_b8d0b654d708ff1255a49b7e6e5" FOREIGN KEY ("accountId") REFERENCES "ledger_account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "ledger_leg" DROP CONSTRAINT "FK_b8d0b654d708ff1255a49b7e6e5"`);
await queryRunner.query(`ALTER TABLE "ledger_leg" DROP CONSTRAINT "FK_7c939d7bfcc9cc3f71bb3eddd90"`);
await queryRunner.query(`ALTER TABLE "ledger_tx" DROP CONSTRAINT "FK_42c53a01650aaa5e88bb9a34700"`);
await queryRunner.query(`ALTER TABLE "ledger_account" DROP CONSTRAINT "FK_6793efdea5c47073f6b5d2af349"`);

await queryRunner.query(`DROP INDEX "public"."IDX_91e1f2192fbd0e1681e461eadb"`);
await queryRunner.query(`DROP INDEX "public"."IDX_b8d0b654d708ff1255a49b7e6e"`);
await queryRunner.query(`DROP INDEX "public"."IDX_7c939d7bfcc9cc3f71bb3eddd9"`);
await queryRunner.query(`DROP TABLE "ledger_leg"`);

await queryRunner.query(`DROP INDEX "public"."IDX_42c53a01650aaa5e88bb9a3470"`);
await queryRunner.query(`DROP INDEX "public"."IDX_e27c60c70525be037830f579b4"`);
await queryRunner.query(`DROP TABLE "ledger_tx"`);

await queryRunner.query(`DROP INDEX "public"."IDX_6793efdea5c47073f6b5d2af34"`);
await queryRunner.query(`DROP INDEX "public"."IDX_ab36f1dc36f9ec0a1857633190"`);
await queryRunner.query(`DROP TABLE "ledger_account"`);
}
};
33 changes: 33 additions & 0 deletions migration/1784117860216-AddBankTxTypeCreatedIndex.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* Backs the monthly bank-cost aggregation (BankTxService.getBankTxFee), which runs every minute from
* the financial-log cron and filters bank_tx by (type = BANK_ACCOUNT_FEE, created >= from). Without a
* matching composite index the query degrades to a full scan over a table that grows continuously.
*
* bank_tx carries no primary key in production (MSSQL->Postgres cutover drift), so this migration adds
* ONLY a secondary index: no foreign key, no column alter, nothing that requires a unique constraint.
*
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddBankTxTypeCreatedIndex1784117860216 {
name = 'AddBankTxTypeCreatedIndex1784117860216';

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`CREATE INDEX "IDX_bank_tx_type_created" ON "bank_tx" ("type", "created")`);
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_bank_tx_type_created"`);
}
};
27 changes: 27 additions & 0 deletions migration/1784136802000-WidenFiroPriceCheckLimit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Widen the FIRO price rule's cross-check tolerance from 1% to 2%.
//
// The rule verifies the primary price against a reference exchange and rejects the refresh
// when both diverge by more than the limit. For this thinly traded pair the two sources
// routinely drift further apart than 1%, so refreshes keep getting rejected and the stored
// price ages past its validity window, failing dependent price lookups. 2% matches the
// pair's typical cross-venue spread while still rejecting clearly wrong quotes.

/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

module.exports = class WidenFiroPriceCheckLimit1784136802000 {
name = 'WidenFiroPriceCheckLimit1784136802000';

async up(queryRunner) {
await queryRunner.query(
`UPDATE "price_rule" SET "check1Limit" = 0.02, "updated" = NOW() WHERE "id" = 66 AND "check1Asset" = 'FIRO' AND "check1Limit" < 0.02`,
);
}

async down(_queryRunner) {
// Intentionally empty: restoring the tighter limit would re-break price refreshes;
// a future tightening needs its own explicit migration.
}
};
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@
"semver": "^7.6.0",
"fast-xml-parser": "$fast-xml-parser",
"axios": "$axios",
"protobufjs": ">=7.5.5"
"protobufjs": ">=7.5.5",
"websocket-driver": "^0.7.5"
}
}
137 changes: 137 additions & 0 deletions scripts/ledger-isolation-gate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/usr/bin/env node
/* eslint-disable */

// Node implementation of the ledger isolation gate (§4.10 R2 / §10.3) — the bundled fallback used by
// ledger-isolation-gate.sh on hosts WITHOUT a PCRE2-capable grep/rg (e.g. macOS BSD grep). It applies the
// IDENTICAL 4-block forbidden pattern as the shell gate plus the `// ledger-allowlist` post-filter (Minor R4-1),
// scanning the module SOURCE only (*.ts, excluding *.spec.ts / __tests__ / __mocks__). JavaScript's regex engine
// supports the (?!ledger) negative-lookahead natively → never a silent no-op. Prints `file:line:match` per
// offending line and exits 1 when any remains.
//
// Usage: node scripts/ledger-isolation-gate.js [TARGET_DIR]

const fs = require('fs');
const path = require('path');

const TARGET_DIR = process.argv[2] || 'src/subdomains/core/accounting';

// §4.10 / §10.3 Minor R4-1: the forbidden pattern is split into two classes so the `// ledger-allowlist` post-filter
// is SCOPED, not blanket. ALLOWLISTABLE = the DB-write blocks (EntityManager / repo / getRepository / queryRunner /
// QueryBuilder-DSL write paths) — the ONLY constructs a ledger-own write (`manager.save(LedgerTx,…)`) legitimately
// needs to clear. NON-ALLOWLISTABLE = pricing/HTTP (block 1), external feed-read (block 2), logService/settingService
// operative side-effects (block 3) and lifecycle/strategy calls (block 4): a `// ledger-allowlist` comment must NEVER
// silence one of these — there is no sanctioned reason for the ledger module to price, hit a feed, mutate the
// FinancialDataLog/setting tables or drive a source lifecycle, so allowing the marker to clear them would re-open the
// exact isolation hole the gate exists to close. Both classes are kept char-for-char equivalent to the shell gate.
const NON_ALLOWLISTABLE_PATTERN = new RegExp(
[
// Block 1 (pricing / HTTP read)
'pricingService|PricingService|getPrice\\(|getPriceAt|priceProvider|CoinGecko|HttpService',
// Block 2 (external feed-read / balance integration)
'\\brefreshBalances\\(|\\brefreshBankBalance|\\bhasPendingOrders|integration\\.getBalances|integration\\.hasPendingOrders|BankAdapter|balanceIntegrationFactory|LiquidityBalanceIntegrationFactory',
// Block 3 (FinancialDataLog / setting operative side-effects — a service write, not a sanctioned ledger DB write)
'\\blogService\\.(create|update)\\(|\\bsettingService\\.(setObj|updateProcess|addIpToBlacklist|deleteIpFromBlacklist)\\(',
// Block 4 (source lifecycle / strategy calls)
'\\.complete\\(|checkOrderCompletion|syncExchanges|doPayout|checkPayoutCompletionData|triggerWebhook|calculateSpreadFee',
].join('|'),
);

const ALLOWLISTABLE_WRITE_PATTERN = new RegExp(
[
// Block 4a/4b (non-ledger repository write)
'balanceRepo\\.(update|save|insert|delete|remove|upsert|softDelete|softRemove|recover|increment|decrement)\\(|\\b(?!ledger)\\w*Repo(sitory)?\\.(update|save|insert|delete|remove|upsert|softDelete|softRemove|recover|increment|decrement)\\(',
// Block 6 (EntityManager + raw-SQL write paths, Major design-accounting): `\w*[Mm]anager.<write>(` catches the
// idiomatic injected EntityManager regardless of the binding identifier — `manager.save`, `entityManager.save`,
// `dataSource.manager.save` (the bare `\bmanager.` missed `entityManager.` because there is no word boundary
// inside the identifier). `dataSource.query(` AND `queryRunner.query(` join `\w*[Mm]anager.query(` so a raw
// `UPDATE/INSERT` SQL write via ANY of the three escape hatches is flagged (`queryRunner` is the idiomatic
// TypeORM write path — `dataSource.createQueryRunner().query(...)`, exactly the migration pattern — and carries
// no `manager`/`dataSource` token, so it was previously unflagged). The allowlisted ledger-own
// `manager.save(LedgerTx,…) // ledger-allowlist` is cleared by the post-filter.
'\\b\\w*[Mm]anager\\.(save|insert|update|delete|remove|upsert|softDelete|softRemove|recover|increment|decrement|query)\\(|\\bdataSource\\.query\\(|\\bqueryRunner\\.query\\(',
// Block 5 (§10.2 robustness gap): getRepository(X).<write>(…) escapes Block 4a (token before `.save` is
// getRepository(...), not a *Repo identifier); a source-service write with a generic name (bankTxService.update,
// assetService.updateAsset) is not named in Blocks 2/3. The legit ledger READ getRepository(LedgerTx).
// createQueryBuilder() is not matched (no write verb); sanctioned service calls (set/sendMail/get*/find*/…) too.
'getRepository\\([^)]*\\)\\.(save|insert|update|delete|remove|upsert|softDelete|softRemove|recover|increment|decrement)\\(|\\b\\w+Service\\.(save|insert|update|delete|remove|upsert)\\w*\\(',
// Block 7 (QueryBuilder write path, Major design-accounting): a write via the QueryBuilder DSL
// `xRepo.createQueryBuilder().update(BankTx).set(...).execute()` or
// `dataSource.createQueryBuilder().insert().into(BankTx).execute()` escapes Block 4a/5 (the verb is .update/.insert
// ON the builder, not directly after `Repo.`/`getRepository(...)`). Only the WRITE verbs are flagged — a read QB
// chain (.select/.where/.getRawOne, e.g. the ledger nextSeq / reconciliation queries) is NOT matched.
'\\.createQueryBuilder\\([^)]*\\)\\.(update|insert|delete|softDelete)\\(',
].join('|'),
);

const ALLOWLIST_MARKER = 'ledger-allowlist';

function isTestPath(p) {
return p.endsWith('.spec.ts') || p.includes('/__tests__/') || p.includes('/__mocks__/');
}

function collectTsFiles(dir, out) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return; // missing dir → nothing to scan (the caller treats "no matches" as clean)
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === '__tests__' || entry.name === '__mocks__') continue;
collectTsFiles(full, out);
} else if (entry.name.endsWith('.ts') && !isTestPath(full)) {
out.push(full);
}
}
}

const matches = [];
const stat = (() => {
try {
return fs.statSync(TARGET_DIR);
} catch {
return undefined;
}
})();

const files = [];
if (stat?.isDirectory()) {
collectTsFiles(TARGET_DIR, files);
} else if (stat?.isFile() && TARGET_DIR.endsWith('.ts') && !isTestPath(TARGET_DIR)) {
files.push(TARGET_DIR);
}

// A3 fail-closed: an EMPTY scan set (wrong path / renamed tree / bad exclude) must NOT report "clean" — that is a silent
// false pass. Abort with a distinct exit 2 (stderr) so CI never green-lights an un-scanned module. Mirrors the shell
// gate's pre-scan file-count guard (only reachable standalone; the wrapper already guards before delegating here).
if (files.length === 0) {
process.stderr.write(
`ledger-isolation-gate: NO .ts source files scanned in ${TARGET_DIR} (wrong path or empty tree) — failing closed\n`,
);
process.exit(2);
}

for (const file of files) {
const lines = fs.readFileSync(file, 'utf8').split('\n');
lines.forEach((line, i) => {
// A non-allowlistable construct (pricing/feed-read/log/setting/lifecycle) ALWAYS flags, marker or not. An
// allowlistable DB-write only flags when it does NOT carry the `// ledger-allowlist` marker — so the post-filter
// is scoped to the write blocks and can never silently clear a pricing/feed/lifecycle line (§10.3 Minor R4-1).
const flagged =
NON_ALLOWLISTABLE_PATTERN.test(line) ||
(ALLOWLISTABLE_WRITE_PATTERN.test(line) && !line.includes(ALLOWLIST_MARKER));
if (flagged) {
matches.push(`${file}:${i + 1}:${line.trim()}`);
}
});
}

if (matches.length) {
// stdout so the shell wrapper captures it identically to the grep path; the wrapper prints it to stderr + exits 1
console.log(matches.join('\n'));
process.exit(1);
}

process.exit(0);
Loading
Loading