UNPKG

brahma-trade-widget

Version:

A React component for trade automation within the Brahma ecosystem.

1,018 lines (911 loc) 28.2 kB
// store.ts import axios from "axios" import { ConsoleKit } from "brahma-console-kit" import { toast } from "react-toastify" import { encodePacked, fromHex, WalletClient } from "viem" import { base } from "viem/chains" import { create } from "zustand" import Safe from "@safe-global/protocol-kit" import { signTypedData, waitForTransactionReceipt } from "@wagmi/core" import withdrawAbi from "@/components/shared/abi/withdrawContractAbi.json" import { BASE_CHAIN_ID, URL_API_KEY_PROD } from "@/constants" import { SupportedChainIds, TAsset } from "@/types" import { fetchAndFilterAssetBalances, formatRejectMetamaskErrorMessage, truncateString, } from "@/utils" import { baseWagmiConfig as wagmiConfig } from "@/wagmi" import { PROD_URL } from "../morphoStrategy/constants" import { dispatchToast } from "../shared/components" import localStorageService from "../shared/services/localStoage" import useConfigStore from "../shared/store" import { Address } from "../shared/types" import { INITIAL_AUTOMATION_DATA } from "./constants" import { AutomationAgent, AutomationAgentData, AutomationAgentMetadata, AutomationAgentSubscription, NewSwapAgentStep, SwapAgentTab, } from "./types" import { isOneToOneSafe } from "./utils" // const HARDCODED_REGISTRY_ID_DEV = "f2bc4f31-e837-4c94-8ef8-73b4535191dd" const HARDCODED_REGISTRY_ID_PROD = "ae40c25f-6292-4028-9753-04cc9459e298" const HARDCODED_REGISTRY_ID = HARDCODED_REGISTRY_ID_PROD const API_URL = PROD_URL const API_KEY = URL_API_KEY_PROD // const API_URL = DEV_URL // const API_KEY = URL_API_KEY_DEV const kit = new ConsoleKit(API_KEY, API_URL) const stepOrder: NewSwapAgentStep[] = [ "SELECT_AGENT", "LOADING_SELECT_AGENT_ANIMATION", "ONE", "TWO", "THREE", "FOUR", "RUNNING", ] type SwapAgentStore = { automations: { activeAutomations: AutomationAgentSubscription[] historyAutomations: AutomationAgentSubscription[] loading: boolean } existingSafeAddress: Address | null currentStep: NewSwapAgentStep currentTab: SwapAgentTab selectedAgent: AutomationAgent automationAgentData: Record<AutomationAgent, AutomationAgentData> eoaBalances: { data: TAsset[] loading: boolean } balances: { data: TAsset[] loading: boolean } preComputedConsoleAddress: Address | null feeEstimateSignature: string | null feeEstimate: string | null permissionModalData: { isShowingModal: boolean state: "PENDING" | "SUCCESS" } isWithdrawLoading: boolean fetchEoaAssets: (eoa: Address, assets: TAsset[]) => Promise<void> setCurrentTab: (tab: SwapAgentTab) => void setCurrentStep: (step: NewSwapAgentStep) => void setSelectedAgent: (agent: AutomationAgent) => void goToNextStep: () => void goToPreviousStep: () => void updateAgentData: ( agent: AutomationAgent, state: Partial<AutomationAgentData>, ) => void resetAgentsData: () => void fetchPreComputedConsoleAddress: ( owner: Address, chainId: SupportedChainIds, feeToken: Address, ) => Promise<void> withdrawAmount: ( eoa: Address, signer: WalletClient, subAccountAddress: Address, consoleAddress: Address, targetToken: TAsset, allocatedToken: TAsset, closeExitModal?: () => void, ) => Promise<void> subscribeToAutomationWithExistingConsole: ( eoa: Address, signer: WalletClient, chainId: SupportedChainIds, tokenInputs: Record<Address, string>, tokenLimits: Record<Address, string>, metadata: AutomationAgentMetadata, existingConsoleAddress: Address, setConsoleDeployedLoading: React.Dispatch<React.SetStateAction<boolean>>, ) => Promise<void> fetchExistingSwapAgentConsole: (eoa: Address) => Promise<void> generateAndDeploySubAccount: ( eoa: Address, chainId: SupportedChainIds, feeToken: Address, feeEstimate: string, tokens: Address[], amounts: string[], tokenInputs: Record<Address, string>, tokenLimits: Record<Address, string>, metadata: AutomationAgentMetadata, ) => Promise<{ taskId: string } | null> fetchConsoleBalances: ( assets: TAsset[], consoleAddress: Address, addressToDetect?: Address, ) => Promise<boolean> fetchAutomations: (eoa: Address, showLoading?: boolean) => Promise<void> pollTaskStatus(taskId: string): Promise<string | undefined> createTransactionEntry( txnHash: string, chainId: SupportedChainIds, ): Promise<void> updatePermissionModalData: ( state: Partial<SwapAgentStore["permissionModalData"]>, ) => void setExistingSafeAddress: (safeAddress: Address) => void } const useAutomationAgentStore = create<SwapAgentStore>((set, get) => ({ existingSafeAddress: null, automations: { activeAutomations: [], historyAutomations: [], loading: false, }, currentStep: "SELECT_AGENT", currentTab: SwapAgentTab.NEW_AGENT, selectedAgent: "SURGE", automationAgentData: INITIAL_AUTOMATION_DATA, permissionModalData: { isShowingModal: false, state: "PENDING", }, isWithdrawLoading: false, eoaBalances: { data: [], loading: false, }, balances: { data: [], loading: false, }, preComputedConsoleAddress: null, feeEstimate: null, feeEstimateSignature: null, setExistingSafeAddress: (safeAddress) => { set({ existingSafeAddress: safeAddress }) }, updatePermissionModalData: ( updatedState: Partial<SwapAgentStore["permissionModalData"]>, ) => { set((state) => ({ permissionModalData: { ...state.permissionModalData, ...updatedState }, })) }, setCurrentTab: (tab: SwapAgentTab) => { set({ currentTab: tab }) }, setCurrentStep: (step: NewSwapAgentStep) => { set({ currentStep: step }) }, setSelectedAgent: (agent: AutomationAgent) => { set({ selectedAgent: agent }) }, goToNextStep: () => { const currentStep = get().currentStep const currentIndex = stepOrder.indexOf(currentStep) if (currentIndex < stepOrder.length - 1) { set({ currentStep: stepOrder[currentIndex + 1] }) } }, goToPreviousStep: () => { const currentStep = get().currentStep const currentIndex = stepOrder.indexOf(currentStep) if (currentStep === "ONE") { set({ currentStep: "SELECT_AGENT" }) return } if (currentIndex > 0) { set({ currentStep: stepOrder[currentIndex - 1] }) } }, updateAgentData: ( agent: AutomationAgent, state: Partial<AutomationAgentData>, ) => { const { automationAgentData } = get() const updatedAgentState = { ...automationAgentData[agent], ...state } set((prev) => ({ automationAgentData: { ...prev.automationAgentData, [agent]: updatedAgentState, }, })) }, fetchEoaAssets: async (eoa, assets) => { try { const filteredUserAssets = await fetchAndFilterAssetBalances(eoa, assets) set({ eoaBalances: { data: filteredUserAssets, loading: false, }, }) } catch (err) { console.error("error on fetching eoa assets", err) } }, resetAgentsData: () => { set({ automationAgentData: INITIAL_AUTOMATION_DATA, currentStep: "SELECT_AGENT", existingSafeAddress: null, currentTab: SwapAgentTab.NEW_AGENT, automations: { activeAutomations: [], historyAutomations: [], loading: false, }, eoaBalances: { data: [], loading: false, }, balances: { data: [], loading: false, }, preComputedConsoleAddress: null, }) }, fetchPreComputedConsoleAddress: async (owner, chainId, feeToken) => { set((state) => ({ ...state, loading: true })) try { const data = await kit.publicDeployer.fetchPreComputeData( 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 } }, withdrawAmount: async ( eoa, signer, subAccountAddress, consoleAddress, targetToken, allocatedToken, closeExitModal, ) => { const { createTransactionEntry, fetchAutomations } = get() set({ isWithdrawLoading: true }) dispatchToast({ id: "withdraw-start", type: "loading", title: "Transaction in progress", description: { value: "Cancelling agent transaction and withdrawing funds. Please wait...", }, toastOptions: { autoClose: false, }, }) try { console.log( "[Withdraw] Step 1: Initiating cancellation with console kit...", ) const response = await kit.automationContext.cancelAutomation({ chainId: BASE_CHAIN_ID, subAccountAddress, data: { ownerConsole: consoleAddress, sweepTokens: [targetToken.address, allocatedToken.address], execViaSubAcc: [], sweepTokenReceiver: eoa, }, }) console.log( "[Withdraw] Step 1 Complete: Agent transaction cancelled successfully", response, ) console.log("[Withdraw] Step 2: Initializing Safe transaction...") const safe = await Safe.init({ provider: "https://base-mainnet.g.alchemy.com/v2/q2VDjsGh2h0P6WZSXUq2eTw7y0_Ffkdq", safeAddress: consoleAddress, }) const transaction = await safe.createTransaction({ transactions: response.data.transactions, onlyCalls: false, }) const { baseGas, data, gasPrice, gasToken, operation, refundReceiver, safeTxGas, to, value, } = transaction.data console.log("[Withdraw] Step 3: Generating transaction signature...") const signature = encodePacked( ["bytes12", "address", "bytes32", "bytes1"], [ "0x000000000000000000000000", eoa, "0x0000000000000000000000000000000000000000000000000000000000000000", "0x01", ], ) console.log("[Withdraw] Step 4: Executing transaction on Safe...") const txnHash = await signer.writeContract({ chain: base, address: consoleAddress, abi: withdrawAbi, functionName: "execTransaction", account: eoa, gas: BigInt(2000000), args: [ to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, signature, ], }) if (!txnHash) throw new Error("execTransaction failed") console.log(`[Withdraw] Step 5: Transaction sent with hash: ${txnHash}`) console.log("[Withdraw] Step 6: Waiting for transaction receipt...") await waitForTransactionReceipt(wagmiConfig, { hash: txnHash }) console.log("[Withdraw] Step 7: Storing transaction entry...") await createTransactionEntry(txnHash, BASE_CHAIN_ID) console.log("[Withdraw] Step 8: Refreshing agent data...") fetchAutomations(eoa) dispatchToast({ id: "withdraw-success", type: "success", title: "Withdrawal Successful", description: { value: `Transaction completed successfully! Hash: ${truncateString(txnHash, 10)}`, }, }) closeExitModal && setTimeout(closeExitModal, 1000) } catch (err: any) { console.error("[Withdraw] Error occurred:", err) dispatchToast({ id: "withdraw-failed", type: "error", title: "Transaction Failed", description: { value: formatRejectMetamaskErrorMessage(err) || err?.shortMessage || err?.message || "Something went wrong. Please try again.", }, }) } finally { toast.dismiss("withdraw-start") set({ isWithdrawLoading: false }) } }, subscribeToAutomationWithExistingConsole: async ( eoa, signer, chainId, tokenInputs, tokenLimits, metadata, existingConsoleAddress, setConsoleDeployedLoading, ) => { const { createTransactionEntry, fetchAutomations, updatePermissionModalData, goToNextStep, } = get() if (!signer?.account) { dispatchToast({ id: "error-missing-wallet", type: "error", title: "Wallet not connected", description: { value: "Please connect your wallet and try again." }, }) return } setConsoleDeployedLoading(true) updatePermissionModalData({ isShowingModal: true, state: "PENDING" }) dispatchToast({ id: "subscribe-start", type: "loading", title: "Transaction in progress", description: { value: "Subscribing to agent. Please wait..." }, toastOptions: { autoClose: false }, }) try { console.log( "[Subscribe] Step 1: Initiating subscription with console kit...", ) const response = await kit.automationContext.subscribeToAutomation({ data: { metadata, tokenInputs, tokenLimits, registryID: HARDCODED_REGISTRY_ID, chainId, ownerAddress: existingConsoleAddress, duration: 0, whitelistedAddresses: [eoa], }, chainId, }) console.log( "[Subscribe] Step 1 Complete: Subscription initialized", response, ) console.log("[Subscribe] Step 2: Initializing Safe transaction...") const safe = await Safe.init({ provider: "https://base-mainnet.g.alchemy.com/v2/q2VDjsGh2h0P6WZSXUq2eTw7y0_Ffkdq", safeAddress: existingConsoleAddress, }) const transaction = await safe.createTransaction({ transactions: response.data.transactions, onlyCalls: false, }) const { baseGas, data, gasPrice, gasToken, operation, refundReceiver, safeTxGas, to, value, } = transaction.data console.log("[Subscribe] Step 3: Generating transaction signature...") const signature = encodePacked( ["bytes12", "address", "bytes32", "bytes1"], [ "0x000000000000000000000000", // Placeholder eoa, "0x0000000000000000000000000000000000000000000000000000000000000000", // Placeholder "0x01", // Placeholder ], ) console.log("[Subscribe] Step 4: Executing transaction on Safe...") const txnHash = await signer.writeContract({ chain: base, address: existingConsoleAddress, abi: withdrawAbi, functionName: "execTransaction", account: eoa, gas: BigInt(2000000), args: [ to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, signature, ], }) if (!txnHash) throw new Error("execTransaction failed") console.log(`[Subscribe] Step 5: Transaction sent with hash: ${txnHash}`) console.log("[Subscribe] Step 6: Waiting for transaction receipt...") await waitForTransactionReceipt(wagmiConfig, { hash: txnHash as Address }) console.log("[Subscribe] Step 7: Storing transaction entry...") await createTransactionEntry(txnHash, BASE_CHAIN_ID) console.log("[Subscribe] Step 8: Updating permission modal data...") updatePermissionModalData({ isShowingModal: true, state: "SUCCESS" }) console.log("[Subscribe] Step 9: Moving to the next step...") goToNextStep() await new Promise((resolve) => setTimeout(resolve, 3000)) console.log("[Subscribe] Step 10: Refreshing agent data...") fetchAutomations(eoa) dispatchToast({ id: "subscribe-success", type: "success", title: "Subscription Successful", description: { value: `Subscription completed successfully! Hash: ${truncateString(txnHash, 10)}`, }, }) } catch (err: any) { console.error("[Subscribe] Error occurred:", err) dispatchToast({ id: "subscribe-failed", type: "error", title: "Setup Failed", description: { value: formatRejectMetamaskErrorMessage(err) || err?.shortMessage || err?.message || "Failed to deploy agent account", }, }) updatePermissionModalData({ isShowingModal: false }) } finally { toast.dismiss("subscribe-start") setConsoleDeployedLoading(false) } }, generateAndDeploySubAccount: async ( eoa, chainId, feeToken, feeEstimate, tokens, amounts, tokenInputs, tokenLimits, metadata, ) => { const { preComputedConsoleAddress, feeEstimateSignature } = get() 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 null } set((state) => ({ ...state, loading: true })) try { const generateData = await kit.publicDeployer.generateAutomationSubAccount( eoa, preComputedConsoleAddress, chainId, HARDCODED_REGISTRY_ID, feeToken, feeEstimate, tokens, amounts, { duration: 0, tokenInputs: tokenInputs, tokenLimits: tokenLimits, whitelistedAddresses: [eoa], }, metadata, ) if (!generateData || !feeEstimateSignature) { dispatchToast({ id: "fetch-signature-error", title: "Error fetching signature", description: { value: "An error occurred while fetching signature", }, type: "error", }) return null } 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 null } // 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 null } // Update state with taskId and set loading to false set((state) => ({ ...state, signature, // Update the state with the signature loading: false, })) return deployData } 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", }) return null } }, fetchConsoleBalances: async (assets, consoleAddress, addressToDetect) => { if (!consoleAddress) { dispatchToast({ id: "fetch-precomputed-account-balances-error", title: "Error fetching balances", description: { value: "Console address not found", }, type: "error", }) return false } set((state) => ({ balances: { ...state.balances, loading: true } })) try { const filteredUserAssets = await fetchAndFilterAssetBalances( consoleAddress, 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) return targetTokenHasAmount } catch (err) { console.error("Error fetching precomputed console balances:", err) set((state) => ({ balances: { ...state.balances, loading: false } })) return false } }, fetchExistingSwapAgentConsole: async (eoa: Address) => { try { // Check if we already have a 1:1 safe in local storage const existingSafeAddress = localStorageService.getDeployedSwapAgentConsole() if (existingSafeAddress) { console.log("Using 1:1 Safe from local storage:", existingSafeAddress) set({ existingSafeAddress: existingSafeAddress as Address, }) return } const eoaAccounts = await kit.coreActions.fetchExistingAccounts(eoa) if (!eoaAccounts.length) return const filteredEoaAccountsForBaseChainId = eoaAccounts.filter( (account) => account.chainId === BASE_CHAIN_ID, ) for (const account of filteredEoaAccountsForBaseChainId) { try { if (!account) continue const isSafe = await isOneToOneSafe(account.consoleAddress) if (isSafe) { console.log("Found 1:1 Safe:", account.consoleAddress) localStorageService.setDeployedSwapAgentConsole( account.consoleAddress, ) // Store in local storage set({ existingSafeAddress: account.consoleAddress }) // Update state with the found address return // Exit the loop after finding the first 1:1 Safe } } catch (error) { console.error( `Error checking ${account.consoleAddress} or setting local storage:`, error, ) } } // If no 1:1 Safe is found, clear the local storage (optional) localStorageService.clearDeployedSwapAgentConsole() set({ existingSafeAddress: null }) // No 1:1 Safe found, update state accordingly } catch (error) { console.error("Error fetching 1:1 Safes:", error) set({ existingSafeAddress: null }) } }, fetchAutomations: async (eoa, showLoading = true) => { const { fetchAndAddToConfig, assets } = useConfigStore.getState() if (showLoading) { set((state) => ({ ...state, automations: { ...state.automations, loading: true }, })) } try { const { data } = await axios.get( `${API_URL}/automations/subscriptions/owner/${eoa}/${HARDCODED_REGISTRY_ID}`, { headers: { "x-api-key": API_KEY, }, }, ) const currentAccountAutomations: AutomationAgentSubscription[] = data?.data const allTokens = currentAccountAutomations.flatMap(({ metadata }) => [ metadata.sellToken, metadata.buyToken, ]) as Address[] const tokensNotInMetadata = allTokens.filter( (token) => !assets.some( (asset) => asset.address.toLowerCase() === token.toLowerCase(), ), ) await fetchAndAddToConfig(tokensNotInMetadata) const activeAutomations: AutomationAgentSubscription[] = currentAccountAutomations.filter( (automation) => automation.registryId.toLowerCase() === (HARDCODED_REGISTRY_ID || "").toLowerCase() && automation.status === 2, ) const historyAutomations: AutomationAgentSubscription[] = currentAccountAutomations.filter( (automation) => automation.registryId.toLowerCase() === (HARDCODED_REGISTRY_ID || "").toLowerCase() && automation.status === 4, ) set((prev) => ({ automations: { ...prev.automations, activeAutomations, historyAutomations, }, })) } catch (error) { console.error("Error in fetchAutomations:", error) } finally { set((state) => ({ ...state, automations: { ...state.automations, loading: false }, })) } }, async pollTaskStatus(taskId) { const POLLING_INTERVAL = 5000 const MAX_ATTEMPTS = 20 let attempts = 0 return new Promise((resolve, reject) => { const poll = async () => { try { try { const response = await kit.publicDeployer.fetchDeploymentStatus(taskId) // Check if response or data is empty if (!response || !response.outputTransactionHash) { attempts++ if (attempts >= MAX_ATTEMPTS) { reject(new Error("Polling timeout exceeded")) return "" } setTimeout(poll, POLLING_INTERVAL) return "" } if (response.status === "successful") { resolve(response.outputTransactionHash) return } attempts++ if (attempts >= MAX_ATTEMPTS) { reject(new Error("Polling timeout exceeded")) return "" } setTimeout(poll, POLLING_INTERVAL) } catch (error) { console.error("Error fetching task status:", error) attempts++ if (attempts >= MAX_ATTEMPTS) { reject(new Error("Polling timeout exceeded")) return "" } setTimeout(poll, POLLING_INTERVAL) } } catch (error) { console.error("Unexpected error in poll:", error) attempts++ if (attempts >= MAX_ATTEMPTS) { reject(new Error("Polling timeout exceeded")) return "" } setTimeout(poll, POLLING_INTERVAL) } } poll() }) }, async createTransactionEntry(txnHash, chainId) { try { await kit.coreActions.indexTransaction(txnHash, chainId) } catch (error) { dispatchToast({ id: "indexer-error", title: "Error", description: { value: error instanceof Error ? error.message : "Failed to process transaction", }, type: "error", }) } }, })) export default useAutomationAgentStore