@chorus-one/cosmos
Version:
All-in-one toolkit for building staking dApps on Cosmos SDK based networks
410 lines (339 loc) • 12.7 kB
text/typescript
import {
coin,
AminoTypes,
createStakingAminoConverters,
createAuthzAminoConverters,
createBankAminoConverters,
createDistributionAminoConverters,
createGovAminoConverters,
createIbcAminoConverters,
createVestingAminoConverters,
defaultRegistryTypes
} from '@cosmjs/stargate'
import type {
Coin,
MsgDelegateEncodeObject,
MsgUndelegateEncodeObject,
MsgBeginRedelegateEncodeObject,
MsgWithdrawDelegatorRewardEncodeObject,
AminoConverters,
StargateClient,
Account
} from '@cosmjs/stargate'
import { TxRaw } from 'cosmjs-types/cosmos/tx/v1beta1/tx'
import { toBech32, fromBase64 } from '@cosmjs/encoding'
import { SignMode } from 'cosmjs-types/cosmos/tx/signing/v1beta1/signing'
import { MsgBeginRedelegate, MsgDelegate } from 'cosmjs-types/cosmos/staking/v1beta1/tx'
import { MsgWithdrawDelegatorReward } from 'cosmjs-types/cosmos/distribution/v1beta1/tx'
import { Int53 } from '@cosmjs/math'
import type { Signature, Signer } from '@chorus-one/signer'
import type { CosmosNetworkConfig, CosmosSigningData } from './types'
import { Sha256, keccak256, Secp256k1 } from '@cosmjs/crypto'
import { publicKeyConvert } from 'secp256k1'
import { SafeJSONStringify, checkMaxDecimalPlaces } from '@chorus-one/utils'
import { CosmosClient } from './client'
import BigNumber from 'bignumber.js'
import { Registry, encodePubkey, makeAuthInfoBytes } from '@cosmjs/proto-signing'
import type { TxBodyEncodeObject, EncodeObject } from '@cosmjs/proto-signing'
import {
makeSignDoc as makeSignDocAmino,
serializeSignDoc,
encodeSecp256k1Pubkey,
encodeSecp256k1Signature
} from '@cosmjs/amino'
import type { StdFee, StdSignDoc } from '@cosmjs/amino'
import { rawSecp256k1PubkeyToRawAddress } from '@cosmjs/amino'
function createDefaultTypes (): AminoConverters {
return {
...createAuthzAminoConverters(),
...createBankAminoConverters(),
...createDistributionAminoConverters(),
...createGovAminoConverters(),
...createStakingAminoConverters(),
...createIbcAminoConverters(),
...createVestingAminoConverters()
}
}
function toCoin (
amount: string, // in lowest denom (e.g. uatom)
expectedDenom: string // e.g. uatom
): Coin {
const total: string | undefined = amount.match(/\d+/)?.at(0)
const denom: string | undefined = amount.match(/[^\d.-]+/)?.at(0)
if (total === undefined) {
throw Error('failed to extract total amount of tokens from: ' + amount)
}
if (denom !== undefined && denom !== expectedDenom) {
throw new Error('denom mismatch, expected: ' + expectedDenom + ' got: ' + denom)
}
return coin(total, expectedDenom)
}
export function macroToDenomAmount (
amount: string, // in macro denom (e.g. ATOM)
denomMultiplier: string
): string {
checkMaxDecimalPlaces(denomMultiplier)
if (BigInt(denomMultiplier) === BigInt(0)) {
throw new Error('denomMultiplier cannot be 0')
}
if (BigNumber(amount).isNaN()) {
throw new Error('invalid amount: ' + amount + ' failed to parse to number')
}
const macroAmount = BigNumber(denomMultiplier).multipliedBy(amount)
if (macroAmount.isNegative()) {
throw new Error('amount cannot be negative')
}
const decimalPlaces = macroAmount.decimalPlaces()
if (decimalPlaces !== null && decimalPlaces > 0) {
throw new Error(
`exceeded maximum denominator precision, amount: ${macroAmount.toString()}, precision: .${macroAmount.precision()}`
)
}
return macroAmount.toString(10)
}
export function denomToMacroAmount (
amount: string, // in denom (e.g. uatom, adydx)
denomMultiplier: string
): string {
checkMaxDecimalPlaces(denomMultiplier)
if (BigInt(denomMultiplier) === BigInt(0)) {
throw new Error('denomMultiplier cannot be 0')
}
if (BigNumber(amount).isNaN()) {
throw new Error('invalid amount: ' + amount + ' failed to parse to number')
}
return BigNumber(amount).dividedBy(denomMultiplier).toString(10)
}
export function genWithdrawRewardsMsg (
delegatorAddress: string,
validatorAddress: string
): MsgWithdrawDelegatorRewardEncodeObject {
const withdrawRewardsMsg: MsgWithdrawDelegatorRewardEncodeObject = {
typeUrl: '/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward',
value: MsgWithdrawDelegatorReward.fromPartial({
delegatorAddress: delegatorAddress,
validatorAddress: validatorAddress
})
}
return withdrawRewardsMsg
}
export function genDelegateOrUndelegateMsg (
networkConfig: CosmosNetworkConfig,
msgType: string,
delegatorAddress: string,
validatorAddress: string,
amount: string // in lowest denom (e.g. uatom)
): MsgDelegateEncodeObject | MsgUndelegateEncodeObject {
const coins = toCoin(amount, networkConfig.denom)
if (!['delegate', 'undelegate'].some((x) => x === msgType)) {
throw new Error('invalid type: ' + msgType)
}
const delegateMsg: MsgDelegateEncodeObject | MsgUndelegateEncodeObject = {
typeUrl: msgType === 'delegate' ? '/cosmos.staking.v1beta1.MsgDelegate' : '/cosmos.staking.v1beta1.MsgUndelegate',
value: MsgDelegate.fromPartial({
delegatorAddress: delegatorAddress,
validatorAddress: validatorAddress,
amount: coins
})
}
return delegateMsg
}
export function genBeginRedelegateMsg (
networkConfig: CosmosNetworkConfig,
delegatorAddress: string,
validatorSrcAddress: string,
validatorDstAddress: string,
amount: string // in lowest denom (e.g. uatom)
): MsgBeginRedelegateEncodeObject {
const coins = toCoin(amount, networkConfig.denom)
const beginRedelegateMsg: MsgBeginRedelegateEncodeObject = {
typeUrl: '/cosmos.staking.v1beta1.MsgBeginRedelegate',
value: MsgBeginRedelegate.fromPartial({
delegatorAddress: delegatorAddress,
validatorSrcAddress: validatorSrcAddress,
validatorDstAddress,
amount: coins
})
}
return beginRedelegateMsg
}
export async function getGas (
client: CosmosClient,
networkConfig: CosmosNetworkConfig,
signerAddress: string,
signer: Signer,
msg: EncodeObject,
memo?: string
): Promise<number> {
const extraGas = networkConfig.extraGas ? Number(networkConfig.extraGas) : 0
if (typeof networkConfig.gas === 'number' && networkConfig.gas > 0) {
return Number(networkConfig.gas) + extraGas
}
if (networkConfig.gas !== 'auto') {
throw new Error('gas must be either a number or "auto"')
}
const registry = new Registry(defaultRegistryTypes)
const anyMsgs = [registry.encodeAsAny(msg)]
const signerPubkey = await signer.getPublicKey(signerAddress)
const pk = Secp256k1.compressPubkey(signerPubkey)
const pubkey = encodeSecp256k1Pubkey(pk)
const { sequence } = await client.getSequence(signerAddress)
const { gasInfo } = await client.getCosmosQueryClient().tx.simulate(anyMsgs, memo ?? '', pubkey, sequence)
if (gasInfo?.gasUsed === undefined) {
throw new Error('failed to get gas estimate')
}
// it's highly unlikely gas will reach the boundry of Number.MAX_SAFE_INTEGER
return BigNumber(gasInfo.gasUsed.toString(10), 10).toNumber() + extraGas
}
export async function genSignableTx (
networkConfig: CosmosNetworkConfig,
chainID: string,
msg: EncodeObject,
accountNumber: number,
accountSequence: number,
gas: number,
memo?: string
): Promise<StdSignDoc> {
const aminoTypes = new AminoTypes(createDefaultTypes())
const feeAmt: BigNumber = networkConfig.fee
? BigNumber(networkConfig.fee)
: BigNumber(gas).multipliedBy(networkConfig.gasPrice)
const fee: StdFee = {
amount: [coin(feeAmt.toFixed(0, BigNumber.ROUND_CEIL).toString(), networkConfig.denom)],
gas: gas.toString(10)
}
const signDoc = makeSignDocAmino(
[msg].map((msg) => aminoTypes.toAmino(msg)),
fee,
chainID,
memo,
accountNumber,
accountSequence
)
return signDoc
}
export async function genSignDocSignature (
signer: Signer,
signerAccount: Account,
signDoc: StdSignDoc,
isEVM: boolean
): Promise<{ sig: Signature; pk: Uint8Array }> {
// The LCD doesn't have to return a public key for an acocunt, therefore
// we do a best effort check to assert the pubkey type is ethsecp256k1
if (isEVM && signerAccount.pubkey !== undefined) {
if (!signerAccount.pubkey?.type.toLowerCase().includes('ethsecp256k1')) {
throw new Error('signer account pubkey type is not ethsecp256k1')
}
}
const msg = isEVM ? keccak256(serializeSignDoc(signDoc)) : new Sha256(serializeSignDoc(signDoc)).digest()
const message = Buffer.from(msg).toString('hex')
const note = SafeJSONStringify(signDoc, 2)
const data: CosmosSigningData = { signDoc }
const signerAddress = signerAccount.address
return await signer.sign(signerAddress, { message, data }, { note })
}
export function genSignedTx (signDoc: StdSignDoc, signature: Signature, pk: Uint8Array, pkType?: string): TxRaw {
const signMode = SignMode.SIGN_MODE_LEGACY_AMINO_JSON
// cosmos signature doesn't use `v` field, only .r and .s
const signatureBytes = new Uint8Array([
...Buffer.from(signature.r ?? '', 'hex'),
...Buffer.from(signature.s ?? '', 'hex')
])
const secppk = encodeSecp256k1Pubkey(pk)
const pubkey = encodePubkey(secppk)
if (pkType !== undefined) {
pubkey.typeUrl = pkType
}
// https://github.com/cosmos/cosmjs/blob/main/packages/stargate/src/signingstargateclient.ts#L331
const aminoTypes = new AminoTypes(createDefaultTypes())
const signedTxBody = {
messages: signDoc.msgs.map((msg) => aminoTypes.fromAmino(msg)),
memo: signDoc.memo
}
const signedTxBodyEncodeObject: TxBodyEncodeObject = {
typeUrl: '/cosmos.tx.v1beta1.TxBody',
value: signedTxBody
}
const registry = new Registry(defaultRegistryTypes)
const signedTxBodyBytes = registry.encode(signedTxBodyEncodeObject)
const signedGasLimit = Int53.fromString(signDoc.fee.gas).toNumber()
const signedSequence = Int53.fromString(signDoc.sequence).toNumber()
const signedAuthInfoBytes = makeAuthInfoBytes(
[{ pubkey, sequence: signedSequence }],
signDoc.fee.amount,
signedGasLimit,
signDoc.fee.granter,
signDoc.fee.payer,
signMode
)
const cosmosSignature = encodeSecp256k1Signature(pk, signatureBytes)
const txRaw = TxRaw.fromPartial({
bodyBytes: signedTxBodyBytes,
authInfoBytes: signedAuthInfoBytes,
signatures: [fromBase64(cosmosSignature.signature)]
})
return txRaw
}
/** @ignore */
export async function getAccount (client: StargateClient, lcdUrl: string, address: string): Promise<Account> {
// try to get account from the RPC endpoint
const cosmosAccount = await client.getAccount(address)
if (cosmosAccount == null) {
throw new Error('failed to query account: ' + address + ' are you sure the account exists?')
}
// if this is an ethermint / evm account, we need to fetch
// account information from the LCD endpoint (due to lack of codec)
if (cosmosAccount.address != 'ethermint_account') {
return cosmosAccount
}
return await getEthermintAccount(lcdUrl, address)
}
/** @ignore */
export async function getEthermintAccount (lcdUrl: string, address: string): Promise<Account> {
const r = await fetch(lcdUrl + '/cosmos/auth/v1beta1/accounts/' + address)
if (r.status !== 200) {
throw new Error(
'failed to query account with LCD endpoint, address: ' + address + ' are you sure the account exists?'
)
}
const data: any = await r.json()
const base = data['account']['base_account']
const pubkey =
base['pub_key'] === null
? {
type: guessPubkeyType(data['account']['@type']),
value: null
}
: {
type: base['pub_key']['@type'],
value: base['pub_key']['key']
}
return {
address: base['address'],
pubkey,
accountNumber: parseInt(base['account_number']),
sequence: parseInt(base['sequence'])
}
}
/** @ignore */
export function publicKeyToAddress (pk: Uint8Array, bechPrefix: string): string {
const pkCompressed = Buffer.from(publicKeyConvert(pk, true))
return toBech32(bechPrefix, rawSecp256k1PubkeyToRawAddress(pkCompressed))
}
/** @ignore */
export function publicKeyToEthBasedAddress (pk: Uint8Array, bechPrefix: string): string {
const pkUncompressed = Buffer.from(publicKeyConvert(pk, false))
const hash = keccak256(pkUncompressed.subarray(1))
const ethAddress = hash.slice(-20)
return toBech32(bechPrefix, ethAddress)
}
/** @ignore */
function guessPubkeyType (accountType: string): string {
if (accountType.startsWith('/ethermint')) {
return '/ethermint.crypto.v1.ethsecp256k1.PubKey'
}
if (accountType.startsWith('/injective')) {
return '/injective.crypto.v1beta1.ethsecp256k1.PubKey'
}
throw new Error('unknown account type: ' + accountType)
}