Merge pull request #68 from confio/no-global-state
Big faucet refactoring
This commit is contained in:
commit
36e909f61d
@ -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: ["/ <reference"] } }],
|
||||
"import/no-cycle": "warn",
|
||||
"simple-import-sort/sort": "warn",
|
||||
|
||||
@ -2,5 +2,5 @@
|
||||
const path = require("path");
|
||||
|
||||
// attempt to call in main file....
|
||||
const faucet = require(path.join(__dirname, "..", "build", "faucet.js"));
|
||||
const faucet = require(path.join(__dirname, "..", "build", "cli.js"));
|
||||
faucet.main(process.argv.slice(2));
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import { ChainId } from "@iov/bcp";
|
||||
import { Bip39, Random } from "@iov/crypto";
|
||||
import { UserProfile } from "@iov/keycontrol";
|
||||
|
||||
import * as constants from "../constants";
|
||||
import { setSecretAndCreateIdentities } from "../profile";
|
||||
import { createUserProfile } from "../profile";
|
||||
|
||||
export async function generate(args: ReadonlyArray<string>): Promise<void> {
|
||||
if (args.length < 1) {
|
||||
@ -17,6 +16,6 @@ export async function generate(args: ReadonlyArray<string>): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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<string>): Promise<void> {
|
||||
if (args.length < 1) {
|
||||
@ -34,116 +14,44 @@ export async function start(args: ReadonlyArray<string>): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
17
packages/faucet/src/addresses.ts
Normal file
17
packages/faucet/src/addresses.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { CosmWasmCodec } from "@cosmwasm/bcp";
|
||||
import { Address, Identity, TxCodec } from "@iov/bcp";
|
||||
|
||||
import * as constants from "./constants";
|
||||
|
||||
const noTokensCodec: Pick<TxCodec, "identityToAddress" | "isValidAddress"> = new CosmWasmCodec(
|
||||
constants.addressPrefix,
|
||||
[],
|
||||
);
|
||||
|
||||
export function identityToAddress(identity: Identity): Address {
|
||||
return noTokensCodec.identityToAddress(identity);
|
||||
}
|
||||
|
||||
export function isValidAddress(input: string): boolean {
|
||||
return noTokensCodec.isValidAddress(input);
|
||||
}
|
||||
94
packages/faucet/src/api/webserver.ts
Normal file
94
packages/faucet/src/api/webserver.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
}
|
||||
37
packages/faucet/src/cli.ts
Normal file
37
packages/faucet/src/cli.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { generate, help, start, version } from "./actions";
|
||||
|
||||
export function main(args: ReadonlyArray<string>): 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");
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
describe("codec", () => {});
|
||||
@ -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<CosmWasmConnection> {
|
||||
return CosmWasmConnection.establish(url, prefix, config);
|
||||
}
|
||||
|
||||
export function codecImplementation(): TxCodec {
|
||||
return new CosmWasmCodec(prefix, config.bankTokens);
|
||||
}
|
||||
|
||||
export function codecDefaultFractionalDigits(): number {
|
||||
return 6;
|
||||
}
|
||||
@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@ -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<Account>): 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} ...`);
|
||||
|
||||
204
packages/faucet/src/faucet.spec.ts
Normal file
204
packages/faucet/src/faucet.spec.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<string>): 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<void> {
|
||||
const sendWithFee = await this.connection.withDefaultFee<SendTransaction>({
|
||||
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<void> {
|
||||
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<ReadonlyArray<TokenTicker>> {
|
||||
return (await this.connection.getAllTokens()).map(token => token.tokenTicker);
|
||||
}
|
||||
|
||||
public async loadAccounts(): Promise<ReadonlyArray<Pick<Account, "address" | "balance">>> {
|
||||
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<void> {
|
||||
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++;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<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);
|
||||
}
|
||||
|
||||
export async function loadAccounts(
|
||||
profile: UserProfile,
|
||||
connection: BlockchainConnection,
|
||||
): Promise<ReadonlyArray<Account>> {
|
||||
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<ReadonlyArray<TokenTicker>> {
|
||||
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<void> {
|
||||
const codec = codecImplementation();
|
||||
|
||||
const sendWithFee = await connection.withDefaultFee<SendTransaction>({
|
||||
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<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.");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<void> {
|
||||
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<Identity>();
|
||||
|
||||
// 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];
|
||||
}
|
||||
|
||||
157
packages/faucet/src/tokenmanager.spec.ts
Normal file
157
packages/faucet/src/tokenmanager.spec.ts
Normal file
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
67
packages/faucet/src/tokenmanager.ts
Normal file
67
packages/faucet/src/tokenmanager.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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"]
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user