Merge pull request #187 from CosmWasm/bcp-free-faucet

Remove BCP dependency from faucet
This commit is contained in:
Simon Warta 2020-06-03 19:37:35 +02:00 committed by GitHub
commit cd862ef573
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 328 additions and 410 deletions

View File

@ -35,11 +35,9 @@
"test": "yarn build-or-skip && yarn test-node" "test": "yarn build-or-skip && yarn test-node"
}, },
"dependencies": { "dependencies": {
"@cosmwasm/bcp": "^0.8.0", "@cosmwasm/sdk38": "^0.8.0",
"@iov/bcp": "^2.1.0",
"@iov/crypto": "^2.1.0", "@iov/crypto": "^2.1.0",
"@iov/encoding": "^2.1.0", "@iov/encoding": "^2.1.0",
"@iov/keycontrol": "^2.1.0",
"@iov/utils": "^2.0.2", "@iov/utils": "^2.0.2",
"@koa/cors": "^3.0.0", "@koa/cors": "^3.0.0",
"axios": "^0.19.0", "axios": "^0.19.0",

View File

@ -1,8 +1,7 @@
import { ChainId } from "@iov/bcp";
import { Bip39, Random } from "@iov/crypto"; import { Bip39, Random } from "@iov/crypto";
import * as constants from "../constants"; import * as constants from "../constants";
import { createUserProfile } from "../profile"; import { createPens } from "../profile";
export async function generate(args: ReadonlyArray<string>): Promise<void> { export async function generate(args: ReadonlyArray<string>): Promise<void> {
if (args.length < 1) { if (args.length < 1) {
@ -11,11 +10,11 @@ export async function generate(args: ReadonlyArray<string>): Promise<void> {
); );
} }
const chainId = args[0] as ChainId; const chainId = args[0];
const mnemonic = Bip39.encode(Random.getBytes(16)).toString(); const mnemonic = Bip39.encode(Random.getBytes(16)).toString();
console.info(`FAUCET_MNEMONIC="${mnemonic}"`); console.info(`FAUCET_MNEMONIC="${mnemonic}"`);
// Log the addresses // Log the addresses
await createUserProfile(mnemonic, chainId, constants.concurrency, true); await createPens(mnemonic, chainId, constants.concurrency, true);
} }

View File

@ -1,11 +1,9 @@
import { createCosmosConnector } from "@cosmwasm/bcp"; import { CosmosClient } from "@cosmwasm/sdk38";
import { Webserver } from "../../api/webserver"; import { Webserver } from "../../api/webserver";
import * as constants from "../../constants"; import * as constants from "../../constants";
import { logAccountsState } from "../../debugging"; import { logAccountsState } from "../../debugging";
import { Faucet } from "../../faucet"; import { Faucet } from "../../faucet";
import { availableTokensFromHolder } from "../../multichainhelpers";
import { createUserProfile } from "../../profile";
export async function start(args: ReadonlyArray<string>): Promise<void> { export async function start(args: ReadonlyArray<string>): Promise<void> {
if (args.length < 1) { if (args.length < 1) {
@ -16,35 +14,28 @@ export async function start(args: ReadonlyArray<string>): Promise<void> {
// Connection // Connection
const blockchainBaseUrl = args[0]; const blockchainBaseUrl = args[0];
const connector = createCosmosConnector( console.info(`Connecting to blockchain ${blockchainBaseUrl} ...`);
const chainId = await new CosmosClient(blockchainBaseUrl).getChainId();
console.info(`Connected to network: ${chainId}`);
// Faucet
if (!constants.mnemonic) throw new Error("The FAUCET_MNEMONIC environment variable is not set");
const faucet = await Faucet.make(
blockchainBaseUrl, blockchainBaseUrl,
constants.addressPrefix, constants.addressPrefix,
constants.developmentTokenConfig, constants.developmentTokenConfig,
);
console.info(`Connecting to blockchain ${blockchainBaseUrl} ...`);
const connection = await connector.establishConnection();
console.info(`Connected to network: ${connection.chainId}`);
// Profile
if (!constants.mnemonic) throw new Error("The FAUCET_MNEMONIC environment variable is not set");
const [profile] = await createUserProfile(
constants.mnemonic, constants.mnemonic,
connection.chainId,
constants.concurrency, constants.concurrency,
true, true,
); );
const chainTokens = faucet.loadTokenTickers();
// Faucet
const faucet = new Faucet(constants.developmentTokenConfig, connection, connector.codec, profile, true);
const chainTokens = await faucet.loadTokenTickers();
console.info("Chain tokens:", chainTokens); console.info("Chain tokens:", chainTokens);
const accounts = await faucet.loadAccounts(); const accounts = await faucet.loadAccounts();
logAccountsState(accounts); logAccountsState(accounts, constants.developmentTokenConfig);
let availableTokens = availableTokensFromHolder(accounts[0]); let availableTokens = await faucet.availableTokens();
console.info("Available tokens:", availableTokens); console.info("Available tokens:", availableTokens);
setInterval(async () => { setInterval(async () => {
const updatedAccounts = await faucet.loadAccounts(); availableTokens = await faucet.availableTokens();
availableTokens = availableTokensFromHolder(updatedAccounts[0]);
console.info("Available tokens:", availableTokens); console.info("Available tokens:", availableTokens);
}, 60_000); }, 60_000);
@ -52,6 +43,6 @@ export async function start(args: ReadonlyArray<string>): Promise<void> {
setInterval(async () => faucet.refill(), 60_000); // ever 60 seconds setInterval(async () => faucet.refill(), 60_000); // ever 60 seconds
console.info("Creating webserver ..."); console.info("Creating webserver ...");
const server = new Webserver(faucet, { nodeUrl: blockchainBaseUrl, chainId: connection.chainId }); const server = new Webserver(faucet, { nodeUrl: blockchainBaseUrl, chainId: chainId });
server.start(constants.port); server.start(constants.port);
} }

View File

@ -1,17 +1,13 @@
import { CosmosCodec } from "@cosmwasm/bcp"; import { Bech32 } from "@iov/encoding";
import { Address, Identity, TxCodec } from "@iov/bcp";
import * as constants from "./constants"; export function isValidAddress(input: string, requiredPrefix: string): boolean {
try {
const noTokensCodec: Pick<TxCodec, "identityToAddress" | "isValidAddress"> = new CosmosCodec( const { prefix, data } = Bech32.decode(input);
constants.addressPrefix, if (prefix !== requiredPrefix) {
[], return false;
); }
return data.length === 20;
export function identityToAddress(identity: Identity): Address { } catch {
return noTokensCodec.identityToAddress(identity); return false;
} }
export function isValidAddress(input: string): boolean {
return noTokensCodec.isValidAddress(input);
} }

View File

@ -1,10 +1,10 @@
import { Address, TokenTicker } from "@iov/bcp";
import { HttpError } from "./httperror"; import { HttpError } from "./httperror";
export interface CreditRequestBodyData { export interface CreditRequestBodyData {
readonly ticker: TokenTicker; /** The ticker symbol */
readonly address: Address; readonly ticker: string;
/** The recipient address */
readonly address: string;
} }
export class RequestParser { export class RequestParser {
@ -28,8 +28,8 @@ export class RequestParser {
} }
return { return {
address: address as Address, address: address,
ticker: ticker as TokenTicker, ticker: ticker,
}; };
} }
} }

View File

@ -1,24 +1,23 @@
import Koa from "koa"; import Koa from "koa";
import cors = require("@koa/cors"); import cors = require("@koa/cors");
import { ChainId } from "@iov/bcp";
import bodyParser from "koa-bodyparser"; import bodyParser from "koa-bodyparser";
import { isValidAddress } from "../addresses"; import { isValidAddress } from "../addresses";
import * as constants from "../constants";
import { Faucet } from "../faucet"; import { Faucet } from "../faucet";
import { availableTokensFromHolder } from "../multichainhelpers";
import { HttpError } from "./httperror"; import { HttpError } from "./httperror";
import { RequestParser } from "./requestparser"; import { RequestParser } from "./requestparser";
/** This will be passed 1:1 to the user */ /** This will be passed 1:1 to the user */
export interface ChainConstants { export interface ChainConstants {
readonly nodeUrl: string; readonly nodeUrl: string;
readonly chainId: ChainId; readonly chainId: string;
} }
export class Webserver { export class Webserver {
private readonly api = new Koa(); private readonly api = new Koa();
constructor(faucet: Faucet, chainChinstants: ChainConstants) { constructor(faucet: Faucet, chainConstants: ChainConstants) {
this.api.use(cors()); this.api.use(cors());
this.api.use(bodyParser()); this.api.use(bodyParser());
@ -35,11 +34,11 @@ export class Webserver {
break; break;
case "/status": { case "/status": {
const [holder, ...distributors] = await faucet.loadAccounts(); const [holder, ...distributors] = await faucet.loadAccounts();
const availableTokens = availableTokensFromHolder(holder); const availableTokens = await faucet.availableTokens();
const chainTokens = await faucet.loadTokenTickers(); const chainTokens = faucet.loadTokenTickers();
context.response.body = { context.response.body = {
status: "ok", status: "ok",
...chainChinstants, ...chainConstants,
chainTokens: chainTokens, chainTokens: chainTokens,
availableTokens: availableTokens, availableTokens: availableTokens,
holder: holder, holder: holder,
@ -60,12 +59,11 @@ export class Webserver {
const requestBody = context.request.body; const requestBody = context.request.body;
const { address, ticker } = RequestParser.parseCreditBody(requestBody); const { address, ticker } = RequestParser.parseCreditBody(requestBody);
if (!isValidAddress(address)) { if (!isValidAddress(address, constants.addressPrefix)) {
throw new HttpError(400, "Address is not in the expected format for this chain."); throw new HttpError(400, "Address is not in the expected format for this chain.");
} }
const [holder] = await faucet.loadAccounts(); const availableTokens = await faucet.availableTokens();
const availableTokens = availableTokensFromHolder(holder);
if (availableTokens.indexOf(ticker) === -1) { if (availableTokens.indexOf(ticker) === -1) {
const tokens = JSON.stringify(availableTokens); const tokens = JSON.stringify(availableTokens);
throw new HttpError(422, `Token is not available. Available tokens are: ${tokens}`); throw new HttpError(422, `Token is not available. Available tokens are: ${tokens}`);

View File

@ -1,4 +1,4 @@
import { TokenConfiguration } from "@cosmwasm/bcp"; import { TokenConfiguration } from "./types";
export const binaryName = "cosmwasm-faucet"; export const binaryName = "cosmwasm-faucet";
export const concurrency: number = Number.parseInt(process.env.FAUCET_CONCURRENCY || "", 10) || 5; export const concurrency: number = Number.parseInt(process.env.FAUCET_CONCURRENCY || "", 10) || 5;
@ -12,14 +12,12 @@ export const developmentTokenConfig: TokenConfiguration = {
bankTokens: [ bankTokens: [
{ {
fractionalDigits: 6, fractionalDigits: 6,
name: "Fee Token", tickerSymbol: "COSM",
ticker: "COSM",
denom: "ucosm", denom: "ucosm",
}, },
{ {
fractionalDigits: 6, fractionalDigits: 6,
name: "Staking Token", tickerSymbol: "STAKE",
ticker: "STAKE",
denom: "ustake", denom: "ustake",
}, },
], ],

View File

@ -1,38 +1,39 @@
import { Account, Amount } from "@iov/bcp"; import { Coin } from "@cosmwasm/sdk38";
import { Decimal } from "@iov/encoding"; import { Decimal } from "@iov/encoding";
import { identityToAddress } from "./addresses"; import { MinimalAccount, SendJob, TokenConfiguration } from "./types";
import { SendJob } from "./types";
/** A string representation of a coin in a human-readable format that can change at any time */ /** A string representation of a coin in a human-readable format that can change at any time */
function debugAmount(amount: Amount): string { function debugCoin(coin: Coin, tokens: TokenConfiguration): string {
const value = Decimal.fromAtomics(amount.quantity, amount.fractionalDigits).toString(); const meta = tokens.bankTokens.find((token) => token.denom == coin.denom);
return `${value} ${amount.tokenTicker}`; if (!meta) throw new Error(`No token configuration found for denom ${coin.denom}`);
const value = Decimal.fromAtomics(coin.amount, meta.fractionalDigits).toString();
return `${value} ${meta?.tickerSymbol}`;
} }
/** A string representation of a balance in a human-readable format that can change at any time */ /** A string representation of a balance in a human-readable format that can change at any time */
export function debugBalance(data: ReadonlyArray<Amount>): string { export function debugBalance(data: readonly Coin[], tokens: TokenConfiguration): string {
return `[${data.map(debugAmount).join(", ")}]`; return `[${data.map((b) => debugCoin(b, tokens)).join(", ")}]`;
} }
/** A string representation of an account in a human-readable format that can change at any time */ /** A string representation of an account in a human-readable format that can change at any time */
export function debugAccount(account: Account): string { export function debugAccount(account: MinimalAccount, tokens: TokenConfiguration): string {
return `${account.address}: ${debugBalance(account.balance)}`; return `${account.address}: ${debugBalance(account.balance, tokens)}`;
} }
export function logAccountsState(accounts: ReadonlyArray<Account>): void { export function logAccountsState(accounts: ReadonlyArray<MinimalAccount>, tokens: TokenConfiguration): void {
if (accounts.length < 2) { if (accounts.length < 2) {
throw new Error("List of accounts must contain at least one token holder and one distributor"); throw new Error("List of accounts must contain at least one token holder and one distributor");
} }
const holder = accounts[0]; const holder = accounts[0];
const distributors = accounts.slice(1); const distributors = accounts.slice(1);
console.info("Holder:\n" + ` ${debugAccount(holder)}`); console.info("Holder:\n" + ` ${debugAccount(holder, tokens)}`);
console.info("Distributors:\n" + distributors.map((r) => ` ${debugAccount(r)}`).join("\n")); console.info("Distributors:\n" + distributors.map((r) => ` ${debugAccount(r, tokens)}`).join("\n"));
} }
export function logSendJob(job: SendJob): void { export function logSendJob(job: SendJob, tokens: TokenConfiguration): void {
const from = identityToAddress(job.sender); const from = job.sender;
const to = job.recipient; const to = job.recipient;
const amount = debugAmount(job.amount); const amount = debugCoin(job.amount, tokens);
console.info(`Sending ${amount} from ${from} to ${to} ...`); console.info(`Sending ${amount} from ${from} to ${to} ...`);
} }

View File

@ -1,12 +1,10 @@
import { CosmosCodec, CosmosConnection, TokenConfiguration } from "@cosmwasm/bcp"; import { CosmosClient } from "@cosmwasm/sdk38";
import { Address, ChainId, Identity, TokenTicker } from "@iov/bcp";
import { Random } from "@iov/crypto"; import { Random } from "@iov/crypto";
import { Bech32 } from "@iov/encoding"; import { Bech32 } from "@iov/encoding";
import { UserProfile } from "@iov/keycontrol";
import { assert } from "@iov/utils"; import { assert } from "@iov/utils";
import { Faucet } from "./faucet"; import { Faucet } from "./faucet";
import { createUserProfile } from "./profile"; import { TokenConfiguration } from "./types";
function pendingWithoutWasmd(): void { function pendingWithoutWasmd(): void {
if (!process.env.WASMD_ENABLED) { if (!process.env.WASMD_ENABLED) {
@ -15,175 +13,165 @@ function pendingWithoutWasmd(): void {
} }
const httpUrl = "http://localhost:1317"; const httpUrl = "http://localhost:1317";
const defaultConfig: TokenConfiguration = { const defaultTokenConfig: TokenConfiguration = {
bankTokens: [ bankTokens: [
{ {
fractionalDigits: 6, fractionalDigits: 6,
name: "Fee Token", tickerSymbol: "COSM",
ticker: "COSM",
denom: "ucosm", denom: "ucosm",
}, },
{ {
fractionalDigits: 6, fractionalDigits: 6,
name: "Staking Token", tickerSymbol: "STAKE",
ticker: "STAKE",
denom: "ustake", denom: "ustake",
}, },
], ],
}; };
const defaultAddressPrefix = "cosmos"; const defaultAddressPrefix = "cosmos";
const defaultChainId = "cosmos:testing" as ChainId;
const codec = new CosmosCodec(defaultAddressPrefix, defaultConfig.bankTokens);
function makeRandomAddress(): Address { function makeRandomAddress(): string {
return Bech32.encode(defaultAddressPrefix, Random.getBytes(20)) as Address; return Bech32.encode(defaultAddressPrefix, Random.getBytes(20));
} }
const faucetMnemonic = 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"; "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("Faucet", () => {
describe("constructor", () => { describe("constructor", () => {
it("can be constructed", async () => { it("can be constructed", async () => {
pendingWithoutWasmd(); pendingWithoutWasmd();
const connection = await CosmosConnection.establish(httpUrl, defaultAddressPrefix, defaultConfig); const faucet = await Faucet.make(httpUrl, defaultAddressPrefix, defaultTokenConfig, faucetMnemonic, 3);
const { profile } = await makeProfile();
const faucet = new Faucet(defaultConfig, connection, codec, profile);
expect(faucet).toBeTruthy(); expect(faucet).toBeTruthy();
connection.disconnect(); });
});
describe("availableTokens", () => {
it("is empty when no tokens are configures", async () => {
const faucet = await Faucet.make(httpUrl, defaultAddressPrefix, { bankTokens: [] }, faucetMnemonic, 3);
const tickers = await faucet.availableTokens();
expect(tickers).toEqual([]);
});
it("is empty when no tokens are configures", async () => {
const faucet = await Faucet.make(httpUrl, defaultAddressPrefix, defaultTokenConfig, faucetMnemonic, 3);
const tickers = await faucet.availableTokens();
expect(tickers).toEqual(["COSM", "STAKE"]);
}); });
}); });
describe("send", () => { describe("send", () => {
it("can send bank token", async () => { it("can send bank token", async () => {
pendingWithoutWasmd(); pendingWithoutWasmd();
const connection = await CosmosConnection.establish(httpUrl, defaultAddressPrefix, defaultConfig); const faucet = await Faucet.make(httpUrl, defaultAddressPrefix, defaultTokenConfig, faucetMnemonic, 3);
const { profile, holder } = await makeProfile();
const faucet = new Faucet(defaultConfig, connection, codec, profile);
const recipient = makeRandomAddress(); const recipient = makeRandomAddress();
await faucet.send({ await faucet.send({
amount: { amount: {
quantity: "23456", amount: "23456",
fractionalDigits: 6, denom: "ucosm",
tokenTicker: "COSM" as TokenTicker,
}, },
sender: holder, sender: faucet.holderAddress,
recipient: recipient, recipient: recipient,
}); });
const account = await connection.getAccount({ address: recipient });
const readOnlyClient = new CosmosClient(httpUrl);
const account = await readOnlyClient.getAccount(recipient);
assert(account); assert(account);
expect(account.balance).toEqual([ expect(account.balance).toEqual([
{ {
quantity: "23456", amount: "23456",
fractionalDigits: 6, denom: "ucosm",
tokenTicker: "COSM" as TokenTicker,
}, },
]); ]);
connection.disconnect();
}); });
}); });
describe("refill", () => { describe("refill", () => {
it("works", async () => { it("works", async () => {
pendingWithoutWasmd(); pendingWithoutWasmd();
const connection = await CosmosConnection.establish(httpUrl, defaultAddressPrefix, defaultConfig); const faucet = await Faucet.make(httpUrl, defaultAddressPrefix, defaultTokenConfig, faucetMnemonic, 3);
const { profile, distributors } = await makeProfile(1);
const faucet = new Faucet(defaultConfig, connection, codec, profile);
await faucet.refill(); await faucet.refill();
const distributorBalance = (await connection.getAccount({ pubkey: distributors[0].pubkey }))?.balance; const readOnlyClient = new CosmosClient(httpUrl);
const distributorBalance = (await readOnlyClient.getAccount(faucet.distributorAddresses[0]))?.balance;
assert(distributorBalance); assert(distributorBalance);
expect(distributorBalance).toEqual([ expect(distributorBalance).toEqual([
jasmine.objectContaining({ jasmine.objectContaining({
tokenTicker: "COSM", denom: "ucosm",
fractionalDigits: 6,
}), }),
jasmine.objectContaining({ jasmine.objectContaining({
tokenTicker: "STAKE", denom: "ustake",
fractionalDigits: 6,
}), }),
]); ]);
expect(Number.parseInt(distributorBalance[0].quantity, 10)).toBeGreaterThanOrEqual(80_000000); expect(Number.parseInt(distributorBalance[0].amount, 10)).toBeGreaterThanOrEqual(80_000000);
expect(Number.parseInt(distributorBalance[1].quantity, 10)).toBeGreaterThanOrEqual(80_000000); expect(Number.parseInt(distributorBalance[1].amount, 10)).toBeGreaterThanOrEqual(80_000000);
connection.disconnect();
}); });
}); });
describe("credit", () => { describe("credit", () => {
it("works for fee token", async () => { it("works for fee token", async () => {
pendingWithoutWasmd(); pendingWithoutWasmd();
const connection = await CosmosConnection.establish(httpUrl, defaultAddressPrefix, defaultConfig); const faucet = await Faucet.make(httpUrl, defaultAddressPrefix, defaultTokenConfig, faucetMnemonic, 3);
const { profile } = await makeProfile(1);
const faucet = new Faucet(defaultConfig, connection, codec, profile);
const recipient = makeRandomAddress(); const recipient = makeRandomAddress();
await faucet.credit(recipient, "COSM" as TokenTicker); await faucet.credit(recipient, "COSM");
const account = await connection.getAccount({ address: recipient });
const readOnlyClient = new CosmosClient(httpUrl);
const account = await readOnlyClient.getAccount(recipient);
assert(account); assert(account);
expect(account.balance).toEqual([ expect(account.balance).toEqual([
{ {
quantity: "10000000", amount: "10000000",
fractionalDigits: 6, denom: "ucosm",
tokenTicker: "COSM" as TokenTicker,
}, },
]); ]);
connection.disconnect();
}); });
it("works for stake token", async () => { it("works for stake token", async () => {
pendingWithoutWasmd(); pendingWithoutWasmd();
const connection = await CosmosConnection.establish(httpUrl, defaultAddressPrefix, defaultConfig); const faucet = await Faucet.make(httpUrl, defaultAddressPrefix, defaultTokenConfig, faucetMnemonic, 3);
const { profile } = await makeProfile(1);
const faucet = new Faucet(defaultConfig, connection, codec, profile);
const recipient = makeRandomAddress(); const recipient = makeRandomAddress();
await faucet.credit(recipient, "STAKE" as TokenTicker); await faucet.credit(recipient, "STAKE");
const account = await connection.getAccount({ address: recipient });
const readOnlyClient = new CosmosClient(httpUrl);
const account = await readOnlyClient.getAccount(recipient);
assert(account); assert(account);
expect(account.balance).toEqual([ expect(account.balance).toEqual([
{ {
quantity: "10000000", amount: "10000000",
fractionalDigits: 6, denom: "ustake",
tokenTicker: "STAKE" as TokenTicker,
}, },
]); ]);
connection.disconnect();
}); });
}); });
describe("loadTokenTickers", () => { describe("loadTokenTickers", () => {
it("works", async () => { it("works", async () => {
pendingWithoutWasmd(); pendingWithoutWasmd();
const connection = await CosmosConnection.establish(httpUrl, defaultAddressPrefix, defaultConfig); const faucet = await Faucet.make(httpUrl, defaultAddressPrefix, defaultTokenConfig, faucetMnemonic, 3);
const { profile } = await makeProfile(); const tickers = faucet.loadTokenTickers();
const faucet = new Faucet(defaultConfig, connection, codec, profile);
const tickers = await faucet.loadTokenTickers();
expect(tickers).toEqual(["COSM", "STAKE"]); expect(tickers).toEqual(["COSM", "STAKE"]);
connection.disconnect();
}); });
}); });
describe("loadAccounts", () => { describe("loadAccounts", () => {
it("works", async () => { it("works", async () => {
pendingWithoutWasmd(); pendingWithoutWasmd();
const connection = await CosmosConnection.establish(httpUrl, defaultAddressPrefix, defaultConfig); const faucet = await Faucet.make(httpUrl, defaultAddressPrefix, defaultTokenConfig, faucetMnemonic, 1);
const { profile, holder } = await makeProfile();
const faucet = new Faucet(defaultConfig, connection, codec, profile);
const accounts = await faucet.loadAccounts(); const accounts = await faucet.loadAccounts();
const expectedHolderAccount = await connection.getAccount({ pubkey: holder.pubkey });
const readOnlyClient = new CosmosClient(httpUrl);
const expectedHolderAccount = await readOnlyClient.getAccount(faucet.holderAddress);
const expectedDistributorAccount = await readOnlyClient.getAccount(faucet.distributorAddresses[0]);
assert(expectedHolderAccount); assert(expectedHolderAccount);
assert(expectedDistributorAccount);
expect(accounts).toEqual([ expect(accounts).toEqual([
{ address: expectedHolderAccount.address, balance: expectedHolderAccount.balance }, jasmine.objectContaining({
address: expectedHolderAccount.address,
balance: expectedHolderAccount.balance,
}),
jasmine.objectContaining({
address: expectedDistributorAccount.address,
balance: expectedDistributorAccount.balance,
}),
]); ]);
connection.disconnect();
}); });
}); });
}); });

View File

@ -1,161 +1,169 @@
import { TokenConfiguration } from "@cosmwasm/bcp"; import { CosmosClient, Pen, SigningCosmosClient } from "@cosmwasm/sdk38";
import {
Account,
Address,
BlockchainConnection,
Identity,
isBlockInfoFailed,
isBlockInfoPending,
SendTransaction,
TokenTicker,
TxCodec,
} from "@iov/bcp";
import { UserProfile } from "@iov/keycontrol";
import { sleep } from "@iov/utils"; import { sleep } from "@iov/utils";
import { identityToAddress } from "./addresses";
import { debugAccount, logAccountsState, logSendJob } from "./debugging"; import { debugAccount, logAccountsState, logSendJob } from "./debugging";
import { availableTokensFromHolder, identitiesOfFirstWallet } from "./multichainhelpers"; import { createPens } from "./profile";
import { TokenManager } from "./tokenmanager"; import { TokenManager } from "./tokenmanager";
import { SendJob } from "./types"; import { MinimalAccount, SendJob, TokenConfiguration } from "./types";
function isDefined<X>(value: X | undefined): value is X {
return value !== undefined;
}
export class Faucet { export class Faucet {
public get holder(): Identity { public static async make(
return identitiesOfFirstWallet(this.profile)[0]; apiUrl: string,
} addressPrefix: string,
public get distributors(): readonly Identity[] { config: TokenConfiguration,
return identitiesOfFirstWallet(this.profile).slice(1); mnemonic: string,
numberOfDistributors: number,
logging = false,
): Promise<Faucet> {
const pens = await createPens(mnemonic, addressPrefix, numberOfDistributors, logging);
return new Faucet(apiUrl, addressPrefix, config, pens, logging);
} }
public readonly addressPrefix: string;
public readonly holderAddress: string;
public readonly distributorAddresses: readonly string[];
private readonly tokenConfig: TokenConfiguration;
private readonly tokenManager: TokenManager; private readonly tokenManager: TokenManager;
private readonly connection: BlockchainConnection; private readonly readOnlyClient: CosmosClient;
private readonly codec: TxCodec; private readonly clients: { [senderAddress: string]: SigningCosmosClient };
private readonly profile: UserProfile;
private readonly logging: boolean; private readonly logging: boolean;
private creditCount = 0; private creditCount = 0;
public constructor( private constructor(
apiUrl: string,
addressPrefix: string,
config: TokenConfiguration, config: TokenConfiguration,
connection: BlockchainConnection, pens: readonly [string, Pen][],
codec: TxCodec,
profile: UserProfile,
logging = false, logging = false,
) { ) {
this.addressPrefix = addressPrefix;
this.tokenConfig = config;
this.tokenManager = new TokenManager(config); this.tokenManager = new TokenManager(config);
this.connection = connection;
this.codec = codec; this.readOnlyClient = new CosmosClient(apiUrl);
this.profile = profile;
this.holderAddress = pens[0][0];
this.distributorAddresses = pens.slice(1).map((pair) => pair[0]);
// we need one client per sender
const clients: { [senderAddress: string]: SigningCosmosClient } = {};
for (const [senderAddress, pen] of pens) {
clients[senderAddress] = new SigningCosmosClient(apiUrl, senderAddress, (signBytes) =>
pen.sign(signBytes),
);
}
this.clients = clients;
this.logging = logging; this.logging = logging;
} }
/**
* Returns a list of ticker symbols of tokens owned by the the holder and configured in the faucet
*/
public async availableTokens(): Promise<ReadonlyArray<string>> {
const holderAccount = await this.readOnlyClient.getAccount(this.holderAddress);
const balance = holderAccount ? holderAccount.balance : [];
return balance
.filter((b) => b.amount !== "0")
.map((b) => this.tokenConfig.bankTokens.find((token) => token.denom == b.denom))
.filter(isDefined)
.map((token) => token.tickerSymbol);
}
/** /**
* Creates and posts a send transaction. Then waits until the transaction is in a block. * Creates and posts a send transaction. Then waits until the transaction is in a block.
*/ */
public async send(job: SendJob): Promise<void> { public async send(job: SendJob): Promise<void> {
const sendWithFee = await this.connection.withDefaultFee<SendTransaction>({ await this.clients[job.sender].sendTokens(job.recipient, [job.amount], "Make love, not war");
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 */ /** Use one of the distributor accounts to send tokend to user */
public async credit(recipient: Address, ticker: TokenTicker): Promise<void> { public async credit(recipient: string, tickerSymbol: string): Promise<void> {
if (this.distributors.length === 0) throw new Error("No distributor account available"); if (this.distributorAddresses.length === 0) throw new Error("No distributor account available");
const sender = this.distributors[this.getCreditCount() % this.distributors.length]; const sender = this.distributorAddresses[this.getCreditCount() % this.distributorAddresses.length];
const job: SendJob = { const job: SendJob = {
sender: sender, sender: sender,
recipient: recipient, recipient: recipient,
amount: this.tokenManager.creditAmount(ticker), amount: this.tokenManager.creditAmount(tickerSymbol),
}; };
if (this.logging) logSendJob(job); if (this.logging) logSendJob(job, this.tokenConfig);
await this.send(job); await this.send(job);
} }
public async loadTokenTickers(): Promise<ReadonlyArray<TokenTicker>> { public loadTokenTickers(): readonly string[] {
return (await this.connection.getAllTokens()).map((token) => token.tokenTicker); return this.tokenConfig.bankTokens.map((token) => token.tickerSymbol);
} }
public async loadAccounts(): Promise<ReadonlyArray<Pick<Account, "address" | "balance">>> { public async loadAccounts(): Promise<ReadonlyArray<MinimalAccount>> {
const addresses = identitiesOfFirstWallet(this.profile).map((identity) => identityToAddress(identity)); const addresses = [this.holderAddress, ...this.distributorAddresses];
const out: Account[] = []; return Promise.all(
for (const address of addresses) { addresses.map(
const response = await this.connection.getAccount({ address: address }); async (address): Promise<MinimalAccount> => {
if (response) { const response = await this.readOnlyClient.getAccount(address);
out.push({ if (response) {
address: response.address, return response;
balance: response.balance, } else {
}); return {
} else { address: address,
out.push({ balance: [],
address: address, };
balance: [], }
}); },
} ),
} );
return out;
} }
public async refill(): Promise<void> { public async refill(): Promise<void> {
if (this.logging) { if (this.logging) {
console.info(`Connected to network: ${this.connection.chainId}`); console.info(`Connected to network: ${this.readOnlyClient.getChainId()}`);
console.info(`Tokens on network: ${(await this.loadTokenTickers()).join(", ")}`); console.info(`Tokens on network: ${this.loadTokenTickers().join(", ")}`);
} }
const accounts = await this.loadAccounts(); const accounts = await this.loadAccounts();
if (this.logging) logAccountsState(accounts); if (this.logging) logAccountsState(accounts, this.tokenConfig);
const [holderAccount, ...distributorAccounts] = accounts; const [_, ...distributorAccounts] = accounts;
const availableTokens = availableTokensFromHolder(holderAccount); const availableTokens = await this.availableTokens();
if (this.logging) console.info("Available tokens:", availableTokens); if (this.logging) console.info("Available tokens:", availableTokens);
const jobs: SendJob[] = []; const jobs: SendJob[] = [];
for (const token of availableTokens) { for (const tickerSymbol of availableTokens) {
const refillDistibutors = distributorAccounts.filter((account) => const refillDistibutors = distributorAccounts.filter((account) =>
this.tokenManager.needsRefill(account, token), this.tokenManager.needsRefill(account, tickerSymbol),
); );
if (this.logging) { if (this.logging) {
console.info(`Refilling ${token} of:`); console.info(`Refilling ${tickerSymbol} of:`);
console.info( console.info(
refillDistibutors.length refillDistibutors.length
? refillDistibutors.map((r) => ` ${debugAccount(r)}`).join("\n") ? refillDistibutors.map((r) => ` ${debugAccount(r, this.tokenConfig)}`).join("\n")
: " none", : " none",
); );
} }
for (const refillDistibutor of refillDistibutors) { for (const refillDistibutor of refillDistibutors) {
jobs.push({ jobs.push({
sender: this.holder, sender: this.holderAddress,
recipient: refillDistibutor.address, recipient: refillDistibutor.address,
amount: this.tokenManager.refillAmount(token), amount: this.tokenManager.refillAmount(tickerSymbol),
}); });
} }
} }
if (jobs.length > 0) { if (jobs.length > 0) {
for (const job of jobs) { for (const job of jobs) {
if (this.logging) logSendJob(job); if (this.logging) logSendJob(job, this.tokenConfig);
await this.send(job); await this.send(job);
await sleep(50); await sleep(50);
} }
if (this.logging) { if (this.logging) {
console.info("Done refilling accounts."); console.info("Done refilling accounts.");
logAccountsState(await this.loadAccounts()); logAccountsState(await this.loadAccounts(), this.tokenConfig);
} }
} else { } else {
if (this.logging) { if (this.logging) {

View File

@ -1,56 +0,0 @@
import { Address, Algorithm, PubkeyBundle, PubkeyBytes, TokenTicker } from "@iov/bcp";
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).toEqual([]);
});
it("works for one token", () => {
const tickers = availableTokensFromHolder({
address: "aabbccdd" as Address,
pubkey: defaultPubkey,
balance: [
{
quantity: "1",
fractionalDigits: 9,
tokenTicker: "JADE" as TokenTicker,
},
],
});
expect(tickers).toEqual(["JADE"]);
});
it("works for two tokens", () => {
const tickers = availableTokensFromHolder({
address: "aabbccdd" as Address,
pubkey: defaultPubkey,
balance: [
{
quantity: "1",
fractionalDigits: 9,
tokenTicker: "JADE" as TokenTicker,
},
{
quantity: "1",
fractionalDigits: 9,
tokenTicker: "TRASH" as TokenTicker,
},
],
});
expect(tickers).toEqual(["JADE", "TRASH"]);
});
});
});

View File

@ -1,11 +0,0 @@
import { Account, Identity, TokenTicker } from "@iov/bcp";
import { UserProfile } from "@iov/keycontrol";
export function identitiesOfFirstWallet(profile: UserProfile): ReadonlyArray<Identity> {
const wallet = profile.wallets.value[0];
return profile.getIdentities(wallet.id);
}
export function availableTokensFromHolder(holderAccount: Account): ReadonlyArray<TokenTicker> {
return holderAccount.balance.map((coin) => coin.tokenTicker);
}

View File

@ -1,30 +1,27 @@
import { ChainId, Identity } from "@iov/bcp"; import { makeCosmoshubPath, Pen, Secp256k1Pen } from "@cosmwasm/sdk38";
import { HdPaths, Secp256k1HdWallet, UserProfile } from "@iov/keycontrol";
import { identityToAddress } from "./addresses";
import { debugPath } from "./hdpaths"; import { debugPath } from "./hdpaths";
export async function createUserProfile( export async function createPens(
mnemonic: string, mnemonic: string,
chainId: ChainId, addressPrefix: string,
numberOfDistributors: number, numberOfDistributors: number,
logging = false, logging = false,
): Promise<[UserProfile, readonly Identity[]]> { ): Promise<readonly [string, Pen][]> {
const profile = new UserProfile(); const pens = new Array<[string, Pen]>();
const wallet = profile.addWallet(Secp256k1HdWallet.fromMnemonic(mnemonic));
const identities = new Array<Identity>();
// first account is the token holder // first account is the token holder
const numberOfIdentities = 1 + numberOfDistributors; const numberOfIdentities = 1 + numberOfDistributors;
for (let i = 0; i < numberOfIdentities; i++) { for (let i = 0; i < numberOfIdentities; i++) {
const path = HdPaths.cosmos(i); const path = makeCosmoshubPath(i);
const identity = await profile.createIdentity(wallet.id, chainId, path); const pen = await Secp256k1Pen.fromMnemonic(mnemonic, path);
const address = pen.address(addressPrefix);
if (logging) { if (logging) {
const role = i === 0 ? "token holder " : `distributor ${i}`; const role = i === 0 ? "token holder " : `distributor ${i}`;
const address = identityToAddress(identity);
console.info(`Created ${role} (${debugPath(path)}): ${address}`); console.info(`Created ${role} (${debugPath(path)}): ${address}`);
} }
identities.push(identity); pens.push([address, pen]);
} }
return [profile, identities];
return pens;
} }

View File

@ -1,19 +1,15 @@
import { TokenConfiguration } from "@cosmwasm/bcp";
import { TokenTicker } from "@iov/bcp";
import { TokenManager } from "./tokenmanager"; import { TokenManager } from "./tokenmanager";
import { TokenConfiguration } from "./types";
const dummyConfig: TokenConfiguration = { const dummyConfig: TokenConfiguration = {
bankTokens: [ bankTokens: [
{ {
ticker: "TOKENZ", tickerSymbol: "TOKENZ",
name: "The tokenz",
fractionalDigits: 6, fractionalDigits: 6,
denom: "utokenz", denom: "utokenz",
}, },
{ {
ticker: "TRASH", tickerSymbol: "TRASH",
name: "Trash token",
fractionalDigits: 3, fractionalDigits: 3,
denom: "mtrash", denom: "mtrash",
}, },
@ -32,34 +28,30 @@ describe("TokenManager", () => {
const tm = new TokenManager(dummyConfig); const tm = new TokenManager(dummyConfig);
it("returns 10 tokens by default", () => { it("returns 10 tokens by default", () => {
expect(tm.creditAmount("TOKENZ" as TokenTicker)).toEqual({ expect(tm.creditAmount("TOKENZ")).toEqual({
quantity: "10000000", amount: "10000000",
fractionalDigits: 6, denom: "utokenz",
tokenTicker: "TOKENZ",
}); });
expect(tm.creditAmount("TRASH" as TokenTicker)).toEqual({ expect(tm.creditAmount("TRASH")).toEqual({
quantity: "10000", amount: "10000",
fractionalDigits: 3, denom: "mtrash",
tokenTicker: "TRASH",
}); });
}); });
it("returns value from env variable when set", () => { it("returns value from env variable when set", () => {
process.env.FAUCET_CREDIT_AMOUNT_TRASH = "22"; process.env.FAUCET_CREDIT_AMOUNT_TRASH = "22";
expect(tm.creditAmount("TRASH" as TokenTicker)).toEqual({ expect(tm.creditAmount("TRASH")).toEqual({
quantity: "22000", amount: "22000",
fractionalDigits: 3, denom: "mtrash",
tokenTicker: "TRASH",
}); });
process.env.FAUCET_CREDIT_AMOUNT_TRASH = ""; process.env.FAUCET_CREDIT_AMOUNT_TRASH = "";
}); });
it("returns default when env variable is set to empty", () => { it("returns default when env variable is set to empty", () => {
process.env.FAUCET_CREDIT_AMOUNT_TRASH = ""; process.env.FAUCET_CREDIT_AMOUNT_TRASH = "";
expect(tm.creditAmount("TRASH" as TokenTicker)).toEqual({ expect(tm.creditAmount("TRASH")).toEqual({
quantity: "10000", amount: "10000",
fractionalDigits: 3, denom: "mtrash",
tokenTicker: "TRASH",
}); });
process.env.FAUCET_CREDIT_AMOUNT_TRASH = ""; process.env.FAUCET_CREDIT_AMOUNT_TRASH = "";
}); });
@ -74,38 +66,34 @@ describe("TokenManager", () => {
}); });
it("returns 20*10 + '000' by default", () => { it("returns 20*10 + '000' by default", () => {
expect(tm.refillAmount("TRASH" as TokenTicker)).toEqual({ expect(tm.refillAmount("TRASH")).toEqual({
quantity: "200000", amount: "200000",
fractionalDigits: 3, denom: "mtrash",
tokenTicker: "TRASH",
}); });
}); });
it("returns 20*22 + '000' when credit amount is 22", () => { it("returns 20*22 + '000' when credit amount is 22", () => {
process.env.FAUCET_CREDIT_AMOUNT_TRASH = "22"; process.env.FAUCET_CREDIT_AMOUNT_TRASH = "22";
expect(tm.refillAmount("TRASH" as TokenTicker)).toEqual({ expect(tm.refillAmount("TRASH")).toEqual({
quantity: "440000", amount: "440000",
fractionalDigits: 3, denom: "mtrash",
tokenTicker: "TRASH",
}); });
}); });
it("returns 30*10 + '000' when refill factor is 30", () => { it("returns 30*10 + '000' when refill factor is 30", () => {
process.env.FAUCET_REFILL_FACTOR = "30"; process.env.FAUCET_REFILL_FACTOR = "30";
expect(tm.refillAmount("TRASH" as TokenTicker)).toEqual({ expect(tm.refillAmount("TRASH")).toEqual({
quantity: "300000", amount: "300000",
fractionalDigits: 3, denom: "mtrash",
tokenTicker: "TRASH",
}); });
}); });
it("returns 30*22 + '000' when refill factor is 30 and credit amount is 22", () => { it("returns 30*22 + '000' when refill factor is 30 and credit amount is 22", () => {
process.env.FAUCET_REFILL_FACTOR = "30"; process.env.FAUCET_REFILL_FACTOR = "30";
process.env.FAUCET_CREDIT_AMOUNT_TRASH = "22"; process.env.FAUCET_CREDIT_AMOUNT_TRASH = "22";
expect(tm.refillAmount("TRASH" as TokenTicker)).toEqual({ expect(tm.refillAmount("TRASH")).toEqual({
quantity: "660000", amount: "660000",
fractionalDigits: 3, denom: "mtrash",
tokenTicker: "TRASH",
}); });
}); });
}); });
@ -119,38 +107,34 @@ describe("TokenManager", () => {
}); });
it("returns 8*10 + '000' by default", () => { it("returns 8*10 + '000' by default", () => {
expect(tm.refillThreshold("TRASH" as TokenTicker)).toEqual({ expect(tm.refillThreshold("TRASH")).toEqual({
quantity: "80000", amount: "80000",
fractionalDigits: 3, denom: "mtrash",
tokenTicker: "TRASH",
}); });
}); });
it("returns 8*22 + '000' when credit amount is 22", () => { it("returns 8*22 + '000' when credit amount is 22", () => {
process.env.FAUCET_CREDIT_AMOUNT_TRASH = "22"; process.env.FAUCET_CREDIT_AMOUNT_TRASH = "22";
expect(tm.refillThreshold("TRASH" as TokenTicker)).toEqual({ expect(tm.refillThreshold("TRASH")).toEqual({
quantity: "176000", amount: "176000",
fractionalDigits: 3, denom: "mtrash",
tokenTicker: "TRASH",
}); });
}); });
it("returns 5*10 + '000' when refill threshold is 5", () => { it("returns 5*10 + '000' when refill threshold is 5", () => {
process.env.FAUCET_REFILL_THRESHOLD = "5"; process.env.FAUCET_REFILL_THRESHOLD = "5";
expect(tm.refillThreshold("TRASH" as TokenTicker)).toEqual({ expect(tm.refillThreshold("TRASH")).toEqual({
quantity: "50000", amount: "50000",
fractionalDigits: 3, denom: "mtrash",
tokenTicker: "TRASH",
}); });
}); });
it("returns 5*22 + '000' when refill threshold is 5 and credit amount is 22", () => { it("returns 5*22 + '000' when refill threshold is 5 and credit amount is 22", () => {
process.env.FAUCET_REFILL_THRESHOLD = "5"; process.env.FAUCET_REFILL_THRESHOLD = "5";
process.env.FAUCET_CREDIT_AMOUNT_TRASH = "22"; process.env.FAUCET_CREDIT_AMOUNT_TRASH = "22";
expect(tm.refillThreshold("TRASH" as TokenTicker)).toEqual({ expect(tm.refillThreshold("TRASH")).toEqual({
quantity: "110000", amount: "110000",
fractionalDigits: 3, denom: "mtrash",
tokenTicker: "TRASH",
}); });
}); });
}); });

View File

@ -1,7 +1,8 @@
import { TokenConfiguration } from "@cosmwasm/bcp"; import { Coin } from "@cosmwasm/sdk38";
import { Account, Amount, TokenTicker } from "@iov/bcp";
import { Decimal, Uint53 } from "@iov/encoding"; import { Decimal, Uint53 } from "@iov/encoding";
import { BankTokenMeta, MinimalAccount, TokenConfiguration } from "./types";
/** Send `factor` times credit amount on refilling */ /** Send `factor` times credit amount on refilling */
const defaultRefillFactor = 20; const defaultRefillFactor = 20;
@ -16,50 +17,51 @@ export class TokenManager {
} }
/** The amount of tokens that will be sent to the user */ /** The amount of tokens that will be sent to the user */
public creditAmount(token: TokenTicker, factor: Uint53 = new Uint53(1)): Amount { public creditAmount(tickerSymbol: string, factor: Uint53 = new Uint53(1)): Coin {
const amountFromEnv = process.env[`FAUCET_CREDIT_AMOUNT_${token}`]; const amountFromEnv = process.env[`FAUCET_CREDIT_AMOUNT_${tickerSymbol}`];
const amount = amountFromEnv ? Uint53.fromString(amountFromEnv).toNumber() : 10; const amount = amountFromEnv ? Uint53.fromString(amountFromEnv).toNumber() : 10;
const value = new Uint53(amount * factor.toNumber()); const value = new Uint53(amount * factor.toNumber());
const fractionalDigits = this.getFractionalDigits(token); const meta = this.getTokenMeta(tickerSymbol);
return { return {
quantity: value.toString() + "0".repeat(fractionalDigits), amount: value.toString() + "0".repeat(meta.fractionalDigits),
fractionalDigits: fractionalDigits, denom: meta.denom,
tokenTicker: token,
}; };
} }
public refillAmount(token: TokenTicker): Amount { public refillAmount(tickerSymbol: string): Coin {
const factorFromEnv = Number.parseInt(process.env.FAUCET_REFILL_FACTOR || "0", 10) || undefined; const factorFromEnv = Number.parseInt(process.env.FAUCET_REFILL_FACTOR || "0", 10) || undefined;
const factor = new Uint53(factorFromEnv || defaultRefillFactor); const factor = new Uint53(factorFromEnv || defaultRefillFactor);
return this.creditAmount(token, factor); return this.creditAmount(tickerSymbol, factor);
} }
public refillThreshold(token: TokenTicker): Amount { public refillThreshold(tickerSymbol: string): Coin {
const factorFromEnv = Number.parseInt(process.env.FAUCET_REFILL_THRESHOLD || "0", 10) || undefined; const factorFromEnv = Number.parseInt(process.env.FAUCET_REFILL_THRESHOLD || "0", 10) || undefined;
const factor = new Uint53(factorFromEnv || defaultRefillThresholdFactor); const factor = new Uint53(factorFromEnv || defaultRefillThresholdFactor);
return this.creditAmount(token, factor); return this.creditAmount(tickerSymbol, factor);
} }
/** true iff the distributor account needs a refill */ /** true iff the distributor account needs a refill */
public needsRefill(account: Account, token: TokenTicker): boolean { public needsRefill(account: MinimalAccount, tickerSymbol: string): boolean {
const balanceAmount = account.balance.find((b) => b.tokenTicker === token); const meta = this.getTokenMeta(tickerSymbol);
const balanceAmount = account.balance.find((b) => b.denom === meta.denom);
const balance = balanceAmount const balance = balanceAmount
? Decimal.fromAtomics(balanceAmount.quantity, balanceAmount.fractionalDigits) ? Decimal.fromAtomics(balanceAmount.amount, meta.fractionalDigits)
: Decimal.fromAtomics("0", 0); : Decimal.fromAtomics("0", 0);
const thresholdAmount = this.refillThreshold(token); const thresholdAmount = this.refillThreshold(tickerSymbol);
const threshold = Decimal.fromAtomics(thresholdAmount.quantity, thresholdAmount.fractionalDigits); const threshold = Decimal.fromAtomics(thresholdAmount.amount, meta.fractionalDigits);
// TODO: perform < operation on Decimal type directly // TODO: perform < operation on Decimal type directly
// https://github.com/iov-one/iov-core/issues/1375 // https://github.com/iov-one/iov-core/issues/1375
return balance.toFloatApproximation() < threshold.toFloatApproximation(); return balance.toFloatApproximation() < threshold.toFloatApproximation();
} }
private getFractionalDigits(ticker: TokenTicker): number { private getTokenMeta(tickerSymbol: string): BankTokenMeta {
const match = this.config.bankTokens.find((token) => token.ticker === ticker); const match = this.config.bankTokens.find((token) => token.tickerSymbol === tickerSymbol);
if (!match) throw new Error(`No token found for ticker symbol: ${ticker}`); if (!match) throw new Error(`No token found for ticker symbol: ${tickerSymbol}`);
return match.fractionalDigits; return match;
} }
} }

View File

@ -1,7 +1,32 @@
import { Address, Amount, Identity } from "@iov/bcp"; import { Account, Coin } from "@cosmwasm/sdk38";
export interface SendJob { export interface SendJob {
readonly sender: Identity; readonly sender: string;
readonly recipient: Address; readonly recipient: string;
readonly amount: Amount; readonly amount: Coin;
} }
export interface BankTokenMeta {
readonly denom: string;
/**
* The token ticker symbol, e.g. ATOM or ETH.
*/
readonly tickerSymbol: string;
/**
* The number of fractional digits the token supports.
*
* A quantity is expressed as atomic units. 10^fractionalDigits of those
* atomic units make up 1 token.
*
* E.g. in Ethereum 10^18 wei are 1 ETH and from the quantity 123000000000000000000
* the last 18 digits are the fractional part and the rest the wole part.
*/
readonly fractionalDigits: number;
}
export interface TokenConfiguration {
/** Supported tokens of the Cosmos SDK bank module */
readonly bankTokens: ReadonlyArray<BankTokenMeta>;
}
export type MinimalAccount = Pick<Account, "address" | "balance">;