diff --git a/.eslintrc.js b/.eslintrc.js index 52a62083..52166b4d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -25,6 +25,7 @@ module.exports = { "no-param-reassign": "warn", "no-shadow": "warn", "prefer-const": "warn", + "radix": ["warn", "always"], "spaced-comment": ["warn", "always", { line: { markers: ["/ ): Promise { if (args.length < 1) { @@ -17,6 +16,6 @@ export async function generate(args: ReadonlyArray): Promise { const mnemonic = Bip39.encode(Random.getBytes(16)).toString(); console.info(`FAUCET_MNEMONIC="${mnemonic}"`); - const profile = new UserProfile(); - await setSecretAndCreateIdentities(profile, mnemonic, chainId); + // Log the addresses + await createUserProfile(mnemonic, chainId, constants.concurrency, true); } diff --git a/packages/faucet/src/actions/start/start.ts b/packages/faucet/src/actions/start/start.ts index 45cc23c6..408203f5 100644 --- a/packages/faucet/src/actions/start/start.ts +++ b/packages/faucet/src/actions/start/start.ts @@ -1,31 +1,11 @@ -import { UserProfile } from "@iov/keycontrol"; -import cors = require("@koa/cors"); -import Koa from "koa"; -import bodyParser from "koa-bodyparser"; +import { createCosmWasmConnector } from "@cosmwasm/bcp"; -import { creditAmount, setFractionalDigits } from "../../cashflow"; -import { codecDefaultFractionalDigits, codecImplementation, establishConnection } from "../../codec"; +import { Webserver } from "../../api/webserver"; import * as constants from "../../constants"; -import { logAccountsState, logSendJob } from "../../debugging"; -import { - availableTokensFromHolder, - identitiesOfFirstWallet, - loadAccounts, - loadTokenTickers, - refill, - send, -} from "../../multichainhelpers"; -import { setSecretAndCreateIdentities } from "../../profile"; -import { SendJob } from "../../types"; -import { HttpError } from "./httperror"; -import { RequestParser } from "./requestparser"; - -let count = 0; - -/** returns an integer >= 0 that increments and is unique in module scope */ -function getCount(): number { - return count++; -} +import { logAccountsState } from "../../debugging"; +import { Faucet } from "../../faucet"; +import { availableTokensFromHolder } from "../../multichainhelpers"; +import { createUserProfile } from "../../profile"; export async function start(args: ReadonlyArray): Promise { if (args.length < 1) { @@ -34,116 +14,44 @@ export async function start(args: ReadonlyArray): Promise { ); } + // Connection const blockchainBaseUrl = args[0]; - - const port = constants.port; - - const profile = new UserProfile(); - if (!constants.mnemonic) { - throw new Error("The FAUCET_MNEMONIC environment variable is not set"); - } + const connector = createCosmWasmConnector( + blockchainBaseUrl, + constants.addressPrefix, + constants.tokenConfig, + ); console.info(`Connecting to blockchain ${blockchainBaseUrl} ...`); - const connection = await establishConnection(blockchainBaseUrl); + const connection = await connector.establishConnection(); + console.info(`Connected to network: ${connection.chainId()}`); - const connectedChainId = connection.chainId(); - console.info(`Connected to network: ${connectedChainId}`); + // Profile + if (!constants.mnemonic) throw new Error("The FAUCET_MNEMONIC environment variable is not set"); + const [profile] = await createUserProfile( + constants.mnemonic, + connection.chainId(), + constants.concurrency, + true, + ); - setFractionalDigits(codecDefaultFractionalDigits()); - await setSecretAndCreateIdentities(profile, constants.mnemonic, connectedChainId); - - const chainTokens = await loadTokenTickers(connection); + // Faucet + const faucet = new Faucet(constants.tokenConfig, connection, connector.codec, profile, true); + const chainTokens = await faucet.loadTokenTickers(); console.info("Chain tokens:", chainTokens); - - const accounts = await loadAccounts(profile, connection); + const accounts = await faucet.loadAccounts(); logAccountsState(accounts); - let availableTokens = availableTokensFromHolder(accounts[0]); console.info("Available tokens:", availableTokens); setInterval(async () => { - const updatedAccounts = await loadAccounts(profile, connection); + const updatedAccounts = await faucet.loadAccounts(); availableTokens = availableTokensFromHolder(updatedAccounts[0]); console.info("Available tokens:", availableTokens); }, 60_000); - const distibutorIdentities = identitiesOfFirstWallet(profile).slice(1); - - await refill(profile, connection); - setInterval(async () => refill(profile, connection), 60_000); // ever 60 seconds + await faucet.refill(); + setInterval(async () => faucet.refill(), 60_000); // ever 60 seconds console.info("Creating webserver ..."); - const api = new Koa(); - api.use(cors()); - api.use(bodyParser()); - - api.use(async context => { - switch (context.path) { - case "/": - case "/healthz": - context.response.body = - "Welcome to the faucet!\n" + - "\n" + - "Check the full status via the /status endpoint.\n" + - "You can get tokens from here by POSTing to /credit.\n" + - "See https://github.com/iov-one/iov-faucet for all further information.\n"; - break; - case "/status": { - const updatedAccounts = await loadAccounts(profile, connection); - context.response.body = { - status: "ok", - nodeUrl: blockchainBaseUrl, - chainId: connectedChainId, - chainTokens: chainTokens, - availableTokens: availableTokens, - holder: updatedAccounts[0], - distributors: updatedAccounts.slice(1), - }; - break; - } - case "/credit": { - if (context.request.method !== "POST") { - throw new HttpError(405, "This endpoint requires a POST request"); - } - - if (context.request.type !== "application/json") { - throw new HttpError(415, "Content-type application/json expected"); - } - - // context.request.body is set by the bodyParser() plugin - const requestBody = context.request.body; - const { address, ticker } = RequestParser.parseCreditBody(requestBody); - - if (!codecImplementation().isValidAddress(address)) { - throw new HttpError(400, "Address is not in the expected format for this chain."); - } - - if (availableTokens.indexOf(ticker) === -1) { - const tokens = JSON.stringify(availableTokens); - throw new HttpError(422, `Token is not available. Available tokens are: ${tokens}`); - } - - const sender = distibutorIdentities[getCount() % distibutorIdentities.length]; - - try { - const job: SendJob = { - sender: sender, - recipient: address, - amount: creditAmount(ticker), - tokenTicker: ticker, - }; - logSendJob(job); - await send(profile, connection, job); - } catch (e) { - console.error(e); - throw new HttpError(500, "Sending tokens failed"); - } - - context.response.body = "ok"; - break; - } - default: - // koa sends 404 by default - } - }); - console.info(`Starting webserver on port ${port} ...`); - api.listen(port); + const server = new Webserver(faucet, { nodeUrl: blockchainBaseUrl, chainId: connection.chainId() }); + server.start(constants.port); } diff --git a/packages/faucet/src/addresses.ts b/packages/faucet/src/addresses.ts new file mode 100644 index 00000000..2b749e62 --- /dev/null +++ b/packages/faucet/src/addresses.ts @@ -0,0 +1,17 @@ +import { CosmWasmCodec } from "@cosmwasm/bcp"; +import { Address, Identity, TxCodec } from "@iov/bcp"; + +import * as constants from "./constants"; + +const noTokensCodec: Pick = new CosmWasmCodec( + constants.addressPrefix, + [], +); + +export function identityToAddress(identity: Identity): Address { + return noTokensCodec.identityToAddress(identity); +} + +export function isValidAddress(input: string): boolean { + return noTokensCodec.isValidAddress(input); +} diff --git a/packages/faucet/src/actions/start/httperror.spec.ts b/packages/faucet/src/api/httperror.spec.ts similarity index 100% rename from packages/faucet/src/actions/start/httperror.spec.ts rename to packages/faucet/src/api/httperror.spec.ts diff --git a/packages/faucet/src/actions/start/httperror.ts b/packages/faucet/src/api/httperror.ts similarity index 100% rename from packages/faucet/src/actions/start/httperror.ts rename to packages/faucet/src/api/httperror.ts diff --git a/packages/faucet/src/actions/start/requestparser.spec.ts b/packages/faucet/src/api/requestparser.spec.ts similarity index 100% rename from packages/faucet/src/actions/start/requestparser.spec.ts rename to packages/faucet/src/api/requestparser.spec.ts diff --git a/packages/faucet/src/actions/start/requestparser.ts b/packages/faucet/src/api/requestparser.ts similarity index 100% rename from packages/faucet/src/actions/start/requestparser.ts rename to packages/faucet/src/api/requestparser.ts diff --git a/packages/faucet/src/api/webserver.ts b/packages/faucet/src/api/webserver.ts new file mode 100644 index 00000000..5669ada6 --- /dev/null +++ b/packages/faucet/src/api/webserver.ts @@ -0,0 +1,94 @@ +import Koa from "koa"; +import cors = require("@koa/cors"); +import { ChainId } from "@iov/bcp"; +import bodyParser from "koa-bodyparser"; + +import { isValidAddress } from "../addresses"; +import { Faucet } from "../faucet"; +import { availableTokensFromHolder } from "../multichainhelpers"; +import { HttpError } from "./httperror"; +import { RequestParser } from "./requestparser"; + +/** This will be passed 1:1 to the user */ +export interface ChainConstants { + readonly nodeUrl: string; + readonly chainId: ChainId; +} + +export class Webserver { + private readonly api = new Koa(); + + constructor(faucet: Faucet, chainChinstants: ChainConstants) { + this.api.use(cors()); + this.api.use(bodyParser()); + + this.api.use(async context => { + switch (context.path) { + case "/": + case "/healthz": + context.response.body = + "Welcome to the faucet!\n" + + "\n" + + "Check the full status via the /status endpoint.\n" + + "You can get tokens from here by POSTing to /credit.\n" + + "See https://github.com/iov-one/iov-faucet for all further information.\n"; + break; + case "/status": { + const [holder, ...distributors] = await faucet.loadAccounts(); + const availableTokens = availableTokensFromHolder(holder); + const chainTokens = await faucet.loadTokenTickers(); + context.response.body = { + status: "ok", + ...chainChinstants, + chainTokens: chainTokens, + availableTokens: availableTokens, + holder: holder, + distributors: distributors, + }; + break; + } + case "/credit": { + if (context.request.method !== "POST") { + throw new HttpError(405, "This endpoint requires a POST request"); + } + + if (context.request.type !== "application/json") { + throw new HttpError(415, "Content-type application/json expected"); + } + + // context.request.body is set by the bodyParser() plugin + const requestBody = context.request.body; + const { address, ticker } = RequestParser.parseCreditBody(requestBody); + + if (!isValidAddress(address)) { + throw new HttpError(400, "Address is not in the expected format for this chain."); + } + + const [holder] = await faucet.loadAccounts(); + const availableTokens = availableTokensFromHolder(holder); + if (availableTokens.indexOf(ticker) === -1) { + const tokens = JSON.stringify(availableTokens); + throw new HttpError(422, `Token is not available. Available tokens are: ${tokens}`); + } + + try { + await faucet.credit(address, ticker); + } catch (e) { + console.error(e); + throw new HttpError(500, "Sending tokens failed"); + } + + context.response.body = "ok"; + break; + } + default: + // koa sends 404 by default + } + }); + } + + public start(port: number): void { + console.info(`Starting webserver on port ${port} ...`); + this.api.listen(port); + } +} diff --git a/packages/faucet/src/cashflow.spec.ts b/packages/faucet/src/cashflow.spec.ts deleted file mode 100644 index b90f6347..00000000 --- a/packages/faucet/src/cashflow.spec.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { TokenTicker } from "@iov/bcp"; - -import { creditAmount, refillAmount, refillThreshold, setFractionalDigits } from "./cashflow"; - -describe("Cashflow", () => { - beforeAll(() => { - setFractionalDigits(3); - }); - - describe("creditAmount", () => { - it("returns '10' + '000' by default", () => { - expect(creditAmount("TOKENZ" as TokenTicker)).toEqual({ - quantity: "10000", - fractionalDigits: 3, - tokenTicker: "TOKENZ", - }); - expect(creditAmount("TRASH" as TokenTicker)).toEqual({ - quantity: "10000", - fractionalDigits: 3, - tokenTicker: "TRASH", - }); - }); - - it("returns value from env variable + '000' when set", () => { - process.env.FAUCET_CREDIT_AMOUNT_WTF = "22"; - expect(creditAmount("WTF" as TokenTicker)).toEqual({ - quantity: "22000", - fractionalDigits: 3, - tokenTicker: "WTF", - }); - }); - - it("returns default from env variable + '000' when set to empty", () => { - process.env.FAUCET_CREDIT_AMOUNT_WTF = ""; - expect(creditAmount("WTF" as TokenTicker)).toEqual({ - quantity: "10000", - fractionalDigits: 3, - tokenTicker: "WTF", - }); - }); - }); - - describe("refillAmount", () => { - beforeEach(() => { - process.env.FAUCET_REFILL_FACTOR = ""; - }); - it("returns 20*10 + '000' by default", () => { - expect(refillAmount("TOKENZ" as TokenTicker)).toEqual({ - quantity: "200000", - fractionalDigits: 3, - tokenTicker: "TOKENZ", - }); - }); - - it("returns 20*22 + '000' when credit amount is 22", () => { - process.env.FAUCET_CREDIT_AMOUNT_WTF = "22"; - expect(refillAmount("WTF" as TokenTicker)).toEqual({ - quantity: "440000", - fractionalDigits: 3, - tokenTicker: "WTF", - }); - }); - - it("returns 30*10 + '000' when refill factor is 30", () => { - process.env.FAUCET_REFILL_FACTOR = "30"; - expect(refillAmount("TOKENZ" as TokenTicker)).toEqual({ - quantity: "300000", - fractionalDigits: 3, - tokenTicker: "TOKENZ", - }); - }); - - it("returns 30*22 + '000' when refill factor is 30 and credit amount is 22", () => { - process.env.FAUCET_REFILL_FACTOR = "30"; - process.env.FAUCET_CREDIT_AMOUNT_WTF = "22"; - expect(refillAmount("WTF" as TokenTicker)).toEqual({ - quantity: "660000", - fractionalDigits: 3, - tokenTicker: "WTF", - }); - }); - }); - - describe("refillThreshold", () => { - beforeEach(() => { - process.env.FAUCET_REFILL_THRESHOLD = ""; - }); - it("returns 8*10 + '000' by default", () => { - expect(refillThreshold("TOKENZ" as TokenTicker)).toEqual({ - quantity: "80000", - fractionalDigits: 3, - tokenTicker: "TOKENZ", - }); - }); - - it("returns 8*22 + '000' when credit amount is 22", () => { - process.env.FAUCET_CREDIT_AMOUNT_WTF = "22"; - expect(refillThreshold("WTF" as TokenTicker)).toEqual({ - quantity: "176000", - fractionalDigits: 3, - tokenTicker: "WTF", - }); - }); - - it("returns 5*10 + '000' when refill threshold is 5", () => { - process.env.FAUCET_REFILL_THRESHOLD = "5"; - expect(refillThreshold("TOKENZ" as TokenTicker)).toEqual({ - quantity: "50000", - fractionalDigits: 3, - tokenTicker: "TOKENZ", - }); - }); - - it("returns 5*22 + '000' when refill threshold is 5 and credit amount is 22", () => { - process.env.FAUCET_REFILL_THRESHOLD = "5"; - process.env.FAUCET_CREDIT_AMOUNT_WTF = "22"; - expect(refillThreshold("WTF" as TokenTicker)).toEqual({ - quantity: "110000", - fractionalDigits: 3, - tokenTicker: "WTF", - }); - }); - }); -}); diff --git a/packages/faucet/src/cashflow.ts b/packages/faucet/src/cashflow.ts deleted file mode 100644 index e95116fe..00000000 --- a/packages/faucet/src/cashflow.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Account, Amount, TokenTicker } from "@iov/bcp"; -import { Decimal, Uint53 } from "@iov/encoding"; - -/** Send `factor` times credit amount on refilling */ -const defaultRefillFactor = 20; - -/** refill when balance gets below `factor` times credit amount */ -const defaultRefillThresholdFactor = 8; - -// Load this from connection? -let globalFractionalDigits: number | undefined; - -export function setFractionalDigits(input: number): void { - globalFractionalDigits = input; -} - -export function getFractionalDigits(): number { - if (globalFractionalDigits === undefined) { - throw new Error("Fractional digits not set"); - } - return globalFractionalDigits; -} - -/** The amount of tokens that will be sent to the user */ -export function creditAmount(token: TokenTicker, factor: Uint53 = new Uint53(1)): Amount { - const amountFromEnv = process.env[`FAUCET_CREDIT_AMOUNT_${token}`]; - const amount = amountFromEnv ? Uint53.fromString(amountFromEnv).toNumber() : 10; - const value = new Uint53(amount * factor.toNumber()); - - const fractionalDigits = getFractionalDigits(); - return { - quantity: value.toString() + "0".repeat(fractionalDigits), - fractionalDigits: fractionalDigits, - tokenTicker: token, - }; -} - -export function refillAmount(token: TokenTicker): Amount { - const factorFromEnv = Number.parseInt(process.env.FAUCET_REFILL_FACTOR || "0", 10) || undefined; - const factor = new Uint53(factorFromEnv || defaultRefillFactor); - return creditAmount(token, factor); -} - -export function refillThreshold(token: TokenTicker): Amount { - const factorFromEnv = Number.parseInt(process.env.FAUCET_REFILL_THRESHOLD || "0", 10) || undefined; - const factor = new Uint53(factorFromEnv || defaultRefillThresholdFactor); - return creditAmount(token, factor); -} - -/** true iff the distributor account needs a refill */ -export function needsRefill(account: Account, token: TokenTicker): boolean { - const balanceAmount = account.balance.find(b => b.tokenTicker === token); - - const balance = balanceAmount - ? Decimal.fromAtomics(balanceAmount.quantity, balanceAmount.fractionalDigits) - : Decimal.fromAtomics("0", 0); - - const thresholdAmount = refillThreshold(token); - const threshold = Decimal.fromAtomics(thresholdAmount.quantity, thresholdAmount.fractionalDigits); - - // TODO: perform < operation on Decimal type directly - // https://github.com/iov-one/iov-core/issues/1375 - return balance.toFloatApproximation() < threshold.toFloatApproximation(); -} diff --git a/packages/faucet/src/cli.ts b/packages/faucet/src/cli.ts new file mode 100644 index 00000000..ff5d111c --- /dev/null +++ b/packages/faucet/src/cli.ts @@ -0,0 +1,37 @@ +import { generate, help, start, version } from "./actions"; + +export function main(args: ReadonlyArray): void { + if (args.length < 1) { + help(); + process.exit(1); + } + + const action = args[0]; + const restArgs = args.slice(1); + + switch (action) { + case "generate": + generate(restArgs).catch(error => { + console.error(error); + process.exit(1); + }); + break; + case "help": + help(); + break; + case "version": + version().catch(error => { + console.error(error); + process.exit(1); + }); + break; + case "start": + start(restArgs).catch(error => { + console.error(error); + process.exit(1); + }); + break; + default: + throw new Error("Unexpected action argument"); + } +} diff --git a/packages/faucet/src/codec.spec.ts b/packages/faucet/src/codec.spec.ts deleted file mode 100644 index 624bf357..00000000 --- a/packages/faucet/src/codec.spec.ts +++ /dev/null @@ -1 +0,0 @@ -describe("codec", () => {}); diff --git a/packages/faucet/src/codec.ts b/packages/faucet/src/codec.ts deleted file mode 100644 index f5d27e48..00000000 --- a/packages/faucet/src/codec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { CosmWasmCodec, CosmWasmConnection, TokenConfiguration } from "@cosmwasm/bcp"; -import { TxCodec } from "@iov/bcp"; - -const prefix = "cosmos"; -const config: TokenConfiguration = { - bankTokens: [ - { - fractionalDigits: 6, - name: "Fee Token", - ticker: "COSM", - denom: "cosm", - }, - { - fractionalDigits: 6, - name: "Staking Token", - ticker: "STAKE", - denom: "stake", - }, - ], -}; - -export async function establishConnection(url: string): Promise { - return CosmWasmConnection.establish(url, prefix, config); -} - -export function codecImplementation(): TxCodec { - return new CosmWasmCodec(prefix, config.bankTokens); -} - -export function codecDefaultFractionalDigits(): number { - return 6; -} diff --git a/packages/faucet/src/constants.ts b/packages/faucet/src/constants.ts index 616d1acd..bea2d710 100644 --- a/packages/faucet/src/constants.ts +++ b/packages/faucet/src/constants.ts @@ -1,4 +1,24 @@ +import { TokenConfiguration } from "@cosmwasm/bcp"; + export const binaryName = "cosmwasm-faucet"; export const concurrency: number = Number.parseInt(process.env.FAUCET_CONCURRENCY || "", 10) || 5; export const port: number = Number.parseInt(process.env.FAUCET_PORT || "", 10) || 8000; export const mnemonic: string | undefined = process.env.FAUCET_MNEMONIC; + +export const addressPrefix = "cosmos"; +export const tokenConfig: TokenConfiguration = { + bankTokens: [ + { + fractionalDigits: 6, + name: "Fee Token", + ticker: "COSM", + denom: "ucosm", + }, + { + fractionalDigits: 6, + name: "Staking Token", + ticker: "STAKE", + denom: "ustake", + }, + ], +}; diff --git a/packages/faucet/src/debugging.ts b/packages/faucet/src/debugging.ts index a5d7cf64..97a50f5f 100644 --- a/packages/faucet/src/debugging.ts +++ b/packages/faucet/src/debugging.ts @@ -1,7 +1,7 @@ import { Account, Amount } from "@iov/bcp"; import { Decimal } from "@iov/encoding"; -import { codecImplementation } from "./codec"; +import { identityToAddress } from "./addresses"; import { SendJob } from "./types"; /** A string representation of a coin in a human-readable format that can change at any time */ @@ -31,7 +31,7 @@ export function logAccountsState(accounts: ReadonlyArray): void { } export function logSendJob(job: SendJob): void { - const from = codecImplementation().identityToAddress(job.sender); + const from = identityToAddress(job.sender); const to = job.recipient; const amount = debugAmount(job.amount); console.info(`Sending ${amount} from ${from} to ${to} ...`); diff --git a/packages/faucet/src/faucet.spec.ts b/packages/faucet/src/faucet.spec.ts new file mode 100644 index 00000000..932ba7a9 --- /dev/null +++ b/packages/faucet/src/faucet.spec.ts @@ -0,0 +1,204 @@ +import { CosmWasmCodec, CosmWasmConnection, TokenConfiguration } from "@cosmwasm/bcp"; +import { CosmosAddressBech32Prefix } from "@cosmwasm/sdk"; +import { Address, ChainId, Identity, TokenTicker } from "@iov/bcp"; +import { Random } from "@iov/crypto"; +import { Bech32 } from "@iov/encoding"; +import { UserProfile } from "@iov/keycontrol"; +import { assert } from "@iov/utils"; + +import { Faucet } from "./faucet"; +import { createUserProfile } from "./profile"; + +function pendingWithoutCosmos(): void { + if (!process.env.COSMOS_ENABLED) { + return pending("Set COSMOS_ENABLED to enable Cosmos node-based tests"); + } +} + +const httpUrl = "http://localhost:1317"; +const defaultConfig: TokenConfiguration = { + bankTokens: [ + { + fractionalDigits: 6, + name: "Fee Token", + ticker: "COSM", + denom: "ucosm", + }, + { + fractionalDigits: 6, + name: "Staking Token", + ticker: "STAKE", + denom: "ustake", + }, + ], + erc20Tokens: [ + // { + // contractAddress: "cosmos18vd8fpwxzck93qlwghaj6arh4p7c5n89uzcee5", + // fractionalDigits: 5, + // ticker: "ASH", + // name: "Ash Token", + // }, + // { + // contractAddress: "cosmos1hqrdl6wstt8qzshwc6mrumpjk9338k0lr4dqxd", + // fractionalDigits: 0, + // ticker: "BASH", + // name: "Bash Token", + // }, + ], +}; +const defaultPrefix = "cosmos" as CosmosAddressBech32Prefix; +const defaultChainId = "cosmos:testing" as ChainId; +const codec = new CosmWasmCodec(defaultPrefix, defaultConfig.bankTokens); + +function makeRandomAddress(): Address { + return Bech32.encode(defaultPrefix, Random.getBytes(20)) as Address; +} + +const faucetMnemonic = + "economy stock theory fatal elder harbor betray wasp final emotion task crumble siren bottom lizard educate guess current outdoor pair theory focus wife stone"; + +async function makeProfile( + distributors = 0, +): Promise<{ readonly profile: UserProfile; readonly holder: Identity; readonly distributors: Identity[] }> { + const [profile, identities] = await createUserProfile(faucetMnemonic, defaultChainId, distributors); + return { + profile: profile, + holder: identities[0], + distributors: identities.slice(1), + }; +} + +describe("Faucet", () => { + describe("constructor", () => { + it("can be constructed", async () => { + pendingWithoutCosmos(); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); + const { profile } = await makeProfile(); + const faucet = new Faucet(defaultConfig, connection, codec, profile); + expect(faucet).toBeTruthy(); + connection.disconnect(); + }); + }); + + describe("send", () => { + it("can send", async () => { + pendingWithoutCosmos(); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); + const { profile, holder } = await makeProfile(); + const faucet = new Faucet(defaultConfig, connection, codec, profile); + const recipient = makeRandomAddress(); + await faucet.send({ + amount: { + quantity: "23456", + fractionalDigits: 6, + tokenTicker: "COSM" as TokenTicker, + }, + sender: holder, + recipient: recipient, + }); + const account = await connection.getAccount({ address: recipient }); + assert(account); + expect(account.balance).toEqual([ + { + quantity: "23456", + fractionalDigits: 6, + tokenTicker: "COSM" as TokenTicker, + }, + ]); + connection.disconnect(); + }); + }); + + describe("refill", () => { + it("works", async () => { + pendingWithoutCosmos(); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); + const { profile, distributors } = await makeProfile(1); + const faucet = new Faucet(defaultConfig, connection, codec, profile); + await faucet.refill(); + const distributorBalance = (await connection.getAccount({ pubkey: distributors[0].pubkey }))?.balance; + assert(distributorBalance); + expect(distributorBalance).toEqual([ + jasmine.objectContaining({ + tokenTicker: "COSM", + fractionalDigits: 6, + }), + jasmine.objectContaining({ + tokenTicker: "STAKE", + fractionalDigits: 6, + }), + ]); + expect(Number.parseInt(distributorBalance[0].quantity, 10)).toBeGreaterThanOrEqual(80_000000); + expect(Number.parseInt(distributorBalance[1].quantity, 10)).toBeGreaterThanOrEqual(80_000000); + connection.disconnect(); + }); + }); + + describe("credit", () => { + it("works for fee token", async () => { + pendingWithoutCosmos(); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); + const { profile } = await makeProfile(1); + const faucet = new Faucet(defaultConfig, connection, codec, profile); + const recipient = makeRandomAddress(); + await faucet.credit(recipient, "COSM" as TokenTicker); + const account = await connection.getAccount({ address: recipient }); + assert(account); + expect(account.balance).toEqual([ + { + quantity: "10000000", + fractionalDigits: 6, + tokenTicker: "COSM" as TokenTicker, + }, + ]); + connection.disconnect(); + }); + + it("works for stake token", async () => { + pendingWithoutCosmos(); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); + const { profile } = await makeProfile(1); + const faucet = new Faucet(defaultConfig, connection, codec, profile); + const recipient = makeRandomAddress(); + await faucet.credit(recipient, "STAKE" as TokenTicker); + const account = await connection.getAccount({ address: recipient }); + assert(account); + expect(account.balance).toEqual([ + { + quantity: "10000000", + fractionalDigits: 6, + tokenTicker: "STAKE" as TokenTicker, + }, + ]); + connection.disconnect(); + }); + }); + + describe("loadTokenTickers", () => { + it("works", async () => { + pendingWithoutCosmos(); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); + const { profile } = await makeProfile(); + const faucet = new Faucet(defaultConfig, connection, codec, profile); + const tickers = await faucet.loadTokenTickers(); + expect(tickers).toEqual(["COSM", "STAKE"]); + connection.disconnect(); + }); + }); + + describe("loadAccounts", () => { + it("works", async () => { + pendingWithoutCosmos(); + const connection = await CosmWasmConnection.establish(httpUrl, defaultPrefix, defaultConfig); + const { profile, holder } = await makeProfile(); + const faucet = new Faucet(defaultConfig, connection, codec, profile); + const accounts = await faucet.loadAccounts(); + const expectedHolderAccount = await connection.getAccount({ pubkey: holder.pubkey }); + assert(expectedHolderAccount); + expect(accounts).toEqual([ + { address: expectedHolderAccount.address, balance: expectedHolderAccount.balance }, + ]); + connection.disconnect(); + }); + }); +}); diff --git a/packages/faucet/src/faucet.ts b/packages/faucet/src/faucet.ts index ff5d111c..66d406b2 100644 --- a/packages/faucet/src/faucet.ts +++ b/packages/faucet/src/faucet.ts @@ -1,37 +1,169 @@ -import { generate, help, start, version } from "./actions"; +import { TokenConfiguration } from "@cosmwasm/bcp"; +import { + Account, + Address, + BlockchainConnection, + Identity, + isBlockInfoFailed, + isBlockInfoPending, + SendTransaction, + TokenTicker, + TxCodec, +} from "@iov/bcp"; +import { UserProfile } from "@iov/keycontrol"; +import { sleep } from "@iov/utils"; -export function main(args: ReadonlyArray): void { - if (args.length < 1) { - help(); - process.exit(1); +import { identityToAddress } from "./addresses"; +import { debugAccount, logAccountsState, logSendJob } from "./debugging"; +import { availableTokensFromHolder, identitiesOfFirstWallet } from "./multichainhelpers"; +import { TokenManager } from "./tokenmanager"; +import { SendJob } from "./types"; + +export class Faucet { + public get holder(): Identity { + return identitiesOfFirstWallet(this.profile)[0]; + } + public get distributors(): readonly Identity[] { + return identitiesOfFirstWallet(this.profile).slice(1); } - const action = args[0]; - const restArgs = args.slice(1); + private readonly tokenManager: TokenManager; + private readonly connection: BlockchainConnection; + private readonly codec: TxCodec; + private readonly profile: UserProfile; + private readonly logging: boolean; + private creditCount = 0; - switch (action) { - case "generate": - generate(restArgs).catch(error => { - console.error(error); - process.exit(1); - }); - break; - case "help": - help(); - break; - case "version": - version().catch(error => { - console.error(error); - process.exit(1); - }); - break; - case "start": - start(restArgs).catch(error => { - console.error(error); - process.exit(1); - }); - break; - default: - throw new Error("Unexpected action argument"); + public constructor( + config: TokenConfiguration, + connection: BlockchainConnection, + codec: TxCodec, + profile: UserProfile, + logging = false, + ) { + this.tokenManager = new TokenManager(config); + this.connection = connection; + this.codec = codec; + this.profile = profile; + this.logging = logging; + } + + /** + * Creates and posts a send transaction. Then waits until the transaction is in a block. + */ + public async send(job: SendJob): Promise { + const sendWithFee = await this.connection.withDefaultFee({ + kind: "bcp/send", + chainId: this.connection.chainId(), + sender: this.codec.identityToAddress(job.sender), + senderPubkey: job.sender.pubkey, + recipient: job.recipient, + memo: "Make love, not war", + amount: job.amount, + }); + + const nonce = await this.connection.getNonce({ pubkey: job.sender.pubkey }); + const signed = await this.profile.signTransaction(job.sender, sendWithFee, this.codec, nonce); + + const post = await this.connection.postTx(this.codec.bytesToPost(signed)); + const blockInfo = await post.blockInfo.waitFor(info => !isBlockInfoPending(info)); + if (isBlockInfoFailed(blockInfo)) { + throw new Error(`Sending tokens failed. Code: ${blockInfo.code}, message: ${blockInfo.message}`); + } + } + + /** Use one of the distributor accounts to send tokend to user */ + public async credit(recipient: Address, ticker: TokenTicker): Promise { + if (this.distributors.length === 0) throw new Error("No distributor account available"); + const sender = this.distributors[this.getCreditCount() % this.distributors.length]; + const job: SendJob = { + sender: sender, + recipient: recipient, + amount: this.tokenManager.creditAmount(ticker), + }; + if (this.logging) logSendJob(job); + await this.send(job); + } + + public async loadTokenTickers(): Promise> { + return (await this.connection.getAllTokens()).map(token => token.tokenTicker); + } + + public async loadAccounts(): Promise>> { + const addresses = identitiesOfFirstWallet(this.profile).map(identity => identityToAddress(identity)); + + const out: Account[] = []; + for (const address of addresses) { + const response = await this.connection.getAccount({ address: address }); + if (response) { + out.push({ + address: response.address, + balance: response.balance, + }); + } else { + out.push({ + address: address, + balance: [], + }); + } + } + + return out; + } + + public async refill(): Promise { + if (this.logging) { + console.info(`Connected to network: ${this.connection.chainId()}`); + console.info(`Tokens on network: ${(await this.loadTokenTickers()).join(", ")}`); + } + + const accounts = await this.loadAccounts(); + if (this.logging) logAccountsState(accounts); + const [holderAccount, ...distributorAccounts] = accounts; + + const availableTokens = availableTokensFromHolder(holderAccount); + if (this.logging) console.info("Available tokens:", availableTokens); + + const jobs: SendJob[] = []; + for (const token of availableTokens) { + const refillDistibutors = distributorAccounts.filter(account => + this.tokenManager.needsRefill(account, token), + ); + + if (this.logging) { + console.info(`Refilling ${token} of:`); + console.info( + refillDistibutors.length ? refillDistibutors.map(r => ` ${debugAccount(r)}`).join("\n") : " none", + ); + } + for (const refillDistibutor of refillDistibutors) { + jobs.push({ + sender: this.holder, + recipient: refillDistibutor.address, + amount: this.tokenManager.refillAmount(token), + }); + } + } + if (jobs.length > 0) { + for (const job of jobs) { + logSendJob(job); + await this.send(job); + await sleep(50); + } + + if (this.logging) { + console.info("Done refilling accounts."); + logAccountsState(await this.loadAccounts()); + } + } else { + if (this.logging) { + console.info("Nothing to be done. Anyways, thanks for checking."); + } + } + } + + /** returns an integer >= 0 that increments and is unique for this instance */ + private getCreditCount(): number { + return this.creditCount++; } } diff --git a/packages/faucet/src/multichainhelpers.ts b/packages/faucet/src/multichainhelpers.ts index 57e2e554..26be6198 100644 --- a/packages/faucet/src/multichainhelpers.ts +++ b/packages/faucet/src/multichainhelpers.ts @@ -1,135 +1,11 @@ -import { - Account, - BlockchainConnection, - Identity, - isBlockInfoFailed, - isBlockInfoPending, - SendTransaction, - TokenTicker, -} from "@iov/bcp"; +import { Account, Identity, TokenTicker } from "@iov/bcp"; import { UserProfile } from "@iov/keycontrol"; -import { needsRefill, refillAmount } from "./cashflow"; -import { codecImplementation } from "./codec"; -import { debugAccount, logAccountsState, logSendJob } from "./debugging"; -import { SendJob } from "./types"; - -async function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - export function identitiesOfFirstWallet(profile: UserProfile): ReadonlyArray { const wallet = profile.wallets.value[0]; return profile.getIdentities(wallet.id); } -export async function loadAccounts( - profile: UserProfile, - connection: BlockchainConnection, -): Promise> { - const codec = codecImplementation(); - const addresses = identitiesOfFirstWallet(profile).map(identity => codec.identityToAddress(identity)); - - const out: Account[] = []; - for (const address of addresses) { - const response = await connection.getAccount({ address: address }); - if (response) { - out.push({ - address: response.address, - balance: response.balance, - }); - } else { - out.push({ - address: address, - balance: [], - }); - } - } - - return out; -} - -export async function loadTokenTickers( - connection: BlockchainConnection, -): Promise> { - return (await connection.getAllTokens()).map(token => token.tokenTicker); -} - -/** - * Creates and posts a send transaction. Then waits until the transaction is in a block. - */ -export async function send( - profile: UserProfile, - connection: BlockchainConnection, - job: SendJob, -): Promise { - const codec = codecImplementation(); - - const sendWithFee = await connection.withDefaultFee({ - kind: "bcp/send", - chainId: connection.chainId(), - sender: codec.identityToAddress(job.sender), - senderPubkey: job.sender.pubkey, - recipient: job.recipient, - memo: "We ❤️ developers – iov.one", - amount: job.amount, - }); - - const nonce = await connection.getNonce({ pubkey: job.sender.pubkey }); - const signed = await profile.signTransaction(job.sender, sendWithFee, codec, nonce); - - const post = await connection.postTx(codec.bytesToPost(signed)); - const blockInfo = await post.blockInfo.waitFor(info => !isBlockInfoPending(info)); - if (isBlockInfoFailed(blockInfo)) { - throw new Error(`Sending tokens failed. Code: ${blockInfo.code}, message: ${blockInfo.message}`); - } -} - export function availableTokensFromHolder(holderAccount: Account): ReadonlyArray { return holderAccount.balance.map(coin => coin.tokenTicker); } - -export async function refill(profile: UserProfile, connection: BlockchainConnection): Promise { - console.info(`Connected to network: ${connection.chainId()}`); - console.info(`Tokens on network: ${(await loadTokenTickers(connection)).join(", ")}`); - - const holderIdentity = identitiesOfFirstWallet(profile)[0]; - - const accounts = await loadAccounts(profile, connection); - logAccountsState(accounts); - const holderAccount = accounts[0]; - const distributorAccounts = accounts.slice(1); - - const availableTokens = availableTokensFromHolder(holderAccount); - console.info("Available tokens:", availableTokens); - - const jobs: SendJob[] = []; - - for (const token of availableTokens) { - const refillDistibutors = distributorAccounts.filter(account => needsRefill(account, token)); - console.info(`Refilling ${token} of:`); - console.info( - refillDistibutors.length ? refillDistibutors.map(r => ` ${debugAccount(r)}`).join("\n") : " none", - ); - for (const refillDistibutor of refillDistibutors) { - jobs.push({ - sender: holderIdentity, - recipient: refillDistibutor.address, - tokenTicker: token, - amount: refillAmount(token), - }); - } - } - if (jobs.length > 0) { - for (const job of jobs) { - logSendJob(job); - await send(profile, connection, job); - await sleep(50); - } - - console.info("Done refilling accounts."); - logAccountsState(await loadAccounts(profile, connection)); - } else { - console.info("Nothing to be done. Anyways, thanks for checking."); - } -} diff --git a/packages/faucet/src/profile.ts b/packages/faucet/src/profile.ts index b5c4b766..ec540e01 100644 --- a/packages/faucet/src/profile.ts +++ b/packages/faucet/src/profile.ts @@ -1,30 +1,30 @@ -import { ChainId } from "@iov/bcp"; +import { ChainId, Identity } from "@iov/bcp"; import { HdPaths, Secp256k1HdWallet, UserProfile } from "@iov/keycontrol"; -import { codecImplementation } from "./codec"; -import * as constants from "./constants"; +import { identityToAddress } from "./addresses"; import { debugPath } from "./hdpaths"; -export async function setSecretAndCreateIdentities( - profile: UserProfile, +export async function createUserProfile( mnemonic: string, chainId: ChainId, -): Promise { - if (profile.wallets.value.length !== 0) { - throw new Error("Profile already contains wallets"); - } + numberOfDistributors: number, + logging = false, +): Promise<[UserProfile, readonly Identity[]]> { + const profile = new UserProfile(); const wallet = profile.addWallet(Secp256k1HdWallet.fromMnemonic(mnemonic)); + const identities = new Array(); // first account is the token holder - const numberOfIdentities = 1 + constants.concurrency; + const numberOfIdentities = 1 + numberOfDistributors; for (let i = 0; i < numberOfIdentities; i++) { - // create const path = HdPaths.cosmos(i); const identity = await profile.createIdentity(wallet.id, chainId, path); - - // log - const role = i === 0 ? "token holder " : `distributor ${i}`; - const address = codecImplementation().identityToAddress(identity); - console.info(`Created ${role} (${debugPath(path)}): ${address}`); + if (logging) { + const role = i === 0 ? "token holder " : `distributor ${i}`; + const address = identityToAddress(identity); + console.info(`Created ${role} (${debugPath(path)}): ${address}`); + } + identities.push(identity); } + return [profile, identities]; } diff --git a/packages/faucet/src/tokenmanager.spec.ts b/packages/faucet/src/tokenmanager.spec.ts new file mode 100644 index 00000000..7801c462 --- /dev/null +++ b/packages/faucet/src/tokenmanager.spec.ts @@ -0,0 +1,157 @@ +import { TokenConfiguration } from "@cosmwasm/bcp"; +import { TokenTicker } from "@iov/bcp"; + +import { TokenManager } from "./tokenmanager"; + +const dummyConfig: TokenConfiguration = { + bankTokens: [ + { + ticker: "TOKENZ", + name: "The tokenz", + fractionalDigits: 6, + denom: "utokenz", + }, + { + ticker: "TRASH", + name: "Trash token", + fractionalDigits: 3, + denom: "mtrash", + }, + ], +}; + +describe("TokenManager", () => { + describe("constructor", () => { + it("can be constructed", () => { + const tm = new TokenManager(dummyConfig); + expect(tm).toBeTruthy(); + }); + }); + + describe("creditAmount", () => { + const tm = new TokenManager(dummyConfig); + + it("returns 10 tokens by default", () => { + expect(tm.creditAmount("TOKENZ" as TokenTicker)).toEqual({ + quantity: "10000000", + fractionalDigits: 6, + tokenTicker: "TOKENZ", + }); + expect(tm.creditAmount("TRASH" as TokenTicker)).toEqual({ + quantity: "10000", + fractionalDigits: 3, + tokenTicker: "TRASH", + }); + }); + + it("returns value from env variable when set", () => { + process.env.FAUCET_CREDIT_AMOUNT_TRASH = "22"; + expect(tm.creditAmount("TRASH" as TokenTicker)).toEqual({ + quantity: "22000", + fractionalDigits: 3, + tokenTicker: "TRASH", + }); + process.env.FAUCET_CREDIT_AMOUNT_TRASH = ""; + }); + + it("returns default when env variable is set to empty", () => { + process.env.FAUCET_CREDIT_AMOUNT_TRASH = ""; + expect(tm.creditAmount("TRASH" as TokenTicker)).toEqual({ + quantity: "10000", + fractionalDigits: 3, + tokenTicker: "TRASH", + }); + process.env.FAUCET_CREDIT_AMOUNT_TRASH = ""; + }); + }); + + describe("refillAmount", () => { + const tm = new TokenManager(dummyConfig); + + beforeEach(() => { + process.env.FAUCET_REFILL_FACTOR = ""; + process.env.FAUCET_CREDIT_AMOUNT_TRASH = ""; + }); + + it("returns 20*10 + '000' by default", () => { + expect(tm.refillAmount("TRASH" as TokenTicker)).toEqual({ + quantity: "200000", + fractionalDigits: 3, + tokenTicker: "TRASH", + }); + }); + + it("returns 20*22 + '000' when credit amount is 22", () => { + process.env.FAUCET_CREDIT_AMOUNT_TRASH = "22"; + expect(tm.refillAmount("TRASH" as TokenTicker)).toEqual({ + quantity: "440000", + fractionalDigits: 3, + tokenTicker: "TRASH", + }); + }); + + it("returns 30*10 + '000' when refill factor is 30", () => { + process.env.FAUCET_REFILL_FACTOR = "30"; + expect(tm.refillAmount("TRASH" as TokenTicker)).toEqual({ + quantity: "300000", + fractionalDigits: 3, + tokenTicker: "TRASH", + }); + }); + + it("returns 30*22 + '000' when refill factor is 30 and credit amount is 22", () => { + process.env.FAUCET_REFILL_FACTOR = "30"; + process.env.FAUCET_CREDIT_AMOUNT_TRASH = "22"; + expect(tm.refillAmount("TRASH" as TokenTicker)).toEqual({ + quantity: "660000", + fractionalDigits: 3, + tokenTicker: "TRASH", + }); + }); + }); + + describe("refillThreshold", () => { + const tm = new TokenManager(dummyConfig); + + beforeEach(() => { + process.env.FAUCET_REFILL_THRESHOLD = ""; + process.env.FAUCET_CREDIT_AMOUNT_TRASH = ""; + }); + + it("returns 8*10 + '000' by default", () => { + expect(tm.refillThreshold("TRASH" as TokenTicker)).toEqual({ + quantity: "80000", + fractionalDigits: 3, + tokenTicker: "TRASH", + }); + }); + + it("returns 8*22 + '000' when credit amount is 22", () => { + process.env.FAUCET_CREDIT_AMOUNT_TRASH = "22"; + expect(tm.refillThreshold("TRASH" as TokenTicker)).toEqual({ + quantity: "176000", + fractionalDigits: 3, + tokenTicker: "TRASH", + }); + }); + + it("returns 5*10 + '000' when refill threshold is 5", () => { + process.env.FAUCET_REFILL_THRESHOLD = "5"; + expect(tm.refillThreshold("TRASH" as TokenTicker)).toEqual({ + quantity: "50000", + fractionalDigits: 3, + tokenTicker: "TRASH", + }); + }); + + it("returns 5*22 + '000' when refill threshold is 5 and credit amount is 22", () => { + process.env.FAUCET_REFILL_THRESHOLD = "5"; + process.env.FAUCET_CREDIT_AMOUNT_TRASH = "22"; + expect(tm.refillThreshold("TRASH" as TokenTicker)).toEqual({ + quantity: "110000", + fractionalDigits: 3, + tokenTicker: "TRASH", + }); + }); + }); +}); diff --git a/packages/faucet/src/tokenmanager.ts b/packages/faucet/src/tokenmanager.ts new file mode 100644 index 00000000..b91685d9 --- /dev/null +++ b/packages/faucet/src/tokenmanager.ts @@ -0,0 +1,67 @@ +import { TokenConfiguration } from "@cosmwasm/bcp"; +import { Account, Amount, TokenTicker } from "@iov/bcp"; +import { Decimal, Uint53 } from "@iov/encoding"; + +/** Send `factor` times credit amount on refilling */ +const defaultRefillFactor = 20; + +/** refill when balance gets below `factor` times credit amount */ +const defaultRefillThresholdFactor = 8; + +export class TokenManager { + private readonly config: TokenConfiguration; + + public constructor(config: TokenConfiguration) { + this.config = config; + } + + /** The amount of tokens that will be sent to the user */ + public creditAmount(token: TokenTicker, factor: Uint53 = new Uint53(1)): Amount { + const amountFromEnv = process.env[`FAUCET_CREDIT_AMOUNT_${token}`]; + const amount = amountFromEnv ? Uint53.fromString(amountFromEnv).toNumber() : 10; + const value = new Uint53(amount * factor.toNumber()); + + const fractionalDigits = this.getFractionalDigits(token); + return { + quantity: value.toString() + "0".repeat(fractionalDigits), + fractionalDigits: fractionalDigits, + tokenTicker: token, + }; + } + + public refillAmount(token: TokenTicker): Amount { + const factorFromEnv = Number.parseInt(process.env.FAUCET_REFILL_FACTOR || "0", 10) || undefined; + const factor = new Uint53(factorFromEnv || defaultRefillFactor); + return this.creditAmount(token, factor); + } + + public refillThreshold(token: TokenTicker): Amount { + const factorFromEnv = Number.parseInt(process.env.FAUCET_REFILL_THRESHOLD || "0", 10) || undefined; + const factor = new Uint53(factorFromEnv || defaultRefillThresholdFactor); + return this.creditAmount(token, factor); + } + + /** true iff the distributor account needs a refill */ + public needsRefill(account: Account, token: TokenTicker): boolean { + const balanceAmount = account.balance.find(b => b.tokenTicker === token); + + const balance = balanceAmount + ? Decimal.fromAtomics(balanceAmount.quantity, balanceAmount.fractionalDigits) + : Decimal.fromAtomics("0", 0); + + const thresholdAmount = this.refillThreshold(token); + const threshold = Decimal.fromAtomics(thresholdAmount.quantity, thresholdAmount.fractionalDigits); + + // TODO: perform < operation on Decimal type directly + // https://github.com/iov-one/iov-core/issues/1375 + return balance.toFloatApproximation() < threshold.toFloatApproximation(); + } + + private getFractionalDigits(ticker: TokenTicker): number { + const match = [...this.config.bankTokens, ...(this.config.erc20Tokens || [])].find( + token => token.ticker === ticker, + ); + if (!match) throw new Error(`No token found for ticker symbol: ${ticker}`); + return match.fractionalDigits; + } +} diff --git a/packages/faucet/src/types.ts b/packages/faucet/src/types.ts index 28186ee9..31334171 100644 --- a/packages/faucet/src/types.ts +++ b/packages/faucet/src/types.ts @@ -1,8 +1,7 @@ -import { Address, Amount, Identity, TokenTicker } from "@iov/bcp"; +import { Address, Amount, Identity } from "@iov/bcp"; export interface SendJob { readonly sender: Identity; readonly recipient: Address; - readonly tokenTicker: TokenTicker; readonly amount: Amount; } diff --git a/tslint.json b/tslint.json index 1e301e42..f1180421 100644 --- a/tslint.json +++ b/tslint.json @@ -26,6 +26,7 @@ "ordered-imports": false, "prefer-const": false, "promise-function-async": true, + "radix": false, "typedef": [true, "call-signature"], "variable-name": [true, "check-format", "allow-leading-underscore"] },