Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3ebc4a0
fix: don't sed dump
chedieck Jun 1, 2026
03e107c
chore: improve testing from dump
chedieck Jun 1, 2026
c275f8e
chore: improve testing from dump 2
chedieck Jun 1, 2026
054ccda
fix: dev-from-dump setup (named volume, prisma deploy, pv progress)
chedieck Jun 2, 2026
a7c0814
feat: SKIP_CACHE_REBUILD to prevent I/O saturation with prod data
chedieck Jun 2, 2026
18879a6
chore: ignore .forever
chedieck Jun 2, 2026
25b5016
fix: skip txs with missing prices instead of failing entire batch
chedieck May 29, 2026
8ce9149
fix: resilient chronik WebSocket with retry + bump sync concurrency t…
chedieck May 29, 2026
6fdb0f6
fix: undo 16 -> 64 bump
chedieck Jun 1, 2026
b72ce29
fix: throttle TX_CONFIRMED handler to max 6 concurrent (prevents DB c…
chedieck Jun 1, 2026
72333f3
perf: non-blocking payment cache rebuild with background backfill and…
chedieck Jun 1, 2026
4a93f89
fix: non-blocking cache rebuild with progressive UX
chedieck Jun 1, 2026
5bdf53f
perf: fix payments page blocking on cache rebuild
chedieck Jun 2, 2026
59373c9
perf: fix page loads blocking on cache rebuild I/O saturation
chedieck Jun 2, 2026
a4b6f5c
fix: revert backgroundPrisma - was starving websocket handlers
chedieck Jun 2, 2026
5392e6c
chore: remove changes from other PR
chedieck Jun 2, 2026
68ad97b
fix: revert 30s TTL cache during rebuild - don't cache partial data
chedieck Jun 2, 2026
3b198d0
test: update tests for generateAddressPaymentInfo aggregate refactor
chedieck Jun 2, 2026
678f9f4
test: expect cacheRebuilding in dashboard response during integration…
chedieck Jun 2, 2026
f83885d
refactor: extract WS retry constants (CHRONIK_WS_MAX_RETRIES, CHRONIK…
chedieck Jun 2, 2026
ff1a38f
Merge branch 'master' into fix/tx-prices-linking
chedieck Jun 2, 2026
e5eafb3
refactor: rename chronik retry constants to CHRONIK_HTTP_* and CHRONI…
chedieck Jun 2, 2026
3d9c4bd
fix: constants names
chedieck Jun 8, 2026
c77cd75
fix: resolve merge conflicts in paymentCache.ts (keep background rebu…
chedieck Jun 22, 2026
2e78112
fix: optmize search iterations
chedieck Jun 30, 2026
89d64e0
fix: deduplicate cache rebuild
chedieck Jun 22, 2026
1231945
fix: retry -> attempt
chedieck Jun 23, 2026
c616877
fix: do not block chronik connection end
chedieck Jun 30, 2026
4f38145
refactor: bump max confirmed txs to process from 6 to 20
chedieck Jun 30, 2026
f076ec9
fix: improve comment
chedieck Jun 30, 2026
9799f62
Merge branch 'master' into fix/tx-prices-linking
Klakurka Jul 11, 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
2 changes: 2 additions & 0 deletions .env.from-dump
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
IGNORE_SEEDING=true
DONT_EXECUTE_TRIGGERS=true
FROM_DUMP=true
SKIP_CACHE_REBUILD=true
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ prisma-local/seeds/productionTxs.csv
paybutton-config.json

dump.sql*
.forever
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ dev-from-dump:
$(touch_local_env)
docker compose -f docker-compose-from-dump.yml --env-file .env --env-file .env.local --env-file .env.from-dump up --build -d

stop-dev-from-dump:
docker compose -f docker-compose-from-dump.yml --env-file .env --env-file .env.local --env-file .env.from-dump down

reset-dev-from-dump:
make stop-dev-from-dump && make dev-from-dump

reset-dev-from-dump-keep-db:
docker compose -f docker-compose-from-dump.yml --env-file .env --env-file .env.local --env-file .env.from-dump up --build -d --force-recreate --no-deps paybutton

nuke-dev-from-dump:
docker compose -f docker-compose-from-dump.yml --env-file .env --env-file .env.local --env-file .env.from-dump down -v

logs-dev:
docker logs -f paybutton-dev

Expand Down
2 changes: 2 additions & 0 deletions constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,10 @@ export const XEC_TX_EXPLORER_URL = 'https://explorer.e.cash/tx/'
export const BCH_TX_EXPLORER_URL = 'https://blockchair.com/bitcoin-cash/transaction/'

export const MAX_MEMPOOL_TXS_TO_PROCESS_AT_A_TIME = 2
export const MAX_CONFIRMED_TXS_TO_PROCESS_AT_A_TIME = 6

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OTOH this default seems very low to me. A block full will take ~10 minutes of total delay, not accounting for the tx processing itself

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I bumped it to 20, but in the end we have to try: Since we have to connect new txs to address & prices, this would sometimes try to do too much at once and end up timing out the DB connection

export const CHRONIK_INITIALIZATION_DELAY = 2000
export const MEMPOOL_PROCESS_DELAY = 100
export const CONFIRMED_TX_PROCESS_DELAY = 100

/* WIP RENAME ALL THOSE */
// When fetching some address transactions, number of transactions to fetch at a time.
Expand Down
4 changes: 4 additions & 0 deletions docker-compose-from-dump.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ services:
- ./scripts/db/init-supertokens-db.sql:/home/mysql/raw_entrypoint/3-init-supertokens-db.sql
- ./scripts/db/prisma-shadow-db-fix.sql:/home/mysql/raw_entrypoint/4-prisma-shadow-db-fix.sql
- ./dump.sql:/home/mysql/raw_entrypoint/5-dump.sql
- paybutton-dump-db-data:/var/lib/mysql

users-service:
container_name: paybutton-users-service
Expand Down Expand Up @@ -69,3 +70,6 @@ services:
- ./redis:/data/redis:z
- ./scripts:/data/scripts
command: sh -c "sed -i 's/\\r//g' ./scripts/redis-start.sh && sh ./scripts/redis-start.sh"

volumes:
paybutton-dump-db-data:
68 changes: 58 additions & 10 deletions redis/paymentCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import {
import { fetchAllUserAddresses, AddressPaymentInfo } from 'services/addressService'
import { fetchPaybuttonArrayByUserId } from 'services/paybuttonService'

import { RESPONSE_MESSAGES, PAYMENT_WEEK_KEY_FORMAT, KeyValueT } from 'constants/index'
import { RESPONSE_MESSAGES, PAYMENT_WEEK_KEY_FORMAT, KeyValueT, N_OF_QUOTES } from 'constants/index'
import moment from 'moment-timezone'
import { CacheSet } from 'redis/index'
import { ButtonDisplayData, Payment } from './types'
import { getUserDashboardData } from './dashboardCache'
import { getUserDashboardData, clearDashboardCache } from './dashboardCache'
// ADDRESS:payments:YYYY:MM
const getPaymentsWeekKey = (addressString: string, timestamp: number): string => {
return `${addressString}:payments:${moment.unix(timestamp).format(PAYMENT_WEEK_KEY_FORMAT)}`
Expand All @@ -31,10 +31,20 @@ export async function * getUserUncachedAddresses (userId: string): AsyncGenerato
}

export const getPaymentList = async (userId: string): Promise<Payment[]> => {
const uncachedAddressStream = getUserUncachedAddresses(userId)
for await (const address of uncachedAddressStream) {
void await CacheSet.addressCreation(address)
const allAddresses = await fetchAllUserAddresses(userId, false, false) as Address[]
const uncachedAddresses: Address[] = []
for (const address of allAddresses) {
const keys = await getCachedWeekKeysForAddress(address.address)
if (keys.length === 0) {
uncachedAddresses.push(address)
}
}

if (uncachedAddresses.length > 0 && process.env.SKIP_CACHE_REBUILD === undefined) {
console.log(`[CACHE] getPaymentList: ${uncachedAddresses.length} uncached addresses for user ${userId}, starting background rebuild`)
void cacheAddressesInBackground(uncachedAddresses, userId)
}

return await getCachedPaymentsForUser(userId)
}

Expand Down Expand Up @@ -145,20 +155,29 @@ export const generatePaymentFromTxWithInvoices = (tx: TransactionWithAddressAndP
}

export const generateAndCacheGroupedPaymentsAndInfoForAddress = async (address: Address): Promise<GroupedPaymentsAndInfoObject> => {
const startTime = Date.now()
let paymentList: Payment[] = []
let balance = new Prisma.Decimal(0)
let paymentCount = 0
let txCount = 0
const txsWithPaybuttonsGenerator = generateTransactionsWithPaybuttonsAndPricesForAddress(address.id)
for await (const batch of txsWithPaybuttonsGenerator) {
for (const tx of batch) {
txCount++
balance = balance.plus(tx.amount)
if (tx.amount.gt(0)) {
if (tx.prices.length !== N_OF_QUOTES) {
console.warn(`[CACHE] Skipping tx ${tx.hash} — missing price links (${tx.prices.length}/${N_OF_QUOTES})`)
continue
}
const payment = generatePaymentFromTx(tx)
paymentList.push(payment)
paymentCount++
}
}
}
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
console.log(`[CACHE] Cached address ${address.address.slice(0, 20)}...: ${paymentCount} payments, ${txCount} txs in ${elapsed}s`)
const info: AddressPaymentInfo = {
balance,
paymentCount
Expand Down Expand Up @@ -304,6 +323,7 @@ export const clearPaymentCacheForAddress = async (addressString: string): Promis
}

export const initPaymentCache = async (address: Address): Promise<boolean> => {
if (process.env.SKIP_CACHE_REBUILD !== undefined) return false
const cachedKeys = await getCachedWeekKeysForAddress(address.address)
if (cachedKeys.length === 0) {
await CacheSet.addressCreation(address)
Expand All @@ -312,12 +332,40 @@ export const initPaymentCache = async (address: Address): Promise<boolean> => {
return false
}

const cacheAddressesInBackground = async (addresses: Address[], userId: string): Promise<void> => {
const startTime = Date.now()
let cached = 0
for (const address of addresses) {
try {
await CacheSet.addressCreation(address)
cached++
if (cached % 10 === 0 || cached === addresses.length) {
console.log(`[CACHE] Background: cached ${cached}/${addresses.length} addresses for user ${userId}`)
}
} catch (err: any) {
console.error(`[CACHE] Background: failed to cache ${address.address}: ${err.message as string}`)
}
}
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
console.log(`[CACHE] Background: completed ${cached}/${addresses.length} addresses for user ${userId} in ${elapsed}s`)
await clearDashboardCache(userId)
}

export async function * getPaymentStream (userId: string): AsyncGenerator<Payment> {
const uncachedAddressStream = getUserUncachedAddresses(userId)
for await (const address of uncachedAddressStream) {
console.log('[CACHE]: Creating cache for address', address.address)
await CacheSet.addressCreation(address)
const allAddresses = await fetchAllUserAddresses(userId, false, false) as Address[]
const uncachedAddresses: Address[] = []
for (const address of allAddresses) {
const keys = await getCachedWeekKeysForAddress(address.address)
if (keys.length === 0) {
uncachedAddresses.push(address)
}
}

if (uncachedAddresses.length > 0 && process.env.SKIP_CACHE_REBUILD === undefined) {
console.log(`[CACHE] ${uncachedAddresses.length}/${allAddresses.length} uncached addresses for user ${userId}, starting background rebuild`)
void cacheAddressesInBackground(uncachedAddresses, userId)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is duplicated from getPaymentList, you should call that instead

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored both to use same auxiliary function.


const userButtonIds: string[] = (await fetchPaybuttonArrayByUserId(userId))
.map(p => p.id)
const weekKeys = await getCachedWeekKeysForUser(userId)
Expand All @@ -336,7 +384,7 @@ export async function * getPaymentStream (userId: string): AsyncGenerator<Paymen
})

for (const payment of weekPayments) {
yield payment // Yield one payment at a time
yield payment
}
}
}
Expand Down
18 changes: 16 additions & 2 deletions scripts/db-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,25 @@ sed_vars () {
cd /home/mysql/raw_entrypoint || exit

for file in *.sql; do
sed_vars "$file"
if [[ "$file" == *dump* ]]; then
echo "Linking dump file: $file"
ln -s /home/mysql/raw_entrypoint/"$file" /home/mysql/entrypoint/"$file"
else
echo "Copying file $file after seding vars"
sed_vars "$file"
fi
done

cd ../entrypoint/ || exit

for file in *.sql; do
mariadb -u root -p"$MYSQL_ROOT_PASSWORD" < "$file"
if [[ "$file" == *dump* ]]; then
filesize=$(stat -Lc%s "$file" 2>/dev/null || stat -Lf%z "$file" 2>/dev/null)
echo "Importing $file ($(numfmt --to=iec $filesize)) ..."
pv "$file" | mariadb -u root -p"$MYSQL_ROOT_PASSWORD"
else
echo "Importing $file ..."
mariadb -u root -p"$MYSQL_ROOT_PASSWORD" < "$file"
fi
echo "Done importing $file"
done
2 changes: 1 addition & 1 deletion scripts/db/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ FROM mariadb:latest
RUN mkhomedir_helper mysql

RUN apt-get update || echo && \
apt-get install -y gettext || echo
apt-get install -y gettext pv || echo

RUN mkdir -p /home/mysql/raw_entrypoint && \
mkdir -p /home/mysql/entrypoint
Expand Down
14 changes: 8 additions & 6 deletions scripts/paybutton-server-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,19 @@ logtime=$(date +%Y-%m-%d@%H:%M)
[ -e logs/jobs.log ] && mv logs/jobs.log logs/history/jobs_"$logtime".log
[ -e logs/ws-server.log ] && mv logs/ws-server.log logs/history/ws-server_"$logtime".log

if [ "$ENVIRONMENT" = "production" ]; then
if [ "$FROM_DUMP" = "true" ] || [ "$ENVIRONMENT" = "production" ]; then
yarn prisma migrate deploy || exit 1
yarn prisma generate || exit 1
pm2 start yarn --time --interpreter ash --name jobs --output logs/jobs.log --error logs/jobs.log -- initJobs || exit 1
pm2 start yarn --time --interpreter ash --name WSServer --output logs/ws-server.log --error logs/ws-server.log -- initWSServer || exit 1
pm2 start yarn --time --interpreter ash --name next --output logs/next.log --error logs/next.log -- prod || exit 1
else
yarn prisma migrate dev || exit 1
yarn prisma db seed || exit 1
pm2 start yarn --time --interpreter ash --name jobs --output logs/jobs.log --error logs/jobs.log -- initJobs || exit 1
pm2 start yarn --time --interpreter ash --name WSServer --output logs/ws-server.log --error logs/ws-server.log -- initWSServer || exit 1
fi

pm2 start yarn --time --interpreter ash --name jobs --output logs/jobs.log --error logs/jobs.log -- initJobs || exit 1
pm2 start yarn --time --interpreter ash --name WSServer --output logs/ws-server.log --error logs/ws-server.log -- initWSServer || exit 1
if [ "$ENVIRONMENT" = "production" ]; then
pm2 start yarn --time --interpreter ash --name next --output logs/next.log --error logs/next.log -- prod || exit 1
else
pm2 start yarn --time --interpreter ash --name next --output logs/next.log --error logs/next.log -- dev || exit 1
fi
pm2 logs next
62 changes: 57 additions & 5 deletions services/chronikService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { BlockInfo, ChronikClient, ConnectionStrategy, ScriptUtxo, Tx, WsConfig, WsEndpoint, WsMsgClient, WsSubScriptClient } from 'chronik-client'
import { encodeCashAddress, decodeCashAddress } from 'ecashaddrjs'
import { AddressWithTransaction, BlockchainInfo, TransactionDetails, ProcessedMessages, SubbedAddressesLog, SyncAndSubscriptionReturn, SubscriptionReturn, SimpleBlockInfo } from 'types/chronikTypes'
import { CHRONIK_MESSAGE_CACHE_DELAY, RESPONSE_MESSAGES, XEC_TIMESTAMP_THRESHOLD, XEC_NETWORK_ID, BCH_NETWORK_ID, BCH_TIMESTAMP_THRESHOLD, CHRONIK_FETCH_N_TXS_PER_PAGE, KeyValueT, NETWORK_IDS_FROM_SLUGS, SOCKET_MESSAGES, NETWORK_IDS, NETWORK_TICKERS, MainNetworkSlugsType, MAX_MEMPOOL_TXS_TO_PROCESS_AT_A_TIME, MEMPOOL_PROCESS_DELAY, CHRONIK_INITIALIZATION_DELAY, LATENCY_TEST_CHECK_DELAY, INITIAL_ADDRESS_SYNC_FETCH_CONCURRENTLY, TX_EMIT_BATCH_SIZE, DB_COMMIT_BATCH_SIZE, MAX_TXS_PER_ADDRESS, TX_BATCH_POLLING_DELAY } from 'constants/index'
import { CHRONIK_MESSAGE_CACHE_DELAY, RESPONSE_MESSAGES, XEC_TIMESTAMP_THRESHOLD, XEC_NETWORK_ID, BCH_NETWORK_ID, BCH_TIMESTAMP_THRESHOLD, CHRONIK_FETCH_N_TXS_PER_PAGE, KeyValueT, NETWORK_IDS_FROM_SLUGS, SOCKET_MESSAGES, NETWORK_IDS, NETWORK_TICKERS, MainNetworkSlugsType, MAX_MEMPOOL_TXS_TO_PROCESS_AT_A_TIME, MAX_CONFIRMED_TXS_TO_PROCESS_AT_A_TIME, MEMPOOL_PROCESS_DELAY, CONFIRMED_TX_PROCESS_DELAY, CHRONIK_INITIALIZATION_DELAY, LATENCY_TEST_CHECK_DELAY, INITIAL_ADDRESS_SYNC_FETCH_CONCURRENTLY, TX_EMIT_BATCH_SIZE, DB_COMMIT_BATCH_SIZE, MAX_TXS_PER_ADDRESS, TX_BATCH_POLLING_DELAY } from 'constants/index'
import { productionAddresses } from 'prisma-local/seeds/addresses'
import prisma from 'prisma-local/clientInstance'
import {
Expand Down Expand Up @@ -137,6 +137,8 @@ export class ChronikBlockchainClient {
mempoolTxsBeingProcessed!: number

private latencyTestFinished: boolean
private wsReconnecting = false
private confirmedTxsBeingProcessed = 0

constructor (networkSlug: string) {
this.latencyTestFinished = false
Expand All @@ -156,8 +158,8 @@ export class ChronikBlockchainClient {
this.latencyTestFinished = true
this.chronikWSEndpoint = this.chronik.ws(this.getWsConfig())
this.confirmedTxsHashesFromLastBlock = []
void this.chronikWSEndpoint.waitForOpen()
this.chronikWSEndpoint.subscribeToBlocks()
void this.connectWsWithRetry()
this.lastProcessedMessages = { confirmed: {}, unconfirmed: {} }
this.CHRONIK_MSG_PREFIX = `[CHRONIK — ${networkSlug}]`
this.wsEndpoint = io(`${config.wsBaseURL}/broadcast`, {
Expand All @@ -181,6 +183,42 @@ export class ChronikBlockchainClient {
}
}

private async connectWsWithRetry (maxRetries = 10, baseDelay = 5000): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is off by one

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed maxRetries to maxAttempts and CHRONIK_WS_MAX_TRIES to CHRONIK_WS_MAX_CONNECTION_ATTEMPTS. Now it should be good

try {
await this.chronikWSEndpoint.waitForOpen()
console.log(`${this.CHRONIK_MSG_PREFIX}: WebSocket connected.`)
return
} catch (err: any) {
console.error(`${this.CHRONIK_MSG_PREFIX}: WebSocket connection attempt ${attempt}/${maxRetries} failed: ${err.message as string}`)
if (attempt < maxRetries) {
const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), 60000)
console.log(`${this.CHRONIK_MSG_PREFIX}: Retrying WebSocket in ${delay / 1000}s...`)
await new Promise(resolve => setTimeout(resolve, delay))
}
}
}
console.error(`${this.CHRONIK_MSG_PREFIX}: WebSocket failed after ${maxRetries} attempts. Continuing without real-time updates.`)
}

private async reconnectWs (): Promise<void> {
if (this.wsReconnecting) return
this.wsReconnecting = true
try {
const addresses = this.getSubscribedAddresses()
this.chronikWSEndpoint = this.chronik.ws(this.getWsConfig())
this.chronikWSEndpoint.subscribeToBlocks()
for (const addr of addresses) {
this.chronikWSEndpoint.subscribeToAddress(addr)
}
await this.connectWsWithRetry()
} catch (err: any) {
console.error(`${this.CHRONIK_MSG_PREFIX}: WebSocket reconnection error: ${err.message as string}`)
} finally {
this.wsReconnecting = false
}
}
Comment on lines +204 to +220

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent reconnects during intentional shutdown.

destroy() closes chronikWSEndpoint at Lines 1460-1465, and this new onEnd handler immediately calls reconnectWs(). Without a shutdown flag, teardown now reopens the Chronik socket you are trying to close, which leaks background connections during process shutdown and test cleanup.

Proposed fix
 export class ChronikBlockchainClient {
   chronik!: ChronikClient
@@
   private latencyTestFinished: boolean
   private wsReconnecting = false
   private confirmedTxsBeingProcessed = 0
+  private wsShuttingDown = false
@@
   private async reconnectWs (): Promise<void> {
-    if (this.wsReconnecting) return
+    if (this.wsReconnecting || this.wsShuttingDown) return
     this.wsReconnecting = true
@@
       onReconnect: (_: ws.Event) => {
+        if (this.wsShuttingDown) return
         console.log(`${this.CHRONIK_MSG_PREFIX}: Chronik webSocket unexpectedly closed. Attempting reconnection...`)
         void this.reconnectWs()
       },
@@
       onEnd: (e: ws.Event) => {
+        if (this.wsShuttingDown) return
         console.log(`${this.CHRONIK_MSG_PREFIX}: Chronik WebSocket ended, type: ${e.type}. Attempting reconnection...`)
         void this.reconnectWs()
       },

Add a small shutdown() method on ChronikBlockchainClient that sets wsShuttingDown = true before closing sockets, and call that from MultiBlockchainClient.destroy().

Also applies to: 705-714

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/chronikService.ts` around lines 204 - 220, The reconnect logic
reopens Chronik during an intentional teardown; add a wsShuttingDown boolean
flag on ChronikBlockchainClient, set it to true in a new shutdown() method (call
shutdown() from MultiBlockchainClient.destroy() before closing
chronikWSEndpoint), and guard the onEnd handler and reconnectWs() (which
currently uses wsReconnecting and reconnectWs) to return immediately if
wsShuttingDown is true so the socket isn't reopened during shutdown; apply the
same guard for the other onEnd/reconnect path around the code at the 705-714
area.


public getUrls (): string[] {
return this.chronik.proxyInterface().getEndpointArray().map(e => e.url)
}
Expand Down Expand Up @@ -664,10 +702,16 @@ export class ChronikBlockchainClient {
return {
onMessage: (msg: WsMsgClient) => { void this.processWsMessage(msg) },
onError: (e: ws.ErrorEvent) => { console.log(`${this.CHRONIK_MSG_PREFIX}: Chronik webSocket error, type: ${e.type} | message: ${e.message} | error: ${e.error as string}`) },
onReconnect: (_: ws.Event) => { console.log(`${this.CHRONIK_MSG_PREFIX}: Chronik webSocket unexpectedly closed.`) },
onReconnect: (_: ws.Event) => {
console.log(`${this.CHRONIK_MSG_PREFIX}: Chronik webSocket unexpectedly closed. Attempting reconnection...`)
void this.reconnectWs()
},
onConnect: (_: ws.Event) => { console.log(`${this.CHRONIK_MSG_PREFIX}: Chronik webSocket connection (re)established.`) },
onEnd: (e: ws.Event) => { console.log(`${this.CHRONIK_MSG_PREFIX}: Chronik WebSocket ended, type: ${e.type}.`) },
autoReconnect: true
onEnd: (e: ws.Event) => {
console.log(`${this.CHRONIK_MSG_PREFIX}: Chronik WebSocket ended, type: ${e.type}. Attempting reconnection...`)
void this.reconnectWs()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure that's what you want, this could prevent you from stopping the app as it will keep trying to reconnect ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, I think the reconnection logic only at onReconnect should fix the issue.

},
autoReconnect: false
}
}

Expand Down Expand Up @@ -788,6 +832,12 @@ export class ChronikBlockchainClient {
}
} else if (msg.msgType === 'TX_CONFIRMED') {
if (this.isAlreadyBeingProcessed(msg.txid, true)) return

while (this.confirmedTxsBeingProcessed >= MAX_CONFIRMED_TXS_TO_PROCESS_AT_A_TIME) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very hacky. What about a proper message queue ?

await new Promise(resolve => setTimeout(resolve, CONFIRMED_TX_PROCESS_DELAY))
}

this.confirmedTxsBeingProcessed += 1
try {
const transaction = await this.fetchTxWithRetry(msg.txid)
const addressesWithTransactions = await this.getAddressesForTransaction(transaction)
Expand All @@ -808,6 +858,8 @@ export class ChronikBlockchainClient {
const { [msg.txid]: _, ...rest } = this.lastProcessedMessages.confirmed
this.lastProcessedMessages.confirmed = rest
}
} finally {
this.confirmedTxsBeingProcessed = Math.max(0, this.confirmedTxsBeingProcessed - 1)
}
} else if (msg.msgType === 'TX_ADDED_TO_MEMPOOL') {
if (this.isAlreadyBeingProcessed(msg.txid, false)) return
Expand Down
Loading