Merge branch 'master' into knorrium/update-node-matrix

This commit is contained in:
Felipe Knorr Kuhn 2025-01-19 23:41:46 -08:00 committed by GitHub
commit 5f5e96984a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1190 additions and 238 deletions

View File

@ -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

View File

@ -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,

View File

@ -151,5 +151,9 @@
"ENABLED": true,
"PAID": false,
"API_KEY": "__MEMPOOL_CURRENCY_API_KEY__"
},
"STRATUM": {
"ENABLED": false,
"API": "http://localhost:1234"
}
}

View File

@ -159,6 +159,11 @@ describe('Mempool Backend Config', () => {
PAID: false,
API_KEY: '',
});
expect(config.STRATUM).toStrictEqual({
ENABLED: false,
API: 'http://localhost:1234',
});
});
});

View File

@ -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;
}

View 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();

View File

@ -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 {

View File

@ -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 => {

View File

@ -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 {

View File

@ -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__",

View File

@ -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

View File

@ -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');
});

View File

@ -27,5 +27,6 @@
"ACCELERATOR": false,
"ACCELERATOR_BUTTON": true,
"PUBLIC_ACCELERATIONS": false,
"STRATUM_ENABLED": false,
"SERVICES_API": "https://mempool.space/api/v1/services"
}

View File

@ -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;

View File

@ -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>&nbsp;<small style="font-family: monospace;">{{ cost | number }}</small>&nbsp;<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>

View File

@ -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);

View File

@ -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 {

View File

@ -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;
}
}

View File

@ -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>&nbsp;</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">

View File

@ -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;
}

View File

@ -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) {

View File

@ -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>

View File

@ -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;
}

View File

@ -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 '│';
}
}
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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),

View File

@ -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

View File

@ -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([]);

View File

@ -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']);
}

View File

@ -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>

View File

@ -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 = '';

View File

@ -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);
}
}