From 2c7d5aba3a79ca21a1a0bd986b45c120abae8e40 Mon Sep 17 00:00:00 2001 From: Sun Burnt Date: Wed, 11 Dec 2024 12:28:37 -0500 Subject: [PATCH 1/4] feat: - refactor form validation. - add gas estimation to amount validation. --- src/config.ts | 7 + .../staking/components/modals/redelegate.tsx | 70 +++++++-- .../staking/components/modals/staking.tsx | 96 ++++++++---- src/features/staking/lib/core/gas.ts | 139 ++++++++++++++++++ 4 files changed, 274 insertions(+), 38 deletions(-) create mode 100644 src/features/staking/lib/core/gas.ts diff --git a/src/config.ts b/src/config.ts index ded5f99..14b0a26 100644 --- a/src/config.ts +++ b/src/config.ts @@ -64,4 +64,11 @@ export const REST_API_URL = IS_TESTNET export const REST_ENDPOINTS = { balances: "/cosmos/bank/v1beta1/balances", + simulate: "/cosmos/tx/v1beta1/simulate", +} as const; + +export const GAS_CONFIG = { + defaultMultiplier: 2.3, + defaultStakeEstimate: 200000, + price: "0.001", } as const; diff --git a/src/features/staking/components/modals/redelegate.tsx b/src/features/staking/components/modals/redelegate.tsx index 363a7d4..a3b5eca 100644 --- a/src/features/staking/components/modals/redelegate.tsx +++ b/src/features/staking/components/modals/redelegate.tsx @@ -28,8 +28,12 @@ import { useValidatorLogo } from "@/features/staking/hooks"; import { redelegateAction } from "../../context/actions"; import { useStaking } from "../../context/hooks"; import { setModalOpened } from "../../context/reducer"; -import { getTotalDelegation } from "../../context/selectors"; +import { + getTokensAvailableBG, + getTotalDelegation, +} from "../../context/selectors"; import { getXionCoin } from "../../lib/core/coins"; +import { estimateGas } from "../../lib/core/gas"; import { formatCoin, formatToSmallDisplay, @@ -164,10 +168,11 @@ const RedelegateModal = () => { validator.operatorAddress, ); - const validateAmount = () => { + const validateAmount = async () => { if ( - !amountUSD || !delegatedTokens || + !amountXIONParsed || + amountXIONParsed.isNaN() || amountXIONParsed.lte(0) || amountXIONParsed.gt(new BigNumber(delegatedTokens.amount)) ) { @@ -178,17 +183,60 @@ const RedelegateModal = () => { return true; } + + if (!dstValidator) { + setFormError({ + ...formError, + amount: "Please select a destination validator", + }); + + return true; + } + + try { + const gasEstimate = await estimateGas({ + amount: getXionCoin(amountXIONParsed), + delegator: account.bech32Address, + messageType: "redelegate", + redelegateParams: { + validatorDst: dstValidator.operatorAddress, + validatorSrc: validator.operatorAddress, + }, + validator: validator.operatorAddress, + }); + + const availableTokens = getTokensAvailableBG(staking.state); + + if (!availableTokens || gasEstimate.gt(availableTokens)) { + setFormError({ + ...formError, + amount: `Insufficient funds for fees. Need ~${gasEstimate.toString()} XION`, + }); + + return true; + } + } catch (error) { + setFormError({ + ...formError, + amount: "Failed to estimate transaction fees", + }); + + return true; + } + + return false; }; - const onSubmit: FormEventHandler = (e) => { - e?.stopPropagation(); + const onSubmit: FormEventHandler = async (e) => { e?.preventDefault(); - if (validateAmount()) return; + if (!client) return; - if (!client || !amountXIONParsed.gt(0)) return; + const hasValidationError = await validateAmount(); - setStep("review"); + if (!hasValidationError) { + setStep("review"); + } }; return ( @@ -258,7 +306,7 @@ const RedelegateModal = () => { You are about to redelegate your token from{" "} - {validator.description.moniker} to + {validator.description.moniker} to{" "} {dstValidator?.description.moniker}. Remember, you will not able to redelegate these token within {UNBONDING_DAYS} days. @@ -322,6 +370,10 @@ const RedelegateModal = () => { onChange={(_, validatorAddress) => { if (!!validatorAddress) { setDstValidator(validatorsPerAddress[validatorAddress]); + + if (formError.amount) { + setFormError({ ...formError, amount: undefined }); + } } }} renderValue={(option: null | SelectOption) => diff --git a/src/features/staking/components/modals/staking.tsx b/src/features/staking/components/modals/staking.tsx index 2f31348..9657b56 100644 --- a/src/features/staking/components/modals/staking.tsx +++ b/src/features/staking/components/modals/staking.tsx @@ -22,6 +22,7 @@ import { useStaking } from "../../context/hooks"; import { setModalOpened } from "../../context/reducer"; import { getTokensAvailableBG } from "../../context/selectors"; import { getXionCoin } from "../../lib/core/coins"; +import { estimateGas } from "../../lib/core/gas"; import type { StakeAddresses } from "../../lib/core/tx"; import { formatCoin, formatXionToUSD } from "../../lib/formatters"; @@ -50,6 +51,7 @@ const StakingModal = () => { const { account, staking } = stakingRef; const { modal } = staking.state; const isOpen = modal?.type === "delegate"; + const validator = isOpen ? modal?.content?.validator : undefined; useEffect( () => () => { @@ -63,8 +65,6 @@ const StakingModal = () => { if (!isOpen) return null; - const { validator } = modal?.content; - if (!validator) return null; const amountXIONParsed = new BigNumber(amountXION); @@ -79,6 +79,71 @@ const StakingModal = () => { const availableTokens = getTokensAvailableBG(staking.state); + const validateAmount = async () => { + if (!availableTokens) { + setFormError({ + ...formError, + amount: "No tokens available", + }); + + return true; + } + + if ( + !amountXIONParsed || + amountXIONParsed.isNaN() || + amountXIONParsed.lte(0) + ) { + setFormError({ + ...formError, + amount: "Invalid amount", + }); + + return true; + } + + try { + const gasEstimate = await estimateGas({ + amount: getXionCoin(amountXIONParsed), + delegator: account.bech32Address, + messageType: "delegate", + validator: validator.operatorAddress, + }); + + const totalRequired = amountXIONParsed.plus(gasEstimate); + + if (totalRequired.gt(availableTokens)) { + setFormError({ + ...formError, + amount: `Amount too high. Need ~${gasEstimate.toString()} XION for fees`, + }); + + return true; + } + } catch (error) { + setFormError({ + ...formError, + amount: "Failed to estimate transaction fees", + }); + + return true; + } + + return false; + }; + + const onSubmit: FormEventHandler = async (e) => { + e?.preventDefault(); + + if (!client) return; + + const hasValidationError = await validateAmount(); + + if (!hasValidationError) { + setStep("review"); + } + }; + return ( { ); } - const validateAmount = () => { - if ( - !amountUSD || - !availableTokens || - amountXIONParsed.isNaN() || - amountXIONParsed.gt(availableTokens) - ) { - setFormError({ - ...formError, - amount: "Invalid amount", - }); - - return true; - } - }; - - const onSubmit: FormEventHandler = (e) => { - e?.stopPropagation(); - e?.preventDefault(); - - if (validateAmount()) return; - - if (!client || hasErrors || amountXIONParsed.lt(0)) return; - - setStep("review"); - }; - return ( <>
diff --git a/src/features/staking/lib/core/gas.ts b/src/features/staking/lib/core/gas.ts new file mode 100644 index 0000000..9a91e88 --- /dev/null +++ b/src/features/staking/lib/core/gas.ts @@ -0,0 +1,139 @@ +import { GasPrice, calculateFee } from "@cosmjs/stargate"; +import type { Coin } from "@cosmjs/stargate"; +import BigNumber from "bignumber.js"; + +import { GAS_CONFIG, REST_API_URL, REST_ENDPOINTS } from "@/config"; + +export type StakingMessageType = + | "claim_rewards" + | "delegate" + | "redelegate" + | "undelegate"; + +interface BaseGasEstimationParams { + amount: Coin; + delegator: string; + validator: string; +} + +interface RedelegateGasParams extends BaseGasEstimationParams { + validatorDst: string; + validatorSrc: string; +} + +export type GasEstimationParams = BaseGasEstimationParams & { + messageType: StakingMessageType; + redelegateParams?: Omit; +}; + +function getMessageBody(params: GasEstimationParams) { + const baseAmount = { + amount: params.amount, + delegator_address: params.delegator, + }; + + switch (params.messageType) { + case "delegate": + return { + "@type": "/cosmos.staking.v1beta1.MsgDelegate", + ...baseAmount, + "validator_address": params.validator, + }; + + case "redelegate": + if (!params.redelegateParams) { + throw new Error("Redelegate params required for redelegate message"); + } + + return { + "@type": "/cosmos.staking.v1beta1.MsgBeginRedelegate", + ...baseAmount, + "validator_dst_address": params.redelegateParams.validatorDst, + "validator_src_address": params.redelegateParams.validatorSrc, + }; + + case "undelegate": + return { + "@type": "/cosmos.staking.v1beta1.MsgUndelegate", + ...baseAmount, + "validator_address": params.validator, + }; + + case "claim_rewards": + return { + "@type": "/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward", + "delegator_address": params.delegator, + "validator_address": params.validator, + }; + + default: + throw new Error(`Unsupported message type: ${params.messageType}`); + } +} + +function estimateGasStatic(): BigNumber { + const gasPrice = new BigNumber(GAS_CONFIG.price); + + return new BigNumber(GAS_CONFIG.defaultStakeEstimate) + .multipliedBy(gasPrice) + .multipliedBy(GAS_CONFIG.defaultMultiplier) + .dividedBy(1e6); +} + +async function estimateGasViaRest( + params: GasEstimationParams, +): Promise { + const body = { + gas_adjustment: GAS_CONFIG.defaultMultiplier.toString(), + messages: [getMessageBody(params)], + }; + + try { + const response = await fetch(`${REST_API_URL}${REST_ENDPOINTS.simulate}`, { + body: JSON.stringify(body), + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }); + + if (!response.ok) { + throw new Error("Failed to simulate transaction"); + } + + const data = await response.json(); + const gasUsed = data.gas_info?.gas_used || GAS_CONFIG.defaultStakeEstimate; + + const gasPrice = new BigNumber(GAS_CONFIG.price); + + return new BigNumber(gasUsed).multipliedBy(gasPrice).dividedBy(1e6); + } catch (error) { + console.warn("Gas estimation via REST failed:", error); + + return estimateGasStatic(); + } +} + +export async function estimateGas( + params: GasEstimationParams, +): Promise { + try { + return await estimateGasViaRest(params); + } catch (error) { + console.warn("All gas estimation methods failed, using static fallback"); + + return estimateGasStatic(); + } +} + +export function calculateTxFee(gasEstimate: number): { + amount: readonly Coin[]; + gas: string; +} { + const gasPrice = GasPrice.fromString(`${GAS_CONFIG.price}uxion`); + + return calculateFee( + Math.round(gasEstimate * GAS_CONFIG.defaultMultiplier), + gasPrice, + ); +} From 3cdedbf28c5b919142030150deecceb593018c9e Mon Sep 17 00:00:00 2001 From: Sun Burnt Date: Wed, 11 Dec 2024 12:42:53 -0500 Subject: [PATCH 2/4] fix: linting --- src/features/staking/lib/core/gas.ts | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/features/staking/lib/core/gas.ts b/src/features/staking/lib/core/gas.ts index 9a91e88..7199d2a 100644 --- a/src/features/staking/lib/core/gas.ts +++ b/src/features/staking/lib/core/gas.ts @@ -1,10 +1,9 @@ -import { GasPrice, calculateFee } from "@cosmjs/stargate"; import type { Coin } from "@cosmjs/stargate"; import BigNumber from "bignumber.js"; import { GAS_CONFIG, REST_API_URL, REST_ENDPOINTS } from "@/config"; -export type StakingMessageType = +type StakingMessageType = | "claim_rewards" | "delegate" | "redelegate" @@ -21,7 +20,7 @@ interface RedelegateGasParams extends BaseGasEstimationParams { validatorSrc: string; } -export type GasEstimationParams = BaseGasEstimationParams & { +type GasEstimationParams = BaseGasEstimationParams & { messageType: StakingMessageType; redelegateParams?: Omit; }; @@ -125,15 +124,3 @@ export async function estimateGas( return estimateGasStatic(); } } - -export function calculateTxFee(gasEstimate: number): { - amount: readonly Coin[]; - gas: string; -} { - const gasPrice = GasPrice.fromString(`${GAS_CONFIG.price}uxion`); - - return calculateFee( - Math.round(gasEstimate * GAS_CONFIG.defaultMultiplier), - gasPrice, - ); -} From 67ba44507994ee789c0ed49a142237dcaa5be55f Mon Sep 17 00:00:00 2001 From: Sun Burnt Date: Wed, 11 Dec 2024 12:56:34 -0500 Subject: [PATCH 3/4] chore: sync with main --- src/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index 4278179..2d1dd6c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -64,6 +64,8 @@ export const REST_API_URL = IS_TESTNET export const REST_ENDPOINTS = { balances: "/cosmos/bank/v1beta1/balances", + distributionParams: "/cosmos/distribution/v1beta1/params", + inflation: "/xion/mint/v1/inflation", simulate: "/cosmos/tx/v1beta1/simulate", } as const; @@ -71,6 +73,4 @@ export const GAS_CONFIG = { defaultMultiplier: 2.3, defaultStakeEstimate: 200000, price: "0.001", - distributionParams: "/cosmos/distribution/v1beta1/params", - inflation: "/xion/mint/v1/inflation", } as const; From eaee0ecc8b9984f6977a837593fecda0ae1860fe Mon Sep 17 00:00:00 2001 From: Sun Burnt Date: Wed, 11 Dec 2024 13:15:01 -0500 Subject: [PATCH 4/4] feat: fix simulate endpoint call --- src/features/staking/lib/core/gas.ts | 152 ++++++++++++++++++++------- 1 file changed, 112 insertions(+), 40 deletions(-) diff --git a/src/features/staking/lib/core/gas.ts b/src/features/staking/lib/core/gas.ts index 7199d2a..316e27c 100644 --- a/src/features/staking/lib/core/gas.ts +++ b/src/features/staking/lib/core/gas.ts @@ -25,49 +25,56 @@ type GasEstimationParams = BaseGasEstimationParams & { redelegateParams?: Omit; }; -function getMessageBody(params: GasEstimationParams) { +function getMessageBody(params: GasEstimationParams): SimulateRequestMessage { const baseAmount = { amount: params.amount, delegator_address: params.delegator, }; - switch (params.messageType) { - case "delegate": - return { - "@type": "/cosmos.staking.v1beta1.MsgDelegate", - ...baseAmount, - "validator_address": params.validator, - }; - - case "redelegate": - if (!params.redelegateParams) { - throw new Error("Redelegate params required for redelegate message"); - } - - return { - "@type": "/cosmos.staking.v1beta1.MsgBeginRedelegate", - ...baseAmount, - "validator_dst_address": params.redelegateParams.validatorDst, - "validator_src_address": params.redelegateParams.validatorSrc, - }; - - case "undelegate": - return { - "@type": "/cosmos.staking.v1beta1.MsgUndelegate", - ...baseAmount, - "validator_address": params.validator, - }; - - case "claim_rewards": - return { - "@type": "/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward", - "delegator_address": params.delegator, - "validator_address": params.validator, - }; + const message = (() => { + switch (params.messageType) { + case "delegate": + return { + "@type": "/cosmos.staking.v1beta1.MsgDelegate", + ...baseAmount, + "validator_address": params.validator, + }; + + case "redelegate": + if (!params.redelegateParams) { + throw new Error("Redelegate params required for redelegate message"); + } + + return { + "@type": "/cosmos.staking.v1beta1.MsgBeginRedelegate", + ...baseAmount, + "validator_dst_address": params.redelegateParams.validatorDst, + "validator_src_address": params.redelegateParams.validatorSrc, + }; + + case "undelegate": + return { + "@type": "/cosmos.staking.v1beta1.MsgUndelegate", + ...baseAmount, + "validator_address": params.validator, + }; + + case "claim_rewards": + return { + "@type": "/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward", + "delegator_address": params.delegator, + "validator_address": params.validator, + }; + + default: + throw new Error(`Unsupported message type: ${params.messageType}`); + } + })(); - default: - throw new Error(`Unsupported message type: ${params.messageType}`); - } + return { + type_url: message["@type"], + value: Buffer.from(JSON.stringify(message)).toString("base64"), + }; } function estimateGasStatic(): BigNumber { @@ -79,12 +86,77 @@ function estimateGasStatic(): BigNumber { .dividedBy(1e6); } +interface SimulateRequestMessage { + type_url: string; + value: string; +} + +interface SimulateRequest { + tx: { + auth_info?: { + fee?: { + amount?: Array<{ + amount: string; + denom: string; + }>; + gas_limit?: string; + granter?: string; + payer?: string; + }; + signer_infos?: Array<{ + mode_info?: { + multi?: { + bitarray?: { + elems: string; + extra_bits_stored: number; + }; + mode_infos?: string[]; + }; + single?: { + mode: string; + }; + }; + public_key?: { + type_url: string; + value: string; + }; + sequence?: string; + }>; + tip?: { + amount?: Array<{ + amount: string; + denom: string; + }>; + tipper?: string; + }; + }; + body: { + extension_options?: Array<{ + type_url: string; + value: string; + }>; + memo?: string; + messages: SimulateRequestMessage[]; + non_critical_extension_options?: Array<{ + type_url: string; + value: string; + }>; + timeout_height?: string; + }; + signatures?: string[]; + }; + tx_bytes?: string; +} + async function estimateGasViaRest( params: GasEstimationParams, ): Promise { - const body = { - gas_adjustment: GAS_CONFIG.defaultMultiplier.toString(), - messages: [getMessageBody(params)], + const body: SimulateRequest = { + tx: { + body: { + messages: [getMessageBody(params)], + }, + }, }; try {