Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
662bb9d
fix(payout): make the payout designation an atomic conditional state …
TaprootFreak Jul 17, 2026
f739572
fix(accounting): book buy_crypto returns from the charged-back row, n…
TaprootFreak Jul 17, 2026
0d3ed02
fix(accounting): name the source booking in the native-imbalance erro…
Danswar Jul 17, 2026
f246c08
fix(payout): bring the EVM expired-tx retry under the designate-befor…
TaprootFreak Jul 17, 2026
933ddf9
fix(payout): classify provably-pre-broadcast bitcoin-family send erro…
TaprootFreak Jul 17, 2026
7b3ad38
feat(payout): add a verification-gated admin retry for uncertain payo…
TaprootFreak Jul 17, 2026
f938975
fix(dex): persist purchase orders before the swap and guard in-flight…
TaprootFreak Jul 17, 2026
b65efc2
fix(payin): designate EVM sends before broadcasting (double-send cras…
TaprootFreak Jul 17, 2026
2863922
feat(bank): wire Bank Frick custody assets into equity, liquidity and…
TaprootFreak Jul 17, 2026
520ee85
fix(payin): fail closed on ambiguous non-EVM pay-in sends (#4263)
TaprootFreak Jul 18, 2026
97c2580
fix: stop the post-cutover prod ERROR-log spam (ledger scan + sign-up…
TaprootFreak Jul 18, 2026
ff28947
feat(payin): add verification-gated admin recovery for SendUncertain …
TaprootFreak Jul 18, 2026
3f235b3
fix(bank): attribute Bank Frick CHF bank-to-Scrypt transfers in the r…
TaprootFreak Jul 18, 2026
2bd7a5e
fix(fee): resolve the sell-side payout bank for fee scoping instead o…
TaprootFreak Jul 18, 2026
7b6ee96
feat(monitoring): add Bank Frick to the bank-balance observer (#4255)
TaprootFreak Jul 18, 2026
6b2b48d
fix(ledger): scope native-balance check to single-currency transfers …
TaprootFreak Jul 18, 2026
8f50fae
fix(ledger): recover feedless-crypto cutover owed openings via a wide…
TaprootFreak Jul 19, 2026
10ad1b5
fix(ledger): expire the widened last-mark memo so a still-feedless wi…
TaprootFreak Jul 19, 2026
3408bfd
fix(history): require auth for transaction list and redact public sin…
TaprootFreak Jul 20, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* Prevents a second in-flight purchase from being created for the same (context, correlationId)
* before the first purchase has a definitive outcome. Completed and cancelled purchase rows are
* intentionally excluded, as are reservations that may legitimately share the correlation ID.
*
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddLiquidityOrderInflightPurchaseUniqueIndex1784160000000 {
name = 'AddLiquidityOrderInflightPurchaseUniqueIndex1784160000000';

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_liquidity_order_inflight_purchase" ON "liquidity_order" ("context", "correlationId") WHERE "isComplete" = false AND "type" = 'Purchase'`,
);
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_liquidity_order_inflight_purchase"`);
}
};
26 changes: 26 additions & 0 deletions migration/1784215858000-AddPayoutOrderReleasedTxIds.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddPayoutOrderReleasedTxIds1784215858000 {
name = 'AddPayoutOrderReleasedTxIds1784215858000'

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "payout_order" ADD "releasedPayoutTxIds" character varying(2048)`);
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "payout_order" DROP COLUMN "releasedPayoutTxIds"`);
}
}
237 changes: 237 additions & 0 deletions migration/1784500000000-AddBankFrickCustodyAssets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* PROD-ONLY data migration that wires Bank Frick into equity / liquidity / accounting.
*
* Creates two Custody assets (Frick/EUR, Frick/CHF), links them to active Bank Frick bank rows
* via bank.assetId (NULL → value fill only), and registers observe-only LiquidityManagementRules
* so balance refresh runs for Frick without ever triggering fund-moving actions.
*
* Guarded to `ENVIRONMENT === 'prd'` (same rationale as ActivateBankFrick): an unguarded
* status='Active' Frick LM rule would make the EVERY_MINUTE liquidity cron call
* frickService.getBalances() in every non-prod environment (no Frick credentials there) and
* log an error every minute. On dev/loc/CI this migration is a complete no-op; local/dev
* obtain the Frick custody assets from migration/seed/asset.csv instead (ids 411/412),
* unlinked from any bank row and without an LM rule — matching the dormant seed Bank Frick
* rows (receive=FALSE, send=FALSE).
*
* Per `docs/bank-frick-operations.md` §3.3 step 1, a Frick bank row must be linked to its
* custody/liquidity asset - and that asset's balance refresh verified - BEFORE `send=true` is set.
* The already-merged `ActivateBankFrick1784400000000` set `receive=TRUE, send=TRUE` directly
* without performing that link. Because this migration's timestamp is strictly later, it always
* runs after `ActivateBankFrick` and is the one that retroactively performs the runbook §3.3
* step-1 link for the rows that migration activated.
*
* up() is fully idempotent and additive-only on prod: never overwrites a non-null assetId,
* never deletes pre-existing rows. down() is not idempotent in that sense - see below.
*
* up():
* 1. prod guard (no-op elsewhere)
* 2. lock_timeout
* 3. fail-loud price-source guard (Yapeal/* primary, Olkypay/* fallback) per currency
* 4. insert Frick/EUR + Frick/CHF custody assets with COALESCE-inlined price columns
* (idempotent per uniqueName)
* 5. link active Bank Frick bank rows (receive+send, assetId IS NULL) via uniqueName subquery
* 6. fail-loud post-condition: every active Frick bank row must now have a non-null assetId
* 7. insert observe-only LM rules (context='Bank Frick', Active, targetAsset via subquery,
* everything else NULL) so LiquidityManagementService refreshes balances without ever
* calling executeRule
*
* down() reverses in FK-safe order (rules → unlink bank → delete assets), prod-guarded the
* same way. Asset DELETE fails loud (FK violation) if a liquidity_balance / ledger row already
* references them — intentional: rolling back a used wiring is an Ops procedure, not a plain
* migration revert.
*
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddBankFrickCustodyAssets1784500000000 {
name = 'AddBankFrickCustodyAssets1784500000000';

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
// Active Frick LM rules must NEVER run on dev/loc/CI — there the Frick custody assets come
// from migration/seed/asset.csv (unlinked) and the Bank Frick registry is dormant. Returning
// early still records the migration as executed, which is the intended no-op on lower
// environments.
if (process.env.ENVIRONMENT !== 'prd') return;

await queryRunner.query(`SET LOCAL lock_timeout = '5s'`);

// --- Frick/EUR ---
const eurPriceSources = (
await queryRunner.query(
`SELECT COUNT(*)::int AS n FROM "asset" WHERE "uniqueName" IN ('Yapeal/EUR', 'Olkypay/EUR')`,
)
).at(0);
if (eurPriceSources.n === 0) {
throw new Error(
'Cannot create Frick/EUR custody asset: no price source found (tried Yapeal/EUR and Olkypay/EUR)',
);
}

const frickEurExisting = (await queryRunner.query(`SELECT "id" FROM "asset" WHERE "uniqueName" = 'Frick/EUR'`)).at(
0,
);
if (!frickEurExisting) {
await queryRunner.query(`
INSERT INTO "asset"
("name", "uniqueName", "type", "blockchain", "category", "dexName", "financialType",
"buyable", "sellable", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable",
"paymentEnabled", "refEnabled", "refundEnabled", "ikna", "personalIbanEnabled", "comingSoon",
"priceRuleId", "approxPriceChf", "approxPriceEur", "approxPriceUsd")
VALUES
('EUR', 'Frick/EUR', 'Custody', 'Frick', 'Private', 'EUR', 'EUR',
false, false, false, false, false, false,
false, false, true, false, false, false,
COALESCE(
(SELECT "priceRuleId" FROM "asset" WHERE "uniqueName" = 'Yapeal/EUR'),
(SELECT "priceRuleId" FROM "asset" WHERE "uniqueName" = 'Olkypay/EUR')
),
COALESCE(
(SELECT "approxPriceChf" FROM "asset" WHERE "uniqueName" = 'Yapeal/EUR'),
(SELECT "approxPriceChf" FROM "asset" WHERE "uniqueName" = 'Olkypay/EUR')
),
COALESCE(
(SELECT "approxPriceEur" FROM "asset" WHERE "uniqueName" = 'Yapeal/EUR'),
(SELECT "approxPriceEur" FROM "asset" WHERE "uniqueName" = 'Olkypay/EUR')
),
COALESCE(
(SELECT "approxPriceUsd" FROM "asset" WHERE "uniqueName" = 'Yapeal/EUR'),
(SELECT "approxPriceUsd" FROM "asset" WHERE "uniqueName" = 'Olkypay/EUR')
))
`);
}

// --- Frick/CHF ---
const chfPriceSources = (
await queryRunner.query(
`SELECT COUNT(*)::int AS n FROM "asset" WHERE "uniqueName" IN ('Yapeal/CHF', 'Olkypay/CHF')`,
)
).at(0);
if (chfPriceSources.n === 0) {
throw new Error(
'Cannot create Frick/CHF custody asset: no price source found (tried Yapeal/CHF and Olkypay/CHF)',
);
}

const frickChfExisting = (await queryRunner.query(`SELECT "id" FROM "asset" WHERE "uniqueName" = 'Frick/CHF'`)).at(
0,
);
if (!frickChfExisting) {
await queryRunner.query(`
INSERT INTO "asset"
("name", "uniqueName", "type", "blockchain", "category", "dexName", "financialType",
"buyable", "sellable", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable",
"paymentEnabled", "refEnabled", "refundEnabled", "ikna", "personalIbanEnabled", "comingSoon",
"priceRuleId", "approxPriceChf", "approxPriceEur", "approxPriceUsd")
VALUES
('CHF', 'Frick/CHF', 'Custody', 'Frick', 'Private', 'CHF', 'CHF',
false, false, false, false, false, false,
false, false, true, false, false, false,
COALESCE(
(SELECT "priceRuleId" FROM "asset" WHERE "uniqueName" = 'Yapeal/CHF'),
(SELECT "priceRuleId" FROM "asset" WHERE "uniqueName" = 'Olkypay/CHF')
),
COALESCE(
(SELECT "approxPriceChf" FROM "asset" WHERE "uniqueName" = 'Yapeal/CHF'),
(SELECT "approxPriceChf" FROM "asset" WHERE "uniqueName" = 'Olkypay/CHF')
),
COALESCE(
(SELECT "approxPriceEur" FROM "asset" WHERE "uniqueName" = 'Yapeal/CHF'),
(SELECT "approxPriceEur" FROM "asset" WHERE "uniqueName" = 'Olkypay/CHF')
),
COALESCE(
(SELECT "approxPriceUsd" FROM "asset" WHERE "uniqueName" = 'Yapeal/CHF'),
(SELECT "approxPriceUsd" FROM "asset" WHERE "uniqueName" = 'Olkypay/CHF')
))
`);
}

// Pure NULL→value fill on active Frick rows only; asset resolved by stable uniqueName.
await queryRunner.query(`
UPDATE "bank" SET "assetId" = (SELECT "id" FROM "asset" WHERE "uniqueName" = 'Frick/EUR')
WHERE "name" = 'Bank Frick' AND "currency" = 'EUR' AND "receive" = true AND "send" = true AND "assetId" IS NULL
`);
await queryRunner.query(`
UPDATE "bank" SET "assetId" = (SELECT "id" FROM "asset" WHERE "uniqueName" = 'Frick/CHF')
WHERE "name" = 'Bank Frick' AND "currency" = 'CHF' AND "receive" = true AND "send" = true AND "assetId" IS NULL
`);

// Fail-loud post-condition: every active Frick bank row must be linked. Vacuously true when no
// active Frick rows exist (e.g. schema-only fixture / pre-activation).
const unlinked = await queryRunner.query(
`SELECT "id", "currency" FROM "bank"
WHERE "name" = 'Bank Frick' AND "receive" = true AND "send" = true AND "assetId" IS NULL`,
);
if (unlinked.length > 0) {
throw new Error('Bank Frick custody-asset wiring incomplete');
}

// Observe-only LM rules: minimal/maximal/actions all NULL so LiquidityManagementRule.verify()
// always returns action:null. status=Active so checkLiquidityBalances still refreshes the balance.
// targetFiatId deliberately omitted (NULL) — rule targets the Custody asset only.
const eurRuleExisting = (
await queryRunner.query(`
SELECT lmr."id" FROM "liquidity_management_rule" lmr
JOIN "asset" a ON a."id" = lmr."targetAssetId"
WHERE lmr."context" = 'Bank Frick' AND a."uniqueName" = 'Frick/EUR' AND lmr."targetFiatId" IS NULL
`)
).at(0);
if (!eurRuleExisting) {
await queryRunner.query(`
INSERT INTO "liquidity_management_rule" ("context", "status", "targetAssetId")
VALUES ('Bank Frick', 'Active', (SELECT "id" FROM "asset" WHERE "uniqueName" = 'Frick/EUR'))
`);
}

const chfRuleExisting = (
await queryRunner.query(`
SELECT lmr."id" FROM "liquidity_management_rule" lmr
JOIN "asset" a ON a."id" = lmr."targetAssetId"
WHERE lmr."context" = 'Bank Frick' AND a."uniqueName" = 'Frick/CHF' AND lmr."targetFiatId" IS NULL
`)
).at(0);
if (!chfRuleExisting) {
await queryRunner.query(`
INSERT INTO "liquidity_management_rule" ("context", "status", "targetAssetId")
VALUES ('Bank Frick', 'Active', (SELECT "id" FROM "asset" WHERE "uniqueName" = 'Frick/CHF'))
`);
}
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
if (process.env.ENVIRONMENT !== 'prd') return;

await queryRunner.query(`SET LOCAL lock_timeout = '5s'`);

// Rules first (FK-safe), then unlink bank rows, then delete assets.
await queryRunner.query(`
DELETE FROM "liquidity_management_rule"
WHERE "context" = 'Bank Frick'
AND "targetAssetId" IN (
SELECT "id" FROM "asset" WHERE "uniqueName" IN ('Frick/EUR', 'Frick/CHF')
)
`);

await queryRunner.query(`
UPDATE "bank" SET "assetId" = NULL
WHERE "assetId" IN (
SELECT "id" FROM "asset" WHERE "uniqueName" IN ('Frick/EUR', 'Frick/CHF')
)
`);

// This DELETE will fail loud (FK violation) if a liquidity_balance / ledger row already
// references these assets by the time someone rolls this back — that is intentional: rolling
// back a used wiring is an Ops procedure, not a plain migration revert.
await queryRunner.query(`DELETE FROM "asset" WHERE "uniqueName" IN ('Frick/EUR', 'Frick/CHF')`);
}
};
2 changes: 2 additions & 0 deletions migration/seed/asset.csv
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,5 @@ id,name,type,buyable,sellable,chainId,sellCommand,dexName,category,blockchain,un
111,ETH,Coin,TRUE,TRUE,0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2,,ETH,Public,Ethereum,Ethereum/ETH,Ether,FALSE,1,2922.079246,FALSE,6,2304.065646,FALSE,FALSE,FALSE,FALSE,Other,18,TRUE,0,0,2480.82045,TRUE
409,FIRO,Coin,TRUE,TRUE,,,FIRO,Public,Firo,Firo/FIRO,,FALSE,,1.5,FALSE,,1.35,FALSE,FALSE,FALSE,FALSE,Other,8,FALSE,0,0,1.4,TRUE
410,EUR,Custody,FALSE,FALSE,,,EUR,Private,OlkyFrozen,OlkyFrozen/EUR,,FALSE,,1.17786809,FALSE,39,0.9287514723,FALSE,FALSE,FALSE,FALSE,EUR,,FALSE,0,0,1,TRUE
411,EUR,Custody,FALSE,FALSE,,,EUR,Private,Frick,Frick/EUR,,FALSE,,1.17786809,FALSE,39,0.9287514723,FALSE,FALSE,FALSE,FALSE,EUR,,FALSE,0,0,1,TRUE
412,CHF,Custody,FALSE,FALSE,,,CHF,Private,Frick,Frick/CHF,,FALSE,,1.268227427,FALSE,37,1,FALSE,FALSE,FALSE,FALSE,CHF,,FALSE,0,0,1.076714309,TRUE
5 changes: 3 additions & 2 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -795,8 +795,9 @@ export class Configuration {

payout = {
// Cap on auto-retries for a payout order that fails provably before the on-chain send call
// (gas estimation, nonce fetch, gasPrice RPC). Beyond this, a permanently failing pre-broadcast
// step (e.g. gas-estimation revert) escalates to PAYOUT_UNCERTAIN instead of retrying forever.
// (e.g. EVM gas estimation/nonce fetch or a bitcoin-family rollback-safe send failure). Beyond
// this, a permanently failing pre-broadcast step escalates to PAYOUT_UNCERTAIN instead of
// retrying forever.
maxPreBroadcastRetries: +(process.env.PAYOUT_MAX_PRE_BROADCAST_RETRIES ?? 3),
};

Expand Down
Loading
Loading