From ef5c8ddcdffa90aea4f8705b324980c6acb2e163 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 16 May 2024 07:35:55 +0000 Subject: [PATCH] Add testnet4 faucet --- .../components/faucet/faucet.component.html | 91 ++++++++++++ .../components/faucet/faucet.component.scss | 38 +++++ .../app/components/faucet/faucet.component.ts | 135 ++++++++++++++++++ frontend/src/app/master-page.module.ts | 16 ++- .../src/app/services/services-api.service.ts | 8 ++ frontend/src/app/services/state.service.ts | 2 + frontend/src/app/shared/shared.module.ts | 2 + 7 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/components/faucet/faucet.component.html create mode 100644 frontend/src/app/components/faucet/faucet.component.scss create mode 100644 frontend/src/app/components/faucet/faucet.component.ts diff --git a/frontend/src/app/components/faucet/faucet.component.html b/frontend/src/app/components/faucet/faucet.component.html new file mode 100644 index 000000000..f1787505b --- /dev/null +++ b/frontend/src/app/components/faucet/faucet.component.html @@ -0,0 +1,91 @@ +
+ +
+

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) { + + } @else if (loading || !status) { +

Waiting for faucet...

+
+ } @else { +
+
+
+
+
+
+ Amount (sats) +
+ +
+ + + +
+
+
+
Amount is required
+
Minimum is {{ amount?.errors?.['min'].min }}
+
Maximum is {{ amount?.errors?.['max'].max }}
+
+
+
+ Address +
+ + +
+
+ @if (address?.errors?.['required']) { +
Address is required
+ } @else { +
Must be a valid testnet4 address
+ } +
+
+
Too many requests! Try again later.
+
+
+
+
+
+ @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 {{ recycleAddress }} to replenish the faucet. +
+
+
diff --git a/frontend/src/app/components/faucet/faucet.component.scss b/frontend/src/app/components/faucet/faucet.component.scss new file mode 100644 index 000000000..a8cf295f3 --- /dev/null +++ b/frontend/src/app/components/faucet/faucet.component.scss @@ -0,0 +1,38 @@ +.formGroup { + width: 100%; +} + +.input-group { + display: flex; + flex-wrap: wrap; + align-items: stretch; + justify-content: flex-end; + row-gap: 0.5rem; + + .form-control { + min-width: 200px; + } + + .button-group { + display: flex; + align-items: stretch; + } + + #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; +} \ No newline at end of file diff --git a/frontend/src/app/components/faucet/faucet.component.ts b/frontend/src/app/components/faucet/faucet.component.ts new file mode 100644 index 000000000..d021a1427 --- /dev/null +++ b/frontend/src/app/components/faucet/faucet.component.ts @@ -0,0 +1,135 @@ +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: { + access: boolean + min: number, + user_max: number, + user_requests: number, + } | null = null; + error = ''; + faucetForm: FormGroup; + txid = ''; + recycleAddress = this.stateService.env.TESTNET4_FAUCET_ADDRESS || 'tb1q548z58kqvwyjqwy8vc2ntmg33d7s2wyfv7ukq4'; + + 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; + console.log(this.user); + if (this.user) { + this.servicesApiService.getFaucetStatus$().subscribe(status => { + this.status = status; + this.initForm(this.status.min, this.status.user_max); + }) + } else { + this.status = { + access: false, + min: 5000, + user_max: 500000, + user_requests: 1, + } + this.initForm(5000, 500000); + } + + 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?.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) + } +} diff --git a/frontend/src/app/master-page.module.ts b/frontend/src/app/master-page.module.ts index 2d3c34a56..68a902e56 100644 --- a/frontend/src/app/master-page.module.ts +++ b/frontend/src/app/master-page.module.ts @@ -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({ diff --git a/frontend/src/app/services/services-api.service.ts b/frontend/src/app/services/services-api.service.ts index 89ea9a603..f0e68ca53 100644 --- a/frontend/src/app/services/services-api.service.ts +++ b/frontend/src/app/services/services-api.service.ts @@ -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<{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}?sats=${sats}`, { responseType: 'json' }); + } } diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index 742ca7ab1..dccb34b79 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -72,6 +72,7 @@ export interface Env { ADDITIONAL_CURRENCIES: boolean; GIT_COMMIT_HASH_MEMPOOL_SPACE?: string; PACKAGE_JSON_VERSION_MEMPOOL_SPACE?: string; + TESTNET4_FAUCET_ADDRESS: string; customize?: Customization; } @@ -104,6 +105,7 @@ const defaultEnv: Env = { 'ACCELERATOR': false, 'PUBLIC_ACCELERATIONS': false, 'ADDITIONAL_CURRENCIES': false, + 'TESTNET4_FAUCET_ADDRESS': '', }; @Injectable({ diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 0e882d12a..ff8fb9043 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -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,