diff --git a/packages/faucet/src/api/requestparser.ts b/packages/faucet/src/api/requestparser.ts index 77dda0f7..1455b05f 100644 --- a/packages/faucet/src/api/requestparser.ts +++ b/packages/faucet/src/api/requestparser.ts @@ -2,20 +2,35 @@ import { isNonNullObject } from "@cosmjs/utils"; import { HttpError } from "./httperror"; -export interface CreditRequestBodyData { +export interface CreditRequestBodyDataWithDenom { + /** The base denomination */ + readonly denom: string; + /** The recipient address */ + readonly address: string; +} + +export interface CreditRequestBodyDataWithTicker { /** The ticker symbol */ readonly ticker: string; /** The recipient address */ readonly address: string; } +export type CreditRequestBodyData = CreditRequestBodyDataWithDenom | CreditRequestBodyDataWithTicker; + +export function isCreditRequestBodyDataWithDenom( + data: CreditRequestBodyData, +): data is CreditRequestBodyDataWithDenom { + return typeof (data as CreditRequestBodyDataWithDenom).denom === "string"; +} + export class RequestParser { public static parseCreditBody(body: unknown): CreditRequestBodyData { if (!isNonNullObject(body) || Array.isArray(body)) { throw new HttpError(400, "Request body must be a dictionary."); } - const { address, ticker } = body as any; + const { address, denom, ticker } = body as any; if (typeof address !== "string") { throw new HttpError(400, "Property 'address' must be a string."); @@ -25,17 +40,29 @@ export class RequestParser { throw new HttpError(400, "Property 'address' must not be empty."); } - if (typeof ticker !== "string") { - throw new HttpError(400, "Property 'ticker' must be a string"); + if ( + (typeof denom !== "string" && typeof ticker !== "string") || + (typeof denom === "string" && typeof ticker === "string") + ) { + throw new HttpError(400, "Exactly one of properties 'denom' or 'ticker' must be a string"); } - if (ticker.length === 0) { + if (typeof ticker === "string" && ticker.length === 0) { throw new HttpError(400, "Property 'ticker' must not be empty."); } - return { - address: address, - ticker: ticker, - }; + if (typeof denom === "string" && denom.length === 0) { + throw new HttpError(400, "Property 'denom' must not be empty."); + } + + return denom + ? { + address: address, + denom: denom, + } + : { + address: address, + ticker: ticker, + }; } } diff --git a/packages/faucet/src/api/webserver.ts b/packages/faucet/src/api/webserver.ts index 12f4ccfc..474501eb 100644 --- a/packages/faucet/src/api/webserver.ts +++ b/packages/faucet/src/api/webserver.ts @@ -6,7 +6,7 @@ import { isValidAddress } from "../addresses"; import * as constants from "../constants"; import { Faucet } from "../faucet"; import { HttpError } from "./httperror"; -import { RequestParser } from "./requestparser"; +import { isCreditRequestBodyDataWithDenom, RequestParser } from "./requestparser"; /** This will be passed 1:1 to the user */ export interface ChainConstants { @@ -57,20 +57,32 @@ export class Webserver { // context.request.body is set by the bodyParser() plugin const requestBody = context.request.body; - const { address, ticker } = RequestParser.parseCreditBody(requestBody); + const creditBody = RequestParser.parseCreditBody(requestBody); + + const { address } = creditBody; + let denom: string | undefined; + let ticker: string | undefined; + if (isCreditRequestBodyDataWithDenom(creditBody)) { + ({ denom } = creditBody); + } else { + ({ ticker } = creditBody); + } if (!isValidAddress(address, constants.addressPrefix)) { throw new HttpError(400, "Address is not in the expected format for this chain."); } const availableTokens = await faucet.availableTokens(); - if (availableTokens.indexOf(ticker) === -1) { + const matchingToken = availableTokens.find( + (token) => token.denom === denom || token.tickerSymbol === ticker, + ); + if (matchingToken === undefined) { const tokens = JSON.stringify(availableTokens); throw new HttpError(422, `Token is not available. Available tokens are: ${tokens}`); } try { - await faucet.credit(address, ticker); + await faucet.credit(address, matchingToken.denom); } catch (e) { console.error(e); throw new HttpError(500, "Sending tokens failed"); diff --git a/packages/faucet/src/debugging.ts b/packages/faucet/src/debugging.ts index 9f6bec7d..b54bae26 100644 --- a/packages/faucet/src/debugging.ts +++ b/packages/faucet/src/debugging.ts @@ -1,5 +1,4 @@ import { Coin } from "@cosmjs/launchpad"; -import { Decimal } from "@cosmjs/math"; import { TokenConfiguration } from "./tokenmanager"; import { MinimalAccount, SendJob } from "./types"; @@ -8,8 +7,7 @@ import { MinimalAccount, SendJob } from "./types"; function debugCoin(coin: Coin, tokens: TokenConfiguration): string { const meta = tokens.bankTokens.find((token) => token.denom == coin.denom); if (!meta) throw new Error(`No token configuration found for denom ${coin.denom}`); - const value = Decimal.fromAtomics(coin.amount, meta.fractionalDigits).toString(); - return `${value} ${meta?.tickerSymbol}`; + return `${coin.amount} ${meta?.denom}`; } /** A string representation of a balance in a human-readable format that can change at any time */ diff --git a/packages/faucet/src/faucet.ts b/packages/faucet/src/faucet.ts index 910b6569..f4521010 100644 --- a/packages/faucet/src/faucet.ts +++ b/packages/faucet/src/faucet.ts @@ -10,6 +10,7 @@ import * as constants from "./constants"; import { debugAccount, logAccountsState, logSendJob } from "./debugging"; import { createWallets } from "./profile"; import { TokenConfiguration, TokenManager } from "./tokenmanager"; +import { BankTokenMeta } from "./tokens"; import { MinimalAccount, SendJob } from "./types"; function isDefined(value: X | undefined): value is X { @@ -74,15 +75,14 @@ export class Faucet { /** * Returns a list of ticker symbols of tokens owned by the the holder and configured in the faucet */ - public async availableTokens(): Promise { + public async availableTokens(): Promise { const holderAccount = await this.readOnlyClient.getAccount(this.holderAddress); const balance = holderAccount ? holderAccount.balance : []; return balance .filter((b) => b.amount !== "0") .map((b) => this.tokenConfig.bankTokens.find((token) => token.denom == b.denom)) - .filter(isDefined) - .map((token) => token.tickerSymbol); + .filter(isDefined); } /** @@ -94,14 +94,14 @@ export class Faucet { assertIsBroadcastTxSuccess(result); } - /** Use one of the distributor accounts to send tokend to user */ - public async credit(recipient: string, tickerSymbol: string): Promise { + /** Use one of the distributor accounts to send tokens to user */ + public async credit(recipient: string, denom: string): Promise { if (this.distributorAddresses.length === 0) throw new Error("No distributor account available"); const sender = this.distributorAddresses[this.getCreditCount() % this.distributorAddresses.length]; const job: SendJob = { sender: sender, recipient: recipient, - amount: this.tokenManager.creditAmount(tickerSymbol), + amount: this.tokenManager.creditAmount(denom), }; if (this.logging) logSendJob(job, this.tokenConfig); await this.send(job); @@ -141,17 +141,17 @@ export class Faucet { if (this.logging) logAccountsState(accounts, this.tokenConfig); const [_, ...distributorAccounts] = accounts; - const availableTokens = await this.availableTokens(); - if (this.logging) console.info("Available tokens:", availableTokens); + const availableTokenDenoms = (await this.availableTokens()).map((token) => token.denom); + if (this.logging) console.info("Available tokens:", availableTokenDenoms); const jobs: SendJob[] = []; - for (const tickerSymbol of availableTokens) { + for (const denom of availableTokenDenoms) { const refillDistibutors = distributorAccounts.filter((account) => - this.tokenManager.needsRefill(account, tickerSymbol), + this.tokenManager.needsRefill(account, denom), ); if (this.logging) { - console.info(`Refilling ${tickerSymbol} of:`); + console.info(`Refilling ${denom} of:`); console.info( refillDistibutors.length ? refillDistibutors.map((r) => ` ${debugAccount(r, this.tokenConfig)}`).join("\n") @@ -162,7 +162,7 @@ export class Faucet { jobs.push({ sender: this.holderAddress, recipient: refillDistibutor.address, - amount: this.tokenManager.refillAmount(tickerSymbol), + amount: this.tokenManager.refillAmount(denom), }); } } diff --git a/packages/faucet/src/tokenmanager.ts b/packages/faucet/src/tokenmanager.ts index cb4c8262..c15a785e 100644 --- a/packages/faucet/src/tokenmanager.ts +++ b/packages/faucet/src/tokenmanager.ts @@ -4,6 +4,8 @@ import { Decimal, Uint53 } from "@cosmjs/math"; import { BankTokenMeta } from "./tokens"; import { MinimalAccount } from "./types"; +const defaultCreditAmount = 10_000_000; + /** Send `factor` times credit amount on refilling */ const defaultRefillFactor = 20; @@ -23,46 +25,52 @@ export class TokenManager { } /** The amount of tokens that will be sent to the user */ - public creditAmount(tickerSymbol: string, factor: Uint53 = new Uint53(1)): Coin { - const amountFromEnv = process.env[`FAUCET_CREDIT_AMOUNT_${tickerSymbol}`]; - const amount = amountFromEnv ? Uint53.fromString(amountFromEnv).toNumber() : 10; + public creditAmount(denom: string, factor: Uint53 = new Uint53(1)): Coin { + const amountFromEnv = process.env[`FAUCET_CREDIT_AMOUNT_${denom.toUpperCase()}`]; + const amount = amountFromEnv ? Uint53.fromString(amountFromEnv).toNumber() : defaultCreditAmount; const value = new Uint53(amount * factor.toNumber()); - const meta = this.getTokenMeta(tickerSymbol); + const meta = this.getTokenMetaForDenom(denom); return { - amount: value.toString() + "0".repeat(meta.fractionalDigits), + amount: value.toString(), denom: meta.denom, }; } - public refillAmount(tickerSymbol: string): Coin { + public refillAmount(denom: string): Coin { const factorFromEnv = Number.parseInt(process.env.FAUCET_REFILL_FACTOR || "0", 10) || undefined; const factor = new Uint53(factorFromEnv || defaultRefillFactor); - return this.creditAmount(tickerSymbol, factor); + return this.creditAmount(denom, factor); } - public refillThreshold(tickerSymbol: string): Coin { + public refillThreshold(denom: string): Coin { const factorFromEnv = Number.parseInt(process.env.FAUCET_REFILL_THRESHOLD || "0", 10) || undefined; const factor = new Uint53(factorFromEnv || defaultRefillThresholdFactor); - return this.creditAmount(tickerSymbol, factor); + return this.creditAmount(denom, factor); } /** true iff the distributor account needs a refill */ - public needsRefill(account: MinimalAccount, tickerSymbol: string): boolean { - const meta = this.getTokenMeta(tickerSymbol); + public needsRefill(account: MinimalAccount, denom: string): boolean { + const meta = this.getTokenMetaForDenom(denom); const balanceAmount = account.balance.find((b) => b.denom === meta.denom); const balance = Decimal.fromAtomics(balanceAmount ? balanceAmount.amount : "0", meta.fractionalDigits); - const thresholdAmount = this.refillThreshold(tickerSymbol); + const thresholdAmount = this.refillThreshold(denom); const threshold = Decimal.fromAtomics(thresholdAmount.amount, meta.fractionalDigits); return balance.isLessThan(threshold); } - private getTokenMeta(tickerSymbol: string): BankTokenMeta { - const match = this.config.bankTokens.find((token) => token.tickerSymbol === tickerSymbol); - if (!match) throw new Error(`No token found for ticker symbol: ${tickerSymbol}`); + private getTokenMetaForDenom(denom: string): BankTokenMeta { + const match = this.config.bankTokens.find((token) => token.denom === denom); + if (!match) throw new Error(`No token found for denom: ${denom}`); + return match; + } + + private getTokenMetaForTicker(ticker: string): BankTokenMeta { + const match = this.config.bankTokens.find((token) => token.tickerSymbol === ticker); + if (!match) throw new Error(`No token found for ticker: ${ticker}`); return match; } }