Merge branch 'master' into knorrium/update-node-matrix
This commit is contained in:
		
						commit
						5f5e96984a
					
				
							
								
								
									
										72
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										72
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							@ -251,17 +251,7 @@ jobs:
 | 
			
		||||
    strategy:
 | 
			
		||||
      fail-fast: false
 | 
			
		||||
      matrix:
 | 
			
		||||
        module: ["mempool", "liquid"]
 | 
			
		||||
        include:
 | 
			
		||||
          - module: "mempool"
 | 
			
		||||
            spec: |
 | 
			
		||||
              cypress/e2e/mainnet/*.spec.ts
 | 
			
		||||
              cypress/e2e/signet/*.spec.ts
 | 
			
		||||
              cypress/e2e/testnet4/*.spec.ts
 | 
			
		||||
          - module: "liquid"
 | 
			
		||||
            spec: |
 | 
			
		||||
              cypress/e2e/liquid/liquid.spec.ts
 | 
			
		||||
              cypress/e2e/liquidtestnet/liquidtestnet.spec.ts
 | 
			
		||||
        module: ["mempool", "liquid", "testnet4"]
 | 
			
		||||
 | 
			
		||||
    name: E2E tests for ${{ matrix.module }}
 | 
			
		||||
    steps:
 | 
			
		||||
@ -310,8 +300,10 @@ jobs:
 | 
			
		||||
 | 
			
		||||
      - name: Unzip assets before building (src/resources)
 | 
			
		||||
        run: unzip -o promo-video-assets.zip -d ${{ matrix.module }}/frontend/src/resources/promo-video
 | 
			
		||||
      
 | 
			
		||||
 | 
			
		||||
      # mempool
 | 
			
		||||
      - name: Chrome browser tests (${{ matrix.module }})
 | 
			
		||||
        if: ${{ matrix.module == 'mempool' }}
 | 
			
		||||
        uses: cypress-io/github-action@v5
 | 
			
		||||
        with:
 | 
			
		||||
          tag: ${{ github.event_name }}
 | 
			
		||||
@ -322,7 +314,9 @@ jobs:
 | 
			
		||||
          wait-on-timeout: 120
 | 
			
		||||
          record: true
 | 
			
		||||
          parallel: true
 | 
			
		||||
          spec: ${{ matrix.spec }}
 | 
			
		||||
          spec: |
 | 
			
		||||
            cypress/e2e/mainnet/*.spec.ts
 | 
			
		||||
            cypress/e2e/signet/*.spec.ts
 | 
			
		||||
          group: Tests on Chrome (${{ matrix.module }})
 | 
			
		||||
          browser: "chrome"
 | 
			
		||||
          ci-build-id: "${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}"
 | 
			
		||||
@ -332,6 +326,56 @@ jobs:
 | 
			
		||||
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
          CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
 | 
			
		||||
 | 
			
		||||
      # liquid
 | 
			
		||||
      - name: Chrome browser tests (${{ matrix.module }})
 | 
			
		||||
        if: ${{ matrix.module == 'liquid' }}
 | 
			
		||||
        uses: cypress-io/github-action@v5
 | 
			
		||||
        with:
 | 
			
		||||
          tag: ${{ github.event_name }}
 | 
			
		||||
          working-directory: ${{ matrix.module }}/frontend
 | 
			
		||||
          build: npm run config:defaults:${{ matrix.module }}
 | 
			
		||||
          start: npm run start:local-staging
 | 
			
		||||
          wait-on: "http://localhost:4200"
 | 
			
		||||
          wait-on-timeout: 120
 | 
			
		||||
          record: true
 | 
			
		||||
          parallel: true
 | 
			
		||||
          spec: |
 | 
			
		||||
            cypress/e2e/liquid/liquid.spec.ts
 | 
			
		||||
            cypress/e2e/liquidtestnet/liquidtestnet.spec.ts
 | 
			
		||||
          group: Tests on Chrome (${{ matrix.module }})
 | 
			
		||||
          browser: "chrome"
 | 
			
		||||
          ci-build-id: "${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}"
 | 
			
		||||
        env:
 | 
			
		||||
          COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }}
 | 
			
		||||
          CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
 | 
			
		||||
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
          CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
 | 
			
		||||
 | 
			
		||||
      # testnet
 | 
			
		||||
      - name: Chrome browser tests (${{ matrix.module }})
 | 
			
		||||
        if: ${{ matrix.module == 'testnet4' }}
 | 
			
		||||
        uses: cypress-io/github-action@v5
 | 
			
		||||
        with:
 | 
			
		||||
          tag: ${{ github.event_name }}
 | 
			
		||||
          working-directory: ${{ matrix.module }}/frontend
 | 
			
		||||
          build: npm run config:defaults:mempool
 | 
			
		||||
          start: npm run start:local-staging
 | 
			
		||||
          wait-on: "http://localhost:4200"
 | 
			
		||||
          wait-on-timeout: 120
 | 
			
		||||
          record: true
 | 
			
		||||
          parallel: true
 | 
			
		||||
          spec: |
 | 
			
		||||
            cypress/e2e/testnet4/*.spec.ts
 | 
			
		||||
          group: Tests on Chrome (${{ matrix.module }})
 | 
			
		||||
          browser: "chrome"
 | 
			
		||||
          ci-build-id: "${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}"
 | 
			
		||||
        env:
 | 
			
		||||
          CYPRESS_REROUTE_TESTNET: true
 | 
			
		||||
          COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }}
 | 
			
		||||
          CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
 | 
			
		||||
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
          CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
 | 
			
		||||
 | 
			
		||||
  validate_docker_json:
 | 
			
		||||
    if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
 | 
			
		||||
    runs-on: "ubuntu-latest"
 | 
			
		||||
@ -359,4 +403,4 @@ jobs:
 | 
			
		||||
      - name: Validate JSON syntax
 | 
			
		||||
        run: |
 | 
			
		||||
          cat mempool-config.json | jq
 | 
			
		||||
        working-directory: docker/docker/backend
 | 
			
		||||
        working-directory: docker/docker/backend
 | 
			
		||||
@ -155,6 +155,10 @@
 | 
			
		||||
    "API": "https://mempool.space/api/v1/services",
 | 
			
		||||
    "ACCELERATIONS": false
 | 
			
		||||
  },
 | 
			
		||||
  "STRATUM": {
 | 
			
		||||
    "ENABLED": false,
 | 
			
		||||
    "API": "http://localhost:1234"
 | 
			
		||||
  },
 | 
			
		||||
  "FIAT_PRICE": {
 | 
			
		||||
    "ENABLED": true,
 | 
			
		||||
    "PAID": false,
 | 
			
		||||
 | 
			
		||||
@ -151,5 +151,9 @@
 | 
			
		||||
    "ENABLED": true,
 | 
			
		||||
    "PAID": false,
 | 
			
		||||
    "API_KEY": "__MEMPOOL_CURRENCY_API_KEY__"
 | 
			
		||||
  },
 | 
			
		||||
  "STRATUM": {
 | 
			
		||||
    "ENABLED": false,
 | 
			
		||||
    "API": "http://localhost:1234"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -159,6 +159,11 @@ describe('Mempool Backend Config', () => {
 | 
			
		||||
        PAID: false,
 | 
			
		||||
        API_KEY: '',
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      expect(config.STRATUM).toStrictEqual({
 | 
			
		||||
        ENABLED: false,
 | 
			
		||||
        API: 'http://localhost:1234',
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -119,7 +119,11 @@ class RbfCache {
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  public add(replaced: MempoolTransactionExtended[], newTxExtended: MempoolTransactionExtended): void {
 | 
			
		||||
    if (!newTxExtended || !replaced?.length || this.txs.has(newTxExtended.txid)) {
 | 
			
		||||
    if ( !newTxExtended
 | 
			
		||||
      || !replaced?.length
 | 
			
		||||
      || this.txs.has(newTxExtended.txid)
 | 
			
		||||
      || !(replaced.some(tx => !this.replacedBy.has(tx.txid)))
 | 
			
		||||
    ) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										105
									
								
								backend/src/api/services/stratum.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								backend/src/api/services/stratum.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,105 @@
 | 
			
		||||
import { WebSocket } from 'ws';
 | 
			
		||||
import logger from '../../logger';
 | 
			
		||||
import config from '../../config';
 | 
			
		||||
import websocketHandler from '../websocket-handler';
 | 
			
		||||
 | 
			
		||||
export interface StratumJob {
 | 
			
		||||
  pool: number;
 | 
			
		||||
  height: number;
 | 
			
		||||
  coinbase: string;
 | 
			
		||||
  scriptsig: string;
 | 
			
		||||
  reward: number;
 | 
			
		||||
  jobId: string;
 | 
			
		||||
  extraNonce: string;
 | 
			
		||||
  extraNonce2Size: number;
 | 
			
		||||
  prevHash: string;
 | 
			
		||||
  coinbase1: string;
 | 
			
		||||
  coinbase2: string;
 | 
			
		||||
  merkleBranches: string[];
 | 
			
		||||
  version: string;
 | 
			
		||||
  bits: string;
 | 
			
		||||
  time: string;
 | 
			
		||||
  timestamp: number;
 | 
			
		||||
  cleanJobs: boolean;
 | 
			
		||||
  received: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function isStratumJob(obj: any): obj is StratumJob {
 | 
			
		||||
  return obj
 | 
			
		||||
    && typeof obj === 'object'
 | 
			
		||||
    && 'pool' in obj
 | 
			
		||||
    && 'prevHash' in obj
 | 
			
		||||
    && 'height' in obj
 | 
			
		||||
    && 'received' in obj
 | 
			
		||||
    && 'version' in obj
 | 
			
		||||
    && 'timestamp' in obj
 | 
			
		||||
    && 'bits' in obj
 | 
			
		||||
    && 'merkleBranches' in obj
 | 
			
		||||
    && 'cleanJobs' in obj;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class StratumApi {
 | 
			
		||||
  private ws: WebSocket | null = null;
 | 
			
		||||
  private runWebsocketLoop: boolean = false;
 | 
			
		||||
  private startedWebsocketLoop: boolean = false;
 | 
			
		||||
  private websocketConnected: boolean = false;
 | 
			
		||||
  private jobs: Record<string, StratumJob> = {};
 | 
			
		||||
 | 
			
		||||
  public constructor() {}
 | 
			
		||||
 | 
			
		||||
  public getJobs(): Record<string, StratumJob> {
 | 
			
		||||
    return this.jobs;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private handleWebsocketMessage(msg: any): void {
 | 
			
		||||
    if (isStratumJob(msg)) {
 | 
			
		||||
      this.jobs[msg.pool] = msg;
 | 
			
		||||
      websocketHandler.handleNewStratumJob(this.jobs[msg.pool]);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async connectWebsocket(): Promise<void> {
 | 
			
		||||
    if (!config.STRATUM.ENABLED) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    this.runWebsocketLoop = true;
 | 
			
		||||
    if (this.startedWebsocketLoop) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    while (this.runWebsocketLoop) {
 | 
			
		||||
      this.startedWebsocketLoop = true;
 | 
			
		||||
      if (!this.ws) {
 | 
			
		||||
        this.ws = new WebSocket(`${config.STRATUM.API}`);
 | 
			
		||||
        this.websocketConnected = true;
 | 
			
		||||
 | 
			
		||||
        this.ws.on('open', () => {
 | 
			
		||||
          logger.info('Stratum websocket opened');
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.ws.on('error', (error) => {
 | 
			
		||||
          logger.err('Stratum websocket error: ' + error);
 | 
			
		||||
          this.ws = null;
 | 
			
		||||
          this.websocketConnected = false;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.ws.on('close', () => {
 | 
			
		||||
          logger.info('Stratum websocket closed');
 | 
			
		||||
          this.ws = null;
 | 
			
		||||
          this.websocketConnected = false;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.ws.on('message', (data, isBinary) => {
 | 
			
		||||
          try {
 | 
			
		||||
            const parsedMsg = JSON.parse((isBinary ? data : data.toString()) as string);
 | 
			
		||||
            this.handleWebsocketMessage(parsedMsg);
 | 
			
		||||
          } catch (e) {
 | 
			
		||||
            logger.warn('Failed to parse stratum websocket message: ' + (e instanceof Error ? e.message : e));
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
      await new Promise(resolve => setTimeout(resolve, 5000));
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default new StratumApi();
 | 
			
		||||
@ -38,6 +38,7 @@ interface AddressTransactions {
 | 
			
		||||
import bitcoinSecondClient from './bitcoin/bitcoin-second-client';
 | 
			
		||||
import { calculateMempoolTxCpfp } from './cpfp';
 | 
			
		||||
import { getRecentFirstSeen } from '../utils/file-read';
 | 
			
		||||
import stratumApi, { StratumJob } from './services/stratum';
 | 
			
		||||
 | 
			
		||||
// valid 'want' subscriptions
 | 
			
		||||
const wantable = [
 | 
			
		||||
@ -403,6 +404,16 @@ class WebsocketHandler {
 | 
			
		||||
            delete client['track-mempool'];
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          if (parsedMessage && parsedMessage['track-stratum'] != null) {
 | 
			
		||||
            if (parsedMessage['track-stratum']) {
 | 
			
		||||
              const sub = parsedMessage['track-stratum'];
 | 
			
		||||
              client['track-stratum'] = sub;
 | 
			
		||||
              response['stratumJobs'] = this.socketData['stratumJobs'];
 | 
			
		||||
            } else {
 | 
			
		||||
              client['track-stratum'] = false;
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          if (Object.keys(response).length) {
 | 
			
		||||
            client.send(this.serializeResponse(response));
 | 
			
		||||
          }
 | 
			
		||||
@ -1384,6 +1395,23 @@ class WebsocketHandler {
 | 
			
		||||
    await statistics.runStatistics();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public handleNewStratumJob(job: StratumJob): void {
 | 
			
		||||
    this.updateSocketDataFields({ 'stratumJobs': stratumApi.getJobs() });
 | 
			
		||||
 | 
			
		||||
    for (const server of this.webSocketServers) {
 | 
			
		||||
      server.clients.forEach((client) => {
 | 
			
		||||
        if (client.readyState !== WebSocket.OPEN) {
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        if (client['track-stratum'] && (client['track-stratum'] === 'all' || client['track-stratum'] === job.pool)) {
 | 
			
		||||
          client.send(JSON.stringify({
 | 
			
		||||
            'stratumJob': job
 | 
			
		||||
        }));
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // takes a dictionary of JSON serialized values
 | 
			
		||||
  // and zips it together into a valid JSON object
 | 
			
		||||
  private serializeResponse(response): string {
 | 
			
		||||
 | 
			
		||||
@ -165,6 +165,10 @@ interface IConfig {
 | 
			
		||||
  WALLETS: {
 | 
			
		||||
    ENABLED: boolean;
 | 
			
		||||
    WALLETS: string[];
 | 
			
		||||
  },
 | 
			
		||||
  STRATUM: {
 | 
			
		||||
    ENABLED: boolean;
 | 
			
		||||
    API: string;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -332,6 +336,10 @@ const defaults: IConfig = {
 | 
			
		||||
    'ENABLED': false,
 | 
			
		||||
    'WALLETS': [],
 | 
			
		||||
  },
 | 
			
		||||
  'STRATUM': {
 | 
			
		||||
    'ENABLED': false,
 | 
			
		||||
    'API': 'http://localhost:1234',
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
class Config implements IConfig {
 | 
			
		||||
@ -354,6 +362,7 @@ class Config implements IConfig {
 | 
			
		||||
  REDIS: IConfig['REDIS'];
 | 
			
		||||
  FIAT_PRICE: IConfig['FIAT_PRICE'];
 | 
			
		||||
  WALLETS: IConfig['WALLETS'];
 | 
			
		||||
  STRATUM: IConfig['STRATUM'];
 | 
			
		||||
 | 
			
		||||
  constructor() {
 | 
			
		||||
    const configs = this.merge(configFromFile, defaults);
 | 
			
		||||
@ -376,6 +385,7 @@ class Config implements IConfig {
 | 
			
		||||
    this.REDIS = configs.REDIS;
 | 
			
		||||
    this.FIAT_PRICE = configs.FIAT_PRICE;
 | 
			
		||||
    this.WALLETS = configs.WALLETS;
 | 
			
		||||
    this.STRATUM = configs.STRATUM;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  merge = (...objects: object[]): IConfig => {
 | 
			
		||||
 | 
			
		||||
@ -48,6 +48,7 @@ import accelerationRoutes from './api/acceleration/acceleration.routes';
 | 
			
		||||
import aboutRoutes from './api/about.routes';
 | 
			
		||||
import mempoolBlocks from './api/mempool-blocks';
 | 
			
		||||
import walletApi from './api/services/wallets';
 | 
			
		||||
import stratumApi from './api/services/stratum';
 | 
			
		||||
 | 
			
		||||
class Server {
 | 
			
		||||
  private wss: WebSocket.Server | undefined;
 | 
			
		||||
@ -320,6 +321,9 @@ class Server {
 | 
			
		||||
    loadingIndicators.setProgressChangedCallback(websocketHandler.handleLoadingChanged.bind(websocketHandler));
 | 
			
		||||
 | 
			
		||||
    accelerationApi.connectWebsocket();
 | 
			
		||||
    if (config.STRATUM.ENABLED) {
 | 
			
		||||
      stratumApi.connectWebsocket();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setUpHttpApiRoutes(): void {
 | 
			
		||||
 | 
			
		||||
@ -148,6 +148,10 @@
 | 
			
		||||
    "API": "__MEMPOOL_SERVICES_API__",
 | 
			
		||||
    "ACCELERATIONS": __MEMPOOL_SERVICES_ACCELERATIONS__
 | 
			
		||||
  },
 | 
			
		||||
  "STRATUM": {
 | 
			
		||||
    "ENABLED": __STRATUM_ENABLED__,
 | 
			
		||||
    "API": "__STRATUM_API__"
 | 
			
		||||
  },
 | 
			
		||||
  "REDIS": {
 | 
			
		||||
    "ENABLED": __REDIS_ENABLED__,
 | 
			
		||||
    "UNIX_SOCKET_PATH": "__REDIS_UNIX_SOCKET_PATH__",
 | 
			
		||||
 | 
			
		||||
@ -149,6 +149,10 @@ __REPLICATION_SERVERS__=${REPLICATION_SERVERS:=[]}
 | 
			
		||||
__MEMPOOL_SERVICES_API__=${MEMPOOL_SERVICES_API:="https://mempool.space/api/v1/services"}
 | 
			
		||||
__MEMPOOL_SERVICES_ACCELERATIONS__=${MEMPOOL_SERVICES_ACCELERATIONS:=false}
 | 
			
		||||
 | 
			
		||||
# STRATUM
 | 
			
		||||
__STRATUM_ENABLED__=${STRATUM_ENABLED:=false}
 | 
			
		||||
__STRATUM_API__=${STRATUM_API:="http://localhost:1234"}
 | 
			
		||||
 | 
			
		||||
# REDIS
 | 
			
		||||
__REDIS_ENABLED__=${REDIS_ENABLED:=false}
 | 
			
		||||
__REDIS_UNIX_SOCKET_PATH__=${REDIS_UNIX_SOCKET_PATH:=""}
 | 
			
		||||
@ -300,6 +304,10 @@ sed -i "s!__REPLICATION_SERVERS__!${__REPLICATION_SERVERS__}!g" mempool-config.j
 | 
			
		||||
sed -i "s!__MEMPOOL_SERVICES_API__!${__MEMPOOL_SERVICES_API__}!g" mempool-config.json
 | 
			
		||||
sed -i "s!__MEMPOOL_SERVICES_ACCELERATIONS__!${__MEMPOOL_SERVICES_ACCELERATIONS__}!g" mempool-config.json
 | 
			
		||||
 | 
			
		||||
# STRATUM
 | 
			
		||||
sed -i "s!__STRATUM_ENABLED__!${__STRATUM_ENABLED__}!g" mempool-config.json
 | 
			
		||||
sed -i "s!__STRATUM_API__!${__STRATUM_API__}!g" mempool-config.json
 | 
			
		||||
 | 
			
		||||
# REDIS
 | 
			
		||||
sed -i "s!__REDIS_ENABLED__!${__REDIS_ENABLED__}!g" mempool-config.json
 | 
			
		||||
sed -i "s!__REDIS_UNIX_SOCKET_PATH__!${__REDIS_UNIX_SOCKET_PATH__}!g" mempool-config.json
 | 
			
		||||
 | 
			
		||||
@ -344,7 +344,9 @@ describe('Mainnet', () => {
 | 
			
		||||
      cy.visit('/');
 | 
			
		||||
      cy.waitForSkeletonGone();
 | 
			
		||||
 | 
			
		||||
      cy.changeNetwork('testnet4');
 | 
			
		||||
      //TODO(knorrium): add a check for the proxied server
 | 
			
		||||
      // cy.changeNetwork('testnet4');
 | 
			
		||||
 | 
			
		||||
      cy.changeNetwork('signet');
 | 
			
		||||
      cy.changeNetwork('mainnet');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -27,5 +27,6 @@
 | 
			
		||||
  "ACCELERATOR": false,
 | 
			
		||||
  "ACCELERATOR_BUTTON": true,
 | 
			
		||||
  "PUBLIC_ACCELERATIONS": false,
 | 
			
		||||
  "STRATUM_ENABLED": false,
 | 
			
		||||
  "SERVICES_API": "https://mempool.space/api/v1/services"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -3,8 +3,10 @@ const fs = require('fs');
 | 
			
		||||
let PROXY_CONFIG = require('./proxy.conf');
 | 
			
		||||
 | 
			
		||||
PROXY_CONFIG.forEach(entry => {
 | 
			
		||||
  entry.target = entry.target.replace("mempool.space", "mempool-staging.fra.mempool.space");
 | 
			
		||||
  entry.target = entry.target.replace("liquid.network", "liquid-staging.fra.mempool.space");
 | 
			
		||||
  const hostname = process.env.CYPRESS_REROUTE_TESTNET === 'true' ? 'mempool-staging.fra.mempool.space' : 'node201.fmt.mempool.space';
 | 
			
		||||
  console.log(`e2e tests running against ${hostname}`);
 | 
			
		||||
  entry.target = entry.target.replace("mempool.space", hostname);
 | 
			
		||||
  entry.target = entry.target.replace("liquid.network", "liquid-staging.fmt.mempool.space");
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
module.exports = PROXY_CONFIG;
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
<div class="box card w-100" style="background: var(--box-bg)" id=acceleratePreviewAnchor>
 | 
			
		||||
<div class="box card w-100 accelerate-checkout-inner" [class.input-disabled]="isCheckoutLocked > 0" style="background: var(--box-bg)" id=acceleratePreviewAnchor>
 | 
			
		||||
  @if (accelerateError) {
 | 
			
		||||
    <div class="row mb-1 text-center">
 | 
			
		||||
      <div class="col-sm">
 | 
			
		||||
@ -361,7 +361,7 @@
 | 
			
		||||
        <div class="row text-center justify-content-center mx-2">
 | 
			
		||||
          <p i18n="accelerator.payment-to-mempool-space">Payment to mempool.space for acceleration of txid <a [routerLink]="'/tx/' + tx.txid" target="_blank">{{ tx.txid.substr(0, 10) }}..{{ tx.txid.substr(-10) }}</a></p>
 | 
			
		||||
        </div>
 | 
			
		||||
        @if (canPayWithBalance || !(canPayWithBitcoin || canPayWithCashapp)) {
 | 
			
		||||
        @if (canPayWithBalance || !(canPayWithBitcoin || canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay)) {
 | 
			
		||||
          <div class="row">
 | 
			
		||||
            <div class="col-sm text-center d-flex flex-column justify-content-center align-items-center">
 | 
			
		||||
              <p><ng-container i18n="accelerator.your-account-will-be-debited">Your account will be debited no more than</ng-container> <small style="font-family: monospace;">{{ cost | number }}</small> <span class="symbol" i18n="shared.sats">sats</span></p>
 | 
			
		||||
@ -484,6 +484,11 @@
 | 
			
		||||
          </div>
 | 
			
		||||
          }
 | 
			
		||||
        </div>
 | 
			
		||||
        @if (isTokenizing > 0) {
 | 
			
		||||
          <div class="d-flex flex-row justify-content-center">
 | 
			
		||||
            <div class="ml-2 spinner-border text-light" style="width: 25px; height: 25px"></div>
 | 
			
		||||
          </div>
 | 
			
		||||
        }
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -8,6 +8,13 @@
 | 
			
		||||
  color: var(--green)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.accelerate-checkout-inner {
 | 
			
		||||
  &.input-disabled {
 | 
			
		||||
    pointer-events: none;
 | 
			
		||||
    opacity: 0.75;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.paymentMethod {
 | 
			
		||||
  padding: 10px;
 | 
			
		||||
  background-color: var(--secondary);
 | 
			
		||||
 | 
			
		||||
@ -76,6 +76,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
 | 
			
		||||
 | 
			
		||||
  calculating = true;
 | 
			
		||||
  processing = false;
 | 
			
		||||
  isCheckoutLocked = 0; // reference counter, 0 = unlocked, >0 = locked
 | 
			
		||||
  isTokenizing = 0; // reference counter, 0 = false, >0 = true
 | 
			
		||||
  selectedOption: 'wait' | 'accel';
 | 
			
		||||
  cantPayReason = '';
 | 
			
		||||
  quoteError = ''; // error fetching estimate or initial data
 | 
			
		||||
@ -154,7 +156,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
 | 
			
		||||
        this.accelerateError = null;
 | 
			
		||||
        this.timePaid = 0;
 | 
			
		||||
        this.btcpayInvoiceFailed = false;
 | 
			
		||||
        this.moveToStep('summary');
 | 
			
		||||
        this.moveToStep('summary', true);
 | 
			
		||||
      } else {
 | 
			
		||||
        this.auth = auth;
 | 
			
		||||
      }
 | 
			
		||||
@ -163,11 +165,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
 | 
			
		||||
 | 
			
		||||
    const urlParams = new URLSearchParams(window.location.search);
 | 
			
		||||
    if (urlParams.get('cash_request_id')) { // Redirected from cashapp
 | 
			
		||||
      this.moveToStep('processing');
 | 
			
		||||
      this.moveToStep('processing', true);
 | 
			
		||||
      this.insertSquare();
 | 
			
		||||
      this.setupSquare();
 | 
			
		||||
    } else {
 | 
			
		||||
      this.moveToStep('summary');
 | 
			
		||||
      this.moveToStep('summary', true);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.conversionsSubscription = this.stateService.conversions$.subscribe(
 | 
			
		||||
@ -192,14 +194,17 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
 | 
			
		||||
    }
 | 
			
		||||
    if (changes.accelerating && this.accelerating) {
 | 
			
		||||
      if (this.step === 'processing' || this.step === 'paid') {
 | 
			
		||||
        this.moveToStep('success');
 | 
			
		||||
        this.moveToStep('success', true);
 | 
			
		||||
      } else { // Edge case where the transaction gets accelerated by someone else or on another session
 | 
			
		||||
        this.closeModal();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  moveToStep(step: CheckoutStep): void {
 | 
			
		||||
  moveToStep(step: CheckoutStep, force: boolean = false): void {
 | 
			
		||||
    if (this.isCheckoutLocked > 0 && !force) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    this.processing = false;
 | 
			
		||||
    this._step = step;
 | 
			
		||||
    if (this.timeoutTimer) {
 | 
			
		||||
@ -242,7 +247,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
 | 
			
		||||
 | 
			
		||||
  closeModal(): void {
 | 
			
		||||
    this.completed.emit(true);
 | 
			
		||||
    this.moveToStep('summary');
 | 
			
		||||
    this.moveToStep('summary', true);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
@ -393,7 +398,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
 | 
			
		||||
        this.audioService.playSound('ascend-chime-cartoon');
 | 
			
		||||
        this.showSuccess = true;
 | 
			
		||||
        this.estimateSubscription.unsubscribe();
 | 
			
		||||
        this.moveToStep('paid');
 | 
			
		||||
        this.moveToStep('paid', true);
 | 
			
		||||
      },
 | 
			
		||||
      error: (response) => {
 | 
			
		||||
        this.processing = false;
 | 
			
		||||
@ -503,56 +508,75 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
 | 
			
		||||
          }
 | 
			
		||||
          this.loadingApplePay = false;
 | 
			
		||||
          applePayButton.addEventListener('click', async event => {
 | 
			
		||||
            if (this.isCheckoutLocked > 0 || this.isTokenizing > 0) {
 | 
			
		||||
              return;
 | 
			
		||||
            }
 | 
			
		||||
            event.preventDefault();
 | 
			
		||||
            const tokenResult = await this.applePay.tokenize();
 | 
			
		||||
            if (tokenResult?.status === 'OK') {
 | 
			
		||||
              const card = tokenResult.details?.card;
 | 
			
		||||
              if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
 | 
			
		||||
                console.error(`Cannot retreive payment card details`);
 | 
			
		||||
                this.accelerateError = 'apple_pay_no_card_details';
 | 
			
		||||
                this.processing = false;
 | 
			
		||||
                return;
 | 
			
		||||
              }
 | 
			
		||||
              const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase());
 | 
			
		||||
              this.servicesApiService.accelerateWithApplePay$(
 | 
			
		||||
                this.tx.txid,
 | 
			
		||||
                tokenResult.token,
 | 
			
		||||
                cardTag,
 | 
			
		||||
                `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
 | 
			
		||||
                costUSD
 | 
			
		||||
              ).subscribe({
 | 
			
		||||
                next: () => {
 | 
			
		||||
            try {
 | 
			
		||||
              // lock the checkout UI and show a loading spinner until the square modals are finished
 | 
			
		||||
              this.isCheckoutLocked++;
 | 
			
		||||
              this.isTokenizing++;
 | 
			
		||||
              const tokenResult = await this.applePay.tokenize();
 | 
			
		||||
              if (tokenResult?.status === 'OK') {
 | 
			
		||||
                const card = tokenResult.details?.card;
 | 
			
		||||
                if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
 | 
			
		||||
                  console.error(`Cannot retreive payment card details`);
 | 
			
		||||
                  this.accelerateError = 'apple_pay_no_card_details';
 | 
			
		||||
                  this.processing = false;
 | 
			
		||||
                  this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
 | 
			
		||||
                  this.audioService.playSound('ascend-chime-cartoon');
 | 
			
		||||
                  if (this.applePay) {
 | 
			
		||||
                    this.applePay.destroy();
 | 
			
		||||
                  }
 | 
			
		||||
                  setTimeout(() => {
 | 
			
		||||
                    this.moveToStep('paid');
 | 
			
		||||
                  }, 1000);
 | 
			
		||||
                },
 | 
			
		||||
                error: (response) => {
 | 
			
		||||
                  this.processing = false;
 | 
			
		||||
                  this.accelerateError = response.error;
 | 
			
		||||
                  if (!(response.status === 403 && response.error === 'not_available')) {
 | 
			
		||||
                    setTimeout(() => {
 | 
			
		||||
                      // Reset everything by reloading the page :D, can be improved
 | 
			
		||||
                      const urlParams = new URLSearchParams(window.location.search);
 | 
			
		||||
                      window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
 | 
			
		||||
                    }, 3000);
 | 
			
		||||
                  }
 | 
			
		||||
                  return;
 | 
			
		||||
                }
 | 
			
		||||
              });
 | 
			
		||||
            } else {
 | 
			
		||||
              this.processing = false;
 | 
			
		||||
              let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
 | 
			
		||||
              if (tokenResult.errors) {
 | 
			
		||||
                errorMessage += ` and errors: ${JSON.stringify(
 | 
			
		||||
                  tokenResult.errors,
 | 
			
		||||
                )}`;
 | 
			
		||||
                const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase());
 | 
			
		||||
                // keep checkout in loading state until the acceleration request completes
 | 
			
		||||
                this.isTokenizing++;
 | 
			
		||||
                this.isCheckoutLocked++;
 | 
			
		||||
                this.servicesApiService.accelerateWithApplePay$(
 | 
			
		||||
                  this.tx.txid,
 | 
			
		||||
                  tokenResult.token,
 | 
			
		||||
                  cardTag,
 | 
			
		||||
                  `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
 | 
			
		||||
                  costUSD
 | 
			
		||||
                ).subscribe({
 | 
			
		||||
                  next: () => {
 | 
			
		||||
                    this.processing = false;
 | 
			
		||||
                    this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
 | 
			
		||||
                    this.audioService.playSound('ascend-chime-cartoon');
 | 
			
		||||
                    if (this.applePay) {
 | 
			
		||||
                      this.applePay.destroy();
 | 
			
		||||
                    }
 | 
			
		||||
                    setTimeout(() => {
 | 
			
		||||
                      this.isTokenizing--;
 | 
			
		||||
                      this.isCheckoutLocked--;
 | 
			
		||||
                      this.moveToStep('paid', true);
 | 
			
		||||
                    }, 1000);
 | 
			
		||||
                  },
 | 
			
		||||
                  error: (response) => {
 | 
			
		||||
                    this.processing = false;
 | 
			
		||||
                    this.accelerateError = response.error;
 | 
			
		||||
                    if (!(response.status === 403 && response.error === 'not_available')) {
 | 
			
		||||
                      setTimeout(() => {
 | 
			
		||||
                        this.isTokenizing--;
 | 
			
		||||
                        this.isCheckoutLocked--;
 | 
			
		||||
                        // Reset everything by reloading the page :D, can be improved
 | 
			
		||||
                        const urlParams = new URLSearchParams(window.location.search);
 | 
			
		||||
                        window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
 | 
			
		||||
                      }, 3000);
 | 
			
		||||
                    }
 | 
			
		||||
                  }
 | 
			
		||||
                });
 | 
			
		||||
              } else {
 | 
			
		||||
                this.processing = false;
 | 
			
		||||
                let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
 | 
			
		||||
                if (tokenResult.errors) {
 | 
			
		||||
                  errorMessage += ` and errors: ${JSON.stringify(
 | 
			
		||||
                    tokenResult.errors,
 | 
			
		||||
                  )}`;
 | 
			
		||||
                }
 | 
			
		||||
                throw new Error(errorMessage);
 | 
			
		||||
              }
 | 
			
		||||
              throw new Error(errorMessage);
 | 
			
		||||
            } finally {
 | 
			
		||||
              // always unlock the checkout once we're finished
 | 
			
		||||
              this.isTokenizing--;
 | 
			
		||||
              this.isCheckoutLocked--;
 | 
			
		||||
            }
 | 
			
		||||
          });
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
@ -602,65 +626,84 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
 | 
			
		||||
        this.loadingGooglePay = false;
 | 
			
		||||
 | 
			
		||||
        document.getElementById('google-pay-button').addEventListener('click', async event => {
 | 
			
		||||
          if (this.isCheckoutLocked > 0 || this.isTokenizing > 0) {
 | 
			
		||||
            return;
 | 
			
		||||
          }
 | 
			
		||||
          event.preventDefault();
 | 
			
		||||
          const tokenResult = await this.googlePay.tokenize();
 | 
			
		||||
          if (tokenResult?.status === 'OK') {
 | 
			
		||||
            const card = tokenResult.details?.card;
 | 
			
		||||
            if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
 | 
			
		||||
              console.error(`Cannot retreive payment card details`);
 | 
			
		||||
              this.accelerateError = 'apple_pay_no_card_details';
 | 
			
		||||
              this.processing = false;
 | 
			
		||||
              return;
 | 
			
		||||
            }
 | 
			
		||||
            const verificationToken = await this.$verifyBuyer(this.payments, tokenResult.token, tokenResult.details, costUSD.toFixed(2));
 | 
			
		||||
            if (!verificationToken || !verificationToken.token) {
 | 
			
		||||
              console.error(`SCA verification failed`);
 | 
			
		||||
              this.accelerateError = 'SCA Verification Failed. Payment Declined.';
 | 
			
		||||
              this.processing = false;
 | 
			
		||||
              return;
 | 
			
		||||
            }
 | 
			
		||||
            const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase());
 | 
			
		||||
            this.servicesApiService.accelerateWithGooglePay$(
 | 
			
		||||
              this.tx.txid,
 | 
			
		||||
              tokenResult.token,
 | 
			
		||||
              verificationToken.token,
 | 
			
		||||
              cardTag,
 | 
			
		||||
              `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
 | 
			
		||||
              costUSD,
 | 
			
		||||
              verificationToken.userChallenged
 | 
			
		||||
            ).subscribe({
 | 
			
		||||
              next: () => {
 | 
			
		||||
          try {
 | 
			
		||||
            // lock the checkout UI and show a loading spinner until the square modals are finished
 | 
			
		||||
            this.isCheckoutLocked++;
 | 
			
		||||
            this.isTokenizing++;
 | 
			
		||||
            const tokenResult = await this.googlePay.tokenize();
 | 
			
		||||
            if (tokenResult?.status === 'OK') {
 | 
			
		||||
              const card = tokenResult.details?.card;
 | 
			
		||||
              if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
 | 
			
		||||
                console.error(`Cannot retreive payment card details`);
 | 
			
		||||
                this.accelerateError = 'apple_pay_no_card_details';
 | 
			
		||||
                this.processing = false;
 | 
			
		||||
                this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
 | 
			
		||||
                this.audioService.playSound('ascend-chime-cartoon');
 | 
			
		||||
                if (this.googlePay) {
 | 
			
		||||
                  this.googlePay.destroy();
 | 
			
		||||
                }
 | 
			
		||||
                setTimeout(() => {
 | 
			
		||||
                  this.moveToStep('paid');
 | 
			
		||||
                }, 1000);
 | 
			
		||||
              },
 | 
			
		||||
              error: (response) => {
 | 
			
		||||
                this.processing = false;
 | 
			
		||||
                this.accelerateError = response.error;
 | 
			
		||||
                if (!(response.status === 403 && response.error === 'not_available')) {
 | 
			
		||||
                  setTimeout(() => {
 | 
			
		||||
                    // Reset everything by reloading the page :D, can be improved
 | 
			
		||||
                    const urlParams = new URLSearchParams(window.location.search);
 | 
			
		||||
                    window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
 | 
			
		||||
                  }, 3000);
 | 
			
		||||
                }
 | 
			
		||||
                return;
 | 
			
		||||
              }
 | 
			
		||||
            });
 | 
			
		||||
          } else {
 | 
			
		||||
            this.processing = false;
 | 
			
		||||
            let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
 | 
			
		||||
            if (tokenResult.errors) {
 | 
			
		||||
              errorMessage += ` and errors: ${JSON.stringify(
 | 
			
		||||
                tokenResult.errors,
 | 
			
		||||
              )}`;
 | 
			
		||||
              const verificationToken = await this.$verifyBuyer(this.payments, tokenResult.token, tokenResult.details, costUSD.toFixed(2));
 | 
			
		||||
              if (!verificationToken || !verificationToken.token) {
 | 
			
		||||
                console.error(`SCA verification failed`);
 | 
			
		||||
                this.accelerateError = 'SCA Verification Failed. Payment Declined.';
 | 
			
		||||
                this.processing = false;
 | 
			
		||||
                return;
 | 
			
		||||
              }
 | 
			
		||||
              const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase());
 | 
			
		||||
              // keep checkout in loading state until the acceleration request completes
 | 
			
		||||
              this.isCheckoutLocked++;
 | 
			
		||||
              this.isTokenizing++;
 | 
			
		||||
              this.servicesApiService.accelerateWithGooglePay$(
 | 
			
		||||
                this.tx.txid,
 | 
			
		||||
                tokenResult.token,
 | 
			
		||||
                verificationToken.token,
 | 
			
		||||
                cardTag,
 | 
			
		||||
                `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
 | 
			
		||||
                costUSD,
 | 
			
		||||
                verificationToken.userChallenged
 | 
			
		||||
              ).subscribe({
 | 
			
		||||
                next: () => {
 | 
			
		||||
                  this.processing = false;
 | 
			
		||||
                  this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
 | 
			
		||||
                  this.audioService.playSound('ascend-chime-cartoon');
 | 
			
		||||
                  if (this.googlePay) {
 | 
			
		||||
                    this.googlePay.destroy();
 | 
			
		||||
                  }
 | 
			
		||||
                  setTimeout(() => {
 | 
			
		||||
                    this.isTokenizing--;
 | 
			
		||||
                    this.isCheckoutLocked--;
 | 
			
		||||
                    this.moveToStep('paid', true);
 | 
			
		||||
                  }, 1000);
 | 
			
		||||
                },
 | 
			
		||||
                error: (response) => {
 | 
			
		||||
                  this.processing = false;
 | 
			
		||||
                  this.accelerateError = response.error;
 | 
			
		||||
                  this.isTokenizing--;
 | 
			
		||||
                  this.isCheckoutLocked--;
 | 
			
		||||
                  if (!(response.status === 403 && response.error === 'not_available')) {
 | 
			
		||||
                    setTimeout(() => {
 | 
			
		||||
                      // Reset everything by reloading the page :D, can be improved
 | 
			
		||||
                      const urlParams = new URLSearchParams(window.location.search);
 | 
			
		||||
                      window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
 | 
			
		||||
                    }, 3000);
 | 
			
		||||
                  }
 | 
			
		||||
                }
 | 
			
		||||
              });
 | 
			
		||||
            } else {
 | 
			
		||||
              this.processing = false;
 | 
			
		||||
              let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
 | 
			
		||||
              if (tokenResult.errors) {
 | 
			
		||||
                errorMessage += ` and errors: ${JSON.stringify(
 | 
			
		||||
                  tokenResult.errors,
 | 
			
		||||
                )}`;
 | 
			
		||||
              }
 | 
			
		||||
              throw new Error(errorMessage);
 | 
			
		||||
            }
 | 
			
		||||
            throw new Error(errorMessage);
 | 
			
		||||
          } finally {
 | 
			
		||||
            // always unlock the checkout once we're finished
 | 
			
		||||
            this.isTokenizing--;
 | 
			
		||||
            this.isCheckoutLocked--;
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
@ -727,7 +770,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
 | 
			
		||||
                  this.cashAppPay.destroy();
 | 
			
		||||
                }
 | 
			
		||||
                setTimeout(() => {
 | 
			
		||||
                  this.moveToStep('paid');
 | 
			
		||||
                  this.moveToStep('paid', true);
 | 
			
		||||
                  if (window.history.replaceState) {
 | 
			
		||||
                    const urlParams = new URLSearchParams(window.location.search);
 | 
			
		||||
                    window.history.replaceState(null, null, window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ''));
 | 
			
		||||
@ -801,7 +844,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
 | 
			
		||||
    this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
 | 
			
		||||
    this.audioService.playSound('ascend-chime-cartoon');
 | 
			
		||||
    this.estimateSubscription.unsubscribe();
 | 
			
		||||
    this.moveToStep('paid');
 | 
			
		||||
    this.moveToStep('paid', true);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  isLoggedIn(): boolean {
 | 
			
		||||
 | 
			
		||||
@ -478,25 +478,30 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  extendSummary(summary) {
 | 
			
		||||
    let extendedSummary = summary.slice();
 | 
			
		||||
    const extendedSummary = summary.slice();
 | 
			
		||||
 | 
			
		||||
    // Add a point at today's date to make the graph end at the current time
 | 
			
		||||
    extendedSummary.unshift({ time: Date.now() / 1000, value: 0 });
 | 
			
		||||
    extendedSummary.reverse();
 | 
			
		||||
 | 
			
		||||
    let oneHour = 60 * 60;
 | 
			
		||||
    let maxTime = Date.now() / 1000;
 | 
			
		||||
 | 
			
		||||
    const oneHour = 60 * 60;
 | 
			
		||||
    // Fill gaps longer than interval
 | 
			
		||||
    for (let i = 0; i < extendedSummary.length - 1; i++) {
 | 
			
		||||
      let hours = Math.floor((extendedSummary[i + 1].time - extendedSummary[i].time) / oneHour);      
 | 
			
		||||
      if (extendedSummary[i].time > maxTime) {
 | 
			
		||||
        extendedSummary[i].time = maxTime - 30;
 | 
			
		||||
      }
 | 
			
		||||
      maxTime = extendedSummary[i].time;
 | 
			
		||||
      const hours = Math.floor((extendedSummary[i].time - extendedSummary[i + 1].time) / oneHour);
 | 
			
		||||
      if (hours > 1) {
 | 
			
		||||
        for (let j = 1; j < hours; j++) {
 | 
			
		||||
          let newTime = extendedSummary[i].time + oneHour * j;
 | 
			
		||||
          const newTime = extendedSummary[i].time - oneHour * j;
 | 
			
		||||
          extendedSummary.splice(i + j, 0, { time: newTime, value: 0 });
 | 
			
		||||
        }
 | 
			
		||||
        i += hours - 1;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return extendedSummary.reverse();
 | 
			
		||||
    return extendedSummary;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -10,7 +10,7 @@
 | 
			
		||||
      <h1 class="m-0 pt-1 pt-md-0">{{ poolStats.pool.name }}</h1>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="box">
 | 
			
		||||
    <div class="box pool-details">
 | 
			
		||||
      <div class="row">
 | 
			
		||||
 | 
			
		||||
        <div class="col-lg-6">
 | 
			
		||||
@ -173,7 +173,119 @@
 | 
			
		||||
    <div class="spinner-border text-light"></div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <!-- Stratum Job -->
 | 
			
		||||
  <ng-container *ngIf="(job$ | async) as job;">
 | 
			
		||||
    <h2 i18n="pool.next_block">Next block</h2>
 | 
			
		||||
    <div class="box mb-3">
 | 
			
		||||
      <div class="row" >
 | 
			
		||||
        <div class="col">
 | 
			
		||||
          <table class="table table-borderless table-striped">
 | 
			
		||||
            <tbody>
 | 
			
		||||
              <tr>
 | 
			
		||||
                <td>
 | 
			
		||||
                  <table class="job-table table table-xs table-borderless table-fixed table-data">
 | 
			
		||||
                    <thead>
 | 
			
		||||
                      <tr>
 | 
			
		||||
                        <th class="data-title clip text-center height" i18n="latest-blocks.height">Height</th>
 | 
			
		||||
                        <th class="data-title clip text-center expected" i18n="next-block.expected-time">Expected</th>
 | 
			
		||||
                        <th class="data-title clip text-center reward" i18n="latest-blocks.reward">Reward</th>
 | 
			
		||||
                        <th class="data-title clip text-center timestamp" i18n="next-block.timestamp">Timestamp</th>
 | 
			
		||||
                      </tr>
 | 
			
		||||
                    </thead>
 | 
			
		||||
                    <tbody>
 | 
			
		||||
                      <tr>
 | 
			
		||||
                        <td class="text-center height">
 | 
			
		||||
                          {{ job.height }}
 | 
			
		||||
                        </td>
 | 
			
		||||
                        <td class="text-center expected">
 | 
			
		||||
                          <ng-container *ngIf="(expectedBlockTime$ | async) as expectedBlockTime; else expectedPlaceholder">
 | 
			
		||||
                            <app-time kind="until" [time]="expectedBlockTime" [fastRender]="false" [fixedRender]="true" [precision]="1" minUnit="minute"></app-time>
 | 
			
		||||
                          </ng-container>
 | 
			
		||||
                          <ng-template #expectedPlaceholder>~</ng-template>
 | 
			
		||||
                        </td>
 | 
			
		||||
                        <td class="text-center reward">
 | 
			
		||||
                          <app-amount [satoshis]="job.reward"></app-amount>
 | 
			
		||||
                        </td>
 | 
			
		||||
                        <td class="text-center timestamp">
 | 
			
		||||
                          <app-timestamp [customFormat]="'yyyy-MM-dd HH:mm:ss'" [unixTime]="job.timestamp" [precision]="1" minUnit="minute" [hideTimeSince]="true"></app-timestamp>
 | 
			
		||||
                        </td>
 | 
			
		||||
                      </tr>
 | 
			
		||||
                    </tbody>
 | 
			
		||||
                  </table>
 | 
			
		||||
                </td>
 | 
			
		||||
              </tr>
 | 
			
		||||
              <tr>
 | 
			
		||||
                <td>
 | 
			
		||||
                  <table class="job-table table table-xs table-borderless table-fixed table-data">
 | 
			
		||||
                    <thead>
 | 
			
		||||
                      <tr>
 | 
			
		||||
                        <th class="data-title clip text-center coinbase" i18n="latest-blocks.coinbasetag">Coinbase tag</th>
 | 
			
		||||
                        <th class="data-title clip text-center clean" i18n="next-block.clean">Clean</th>
 | 
			
		||||
                        <th class="data-title clip text-center prevhash" i18n="next-block.prevhash">Prevhash</th>
 | 
			
		||||
                        <th class="data-title clip text-center job-received" i18n="next-block.job-received">Job Received</th>
 | 
			
		||||
                      </tr>
 | 
			
		||||
                    </thead>
 | 
			
		||||
                    <tbody>
 | 
			
		||||
                      <tr>
 | 
			
		||||
                        <td class="text-center coinbase">
 | 
			
		||||
                          {{ job.scriptsig | hex2ascii }}
 | 
			
		||||
                        </td>
 | 
			
		||||
                        <td class="text-center clean">
 | 
			
		||||
                          @if (job.cleanJobs) {
 | 
			
		||||
                            <fa-icon [icon]="['fas', 'check-circle']" [fixedWidth]="true"></fa-icon>
 | 
			
		||||
                          } @else {
 | 
			
		||||
                            <fa-icon [icon]="['fas', 'times-circle']" [fixedWidth]="true"></fa-icon>
 | 
			
		||||
                          }
 | 
			
		||||
                        </td>
 | 
			
		||||
                        <td class="text-center prevhash">
 | 
			
		||||
                          <a [routerLink]="['/block' | relativeUrl, job.prevHash]">
 | 
			
		||||
                            <app-truncate [text]="job.prevHash" [lastChars]="8"></app-truncate>
 | 
			
		||||
                          </a>
 | 
			
		||||
                        </td>
 | 
			
		||||
                        <td class="text-center job-received">
 | 
			
		||||
                          <app-timestamp [customFormat]="'yyyy-MM-dd HH:mm:ss'" [unixTime]="job.received / 1000" [precision]="1" minUnit="minute" [hideTimeSince]="true"></app-timestamp>
 | 
			
		||||
                        </td>
 | 
			
		||||
                      </tr>
 | 
			
		||||
                    </tbody>
 | 
			
		||||
                  </table>
 | 
			
		||||
                </td>
 | 
			
		||||
              </tr>
 | 
			
		||||
              <tr>
 | 
			
		||||
                <td>
 | 
			
		||||
                  <table class="stratum-table">
 | 
			
		||||
                    <thead>
 | 
			
		||||
                      <tr>
 | 
			
		||||
                        <th class="data-title clip text-center" [attr.colspan]="Math.max(job.merkleBranches.length, 12)">
 | 
			
		||||
                          <a class="title-link" href="" [routerLink]="['/stratum' | relativeUrl]">
 | 
			
		||||
                            Merkle Branches
 | 
			
		||||
                            <span> </span>
 | 
			
		||||
                            <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: var(--title-fg)"></fa-icon>
 | 
			
		||||
                          </a>
 | 
			
		||||
                        </th>
 | 
			
		||||
                      </tr>
 | 
			
		||||
                    </thead>
 | 
			
		||||
                    <tbody>
 | 
			
		||||
                      <tr>
 | 
			
		||||
                        @for (branch of job.merkleBranches; track $index) {
 | 
			
		||||
                          <td class="merkle" [style.background-color]="branch ? '#' + branch.slice(0, 6) : ''"></td>
 | 
			
		||||
                        }
 | 
			
		||||
                        @for (_ of [].constructor(Math.max(0, 12 - job.merkleBranches.length)); track $index) {
 | 
			
		||||
                          <td class="merkle empty-branch"></td>
 | 
			
		||||
                        }
 | 
			
		||||
                      </tr>
 | 
			
		||||
                    </tbody>
 | 
			
		||||
                  </table>
 | 
			
		||||
                </td>
 | 
			
		||||
              </tr>
 | 
			
		||||
            </tbody>
 | 
			
		||||
          </table>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </ng-container>
 | 
			
		||||
 | 
			
		||||
  <!-- Blocks list -->
 | 
			
		||||
  <h2 i18n="master-page.blocks">Blocks</h2>
 | 
			
		||||
  <table class="table table-borderless" [alwaysCallback]="true" infiniteScroll [infiniteScrollDistance]="1.5"
 | 
			
		||||
    [infiniteScrollUpDistance]="1.5" [infiniteScrollThrottle]="50" (scrolled)="loadMore()">
 | 
			
		||||
    <ng-container *ngIf="blocks$ | async as blocks; else skeleton">
 | 
			
		||||
 | 
			
		||||
@ -49,111 +49,110 @@ div.scrollable {
 | 
			
		||||
  max-height: 75px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.box {
 | 
			
		||||
  padding-bottom: 5px;
 | 
			
		||||
.pool-details {
 | 
			
		||||
  @media (min-width: 767.98px) {
 | 
			
		||||
    min-height: 187px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.label {
 | 
			
		||||
  width: 25%;
 | 
			
		||||
  @media (min-width: 767.98px) {
 | 
			
		||||
    vertical-align: middle;
 | 
			
		||||
  .label {
 | 
			
		||||
    width: 25%;
 | 
			
		||||
    @media (min-width: 767.98px) {
 | 
			
		||||
      vertical-align: middle;
 | 
			
		||||
    }
 | 
			
		||||
    @media (max-width: 767.98px) {
 | 
			
		||||
      font-weight: bold;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  @media (max-width: 767.98px) {
 | 
			
		||||
    font-weight: bold;
 | 
			
		||||
  .label.addresses {
 | 
			
		||||
    vertical-align: top;
 | 
			
		||||
    padding-top: 25px;
 | 
			
		||||
  }
 | 
			
		||||
  .addresses-data {
 | 
			
		||||
    vertical-align: top;
 | 
			
		||||
    font-family: monospace;
 | 
			
		||||
    font-size: 14px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
.label.addresses {
 | 
			
		||||
  vertical-align: top;
 | 
			
		||||
  padding-top: 25px;
 | 
			
		||||
}
 | 
			
		||||
.addresses-data {
 | 
			
		||||
  vertical-align: top;
 | 
			
		||||
  font-family: monospace;
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.data {
 | 
			
		||||
  text-align: right;
 | 
			
		||||
  padding-left: 5%;
 | 
			
		||||
  @media (max-width: 992px) {
 | 
			
		||||
    text-align: left;
 | 
			
		||||
    padding-left: 12px;
 | 
			
		||||
  }
 | 
			
		||||
  @media (max-width: 450px) {
 | 
			
		||||
  .data {
 | 
			
		||||
    text-align: right;
 | 
			
		||||
    padding-left: 5%;
 | 
			
		||||
    @media (max-width: 992px) {
 | 
			
		||||
      text-align: left;
 | 
			
		||||
      padding-left: 12px;
 | 
			
		||||
    }
 | 
			
		||||
    @media (max-width: 450px) {
 | 
			
		||||
      text-align: right;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.progress {
 | 
			
		||||
  background-color: var(--secondary);
 | 
			
		||||
}
 | 
			
		||||
  .progress {
 | 
			
		||||
    background-color: var(--secondary);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
.coinbase {
 | 
			
		||||
  width: 20%;
 | 
			
		||||
  @media (max-width: 875px) {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.height {
 | 
			
		||||
  width: 10%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.timestamp {
 | 
			
		||||
  @media (max-width: 875px) {
 | 
			
		||||
    padding-left: 50px;
 | 
			
		||||
  }
 | 
			
		||||
  @media (max-width: 685px) {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mined {
 | 
			
		||||
  width: 13%;
 | 
			
		||||
  @media (max-width: 1100px) {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.txs {
 | 
			
		||||
  padding-right: 40px;
 | 
			
		||||
  @media (max-width: 1100px) {
 | 
			
		||||
    padding-right: 10px;
 | 
			
		||||
  }
 | 
			
		||||
  @media (max-width: 875px) {
 | 
			
		||||
    padding-right: 20px;
 | 
			
		||||
  }
 | 
			
		||||
  @media (max-width: 567px) {
 | 
			
		||||
    padding-right: 10px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.size {
 | 
			
		||||
  width: 12%;
 | 
			
		||||
  @media (max-width: 1000px) {
 | 
			
		||||
    width: 15%;
 | 
			
		||||
  }
 | 
			
		||||
  @media (max-width: 875px) {
 | 
			
		||||
  .coinbase {
 | 
			
		||||
    width: 20%;
 | 
			
		||||
    @media (max-width: 875px) {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  @media (max-width: 650px) {
 | 
			
		||||
    width: 20%;
 | 
			
		||||
  }
 | 
			
		||||
  @media (max-width: 450px) {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.scriptmessage {
 | 
			
		||||
	overflow: hidden;
 | 
			
		||||
	display: inline-block;
 | 
			
		||||
	text-overflow: ellipsis;
 | 
			
		||||
	vertical-align: middle;
 | 
			
		||||
	width: auto;
 | 
			
		||||
  text-align: left;
 | 
			
		||||
  .height {
 | 
			
		||||
    width: 10%;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .timestamp {
 | 
			
		||||
    @media (max-width: 875px) {
 | 
			
		||||
      padding-left: 50px;
 | 
			
		||||
    }
 | 
			
		||||
    @media (max-width: 685px) {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .mined {
 | 
			
		||||
    width: 13%;
 | 
			
		||||
    @media (max-width: 1100px) {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .txs {
 | 
			
		||||
    padding-right: 40px;
 | 
			
		||||
    @media (max-width: 1100px) {
 | 
			
		||||
      padding-right: 10px;
 | 
			
		||||
    }
 | 
			
		||||
    @media (max-width: 875px) {
 | 
			
		||||
      padding-right: 20px;
 | 
			
		||||
    }
 | 
			
		||||
    @media (max-width: 567px) {
 | 
			
		||||
      padding-right: 10px;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .size {
 | 
			
		||||
    width: 12%;
 | 
			
		||||
    @media (max-width: 1000px) {
 | 
			
		||||
      width: 15%;
 | 
			
		||||
    }
 | 
			
		||||
    @media (max-width: 875px) {
 | 
			
		||||
      width: 20%;
 | 
			
		||||
    }
 | 
			
		||||
    @media (max-width: 650px) {
 | 
			
		||||
      width: 20%;
 | 
			
		||||
    }
 | 
			
		||||
    @media (max-width: 450px) {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .scriptmessage {
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    text-overflow: ellipsis;
 | 
			
		||||
    vertical-align: middle;
 | 
			
		||||
    width: auto;
 | 
			
		||||
    text-align: left;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.skeleton-loader {
 | 
			
		||||
@ -214,4 +213,55 @@ div.scrollable {
 | 
			
		||||
 | 
			
		||||
.taller-row {
 | 
			
		||||
  height: 75px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.stratum-table {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
 | 
			
		||||
  .merkle {
 | 
			
		||||
    width: 100px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .empty-branch {
 | 
			
		||||
    outline: solid 1px white;
 | 
			
		||||
    outline-offset: -1px;
 | 
			
		||||
 | 
			
		||||
    &::after {
 | 
			
		||||
      content: "";
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      left: 0;
 | 
			
		||||
      top: 0;
 | 
			
		||||
      height: 100%;
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      background: linear-gradient(to top left, transparent, transparent 48%, white 49%, white 51%, transparent 52%, transparent);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  td {
 | 
			
		||||
    position: relative;
 | 
			
		||||
    height: 2em;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.job-table {
 | 
			
		||||
  td, th {
 | 
			
		||||
    width: 25%;
 | 
			
		||||
    max-width: 25%;
 | 
			
		||||
    min-width: 25%;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    text-overflow: ellipsis;
 | 
			
		||||
    padding: 0.1rem 0.2rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @media (max-width: 767.98px) {
 | 
			
		||||
    .expected, .timestamp, .clean, .job-received {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.title-link, .title-link:hover, .title-link:focus, .title-link:active {
 | 
			
		||||
  display: block;
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
  color: inherit;
 | 
			
		||||
}
 | 
			
		||||
@ -10,6 +10,9 @@ import { selectPowerOfTen } from '@app/bitcoin.utils';
 | 
			
		||||
import { formatNumber } from '@angular/common';
 | 
			
		||||
import { SeoService } from '@app/services/seo.service';
 | 
			
		||||
import { HttpErrorResponse } from '@angular/common/http';
 | 
			
		||||
import { StratumJob } from '../../interfaces/websocket.interface';
 | 
			
		||||
import { WebsocketService } from '../../services/websocket.service';
 | 
			
		||||
import { MiningService } from '../../services/mining.service';
 | 
			
		||||
 | 
			
		||||
interface AccelerationTotal {
 | 
			
		||||
  cost: number,
 | 
			
		||||
@ -27,12 +30,16 @@ export class PoolComponent implements OnInit {
 | 
			
		||||
  @Input() left: number | string = 75;
 | 
			
		||||
 | 
			
		||||
  gfg = true;
 | 
			
		||||
  stratumEnabled = this.stateService.env.STRATUM_ENABLED;
 | 
			
		||||
 | 
			
		||||
  formatNumber = formatNumber;
 | 
			
		||||
  Math = Math;
 | 
			
		||||
  slugSubscription: Subscription;
 | 
			
		||||
  poolStats$: Observable<PoolStat>;
 | 
			
		||||
  blocks$: Observable<BlockExtended[]>;
 | 
			
		||||
  oobFees$: Observable<AccelerationTotal[]>;
 | 
			
		||||
  job$: Observable<StratumJob | null>;
 | 
			
		||||
  expectedBlockTime$: Observable<number>;
 | 
			
		||||
  isLoading = true;
 | 
			
		||||
  error: HttpErrorResponse | null = null;
 | 
			
		||||
 | 
			
		||||
@ -53,6 +60,8 @@ export class PoolComponent implements OnInit {
 | 
			
		||||
    private apiService: ApiService,
 | 
			
		||||
    private route: ActivatedRoute,
 | 
			
		||||
    public stateService: StateService,
 | 
			
		||||
    private websocketService: WebsocketService,
 | 
			
		||||
    private miningService: MiningService,
 | 
			
		||||
    private seoService: SeoService,
 | 
			
		||||
  ) {
 | 
			
		||||
    this.auditAvailable = this.stateService.env.AUDIT;
 | 
			
		||||
@ -62,7 +71,7 @@ export class PoolComponent implements OnInit {
 | 
			
		||||
    this.slugSubscription = this.route.params.pipe(map((params) => params.slug)).subscribe((slug) => {
 | 
			
		||||
      this.isLoading = true;
 | 
			
		||||
      this.blocks = [];
 | 
			
		||||
      this.chartOptions = {};  
 | 
			
		||||
      this.chartOptions = {};
 | 
			
		||||
      this.slug = slug;
 | 
			
		||||
      this.initializeObservables();
 | 
			
		||||
    });
 | 
			
		||||
@ -129,6 +138,31 @@ export class PoolComponent implements OnInit {
 | 
			
		||||
      }),
 | 
			
		||||
      filter(oob => oob.length === 3 && oob[2].count > 0)
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (this.stratumEnabled) {
 | 
			
		||||
      this.job$ = combineLatest([
 | 
			
		||||
        this.poolStats$.pipe(
 | 
			
		||||
          tap((poolStats) => {
 | 
			
		||||
            this.websocketService.startTrackStratum(poolStats.pool.unique_id);
 | 
			
		||||
          })
 | 
			
		||||
        ),
 | 
			
		||||
        this.stateService.stratumJobs$
 | 
			
		||||
      ]).pipe(
 | 
			
		||||
        map(([poolStats, jobs]) => {
 | 
			
		||||
          return jobs[poolStats.pool.unique_id];
 | 
			
		||||
        })
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      this.expectedBlockTime$ = combineLatest([
 | 
			
		||||
        this.miningService.getMiningStats('1w'),
 | 
			
		||||
        this.poolStats$,
 | 
			
		||||
        this.stateService.difficultyAdjustment$
 | 
			
		||||
      ]).pipe(
 | 
			
		||||
        map(([miningStats, poolStat, da]) => {
 | 
			
		||||
          return (da.timeAvg / ((poolStat.estimatedHashrate || 0) / (miningStats.lastEstimatedHashrate * 1_000_000_000_000_000_000))) + Date.now() + da.timeOffset;
 | 
			
		||||
        })
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  prepareChartOptions(hashrate, share) {
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,49 @@
 | 
			
		||||
<div class="container-xl" style="min-height: 335px">
 | 
			
		||||
  <h1 class="float-left" i18n="master-page.blocks">Stratum Jobs</h1>
 | 
			
		||||
 | 
			
		||||
  <div class="clearfix"></div>
 | 
			
		||||
 | 
			
		||||
  <div style="min-height: 295px">
 | 
			
		||||
    <table *ngIf="poolsReady && (rows$ | async) as rows;" class="stratum-table">
 | 
			
		||||
      <thead>
 | 
			
		||||
        <tr>
 | 
			
		||||
          <td class="height">Height</td>
 | 
			
		||||
          <td class="reward">Reward</td>
 | 
			
		||||
          <td class="tag">Coinbase Tag</td>
 | 
			
		||||
          <td class="merkle" [attr.colspan]="rows[0]?.merkleCells?.length || 4">
 | 
			
		||||
            Merkle Branches
 | 
			
		||||
          </td>
 | 
			
		||||
          <td class="pool">Pool</td>
 | 
			
		||||
        </tr>
 | 
			
		||||
      </thead>
 | 
			
		||||
      <tbody>
 | 
			
		||||
        @for (row of rows; track row.job.pool) {
 | 
			
		||||
          <tr>
 | 
			
		||||
            <td class="height">
 | 
			
		||||
              {{ row.job.height }}
 | 
			
		||||
            </td>
 | 
			
		||||
            <td class="reward">
 | 
			
		||||
              <app-amount [satoshis]="row.job.reward"></app-amount>
 | 
			
		||||
            </td>
 | 
			
		||||
            <td class="tag">
 | 
			
		||||
              {{ row.job.tag }}
 | 
			
		||||
            </td>
 | 
			
		||||
            @for (cell of row.merkleCells; track $index) {
 | 
			
		||||
              <td class="merkle" [style.background-color]="cell.hash ? '#' + cell.hash.slice(0, 6) : ''">
 | 
			
		||||
                <div class="pipe-segment" [class]="pipeToClass(cell.type)"></div>
 | 
			
		||||
              </td>
 | 
			
		||||
            }
 | 
			
		||||
            <td class="pool">
 | 
			
		||||
              @if (pools[row.job.pool]) {
 | 
			
		||||
                <a class="badge" [routerLink]="[('/mining/pool/' + pools[row.job.pool].slug) | relativeUrl]">
 | 
			
		||||
                  <img class="pool-logo" [src]="'/resources/mining-pools/' + pools[row.job.pool].slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + pools[row.job.pool].name + ' mining pool'"> 
 | 
			
		||||
                  {{ pools[row.job.pool].name}}
 | 
			
		||||
                </a>
 | 
			
		||||
              }
 | 
			
		||||
            </td>
 | 
			
		||||
          </tr>
 | 
			
		||||
        }
 | 
			
		||||
      </tbody>
 | 
			
		||||
    </table>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
@ -0,0 +1,108 @@
 | 
			
		||||
.stratum-table {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
td {
 | 
			
		||||
  position: relative;
 | 
			
		||||
  height: 2em;
 | 
			
		||||
 | 
			
		||||
  &.height, &.reward, &.tag {
 | 
			
		||||
    padding: 0 5px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &.tag {
 | 
			
		||||
    max-width: 180px;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    text-overflow: ellipsis;
 | 
			
		||||
    white-space: nowrap;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  &.pool {
 | 
			
		||||
    padding-left: 5px;
 | 
			
		||||
    padding-right: 20px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &.merkle {
 | 
			
		||||
    width: 100px;
 | 
			
		||||
    .pipe-segment {
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      border-color: white;
 | 
			
		||||
      box-sizing: content-box;
 | 
			
		||||
 | 
			
		||||
      &.vertical {
 | 
			
		||||
        top: 0;
 | 
			
		||||
        right: 0;
 | 
			
		||||
        width: 50%;
 | 
			
		||||
        height: 100%;
 | 
			
		||||
        border-left: solid 4px;
 | 
			
		||||
      }
 | 
			
		||||
      &.horizontal {
 | 
			
		||||
        bottom: 0;
 | 
			
		||||
        left: 0;
 | 
			
		||||
        width: 100%;
 | 
			
		||||
        height: 50%;
 | 
			
		||||
        border-top: solid 4px;
 | 
			
		||||
      }
 | 
			
		||||
      &.branch-top {
 | 
			
		||||
        bottom: 0;
 | 
			
		||||
        right: 0;
 | 
			
		||||
        width: 100%;
 | 
			
		||||
        height: 50%;
 | 
			
		||||
        border-top: solid 4px;
 | 
			
		||||
        &::after {
 | 
			
		||||
          content: "";
 | 
			
		||||
          position: absolute;
 | 
			
		||||
          box-sizing: content-box;
 | 
			
		||||
          top: -4px;
 | 
			
		||||
          right: 0px;
 | 
			
		||||
          bottom: 0;
 | 
			
		||||
          width: 50%;
 | 
			
		||||
          border-top: solid 4px;
 | 
			
		||||
          border-left: solid 4px;
 | 
			
		||||
          border-top-left-radius: 5px;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      &.branch-mid {
 | 
			
		||||
        bottom: 0;
 | 
			
		||||
        right: 0px;
 | 
			
		||||
        width: 50%;
 | 
			
		||||
        height: 100%;
 | 
			
		||||
        border-left: solid 4px;
 | 
			
		||||
        &::after {
 | 
			
		||||
          content: "";
 | 
			
		||||
          position: absolute;
 | 
			
		||||
          box-sizing: content-box;
 | 
			
		||||
          top: -4px;
 | 
			
		||||
          left: -4px;
 | 
			
		||||
          width: 100%;
 | 
			
		||||
          height: 50%;
 | 
			
		||||
          border-bottom: solid 4px;
 | 
			
		||||
          border-left: solid 4px;
 | 
			
		||||
          border-bottom-left-radius: 5px;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      &.branch-end {
 | 
			
		||||
        top: -4px;
 | 
			
		||||
        right: 0;
 | 
			
		||||
        width: 50%;
 | 
			
		||||
        height: 50%;
 | 
			
		||||
        border-bottom-left-radius: 5px;
 | 
			
		||||
        border-bottom: solid 4px;
 | 
			
		||||
        border-left: solid 4px;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.badge {
 | 
			
		||||
  position: relative;
 | 
			
		||||
  color: #FFF;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pool-logo {
 | 
			
		||||
  width: 15px;
 | 
			
		||||
  height: 15px;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  top: -1px;
 | 
			
		||||
  margin-right: 2px;
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,224 @@
 | 
			
		||||
import { Component, OnInit, ChangeDetectionStrategy, OnDestroy, ChangeDetectorRef } from '@angular/core';
 | 
			
		||||
import { StateService } from '../../../services/state.service';
 | 
			
		||||
import { WebsocketService } from '../../../services/websocket.service';
 | 
			
		||||
import { map, Observable } from 'rxjs';
 | 
			
		||||
import { StratumJob } from '../../../interfaces/websocket.interface';
 | 
			
		||||
import { MiningService } from '../../../services/mining.service';
 | 
			
		||||
import { SinglePoolStats } from '../../../interfaces/node-api.interface';
 | 
			
		||||
 | 
			
		||||
type MerkleCellType = ' ' | '┬' | '├' | '└' | '│' | '─' | 'leaf';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
interface TaggedStratumJob extends StratumJob {
 | 
			
		||||
  tag: string;
 | 
			
		||||
  merkleBranchIds: string[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface MerkleCell {
 | 
			
		||||
  hash: string;
 | 
			
		||||
  type: MerkleCellType;
 | 
			
		||||
  job?: TaggedStratumJob;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface MerkleTree {
 | 
			
		||||
  hash?: string;
 | 
			
		||||
  job: string;
 | 
			
		||||
  size: number;
 | 
			
		||||
  children?: MerkleTree[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface PoolRow {
 | 
			
		||||
  job: TaggedStratumJob;
 | 
			
		||||
  merkleCells: MerkleCell[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function parseTag(scriptSig: string): string {
 | 
			
		||||
  const hex = scriptSig.slice(8).replace(/6d6d.{64}/, '');
 | 
			
		||||
  const bytes: number[] = [];
 | 
			
		||||
  for (let i = 0; i < hex.length; i += 2) {
 | 
			
		||||
    bytes.push(parseInt(hex.substr(i, 2), 16));
 | 
			
		||||
  }
 | 
			
		||||
  // eslint-disable-next-line no-control-regex
 | 
			
		||||
  const ascii = new TextDecoder('utf8').decode(Uint8Array.from(bytes)).replace(/\uFFFD/g, '').replace(/\\0/g, '').replace(/[\x00-\x1F\x7F-\x9F]/g, '');
 | 
			
		||||
  if (ascii.includes('/ViaBTC/')) {
 | 
			
		||||
    return '/ViaBTC/';
 | 
			
		||||
  } else if (ascii.includes('SpiderPool/')) {
 | 
			
		||||
    return 'SpiderPool/';
 | 
			
		||||
  }
 | 
			
		||||
  return (ascii.match(/\/.*\//)?.[0] || ascii).trim();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getMerkleBranchIds(merkleBranches: string[], numBranches: number): string[] {
 | 
			
		||||
  let lastHash = '';
 | 
			
		||||
  const ids: string[] = [];
 | 
			
		||||
  for (let i = 0; i < numBranches; i++) {
 | 
			
		||||
    if (merkleBranches[i]) {
 | 
			
		||||
      lastHash = merkleBranches[i];
 | 
			
		||||
    }
 | 
			
		||||
    ids.push(`${i}-${lastHash}`);
 | 
			
		||||
  }
 | 
			
		||||
  return ids;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'app-stratum-list',
 | 
			
		||||
  templateUrl: './stratum-list.component.html',
 | 
			
		||||
  styleUrls: ['./stratum-list.component.scss'],
 | 
			
		||||
  changeDetection: ChangeDetectionStrategy.OnPush,
 | 
			
		||||
})
 | 
			
		||||
export class StratumList implements OnInit, OnDestroy {
 | 
			
		||||
  rows$: Observable<PoolRow[]>;
 | 
			
		||||
  pools: { [id: number]: SinglePoolStats } = {};
 | 
			
		||||
  poolsReady: boolean = false;
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    private stateService: StateService,
 | 
			
		||||
    private websocketService: WebsocketService,
 | 
			
		||||
    private miningService: MiningService,
 | 
			
		||||
    private cd: ChangeDetectorRef,
 | 
			
		||||
  ) {}
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
    this.websocketService.want(['stats', 'blocks', 'mempool-blocks']);
 | 
			
		||||
    this.miningService.getPools().subscribe(pools => {
 | 
			
		||||
      this.pools = {};
 | 
			
		||||
      for (const pool of pools) {
 | 
			
		||||
        this.pools[pool.unique_id] = pool;
 | 
			
		||||
      }
 | 
			
		||||
      this.poolsReady = true;
 | 
			
		||||
      this.cd.markForCheck();
 | 
			
		||||
    });
 | 
			
		||||
    this.rows$ = this.stateService.stratumJobs$.pipe(
 | 
			
		||||
      map((jobs) => this.processJobs(jobs)),
 | 
			
		||||
    );
 | 
			
		||||
    this.websocketService.startTrackStratum('all');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  processJobs(rawJobs: Record<string, StratumJob>): PoolRow[] {
 | 
			
		||||
    const numBranches = Math.max(...Object.values(rawJobs).map(job => job.merkleBranches.length));
 | 
			
		||||
    const jobs: Record<string, TaggedStratumJob> = {};
 | 
			
		||||
    for (const [id, job] of Object.entries(rawJobs)) {
 | 
			
		||||
      jobs[id] = { ...job, tag: parseTag(job.scriptsig), merkleBranchIds: getMerkleBranchIds(job.merkleBranches, numBranches) };
 | 
			
		||||
    }
 | 
			
		||||
    if (Object.keys(jobs).length === 0) {
 | 
			
		||||
      return [];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let trees: MerkleTree[] = Object.keys(jobs).map(job => ({
 | 
			
		||||
      job,
 | 
			
		||||
      size: 1,
 | 
			
		||||
    }));
 | 
			
		||||
 | 
			
		||||
    // build tree from bottom up
 | 
			
		||||
    for (let col = numBranches - 1; col >= 0; col--) {
 | 
			
		||||
      const groups: Record<string, MerkleTree[]> = {};
 | 
			
		||||
      for (const tree of trees) {
 | 
			
		||||
        const branchId = jobs[tree.job].merkleBranchIds[col];
 | 
			
		||||
        if (!groups[branchId]) {
 | 
			
		||||
          groups[branchId] = [];
 | 
			
		||||
        }
 | 
			
		||||
        groups[branchId].push(tree);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      trees = Object.values(groups).map(group => ({
 | 
			
		||||
        hash: jobs[group[0].job].merkleBranches[col],
 | 
			
		||||
        job: group[0].job,
 | 
			
		||||
        children: group,
 | 
			
		||||
        size: group.reduce((acc, tree) => acc + tree.size, 0),
 | 
			
		||||
      }));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // initialize grid of cells
 | 
			
		||||
    const rows: (MerkleCell | null)[][] = [];
 | 
			
		||||
    for (let i = 0; i < Object.keys(jobs).length; i++) {
 | 
			
		||||
      const row: (MerkleCell | null)[] = [];
 | 
			
		||||
      for (let j = 0; j <= numBranches; j++) {
 | 
			
		||||
        row.push(null);
 | 
			
		||||
      }
 | 
			
		||||
      rows.push(row);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // fill in the cells
 | 
			
		||||
    let colTrees = [trees.sort((a, b) => {
 | 
			
		||||
      if (a.size !== b.size) {
 | 
			
		||||
        return b.size - a.size;
 | 
			
		||||
      }
 | 
			
		||||
      return a.job.localeCompare(b.job);
 | 
			
		||||
    })];
 | 
			
		||||
    for (let col = 0; col <= numBranches; col++) {
 | 
			
		||||
      let row = 0;
 | 
			
		||||
      const nextTrees: MerkleTree[][] = [];
 | 
			
		||||
      for (let g = 0; g < colTrees.length; g++) {
 | 
			
		||||
        for (let t = 0; t < colTrees[g].length; t++) {
 | 
			
		||||
          const tree = colTrees[g][t];
 | 
			
		||||
          const isFirstTree = (t === 0);
 | 
			
		||||
          const isLastTree = (t === colTrees[g].length - 1);
 | 
			
		||||
          for (let i = 0; i < tree.size; i++) {
 | 
			
		||||
            const isFirstCell = (i === 0);
 | 
			
		||||
            const isLeaf = (col === numBranches);
 | 
			
		||||
            rows[row][col] = {
 | 
			
		||||
              hash: tree.hash,
 | 
			
		||||
              job: isLeaf ? jobs[tree.job] : undefined,
 | 
			
		||||
              type: 'leaf',
 | 
			
		||||
            };
 | 
			
		||||
            if (col > 0) {
 | 
			
		||||
              rows[row][col - 1].type = getCellType(isFirstCell, isFirstTree, isLastTree);
 | 
			
		||||
            }
 | 
			
		||||
            row++;
 | 
			
		||||
          }
 | 
			
		||||
          if (tree.children) {
 | 
			
		||||
            nextTrees.push(tree.children.sort((a, b) => {
 | 
			
		||||
              if (a.size !== b.size) {
 | 
			
		||||
                return b.size - a.size;
 | 
			
		||||
              }
 | 
			
		||||
              return a.job.localeCompare(b.job);
 | 
			
		||||
            }));
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      colTrees = nextTrees;
 | 
			
		||||
    }
 | 
			
		||||
    return rows.map(row => ({
 | 
			
		||||
      job: row[row.length - 1].job,
 | 
			
		||||
      merkleCells: row.slice(0, -1),
 | 
			
		||||
    }));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  pipeToClass(type: MerkleCellType): string {
 | 
			
		||||
    return {
 | 
			
		||||
      ' ': 'empty',
 | 
			
		||||
      '┬': 'branch-top',
 | 
			
		||||
      '├': 'branch-mid',
 | 
			
		||||
      '└': 'branch-end',
 | 
			
		||||
      '│': 'vertical',
 | 
			
		||||
      '─': 'horizontal',
 | 
			
		||||
      'leaf': 'leaf'
 | 
			
		||||
    }[type];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnDestroy(): void {
 | 
			
		||||
    this.websocketService.stopTrackStratum();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getCellType(isFirstCell, isFirstTree, isLastTree): MerkleCellType {
 | 
			
		||||
  if (isFirstCell) {
 | 
			
		||||
    if (isFirstTree) {
 | 
			
		||||
      if (isLastTree) {
 | 
			
		||||
        return '─';
 | 
			
		||||
      } else {
 | 
			
		||||
        return '┬';
 | 
			
		||||
      }
 | 
			
		||||
    } else if (isLastTree) {
 | 
			
		||||
      return '└';
 | 
			
		||||
    } else {
 | 
			
		||||
      return '├';
 | 
			
		||||
    }
 | 
			
		||||
  } else {
 | 
			
		||||
    if (isLastTree) {
 | 
			
		||||
      return ' ';
 | 
			
		||||
    } else {
 | 
			
		||||
      return '│';
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -24,6 +24,7 @@
 | 
			
		||||
          [height]="tx?.status?.block_height"
 | 
			
		||||
          [replaced]="replaced"
 | 
			
		||||
          [removed]="this.rbfInfo?.mined && !this.tx?.status?.confirmed"
 | 
			
		||||
          [cached]="isCached"
 | 
			
		||||
        ></app-confirmations>
 | 
			
		||||
      </div>
 | 
			
		||||
    </ng-container>
 | 
			
		||||
 | 
			
		||||
@ -21,6 +21,8 @@ export interface WebsocketResponse {
 | 
			
		||||
  rbfInfo?: RbfTree;
 | 
			
		||||
  rbfLatest?: RbfTree[];
 | 
			
		||||
  rbfLatestSummary?: ReplacementInfo[];
 | 
			
		||||
  stratumJob?: StratumJob;
 | 
			
		||||
  stratumJobs?: Record<number, StratumJob>;
 | 
			
		||||
  utxoSpent?: object;
 | 
			
		||||
  transactions?: TransactionStripped[];
 | 
			
		||||
  loadingIndicators?: ILoadingIndicators;
 | 
			
		||||
@ -37,6 +39,7 @@ export interface WebsocketResponse {
 | 
			
		||||
  'track-rbf-summary'?: boolean;
 | 
			
		||||
  'track-accelerations'?: boolean;
 | 
			
		||||
  'track-wallet'?: string;
 | 
			
		||||
  'track-stratum'?: string | number;
 | 
			
		||||
  'watch-mempool'?: boolean;
 | 
			
		||||
  'refresh-blocks'?: boolean;
 | 
			
		||||
}
 | 
			
		||||
@ -150,3 +153,24 @@ export interface HealthCheckHost {
 | 
			
		||||
    electrs?: string;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface StratumJob {
 | 
			
		||||
  pool: number;
 | 
			
		||||
  height: number;
 | 
			
		||||
  coinbase: string;
 | 
			
		||||
  scriptsig: string;
 | 
			
		||||
  reward: number;
 | 
			
		||||
  jobId: string;
 | 
			
		||||
  extraNonce: string;
 | 
			
		||||
  extraNonce2Size: number;
 | 
			
		||||
  prevHash: string;
 | 
			
		||||
  coinbase1: string;
 | 
			
		||||
  coinbase2: string;
 | 
			
		||||
  merkleBranches: string[];
 | 
			
		||||
  version: string;
 | 
			
		||||
  bits: string;
 | 
			
		||||
  time: string;
 | 
			
		||||
  timestamp: number;
 | 
			
		||||
  cleanJobs: boolean;
 | 
			
		||||
  received: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -10,9 +10,10 @@ import { TestTransactionsComponent } from '@components/test-transactions/test-tr
 | 
			
		||||
import { CalculatorComponent } from '@components/calculator/calculator.component';
 | 
			
		||||
import { BlocksList } from '@components/blocks-list/blocks-list.component';
 | 
			
		||||
import { RbfList } from '@components/rbf-list/rbf-list.component';
 | 
			
		||||
import { StratumList } from '@components/stratum/stratum-list/stratum-list.component';
 | 
			
		||||
import { ServerHealthComponent } from '@components/server-health/server-health.component';
 | 
			
		||||
import { ServerStatusComponent } from '@components/server-health/server-status.component';
 | 
			
		||||
import { FaucetComponent } from '@components/faucet/faucet.component'
 | 
			
		||||
import { FaucetComponent } from '@components/faucet/faucet.component';
 | 
			
		||||
 | 
			
		||||
const browserWindow = window || {};
 | 
			
		||||
// @ts-ignore
 | 
			
		||||
@ -56,6 +57,16 @@ const routes: Routes = [
 | 
			
		||||
        path: 'rbf',
 | 
			
		||||
        component: RbfList,
 | 
			
		||||
      },
 | 
			
		||||
      ...(browserWindowEnv.STRATUM_ENABLED ? [{
 | 
			
		||||
        path: 'stratum',
 | 
			
		||||
        component: StartComponent,
 | 
			
		||||
        children: [
 | 
			
		||||
          {
 | 
			
		||||
            path: '',
 | 
			
		||||
            component: StratumList,
 | 
			
		||||
          }
 | 
			
		||||
        ]
 | 
			
		||||
      }] : []),
 | 
			
		||||
      {
 | 
			
		||||
        path: 'terms-of-service',
 | 
			
		||||
        loadChildren: () => import('@components/terms-of-service/terms-of-service.module').then(m => m.TermsOfServiceModule),
 | 
			
		||||
 | 
			
		||||
@ -64,8 +64,8 @@ export class MiningService {
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  /** 
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get names and slugs of all pools
 | 
			
		||||
   */
 | 
			
		||||
  public getPools(): Observable<any[]> {
 | 
			
		||||
@ -75,7 +75,6 @@ export class MiningService {
 | 
			
		||||
        return this.poolsData;
 | 
			
		||||
      })
 | 
			
		||||
    );
 | 
			
		||||
    
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * Set the hashrate power of ten we want to display
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core';
 | 
			
		||||
