Merge branch 'master' into simon/refactor-block-page
This commit is contained in:
		
						commit
						abad704fc6
					
				
							
								
								
									
										2
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @ -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 | ||||
|  | ||||
| @ -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'); | ||||
|     }); | ||||
|  | ||||
| @ -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(() => { | ||||
| @ -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(); | ||||
|  | ||||
							
								
								
									
										2
									
								
								frontend/cypress/support/index.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								frontend/cypress/support/index.d.ts
									
									
									
									
										vendored
									
									
								
							| @ -5,6 +5,6 @@ declare namespace Cypress { | ||||
|         waitForSkeletonGone(): Chainable<any> | ||||
|         waitForPageIdle(): Chainable<any> | ||||
|         mockMempoolSocket(): Chainable<any> | ||||
|         changeNetwork(network: "testnet"|"signet"|"liquid"|"mainnet"): Chainable<any> | ||||
|         changeNetwork(network: "testnet"|"testnet4"|"signet"|"liquid"|"mainnet"): Chainable<any> | ||||
|     } | ||||
| } | ||||
| @ -1,5 +1,6 @@ | ||||
| { | ||||
|   "TESTNET_ENABLED": false, | ||||
|   "TESTNET4_ENABLED": false, | ||||
|   "SIGNET_ENABLED": false, | ||||
|   "LIQUID_ENABLED": false, | ||||
|   "LIQUID_TESTNET_ENABLED": false, | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -4,43 +4,54 @@ | ||||
|     <h1 i18n="testnet4.faucet">Testnet4 Faucet</h1> | ||||
|   </div> | ||||
| 
 | ||||
|   @if (error) { | ||||
|     <div class="alert alert-danger"> | ||||
|       @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. | ||||
|   <div class="faucet-container text-center"> | ||||
|    | ||||
|     @if (txid) { | ||||
|       <div class="alert alert-success w-100 text-truncate"> | ||||
|         <fa-icon [icon]="['fas', 'circle-check']"></fa-icon> | ||||
|         Sent! | ||||
|         <a class="text-primary" [href]="'/testnet4/tx/' + txid">{{ txid }}</a> | ||||
|       </div> | ||||
|     } | ||||
|     @else if (loading) { | ||||
|       <p>Loading faucet...</p> | ||||
|       <div class="spinner-border text-light"></div> | ||||
|     } @else if (!user) { | ||||
|       <!-- User not logged in --> | ||||
|       <div class="alert alert-mempool d-block text-center w-100"> | ||||
|         <div class="d-inline align-middle"> | ||||
|           <span>To limit abuse, </span> | ||||
|           <a routerLink="/login" [queryParams]="{'redirectTo': '/testnet4/faucet'}">authenticate </a> | ||||
|           <span class="mr-2">or</span> | ||||
|         </div> | ||||
|         <app-twitter-login customClass="btn btn-sm" width="220px" redirectTo="/testnet4/faucet" buttonString="Sign up with Twitter"></app-twitter-login> | ||||
|       </div> | ||||
|     } | ||||
|     @else if (error === 'not_available') { | ||||
|       <!-- User logged in but not a paid user or did not link its Twitter account --> | ||||
|       <div class="alert alert-mempool d-block text-center w-100"> | ||||
|         <div class="d-inline align-middle"> | ||||
|           <span class="mb-2 mr-2">To limit abuse</span> | ||||
|         </div> | ||||
|         <app-twitter-login customClass="btn btn-sm" width="180px" redirectTo="/testnet4/faucet" buttonString="Link your Twitter"></app-twitter-login> | ||||
|       </div> | ||||
|     } | ||||
|      | ||||
|   <div class="faucet-container text-center"> | ||||
|     @if (txid) { | ||||
|       <div class="alert alert-mempool d-block text-center"> | ||||
|         <a [routerLink]="['/tx/' | relativeUrl, txid]">{{ txid }}</a> | ||||
|       </div> | ||||
|     } @else if (loading) { | ||||
|       <p>Waiting for faucet...</p> | ||||
|       <div class="spinner-border text-light"></div> | ||||
|     } @else { | ||||
|       <form [formGroup]="faucetForm" class="formGroup" (submit)="requestCoins()"> | ||||
|     @else if (error) { | ||||
|       <!-- User can request --> | ||||
|       <app-mempool-error class="w-100" [error]="error"></app-mempool-error> | ||||
|     } | ||||
| 
 | ||||
|     @if (!loading) { | ||||
|       <form [formGroup]="faucetForm" class="formGroup" (submit)="requestCoins()" [style]="(error || !this.user) ? 'opacity: 0.3; pointer-events: none' : ''"> | ||||
|         <div class="row"> | ||||
|           <div class="col"> | ||||
|             <div class="form-group"> | ||||
|               <div class="input-group input-group-lg mb-2"> | ||||
|             <div class="form-group mb-0"> | ||||
|               <div class="input-group input-group-lg"> | ||||
|                 <div class="input-group-prepend"> | ||||
|                   <span class="input-group-text" i18n="amount-sats">Amount (sats)</span> | ||||
|                 </div> | ||||
|                 <input type="number" class="form-control" formControlName="satoshis" id="satoshis"> | ||||
|                 <input type="number" class="form-control" [class]="{invalid: invalidAmount}" formControlName="satoshis" id="satoshis"> | ||||
|                 <div class="button-group"> | ||||
|                   <button type="button" class="btn btn-secondary" (click)="setAmount(5000)">5k</button> | ||||
|                   <button type="button" class="btn btn-secondary ml-2" (click)="setAmount(50000)">50k</button> | ||||
| @ -49,43 +60,32 @@ | ||||
|               </div> | ||||
|               <div class="text-danger text-left" *ngIf="invalidAmount"> | ||||
|                 <div *ngIf="amount?.errors?.['required']">Amount is required</div> | ||||
|                 <div *ngIf="status?.user_requests && amount?.errors?.['min']">Minimum is {{ amount?.errors?.['min'].min }}</div> | ||||
|                 <div *ngIf="status?.user_requests && amount?.errors?.['max']">Maximum is {{ amount?.errors?.['max'].max }}</div> | ||||
|                 <div *ngIf="amount?.errors?.['min']">Minimum is {{ amount?.errors?.['min'].min | number }} tSats</div> | ||||
|                 <div *ngIf="amount?.errors?.['max']">Maximum is {{ amount?.errors?.['max'].max | number }} tSats</div> | ||||
|               </div> | ||||
|               <div class="input-group input-group-lg mb-2"> | ||||
|               <div class="input-group input-group-lg mt-2"> | ||||
|                 <div class="input-group-prepend"> | ||||
|                   <span class="input-group-text" i18n="address">Address</span> | ||||
|                 </div> | ||||
|                 <input type="address" class="form-control" formControlName="address" id="address" placeholder="tb1q..."> | ||||
|                 <button type="submit" class="btn btn-primary submit-button" [disabled]="!status?.access || !faucetForm.valid || !faucetForm.get('address')?.dirty" i18n="testnet4.request-coins">Request Testnet4 Coins</button> | ||||
|                 <input type="text" class="form-control" [class]="{invalid: invalidAddress}" formControlName="address" id="address" placeholder="tb1q..."> | ||||
|                 <button type="submit" class="btn btn-primary submit-button" [disabled]="!faucetForm.valid || !faucetForm.get('address')?.dirty" i18n="testnet4.request-coins">Request Testnet4 Coins</button> | ||||
|               </div> | ||||
|               <div class="text-danger text-left" *ngIf="invalidAddress"> | ||||
|                 @if (address?.errors?.['required']) { | ||||
|                   <div>Address is required</div> | ||||
|                 } @else { | ||||
|                   <div>Must be a valid testnet4 address</div> | ||||
|                 } | ||||
|               </div> | ||||
|               <div class="text-danger text-left" *ngIf="status && !status.user_requests"> | ||||
|                 <div>Too many requests! Try again later.</div> | ||||
|                 <div *ngIf="address?.errors?.['required']">Address is required</div> | ||||
|                 <div *ngIf="address?.errors?.['pattern']">Must be a valid testnet4 address</div> | ||||
|                 <div *ngIf="address?.errors?.['forbiddenAddress']">You cannot use this address</div> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </form> | ||||
|       @if (!user) { | ||||
|         <div class="alert alert-mempool d-block"> | ||||
|           To limit abuse, please <a routerLink="/login" [queryParams]="{'redirectTo': '/testnet4/faucet'}">log in</a> or <a routerLink="/signup" [queryParams]="{'redirectTo': '/testnet4/faucet'}">sign up</a> and link your Twitter account to use the faucet. | ||||
|         </div> | ||||
|       } @else if (!status?.access) { | ||||
|         <div class="alert alert-mempool d-block"> | ||||
|           To use this feature, please <a routerLink="/services/account/settings">link your Twitter account</a>. | ||||
|         </div> | ||||
|     } | ||||
| 
 | ||||
|     <!-- Send back coins --> | ||||
|     @if (status?.address) {   | ||||
|       <div class="mt-2 alert alert-info w-100">If you no longer need your testnet4 coins, please consider <a class="text-primary" [routerLink]="['/address/' | relativeUrl, status.address]"><u>sending them back</u></a> to replenish the faucet.</div> | ||||
|     } | ||||
|     <br> | ||||
|     <div *ngIf="status?.address"> | ||||
|       If you no longer need your testnet4 coins, please consider sending them back to <a [routerLink]="['/address/' | relativeUrl, status.address]">{{ status.address }}</a> to replenish the faucet. | ||||
|     </div> | ||||
| 
 | ||||
|   </div> | ||||
| 
 | ||||
| </div> | ||||
| @ -23,6 +23,9 @@ | ||||
|   .submit-button, .button-group, .button-group .btn { | ||||
|     flex-grow: 1; | ||||
|   } | ||||
|   .submit-button:disabled { | ||||
|     pointer-events: none; | ||||
|   } | ||||
| 
 | ||||
|   #satoshis::after { | ||||
|     content: 'sats'; | ||||
| @ -42,3 +45,8 @@ | ||||
|   max-width: 800px; | ||||
|   margin: auto; | ||||
| } | ||||
| 
 | ||||
| .invalid { | ||||
|   border-width: 1px; | ||||
|   border-color: var(--red); | ||||
| } | ||||
|  | ||||
| @ -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) | ||||
|  | ||||
| @ -70,7 +70,7 @@ | ||||
|       <a ngbDropdownItem class="mainnet" [routerLink]="networkPaths['mainnet'] || '/'"><app-svg-images name="bitcoin" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Mainnet</a> | ||||
|       <a ngbDropdownItem *ngIf="env.SIGNET_ENABLED" class="signet" [class.active]="network.val === 'signet'" [routerLink]="networkPaths['signet'] || '/signet'"><app-svg-images name="signet" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Signet</a> | ||||
|       <a ngbDropdownItem *ngIf="env.TESTNET_ENABLED" class="testnet" [class.active]="network.val === 'testnet'" [routerLink]="networkPaths['testnet'] || '/testnet'"><app-svg-images name="testnet" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Testnet3</a> | ||||
|       <a ngbDropdownItem *ngIf="env.TESTNET4_ENABLED" class="testnet" [class.active]="network.val === 'testnet4'" [routerLink]="networkPaths['testnet4'] || '/testnet4'"><app-svg-images name="testnet4" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Testnet4 <span class="badge badge-pill badge-warning beta-network" i18n="beta">beta</span></a> | ||||
|       <a ngbDropdownItem *ngIf="env.TESTNET4_ENABLED" class="testnet4" [class.active]="network.val === 'testnet4'" [routerLink]="networkPaths['testnet4'] || '/testnet4'"><app-svg-images name="testnet4" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Testnet4 <span class="badge badge-pill badge-warning beta-network" i18n="beta">beta</span></a> | ||||
|       <h6 *ngIf="env.LIQUID_ENABLED" class="dropdown-header" i18n="master-page.layer2-networks-header">Layer 2 Networks</h6> | ||||
|       <a [href]="env.LIQUID_WEBSITE_URL + urlLanguage + (networkPaths['liquid'] || '')" ngbDropdownItem *ngIf="env.LIQUID_ENABLED" class="liquid" [class.active]="network.val === 'liquid'"><app-svg-images name="liquid" width="22" height="22" viewBox="0 0 125 125" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Liquid</a> | ||||
|       <a [href]="env.LIQUID_WEBSITE_URL + urlLanguage  + (networkPaths['liquidtestnet'] || '/testnet')" ngbDropdownItem *ngIf="env.LIQUID_TESTNET_ENABLED" class="liquidtestnet" [class.active]="network.val === 'liquid'"><app-svg-images name="liquidtestnet" width="22" height="22" viewBox="0 0 125 125" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Liquid Testnet</a> | ||||
| @ -102,7 +102,7 @@ | ||||
|       <li class="nav-item" routerLinkActive="active" id="btn-graphs"> | ||||
|         <a class="nav-link" [routerLink]="['/graphs' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'chart-area']" [fixedWidth]="true" i18n-title="master-page.graphs" title="Graphs"></fa-icon></a> | ||||
|       </li> | ||||
|       <li class="nav-item" routerLinkActive="active" id="btn-faucet" *ngIf="stateService.env.OFFICIAL_MEMPOOL_SPACE && stateService.network === 'testnet4'"> | ||||
|       <li class="nav-item" routerLinkActive="active" id="btn-faucet" *ngIf="stateService.isMempoolSpaceBuild && stateService.env.OFFICIAL_MEMPOOL_SPACE && stateService.network === 'testnet4'"> | ||||
|         <a class="nav-link" [routerLink]="['/faucet' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'faucet-drip']" [fixedWidth]="true" i18n-title="master-page.faucet" title="Faucet"></fa-icon></a> | ||||
|       </li> | ||||
|       <li class="nav-item" routerLinkActive="active" id="btn-docs"> | ||||
|  | ||||
| @ -0,0 +1,6 @@ | ||||
| <a href="#" (click)="twitterLogin()" | ||||
|   [class]="(disabled ? 'disabled': '') + (customClass ? customClass : 'w-100 btn mt-1 d-flex justify-content-center align-items-center')" | ||||
|   style="background-color: #1DA1F2" [style]="width ? 'width: ' + width : ''"> | ||||
|   <img src="./resources/twitter.svg" height="25" style="padding: 2px" [alt]="buttonString + ' with Twitter'" /> | ||||
|   <span class="ml-2 text-light align-middle">{{ buttonString }}</span> | ||||
| </a> | ||||
| @ -0,0 +1,25 @@ | ||||
| import { Component, EventEmitter, Input, Output } from '@angular/core'; | ||||
| @Component({ | ||||
|   selector: 'app-twitter-login', | ||||
|   templateUrl: './twitter-login.component.html', | ||||
| }) | ||||
| export class TwitterLogin { | ||||
|   @Input() width: string | null = null; | ||||
|   @Input() customClass: string | null = null; | ||||
|   @Input() buttonString: string= 'unset'; | ||||
|   @Input() redirectTo: string | null = null; | ||||
|   @Output() clicked = new EventEmitter<boolean>(); | ||||
|   @Input() disabled: boolean = false; | ||||
| 
 | ||||
|   constructor() {} | ||||
| 
 | ||||
|   twitterLogin() { | ||||
|     this.clicked.emit(true); | ||||
|     if (this.redirectTo) { | ||||
|       location.replace(`/api/v1/services/auth/login/twitter?redirectTo=${encodeURIComponent(this.redirectTo)}`); | ||||
|     } else { | ||||
|       location.replace(`/api/v1/services/auth/login/twitter?redirectTo=${location.href}`); | ||||
|     } | ||||
|     return false; | ||||
|   } | ||||
| } | ||||
| @ -105,6 +105,7 @@ if (window['__env']?.OFFICIAL_MEMPOOL_SPACE) { | ||||
|     data: { networks: ['bitcoin', 'liquid'] }, | ||||
|     component: ServerStatusComponent | ||||
|   }); | ||||
|   if (window['isMempoolSpaceBuild']) { | ||||
|     routes[0].children.push({ | ||||
|       path: 'faucet', | ||||
|       canActivate: [(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { | ||||
| @ -119,6 +120,7 @@ if (window['__env']?.OFFICIAL_MEMPOOL_SPACE) { | ||||
|       }] | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @NgModule({ | ||||
|   imports: [ | ||||
|  | ||||
| @ -161,7 +161,7 @@ export class ServicesApiServices { | ||||
|   } | ||||
| 
 | ||||
|   getFaucetStatus$() { | ||||
|     return this.httpClient.get<{ address?: string, access: boolean, min: number, user_max: number, user_requests: number }>(`${SERVICES_API_PREFIX}/testnet4/faucet/status`, { responseType: 'json' }); | ||||
|     return this.httpClient.get<{ address?: string, min: number, max: number, code: 'ok' | 'faucet_not_available' | 'faucet_maximum_reached' | 'faucet_too_soon'}>(`${SERVICES_API_PREFIX}/testnet4/faucet/status`, { responseType: 'json' }); | ||||
|   } | ||||
| 
 | ||||
|   requestTestnet4Coins$(address: string, sats: number) { | ||||
|  | ||||
| @ -2,6 +2,7 @@ import { Component, Input, OnInit } from "@angular/core"; | ||||
| import { DomSanitizer, SafeHtml } from "@angular/platform-browser"; | ||||
| 
 | ||||
| const MempoolErrors = { | ||||
|   'bad_request': `Your request was not valid. Please try again.`, | ||||
|   'internal_server_error': `Something went wrong, please try again later`, | ||||
|   'acceleration_duplicated': `This transaction has already been accelerated.`, | ||||
|   'acceleration_outbid': `Your fee delta is too low.`, | ||||
| @ -22,6 +23,12 @@ const MempoolErrors = { | ||||
|   'waitlisted': `You are currently on the wait list. You will get notified once you are granted access.`,   | ||||
|   'not_whitelisted_by_any_pool': `You are not whitelisted by any mining pool`, | ||||
|   'unauthorized': `You are not authorized to do this`, | ||||
|   'faucet_too_soon': `You cannot request any more coins right now. Try again later.`, | ||||
|   'faucet_not_available': `The faucet is not available right now. Try again later.`, | ||||
|   'faucet_maximum_reached': `You are not allowed to request more coins`, | ||||
|   'faucet_address_not_allowed': `You cannot use this address`, | ||||
|   'faucet_below_minimum': `Requested amount is too small`, | ||||
|   'faucet_above_maximum': `Requested amount is too high`, | ||||
| } as { [error: string]: string }; | ||||
| 
 | ||||
| export function isMempoolError(error: string) { | ||||
|  | ||||
| @ -115,6 +115,7 @@ import { BitcoinsatoshisPipe } from '../shared/pipes/bitcoinsatoshis.pipe'; | ||||
| import { HttpErrorComponent } from '../shared/components/http-error/http-error.component'; | ||||
| import { TwitterWidgetComponent } from '../components/twitter-widget/twitter-widget.component'; | ||||
| import { FaucetComponent } from '../components/faucet/faucet.component'; | ||||
| import { TwitterLogin } from '../components/twitter-login/twitter-login.component'; | ||||
| 
 | ||||
| import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-directives/weight-directives'; | ||||
| 
 | ||||
| @ -230,6 +231,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir | ||||
|     HttpErrorComponent, | ||||
|     TwitterWidgetComponent, | ||||
|     FaucetComponent, | ||||
|     TwitterLogin, | ||||
|   ], | ||||
|   imports: [ | ||||
|     CommonModule, | ||||
| @ -359,6 +361,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir | ||||
|     PendingStatsComponent, | ||||
|     HttpErrorComponent, | ||||
|     TwitterWidgetComponent, | ||||
|     TwitterLogin, | ||||
| 
 | ||||
|     MempoolBlockOverviewComponent, | ||||
|     ClockchainComponent, | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user