commit
						e1c47fddee
					
				
							
								
								
									
										91
									
								
								frontend/src/app/components/faucet/faucet.component.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								frontend/src/app/components/faucet/faucet.component.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,91 @@ | ||||
| <div class="container-xl"> | ||||
| 
 | ||||
|   <div class="title-block justify-content-center"> | ||||
|     <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> | ||||
|   } | ||||
| 
 | ||||
|   <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()"> | ||||
|         <div class="row"> | ||||
|           <div class="col"> | ||||
|             <div class="form-group"> | ||||
|               <div class="input-group input-group-lg mb-2"> | ||||
|                 <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"> | ||||
|                 <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> | ||||
|                   <button type="button" class="btn btn-secondary ml-2" (click)="setAmount(500000)">500k</button> | ||||
|                 </div> | ||||
|               </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> | ||||
|               <div class="input-group input-group-lg mb-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> | ||||
|               </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> | ||||
|             </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> | ||||
|       } | ||||
|     } | ||||
|     <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> | ||||
							
								
								
									
										44
									
								
								frontend/src/app/components/faucet/faucet.component.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								frontend/src/app/components/faucet/faucet.component.scss
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,44 @@ | ||||
| .formGroup { | ||||
|   width: 100%; | ||||
| } | ||||
| 
 | ||||
