From bb23da62ac1d77289ca4c583b345d9edcb879730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81rgio?= Date: Fri, 3 Jan 2025 18:07:56 +0000 Subject: [PATCH] Solana transaction support --- package.json | 4 +- src/components/AccountContext.tsx | 19 +- src/components/BitteAiChat.tsx | 4 +- src/components/chat/ChatContent.tsx | 4 +- src/components/chat/EvmTxCard.tsx | 1 + src/components/chat/MessageGroup.tsx | 27 ++- src/components/chat/SolTxCard.tsx | 204 ++++++++++++++++++++++ src/components/chat/TransactionResult.tsx | 20 ++- src/hooks/useTransaction.ts | 77 +++++++- src/types/ai/constants.ts | 1 + src/types/types.ts | 19 +- 11 files changed, 362 insertions(+), 18 deletions(-) create mode 100644 src/components/chat/SolTxCard.tsx diff --git a/package.json b/package.json index 46a8c4f..2f1ae63 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,9 @@ }, "peerDependencies": { "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "@solana/web3.js": "1.77.3", + "@reown/appkit-adapter-solana": "^1.6.3" }, "dependencies": { "@ai-sdk/anthropic": "^1.0.2", diff --git a/src/components/AccountContext.tsx b/src/components/AccountContext.tsx index 45b3657..b06aeae 100644 --- a/src/components/AccountContext.tsx +++ b/src/components/AccountContext.tsx @@ -7,7 +7,7 @@ import React, { useState, useEffect, } from "react"; -import { EVMWalletAdapter } from "../types"; +import { EVMWalletAdapter, SolanaWallet } from "../types"; interface AccountContextType { wallet: Wallet; @@ -15,6 +15,8 @@ interface AccountContextType { accountId: string | null; evmWallet?: EVMWalletAdapter; evmAddress?: string; + solanaWallet?: SolanaWallet; + solanaAddress?: string; } const AccountContext = createContext(undefined); @@ -24,15 +26,18 @@ interface AccountProviderProps { wallet: any; account: any; evmWallet?: EVMWalletAdapter; + solanaWallet?: SolanaWallet } - +//TODO fetch solana address export function AccountProvider({ children, wallet, account, evmWallet, + solanaWallet, }: AccountProviderProps) { const [accountId, setAccountId] = useState(null); + const [solWallet, setSolWallet] = useState(); useEffect(() => { const getAccountId = async () => { @@ -44,6 +49,15 @@ export function AccountProvider({ getAccountId(); }, [wallet, account, accountId]); + useEffect(() => { + const setupSolanaWallet = async () => { + if (solanaWallet?.provider) { + setSolWallet(solanaWallet); + } + }; + setupSolanaWallet(); + }, [solanaWallet]); + return ( {children} diff --git a/src/components/BitteAiChat.tsx b/src/components/BitteAiChat.tsx index 4cfb257..86e3855 100644 --- a/src/components/BitteAiChat.tsx +++ b/src/components/BitteAiChat.tsx @@ -15,9 +15,10 @@ export const BitteAiChat = ({ wallet, apiUrl, evmWallet, + solanaWallet, }: BitteAiChatProps) => { return ( - + ); diff --git a/src/components/chat/ChatContent.tsx b/src/components/chat/ChatContent.tsx index 4b2d798..1c2e461 100644 --- a/src/components/chat/ChatContent.tsx +++ b/src/components/chat/ChatContent.tsx @@ -17,7 +17,7 @@ import { BitteAiChatProps, ChatRequestBody, } from "../../types/types"; -import { AccountProvider, useAccount } from "../AccountContext"; +import { useAccount } from "../AccountContext"; import { Button } from "../ui/button"; import ShareModal from "../ui/modal/ShareModal"; import { BitteSpinner } from "./BitteSpinner"; @@ -43,6 +43,7 @@ export const ChatContent = ({ isShare, colors = defaultColors, apiUrl, + solanaWallet, }: BitteAiChatProps) => { const chatId = useRef(id || generateId()).current; const [isAtBottom, setIsAtBottom] = useState(true); @@ -91,6 +92,7 @@ export const ChatContent = ({ }, accountId: accountId || "", evmAddress: evmAddress, + solanaWallet: solanaWallet, } satisfies ChatRequestBody, }); diff --git a/src/components/chat/EvmTxCard.tsx b/src/components/chat/EvmTxCard.tsx index b007f15..1175a75 100644 --- a/src/components/chat/EvmTxCard.tsx +++ b/src/components/chat/EvmTxCard.tsx @@ -26,6 +26,7 @@ export const EvmTxCard = ({ evmData }: { evmData?: SignRequestData }) => { const [isLoading, setIsLoading] = useState(false); const [txHash, setTxHash] = useState(); const { evmAddress, evmWallet } = useAccount(); + //TODO add estimated fees back in if (!evmData) return ( diff --git a/src/components/chat/MessageGroup.tsx b/src/components/chat/MessageGroup.tsx index 675ffbc..52862d5 100644 --- a/src/components/chat/MessageGroup.tsx +++ b/src/components/chat/MessageGroup.tsx @@ -28,6 +28,7 @@ import { EvmTxCard } from "./EvmTxCard"; import { SAMessage } from "./Message"; import { ReviewTransaction } from "./ReviewTransaction"; import ShareDropButton from "./ShareDropButton"; +import { SolTxCard } from "./SolTxCard"; interface MessageGroupProps { groupKey: string; @@ -109,19 +110,27 @@ export const MessageGroup = ({ if ( toolName === BittePrimitiveName.GENERATE_TRANSACTION || toolName === BittePrimitiveName.TRANSFER_FT || - toolName === BittePrimitiveName.GENERATE_EVM_TX + toolName === BittePrimitiveName.GENERATE_EVM_TX || + toolName === BittePrimitiveName.GENERATE_SOL_TX ) { - const [transactions, evmSignRequest] = - result.data && "evmSignRequest" in result.data - ? [result.data.transactions, result.data.evmSignRequest] - : [result.data, undefined]; - + let transactions, evmSignRequest, solSignRequest; + + if (result.data?.evmSignRequest) { + transactions = result.data.transactions; + evmSignRequest = result.data.evmSignRequest; + } else if (result.data?.solSignRequest) { + transactions = result.data.transactions; + solSignRequest = result.data.solSignRequest; + } else { + transactions = result.data; + } + return ( {evmSignRequest ? ( - + + ) : solSignRequest ? ( + ) : ( { + const { width } = useWindowSize(); + const isMobile = !!width && width < 640; + const [errorMsg, setErrorMsg] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [txResult, setTxResult] = useState<{signatures: string[], confirmations: any[]} | undefined>(); + const { solanaWallet, solanaAddress } = useAccount(); + + if (!solanaWallet || !solData) + return ( +

+ Unable to create Solana transaction. +

+ ); + + if (!Array.isArray(solData.params) || solData.params.length === 0) { + return ( +

+ Invalid Solana transaction parameters. +

+ ); + } + + const { provider, connection } = solanaWallet; + + const { handleTxn } = useTransaction({ + solanaProvider: provider, + solanaConnection: connection, + }); + + const handleSmartAction = async () => { + setIsLoading(true); + try { + const result = await handleTxn({ solData: solData }); + if (result.solana) { + setTxResult({ + signatures: result.solana.signatures, + confirmations: result.solana.confirmations + }); + } + } catch (error: any) { + setErrorMsg(error.message); + } finally { + setIsLoading(false); + } + }; + + return ( + <> +
+ + +

Solana Transaction

+
+
+ {solData ? ( +
+
+ + + {solData.params.map((transaction: any, index: any) => ( + + +
+

+ Transaction {index + 1} +

+
+
+ + + } + /> + +
+ ))} +
+
+
+ ) : null} +
+ + {errorMsg && !isLoading ? ( +
+

+ An error occurred trying to execute your transaction: {errorMsg} + . +

+ +
+ ) : null} + + {isLoading ? : null} + {txResult ? ( + + ) : null} + {!isLoading && !errorMsg && !txResult ? ( + + <> + + + + + + ) : null} +
+
+ + ); +}; + +// TODO: Remove this test helper once we have proper agent integration for testing +export async function createTestTransaction(connection: Connection) { + if (!connection) { + throw new Error("Connection is required"); + } + + try { + // Define addresses + const fromPubkey = new PublicKey("DRpbCBMxVnDK7maPGv6MvL4SXKKBYv3vzjact1i7DKG8"); + const toPubkey = new PublicKey("6fQY9fgE4zEWjkZz3XNZGwpjP1VXE9c3ZvGHkGgHqe1V"); + + // Create transfer instruction + const transferInstruction = SystemProgram.transfer({ + fromPubkey, + toPubkey, + lamports: LAMPORTS_PER_SOL * 0.1 + }); + + // Create and prepare transaction + const transaction = new Transaction(); + const { blockhash } = await connection.getLatestBlockhash(); + transaction.recentBlockhash = blockhash; + transaction.feePayer = fromPubkey; + transaction.add(transferInstruction); + + return [transaction]; + + } catch (error) { + console.error("Error creating test transaction:", error); + throw error; + } +} diff --git a/src/components/chat/TransactionResult.tsx b/src/components/chat/TransactionResult.tsx index 6dec5ce..275786a 100644 --- a/src/components/chat/TransactionResult.tsx +++ b/src/components/chat/TransactionResult.tsx @@ -2,7 +2,7 @@ import { Network } from "near-safe"; import { getNearblocksURL, shortenString } from "../../lib/utils"; export const TransactionResult = ({ - result: { evm, near }, + result: { evm, near, solana }, accountId, textColor, }: any) => { @@ -51,6 +51,24 @@ export const TransactionResult = ({ ))} + {solana?.signatures && + solana.signatures.map((signature: string, index: number) => ( + + ))} ); diff --git a/src/hooks/useTransaction.ts b/src/hooks/useTransaction.ts index 4930a4c..c5e030c 100644 --- a/src/hooks/useTransaction.ts +++ b/src/hooks/useTransaction.ts @@ -5,7 +5,9 @@ import { Wallet, } from "@near-wallet-selector/core"; import { EthTransactionParams, SignRequestData } from "near-safe"; -import { EVMWalletAdapter } from "../types"; +import { EVMWalletAdapter, SolSignRequest } from "../types"; +import { Connection, Transaction as SolTransaction } from "@solana/web3.js"; +import type { Provider } from "@reown/appkit-adapter-solana/react"; export interface SuccessInfo { near: { @@ -13,34 +15,54 @@ export interface SuccessInfo { transactions: Transaction[]; encodedTxn?: string; }; + solana?: { + signatures: string[]; + confirmations: Array<{ + status: { + Ok: null; + } | { + Err: any; + }; + confirmationStatus: "processed" | "confirmed" | "finalized" | null; + }>; + }; } interface UseTransactionProps { account?: Account; wallet?: Wallet; evmWallet?: EVMWalletAdapter; + solanaConnection?: Connection; + solanaProvider?: Provider; } interface HandleTxnOptions { transactions?: Transaction[]; evmData?: SignRequestData; + solData?: SolSignRequest; } export const useTransaction = ({ account, wallet, evmWallet, + solanaConnection, + solanaProvider, }: UseTransactionProps) => { const handleTxn = async ({ transactions, evmData, + solData, }: HandleTxnOptions): Promise => { - const hasNoWalletOrAccount = !wallet && !account && !evmWallet?.address; + const hasNoWalletOrAccount = + !wallet && !account && !evmWallet?.address && !solanaProvider; if (hasNoWalletOrAccount) { throw new Error("No wallet or account provided"); } let nearResult; + let solanaResult; + if (transactions) { nearResult = account ? await executeWithAccount(transactions, account) @@ -51,11 +73,26 @@ export const useTransaction = ({ await executeWithEvmWallet(evmData, evmWallet); } + if (solData && solanaConnection && solanaProvider) { + solanaResult = await sendSolanaTransaction( + solData, + solanaConnection, + solanaProvider + ); + } + return { near: { receipts: Array.isArray(nearResult) ? nearResult : [], transactions: transactions || [], }, + solana: solanaResult ? { + signatures: solanaResult.map(tx => tx.signature), + confirmations: solanaResult.map(tx => ({ + status: tx.confirmation.err ? { Err: tx.confirmation.err } : { Ok: null }, + confirmationStatus: tx.confirmation.confirmationStatus || null + })) + } : undefined }; }; @@ -137,3 +174,39 @@ export const executeWithEvmWallet = async ( await Promise.all(txPromises); }; + +async function sendSolanaTransaction( + signRequest: SolSignRequest, + connection: Connection, + walletProvider: Provider +) { + try { + const signatures = await Promise.all( + signRequest.params.map(async (tx) => { + return walletProvider.sendTransaction(tx, connection); + }) + ); + + const { value: statuses } = await connection.getSignatureStatuses(signatures, { + searchTransactionHistory: true + }); + + if (!statuses || statuses.some(status => !status)) { + throw new Error("Failed to confirm one or more transactions"); + } + + const confirmations = statuses.map(status => ({ + err: status?.err, + confirmationStatus: status?.confirmationStatus + })); + + return signatures.map((signature, index) => ({ + signature, + confirmation: confirmations[index] + })); + + } catch (error) { + console.error("Failed to send transactions:", error); + throw error; + } +} diff --git a/src/types/ai/constants.ts b/src/types/ai/constants.ts index fe32e2c..493e707 100644 --- a/src/types/ai/constants.ts +++ b/src/types/ai/constants.ts @@ -24,4 +24,5 @@ export enum BittePrimitiveName { GET_SWAP_TRANSACTIONS = "getSwapTransactions", GET_TOKEN_METADATA = "getTokenMetadata", GENERATE_EVM_TX = "generate-evm-tx", + GENERATE_SOL_TX= "generate-sol-tx" } diff --git a/src/types/types.ts b/src/types/types.ts index 633a8f4..ad57e78 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -14,6 +14,9 @@ import { OpenAPIV3 } from "openapi-types"; import { Account } from "near-api-js/lib/account"; import { Hex } from "viem"; import { BittePrimitiveName } from "./ai/constants"; +import { Connection } from "@reown/appkit-adapter-solana/react"; +import type { Provider } from "@reown/appkit-adapter-solana/react"; +import { Transaction as SolanaTransaction } from "@solana/web3.js"; export type BitteMetadata = { [key: string]: unknown; @@ -194,6 +197,13 @@ export interface BitteAiChatProps { colors: ChatComponentColors; apiUrl?: string; evmWallet?: EVMWalletAdapter; + solanaWallet?: SolanaWallet; +} + +export interface SolanaWallet { + provider?: Provider; + connection?: Connection; + address?: string; } export type SelectedAgent = { @@ -221,7 +231,8 @@ export interface ChatRequestBody { }; accountId?: string; network?: string; - evmAddress?: Hex; + evmAddress?: string; + solanaWallet?: SolanaWallet; } export type AllowlistedToken = { @@ -256,3 +267,9 @@ export type TransactionListProps = { showTxnDetail: boolean; setShowTxnDetail: (showTxnDetail: boolean) => void; }; + +export type SolSignRequest = { + method: "sol_sendTransaction"; + network: string; + params: SolanaTransaction[]; +};