From b85a40817e81b2907ec2a9ab5bede25c8d3fec9a Mon Sep 17 00:00:00 2001 From: Shreerang Kale Date: Wed, 30 Apr 2025 11:19:12 +0530 Subject: [PATCH] Implement handlers for signing onboard tx --- src/screens/SendTxEmbed.tsx | 534 +++++++++++++++++++++++------------- src/screens/WalletEmbed.tsx | 1 + 2 files changed, 339 insertions(+), 196 deletions(-) diff --git a/src/screens/SendTxEmbed.tsx b/src/screens/SendTxEmbed.tsx index 283f8b3..77e3704 100644 --- a/src/screens/SendTxEmbed.tsx +++ b/src/screens/SendTxEmbed.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback, useRef } from 'react'; +import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { ScrollView, View } from 'react-native'; import { ActivityIndicator, @@ -6,268 +6,410 @@ import { Text, TextInput, } from 'react-native-paper'; +import { Box } from '@mui/system'; -import { DirectSecp256k1Wallet } from '@cosmjs/proto-signing'; -import { - calculateFee, - GasPrice, - SigningStargateClient, -} from '@cosmjs/stargate'; +import { DirectSecp256k1Wallet, Algo } from '@cosmjs/proto-signing'; +import { SignDoc } from "cosmjs-types/cosmos/tx/v1beta1/tx"; +import { calculateFee, GasPrice, SigningStargateClient } from '@cosmjs/stargate'; -import { retrieveSingleAccount } from '../utils/accounts'; +import { retrieveAccounts, retrieveSingleAccount } from '../utils/accounts'; // Use retrieveAccounts import AccountDetails from '../components/AccountDetails'; import styles from '../styles/stylesheet'; import DataBox from '../components/DataBox'; import { getPathKey, sendMessage } from '../utils/misc'; import { useNetworks } from '../context/NetworksContext'; import TxErrorDialog from '../components/TxErrorDialog'; -import { MEMO } from './ApproveTransfer'; import { Account, NetworksDataState } from '../types'; -import { Box } from '@mui/system'; + +// --- Type Definitions --- + +const GET_ACCOUNTS_RESPONSE = "GET_ACCOUNTS_RESPONSE"; +const SIGN_ONBOARD_TX_RESPONSE = "SIGN_ONBOARD_TX_RESPONSE"; + +interface SignOnboardTxRequestData { + address: string; + signDoc: SignDoc; +} + +interface GetAccountsRequestData {} // Currently no specific data needed + +type IncomingMessageData = SignOnboardTxRequestData | GetAccountsRequestData; + +interface IncomingMessageEventData { + id: string; + type: 'SIGN_ONBOARD_TX_REQUEST' | 'GET_ACCOUNTS_REQUEST'; + data: IncomingMessageData; +} type TransactionDetails = { + requestId: string; + source: MessageEventSource; + origin: string; signerAddress: string; chainId: string; - account: Account; + account: Account; // Wallet's internal Account type requestedNetwork: NetworksDataState; balance: string; - attestation: any + signDoc: SignDoc; // Deserialized SignDoc }; +interface GetAccountsResponse { + accounts: Array<{ + algo: Algo; + address: string; + pubkey: string; // hex encoded pubkey + }>; +} + +interface SignDirectResponseData { + signed: { + bodyBytes: string; // base64 + authInfoBytes: string; // base64 + chainId: string; + accountNumber: string; // string representation of BigInt + }; + signature: { + pub_key: { + type: string; // e.g., "tendermint/PubKeySecp256k1" + value: string; // base64 encoded pubkey value + }; + signature: string; // base64 encoded signature + }; +} + +// --- Component --- + export const SendTxEmbed = () => { - const [isTxRequested, setIsTxRequested] = useState(false); + const [isTxApprovalVisible, setIsTxApprovalVisible] = useState(false); const [transactionDetails, setTransactionDetails] = useState(null); const [fees, setFees] = useState(''); - const [gasLimit, setGasLimit] = useState(''); + const [gasLimit, setGasLimit] = useState('200000'); // TODO: Revisit gas estimation const [isTxLoading, setIsTxLoading] = useState(false); const [txError, setTxError] = useState(null); - const txEventRef = useRef(null); const { networksData } = useNetworks(); - const handleTxRequested = useCallback( - async (event: MessageEvent) => { + // --- Message Handlers --- + + const handleGetAccountsRequest = useCallback(async (event: MessageEvent) => { + const { id } = event.data; + const source = event.source as Window; + const origin = event.origin; + + console.log("Received GET_ACCOUNTS_REQUEST", id); + try { + const zenithNetworkData = networksData.find(networkData => networkData.chainId === "zenith-testnet") + + if(!zenithNetworkData) { + throw new Error("Zenith network data not found") + } + // Ensure retrieveAccounts exists and returns Account[] + const allAccounts = await retrieveAccounts(zenithNetworkData); // Use retrieveAccounts + + if (!allAccounts || allAccounts.length === 0) { + throw new Error("Accounts not found for zenithNetwork") + } + + const responseAccounts = allAccounts.map((acc) => ({ + algo: 'secp256k1' as Algo, // Assuming secp256k1 + address: acc.address, + pubkey: acc.pubKey.startsWith('0x') ? acc.pubKey : `0x${acc.pubKey}`, // Ensure hex format + })); + + const response: GetAccountsResponse = { accounts: responseAccounts }; + sendMessage(source, GET_ACCOUNTS_RESPONSE, {id, data: response}, origin); + } catch (error: unknown) { + console.error("Error handling GET_ACCOUNTS_REQUEST:", error); + const errorMsg = error instanceof Error ? error.message : String(error); + // Check if source is a Window before sending message + if (source instanceof Window) { + sendMessage(source, GET_ACCOUNTS_RESPONSE, { id, error: `Failed to get accounts: ${errorMsg}` }, origin); + } else { + console.error("Cannot send error message: source is not a Window"); + } + } + }, [networksData]); // Add dependencies like retrieveAccounts if needed + + const handleSignOnboardTxRequest = useCallback(async (event: MessageEvent) => { + const { id, data } = event.data; + const source = event.source as Window; + const origin = event.origin; + const requestData = data as SignOnboardTxRequestData; + + console.log("Received SIGN_ONBOARD_TX_REQUEST", id); + setIsTxApprovalVisible(false); // Hide previous request first + setTransactionDetails(null); + setTxError(null); + + try { + const { address: signerAddress, signDoc } = requestData; + + const network = networksData.find(net => net.chainId === signDoc.chainId); + if (!network) throw new Error(`Network with chainId "${signDoc.chainId}" not supported.`); + + const account = await retrieveSingleAccount(network.namespace, network.chainId, signerAddress); + if (!account) throw new Error(`Account not found for address "${signerAddress}" on chain "${signDoc.chainId}".`); + + // Balance Check + let balanceAmount = '0'; try { - if (event.data.type !== 'REQUEST_ZENITH_SEND_TX') return; - - txEventRef.current = event; - - const { chainId, signerAddress, attestation } = event.data; - const network = networksData.find(net => net.chainId === chainId); - - if (!network) { - console.error('Network not found'); - throw new Error('Requested network not supported.'); - } - - const account = await retrieveSingleAccount(network.namespace, network.chainId, signerAddress); - if (!account) { - throw new Error('Account not found for the requested address.'); - } - - const cosmosPrivKey = ( - await getPathKey(`${network.namespace}:${chainId}`, account.index) - ).privKey; - - const sender = await DirectSecp256k1Wallet.fromKey( - Buffer.from(cosmosPrivKey.split('0x')[1], 'hex'), + // Use a temporary read-only client for balance check if possible, or the signing client + const tempWallet = await DirectSecp256k1Wallet.fromKey( + new Uint8Array(Buffer.from((await getPathKey(`${network.namespace}:${network.chainId}`, account.index)).privKey.replace(/^0x/, ''), 'hex')), // Wrap in Uint8Array network.addressPrefix ); - - const client = await SigningStargateClient.connectWithSigner(network.rpcUrl!, sender); - - const sendMsg = { - // TODO: Update with actual type - typeUrl: '/laconic.onboarding.v1beta1.MsgOnboard', - value: { - attestation - }, - }; - - // TODO: Check funds for the tx - const balance = await client.getBalance( - account.address, - network.nativeDenom!.toLowerCase() - ); - - setTransactionDetails({ - signerAddress, - chainId, - account, - requestedNetwork: network, - balance: balance.amount, - attestation, - }); - - const gasEstimation = await client.simulate(signerAddress, [sendMsg], MEMO); - const gasLimit = String( - Math.round(gasEstimation * Number(process.env.REACT_APP_GAS_ADJUSTMENT)) - ); - setGasLimit(gasLimit); - - const gasPrice = GasPrice.fromString(`${network.gasPrice}${network.nativeDenom}`); - const cosmosFees = calculateFee(Number(gasLimit), gasPrice); - setFees(cosmosFees.amount[0].amount); - - setIsTxRequested(true); - } catch (error) { - if (!(error instanceof Error)) { - throw error; - } - setTxError(error.message); + const client = await SigningStargateClient.connectWithSigner(network.rpcUrl!, tempWallet); + const balance = await client.getBalance(account.address, network.nativeDenom!); + balanceAmount = balance.amount; + client.disconnect(); + } catch (balanceError) { + console.warn("Could not retrieve balance:", balanceError); } - }, [networksData]); + + // Fee Calculation + const gasPrice = GasPrice.fromString(`${network.gasPrice}${network.nativeDenom}`); + const calculatedFee = calculateFee(Number(gasLimit), gasPrice); + setFees(calculatedFee.amount[0].amount); + + setTransactionDetails({ + requestId: id, + source: source, + origin: origin, + signerAddress, + chainId: signDoc.chainId, + account, + requestedNetwork: network, + balance: balanceAmount, + signDoc, + }); + + setIsTxApprovalVisible(true); + + } catch (error: unknown) { + console.error("Error handling SIGN_ONBOARD_TX_REQUEST:", error); + const errorMsg = error instanceof Error ? error.message : String(error); + // Check if source is a Window before sending message + if (source instanceof Window) { + sendMessage(source, id, { error: `Failed to prepare transaction: ${errorMsg}` }, origin); + } else { + console.error("Cannot send error message: source is not a Window"); + } + setTxError(errorMsg); + } + }, [networksData, gasLimit]); // Dependencies: networksData, gasLimit + + const handleIncomingMessage = useCallback((event: MessageEvent) => { + if (!event.data || typeof event.data !== 'object' || !event.data.type || !event.data.id || !event.source || event.source === window) { + return; // Basic validation + } + + const messageData = event.data as IncomingMessageEventData; + + switch (messageData.type) { + case 'GET_ACCOUNTS_REQUEST': + handleGetAccountsRequest(event as MessageEvent); + break; + case 'SIGN_ONBOARD_TX_REQUEST': + handleSignOnboardTxRequest(event as MessageEvent); + break; + default: + console.warn(`Received unknown message type: ${messageData.type}`); + } + }, [handleGetAccountsRequest, handleSignOnboardTxRequest]); useEffect(() => { - window.addEventListener('message', handleTxRequested); - return () => window.removeEventListener('message', handleTxRequested); - }, [handleTxRequested]); + window.addEventListener('message', handleIncomingMessage); + console.log("SendTxEmbed: Message listener added."); + return () => { + window.removeEventListener('message', handleIncomingMessage); + console.log("SendTxEmbed: Message listener removed."); + }; + }, [handleIncomingMessage]); + + // --- UI Action Handlers --- const acceptRequestHandler = async () => { + if (!transactionDetails) { + setTxError("Transaction details are missing."); + return; + } + + setIsTxLoading(true); + setTxError(null); + + const { requestId, source, origin, requestedNetwork, chainId, account, signerAddress, signDoc } = transactionDetails; + try { - setIsTxLoading(true); - if (!transactionDetails) { - throw new Error('Tx details not set'); - } + const { privKey } = await getPathKey(`${requestedNetwork.namespace}:${chainId}`, account.index); + const privateKeyBytes = Buffer.from(privKey.replace(/^0x/, ''), 'hex'); + const wallet = await DirectSecp256k1Wallet.fromKey(new Uint8Array(privateKeyBytes), requestedNetwork.addressPrefix); // Wrap in Uint8Array - const cosmosPrivKey = ( - await getPathKey(`${transactionDetails.requestedNetwork.namespace}:${transactionDetails.chainId}`, transactionDetails.account.index) - ).privKey; + // Perform the actual signing + const signResponse = await wallet.signDirect(signerAddress, signDoc); - const sender = await DirectSecp256k1Wallet.fromKey( - Buffer.from(cosmosPrivKey.split('0x')[1], 'hex'), - transactionDetails.requestedNetwork.addressPrefix - ); + sendMessage(source as Window, SIGN_ONBOARD_TX_RESPONSE, {id: requestId, data: signResponse}, origin); + console.log("Sent signDirect response:", requestId); - const client = await SigningStargateClient.connectWithSigner( - transactionDetails.requestedNetwork.rpcUrl!, - sender - ); + setIsTxApprovalVisible(false); + setTransactionDetails(null); - const fee = calculateFee( - Number(gasLimit), - GasPrice.fromString(`${transactionDetails.requestedNetwork.gasPrice}${transactionDetails.requestedNetwork.nativeDenom}`) - ); - - const txResult = await client.signAndBroadcast(transactionDetails.signerAddress, [transactionDetails.attestation], fee); - - const event = txEventRef.current; - - if (event?.source) { - sendMessage(event.source as Window, 'ZENITH_TRANSACTION_RESPONSE', {txHash: txResult.transactionHash}, event.origin); + } catch (error: unknown) { + console.error("Error during signDirect:", error); + const errorMsg = error instanceof Error ? error.message : String(error); + setTxError(errorMsg); + // Check if source is a Window before sending message + if (source instanceof Window) { + sendMessage(source, SIGN_ONBOARD_TX_RESPONSE, {id: requestId, error: `Failed to sign transaction: ${errorMsg}` }, origin); } else { - console.error('No event source available to send message'); + console.error("Cannot send error message: source is not a Window"); } - } catch (error) { - if (!(error instanceof Error)) { - throw error; - } - setTxError(error.message); } finally { setIsTxLoading(false); } }; const rejectRequestHandler = () => { - const event = txEventRef.current; - - setIsTxRequested(false); - setTransactionDetails(null); - if (event?.source) { - sendMessage(event.source as Window, 'TRANSACTION_RESPONSE', {txHash: null}, event.origin); + if (!transactionDetails) return; + const { requestId, source, origin } = transactionDetails; + console.log("Rejecting request:", requestId); + // Check if source is a Window before sending message + if (source instanceof Window) { + sendMessage(source, SIGN_ONBOARD_TX_RESPONSE, {id: requestId, error: "User rejected the signature request." }, origin); } else { - console.error('No event source available to send message'); + console.error("Cannot send rejection message: source is not a Window"); } + setIsTxApprovalVisible(false); + setTransactionDetails(null); + setTxError(null); }; + // --- Display Logic --- + + const safeStringify = useCallback((obj: any, replacer: any = null, space: number = 2) => { + return JSON.stringify( + obj, + (key, value) => { + if (typeof value === 'bigint') { + return value.toString(); + } + return replacer ? replacer(key, value) : value; + }, + space + ); + }, []) + + const decodeUint8Arrays = useCallback((obj: any): any => { + if (obj instanceof Uint8Array) { + try { + return new TextDecoder().decode(obj); + } catch (e) { + return obj; // fallback if decoding fails + } + } else if (Array.isArray(obj)) { + return obj.map(decodeUint8Arrays); + } else if (obj && typeof obj === 'object') { + const newObj: any = {}; + for (const [key, value] of Object.entries(obj)) { + newObj[key] = decodeUint8Arrays(value); + } + return newObj; + } + return obj; + }, []) + + const displaySignDoc = useMemo(() => { + if (!transactionDetails?.signDoc) return null; + + try { + const signDocCopy = typeof structuredClone === 'function' + ? structuredClone(transactionDetails.signDoc) + : JSON.parse(safeStringify(transactionDetails.signDoc)); + + // Attempt to parse attestation + if ( + signDocCopy.msgs && + signDocCopy.msgs[0]?.value?.attestation && + typeof signDocCopy.msgs[0].value.attestation === 'string' + ) { + try { + signDocCopy.msgs[0].value.attestation = JSON.parse(signDocCopy.msgs[0].value.attestation); + } catch (e) { + console.warn('Could not parse attestation string:', e); + } + } + + const decoded = decodeUint8Arrays(signDocCopy); + return decoded; + } catch (e) { + console.error('Error processing SignDoc:', e); + return transactionDetails.signDoc; + } + }, [transactionDetails?.signDoc, decodeUint8Arrays, safeStringify]); + + // --- Render --- + return ( <> - {isTxRequested && transactionDetails ? ( - <> - - - From - - - + {isTxApprovalVisible && transactionDetails ? ( + + + Sign Request From + + Origin: {transactionDetails.origin} - -
-                {JSON.stringify(JSON.parse(transactionDetails.attestation), null, 2)}
+          
+          
+            Account
+            
+              
+            
+          
+          
+          
+            Transaction Details
+            
+              
+                {safeStringify(displaySignDoc, null, 2)}
               
- + + - - - - /^\d+$/.test(value) ? setGasLimit(value) : null - } - /> - - - + + - - - + ) : ( - - + Waiting for request... )} { - setTxError(null) - if (window.parent) { - sendMessage(window.parent, 'TRANSACTION_RESPONSE', null, '*'); - sendMessage(window.parent, 'closeIframe', null, '*'); - } - }} + hideDialog={() => setTxError(null)} /> ); diff --git a/src/screens/WalletEmbed.tsx b/src/screens/WalletEmbed.tsx index c3e836d..301c421 100644 --- a/src/screens/WalletEmbed.tsx +++ b/src/screens/WalletEmbed.tsx @@ -51,6 +51,7 @@ export const WalletEmbed = () => { useEffect(() => { const handleGetAccounts = async (event: MessageEvent) => { + // TODO: Keep event data types in constant file if (event.data.type !== 'REQUEST_WALLET_ACCOUNTS') return; const accountsData = await getAccountsData(event.data.chainId);