| .input-group { | ||||
|   display: flex; | ||||
|   flex-wrap: wrap; | ||||
|   align-items: stretch; | ||||
|   justify-content: flex-end; | ||||
|   row-gap: 0.5rem; | ||||
|   gap: 0.5rem; | ||||
|    | ||||
|   .form-control { | ||||
|     min-width: 160px; | ||||
|     flex-grow: 100; | ||||
|   } | ||||
| 
 | ||||
|   .button-group { | ||||
|     display: flex; | ||||
|     align-items: stretch; | ||||
|   } | ||||
| 
 | ||||
|   .submit-button, .button-group, .button-group .btn { | ||||
|     flex-grow: 1; | ||||
|   } | ||||
| 
 | ||||
|   #satoshis::after { | ||||
|     content: 'sats'; | ||||
|     position: absolute; | ||||
|     right: 0.5em; | ||||
|     top: 0; | ||||
|     bottom: 0; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .faucet-container { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   align-items: center; | ||||
|   justify-content: flex-start; | ||||
|   width: 100%; | ||||
|   max-width: 800px; | ||||
|   margin: auto; | ||||
| } | ||||
							
								
								
									
										138
									
								
								frontend/src/app/components/faucet/faucet.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								frontend/src/app/components/faucet/faucet.component.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,138 @@ | ||||
| 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 { getRegex } from "../../shared/regex.utils"; | ||||
| import { WebsocketService } from "../../services/websocket.service"; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-faucet', | ||||
|   templateUrl: './faucet.component.html', | ||||
|   styleUrls: ['./faucet.component.scss'] | ||||
| }) | ||||
| export class FaucetComponent implements OnInit, OnDestroy { | ||||
|   user: any; | ||||
|   loading: boolean = true; | ||||
|   status: { | ||||
|     address?: string, | ||||
|     access: boolean | ||||
|     min: number, | ||||
|     user_max: number, | ||||
|     user_requests: number, | ||||
|   } | null = null; | ||||
|   error = ''; | ||||
|   faucetForm: FormGroup; | ||||
|   txid = ''; | ||||
| 
 | ||||
|   mempoolPositionSubscription: Subscription; | ||||
|   confirmationSubscription: Subscription; | ||||
| 
 | ||||
|   constructor( | ||||
|     private stateService: StateService, | ||||
|     private storageService: StorageService, | ||||
|     private servicesApiService: ServicesApiServices, | ||||
|     private websocketService: WebsocketService, | ||||
|     private audioService: AudioService, | ||||
|     private formBuilder: FormBuilder, | ||||
|   ) { | ||||
|   } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     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 { | ||||
|       this.loading = false; | ||||
|     } | ||||
| 
 | ||||
|     this.websocketService.want(['blocks', 'mempool-blocks']); | ||||
|     this.mempoolPositionSubscription = this.stateService.mempoolTxPosition$.subscribe(txPosition => { | ||||
|       if (txPosition && txPosition.txid === this.txid) { | ||||
|         this.stateService.markBlock$.next({ | ||||
|           txid: txPosition.txid, | ||||
|           mempoolPosition: txPosition.position, | ||||
|         }); | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     this.confirmationSubscription = this.stateService.txConfirmed$.subscribe(([txConfirmed, block]) => { | ||||
|       if (txConfirmed && txConfirmed === this.txid) { | ||||
|         this.stateService.markBlock$.next({ blockHeight: block.height }); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   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; | ||||
|   } | ||||
| 
 | ||||
|   setAmount(value: number): void { | ||||
|     if (this.faucetForm) { | ||||
|       this.faucetForm.get('satoshis').setValue(value); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   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 invalidAddress() { | ||||
|     const address = this.faucetForm.get('address')!; | ||||
|     return address?.invalid && (address.dirty || address.touched) | ||||
|   } | ||||
| } | ||||
| @ -102,6 +102,9 @@ | ||||
|       <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'"> | ||||
|         <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"> | ||||
|         <a class="nav-link" [routerLink]="['/docs' | relativeUrl ]" (click)="collapse()"><fa-icon [icon]="['fas', 'book']" [fixedWidth]="true" i18n-title="documentation.title" title="Documentation"></fa-icon></a> | ||||
|       </li> | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| import { NgModule } from '@angular/core'; | ||||
| import { CommonModule } from '@angular/common'; | ||||
| import { Routes, RouterModule } from '@angular/router'; | ||||
| import { Routes, RouterModule, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; | ||||
| import { MasterPageComponent } from './components/master-page/master-page.component'; | ||||
| import { SharedModule } from './shared/shared.module'; | ||||
| 
 | ||||
| @ -12,6 +12,7 @@ import { BlocksList } from './components/blocks-list/blocks-list.component'; | ||||
| import { RbfList } from './components/rbf-list/rbf-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' | ||||
| 
 | ||||
| const browserWindow = window || {}; | ||||
| // @ts-ignore
 | ||||
| @ -104,6 +105,19 @@ if (window['__env']?.OFFICIAL_MEMPOOL_SPACE) { | ||||
|     data: { networks: ['bitcoin', 'liquid'] }, | ||||
|     component: ServerStatusComponent | ||||
|   }); | ||||
|   routes[0].children.push({ | ||||
|     path: 'faucet', | ||||
|     canActivate: [(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { | ||||
|       return state.url.startsWith('/testnet4/'); | ||||
|     }], | ||||
|     component: StartComponent, | ||||
|     data: { preload: true, networkSpecific: true }, | ||||
|     children: [{ | ||||
|       path: '', | ||||
|       data: { networks: ['bitcoin'] }, | ||||
|       component: FaucetComponent, | ||||
|     }] | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| @NgModule({ | ||||
|  | ||||
| @ -159,4 +159,12 @@ export class ServicesApiServices { | ||||
|   setupSquare$(): Observable<{squareAppId: string, squareLocationId: string}> { | ||||
|     return this.httpClient.get<{squareAppId: string, squareLocationId: string}>(`${SERVICES_API_PREFIX}/square/setup`); | ||||
|   } | ||||
| 
 | ||||
|   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' }); | ||||
|   } | ||||
| 
 | ||||
|   requestTestnet4Coins$(address: string, sats: number) { | ||||
|     return this.httpClient.get<{txid: string}>(`${SERVICES_API_PREFIX}/testnet4/faucet/request?address=${address}&sats=${sats}`, { responseType: 'json' }); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -4,7 +4,7 @@ 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 } 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 } 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'; | ||||
| @ -114,6 +114,7 @@ import { CalculatorComponent } from '../components/calculator/calculator.compone | ||||
| 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 { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-directives/weight-directives'; | ||||
| 
 | ||||
| @ -228,6 +229,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir | ||||
|     PendingStatsComponent, | ||||
|     HttpErrorComponent, | ||||
|     TwitterWidgetComponent, | ||||
|     FaucetComponent, | ||||
|   ], | ||||
|   imports: [ | ||||
|     CommonModule, | ||||
| @ -432,5 +434,6 @@ export class SharedModule { | ||||
|     library.addIcons(faHourglassHalf); | ||||
|     library.addIcons(faHourglassEnd); | ||||
|     library.addIcons(faWandMagicSparkles); | ||||
|     library.addIcons(faFaucetDrip); | ||||
|   } | ||||
| } | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user