Merge pull request #5075 from mempool/mononaut/testnet4-faucet

Faucet
This commit is contained in:
wiz 2024-05-17 17:40:24 +09:00 committed by GitHub
commit e1c47fddee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 303 additions and 2 deletions

View 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>

View 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;
}

View 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)
}
}

View file

@ -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>

View file

@ -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({

View file

@ -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' });
}
}

View file

@ -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);
}
}