Implement handlers for signing onboard tx

This commit is contained in:
Shreerang Kale 2025-04-30 11:19:12 +05:30
parent 7b1a6b5666
commit b85a40817e
2 changed files with 339 additions and 196 deletions

View File

@ -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<boolean>(false);
const [isTxApprovalVisible, setIsTxApprovalVisible] = useState<boolean>(false);
const [transactionDetails, setTransactionDetails] = useState<TransactionDetails | null>(null);
const [fees, setFees] = useState<string>('');
const [gasLimit, setGasLimit] = useState<string>('');
const [gasLimit, setGasLimit] = useState<string>('200000'); // TODO: Revisit gas estimation
const [isTxLoading, setIsTxLoading] = useState(false);
const [txError, setTxError] = useState<string | null>(null);
const txEventRef = useRef<MessageEvent | null>(null);
const { networksData } = useNetworks();
const handleTxRequested = useCallback(
async (event: MessageEvent) => {
// --- Message Handlers ---
const handleGetAccountsRequest = useCallback(async (event: MessageEvent<IncomingMessageEventData>) => {
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<IncomingMessageEventData>) => {
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<IncomingMessageEventData>);
break;
case 'SIGN_ONBOARD_TX_REQUEST':
handleSignOnboardTxRequest(event as MessageEvent<IncomingMessageEventData>);
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 ? (
<>
<ScrollView contentContainerStyle={styles.appContainer}>
<View style={styles.dataBoxContainer}>
<Text style={styles.dataBoxLabel}>From</Text>
<View style={styles.dataBox}>
<AccountDetails account={transactionDetails.account} />
</View>
{isTxApprovalVisible && transactionDetails ? (
<ScrollView contentContainerStyle={styles.appContainer}>
<View style={styles.dataBoxContainer}>
<Text style={styles.dataBoxLabel}>Sign Request From</Text>
<View style={styles.dataBox}>
<Text>Origin: {transactionDetails.origin}</Text>
</View>
<Box
sx={{
backgroundColor: "lightgray",
padding: 3,
borderRadius: 2,
wordWrap: "break-word",
overflowX: "auto",
mb: 2,
}}
>
<pre style={{ whiteSpace: "pre-wrap", margin: 0 }}>
{JSON.stringify(JSON.parse(transactionDetails.attestation), null, 2)}
</View>
<View style={styles.dataBoxContainer}>
<Text style={styles.dataBoxLabel}>Account</Text>
<View style={styles.dataBox}>
<AccountDetails account={transactionDetails.account} />
</View>
</View>
<DataBox
label={`Balance (${transactionDetails.requestedNetwork.nativeDenom})`}
data={transactionDetails.balance === 'N/A' ? 'Could not load' : transactionDetails.balance}
/>
<View style={styles.dataBoxContainer}>
<Text style={styles.dataBoxLabel}>Transaction Details</Text>
<Box sx={{ backgroundColor: "lightgray", padding: 1, borderRadius: 1, wordWrap: "break-word", overflowX: "auto", mb: 2 }}>
<pre style={{ whiteSpace: "pre-wrap", margin: 0, fontSize: '0.8em' }}>
{safeStringify(displaySignDoc, null, 2)}
</pre>
</Box>
<DataBox
label={`Balance (${transactionDetails.requestedNetwork.nativeDenom})`}
data={
transactionDetails.balance === '' ||
transactionDetails.balance === undefined
? 'Loading balance...'
: `${transactionDetails.balance}`
}
</View>
<View style={styles.approveTransfer}>
<TextInput
mode="outlined"
label="Estimated Fee"
value={fees}
editable={false}
style={styles.transactionFeesInput}
/>
<View style={styles.approveTransfer}>
<TextInput
mode="outlined"
label="Fee"
value={fees}
onChangeText={setFees}
style={styles.transactionFeesInput}
/>
<TextInput
mode="outlined"
label="Gas Limit"
value={gasLimit}
onChangeText={value =>
/^\d+$/.test(value) ? setGasLimit(value) : null
}
/>
</View>
</ScrollView>
<TextInput
mode="outlined"
label="Gas Limit"
value={gasLimit}
onChangeText={setGasLimit}
keyboardType="numeric"
/>
</View>
<View style={styles.buttonContainer}>
<Button
mode="contained"
onPress={acceptRequestHandler}
loading={isTxLoading}
// disabled={!transactionDetails.balance || !fees || isTxLoading}
>
{isTxLoading ? 'Processing' : 'Yes'}
<Button mode="contained" onPress={acceptRequestHandler} loading={isTxLoading} disabled={isTxLoading}>
{isTxLoading ? 'Processing...' : 'Approve'}
</Button>
<Button
mode="contained"
onPress={rejectRequestHandler}
buttonColor="#B82B0D"
disabled={isTxLoading}
>
No
<Button mode="contained" onPress={rejectRequestHandler} buttonColor="#B82B0D" disabled={isTxLoading}>
Reject
</Button>
</View>
</>
</ScrollView>
) : (
<View style={styles.spinnerContainer}>
<View style={{ marginTop: 50 }}></View>
<ActivityIndicator size="large" color="#0000ff" />
<Text style={{ marginTop: 50, textAlign: 'center' }}>Waiting for request...</Text>
</View>
)}
<TxErrorDialog
error={txError!}
visible={!!txError}
hideDialog={() => {
setTxError(null)
if (window.parent) {
sendMessage(window.parent, 'TRANSACTION_RESPONSE', null, '*');
sendMessage(window.parent, 'closeIframe', null, '*');
}
}}
hideDialog={() => setTxError(null)}
/>
</>
);

View File

@ -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);