diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cf29564..4a3191f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## 0.23.0 (unreleased) - @cosmjs/cosmwasm: Rename `CosmWasmClient.postTx` method to `.broadcastTx`. +- @cosmjs/cosmwasm: Rename `FeeTable` type to `CosmWasmFeeTable`. +- @cosmjs/cosmwasm: `SigningCosmWasmClient` constructor now takes optional + arguments `gasPrice` and `gasLimits` instead of `customFees` for easier + customization. - @cosmjs/cosmwasm: Rename `SigningCosmWasmClient.signAndPost` method to `.signAndBroadcast`. - @cosmjs/cosmwasm: Use stricter type `Record` for smart query, @@ -13,7 +17,18 @@ - @cosmjs/encoding: Add `limit` parameter to `Bech32.encode` and `.decode`. The new default limit for decoding is infinity (was 90 before). Set it to 90 to create a strict decoder. +- @cosmjs/faucet: Environmental variable `FAUCET_FEE` renamed to + `FAUCET_GAS_PRICE` and now only accepts one token. Environmental variable + `FAUCET_GAS` renamed to `FAUCET_GAS_LIMIT`. +- @cosmjs/launchpad: Rename `FeeTable` type to `CosmosFeeTable` and export a new + more generic type `FeeTable`. +- @cosmjs/launchpad: Add new class `GasPrice`, new helper type `GasLimits` and + new helper function `buildFeeTable` for easier handling of gas prices and + fees. - @cosmjs/launchpad: Rename `CosmosClient.postTx` method to `.broadcastTx`. +- @cosmjs/launchpad: `SigningCosmosClient` constructor now takes optional + arguments `gasPrice` and `gasLimits` instead of `customFees` for easier + customization. - @cosmjs/launchpad: Rename `SigningCosmosClient.signAndPost` method to `.signAndBroadcast`. - @cosmjs/launchpad: Rename `PostTx`-related types to `BroadcastTxResult`, @@ -24,6 +39,7 @@ `isSearchBySentFromOrToQuery` and `isSearchByTagsQuery`. - @cosmjs/launchpad: Change type of `TxsResponse.logs` and `BroadcastTxsResponse.logs` to `unknown[]`. +- @cosmjs/math: Add `.multiply` method to `Decimal` class. - @cosmjs/tendermint-rpc: Make `BroadcastTxCommitResponse.height` non-optional. - @cosmjs/tendermint-rpc: Change type of `GenesisResponse.appState` to `Record | undefined`. diff --git a/packages/cli/examples/helpers.ts b/packages/cli/examples/helpers.ts index 7001abb3..b3898317 100644 --- a/packages/cli/examples/helpers.ts +++ b/packages/cli/examples/helpers.ts @@ -16,8 +16,8 @@ const defaultOptions: Options = { const defaultFaucetUrl = "https://faucet.demo-10.cosmwasm.com/credit"; -const buildFeeTable = (feeToken: string, gasPrice: number): FeeTable => { - const stdFee = (gas: number, denom: string, price: number) => { +const buildFeeTable = (feeToken: string, gasPrice: number): CosmWasmFeeTable => { + const calculateFee = (gas: number, denom: string, price: number) => { const amount = Math.floor(gas * price); return { amount: [{ amount: amount.toString(), denom: denom }], @@ -26,12 +26,12 @@ const buildFeeTable = (feeToken: string, gasPrice: number): FeeTable => { }; return { - upload: stdFee(1000000, feeToken, gasPrice), - init: stdFee(500000, feeToken, gasPrice), - migrate: stdFee(500000, feeToken, gasPrice), - exec: stdFee(200000, feeToken, gasPrice), - send: stdFee(80000, feeToken, gasPrice), - changeAdmin: stdFee(80000, feeToken, gasPrice), + upload: calculateFee(1000000, feeToken, gasPrice), + init: calculateFee(500000, feeToken, gasPrice), + migrate: calculateFee(500000, feeToken, gasPrice), + exec: calculateFee(200000, feeToken, gasPrice), + send: calculateFee(80000, feeToken, gasPrice), + changeAdmin: calculateFee(80000, feeToken, gasPrice), }; }; @@ -51,11 +51,11 @@ const connect = async ( address: string; }> => { const options: Options = { ...defaultOptions, ...opts }; - const feeTable = buildFeeTable(options.feeToken, options.gasPrice); + const gasPrice = GasPrice.fromString(`${options.gasPrice}${options.feeToken}`); const wallet = await Secp256k1Wallet.fromMnemonic(mnemonic); const [{ address }] = await wallet.getAccounts(); - const client = new SigningCosmWasmClient(options.httpUrl, address, wallet, feeTable); + const client = new SigningCosmWasmClient(options.httpUrl, address, wallet, gasPrice); return { client, address }; }; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index bf937150..f72023af 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -57,7 +57,7 @@ export async function main(originalArgs: readonly string[]): Promise { "SearchTxFilter", // signingcosmwasmclient "ExecuteResult", - "FeeTable", + "CosmWasmFeeTable", "InstantiateResult", "SigningCosmWasmClient", "UploadMeta", @@ -102,6 +102,7 @@ export async function main(originalArgs: readonly string[]): Promise { "BroadcastTxResult", "Coin", "CosmosClient", + "GasPrice", "Msg", "MsgDelegate", "MsgSend", diff --git a/packages/cosmwasm/src/index.ts b/packages/cosmwasm/src/index.ts index 52a495d9..f7900667 100644 --- a/packages/cosmwasm/src/index.ts +++ b/packages/cosmwasm/src/index.ts @@ -21,7 +21,7 @@ export { } from "./cosmwasmclient"; export { ExecuteResult, - FeeTable, + CosmWasmFeeTable, InstantiateOptions, InstantiateResult, MigrateResult, diff --git a/packages/cosmwasm/src/signingcosmwasmclient.spec.ts b/packages/cosmwasm/src/signingcosmwasmclient.spec.ts index 2adc6ebb..56c275a7 100644 --- a/packages/cosmwasm/src/signingcosmwasmclient.spec.ts +++ b/packages/cosmwasm/src/signingcosmwasmclient.spec.ts @@ -6,6 +6,7 @@ import { AuthExtension, coin, coins, + GasPrice, LcdClient, MsgDelegate, Secp256k1Wallet, @@ -15,7 +16,7 @@ import { assert } from "@cosmjs/utils"; import { PrivateCosmWasmClient } from "./cosmwasmclient"; import { setupWasmExtension, WasmExtension } from "./lcdapi/wasm"; -import { SigningCosmWasmClient, UploadMeta } from "./signingcosmwasmclient"; +import { PrivateSigningCosmWasmClient, SigningCosmWasmClient, UploadMeta } from "./signingcosmwasmclient"; import { alice, getHackatom, @@ -38,6 +39,200 @@ describe("SigningCosmWasmClient", () => { const client = new SigningCosmWasmClient(httpUrl, alice.address0, wallet); expect(client).toBeTruthy(); }); + + it("can be constructed with custom gas price", async () => { + const wallet = await Secp256k1Wallet.fromMnemonic(alice.mnemonic); + const gasPrice = GasPrice.fromString("3.14utest"); + const client = new SigningCosmWasmClient(httpUrl, alice.address0, wallet, gasPrice); + const openedClient = (client as unknown) as PrivateSigningCosmWasmClient; + expect(openedClient.fees).toEqual({ + upload: { + amount: [ + { + amount: "3140000", + denom: "utest", + }, + ], + gas: "1000000", + }, + init: { + amount: [ + { + amount: "1570000", + denom: "utest", + }, + ], + gas: "500000", + }, + migrate: { + amount: [ + { + amount: "628000", + denom: "utest", + }, + ], + gas: "200000", + }, + exec: { + amount: [ + { + amount: "628000", + denom: "utest", + }, + ], + gas: "200000", + }, + send: { + amount: [ + { + amount: "251200", + denom: "utest", + }, + ], + gas: "80000", + }, + changeAdmin: { + amount: [ + { + amount: "251200", + denom: "utest", + }, + ], + gas: "80000", + }, + }); + }); + + it("can be constructed with custom gas limits", async () => { + const wallet = await Secp256k1Wallet.fromMnemonic(alice.mnemonic); + const gasLimits = { + send: 160000, + }; + const client = new SigningCosmWasmClient(httpUrl, alice.address0, wallet, undefined, gasLimits); + const openedClient = (client as unknown) as PrivateSigningCosmWasmClient; + expect(openedClient.fees).toEqual({ + upload: { + amount: [ + { + amount: "25000", + denom: "ucosm", + }, + ], + gas: "1000000", + }, + init: { + amount: [ + { + amount: "12500", + denom: "ucosm", + }, + ], + gas: "500000", + }, + migrate: { + amount: [ + { + amount: "5000", + denom: "ucosm", + }, + ], + gas: "200000", + }, + exec: { + amount: [ + { + amount: "5000", + denom: "ucosm", + }, + ], + gas: "200000", + }, + send: { + amount: [ + { + amount: "4000", + denom: "ucosm", + }, + ], + gas: "160000", + }, + changeAdmin: { + amount: [ + { + amount: "2000", + denom: "ucosm", + }, + ], + gas: "80000", + }, + }); + }); + + it("can be constructed with custom gas price and gas limits", async () => { + const wallet = await Secp256k1Wallet.fromMnemonic(alice.mnemonic); + const gasPrice = GasPrice.fromString("3.14utest"); + const gasLimits = { + send: 160000, + }; + const client = new SigningCosmWasmClient(httpUrl, alice.address0, wallet, gasPrice, gasLimits); + const openedClient = (client as unknown) as PrivateSigningCosmWasmClient; + expect(openedClient.fees).toEqual({ + upload: { + amount: [ + { + amount: "3140000", + denom: "utest", + }, + ], + gas: "1000000", + }, + init: { + amount: [ + { + amount: "1570000", + denom: "utest", + }, + ], + gas: "500000", + }, + migrate: { + amount: [ + { + amount: "628000", + denom: "utest", + }, + ], + gas: "200000", + }, + exec: { + amount: [ + { + amount: "628000", + denom: "utest", + }, + ], + gas: "200000", + }, + send: { + amount: [ + { + amount: "502400", + denom: "utest", + }, + ], + gas: "160000", + }, + changeAdmin: { + amount: [ + { + amount: "251200", + denom: "utest", + }, + ], + gas: "80000", + }, + }); + }); }); describe("getHeight", () => { diff --git a/packages/cosmwasm/src/signingcosmwasmclient.ts b/packages/cosmwasm/src/signingcosmwasmclient.ts index 797686cf..d0fff774 100644 --- a/packages/cosmwasm/src/signingcosmwasmclient.ts +++ b/packages/cosmwasm/src/signingcosmwasmclient.ts @@ -5,8 +5,11 @@ import { BroadcastMode, BroadcastTxFailure, BroadcastTxResult, + buildFeeTable, Coin, - coins, + CosmosFeeTable, + GasLimits, + GasPrice, isBroadcastTxFailure, makeSignBytes, Msg, @@ -31,14 +34,13 @@ import { } from "./msgs"; /** - * Those fees are used by the higher level methods of SigningCosmWasmClient + * These fees are used by the higher level methods of SigningCosmWasmClient */ -export interface FeeTable { +export interface CosmWasmFeeTable extends CosmosFeeTable { readonly upload: StdFee; readonly init: StdFee; readonly exec: StdFee; readonly migrate: StdFee; - readonly send: StdFee; /** Paid when setting the contract admin to a new address or unsetting it */ readonly changeAdmin: StdFee; } @@ -52,31 +54,14 @@ function prepareBuilder(buider: string | undefined): string { } } -const defaultFees: FeeTable = { - upload: { - amount: coins(25000, "ucosm"), - gas: "1000000", // one million - }, - init: { - amount: coins(12500, "ucosm"), - gas: "500000", // 500k - }, - migrate: { - amount: coins(5000, "ucosm"), - gas: "200000", // 200k - }, - exec: { - amount: coins(5000, "ucosm"), - gas: "200000", // 200k - }, - send: { - amount: coins(2000, "ucosm"), - gas: "80000", // 80k - }, - changeAdmin: { - amount: coins(2000, "ucosm"), - gas: "80000", // 80k - }, +const defaultGasPrice = GasPrice.fromString("0.025ucosm"); +const defaultGasLimits: GasLimits = { + upload: 1000000, + init: 500000, + migrate: 200000, + exec: 200000, + send: 80000, + changeAdmin: 80000, }; export interface UploadMeta { @@ -158,11 +143,16 @@ function createBroadcastTxErrorMessage(result: BroadcastTxFailure): string { return `Error when broadcasting tx ${result.transactionHash} at height ${result.height}. Code: ${result.code}; Raw log: ${result.rawLog}`; } +/** Use for testing only */ +export interface PrivateSigningCosmWasmClient { + readonly fees: CosmWasmFeeTable; +} + export class SigningCosmWasmClient extends CosmWasmClient { public readonly senderAddress: string; private readonly signer: OfflineSigner; - private readonly fees: FeeTable; + private readonly fees: CosmWasmFeeTable; /** * Creates a new client with signing capability to interact with a CosmWasm blockchain. This is the bigger brother of CosmWasmClient. @@ -173,22 +163,23 @@ export class SigningCosmWasmClient extends CosmWasmClient { * @param apiUrl The URL of a Cosmos SDK light client daemon API (sometimes called REST server or REST API) * @param senderAddress The address that will sign and send transactions using this instance * @param signer An implementation of OfflineSigner which can provide signatures for transactions, potentially requiring user input. - * @param customFees The fees that are paid for transactions + * @param gasPrice The price paid per unit of gas + * @param gasLimits Custom overrides for gas limits related to specific transaction types * @param broadcastMode Defines at which point of the transaction processing the broadcastTx method returns */ public constructor( apiUrl: string, senderAddress: string, signer: OfflineSigner, - customFees?: Partial, + gasPrice: GasPrice = defaultGasPrice, + gasLimits: Partial> = {}, broadcastMode = BroadcastMode.Block, ) { super(apiUrl, broadcastMode); this.anyValidAddress = senderAddress; - this.senderAddress = senderAddress; this.signer = signer; - this.fees = { ...defaultFees, ...(customFees || {}) }; + this.fees = buildFeeTable(gasPrice, defaultGasLimits, gasLimits); } public async getSequence(address?: string): Promise { diff --git a/packages/cosmwasm/types/index.d.ts b/packages/cosmwasm/types/index.d.ts index 1b875e29..f74c9ba5 100644 --- a/packages/cosmwasm/types/index.d.ts +++ b/packages/cosmwasm/types/index.d.ts @@ -20,7 +20,7 @@ export { } from "./cosmwasmclient"; export { ExecuteResult, - FeeTable, + CosmWasmFeeTable, InstantiateOptions, InstantiateResult, MigrateResult, diff --git a/packages/cosmwasm/types/signingcosmwasmclient.d.ts b/packages/cosmwasm/types/signingcosmwasmclient.d.ts index 8f03f640..568c2017 100644 --- a/packages/cosmwasm/types/signingcosmwasmclient.d.ts +++ b/packages/cosmwasm/types/signingcosmwasmclient.d.ts @@ -1,15 +1,24 @@ -import { BroadcastMode, BroadcastTxResult, Coin, Msg, OfflineSigner, StdFee } from "@cosmjs/launchpad"; +import { + BroadcastMode, + BroadcastTxResult, + Coin, + CosmosFeeTable, + GasLimits, + GasPrice, + Msg, + OfflineSigner, + StdFee, +} from "@cosmjs/launchpad"; import { Account, CosmWasmClient, GetSequenceResult } from "./cosmwasmclient"; import { Log } from "./logs"; /** - * Those fees are used by the higher level methods of SigningCosmWasmClient + * These fees are used by the higher level methods of SigningCosmWasmClient */ -export interface FeeTable { +export interface CosmWasmFeeTable extends CosmosFeeTable { readonly upload: StdFee; readonly init: StdFee; readonly exec: StdFee; readonly migrate: StdFee; - readonly send: StdFee; /** Paid when setting the contract admin to a new address or unsetting it */ readonly changeAdmin: StdFee; } @@ -81,6 +90,10 @@ export interface ExecuteResult { /** Transaction hash (might be used as transaction ID). Guaranteed to be non-empty upper-case hex */ readonly transactionHash: string; } +/** Use for testing only */ +export interface PrivateSigningCosmWasmClient { + readonly fees: CosmWasmFeeTable; +} export declare class SigningCosmWasmClient extends CosmWasmClient { readonly senderAddress: string; private readonly signer; @@ -94,14 +107,16 @@ export declare class SigningCosmWasmClient extends CosmWasmClient { * @param apiUrl The URL of a Cosmos SDK light client daemon API (sometimes called REST server or REST API) * @param senderAddress The address that will sign and send transactions using this instance * @param signer An implementation of OfflineSigner which can provide signatures for transactions, potentially requiring user input. - * @param customFees The fees that are paid for transactions + * @param gasPrice The price paid per unit of gas + * @param gasLimits Custom overrides for gas limits related to specific transaction types * @param broadcastMode Defines at which point of the transaction processing the broadcastTx method returns */ constructor( apiUrl: string, senderAddress: string, signer: OfflineSigner, - customFees?: Partial, + gasPrice?: GasPrice, + gasLimits?: Partial>, broadcastMode?: BroadcastMode, ); getSequence(address?: string): Promise; diff --git a/packages/faucet/README.md b/packages/faucet/README.md index eda63418..c191adbf 100644 --- a/packages/faucet/README.md +++ b/packages/faucet/README.md @@ -47,9 +47,9 @@ Environment variables FAUCET_CONCURRENCY Number of distributor accounts. Defaults to 5. FAUCET_PORT Port of the webserver. Defaults to 8000. FAUCET_MEMO Memo for send transactions. Defaults to unset. -FAUCET_FEE Fee for send transactions as a comma separated list, - e.g. "200ushell,30ureef". Defaults to "2000ucosm". -FAUCET_GAS Gas for send transactions. Defaults to 80000. +FAUCET_GAS_PRICE Gas price for transactions as a comma separated list. + Defaults to "0.025ucosm". +FAUCET_GAS_LIMIT Gas limit for send transactions. Defaults to 80000. FAUCET_MNEMONIC Secret mnemonic that serves as the base secret for the faucet HD accounts FAUCET_ADDRESS_PREFIX The bech32 address prefix. Defaults to "cosmos". diff --git a/packages/faucet/src/actions/help.ts b/packages/faucet/src/actions/help.ts index 466c96c6..1c6a73b7 100644 --- a/packages/faucet/src/actions/help.ts +++ b/packages/faucet/src/actions/help.ts @@ -20,9 +20,9 @@ Environment variables FAUCET_CONCURRENCY Number of distributor accounts. Defaults to 5. FAUCET_PORT Port of the webserver. Defaults to 8000. FAUCET_MEMO Memo for send transactions. Defaults to unset. -FAUCET_FEE Fee for send transactions as a comma separated list, - e.g. "200ushell,30ureef". Defaults to "2000ucosm". -FAUCET_GAS Gas for send transactions. Defaults to 80000. +FAUCET_GAS_PRICE Gas price for transactions as a comma separated list. + Defaults to "0.025ucosm". +FAUCET_GAS_LIMIT Gas limit for send transactions. Defaults to 80000. FAUCET_MNEMONIC Secret mnemonic that serves as the base secret for the faucet HD accounts FAUCET_ADDRESS_PREFIX The bech32 address prefix. Defaults to "cosmos". diff --git a/packages/faucet/src/constants.ts b/packages/faucet/src/constants.ts index b20ef9af..32822e70 100644 --- a/packages/faucet/src/constants.ts +++ b/packages/faucet/src/constants.ts @@ -1,12 +1,14 @@ -import { Coin, parseCoins } from "@cosmjs/launchpad"; +import { CosmosFeeTable, GasLimits, GasPrice } from "@cosmjs/launchpad"; import { TokenConfiguration } from "./tokenmanager"; import { parseBankTokens } from "./tokens"; export const binaryName = "cosmos-faucet"; export const memo: string | undefined = process.env.FAUCET_MEMO; -export const fee: readonly Coin[] = parseCoins(process.env.FAUCET_FEE || "2000ucosm"); -export const gas: string = process.env.FAUCET_GAS || "80000"; +export const gasPrice = GasPrice.fromString(process.env.FAUCET_GAS_PRICE || "0.025ucosm"); +export const gasLimits: GasLimits = { + send: parseInt(process.env.FAUCET_GAS_LIMIT || "80000", 10), +}; 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; diff --git a/packages/faucet/src/faucet.ts b/packages/faucet/src/faucet.ts index c091ce5a..910b6569 100644 --- a/packages/faucet/src/faucet.ts +++ b/packages/faucet/src/faucet.ts @@ -1,7 +1,6 @@ import { assertIsBroadcastTxSuccess, CosmosClient, - FeeTable, OfflineSigner, SigningCosmosClient, } from "@cosmjs/launchpad"; @@ -57,17 +56,16 @@ export class Faucet { this.holderAddress = wallets[0][0]; this.distributorAddresses = wallets.slice(1).map((pair) => pair[0]); - const fees: Partial = { - send: { - amount: constants.fee, - gas: constants.gas, - }, - }; - // we need one client per sender const clients: { [senderAddress: string]: SigningCosmosClient } = {}; for (const [senderAddress, wallet] of wallets) { - clients[senderAddress] = new SigningCosmosClient(apiUrl, senderAddress, wallet, fees); + clients[senderAddress] = new SigningCosmosClient( + apiUrl, + senderAddress, + wallet, + constants.gasPrice, + constants.gasLimits, + ); } this.clients = clients; this.logging = logging; diff --git a/packages/launchpad/src/gas.spec.ts b/packages/launchpad/src/gas.spec.ts new file mode 100644 index 00000000..a92e4e08 --- /dev/null +++ b/packages/launchpad/src/gas.spec.ts @@ -0,0 +1,23 @@ +import { Decimal } from "@cosmjs/math"; + +import { GasPrice } from "./gas"; + +describe("GasPrice", () => { + it("can be constructed", () => { + const inputs = ["3.14", "3", "0.14"]; + inputs.forEach((input) => { + const gasPrice = new GasPrice(Decimal.fromUserInput(input, 18), "utest"); + expect(gasPrice.amount.toString()).toEqual(input); + expect(gasPrice.denom).toEqual("utest"); + }); + }); + + it("can be constructed from a config string", () => { + const inputs = ["3.14", "3", "0.14"]; + inputs.forEach((input) => { + const gasPrice = GasPrice.fromString(`${input}utest`); + expect(gasPrice.amount.toString()).toEqual(input); + expect(gasPrice.denom).toEqual("utest"); + }); + }); +}); diff --git a/packages/launchpad/src/gas.ts b/packages/launchpad/src/gas.ts new file mode 100644 index 00000000..8f27dfdc --- /dev/null +++ b/packages/launchpad/src/gas.ts @@ -0,0 +1,56 @@ +import { Decimal, Uint53 } from "@cosmjs/math"; + +import { coins } from "./coins"; +import { StdFee } from "./types"; + +export type FeeTable = Record; + +export class GasPrice { + public readonly amount: Decimal; + public readonly denom: string; + + constructor(amount: Decimal, denom: string) { + this.amount = amount; + this.denom = denom; + } + + public static fromString(gasPrice: string): GasPrice { + const matchResult = gasPrice.match(/^(?.+?)(?[a-z]+)$/); + if (!matchResult) { + throw new Error("Invalid gas price string"); + } + const { amount, denom } = matchResult.groups as { readonly amount: string; readonly denom: string }; + if (denom.length < 3 || denom.length > 127) { + throw new Error("Gas price denomination must be between 3 and 127 characters"); + } + const fractionalDigits = 18; + const decimalAmount = Decimal.fromUserInput(amount, fractionalDigits); + return new GasPrice(decimalAmount, denom); + } +} + +export type GasLimits> = { + readonly [key in keyof T]: number; +}; + +function calculateFee(gasLimit: number, { denom, amount: gasPriceAmount }: GasPrice): StdFee { + const amount = Math.ceil(gasPriceAmount.multiply(new Uint53(gasLimit)).toFloatApproximation()); + return { + amount: coins(amount, denom), + gas: gasLimit.toString(), + }; +} + +export function buildFeeTable>( + gasPrice: GasPrice, + defaultGasLimits: GasLimits, + gasLimits: Partial>, +): T { + return Object.entries(defaultGasLimits).reduce( + (feeTable, [type, defaultGasLimit]) => ({ + ...feeTable, + [type]: calculateFee(gasLimits[type] || defaultGasLimit, gasPrice), + }), + {} as T, + ); +} diff --git a/packages/launchpad/src/index.ts b/packages/launchpad/src/index.ts index 671863ea..49a0ea91 100644 --- a/packages/launchpad/src/index.ts +++ b/packages/launchpad/src/index.ts @@ -29,6 +29,7 @@ export { isSearchByTagsQuery, } from "./cosmosclient"; export { makeSignBytes } from "./encoding"; +export { buildFeeTable, FeeTable, GasLimits, GasPrice } from "./gas"; export { AuthAccountsResponse, AuthExtension, @@ -97,7 +98,7 @@ export { } from "./pubkey"; export { findSequenceForSignedTx } from "./sequence"; export { encodeSecp256k1Signature, decodeSignature } from "./signature"; -export { FeeTable, SigningCosmosClient } from "./signingcosmosclient"; +export { CosmosFeeTable, SigningCosmosClient } from "./signingcosmosclient"; export { isStdTx, pubkeyType, CosmosSdkTx, PubKey, StdFee, StdSignature, StdTx } from "./types"; export { AccountData, diff --git a/packages/launchpad/src/lcdapi/distribution.spec.ts b/packages/launchpad/src/lcdapi/distribution.spec.ts index 38562109..66e7cd9b 100644 --- a/packages/launchpad/src/lcdapi/distribution.spec.ts +++ b/packages/launchpad/src/lcdapi/distribution.spec.ts @@ -33,7 +33,7 @@ describe("DistributionExtension", () => { beforeAll(async () => { if (wasmdEnabled()) { const wallet = await Secp256k1Wallet.fromMnemonic(faucet.mnemonic); - const client = new SigningCosmosClient(wasmd.endpoint, faucet.address, wallet, {}); + const client = new SigningCosmosClient(wasmd.endpoint, faucet.address, wallet); const chainId = await client.getChainId(); const msg: MsgDelegate = { diff --git a/packages/launchpad/src/lcdapi/gov.spec.ts b/packages/launchpad/src/lcdapi/gov.spec.ts index 44f79889..c6cd10d1 100644 --- a/packages/launchpad/src/lcdapi/gov.spec.ts +++ b/packages/launchpad/src/lcdapi/gov.spec.ts @@ -31,7 +31,7 @@ describe("GovExtension", () => { beforeAll(async () => { if (wasmdEnabled()) { const wallet = await Secp256k1Wallet.fromMnemonic(faucet.mnemonic); - const client = new SigningCosmosClient(wasmd.endpoint, faucet.address, wallet, {}); + const client = new SigningCosmosClient(wasmd.endpoint, faucet.address, wallet); const chainId = await client.getChainId(); const proposalMsg = { diff --git a/packages/launchpad/src/lcdapi/staking.spec.ts b/packages/launchpad/src/lcdapi/staking.spec.ts index 4580bf17..5127f65e 100644 --- a/packages/launchpad/src/lcdapi/staking.spec.ts +++ b/packages/launchpad/src/lcdapi/staking.spec.ts @@ -34,7 +34,7 @@ describe("StakingExtension", () => { beforeAll(async () => { if (wasmdEnabled()) { const wallet = await Secp256k1Wallet.fromMnemonic(faucet.mnemonic); - const client = new SigningCosmosClient(wasmd.endpoint, faucet.address, wallet, {}); + const client = new SigningCosmosClient(wasmd.endpoint, faucet.address, wallet); const chainId = await client.getChainId(); { diff --git a/packages/launchpad/src/signingcosmosclient.spec.ts b/packages/launchpad/src/signingcosmosclient.spec.ts index d657d140..6ab1c5e9 100644 --- a/packages/launchpad/src/signingcosmosclient.spec.ts +++ b/packages/launchpad/src/signingcosmosclient.spec.ts @@ -3,9 +3,10 @@ import { assert } from "@cosmjs/utils"; import { Coin, coin, coins } from "./coins"; import { assertIsBroadcastTxSuccess, PrivateCosmosClient } from "./cosmosclient"; +import { GasPrice } from "./gas"; import { MsgDelegate } from "./msgs"; import { Secp256k1Wallet } from "./secp256k1wallet"; -import { SigningCosmosClient } from "./signingcosmosclient"; +import { PrivateSigningCosmosClient, SigningCosmosClient } from "./signingcosmosclient"; import { makeRandomAddress, pendingWithoutWasmd, validatorAddress } from "./testutils.spec"; const httpUrl = "http://localhost:1317"; @@ -22,10 +23,80 @@ const faucet = { describe("SigningCosmosClient", () => { describe("makeReadOnly", () => { - it("can be constructed", async () => { + it("can be constructed with default fees", async () => { const wallet = await Secp256k1Wallet.fromMnemonic(faucet.mnemonic); const client = new SigningCosmosClient(httpUrl, faucet.address, wallet); - expect(client).toBeTruthy(); + const openedClient = (client as unknown) as PrivateSigningCosmosClient; + expect(openedClient.fees).toEqual({ + send: { + amount: [ + { + amount: "2000", + denom: "ucosm", + }, + ], + gas: "80000", + }, + }); + }); + + it("can be constructed with custom gas price", async () => { + const wallet = await Secp256k1Wallet.fromMnemonic(faucet.mnemonic); + const gasPrice = GasPrice.fromString("3.14utest"); + const client = new SigningCosmosClient(httpUrl, faucet.address, wallet, gasPrice); + const openedClient = (client as unknown) as PrivateSigningCosmosClient; + expect(openedClient.fees).toEqual({ + send: { + amount: [ + { + amount: "251200", // 3.14 * 80_000 + denom: "utest", + }, + ], + gas: "80000", + }, + }); + }); + + it("can be constructed with custom gas limits", async () => { + const wallet = await Secp256k1Wallet.fromMnemonic(faucet.mnemonic); + const gasLimits = { + send: 160000, + }; + const client = new SigningCosmosClient(httpUrl, faucet.address, wallet, undefined, gasLimits); + const openedClient = (client as unknown) as PrivateSigningCosmosClient; + expect(openedClient.fees).toEqual({ + send: { + amount: [ + { + amount: "4000", // 0.025 * 160_000 + denom: "ucosm", + }, + ], + gas: "160000", + }, + }); + }); + + it("can be constructed with custom gas price and gas limits", async () => { + const wallet = await Secp256k1Wallet.fromMnemonic(faucet.mnemonic); + const gasPrice = GasPrice.fromString("3.14utest"); + const gasLimits = { + send: 160000, + }; + const client = new SigningCosmosClient(httpUrl, faucet.address, wallet, gasPrice, gasLimits); + const openedClient = (client as unknown) as PrivateSigningCosmosClient; + expect(openedClient.fees).toEqual({ + send: { + amount: [ + { + amount: "502400", // 3.14 * 160_000 + denom: "utest", + }, + ], + gas: "160000", + }, + }); }); }); diff --git a/packages/launchpad/src/signingcosmosclient.ts b/packages/launchpad/src/signingcosmosclient.ts index ea9a79e6..5ffbd236 100644 --- a/packages/launchpad/src/signingcosmosclient.ts +++ b/packages/launchpad/src/signingcosmosclient.ts @@ -1,31 +1,33 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import { Coin, coins } from "./coins"; +import { Coin } from "./coins"; import { Account, BroadcastTxResult, CosmosClient, GetSequenceResult } from "./cosmosclient"; import { makeSignBytes } from "./encoding"; +import { buildFeeTable, FeeTable, GasLimits, GasPrice } from "./gas"; import { BroadcastMode } from "./lcdapi"; import { Msg, MsgSend } from "./msgs"; import { StdFee, StdTx } from "./types"; import { OfflineSigner } from "./wallet"; /** - * Those fees are used by the higher level methods of SigningCosmosClient + * These fees are used by the higher level methods of SigningCosmosClient */ -export interface FeeTable { +export interface CosmosFeeTable extends FeeTable { readonly send: StdFee; } -const defaultFees: FeeTable = { - send: { - amount: coins(2000, "ucosm"), - gas: "80000", // 80k - }, -}; +const defaultGasPrice = GasPrice.fromString("0.025ucosm"); +const defaultGasLimits: GasLimits = { send: 80000 }; + +/** Use for testing only */ +export interface PrivateSigningCosmosClient { + readonly fees: CosmosFeeTable; +} export class SigningCosmosClient extends CosmosClient { public readonly senderAddress: string; private readonly signer: OfflineSigner; - private readonly fees: FeeTable; + private readonly fees: CosmosFeeTable; /** * Creates a new client with signing capability to interact with a Cosmos SDK blockchain. This is the bigger brother of CosmosClient. @@ -36,22 +38,23 @@ export class SigningCosmosClient extends CosmosClient { * @param apiUrl The URL of a Cosmos SDK light client daemon API (sometimes called REST server or REST API) * @param senderAddress The address that will sign and send transactions using this instance * @param signer An implementation of OfflineSigner which can provide signatures for transactions, potentially requiring user input. - * @param customFees The fees that are paid for transactions + * @param gasPrice The price paid per unit of gas + * @param gasLimits Custom overrides for gas limits related to specific transaction types * @param broadcastMode Defines at which point of the transaction processing the broadcastTx method returns */ public constructor( apiUrl: string, senderAddress: string, signer: OfflineSigner, - customFees?: Partial, + gasPrice: GasPrice = defaultGasPrice, + gasLimits: Partial> = {}, broadcastMode = BroadcastMode.Block, ) { super(apiUrl, broadcastMode); this.anyValidAddress = senderAddress; - this.senderAddress = senderAddress; this.signer = signer; - this.fees = { ...defaultFees, ...(customFees || {}) }; + this.fees = buildFeeTable(gasPrice, defaultGasLimits, gasLimits); } public async getSequence(address?: string): Promise { diff --git a/packages/launchpad/types/gas.d.ts b/packages/launchpad/types/gas.d.ts new file mode 100644 index 00000000..8fccc2bc --- /dev/null +++ b/packages/launchpad/types/gas.d.ts @@ -0,0 +1,17 @@ +import { Decimal } from "@cosmjs/math"; +import { StdFee } from "./types"; +export declare type FeeTable = Record; +export declare class GasPrice { + readonly amount: Decimal; + readonly denom: string; + constructor(amount: Decimal, denom: string); + static fromString(gasPrice: string): GasPrice; +} +export declare type GasLimits> = { + readonly [key in keyof T]: number; +}; +export declare function buildFeeTable>( + gasPrice: GasPrice, + defaultGasLimits: GasLimits, + gasLimits: Partial>, +): T; diff --git a/packages/launchpad/types/index.d.ts b/packages/launchpad/types/index.d.ts index 8c7d2c0c..17332cf2 100644 --- a/packages/launchpad/types/index.d.ts +++ b/packages/launchpad/types/index.d.ts @@ -27,6 +27,7 @@ export { isSearchByTagsQuery, } from "./cosmosclient"; export { makeSignBytes } from "./encoding"; +export { buildFeeTable, FeeTable, GasLimits, GasPrice } from "./gas"; export { AuthAccountsResponse, AuthExtension, @@ -95,7 +96,7 @@ export { } from "./pubkey"; export { findSequenceForSignedTx } from "./sequence"; export { encodeSecp256k1Signature, decodeSignature } from "./signature"; -export { FeeTable, SigningCosmosClient } from "./signingcosmosclient"; +export { CosmosFeeTable, SigningCosmosClient } from "./signingcosmosclient"; export { isStdTx, pubkeyType, CosmosSdkTx, PubKey, StdFee, StdSignature, StdTx } from "./types"; export { AccountData, diff --git a/packages/launchpad/types/signingcosmosclient.d.ts b/packages/launchpad/types/signingcosmosclient.d.ts index 9e6062a2..e1110ad9 100644 --- a/packages/launchpad/types/signingcosmosclient.d.ts +++ b/packages/launchpad/types/signingcosmosclient.d.ts @@ -1,15 +1,20 @@ import { Coin } from "./coins"; import { Account, BroadcastTxResult, CosmosClient, GetSequenceResult } from "./cosmosclient"; +import { FeeTable, GasLimits, GasPrice } from "./gas"; import { BroadcastMode } from "./lcdapi"; import { Msg } from "./msgs"; import { StdFee } from "./types"; import { OfflineSigner } from "./wallet"; /** - * Those fees are used by the higher level methods of SigningCosmosClient + * These fees are used by the higher level methods of SigningCosmosClient */ -export interface FeeTable { +export interface CosmosFeeTable extends FeeTable { readonly send: StdFee; } +/** Use for testing only */ +export interface PrivateSigningCosmosClient { + readonly fees: CosmosFeeTable; +} export declare class SigningCosmosClient extends CosmosClient { readonly senderAddress: string; private readonly signer; @@ -23,14 +28,16 @@ export declare class SigningCosmosClient extends CosmosClient { * @param apiUrl The URL of a Cosmos SDK light client daemon API (sometimes called REST server or REST API) * @param senderAddress The address that will sign and send transactions using this instance * @param signer An implementation of OfflineSigner which can provide signatures for transactions, potentially requiring user input. - * @param customFees The fees that are paid for transactions + * @param gasPrice The price paid per unit of gas + * @param gasLimits Custom overrides for gas limits related to specific transaction types * @param broadcastMode Defines at which point of the transaction processing the broadcastTx method returns */ constructor( apiUrl: string, senderAddress: string, signer: OfflineSigner, - customFees?: Partial, + gasPrice?: GasPrice, + gasLimits?: Partial>, broadcastMode?: BroadcastMode, ); getSequence(address?: string): Promise; diff --git a/packages/math/src/decimal.spec.ts b/packages/math/src/decimal.spec.ts index b0e1803b..9f9dee5b 100644 --- a/packages/math/src/decimal.spec.ts +++ b/packages/math/src/decimal.spec.ts @@ -1,4 +1,5 @@ import { Decimal } from "./decimal"; +import { Uint32, Uint53, Uint64 } from "./integers"; describe("Decimal", () => { describe("fromAtomics", () => { @@ -211,6 +212,87 @@ describe("Decimal", () => { }); }); + describe("multiply", () => { + it("returns correct values for Uint32", () => { + const zero = Decimal.fromUserInput("0", 5); + expect(zero.multiply(new Uint32(0)).toString()).toEqual("0"); + expect(zero.multiply(new Uint32(1)).toString()).toEqual("0"); + expect(zero.multiply(new Uint32(2)).toString()).toEqual("0"); + expect(zero.multiply(new Uint32(4294967295)).toString()).toEqual("0"); + + const one = Decimal.fromUserInput("1", 5); + expect(one.multiply(new Uint32(0)).toString()).toEqual("0"); + expect(one.multiply(new Uint32(1)).toString()).toEqual("1"); + expect(one.multiply(new Uint32(2)).toString()).toEqual("2"); + expect(one.multiply(new Uint32(4294967295)).toString()).toEqual("4294967295"); + + const oneDotFive = Decimal.fromUserInput("1.5", 5); + expect(oneDotFive.multiply(new Uint32(0)).toString()).toEqual("0"); + expect(oneDotFive.multiply(new Uint32(1)).toString()).toEqual("1.5"); + expect(oneDotFive.multiply(new Uint32(2)).toString()).toEqual("3"); + expect(oneDotFive.multiply(new Uint32(4294967295)).toString()).toEqual("6442450942.5"); + + // original value remain unchanged + expect(zero.toString()).toEqual("0"); + expect(one.toString()).toEqual("1"); + expect(oneDotFive.toString()).toEqual("1.5"); + }); + + it("returns correct values for Uint53", () => { + const zero = Decimal.fromUserInput("0", 5); + expect(zero.multiply(new Uint53(0)).toString()).toEqual("0"); + expect(zero.multiply(new Uint53(1)).toString()).toEqual("0"); + expect(zero.multiply(new Uint53(2)).toString()).toEqual("0"); + expect(zero.multiply(new Uint53(9007199254740991)).toString()).toEqual("0"); + + const one = Decimal.fromUserInput("1", 5); + expect(one.multiply(new Uint53(0)).toString()).toEqual("0"); + expect(one.multiply(new Uint53(1)).toString()).toEqual("1"); + expect(one.multiply(new Uint53(2)).toString()).toEqual("2"); + expect(one.multiply(new Uint53(9007199254740991)).toString()).toEqual("9007199254740991"); + + const oneDotFive = Decimal.fromUserInput("1.5", 5); + expect(oneDotFive.multiply(new Uint53(0)).toString()).toEqual("0"); + expect(oneDotFive.multiply(new Uint53(1)).toString()).toEqual("1.5"); + expect(oneDotFive.multiply(new Uint53(2)).toString()).toEqual("3"); + expect(oneDotFive.multiply(new Uint53(9007199254740991)).toString()).toEqual("13510798882111486.5"); + + // original value remain unchanged + expect(zero.toString()).toEqual("0"); + expect(one.toString()).toEqual("1"); + expect(oneDotFive.toString()).toEqual("1.5"); + }); + + it("returns correct values for Uint64", () => { + const zero = Decimal.fromUserInput("0", 5); + expect(zero.multiply(Uint64.fromString("0")).toString()).toEqual("0"); + expect(zero.multiply(Uint64.fromString("1")).toString()).toEqual("0"); + expect(zero.multiply(Uint64.fromString("2")).toString()).toEqual("0"); + expect(zero.multiply(Uint64.fromString("18446744073709551615")).toString()).toEqual("0"); + + const one = Decimal.fromUserInput("1", 5); + expect(one.multiply(Uint64.fromString("0")).toString()).toEqual("0"); + expect(one.multiply(Uint64.fromString("1")).toString()).toEqual("1"); + expect(one.multiply(Uint64.fromString("2")).toString()).toEqual("2"); + expect(one.multiply(Uint64.fromString("18446744073709551615")).toString()).toEqual( + "18446744073709551615", + ); + + const oneDotFive = Decimal.fromUserInput("1.5", 5); + expect(oneDotFive.multiply(Uint64.fromString("0")).toString()).toEqual("0"); + expect(oneDotFive.multiply(Uint64.fromString("1")).toString()).toEqual("1.5"); + expect(oneDotFive.multiply(Uint64.fromString("2")).toString()).toEqual("3"); + expect(oneDotFive.multiply(Uint64.fromString("18446744073709551615")).toString()).toEqual( + "27670116110564327422.5", + ); + + // original value remain unchanged + expect(zero.toString()).toEqual("0"); + expect(one.toString()).toEqual("1"); + expect(oneDotFive.toString()).toEqual("1.5"); + }); + }); + describe("equals", () => { it("returns correct values", () => { const zero = Decimal.fromUserInput("0", 5); diff --git a/packages/math/src/decimal.ts b/packages/math/src/decimal.ts index ffedd0ed..75ddf103 100644 --- a/packages/math/src/decimal.ts +++ b/packages/math/src/decimal.ts @@ -1,5 +1,7 @@ import BN from "bn.js"; +import { Uint32, Uint53, Uint64 } from "./integers"; + // Too large values lead to massive memory usage. Limit to something sensible. // The largest value we need is 18 (Ether). const maxFractionalDigits = 100; @@ -124,6 +126,16 @@ export class Decimal { return new Decimal(sum.toString(), this.fractionalDigits); } + /** + * a.multiply(b) returns a*b. + * + * We only allow multiplication by unsigned integers to avoid rounding errors. + */ + public multiply(b: Uint32 | Uint53 | Uint64): Decimal { + const product = this.data.atomics.mul(new BN(b.toString())); + return new Decimal(product.toString(), this.fractionalDigits); + } + public equals(b: Decimal): boolean { return Decimal.compare(this, b) === 0; } diff --git a/packages/math/types/decimal.d.ts b/packages/math/types/decimal.d.ts index 0642fa07..e2828055 100644 --- a/packages/math/types/decimal.d.ts +++ b/packages/math/types/decimal.d.ts @@ -1,3 +1,4 @@ +import { Uint32, Uint53, Uint64 } from "./integers"; /** * A type for arbitrary precision, non-negative decimals. * @@ -24,6 +25,12 @@ export declare class Decimal { * Both values need to have the same fractional digits. */ plus(b: Decimal): Decimal; + /** + * a.multiply(b) returns a*b. + * + * We only allow multiplication by unsigned integers to avoid rounding errors. + */ + multiply(b: Uint32 | Uint53 | Uint64): Decimal; equals(b: Decimal): boolean; isLessThan(b: Decimal): boolean; isLessThanOrEqual(b: Decimal): boolean;