Pull out faucet class

Closes #65
This commit is contained in:
Simon Warta 2020-02-10 10:32:16 +01:00
parent 144a051e39
commit 59d336e54a
9 changed files with 313 additions and 269 deletions

View File

@ -40,6 +40,7 @@
"@iov/crypto": "^2.0.0-alpha.7",
"@iov/encoding": "^2.0.0-alpha.7",
"@iov/keycontrol": "^2.0.0-alpha.7",
"@iov/utils": "^2.0.0-alpha.7",
"@koa/cors": "^3.0.0",
"axios": "^0.19.0",
"fast-deep-equal": "^3.1.1",

View File

@ -3,16 +3,15 @@ import cors = require("@koa/cors");
import Koa from "koa";
import bodyParser from "koa-bodyparser";
import { creditAmount, setFractionalDigits } from "../../cashflow";
import { codecDefaultFractionalDigits, codecImplementation, establishConnection } from "../../codec";
import { codecImplementation, establishConnection } from "../../codec";
import * as constants from "../../constants";
import { logAccountsState, logSendJob } from "../../debugging";
import { Faucet } from "../../faucet";
import {
availableTokensFromHolder,
identitiesOfFirstWallet,
loadAccounts,
loadTokenTickers,
refill,
send,
} from "../../multichainhelpers";
import { setSecretAndCreateIdentities } from "../../profile";
@ -48,7 +47,6 @@ export async function start(args: ReadonlyArray<string>): Promise<void> {
const connectedChainId = connection.chainId();
console.info(`Connected to network: ${connectedChainId}`);
setFractionalDigits(codecDefaultFractionalDigits());
await setSecretAndCreateIdentities(profile, constants.mnemonic, connectedChainId);
const chainTokens = await loadTokenTickers(connection);
@ -67,8 +65,10 @@ export async function start(args: ReadonlyArray<string>): Promise<void> {
const distibutorIdentities = identitiesOfFirstWallet(profile).slice(1);
await refill(profile, connection);
setInterval(async () => refill(profile, connection), 60_000); // ever 60 seconds
const faucet = new Faucet(constants.tokenConfig);
await faucet.refill(profile, connection);
setInterval(async () => faucet.refill(profile, connection), 60_000); // ever 60 seconds
console.info("Creating webserver ...");
const api = new Koa();
@ -127,7 +127,7 @@ export async function start(args: ReadonlyArray<string>): Promise<void> {
const job: SendJob = {
sender: sender,
recipient: address,
amount: creditAmount(ticker),
amount: faucet.creditAmount(ticker),
tokenTicker: ticker,
};
logSendJob(job);

View File

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

View File

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

View File

@ -1,32 +1,14 @@
import { CosmWasmCodec, CosmWasmConnection, TokenConfiguration } from "@cosmwasm/bcp";
import { CosmWasmCodec, CosmWasmConnection } from "@cosmwasm/bcp";
import { TxCodec } from "@iov/bcp";
import { tokenConfig } from "./constants";
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<CosmWasmConnection> {
return CosmWasmConnection.establish(url, prefix, config);
return CosmWasmConnection.establish(url, prefix, tokenConfig);
}
export function codecImplementation(): TxCodec {
return new CosmWasmCodec(prefix, config.bankTokens);
}
export function codecDefaultFractionalDigits(): number {
return 6;
return new CosmWasmCodec(prefix, tokenConfig.bankTokens);
}

View File

@ -1,4 +1,23 @@
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 tokenConfig: TokenConfiguration = {
bankTokens: [
{
fractionalDigits: 6,
name: "Fee Token",
ticker: "COSM",
denom: "cosm",
},
{
fractionalDigits: 6,
name: "Staking Token",
ticker: "STAKE",
denom: "stake",
},
],
};

View File

@ -0,0 +1,157 @@
import { TokenConfiguration } from "@cosmwasm/bcp";
import { TokenTicker } from "@iov/bcp";
import { Faucet } from "./faucet";
const dummyConfig: TokenConfiguration = {
bankTokens: [
{
ticker: "TOKENZ",
name: "The tokenz",
fractionalDigits: 6,
denom: "utokenz",
},
{
ticker: "TRASH",
name: "Trash token",
fractionalDigits: 3,
denom: "mtrash",
},
],
};
describe("Faucet", () => {
describe("constructor", () => {
it("can be constructed", () => {
const faucet = new Faucet(dummyConfig);
expect(faucet).toBeTruthy();
});
});
describe("creditAmount", () => {
const faucet = new Faucet(dummyConfig);
it("returns 10 tokens by default", () => {
expect(faucet.creditAmount("TOKENZ" as TokenTicker)).toEqual({
quantity: "10000000",
fractionalDigits: 6,
tokenTicker: "TOKENZ",
});
expect(faucet.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(faucet.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(faucet.creditAmount("TRASH" as TokenTicker)).toEqual({
quantity: "10000",
fractionalDigits: 3,
tokenTicker: "TRASH",
});
process.env.FAUCET_CREDIT_AMOUNT_TRASH = "";
});
});
describe("refillAmount", () => {
const faucet = new Faucet(dummyConfig);
beforeEach(() => {
process.env.FAUCET_REFILL_FACTOR = "";
process.env.FAUCET_CREDIT_AMOUNT_TRASH = "";
});
it("returns 20*10 + '000' by default", () => {
expect(faucet.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(faucet.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(faucet.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(faucet.refillAmount("TRASH" as TokenTicker)).toEqual({
quantity: "660000",
fractionalDigits: 3,
tokenTicker: "TRASH",
});
});
});
describe("refillThreshold", () => {
const faucet = new Faucet(dummyConfig);
beforeEach(() => {
process.env.FAUCET_REFILL_THRESHOLD = "";
process.env.FAUCET_CREDIT_AMOUNT_TRASH = "";
});
it("returns 8*10 + '000' by default", () => {
expect(faucet.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(faucet.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(faucet.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(faucet.refillThreshold("TRASH" as TokenTicker)).toEqual({
quantity: "110000",
fractionalDigits: 3,
tokenTicker: "TRASH",
});
});
});
});

View File

@ -0,0 +1,124 @@
import { TokenConfiguration } from "@cosmwasm/bcp";
import { Account, Amount, BlockchainConnection, TokenTicker } from "@iov/bcp";
import { Decimal, Uint53 } from "@iov/encoding";
import { UserProfile } from "@iov/keycontrol";
import { sleep } from "@iov/utils";
import { debugAccount, logAccountsState, logSendJob } from "./debugging";
import {
availableTokensFromHolder,
identitiesOfFirstWallet,
loadAccounts,
loadTokenTickers,
send,
} from "./multichainhelpers";
import { SendJob } from "./types";
/** Send `factor` times credit amount on refilling */
const defaultRefillFactor = 20;
/** refill when balance gets below `factor` times credit amount */
const defaultRefillThresholdFactor = 8;
export class Faucet {
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);
}
public async refill(profile: UserProfile, connection: BlockchainConnection): Promise<void> {
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 => this.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: this.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.");
}
}
/** 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;
}
}

View File

@ -9,15 +9,9 @@ import {
} 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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
export function identitiesOfFirstWallet(profile: UserProfile): ReadonlyArray<Identity> {
const wallet = profile.wallets.value[0];
return profile.getIdentities(wallet.id);
@ -88,48 +82,3 @@ export async function send(
export function availableTokensFromHolder(holderAccount: Account): ReadonlyArray<TokenTicker> {
return holderAccount.balance.map(coin => coin.tokenTicker);
}
export async function refill(profile: UserProfile, connection: BlockchainConnection): Promise<void> {
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.");
}
}