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..1030087b1 --- /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) { +

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 {{ status.address }} 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..084168ca4 --- /dev/null +++ b/frontend/src/app/components/faucet/faucet.component.scss @@ -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; +} \ 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..98d7a0c57 --- /dev/null +++ b/frontend/src/app/components/faucet/faucet.component.ts @@ -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) + } +} 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 169dd24a3..4b431138c 100644 --- a/frontend/src/app/components/master-page/master-page.component.html +++ b/frontend/src/app/components/master-page/master-page.component.html @@ -102,6 +102,9 @@ + 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..aec4be089 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<{ 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' }); + } } diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 0e882d12a..89d62b375 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -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); } }