diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8e9ae33b..8a29e9184 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -257,7 +257,7 @@ jobs: spec: | cypress/e2e/mainnet/*.spec.ts cypress/e2e/signet/*.spec.ts - cypress/e2e/testnet/*.spec.ts + cypress/e2e/testnet4/*.spec.ts - module: "liquid" spec: | cypress/e2e/liquid/liquid.spec.ts diff --git a/frontend/cypress/e2e/mainnet/mainnet.spec.ts b/frontend/cypress/e2e/mainnet/mainnet.spec.ts index c0f5cbfda..5032144f8 100644 --- a/frontend/cypress/e2e/mainnet/mainnet.spec.ts +++ b/frontend/cypress/e2e/mainnet/mainnet.spec.ts @@ -112,8 +112,8 @@ describe('Mainnet', () => { it('check op_return coinbase tooltip', () => { cy.visit('/block/00000000000000000003c5f542bed265319c6cf64238cf1f1bb9bca3ebf686d2'); cy.waitForSkeletonGone(); - cy.get('div > a > .badge').first().trigger('onmouseover'); - cy.get('div > a > .badge').first().trigger('mouseenter'); + cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('onmouseover'); + cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('mouseenter'); cy.get('.tooltip-inner').should('be.visible'); }); @@ -339,7 +339,7 @@ describe('Mainnet', () => { cy.visit('/'); cy.waitForSkeletonGone(); - cy.changeNetwork('testnet'); + cy.changeNetwork('testnet4'); cy.changeNetwork('signet'); cy.changeNetwork('mainnet'); }); diff --git a/frontend/cypress/e2e/testnet/testnet.spec.ts b/frontend/cypress/e2e/testnet4/testnet4.spec.ts similarity index 86% rename from frontend/cypress/e2e/testnet/testnet.spec.ts rename to frontend/cypress/e2e/testnet4/testnet4.spec.ts index 4236ca207..4e2b6e3fa 100644 --- a/frontend/cypress/e2e/testnet/testnet.spec.ts +++ b/frontend/cypress/e2e/testnet4/testnet4.spec.ts @@ -2,7 +2,7 @@ import { emitMempoolInfo } from '../../support/websocket'; const baseModule = Cypress.env('BASE_MODULE'); -describe('Testnet', () => { +describe('Testnet4', () => { beforeEach(() => { cy.intercept('/api/block-height/*').as('block-height'); cy.intercept('/api/block/*').as('block'); @@ -13,7 +13,7 @@ describe('Testnet', () => { if (baseModule === 'mempool') { it('loads the dashboard', () => { - cy.visit('/testnet'); + cy.visit('/testnet4'); cy.waitForSkeletonGone(); }); @@ -25,7 +25,7 @@ describe('Testnet', () => { it.skip('loads the dashboard with the skeleton blocks', () => { cy.mockMempoolSocket(); - cy.visit('/testnet'); + cy.visit('/testnet4'); cy.get(':nth-child(1) > #bitcoin-block-0').should('be.visible'); cy.get(':nth-child(2) > #bitcoin-block-0').should('be.visible'); cy.get(':nth-child(3) > #bitcoin-block-0').should('be.visible'); @@ -45,7 +45,7 @@ describe('Testnet', () => { }); it('loads the pools screen', () => { - cy.visit('/testnet'); + cy.visit('/testnet4'); cy.waitForSkeletonGone(); cy.get('#btn-pools').click().then(() => { cy.wait(1000); @@ -53,7 +53,7 @@ describe('Testnet', () => { }); it('loads the graphs screen', () => { - cy.visit('/testnet'); + cy.visit('/testnet4'); cy.waitForSkeletonGone(); cy.get('#btn-graphs').click().then(() => { cy.wait(1000); @@ -63,7 +63,7 @@ describe('Testnet', () => { describe('tv mode', () => { it('loads the tv screen - desktop', () => { cy.viewport('macbook-16'); - cy.visit('/testnet/graphs'); + cy.visit('/testnet4/graphs'); cy.waitForSkeletonGone(); cy.get('#btn-tv').click().then(() => { cy.wait(1000); @@ -73,7 +73,7 @@ describe('Testnet', () => { }); it('loads the tv screen - mobile', () => { - cy.visit('/testnet/graphs'); + cy.visit('/testnet4/graphs'); cy.waitForSkeletonGone(); cy.get('#btn-tv').click().then(() => { cy.viewport('iphone-6'); @@ -85,7 +85,7 @@ describe('Testnet', () => { it('loads the api screen', () => { - cy.visit('/testnet'); + cy.visit('/testnet4'); cy.waitForSkeletonGone(); cy.get('#btn-docs').click().then(() => { cy.wait(1000); @@ -94,13 +94,13 @@ describe('Testnet', () => { describe('blocks', () => { it('shows empty blocks properly', () => { - cy.visit('/testnet/block/0'); + cy.visit('/testnet4/block/0'); cy.waitForSkeletonGone(); cy.get('h2').invoke('text').should('equal', '1 transaction'); }); it('expands and collapses the block details', () => { - cy.visit('/testnet/block/0'); + cy.visit('/testnet4/block/0'); cy.waitForSkeletonGone(); cy.get('.btn.btn-outline-info').click().then(() => { cy.get('#details').should('be.visible'); @@ -112,15 +112,15 @@ describe('Testnet', () => { }); it('shows blocks with no pagination', () => { - cy.visit('/testnet/block/000000000000002f8ce27716e74ecc7ad9f7b5101fed12d09e28bb721b9460ea'); + cy.visit('/testnet4/block/000000000066e8b6cc78a93f8989587f5819624bae2eb1c05f535cadded19f99'); cy.waitForSkeletonGone(); - cy.get('h2').invoke('text').should('equal', '11 transactions'); + cy.get('h2').invoke('text').should('equal', '18 transactions'); cy.get('ul.pagination').first().children().should('have.length', 5); }); it('supports pagination on the block screen', () => { // 48 txs - cy.visit('/testnet/block/000000000000002ca3878ebd98b313a1c2d531f2e70a6575d232ca7564dea7a9'); + cy.visit('/testnet4/block/000000000000006982d53f8273bdff21dafc380c292eabc669b5ab6d732311c3'); cy.waitForSkeletonGone(); cy.get('.header-bg.box > a').invoke('text').then((text1) => { cy.get('.active + li').first().click().then(() => { diff --git a/frontend/cypress/support/commands.ts b/frontend/cypress/support/commands.ts index 23376e5b2..018f63569 100644 --- a/frontend/cypress/support/commands.ts +++ b/frontend/cypress/support/commands.ts @@ -72,7 +72,7 @@ Cypress.Commands.add('mockMempoolSocket', () => { mockWebSocket(); }); -Cypress.Commands.add('changeNetwork', (network: "testnet" | "signet" | "liquid" | "mainnet") => { +Cypress.Commands.add('changeNetwork', (network: "testnet" | "testnet4" | "signet" | "liquid" | "mainnet") => { cy.get('.dropdown-toggle').click().then(() => { cy.get(`a.${network}`).click().then(() => { cy.waitForPageIdle(); diff --git a/frontend/cypress/support/index.d.ts b/frontend/cypress/support/index.d.ts index 3cc151b21..2c5328301 100644 --- a/frontend/cypress/support/index.d.ts +++ b/frontend/cypress/support/index.d.ts @@ -5,6 +5,6 @@ declare namespace Cypress { waitForSkeletonGone(): Chainable waitForPageIdle(): Chainable mockMempoolSocket(): Chainable - changeNetwork(network: "testnet"|"signet"|"liquid"|"mainnet"): Chainable + changeNetwork(network: "testnet"|"testnet4"|"signet"|"liquid"|"mainnet"): Chainable } } \ No newline at end of file diff --git a/frontend/mempool-frontend-config.sample.json b/frontend/mempool-frontend-config.sample.json index 7f06c8fbc..c111f35af 100644 --- a/frontend/mempool-frontend-config.sample.json +++ b/frontend/mempool-frontend-config.sample.json @@ -1,5 +1,6 @@ { "TESTNET_ENABLED": false, + "TESTNET4_ENABLED": false, "SIGNET_ENABLED": false, "LIQUID_ENABLED": false, "LIQUID_TESTNET_ENABLED": false, diff --git a/frontend/package.json b/frontend/package.json index 49e2be379..4c9e63b8d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,16 +50,16 @@ "dev:ssr": "npm run generate-config && ng run mempool:serve-ssr", "serve:ssr": "npm run generate-config && node server.run.js", "build:ssr": "npm run build && ng run mempool:server:production && ./node_modules/typescript/bin/tsc server.run.ts", - "config:defaults:mempool": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=mempool BLOCK_WEIGHT_UNITS=4000000 && npm run generate-config", - "config:defaults:liquid": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=liquid BLOCK_WEIGHT_UNITS=300000 && npm run generate-config", + "config:defaults:mempool": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=mempool BLOCK_WEIGHT_UNITS=4000000 && npm run generate-config", + "config:defaults:liquid": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=liquid BLOCK_WEIGHT_UNITS=300000 && npm run generate-config", "prerender": "npm run ng -- run mempool:prerender", "cypress:open": "cypress open", "cypress:run": "cypress run", "cypress:run:record": "cypress run --record", - "cypress:open:ci": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-prod 4200 cypress:open", - "cypress:run:ci": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-prod 4200 cypress:run:record", - "cypress:open:ci:staging": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:open", - "cypress:run:ci:staging": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:run:record" + "cypress:open:ci": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-prod 4200 cypress:open", + "cypress:run:ci": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-prod 4200 cypress:run:record", + "cypress:open:ci:staging": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:open", + "cypress:run:ci:staging": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:run:record" }, "dependencies": { "@angular-devkit/build-angular": "^17.3.1", diff --git a/frontend/proxy.conf.js b/frontend/proxy.conf.js index b63d343e2..05f7550e0 100644 --- a/frontend/proxy.conf.js +++ b/frontend/proxy.conf.js @@ -24,7 +24,7 @@ PROXY_CONFIG = [ '/api/**', '!/api/v1/ws', '!/liquid', '!/liquid/**', '!/liquid/', '!/liquidtestnet', '!/liquidtestnet/**', '!/liquidtestnet/', - '/testnet/api/**', '/signet/api/**' + '/testnet/api/**', '/signet/api/**', '/testnet4/api/**' ], target: "https://mempool.space", ws: true, diff --git a/frontend/src/app/components/faucet/faucet.component.html b/frontend/src/app/components/faucet/faucet.component.html index 1030087b1..2de642c2c 100644 --- a/frontend/src/app/components/faucet/faucet.component.html +++ b/frontend/src/app/components/faucet/faucet.component.html @@ -4,43 +4,54 @@

Testnet4 Faucet

- @if (error) { -
- @switch (error) { - @case ('faucet_too_soon') { - Too many requests! Try again later. - } - @case ('faucet_maximum_reached') { - You have exceeded your testnet4 allowance. Try again later. - } - @case ('faucet_not_available') { - The faucet is not available right now. Try again later. - } - @default { - Sorry, something went wrong! Try again later. - } - } -
- } -
+ @if (txid) { -
- {{ txid }} +
+ + Sent! + {{ txid }}
- } @else if (loading) { -

Waiting for faucet...

+ } + @else if (loading) { +

Loading faucet...

- } @else { -
+ } @else if (!user) { + +
+
+ To limit abuse,  + authenticate  + or +
+ +
+ } + @else if (error === 'not_available') { + +
+
+ To limit abuse +
+ +
+ } + + @else if (error) { + + + } + + @if (!loading) { +
-
-
+
+
Amount (sats)
- +
@@ -49,43 +60,32 @@
Amount is required
-
Minimum is {{ amount?.errors?.['min'].min }}
-
Maximum is {{ amount?.errors?.['max'].max }}
+
Minimum is {{ amount?.errors?.['min'].min | number }} tSats
+
Maximum is {{ amount?.errors?.['max'].max | number }} tSats
-
+
Address
- - + +
- @if (address?.errors?.['required']) { -
Address is required
- } @else { -
Must be a valid testnet4 address
- } -
-
-
Too many requests! Try again later.
+
Address is required
+
Must be a valid testnet4 address
+
You cannot use this address
- @if (!user) { -
- To limit abuse, please log in or sign up and link your Twitter account to use the faucet. -
- } @else if (!status?.access) { -
- To use this feature, please link your Twitter account. -
- } } -
-
- If you no longer need your testnet4 coins, please consider sending them back to {{ status.address }} to replenish the faucet. -
+ + + @if (status?.address) { +
If you no longer need your testnet4 coins, please consider sending them back to replenish the faucet.
+ } +
-
+ +
\ No newline at end of file diff --git a/frontend/src/app/components/faucet/faucet.component.scss b/frontend/src/app/components/faucet/faucet.component.scss index 084168ca4..d611f5a23 100644 --- a/frontend/src/app/components/faucet/faucet.component.scss +++ b/frontend/src/app/components/faucet/faucet.component.scss @@ -23,6 +23,9 @@ .submit-button, .button-group, .button-group .btn { flex-grow: 1; } + .submit-button:disabled { + pointer-events: none; + } #satoshis::after { content: 'sats'; @@ -41,4 +44,9 @@ width: 100%; max-width: 800px; margin: auto; -} \ No newline at end of file +} + +.invalid { + border-width: 1px; + border-color: var(--red); +} diff --git a/frontend/src/app/components/faucet/faucet.component.ts b/frontend/src/app/components/faucet/faucet.component.ts index 98d7a0c57..bfb485d0e 100644 --- a/frontend/src/app/components/faucet/faucet.component.ts +++ b/frontend/src/app/components/faucet/faucet.component.ts @@ -1,13 +1,13 @@ -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { FormBuilder, FormGroup, ValidationErrors, Validators } from "@angular/forms"; -import { StorageService } from '../../services/storage.service'; -import { ServicesApiServices } from '../../services/services-api.service'; -import { AudioService } from '../../services/audio.service'; -import { StateService } from '../../services/state.service'; -import { Subscription, tap } from "rxjs"; -import { HttpErrorResponse } from "@angular/common/http"; +import { Component, OnDestroy, OnInit, ChangeDetectorRef } from "@angular/core"; +import { FormBuilder, FormGroup, Validators, ValidatorFn, AbstractControl, ValidationErrors } from "@angular/forms"; +import { Subscription } from "rxjs"; +import { StorageService } from "../../services/storage.service"; +import { ServicesApiServices } from "../../services/services-api.service"; import { getRegex } from "../../shared/regex.utils"; +import { StateService } from "../../services/state.service"; import { WebsocketService } from "../../services/websocket.service"; +import { AudioService } from "../../services/audio.service"; +import { HttpErrorResponse } from "@angular/common/http"; @Component({ selector: 'app-faucet', @@ -15,52 +15,91 @@ import { WebsocketService } from "../../services/websocket.service"; styleUrls: ['./faucet.component.scss'] }) export class FaucetComponent implements OnInit, OnDestroy { - user: any; - loading: boolean = true; + loading = true; + error: string = ''; + user: any = undefined; + txid: string = ''; + + faucetStatusSubscription: Subscription; status: { - address?: string, - access: boolean - min: number, - user_max: number, - user_requests: number, + min: number; // minimum amount to request at once (in sats) + max: number; // maximum amount to request at once + address?: string; // faucet address + code: 'ok' | 'faucet_not_available' | 'faucet_maximum_reached' | 'faucet_too_soon'; } | null = null; - error = ''; faucetForm: FormGroup; - txid = ''; mempoolPositionSubscription: Subscription; confirmationSubscription: Subscription; constructor( - private stateService: StateService, + private cd: ChangeDetectorRef, private storageService: StorageService, private servicesApiService: ServicesApiServices, - private websocketService: WebsocketService, - private audioService: AudioService, private formBuilder: FormBuilder, + private stateService: StateService, + private websocketService: WebsocketService, + private audioService: AudioService ) { + this.faucetForm = this.formBuilder.group({ + 'address': ['', [Validators.required, Validators.pattern(getRegex('address', 'testnet4'))]], + 'satoshis': [0, [Validators.required, Validators.min(0), Validators.max(0)]] + }); } - ngOnInit(): void { + ngOnDestroy() { + this.stateService.markBlock$.next({}); + this.websocketService.stopTrackingTransaction(); + if (this.mempoolPositionSubscription) { + this.mempoolPositionSubscription.unsubscribe(); + } + if (this.confirmationSubscription) { + this.confirmationSubscription.unsubscribe(); + } + } + + ngOnInit() { this.user = this.storageService.getAuth()?.user ?? null; - this.initForm(5000, 500000); - if (this.user) { - try { - this.servicesApiService.getFaucetStatus$().subscribe(status => { - this.status = status; - this.initForm(this.status.min, this.status.user_max); - }) - } catch (e) { - if (e?.status !== 403) { - this.error = 'faucet_not_available'; - } - } finally { - this.loading = false; - } - } else { + if (!this.user) { this.loading = false; + return; } + // Setup form + this.faucetStatusSubscription = this.servicesApiService.getFaucetStatus$().subscribe({ + next: (status) => { + if (!status) { + this.error = 'internal_server_error'; + return; + } + this.status = status; + + const notFaucetAddressValidator = (faucetAddress: string): ValidatorFn => { + return (control: AbstractControl): ValidationErrors | null => { + const forbidden = control.value === faucetAddress; + return forbidden ? { forbiddenAddress: { value: control.value } } : null; + }; + } + this.faucetForm = this.formBuilder.group({ + 'address': ['', [Validators.required, Validators.pattern(getRegex('address', 'testnet4')), notFaucetAddressValidator(this.status.address)]], + 'satoshis': [this.status.min, [Validators.required, Validators.min(this.status.min), Validators.max(this.status.max)]] + }); + + if (this.status.code !== 'ok') { + this.error = this.status.code; + } + + this.loading = false; + this.cd.markForCheck(); + }, + error: (response) => { + this.loading = false; + this.error = response.error; + this.cd.markForCheck(); + } + }); + + // Track transaction this.websocketService.want(['blocks', 'mempool-blocks']); this.mempoolPositionSubscription = this.stateService.mempoolTxPosition$.subscribe(txPosition => { if (txPosition && txPosition.txid === this.txid) { @@ -78,18 +117,22 @@ export class FaucetComponent implements OnInit, OnDestroy { }); } - initForm(min: number, max: number): void { - this.faucetForm = this.formBuilder.group({ - 'address': ['', [Validators.required, Validators.pattern(getRegex('address', 'testnet4'))]], - 'satoshis': ['', [Validators.required, Validators.min(min), Validators.max(max)]] - }, { validators: (formGroup): ValidationErrors | null => { - if (this.status && !this.status?.user_requests) { - return { customError: 'You have used the faucet too many times already! Come back later.'} - } - return null; - }}); - this.faucetForm.get('satoshis').setValue(min); - this.loading = false; + requestCoins(): void { + this.error = null; + this.txid = ''; + this.stateService.markBlock$.next({}); + this.servicesApiService.requestTestnet4Coins$(this.faucetForm.get('address')?.value, parseInt(this.faucetForm.get('satoshis')?.value)) + .subscribe({ + next: ((response) => { + this.txid = response.txid; + this.websocketService.startTrackTransaction(this.txid); + this.audioService.playSound('cha-ching'); + this.cd.markForCheck(); + }), + error: (response: HttpErrorResponse) => { + this.error = response.error; + }, + }); } setAmount(value: number): void { @@ -98,39 +141,13 @@ export class FaucetComponent implements OnInit, OnDestroy { } } - requestCoins(): void { - this.error = null; - this.stateService.markBlock$.next({}); - this.servicesApiService.requestTestnet4Coins$(this.faucetForm.get('address')?.value, parseInt(this.faucetForm.get('satoshis')?.value)) - .subscribe({ - next: ((response) => { - this.txid = response.txid; - this.websocketService.startTrackTransaction(this.txid); - this.audioService.playSound('cha-ching'); - }), - error: (response: HttpErrorResponse) => { - this.error = response.error; - }, - }); - } - - ngOnDestroy(): void { - this.stateService.markBlock$.next({}); - this.websocketService.stopTrackingTransaction(); - if (this.mempoolPositionSubscription) { - this.mempoolPositionSubscription.unsubscribe(); - } - if (this.confirmationSubscription) { - this.confirmationSubscription.unsubscribe(); - } - } - get amount() { return this.faucetForm.get('satoshis')!; } - get address() { return this.faucetForm.get('address')!; } get invalidAmount() { const amount = this.faucetForm.get('satoshis')!; return amount?.invalid && (amount.dirty || amount.touched) } + + get address() { return this.faucetForm.get('address')!; } get invalidAddress() { const address = this.faucetForm.get('address')!; return address?.invalid && (address.dirty || address.touched) diff --git a/frontend/src/app/components/master-page/master-page.component.html b/frontend/src/app/components/master-page/master-page.component.html index 86c653deb..5616d7f96 100644 --- a/frontend/src/app/components/master-page/master-page.component.html +++ b/frontend/src/app/components/master-page/master-page.component.html @@ -70,7 +70,7 @@ Mainnet Signet Testnet3 - Testnet4 beta + Testnet4 beta Liquid Liquid Testnet @@ -102,7 +102,7 @@ -