brahma-trade-widget
Version:
A React component for trade automation within the Brahma ecosystem.
839 lines (766 loc) • 23.7 kB
text/typescript
import { create } from "zustand"
import { ConsoleKit, TaskIdStatusType } from "brahma-console-kit"
import { signTypedData } from "@wagmi/core"
import { Address, fromHex } from "viem"
import {
AiResponseToDetect,
MorphoTokensOptions,
MorphoVault,
MorphoYieldAutomationLog,
MorphoYieldUserPosition,
} from "./types"
import { SupportedChainIds, TAsset } from "@/types"
import { fetchAndFilterAssetBalances, formatUnits } from "@/utils"
import { BASE_CHAIN_ID, URL_API_KEY_PROD } from "@/constants"
import { baseWagmiConfig as wagmiConfig } from "@/wagmi"
import { dispatchToast } from "../shared/components"
import {
getBaseBackendUrl,
getFormattedDate,
getMorphoYieldExecutorId,
getTokenData,
waitFor,
} from "./utils"
import { MORPHO_YIELD_AUTOMATION_ACTIVE_STATE } from "./constants"
import localStorageService from "../shared/services/localStoage"
import { MORPHO_STRATEGY_TOKENS } from "../shared/constants"
import { base } from "viem/chains"
import {
callIndexer,
fetchMorphoYieldAutomation,
fetchMorphoYieldLogs,
fetchMorphoYieldVaultPosition,
fetchMorphoYieldVaults,
} from "../shared/api"
const kit = new ConsoleKit(URL_API_KEY_PROD, getBaseBackendUrl())
type State = {
loading: boolean
isDepositModalOpen: boolean
strategyDeployedOn: Address | null
deploymentStatus: {
status: TaskIdStatusType
taskId: string
} | null
preComputedConsoleAddress: Address | null
balances: {
data: TAsset[]
loading: boolean
}
eoaBalances: {
data: TAsset[]
loading: boolean
}
feeEstimateSignature: string | null
feeEstimate: string | null
automationConsoleAddress: Record<
MorphoTokensOptions,
{ address: Address | null; loading: boolean }
>
userPositions: Record<
MorphoTokensOptions,
{
loading: boolean
data: MorphoYieldUserPosition[]
consoleAddress: Address
}
>
loopFetchingPositions: boolean
morphoVaults: {
loading: boolean
vaultMapping: Map<`0x${string}`, MorphoVault>
vaults: MorphoVault[]
}
hasNftData: {
loading: boolean
value: boolean
}
showPageSpinner: boolean
paramsDetectedInAiRes: null | AiResponseToDetect
}
type Actions = {
setParamsDetectedInAiRes: (value: null | AiResponseToDetect) => void
fetchPreComputedConsoleAddress: (
owner: Address,
chainId: SupportedChainIds,
feeToken: Address,
) => Promise<void>
generateAndDeploySubAccount: (
eoa: Address,
chainId: SupportedChainIds,
feeToken: Address,
feeEstimate: string,
tokens: Address[],
amounts: string[],
duration: number,
tokenInputs: Record<Address, string>,
tokenLimits: Record<Address, string>,
preferredVaults: Address[],
) => Promise<void>
fetchDeploymentStatus: (
taskId: string,
token: MorphoTokensOptions,
eoa: Address,
) => Promise<void>
fetchPreComputedConsoleBalances: (
assets: TAsset[],
addressToDetect?: Address,
) => Promise<boolean>
fetchEoaAssets: (eoa: Address, assets: TAsset[]) => Promise<void>
fetchMorphoUserPositions: (
consoleAddress: Address,
chainId: SupportedChainIds,
token: MorphoTokensOptions,
isPollingExecution?: boolean,
) => Promise<boolean>
fetchMorphoVaults: () => Promise<void>
setNftData: (value: boolean, loading?: boolean) => void
setPageSpinnerState: (value: boolean) => void
resetUserPostion: (token: MorphoTokensOptions) => void
setAutomationConsoleAddress: (
consoleAddress: Address | null,
token: MorphoTokensOptions,
) => void
resetPreComputedConsoleAddress: () => void
resetAutomationConsoleAddress: (token: MorphoTokensOptions) => void
openDepositModal: () => void
closeDepositModal: () => void
}
const useStore = create<State & Actions>((set, get) => ({
isDepositModalOpen: false,
paramsDetectedInAiRes: null,
showPageSpinner: false,
loading: false,
hasNftData: {
loading: true,
value: false,
},
automationConsoleAddress: {
usdc: {
loading: true,
address: null,
},
weth: {
loading: true,
address: null,
},
},
deploymentStatus: null,
balances: {
data: [],
loading: false,
},
morphoVaults: {
loading: true,
vaults: [],
vaultMapping: new Map(),
},
userPositions: {
weth: { data: [], loading: true, consoleAddress: "0x" as Address },
usdc: { data: [], loading: true, consoleAddress: "0x" as Address },
},
strategyDeployedOn: null,
eoaBalances: {
data: [],
loading: false,
},
preComputedConsoleAddress: null,
feeEstimate: null,
feeEstimateSignature: null,
loopFetchingPositions: false,
openDepositModal: () => set({ isDepositModalOpen: true }),
closeDepositModal: () => set({ isDepositModalOpen: false }),
setParamsDetectedInAiRes: (value: null | AiResponseToDetect) =>
set({ paramsDetectedInAiRes: value }),
resetAutomationConsoleAddress: (token: MorphoTokensOptions) => {
set((prev) => ({
automationConsoleAddress: {
...prev.automationConsoleAddress,
[token]: {
address: null,
loading: true,
},
},
}))
},
setAutomationConsoleAddress: (
consoleAddress: Address | null,
token: MorphoTokensOptions,
) => {
set((prev) => ({
automationConsoleAddress: {
...prev.automationConsoleAddress,
[token]: {
address: consoleAddress,
loading: false,
},
},
}))
},
resetUserPostion: (token: MorphoTokensOptions) => {
set((prev) => ({
userPositions: {
...prev.userPositions,
[token]: { data: [], loading: false, consoleAddress: "0x" as Address },
strategyDeployedOn: null,
},
}))
},
setPageSpinnerState: (value: boolean) => set({ showPageSpinner: value }),
setNftData: (value: boolean, loading?: boolean) =>
set({
hasNftData: {
value,
loading: typeof loading === "boolean" ? loading : false,
},
}),
fetchMorphoVaults: async () => {
try {
const response = (await fetchMorphoYieldVaults()) || []
const baseVaults = response.filter(
(vault) => vault.asset.chain.id === base.id,
)
// Get the best performing vault with with min TVL threshold as 100K USD
const getFirstVault = (tokenAddress: string) =>
baseVaults
.filter(
(vault) =>
vault.asset.address.toLowerCase() ===
tokenAddress.toLowerCase() && vault.supply.usd > 100_000,
)
.sort((a, b) => b.state.netApy - a.state.netApy)[0]
const firstUSDCVault = getFirstVault(MORPHO_STRATEGY_TOKENS.usdc.address)
const firstWETHVault = getFirstVault(MORPHO_STRATEGY_TOKENS.weth.address)
const filteredOrderedVaults = [firstUSDCVault, firstWETHVault].filter(
Boolean,
) as MorphoVault[]
const vaultMapping = filteredOrderedVaults.reduce(
(acc, { asset, ...data }) => {
acc.set(asset.address.toLowerCase() as Address, { asset, ...data })
return acc
},
new Map<Address, MorphoVault>(),
)
set({
morphoVaults: { vaultMapping, vaults: baseVaults, loading: false },
})
} catch (err) {
set({
morphoVaults: { vaultMapping: new Map(), loading: false, vaults: [] },
})
}
},
resetPreComputedConsoleAddress: () => {
set({
preComputedConsoleAddress: null,
})
},
fetchPreComputedConsoleAddress: async (owner, chainId, feeToken) => {
set((state) => ({ ...state, loading: true }))
try {
const data = await kit.publicDeployer.fetchPreComputeData(
owner,
chainId,
feeToken,
)
console.log({ data, owner, chainId, feeToken })
if (
!data ||
!data.feeEstimate ||
!data.feeEstimateSignature ||
!data.precomputedAddress
) {
throw new Error("Invalid data received from fetchPreComputeAddress")
}
set((state) => ({
...state,
loading: false,
feeEstimate: data.feeEstimate,
feeEstimateSignature: data.feeEstimateSignature,
preComputedConsoleAddress: data.precomputedAddress,
}))
} catch (err: any) {
console.log(`Error fetching precompute address: ${err}`)
dispatchToast({
id: "fetch-precompute-address-error",
title: "Error fetching precompute address",
description: {
value:
err?.message ||
"An error occurred while fetching precompute address",
},
type: "error",
})
set((state) => ({ ...state, loading: false })) // Ensure loading is set to false on error
}
},
generateAndDeploySubAccount: async (
eoa,
chainId,
feeToken,
feeEstimate,
tokens,
amounts,
duration,
tokenInputs,
tokenLimits,
preferredVaults,
) => {
const { preComputedConsoleAddress, feeEstimateSignature } = get()
const HARDCODED_REGISTRY_ID =
getMorphoYieldExecutorId(chainId) ||
"8fa8bacb-7be0-4232-8dce-d080e1e56540"
if (!preComputedConsoleAddress) {
dispatchToast({
id: "fetch-precomputed-account-address-error",
title: "Error fetching precomputed account address",
description: {
value: "Precomputed account address not found",
},
type: "error",
})
return
}
set((state) => ({ ...state, loading: true }))
try {
// Generate Automation SubAccount
const durationForPayload = duration > 3600 ? duration - 3600 : duration
const generateData =
await kit.publicDeployer.generateAutomationSubAccount(
eoa,
preComputedConsoleAddress,
chainId,
HARDCODED_REGISTRY_ID,
feeToken,
feeEstimate,
tokens,
amounts,
{
duration: durationForPayload,
tokenInputs: tokenInputs,
tokenLimits: tokenLimits,
},
{
baseToken: feeToken,
every: duration.toString(),
type: "EARN",
preferredVaults,
},
)
if (!generateData || !feeEstimateSignature) {
dispatchToast({
id: "fetch-signature-error",
title: "Error fetching signature",
description: {
value: "An error occurred while fetching signature",
},
type: "error",
})
return
}
const {
signaturePayload: { domain, message, types, primaryType },
subAccountPolicyCommit,
subscriptionDraftID,
} = generateData
const signature = await signTypedData(wagmiConfig, {
domain: {
verifyingContract: domain.verifyingContract,
chainId: fromHex(domain.chainId as Address, "number"),
},
types,
primaryType,
message,
})
if (!signature) {
dispatchToast({
id: "upgrade-console-error",
type: "error",
title: "Error",
description: {
value: "User rejected the transaction",
},
})
return
}
// Deploy Brahma Account
const deployData = await kit.publicDeployer.deployBrahmaAccount(
eoa,
chainId,
HARDCODED_REGISTRY_ID,
subscriptionDraftID,
subAccountPolicyCommit,
feeToken,
tokens,
amounts,
signature, // Use the signature obtained from the previous step
feeEstimateSignature,
feeEstimate,
{},
)
if (!deployData) {
dispatchToast({
id: "deploy-account-error",
title: "Error deploying account and sub-account",
description: {
value: "An error occurred during deployment",
},
type: "error",
})
return
}
dispatchToast({
id: "deployment-status-pending",
title: "Deployment Status",
description: {
value: "The deployment is currently pending.",
},
type: "loading",
})
// Update state with taskId and set loading to false
set((state) => ({
...state,
deploymentStatus: {
status: "pending",
taskId: deployData.taskId,
},
signature, // Update the state with the signature
loading: false,
strategyDeployedOn: preComputedConsoleAddress,
}))
} catch (err: any) {
console.error("Error in generate and deploy sub-account:", err)
dispatchToast({
id: "generate-deploy-error",
title: "Error in generate and deploy sub-account",
description: {
value: err?.message || "An error occurred during the process",
},
type: "error",
})
}
},
fetchDeploymentStatus: async (
taskId,
token: MorphoTokensOptions,
eoa: Address,
) => {
const {
strategyDeployedOn,
userPositions,
fetchMorphoUserPositions,
setAutomationConsoleAddress,
} = get()
try {
const data = await kit.publicDeployer.fetchDeploymentStatus(taskId)
set({ showPageSpinner: true })
if (!data) {
dispatchToast({
id: "fetch-deployment-status-error",
title: "Error fetching deployment status",
description: {
value: "An error occurred while fetching deployment status",
},
type: "error",
})
set({ showPageSpinner: false })
return
}
console.log({ deploymentData: data })
switch (data.status) {
case "pending":
dispatchToast({
id: "deployment-status-pending",
title: "Deployment Status",
description: {
value: "The deployment is currently pending.",
},
type: "loading",
})
break
case "executing":
dispatchToast({
id: "deployment-status-executing",
title: "Deployment Status",
description: {
value: "The deployment is currently executing.",
},
type: "loading",
})
break
case "cancelled":
dispatchToast({
id: "deployment-status-cancelled",
title: "Deployment Status",
description: {
value: "The deployment has been cancelled.",
},
type: "error",
})
break
case "successful":
dispatchToast({
id: "deployment-status-successful",
title: "Deployment Status",
description: {
value:
"Your account and sub-account have been deployed successfully",
},
type: "success",
})
strategyDeployedOn &&
localStorageService.setDeployedMorphoStrategyConsole(
strategyDeployedOn as Address,
token,
eoa,
)
break
case "failed":
{
dispatchToast({
id: "deployment-status-failed",
title: "Deployment Status",
description: {
value: "The deployment has failed.",
},
type: "error",
})
set({ showPageSpinner: false })
}
break
default:
{
dispatchToast({
id: "deployment-status-unknown",
title: "Deployment Status",
description: {
value: "The deployment status is unknown.",
},
type: "error",
})
set({ showPageSpinner: false })
}
break
}
set((state) => ({
...state,
deploymentStatus: {
status: data.status,
taskId: taskId,
},
}))
// add a loop to continue fetch positions
if (data.status === "successful" && strategyDeployedOn) {
await callIndexer(data?.outputTransactionHash || "")
set({ loopFetchingPositions: true })
let isPositionDeployed = false
while (!isPositionDeployed) {
const isPositionDeployedResponse = await fetchMorphoUserPositions(
strategyDeployedOn,
BASE_CHAIN_ID,
token,
)
isPositionDeployed = isPositionDeployedResponse
await waitFor(3000)
console.log("LOOP FETCHING USER POSITIONS")
}
setAutomationConsoleAddress(strategyDeployedOn, token)
set({
loopFetchingPositions: false,
showPageSpinner: false,
balances: {
data: [],
loading: false,
},
})
}
} catch (err: any) {
set({ loopFetchingPositions: false })
set({ showPageSpinner: false })
console.error("Error fetching deployment status:", err)
dispatchToast({
id: "fetch-deployment-status-error",
title: "Error fetching deployment status",
description: {
value:
err?.message ||
"An error occurred while fetching deployment status",
},
type: "error",
})
}
},
fetchPreComputedConsoleBalances: async (assets, addressToDetect) => {
const { preComputedConsoleAddress } = get()
if (!preComputedConsoleAddress) {
dispatchToast({
id: "fetch-precomputed-account-balances-error",
title: "Error fetching precomputed account balances",
description: {
value: "Precomputed account address not found",
},
type: "error",
})
return false
}
set((state) => ({ balances: { ...state.balances, loading: true } }))
try {
const filteredUserAssets = await fetchAndFilterAssetBalances(
preComputedConsoleAddress,
assets,
)
set({
balances: {
data: filteredUserAssets,
loading: false,
},
})
if (!addressToDetect) return false
const targetToken = filteredUserAssets.find(
(asset) =>
asset.address.toLowerCase() === addressToDetect.toLowerCase(),
)
if (!targetToken) return false
const targetTokenHasAmount = targetToken?.balanceOf?.value !== BigInt(0)
console.log(targetTokenHasAmount, targetToken)
return targetTokenHasAmount
} catch (err) {
console.error("Error fetching precomputed console balances:", err)
set((state) => ({ balances: { ...state.balances, loading: false } }))
return false
}
},
fetchEoaAssets: async (eoa, assets) => {
set((state) => ({ balances: { ...state.balances, loading: true } }))
try {
const filteredUserAssets = await fetchAndFilterAssetBalances(eoa, assets)
set({
eoaBalances: {
data: filteredUserAssets,
loading: false,
},
})
} catch (err) {
console.error("error on fetching eoa assets", err)
set({ balances: { data: [], loading: false } })
}
},
fetchMorphoUserPositions: async (
consoleAddress: Address,
chainId: SupportedChainIds,
token: MorphoTokensOptions,
isPollingExecution?: boolean,
) => {
const { morphoVaults } = get()
const automations = await fetchMorphoYieldAutomation(
consoleAddress,
chainId,
)
const morphoExecutorId = getMorphoYieldExecutorId(chainId)
// filter by registery ID and staus
const activeMorphoAutomations = automations.filter(
(automation) =>
automation.registryId.toLowerCase() ===
(morphoExecutorId || "").toLowerCase() &&
automation.status === MORPHO_YIELD_AUTOMATION_ACTIVE_STATE,
)
// for every automation, fetch vault info and append in the vault fields
const automationWithVaultInfo: MorphoYieldUserPosition[] =
await Promise.all(
activeMorphoAutomations.map(async (_automation) => {
// vault data
const vaultResponse =
(await fetchMorphoYieldVaultPosition(
_automation.subAccountAddress,
)) || []
let vaultPositionData = null
const baseToken = Object.entries(_automation.tokenInputs).map(
([address, value]) => ({
address,
value: Number(value), // Convert the value to a number if needed
}),
)[0]
// we pick only first object, asked by BE.
if (vaultResponse.length > 0) {
vaultPositionData = vaultResponse[0]
}
const nonFormattedAmount = vaultPositionData
? vaultPositionData.assets
: baseToken.address
? _automation.tokenInputs[baseToken.address as Address]
: "0"
const asset = getTokenData(baseToken.address as Address, chainId)
const amount = formatUnits(nonFormattedAmount || "0", asset.decimals)
// automation logs
const logs = (await fetchMorphoYieldLogs(_automation.id)) || []
const history: MorphoYieldAutomationLog[] = logs.map((log) => {
const prevVault = log.metadata.transitionState.prev
const currentVault = log.metadata.transitionState.current
const name = prevVault ? "Rebalance" : "Deposit"
const fromVault = prevVault
? prevVault.targetVault
: currentVault.targetVault
const toVault = prevVault ? currentVault.targetVault : null
const time = getFormattedDate(new Date(log.createdAt), {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
return {
id: log.id,
name,
toVault,
fromVault,
amount: formatUnits(currentVault.inputAmount, asset.decimals),
asset,
time,
outputTxHash: log.outputTxHash,
underlyingVault: currentVault.targetVault,
}
})
const targetVault = vaultPositionData
? morphoVaults.vaults.find(
(vault) =>
vault.address.toLowerCase() ===
vaultPositionData.vault.address.toLowerCase(),
)
: null
const vaultName = targetVault?.name || ""
const vaultCurators = targetVault?.metadata?.curators || []
const targetCurator =
vaultCurators.length > 0 ? vaultCurators[0] : null
return {
..._automation,
// vaultPositionData,
vaultPositionData: vaultPositionData
? { ...vaultPositionData, vaultName, curator: targetCurator }
: null,
history,
tokenData: {
asset,
amount,
},
}
}),
)
console.log({ automationWithVaultInfo })
const { userPositions } = get()
if (userPositions[token].data.length === 0 && isPollingExecution) {
console.log("STOPPING OVERRIDING EMPTY STATE")
return true // stop overriding empty state during polling}
}
set((prev) => ({
userPositions: {
...prev.userPositions,
[token]: {
loading: false,
data: automationWithVaultInfo,
consoleAddress: consoleAddress,
},
},
}))
return automationWithVaultInfo.length > 0
},
}))
export default useStore