import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs';
 | 
			
		||||
import { AddressTxSummary, Transaction } from '@interfaces/electrs.interface';
 | 
			
		||||
import { AccelerationDelta, HealthCheckHost, IBackendInfo, MempoolBlock, MempoolBlockUpdate, MempoolInfo, Recommendedfees, ReplacedTransaction, ReplacementInfo, isMempoolState } from '@interfaces/websocket.interface';
 | 
			
		||||
import { Transaction } from '@interfaces/electrs.interface';
 | 
			
		||||
import { AccelerationDelta, HealthCheckHost, IBackendInfo, MempoolBlock, MempoolBlockUpdate, MempoolInfo, Recommendedfees, ReplacedTransaction, ReplacementInfo, StratumJob, isMempoolState } from '@interfaces/websocket.interface';
 | 
			
		||||
import { Acceleration, AccelerationPosition, BlockExtended, CpfpInfo, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree, TransactionStripped } from '@interfaces/node-api.interface';
 | 
			
		||||
import { Router, NavigationStart } from '@angular/router';
 | 
			
		||||
import { isPlatformBrowser } from '@angular/common';
 | 
			
		||||
@ -81,6 +81,7 @@ export interface Env {
 | 
			
		||||
  ADDITIONAL_CURRENCIES: boolean;
 | 
			
		||||
  GIT_COMMIT_HASH_MEMPOOL_SPACE?: string;
 | 
			
		||||
  PACKAGE_JSON_VERSION_MEMPOOL_SPACE?: string;
 | 
			
		||||
  STRATUM_ENABLED: boolean;
 | 
			
		||||
  SERVICES_API?: string;
 | 
			
		||||
  customize?: Customization;
 | 
			
		||||
  PROD_DOMAINS: string[];
 | 
			
		||||
@ -123,6 +124,7 @@ const defaultEnv: Env = {
 | 
			
		||||
  'ACCELERATOR_BUTTON': true,
 | 
			
		||||
  'PUBLIC_ACCELERATIONS': false,
 | 
			
		||||
  'ADDITIONAL_CURRENCIES': false,
 | 
			
		||||
  'STRATUM_ENABLED': false,
 | 
			
		||||
  'SERVICES_API': 'https://mempool.space/api/v1/services',
 | 
			
		||||
  'PROD_DOMAINS': [],
 | 
			
		||||
};
 | 
			
		||||
@ -159,6 +161,8 @@ export class StateService {
 | 
			
		||||
  liveMempoolBlockTransactions$: Observable<{ block: number, transactions: { [txid: string]: TransactionStripped} }>;
 | 
			
		||||
  accelerations$ = new Subject<AccelerationDelta>();
 | 
			
		||||
  liveAccelerations$: Observable<Acceleration[]>;
 | 
			
		||||
  stratumJobUpdate$ = new Subject<{ state: Record<string, StratumJob> } | { job: StratumJob }>();
 | 
			
		||||
  stratumJobs$ = new BehaviorSubject<Record<string, StratumJob>>({});
 | 
			
		||||
  txConfirmed$ = new Subject<[string, BlockExtended]>();
 | 
			
		||||
  txReplaced$ = new Subject<ReplacedTransaction>();
 | 
			
		||||
  txRbfInfo$ = new Subject<RbfTree>();
 | 
			
		||||
@ -303,6 +307,24 @@ export class StateService {
 | 
			
		||||
      map((accMap) => Object.values(accMap).sort((a,b) => b.added - a.added))
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    this.stratumJobUpdate$.pipe(
 | 
			
		||||
      scan((acc: Record<string, StratumJob>, update: { state: Record<string, StratumJob> } | { job: StratumJob }) => {
 | 
			
		||||
        if ('state' in update) {
 | 
			
		||||
          // Replace the entire state
 | 
			
		||||
          return update.state;
 | 
			
		||||
        } else {
 | 
			
		||||
          // Update or create a single job entry
 | 
			
		||||
          return {
 | 
			
		||||
            ...acc,
 | 
			
		||||
            [update.job.pool]: update.job
 | 
			
		||||
          };
 | 
			
		||||
        }
 | 
			
		||||
      }, {}),
 | 
			
		||||
      shareReplay(1)
 | 
			
		||||
    ).subscribe(val => {
 | 
			
		||||
      this.stratumJobs$.next(val);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.networkChanged$.subscribe((network) => {
 | 
			
		||||
      this.transactions$ = new BehaviorSubject<TransactionStripped[]>(null);
 | 
			
		||||
      this.blocksSubject$.next([]);
 | 
			
		||||
 | 
			
		||||
@ -36,6 +36,7 @@ export class WebsocketService {
 | 
			
		||||
  private isTrackingAccelerations: boolean = false;
 | 
			
		||||
  private isTrackingWallet: boolean = false;
 | 
			
		||||
  private trackingWalletName: string;
 | 
			
		||||
  private isTrackingStratum: string | number | false = false;
 | 
			
		||||
  private trackingMempoolBlock: number;
 | 
			
		||||
  private trackingMempoolBlockNetwork: string;
 | 
			
		||||
  private stoppingTrackMempoolBlock: any | null = null;
 | 
			
		||||
@ -143,6 +144,9 @@ export class WebsocketService {
 | 
			
		||||
          if (this.isTrackingWallet) {
 | 
			
		||||
            this.startTrackingWallet(this.trackingWalletName);
 | 
			
		||||
          }
 | 
			
		||||
          if (this.isTrackingStratum !== false) {
 | 
			
		||||
            this.startTrackStratum(this.isTrackingStratum);
 | 
			
		||||
          }
 | 
			
		||||
          this.stateService.connectionState$.next(2);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -289,6 +293,18 @@ export class WebsocketService {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  startTrackStratum(pool: number | string) {
 | 
			
		||||
    this.websocketSubject.next({ 'track-stratum': pool });
 | 
			
		||||
    this.isTrackingStratum = pool;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  stopTrackStratum() {
 | 
			
		||||
    if (this.isTrackingStratum) {
 | 
			
		||||
      this.websocketSubject.next({ 'track-stratum': null });
 | 
			
		||||
      this.isTrackingStratum = false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  fetchStatistics(historicalDate: string) {
 | 
			
		||||
    this.websocketSubject.next({ historicalDate });
 | 
			
		||||
  }
 | 
			
		||||
@ -512,6 +528,14 @@ export class WebsocketService {
 | 
			
		||||
      this.stateService.previousRetarget$.next(response.previousRetarget);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (response.stratumJobs) {
 | 
			
		||||
      this.stateService.stratumJobUpdate$.next({ state: response.stratumJobs });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (response.stratumJob) {
 | 
			
		||||
      this.stateService.stratumJobUpdate$.next({ job: response.stratumJob });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (response['tomahawk']) {
 | 
			
		||||
      this.stateService.serverHealth$.next(response['tomahawk']);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -11,9 +11,9 @@
 | 
			
		||||
<ng-template [ngIf]="!hideUnconfirmed && !confirmations && replaced">
 | 
			
		||||
  <button type="button" class="btn btn-sm btn-warning no-cursor {{buttonClass}}" i18n="transaction.replaced|Transaction replaced state">Replaced</button>
 | 
			
		||||
</ng-template>
 | 
			
		||||
<ng-template [ngIf]="!hideUnconfirmed && !confirmations && !replaced && removed">
 | 
			
		||||
<ng-template [ngIf]="!hideUnconfirmed && !confirmations && !replaced && (removed || cached)">
 | 
			
		||||
  <button type="button" class="btn btn-sm btn-warning no-cursor {{buttonClass}}" i18n="transaction.audit.removed|Transaction removed state">Removed</button>
 | 
			
		||||
</ng-template>
 | 
			
		||||
<ng-template [ngIf]="!hideUnconfirmed && chainTip != null && !confirmations && !replaced && !removed">
 | 
			
		||||
<ng-template [ngIf]="!hideUnconfirmed && chainTip != null && !confirmations && !replaced && !(removed || cached)">
 | 
			
		||||
  <button type="button" class="btn btn-sm btn-danger no-cursor {{buttonClass}}" i18n="transaction.unconfirmed|Transaction unconfirmed state">Unconfirmed</button>
 | 
			
		||||
</ng-template>
 | 
			
		||||
@ -12,6 +12,7 @@ export class ConfirmationsComponent implements OnChanges {
 | 
			
		||||
  @Input() height: number;
 | 
			
		||||
  @Input() replaced: boolean = false;
 | 
			
		||||
  @Input() removed: boolean = false;
 | 
			
		||||
  @Input() cached: boolean = false;
 | 
			
		||||
  @Input() hideUnconfirmed: boolean = false;
 | 
			
		||||
  @Input() buttonClass: string = '';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,10 @@ import { NgbCollapseModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstra
 | 
			
		||||
import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome';
 | 
			
		||||
import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, faChartArea, faCogs, faCubes, faHammer, faDatabase, faExchangeAlt, faInfoCircle,
 | 
			
		||||
  faLink, faList, faSearch, faCaretUp, faCaretDown, faTachometerAlt, faThList, faTint, faTv, faClock, faAngleDoubleDown, faSortUp, faAngleDoubleUp, faChevronDown,
 | 
			
		||||
  faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowsRotate, faCircleLeft, faFastForward, faWallet, faUserClock, faWrench, faUserFriends, faQuestionCircle, faHistory, faSignOutAlt, faKey, faSuitcase, faIdCardAlt, faNetworkWired, faUserCheck, faCircleCheck, faUserCircle, faCheck, faRocket, faScaleBalanced, faHourglassStart, faHourglassHalf, faHourglassEnd, faWandMagicSparkles, faFaucetDrip, faTimeline, faCircleXmark, faCalendarCheck, faMoneyBillTrendUp } from '@fortawesome/free-solid-svg-icons';
 | 
			
		||||
  faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowsRotate, faCircleLeft,
 | 
			
		||||
  faFastForward, faWallet, faUserClock, faWrench, faUserFriends, faQuestionCircle, faHistory, faSignOutAlt, faKey, faSuitcase, faIdCardAlt, faNetworkWired, faUserCheck,
 | 
			
		||||
  faCircleCheck, faUserCircle, faCheck, faRocket, faScaleBalanced, faHourglassStart, faHourglassHalf, faHourglassEnd, faWandMagicSparkles, faFaucetDrip, faTimeline,
 | 
			
		||||
  faCircleXmark, faCalendarCheck, faMoneyBillTrendUp, faRobot, faShareNodes } from '@fortawesome/free-solid-svg-icons';
 | 
			
		||||
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
 | 
			
		||||
import { MenuComponent } from '@components/menu/menu.component';
 | 
			
		||||
import { PreviewTitleComponent } from '@components/master-page-preview/preview-title.component';
 | 
			
		||||
@ -80,6 +83,7 @@ import { AmountShortenerPipe } from '@app/shared/pipes/amount-shortener.pipe';
 | 
			
		||||
import { DifficultyAdjustmentsTable } from '@components/difficulty-adjustments-table/difficulty-adjustments-table.components';
 | 
			
		||||
import { BlocksList } from '@components/blocks-list/blocks-list.component';
 | 
			
		||||
import { RbfList } from '@components/rbf-list/rbf-list.component';
 | 
			
		||||
import { StratumList } from '@components/stratum/stratum-list/stratum-list.component';
 | 
			
		||||
import { RewardStatsComponent } from '@components/reward-stats/reward-stats.component';
 | 
			
		||||
import { DataCyDirective } from '@app/data-cy.directive';
 | 
			
		||||
import { LoadingIndicatorComponent } from '@components/loading-indicator/loading-indicator.component';
 | 
			
		||||
@ -198,6 +202,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from '@app/shared/components/
 | 
			
		||||
    DifficultyAdjustmentsTable,
 | 
			
		||||
    BlocksList,
 | 
			
		||||
    RbfList,
 | 
			
		||||
    StratumList,
 | 
			
		||||
    DataCyDirective,
 | 
			
		||||
    RewardStatsComponent,
 | 
			
		||||
    LoadingIndicatorComponent,
 | 
			
		||||
@ -342,6 +347,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from '@app/shared/components/
 | 
			
		||||
    AmountShortenerPipe,
 | 
			
		||||
    DifficultyAdjustmentsTable,
 | 
			
		||||
    BlocksList,
 | 
			
		||||
    StratumList,
 | 
			
		||||
    DataCyDirective,
 | 
			
		||||
    RewardStatsComponent,
 | 
			
		||||
    LoadingIndicatorComponent,
 | 
			
		||||
@ -452,5 +458,7 @@ export class SharedModule {
 | 
			
		||||
    library.addIcons(faCircleXmark);
 | 
			
		||||
    library.addIcons(faCalendarCheck);
 | 
			
		||||
    library.addIcons(faMoneyBillTrendUp);
 | 
			
		||||
    library.addIcons(faRobot);
 | 
			
		||||
    library.addIcons(faShareNodes);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user