Add packages/faucet

This commit is contained in:
Simon Warta
2020-01-30 07:36:44 +01:00
parent 4b6a5bbddd
commit 9354ad5afa
34 changed files with 2006 additions and 43 deletions
+3
View File
@@ -2,6 +2,9 @@ This repository was forked from the folders packages/iov-cosmos and scripts/cosm
of https://github.com/iov-one/iov-core at tag v2.0.0-alpha.7. It was repurposed
and heavily modified from there on.
The code in packages/faucet was forked from https://github.com/iov-one/iov-faucet on
2020-01-29 at commit 33e2d707e7.
Copyright 2018-2020 IOV SAS
Copyright 2020 Confio UO
Copyright 2020 Simon Warta
+1
View File
@@ -0,0 +1 @@
../../.eslintignore
+3
View File
@@ -0,0 +1,3 @@
build/
dist/
docs/
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env node
const path = require("path");
// attempt to call in main file....
const faucet = require(path.join(__dirname, "..", "build", "faucet.js"));
faucet.main(process.argv.slice(2));
+1
View File
@@ -0,0 +1 @@
Directory used to trigger lerna package updates for all packages
+62
View File
@@ -0,0 +1,62 @@
{
"name": "@cosmwasm/faucet",
"version": "0.0.1",
"description": "The faucet",
"author": "Ethan Frey <ethanfrey@users.noreply.github.com>",
"license": "Apache-2.0",
"main": "build/index.js",
"types": "types/index.d.ts",
"files": [
"build/",
"types/",
"*.md",
"!*.spec.*",
"!**/testdata/"
],
"repository": {
"type": "git",
"url": "https://github.com/confio/cosm-js/tree/master/packages/faucet"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"docs": "shx rm -rf docs && typedoc --options typedoc.js",
"format": "prettier --write --loglevel warn \"./src/**/*.ts\"",
"lint": "eslint --max-warnings 0 \"**/*.{js,ts}\" && tslint -t verbose --project .",
"lint-fix": "eslint --max-warnings 0 \"**/*.{js,ts}\" --fix",
"move-types": "shx rm -rf ./types/* && shx mv build/types/* ./types && rm -rf ./types/testdata && shx rm -f ./types/*.spec.d.ts",
"format-types": "prettier --write --loglevel warn \"./types/**/*.d.ts\"",
"build": "shx rm -rf ./build && tsc && yarn move-types && yarn format-types",
"build-or-skip": "[ -n \"$SKIP_BUILD\" ] || yarn build",
"test-node": "mocha \"./build/**/*.spec.js\"",
"test": "yarn build-or-skip && yarn test-node"
},
"dependencies": {
"@iov/bcp": "^2.0.0-alpha.7",
"@iov/bns": "^2.0.0-alpha.7",
"@iov/crypto": "^2.0.0-alpha.7",
"@iov/encoding": "^2.0.0-alpha.7",
"@iov/ethereum": "^2.0.0-alpha.7",
"@iov/lisk": "^2.0.0-alpha.7",
"@iov/multichain": "^2.0.0-alpha.7",
"@koa/cors": "^3.0.0",
"axios": "^0.19.0",
"bn.js": "^5.1.1",
"fast-deep-equal": "^3.1.1",
"koa": "^2.11.0",
"koa-bodyparser": "^4.2.1",
"readonly-date": "^1.0.0",
"xstream": "^11.11.0"
},
"devDependencies": {
"@types/bn.js": "^4.11.6",
"@types/chai": "^4.2.7",
"@types/koa": "^2.11.0",
"@types/koa-bodyparser": "^4.3.0",
"@types/koa__cors": "^3.0.1",
"@types/mocha": "^5.2.7",
"chai": "^4.2.0",
"mocha": "^7.0.1"
}
}
+20
View File
@@ -0,0 +1,20 @@
import { ChainId } from "@iov/bcp";
import { Bip39, Random } from "@iov/crypto";
import { UserProfile } from "@iov/keycontrol";
import { codecFromString } from "../codec";
import { setSecretAndCreateIdentities } from "../profile";
export async function generate(args: ReadonlyArray<string>): Promise<void> {
if (args.length < 2) {
throw Error(`Not enough arguments for action 'generate'. See 'iov-faucet help' or README for arguments.`);
}
const codecName = codecFromString(args[0]);
const chainId = args[1] as ChainId;
const mnemonic = Bip39.encode(await Random.getBytes(16)).toString();
console.info(`FAUCET_MNEMONIC="${mnemonic}"`);
const profile = new UserProfile();
await setSecretAndCreateIdentities(profile, mnemonic, chainId, codecName);
}
+33
View File
@@ -0,0 +1,33 @@
export function help(): void {
const out = `
Usage: iov-faucet action [arguments...]
Positional arguments per action are listed below. Arguments in parentheses are optional.
help Shows a help text and exits
version Prints the version and exits
generate Generates a random mnemonic, shows derived faucet addresses and exits
1 Codec
2 Chain ID
start Starts the faucet
1 Codec
2 Node base URL, e.g. wss://bov.friendnet-fast.iov.one
Environment variables
FAUCET_CONCURRENCY Number of distributor accounts. Defaults to 5.
FAUCET_PORT Port of the webserver. Defaults to 8000.
FAUCET_MNEMONIC Secret mnemonic that serves as the base secret for the
faucet HD accounts
FAUCET_CREDIT_AMOUNT_TKN Send this amount of TKN to a user requesting TKN. TKN is
a placeholder for the token ticker. Defaults to 10.
FAUCET_REFILL_FACTOR Send factor times credit amount on refilling. Defauls to 8.
FAUCET_REFILL_THRESHOLD Refill when balance gets below factor times credit amount.
Defaults to 20.
`.trim();
process.stdout.write(`${out}\n`);
}
+4
View File
@@ -0,0 +1,4 @@
export { generate } from "./generate";
export { help } from "./help";
export { start } from "./start";
export { version } from "./version";
@@ -0,0 +1,20 @@
import { expect } from "chai";
import { HttpError } from "./httperror";
describe("HttpError", () => {
it("can be constructed", () => {
{
const error = new HttpError(400, "Invalid name field");
expect(error.message).to.eql("Invalid name field");
expect(error.status).to.eql(400);
expect(error.expose).to.eql(true);
}
{
const error = new HttpError(500, "Out of memory", false);
expect(error.message).to.eql("Out of memory");
expect(error.status).to.eql(500);
expect(error.expose).to.eql(false);
}
});
});
@@ -0,0 +1,5 @@
export class HttpError extends Error {
constructor(public readonly status: number, text: string, public readonly expose: boolean = true) {
super(text);
}
}
@@ -0,0 +1 @@
export { start } from "./start";
@@ -0,0 +1,48 @@
import { expect } from "chai";
import { RequestParser } from "./requestparser";
describe("RequestParser", () => {
it("can process valid credit request", () => {
const body = { address: "abc", ticker: "CASH" };
expect(RequestParser.parseCreditBody(body)).to.eql({ address: "abc", ticker: "CASH" });
});
it("throws for invalid credit requests", () => {
// address unset
{
const body = { ticker: "CASH" };
expect(() => RequestParser.parseCreditBody(body)).to.throw(/Property 'address' must be a string/i);
}
// address wrong type
{
const body = { address: true, ticker: "CASH" };
expect(() => RequestParser.parseCreditBody(body)).to.throw(/Property 'address' must be a string/i);
}
// address empty
{
const body = { address: "", ticker: "CASH" };
expect(() => RequestParser.parseCreditBody(body)).to.throw(/Property 'address' must not be empty/i);
}
// ticker unset
{
const body = { address: "abc" };
expect(() => RequestParser.parseCreditBody(body)).to.throw(/Property 'ticker' must be a string/i);
}
// ticker wrong type
{
const body = { address: "abc", ticker: true };
expect(() => RequestParser.parseCreditBody(body)).to.throw(/Property 'ticker' must be a string/i);
}
// ticker empty
{
const body = { address: "abc", ticker: "" };
expect(() => RequestParser.parseCreditBody(body)).to.throw(/Property 'ticker' must not be empty/i);
}
});
});
@@ -0,0 +1,35 @@
import { Address, TokenTicker } from "@iov/bcp";
import { HttpError } from "./httperror";
export interface CreditRequestBodyData {
readonly ticker: TokenTicker;
readonly address: Address;
}
export class RequestParser {
public static parseCreditBody(body: any): CreditRequestBodyData {
const { address, ticker } = body;
if (typeof address !== "string") {
throw new HttpError(400, "Property 'address' must be a string.");
}
if (address.length === 0) {
throw new HttpError(400, "Property 'address' must not be empty.");
}
if (typeof ticker !== "string") {
throw new HttpError(400, "Property 'ticker' must be a string");
}
if (ticker.length === 0) {
throw new HttpError(400, "Property 'ticker' must not be empty.");
}
return {
address: address as Address,
ticker: ticker as TokenTicker,
};
}
}
+157
View File
@@ -0,0 +1,157 @@
/* eslint-disable require-atomic-updates */
import { UserProfile } from "@iov/keycontrol";
import { MultiChainSigner } from "@iov/multichain";
import cors = require("@koa/cors");
import Koa from "koa";
import bodyParser from "koa-bodyparser";
import { creditAmount, gasLimit, gasPrice, setFractionalDigits } from "../../cashflow";
import {
codecDefaultFractionalDigits,
codecFromString,
codecImplementation,
createChainConnector,
} from "../../codec";
import * as constants from "../../constants";
import { logAccountsState, logSendJob } from "../../debugging";
import {
accountsOfFirstChain,
availableTokensFromHolder,
identitiesOfFirstWallet,
refillFirstChain,
sendOnFirstChain,
tokenTickersOfFirstChain,
} 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++;
}
export async function start(args: ReadonlyArray<string>): Promise<void> {
if (args.length < 2) {
throw Error(`Not enough arguments for action 'start'. See 'iov-faucet help' or README for arguments.`);
}
const codec = codecFromString(args[0]);
const blockchainBaseUrl: string = args[1];
const port = constants.port;
const profile = new UserProfile();
if (!constants.mnemonic) {
throw new Error("The FAUCET_MNEMONIC environment variable is not set");
}
const signer = new MultiChainSigner(profile);
console.info(`Connecting to blockchain ${blockchainBaseUrl} ...`);
const connection = (await signer.addChain(createChainConnector(codec, blockchainBaseUrl))).connection;
const connectedChainId = connection.chainId();
console.info(`Connected to network: ${connectedChainId}`);
setFractionalDigits(codecDefaultFractionalDigits(codec));
await setSecretAndCreateIdentities(profile, constants.mnemonic, connectedChainId, codec);
const chainTokens = await tokenTickersOfFirstChain(signer);
console.info("Chain tokens:", chainTokens);
const accounts = await accountsOfFirstChain(profile, signer);
logAccountsState(accounts);
let availableTokens = availableTokensFromHolder(accounts[0]);
console.info("Available tokens:", availableTokens);
setInterval(async () => {
const updatedAccounts = await accountsOfFirstChain(profile, signer);
availableTokens = availableTokensFromHolder(updatedAccounts[0]);
console.info("Available tokens:", availableTokens);
}, 60_000);
const distibutorIdentities = identitiesOfFirstWallet(profile).slice(1);
await refillFirstChain(profile, signer, codec);
setInterval(async () => refillFirstChain(profile, signer, codec), 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 accountsOfFirstChain(profile, signer);
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 as any).body;
const { address, ticker } = RequestParser.parseCreditBody(requestBody);
if (!codecImplementation(codec).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,
gasPrice: gasPrice(codec),
gasLimit: gasLimit(codec),
};
logSendJob(signer, job);
await sendOnFirstChain(profile, signer, 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);
}
+15
View File
@@ -0,0 +1,15 @@
import fs from "fs";
export async function version(): Promise<void> {
return new Promise<void>((resolve, reject) => {
fs.readFile(__dirname + "/../../package.json", { encoding: "utf8" }, (error, data) => {
if (error) {
reject(error);
} else {
const packagejson = JSON.parse(data);
process.stdout.write(`${packagejson.version}\n`);
resolve();
}
});
});
}
+163
View File
@@ -0,0 +1,163 @@
import { TokenTicker } from "@iov/bcp";
import { expect } from "chai";
import {
creditAmount,
gasLimit,
gasPrice,
refillAmount,
refillThreshold,
setFractionalDigits,
} from "./cashflow";
import { Codec } from "./codec";
describe("Cashflow", () => {
before(() => {
setFractionalDigits(3);
});
describe("creditAmount", () => {
it("returns '10' + '000' by default", () => {
expect(creditAmount("TOKENZ" as TokenTicker)).to.eql({
quantity: "10000",
fractionalDigits: 3,
tokenTicker: "TOKENZ",
});
expect(creditAmount("TRASH" as TokenTicker)).to.eql({
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)).to.eql({
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)).to.eql({
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)).to.eql({
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)).to.eql({
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)).to.eql({
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)).to.eql({
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)).to.eql({
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)).to.eql({
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)).to.eql({
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)).to.eql({
quantity: "110000",
fractionalDigits: 3,
tokenTicker: "WTF",
});
});
});
describe("gasPrice", () => {
it("returns undefined for non-Ethereum codecs", () => {
expect(gasPrice(Codec.Lisk)).to.be.undefined;
expect(gasPrice(Codec.Bns)).to.be.undefined;
});
it("returns amount for Ethereum codec", () => {
expect(gasPrice(Codec.Ethereum)).to.be.eql({
quantity: "20000000000",
fractionalDigits: 18,
tokenTicker: "ETH",
});
});
});
describe("gasLimit", () => {
it("returns undefined for non-Ethereum codecs", () => {
expect(gasLimit(Codec.Lisk)).to.be.undefined;
expect(gasLimit(Codec.Bns)).to.be.undefined;
});
it("returns amount for Ethereum codec", () => {
expect(gasLimit(Codec.Ethereum)).to.be.eql({
quantity: "2100000",
fractionalDigits: 18,
tokenTicker: "ETH",
});
});
});
});
+95
View File
@@ -0,0 +1,95 @@
import BN = require("bn.js");
import { Account, Amount, TokenTicker } from "@iov/bcp";
import { Int53 } from "@iov/encoding";
import { Codec } from "./codec";
import * as constants from "./constants";
/** 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 = 1): Amount {
const amountFromEnv = process.env[`FAUCET_CREDIT_AMOUNT_${token}`];
const wholeNumber = amountFromEnv ? Int53.fromString(amountFromEnv).toNumber() : 10;
const total = wholeNumber * factor;
const fractionalDigits = getFractionalDigits();
// replace BN with BigInt with TypeScript 3.2 and node 11
const quantity = new BN(total).imul(new BN(10).pow(new BN(fractionalDigits))).toString();
return {
quantity: quantity,
fractionalDigits: fractionalDigits,
tokenTicker: token,
};
}
export function refillAmount(token: TokenTicker): Amount {
const factorFromEnv = Number.parseInt(process.env.FAUCET_REFILL_FACTOR || "0", 10) || undefined;
const factor = 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 = factorFromEnv || defaultRefillThresholdFactor;
return creditAmount(token, factor);
}
/** true iff the distributor account needs a refill */
export function needsRefill(account: Account, token: TokenTicker): boolean {
const coin = account.balance.find(balance => balance.tokenTicker === token);
const tokenBalance = coin ? coin.quantity : "0";
const refillQty = new BN(refillThreshold(token).quantity);
return new BN(tokenBalance).lt(refillQty);
}
export function gasPrice(codec: Codec): Amount | undefined {
switch (codec) {
case Codec.Bns:
case Codec.Lisk:
return undefined;
case Codec.Ethereum:
return {
quantity: constants.ethereum.gasPrice,
fractionalDigits: 18,
tokenTicker: "ETH" as TokenTicker,
};
default:
throw new Error("No codec imlementation for this codec found");
}
}
export function gasLimit(codec: Codec): Amount | undefined {
switch (codec) {
case Codec.Bns:
case Codec.Lisk:
return undefined;
case Codec.Ethereum:
return {
quantity: constants.ethereum.gasLimit,
fractionalDigits: 18,
tokenTicker: "ETH" as TokenTicker,
};
default:
throw new Error("Codec not supported");
}
}
+16
View File
@@ -0,0 +1,16 @@
import { expect } from "chai";
import { Codec, codecFromString } from "./codec";
describe("Codec", () => {
it("can convert string to codec", () => {
expect(codecFromString("bns")).to.equal(Codec.Bns);
expect(codecFromString("lisk")).to.equal(Codec.Lisk);
expect(codecFromString("ethereum")).to.equal(Codec.Ethereum);
expect(() => codecFromString("")).to.throw(/not supported/i);
expect(() => codecFromString("abc")).to.throw(/not supported/i);
expect(() => codecFromString("LISK")).to.throw(/not supported/i);
expect(() => codecFromString("ETHEREUM")).to.throw(/not supported/i);
});
});
+80
View File
@@ -0,0 +1,80 @@
import { ChainConnector, TxCodec } from "@iov/bcp";
import { bnsCodec, createBnsConnector } from "@iov/bns";
import { Slip10RawIndex } from "@iov/crypto";
import { createEthereumConnector, ethereumCodec } from "@iov/ethereum";
import { HdPaths } from "@iov/keycontrol";
import { createLiskConnector, liskCodec } from "@iov/lisk";
export const enum Codec {
Bns,
Lisk,
Ethereum,
}
export function codecFromString(input: string): Codec {
switch (input) {
case "bns":
return Codec.Bns;
case "lisk":
return Codec.Lisk;
case "ethereum":
return Codec.Ethereum;
default:
throw new Error(`Codec '${input}' not supported`);
}
}
export function codecImplementation(codec: Codec): TxCodec {
switch (codec) {
case Codec.Bns:
return bnsCodec;
case Codec.Lisk:
return liskCodec;
case Codec.Ethereum:
return ethereumCodec;
default:
throw new Error("No codec implementation for this codec found");
}
}
export function createPathBuilderForCodec(codec: Codec): (derivation: number) => readonly Slip10RawIndex[] {
const pathBuilder = (accountIndex: number): readonly Slip10RawIndex[] => {
switch (codec) {
case Codec.Bns:
return HdPaths.iov(accountIndex);
case Codec.Lisk:
return HdPaths.bip44Like(134, accountIndex);
case Codec.Ethereum:
return HdPaths.ethereum(accountIndex);
default:
throw new Error("No path builder for this codec found");
}
};
return pathBuilder;
}
export function createChainConnector(codec: Codec, url: string): ChainConnector {
switch (codec) {
case Codec.Bns:
return createBnsConnector(url);
case Codec.Lisk:
return createLiskConnector(url);
case Codec.Ethereum:
return createEthereumConnector(url, {});
default:
throw new Error("No connector for this codec found");
}
}
export function codecDefaultFractionalDigits(codec: Codec): number {
switch (codec) {
case Codec.Bns:
return 9; // fixed for all weave tokens
case Codec.Lisk:
return 8;
case Codec.Ethereum:
return 18;
default:
throw new Error("Unknown codec");
}
}
+7
View File
@@ -0,0 +1,7 @@
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 ethereum = {
gasPrice: "20000000000",
gasLimit: "2100000",
};
+15
View File
@@ -0,0 +1,15 @@
import { Ed25519HdWallet, Secp256k1HdWallet, Wallet } from "@iov/keycontrol";
import { Codec } from "./codec";
export function createWalletForCodec(input: Codec, mnemonic: string): Wallet {
switch (input) {
case Codec.Bns:
case Codec.Lisk:
return Ed25519HdWallet.fromMnemonic(mnemonic);
case Codec.Ethereum:
return Secp256k1HdWallet.fromMnemonic(mnemonic);
default:
throw new Error(`Codec '${input}' not supported`);
}
}
+58
View File
@@ -0,0 +1,58 @@
import { Account, Amount } from "@iov/bcp";
import { MultiChainSigner } from "@iov/multichain";
import { SendJob } from "./types";
export function amountToNumber(amount: Amount): number {
const { quantity, fractionalDigits } = amount;
if (!quantity.match(/^[0-9]+$/)) {
throw new Error(`quantity must be a number, got ${quantity}`);
}
if (fractionalDigits < 0) {
throw new Error(`invalid fractional digits: ${fractionalDigits}`);
}
// let's remove those leading zeros...
const temp = quantity.replace(/^0+/, "");
// unless we need them to reach a decimal point
const pad = fractionalDigits - temp.length;
const trimmed = pad > 0 ? "0".repeat(pad) + temp : temp;
const cut = trimmed.length - fractionalDigits;
const whole = cut === 0 ? "0" : trimmed.slice(0, cut);
const decimal = fractionalDigits === 0 ? "" : `.${trimmed.slice(cut)}`;
const value = `${whole}${decimal}`;
return Number(value);
}
/** A string representation of a coin in a human-readable format that can change at any time */
export function debugCoin(coin: Amount): string {
return `${amountToNumber(coin)} ${coin.tokenTicker}`;
}
/** A string representation of a balance in a human-readable format that can change at any time */
export function debugBalance(data: ReadonlyArray<Amount>): string {
return `[${data.map(debugCoin).join(", ")}]`;
}
/** A string representation of an account in a human-readable format that can change at any time */
export function debugAccount(account: Account): string {
return `${account.address}: ${debugBalance(account.balance)}`;
}
export function logAccountsState(accounts: ReadonlyArray<Account>): void {
if (accounts.length < 2) {
throw new Error("List of accounts must contain at least one token holder and one distributor");
}
const holder = accounts[0];
const distributors = accounts.slice(1);
console.info("Holder:\n" + ` ${debugAccount(holder)}`);
console.info("Distributors:\n" + distributors.map(r => ` ${debugAccount(r)}`).join("\n"));
}
export function logSendJob(signer: MultiChainSigner, job: SendJob): void {
const from = signer.identityToAddress(job.sender);
const to = job.recipient;
const amount = debugCoin(job.amount);
console.info(`Sending ${amount} from ${from} to ${to} ...`);
}
+38
View File
@@ -0,0 +1,38 @@
import { generate, help, start, version } from "./actions";
export function main(args: ReadonlyArray<string>): void {
if (args.length < 1) {
help();
process.exit(1);
return;
}
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");
}
}
+45
View File
@@ -0,0 +1,45 @@
import { Slip10RawIndex } from "@iov/crypto";
import { expect } from "chai";
import { debugPath } from "./hdpaths";
describe("hdpaths", () => {
describe("debugPath", () => {
it("works for no component", () => {
// See https://github.com/bitcoin/bips/blob/master/bip-0032/derivation.png from BIP32
expect(debugPath([])).to.eql("m");
});
it("works for normal components", () => {
const one = Slip10RawIndex.normal(1);
expect(debugPath([one])).to.eql("m/1");
expect(debugPath([one, one])).to.eql("m/1/1");
expect(debugPath([one, one, one])).to.eql("m/1/1/1");
const min = Slip10RawIndex.normal(0);
expect(debugPath([min])).to.eql("m/0");
const max = Slip10RawIndex.normal(2 ** 31 - 1);
expect(debugPath([max])).to.eql("m/2147483647");
});
it("works for hardened components", () => {
const one = Slip10RawIndex.hardened(1);
expect(debugPath([one])).to.eql("m/1'");
expect(debugPath([one, one])).to.eql("m/1'/1'");
expect(debugPath([one, one, one])).to.eql("m/1'/1'/1'");
const min = Slip10RawIndex.hardened(0);
expect(debugPath([min])).to.eql("m/0'");
const max = Slip10RawIndex.hardened(2 ** 31 - 1);
expect(debugPath([max])).to.eql("m/2147483647'");
});
it("works for mixed components", () => {
const one = Slip10RawIndex.normal(1);
const two = Slip10RawIndex.hardened(2);
expect(debugPath([one, two, two, one])).to.eql("m/1/2'/2'/1");
});
});
});
+10
View File
@@ -0,0 +1,10 @@
import { Slip10RawIndex } from "@iov/crypto";
export function debugPath(path: readonly Slip10RawIndex[]): string {
return path.reduce((current, component): string => {
const componentString = component.isHardened()
? `${component.toNumber() - 2 ** 31}'`
: component.toString();
return current + "/" + componentString;
}, "m");
}
@@ -0,0 +1,57 @@
import { Address, Algorithm, PubkeyBundle, PubkeyBytes, TokenTicker } from "@iov/bcp";
import { expect } from "chai";
import { availableTokensFromHolder } from "./multichainhelpers";
describe("multichainhelpers", () => {
describe("availableTokensFromHolder", () => {
const defaultPubkey: PubkeyBundle = {
algo: Algorithm.Ed25519,
data: new Uint8Array([0, 1, 2, 3]) as PubkeyBytes,
};
it("works for an empty account", () => {
const tickers = availableTokensFromHolder({
address: "aabbccdd" as Address,
pubkey: defaultPubkey,
balance: [],
});
expect(tickers).to.be.empty;
});
it("works for one token", () => {
const tickers = availableTokensFromHolder({
address: "aabbccdd" as Address,
pubkey: defaultPubkey,
balance: [
{
quantity: "1",
fractionalDigits: 9,
tokenTicker: "CASH" as TokenTicker,
},
],
});
expect(tickers).to.eql(["CASH"]);
});
it("works for two tokens", () => {
const tickers = availableTokensFromHolder({
address: "aabbccdd" as Address,
pubkey: defaultPubkey,
balance: [
{
quantity: "1",
fractionalDigits: 9,
tokenTicker: "CASH" as TokenTicker,
},
{
quantity: "1",
fractionalDigits: 9,
tokenTicker: "TRASH" as TokenTicker,
},
],
});
expect(tickers).to.eql(["CASH", "TRASH"]);
});
});
});
+145
View File
@@ -0,0 +1,145 @@
import {
Account,
Identity,
isBlockInfoFailed,
isBlockInfoPending,
SendTransaction,
TokenTicker,
WithCreator,
} from "@iov/bcp";
import { UserProfile } from "@iov/keycontrol";
import { MultiChainSigner } from "@iov/multichain";
import { gasLimit, gasPrice, needsRefill, refillAmount } from "./cashflow";
import { Codec } 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 accountsOfFirstChain(
profile: UserProfile,
signer: MultiChainSigner,
): Promise<ReadonlyArray<Account>> {
const addresses = identitiesOfFirstWallet(profile).map(identity => signer.identityToAddress(identity));
const chainId = signer.chainIds()[0];
const out: Account[] = [];
for (const address of addresses) {
const response = await signer.connection(chainId).getAccount({ address: address });
if (response) {
out.push({
address: response.address,
balance: response.balance,
});
} else {
out.push({
address: address,
balance: [],
});
}
}
return out;
}
export async function tokenTickersOfFirstChain(
signer: MultiChainSigner,
): Promise<ReadonlyArray<TokenTicker>> {
const chainId = signer.chainIds()[0];
return (await signer.connection(chainId).getAllTokens()).map(token => token.tokenTicker);
}
/**
* Creates and posts a send transaction. Then waits until the transaction is in a block.
*/
export async function sendOnFirstChain(
profile: UserProfile,
signer: MultiChainSigner,
job: SendJob,
): Promise<void> {
const chainId = signer.chainIds()[0];
const connection = signer.connection(chainId);
const sendWithFee = await connection.withDefaultFee<SendTransaction & WithCreator>({
kind: "bcp/send",
creator: {
chainId: chainId,
pubkey: job.sender.pubkey,
},
sender: signer.identityToAddress(job.sender),
recipient: job.recipient,
memo: "We ❤️ developers iov.one",
amount: job.amount,
});
const post = await signer.signAndPost(sendWithFee);
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 refillFirstChain(
profile: UserProfile,
signer: MultiChainSigner,
codec: Codec,
): Promise<void> {
const chainId = signer.chainIds()[0];
console.info(`Connected to network: ${chainId}`);
console.info(`Tokens on network: ${(await tokenTickersOfFirstChain(signer)).join(", ")}`);
const holderIdentity = identitiesOfFirstWallet(profile)[0];
const accounts = await accountsOfFirstChain(profile, signer);
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),
gasPrice: gasPrice(codec),
gasLimit: gasLimit(codec),
});
}
}
if (jobs.length > 0) {
for (const job of jobs) {
logSendJob(signer, job);
await sendOnFirstChain(profile, signer, job);
await sleep(50);
}
console.info("Done refilling accounts.");
logAccountsState(await accountsOfFirstChain(profile, signer));
} else {
console.info("Nothing to be done. Anyways, thanks for checking.");
}
}
+34
View File
@@ -0,0 +1,34 @@
import { ChainId } from "@iov/bcp";
import { UserProfile } from "@iov/keycontrol";
import { Codec, codecImplementation, createPathBuilderForCodec } from "./codec";
import * as constants from "./constants";
import { createWalletForCodec } from "./crypto";
import { debugPath } from "./hdpaths";
export async function setSecretAndCreateIdentities(
profile: UserProfile,
mnemonic: string,
chainId: ChainId,
codecName: Codec,
): Promise<void> {
if (profile.wallets.value.length !== 0) {
throw new Error("Profile already contains wallets");
}
const wallet = profile.addWallet(createWalletForCodec(codecName, mnemonic));
const pathBuilder = createPathBuilderForCodec(codecName);
// first account is the token holder
const numberOfIdentities = 1 + constants.concurrency;
for (let i = 0; i < numberOfIdentities; i++) {
// create
const path = pathBuilder(i);
const identity = await profile.createIdentity(wallet.id, chainId, path);
// log
const role = i === 0 ? "token holder " : `distributor ${i}`;
const address = codecImplementation(codecName).identityToAddress(identity);
console.info(`Created ${role} (${debugPath(path)}): ${address}`);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Address, Amount, Identity, TokenTicker } from "@iov/bcp";
export interface SendJob {
readonly sender: Identity;
readonly recipient: Address;
readonly tokenTicker: TokenTicker;
readonly amount: Amount;
readonly gasPrice?: Amount;
readonly gasLimit?: Amount;
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"outDir": "build",
"declarationDir": "build/types",
"rootDir": "src"
},
"include": [
"src/**/*"
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../tslint.json"
}
+14
View File
@@ -0,0 +1,14 @@
const packageJson = require("./package.json");
module.exports = {
src: ["./src"],
out: "docs",
exclude: "**/*.spec.ts",
target: "es6",
name: `${packageJson.name} Documentation`,
readme: "README.md",
mode: "file",
excludeExternals: true,
excludeNotExported: true,
excludePrivate: true,
};
+790 -43
View File
File diff suppressed because it is too large Load Diff