diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts index 89377335d..1f55179fb 100644 --- a/backend/src/api/mempool.ts +++ b/backend/src/api/mempool.ts @@ -396,10 +396,6 @@ class Mempool { } public $updateAccelerations(newAccelerations: Acceleration[]): string[] { - if (!config.MEMPOOL_SERVICES.ACCELERATIONS) { - return []; - } - try { const changed: string[] = []; diff --git a/backend/src/api/mining/mining-routes.ts b/backend/src/api/mining/mining-routes.ts index bdfc83d43..08ea0d1bc 100644 --- a/backend/src/api/mining/mining-routes.ts +++ b/backend/src/api/mining/mining-routes.ts @@ -9,6 +9,7 @@ import bitcoinClient from '../bitcoin/bitcoin-client'; import mining from "./mining"; import PricesRepository from '../../repositories/PricesRepository'; import AccelerationRepository from '../../repositories/AccelerationRepository'; +import accelerationApi from '../services/acceleration'; class MiningRoutes { public initRoutes(app: Application) { @@ -41,6 +42,8 @@ class MiningRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'accelerations/block/:height', this.$getAccelerationsByHeight) .get(config.MEMPOOL.API_URL_PREFIX + 'accelerations/recent/:interval', this.$getRecentAccelerations) .get(config.MEMPOOL.API_URL_PREFIX + 'accelerations/total', this.$getAccelerationTotals) + .get(config.MEMPOOL.API_URL_PREFIX + 'accelerations', this.$getActiveAccelerations) + .post(config.MEMPOOL.API_URL_PREFIX + 'acceleration/request/:txid', this.$requestAcceleration) ; } @@ -445,6 +448,37 @@ class MiningRoutes { res.status(500).send(e instanceof Error ? e.message : e); } } + + private async $getActiveAccelerations(req: Request, res: Response): Promise { + try { + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); + if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) { + res.status(400).send('Acceleration data is not available.'); + return; + } + res.status(200).send(accelerationApi.accelerations || []); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + + private async $requestAcceleration(req: Request, res: Response): Promise { + if (config.MEMPOOL_SERVICES.ACCELERATIONS || config.MEMPOOL.OFFICIAL) { + res.status(405).send('not available.'); + return; + } + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Cache-control', 'private, no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'); + res.setHeader('expires', -1); + try { + accelerationApi.accelerationRequested(req.params.txid); + res.status(200).send('ok'); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } } export default new MiningRoutes(); diff --git a/backend/src/api/services/acceleration.ts b/backend/src/api/services/acceleration.ts index 7debe7119..386c40b8e 100644 --- a/backend/src/api/services/acceleration.ts +++ b/backend/src/api/services/acceleration.ts @@ -1,8 +1,10 @@ import config from '../../config'; import logger from '../../logger'; -import { BlockExtended, PoolTag } from '../../mempool.interfaces'; +import { BlockExtended } from '../../mempool.interfaces'; import axios from 'axios'; +type MyAccelerationStatus = 'requested' | 'accelerating' | 'done'; + export interface Acceleration { txid: string, added: number, @@ -35,18 +37,88 @@ export interface AccelerationHistory { }; class AccelerationApi { - public async $fetchAccelerations(): Promise { + private apiPath = config.MEMPOOL.OFFICIAL ? (config.MEMPOOL_SERVICES.API + '/accelerator/accelerations') : (config.EXTERNAL_DATA_SERVER.MEMPOOL_API + '/accelerations'); + private _accelerations: Acceleration[] | null = null; + private lastPoll = 0; + private forcePoll = false; + private myAccelerations: Record = {}; + + public get accelerations(): Acceleration[] | null { + return this._accelerations; + } + + public countMyAccelerationsWithStatus(filter: MyAccelerationStatus): number { + return Object.values(this.myAccelerations).reduce((count, {status}) => { return count + (status === filter ? 1 : 0); }, 0); + } + + public accelerationRequested(txid: string): void { + this.myAccelerations[txid] = { status: 'requested', added: Date.now() }; + } + + public accelerationConfirmed(): void { + this.forcePoll = true; + } + + private async $fetchAccelerations(): Promise { + try { + const response = await axios.get(this.apiPath, { responseType: 'json', timeout: 10000 }); + return response?.data || []; + } catch (e) { + logger.warn('Failed to fetch current accelerations from the mempool services backend: ' + (e instanceof Error ? e.message : e)); + return null; + } + } + + public async $updateAccelerations(): Promise { if (config.MEMPOOL_SERVICES.ACCELERATIONS) { - try { - const response = await axios.get(`${config.MEMPOOL_SERVICES.API}/accelerator/accelerations`, { responseType: 'json', timeout: 10000 }); - return response.data as Acceleration[]; - } catch (e) { - logger.warn('Failed to fetch current accelerations from the mempool services backend: ' + (e instanceof Error ? e.message : e)); - return null; + const accelerations = await this.$fetchAccelerations(); + if (accelerations) { + this._accelerations = accelerations; + return this._accelerations; } } else { - return []; + return this.$updateAccelerationsOnDemand(); } + return null; + } + + private async $updateAccelerationsOnDemand(): Promise { + const shouldUpdate = this.forcePoll + || this.countMyAccelerationsWithStatus('requested') > 0 + || (this.countMyAccelerationsWithStatus('accelerating') > 0 && this.lastPoll < (Date.now() - (10 * 60 * 1000))); + + // update accelerations if necessary + if (shouldUpdate) { + const accelerations = await this.$fetchAccelerations(); + this.lastPoll = Date.now(); + this.forcePoll = false; + if (accelerations) { + const latestAccelerations: Record = {}; + // set relevant accelerations to 'accelerating' + for (const acc of accelerations) { + if (this.myAccelerations[acc.txid]) { + latestAccelerations[acc.txid] = acc; + this.myAccelerations[acc.txid] = { status: 'accelerating', added: Date.now(), acceleration: acc }; + } + } + // txs that are no longer accelerating are either confirmed or canceled, so mark for expiry + for (const [txid, { status, acceleration }] of Object.entries(this.myAccelerations)) { + if (status === 'accelerating' && !latestAccelerations[txid]) { + this.myAccelerations[txid] = { status: 'done', added: Date.now(), acceleration }; + } + } + } + } + + // clear expired accelerations (confirmed / failed / not accepted) after 10 minutes + for (const [txid, { status, added }] of Object.entries(this.myAccelerations)) { + if (['requested', 'done'].includes(status) && added < (Date.now() - (1000 * 60 * 10))) { + delete this.myAccelerations[txid]; + } + } + + this._accelerations = Object.values(this.myAccelerations).map(({ acceleration }) => acceleration).filter(acc => acc) as Acceleration[]; + return this._accelerations; } public async $fetchAccelerationHistory(page?: number, status?: string): Promise { diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index cf2c56763..32d306ad2 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -538,9 +538,9 @@ class WebsocketHandler { } if (config.MEMPOOL.RUST_GBT) { - await mempoolBlocks.$rustUpdateBlockTemplates(transactionIds, newMempool, added, removed, candidates, config.MEMPOOL_SERVICES.ACCELERATIONS); + await mempoolBlocks.$rustUpdateBlockTemplates(transactionIds, newMempool, added, removed, candidates, true); } else { - await mempoolBlocks.$updateBlockTemplates(transactionIds, newMempool, added, removed, candidates, accelerationDelta, true, config.MEMPOOL_SERVICES.ACCELERATIONS); + await mempoolBlocks.$updateBlockTemplates(transactionIds, newMempool, added, removed, candidates, accelerationDelta, true, true); } const mBlocks = mempoolBlocks.getMempoolBlocks(); @@ -949,18 +949,14 @@ class WebsocketHandler { if (config.MEMPOOL.AUDIT && memPool.isInSync()) { let projectedBlocks; const auditMempool = _memPool; - const isAccelerated = config.MEMPOOL_SERVICES.ACCELERATIONS && accelerationApi.isAcceleratedBlock(block, Object.values(mempool.getAccelerations())); + const isAccelerated = accelerationApi.isAcceleratedBlock(block, Object.values(mempool.getAccelerations())); - if ((config.MEMPOOL_SERVICES.ACCELERATIONS)) { - if (config.MEMPOOL.RUST_GBT) { - const added = memPool.limitGBT ? (candidates?.added || []) : []; - const removed = memPool.limitGBT ? (candidates?.removed || []) : []; - projectedBlocks = await mempoolBlocks.$rustUpdateBlockTemplates(transactionIds, auditMempool, added, removed, candidates, isAccelerated, block.extras.pool.id); - } else { - projectedBlocks = await mempoolBlocks.$makeBlockTemplates(transactionIds, auditMempool, candidates, false, isAccelerated, block.extras.pool.id); - } + if (config.MEMPOOL.RUST_GBT) { + const added = memPool.limitGBT ? (candidates?.added || []) : []; + const removed = memPool.limitGBT ? (candidates?.removed || []) : []; + projectedBlocks = await mempoolBlocks.$rustUpdateBlockTemplates(transactionIds, auditMempool, added, removed, candidates, isAccelerated, block.extras.pool.id); } else { - projectedBlocks = mempoolBlocks.getMempoolBlocksWithTransactions(); + projectedBlocks = await mempoolBlocks.$makeBlockTemplates(transactionIds, auditMempool, candidates, false, isAccelerated, block.extras.pool.id); } if (Common.indexingEnabled()) { @@ -1040,7 +1036,7 @@ class WebsocketHandler { const removed = memPool.limitGBT ? (candidates?.removed || []) : transactions; await mempoolBlocks.$rustUpdateBlockTemplates(transactionIds, _memPool, added, removed, candidates, true); } else { - await mempoolBlocks.$makeBlockTemplates(transactionIds, _memPool, candidates, true, config.MEMPOOL_SERVICES.ACCELERATIONS); + await mempoolBlocks.$makeBlockTemplates(transactionIds, _memPool, candidates, true, true); } const mBlocks = mempoolBlocks.getMempoolBlocks(); const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas(); diff --git a/backend/src/index.ts b/backend/src/index.ts index 2a1afc712..1d83c56a3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -229,7 +229,7 @@ class Server { const newMempool = await bitcoinApi.$getRawMempool(); const minFeeMempool = memPool.limitGBT ? await bitcoinSecondClient.getRawMemPool() : null; const minFeeTip = memPool.limitGBT ? await bitcoinSecondClient.getBlockCount() : -1; - const newAccelerations = await accelerationApi.$fetchAccelerations(); + const newAccelerations = await accelerationApi.$updateAccelerations(); const numHandledBlocks = await blocks.$updateBlocks(); const pollRate = config.MEMPOOL.POLL_RATE_MS * (indexer.indexerIsRunning() ? 10 : 1); if (numHandledBlocks === 0) { diff --git a/backend/src/repositories/AccelerationRepository.ts b/backend/src/repositories/AccelerationRepository.ts index 1c3bdad29..70fa78dc6 100644 --- a/backend/src/repositories/AccelerationRepository.ts +++ b/backend/src/repositories/AccelerationRepository.ts @@ -213,6 +213,15 @@ class AccelerationRepository { this.$saveAcceleration(accelerationInfo, block, block.extras.pool.id, successfulAccelerations); } } + let anyConfirmed = false; + for (const acc of accelerations) { + if (blockTxs[acc.txid]) { + anyConfirmed = true; + } + } + if (anyConfirmed) { + accelerationApi.accelerationConfirmed(); + } const lastSyncedHeight = await this.$getLastSyncedHeight(); // if we've missed any blocks, let the indexer catch up from the last synced height on the next run if (block.height === lastSyncedHeight + 1) { diff --git a/docker/frontend/entrypoint.sh b/docker/frontend/entrypoint.sh index dc0fa6f7a..20b391087 100644 --- a/docker/frontend/entrypoint.sh +++ b/docker/frontend/entrypoint.sh @@ -40,6 +40,7 @@ __MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0} __TESTNET_BLOCK_AUDIT_START_HEIGHT__=${TESTNET_BLOCK_AUDIT_START_HEIGHT:=0} __SIGNET_BLOCK_AUDIT_START_HEIGHT__=${SIGNET_BLOCK_AUDIT_START_HEIGHT:=0} __ACCELERATOR__=${ACCELERATOR:=false} +__ACCELERATOR_BUTTON__=${ACCELERATOR_BUTTON:=true} __SERVICES_API__=${SERVICES_API:=false} __PUBLIC_ACCELERATIONS__=${PUBLIC_ACCELERATIONS:=false} __HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true} @@ -70,6 +71,7 @@ export __MAINNET_BLOCK_AUDIT_START_HEIGHT__ export __TESTNET_BLOCK_AUDIT_START_HEIGHT__ export __SIGNET_BLOCK_AUDIT_START_HEIGHT__ export __ACCELERATOR__ +export __ACCELERATOR_BUTTON__ export __SERVICES_API__ export __PUBLIC_ACCELERATIONS__ export __HISTORICAL_PRICE__ diff --git a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts index 2512191b9..185238f72 100644 --- a/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts +++ b/frontend/src/app/components/accelerate-checkout/accelerate-checkout.component.ts @@ -9,6 +9,7 @@ import { Transaction } from '../../interfaces/electrs.interface'; import { MiningStats } from '../../services/mining.service'; import { IAuth, AuthServiceMempool } from '../../services/auth.service'; import { EnterpriseService } from '../../services/enterprise.service'; +import { ApiService } from '../../services/api.service'; export type PaymentMethod = 'balance' | 'bitcoin' | 'cashapp'; @@ -123,6 +124,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { constructor( public stateService: StateService, + private apiService: ApiService, private servicesApiService: ServicesApiServices, private etaService: EtaService, private audioService: AudioService, @@ -370,10 +372,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy { this.accelerationUUID ).subscribe({ next: () => { + this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); this.audioService.playSound('ascend-chime-cartoon'); this.showSuccess = true; this.estimateSubscription.unsubscribe(); - this.moveToStep('paid') + this.moveToStep('paid'); }, error: (response) => { this.accelerateError = response.error; @@ -481,6 +484,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy { that.accelerationUUID ).subscribe({ next: () => { + this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); that.audioService.playSound('ascend-chime-cartoon'); if (that.cashAppPay) { that.cashAppPay.destroy(); @@ -530,9 +534,10 @@ export class AccelerateCheckout implements OnInit, OnDestroy { } bitcoinPaymentCompleted(): void { + this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); this.audioService.playSound('ascend-chime-cartoon'); this.estimateSubscription.unsubscribe(); - this.moveToStep('paid') + this.moveToStep('paid'); } isLoggedIn(): boolean { diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 47b37cafb..cbd6dd352 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -142,7 +142,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { taprootEnabled: boolean; hasEffectiveFeeRate: boolean; accelerateCtaType: 'alert' | 'button' = 'button'; - acceleratorAvailable: boolean = this.stateService.env.ACCELERATOR && this.stateService.network === ''; + acceleratorAvailable: boolean = this.stateService.env.ACCELERATOR_BUTTON && this.stateService.network === ''; eligibleForAcceleration: boolean = false; forceAccelerationSummary = false; hideAccelerationSummary = false; @@ -196,7 +196,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.stateService.networkChanged$.subscribe( (network) => { this.network = network; - this.acceleratorAvailable = this.stateService.env.ACCELERATOR && this.stateService.network === ''; + this.acceleratorAvailable = this.stateService.env.ACCELERATOR_BUTTON && this.stateService.network === ''; } ); diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index d7efa4d02..fa52ec707 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -536,6 +536,10 @@ export class ApiService { ); } + logAccelerationRequest$(txid: string): Observable { + return this.httpClient.post(this.apiBaseUrl + this.apiBasePath + '/api/v1/acceleration/request/' + txid, ''); + } + // Cache methods async setBlockAuditLoaded(hash: string) { this.blockAuditLoaded[hash] = true; diff --git a/frontend/src/app/services/enterprise.service.ts b/frontend/src/app/services/enterprise.service.ts index 4ea890f1f..f9549cc8a 100644 --- a/frontend/src/app/services/enterprise.service.ts +++ b/frontend/src/app/services/enterprise.service.ts @@ -30,6 +30,7 @@ export class EnterpriseService { this.fetchSubdomainInfo(); this.disableSubnetworks(); this.stateService.env.ACCELERATOR = false; + this.stateService.env.ACCELERATOR_BUTTON = false; } else { this.insertMatomo(); } diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index ef13ea07d..05f1ac69f 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -71,6 +71,7 @@ export interface Env { SIGNET_BLOCK_AUDIT_START_HEIGHT: number; HISTORICAL_PRICE: boolean; ACCELERATOR: boolean; + ACCELERATOR_BUTTON: boolean; PUBLIC_ACCELERATIONS: boolean; ADDITIONAL_CURRENCIES: boolean; GIT_COMMIT_HASH_MEMPOOL_SPACE?: string; @@ -108,6 +109,7 @@ const defaultEnv: Env = { 'SIGNET_BLOCK_AUDIT_START_HEIGHT': 0, 'HISTORICAL_PRICE': true, 'ACCELERATOR': false, + 'ACCELERATOR_BUTTON': true, 'PUBLIC_ACCELERATIONS': false, 'ADDITIONAL_CURRENCIES': false, 'SERVICES_API': 'https://mempool.space/api/v1/services', diff --git a/production/mempool-frontend-config.mainnet.json b/production/mempool-frontend-config.mainnet.json index 0465cb7d3..84cde82cf 100644 --- a/production/mempool-frontend-config.mainnet.json +++ b/production/mempool-frontend-config.mainnet.json @@ -13,6 +13,7 @@ "ITEMS_PER_PAGE": 25, "LIGHTNING": true, "ACCELERATOR": true, + "ACCELERATOR_BUTTON": true, "PUBLIC_ACCELERATIONS": true, "AUDIT": true } diff --git a/production/nginx-cache-heater b/production/nginx-cache-heater index 4bbe8ee15..24ec8a061 100755 --- a/production/nginx-cache-heater +++ b/production/nginx-cache-heater @@ -9,6 +9,7 @@ heat() heatURLs=( '/api/v1/fees/recommended' + '/api/v1/accelerations' ) while true diff --git a/production/nginx/location-api-v1-services.conf b/production/nginx/location-api-v1-services.conf index 88f510e79..aad13264c 100644 --- a/production/nginx/location-api-v1-services.conf +++ b/production/nginx/location-api-v1-services.conf @@ -2,6 +2,9 @@ # routing # ########### +location /api/v1/accelerations { + try_files /dev/null @mempool-api-v1-services-cache-short; +} location /api/v1/assets { try_files /dev/null @mempool-api-v1-services-cache-short; }