diff --git a/frontend/src/app/(routes)/multisig/components/DialogUpdateSequence.tsx b/frontend/src/app/(routes)/multisig/components/DialogUpdateSequence.tsx new file mode 100644 index 000000000..b197f6403 --- /dev/null +++ b/frontend/src/app/(routes)/multisig/components/DialogUpdateSequence.tsx @@ -0,0 +1,78 @@ +import CustomButton from '@/components/common/CustomButton'; +import { Dialog, DialogContent } from '@mui/material'; +import Image from 'next/image'; +import React from 'react'; + +const DialogUpdateSequence = ({ + open, + onClose, + onUpdateSequence, + loading, +}: { + open: boolean; + onClose: () => void; + onUpdateSequence: () => void; + loading: boolean; +}) => { + return ( + + +
+ +
+
+
+
+ ! +
+
+
+
Transaction Sequence Outdated
+
+ Transaction sequence is outdated. To broadcast this + transaction, the sequence number needs to be updated. +
+
+ Would you like to update? +
+
+
+
+
+ After this action all the signers will be required to re-sign. +
+ +
+
+
+
+
+ ); +}; + +export default DialogUpdateSequence; diff --git a/frontend/src/app/(routes)/multisig/components/common/BroadCastTxn.tsx b/frontend/src/app/(routes)/multisig/components/common/BroadCastTxn.tsx index 82beaae44..6ac2cb36c 100644 --- a/frontend/src/app/(routes)/multisig/components/common/BroadCastTxn.tsx +++ b/frontend/src/app/(routes)/multisig/components/common/BroadCastTxn.tsx @@ -3,16 +3,28 @@ import useGetChainInfo from '@/custom-hooks/useGetChainInfo'; import { setError } from '@/store/features/common/commonSlice'; import { broadcastTransaction, + resetUpdateTxnSequences, resetUpdateTxnState, setVerifyDialogOpen, + updateTxnSequences, } from '@/store/features/multisig/multisigSlice'; import { RootState } from '@/store/store'; import { MultisigAddressPubkey, Txn } from '@/types/multisig'; -import React, { useEffect } from 'react'; -import { FAILED_TO_BROADCAST_ERROR } from '@/utils/errors'; +import React, { useEffect, useState } from 'react'; +import { + CANNOT_BROADCAST_ERROR, + FAILED_TO_BROADCAST_ERROR, + FAILED_TO_BROADCAST_TRY_AGAIN, + FAILED_TO_UPDATE_SEQUENCE, + UPDATED_SEQUENCE_SUCCESSFULLY, +} from '@/utils/errors'; import useVerifyAccount from '@/custom-hooks/useVerifyAccount'; import CustomButton from '@/components/common/CustomButton'; import { useRouter } from 'next/navigation'; +import DialogUpdateSequence from '../DialogUpdateSequence'; +import { getAuthToken } from '@/utils/localStorage'; +import { COSMOS_CHAIN_ID } from '@/utils/constants'; +import { TxStatus } from '@/types/enums'; interface BroadCastTxnProps { txn: Txn; @@ -21,8 +33,13 @@ interface BroadCastTxnProps { pubKeys: MultisigAddressPubkey[]; chainID: string; isMember: boolean; - disableBroadcast?: boolean; isOverview?: boolean; + broadcastInfo?: { + disable: boolean; + isSequenceLess: boolean; + isSequenceGreater: boolean; + isSequenceAvailable: boolean; + }; } const BroadCastTxn: React.FC = (props) => { @@ -33,8 +50,8 @@ const BroadCastTxn: React.FC = (props) => { threshold, chainID, isMember, - disableBroadcast, isOverview, + broadcastInfo, } = props; const dispatch = useAppDispatch(); const { getChainInfo } = useGetChainInfo(); @@ -49,9 +66,15 @@ const BroadCastTxn: React.FC = (props) => { }); const router = useRouter(); + const [seqNotSyncOpen, setSeqNotSyncOpen] = useState(false); const updateTxnRes = useAppSelector( (state: RootState) => state.multisig.updateTxnRes ); + const updateTxnSequencesStatus = useAppSelector( + (state) => state.multisig.updateTxnSequences + ); + + const authToken = getAuthToken(COSMOS_CHAIN_ID); useEffect(() => { if (updateTxnRes.status === 'rejected') { @@ -71,10 +94,6 @@ const BroadCastTxn: React.FC = (props) => { }, []); const broadcastTxn = async () => { - if (!isAccountVerified()) { - dispatch(setVerifyDialogOpen(true)); - return; - } dispatch( broadcastTransaction({ chainID, @@ -88,19 +107,80 @@ const BroadCastTxn: React.FC = (props) => { }) ); }; + + const handleBroadcast = () => { + if (!isAccountVerified()) { + dispatch(setVerifyDialogOpen(true)); + return; + } + if (isOverview) { + router.push(`/multisig/${chainName}/${multisigAddress}`); + } else if (broadcastInfo) { + if (broadcastInfo.isSequenceLess) { + setSeqNotSyncOpen(true); + } else if (broadcastInfo.isSequenceGreater) { + dispatch(setError({ type: 'error', message: CANNOT_BROADCAST_ERROR })); + } else if (!broadcastInfo.isSequenceAvailable) { + // TODO: Sequence number is not available is the txn is signed before adding txn_Sequence into db + // This needs to be handled + dispatch( + setError({ type: 'error', message: "Seqeunce not found" }) + ); + } else if (!broadcastInfo.disable) { + broadcastTxn(); + } else { + dispatch( + setError({ type: 'error', message: FAILED_TO_BROADCAST_TRY_AGAIN }) + ); + } + } + }; + + const handleUpdateSequence = () => { + dispatch( + updateTxnSequences({ + data: { address: multisigAddress }, + queryParams: { + address: walletAddress, + signature: authToken?.signature || '', + }, + }) + ); + }; + + useEffect(() => { + if (updateTxnSequencesStatus.status === TxStatus.IDLE) { + dispatch( + setError({ type: 'success', message: UPDATED_SEQUENCE_SUCCESSFULLY }) + ); + dispatch(resetUpdateTxnSequences()); + setSeqNotSyncOpen(false); + } + if (updateTxnSequencesStatus.status === TxStatus.REJECTED) { + dispatch( + setError({ + type: 'error', + message: updateTxnSequencesStatus?.error || FAILED_TO_UPDATE_SEQUENCE, + }) + ); + } + }, [updateTxnSequencesStatus]); + return ( - { - if (isOverview) { - router.push(`/multisig/${chainName}/${multisigAddress}`); - } else { - broadcastTxn(); - } - }} - btnDisabled={!isMember || disableBroadcast} - btnStyles="w-[115px]" - /> + <> + + setSeqNotSyncOpen(false)} + onUpdateSequence={handleUpdateSequence} + loading={updateTxnSequencesStatus.status === TxStatus.PENDING} + /> + ); }; diff --git a/frontend/src/app/(routes)/multisig/components/common/SignTxn.tsx b/frontend/src/app/(routes)/multisig/components/common/SignTxn.tsx index bf2ee18a5..fd85194f4 100644 --- a/frontend/src/app/(routes)/multisig/components/common/SignTxn.tsx +++ b/frontend/src/app/(routes)/multisig/components/common/SignTxn.tsx @@ -16,11 +16,16 @@ interface SignTxnProps { unSignedTxn: Txn; isMember: boolean; chainID: string; + isSigned: boolean; isOverview?: boolean; } +// TODO: Sequence number is not available is the txn is signed before adding txn_Sequence into db +// This needs to be handled + const SignTxn: React.FC = (props) => { - const { address, isMember, unSignedTxn, chainID, isOverview } = props; + const { address, isMember, unSignedTxn, chainID, isOverview, isSigned } = + props; const dispatch = useAppDispatch(); const { getChainInfo } = useGetChainInfo(); const { address: walletAddress, rpcURLs, chainName } = getChainInfo(chainID); @@ -30,6 +35,17 @@ const SignTxn: React.FC = (props) => { const router = useRouter(); const txnsCount = useAppSelector((state) => state.multisig.txns.Count); + const allTxns = useAppSelector((state) => state.multisig.txns.list); + const multisigAccount = useAppSelector( + (state) => state.multisig.multisigAccount + ); + + const partiallySigned = allTxns.filter( + (tx) => + tx.signatures.length > 0 && + tx.signatures.length < multisigAccount.account.threshold + ); + const getCount = (option: string) => { let count = 0; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -56,14 +72,16 @@ const SignTxn: React.FC = (props) => { unSignedTxn, walletAddress, rpcURLs, + txnSequence: unSignedTxn.txn_sequence, toBeBroadcastedCount, + partiallySignedCount: partiallySigned?.length, }) ); }; return ( { if (isOverview) { diff --git a/frontend/src/app/(routes)/multisig/components/common/TxnsCard.tsx b/frontend/src/app/(routes)/multisig/components/common/TxnsCard.tsx index 4319fcdb9..8f309e58f 100644 --- a/frontend/src/app/(routes)/multisig/components/common/TxnsCard.tsx +++ b/frontend/src/app/(routes)/multisig/components/common/TxnsCard.tsx @@ -30,6 +30,8 @@ import useVerifyAccount from '@/custom-hooks/useVerifyAccount'; import { fee } from '@/txns/execute'; import DialogRepeatTxn from '../DialogRepeatTxn'; import { getTimeDifferenceToFutureDate } from '@/utils/dataTime'; +import { setError } from '@/store/features/common/commonSlice'; +import { ADMIN_ONLY_ALLOWED } from '@/utils/errors'; export const TxnsCard = ({ txn, @@ -40,8 +42,8 @@ export const TxnsCard = ({ isHistory, onViewError, allowRepeat, - disableBroadcast, isOverview, + broadcastInfo, }: { txn: Txn; currency: Currency; @@ -51,8 +53,13 @@ export const TxnsCard = ({ isHistory: boolean; onViewError?: (errMsg: string) => void; allowRepeat?: boolean; - disableBroadcast?: boolean; isOverview?: boolean; + broadcastInfo?: { + disable: boolean; + isSequenceLess: boolean; + isSequenceGreater: boolean; + isSequenceAvailable: boolean; + }; }) => { const dispatch = useAppDispatch(); const { getChainInfo } = useGetChainInfo(); @@ -61,6 +68,11 @@ export const TxnsCard = ({ const { isAccountVerified } = useVerifyAccount({ address: walletAddress, }); + const multisigAccount = useAppSelector( + (state) => state.multisig.multisigAccount + ); + const isAdmin = + multisigAccount?.account?.created_by === (walletAddress || ''); const [showAll, setShowAll] = useState(false); const [viewRawOpen, setViewRawOpen] = useState(false); @@ -81,7 +93,15 @@ export const TxnsCard = ({ const loading = useAppSelector((state) => state.multisig.deleteTxnRes.status); + const isSigned = txn?.signatures?.some( + (sign) => sign.address === walletAddress + ); + const hanldeDeleteTxn = () => { + if (!isAdmin) { + dispatch(setError({ type: 'error', message: ADMIN_ONLY_ALLOWED })); + return; + } if (isAccountVerified()) { setDeleteDialogOpen(true); } else { @@ -272,8 +292,8 @@ export const TxnsCard = ({ threshold={threshold} chainID={chainID} isMember={isMember} - disableBroadcast={disableBroadcast} isOverview={isOverview} + broadcastInfo={broadcastInfo} /> ) : ( )} diff --git a/frontend/src/app/(routes)/multisig/components/multisig-account/MultisigAccount.tsx b/frontend/src/app/(routes)/multisig/components/multisig-account/MultisigAccount.tsx index 27c753abb..9ebbe5893 100644 --- a/frontend/src/app/(routes)/multisig/components/multisig-account/MultisigAccount.tsx +++ b/frontend/src/app/(routes)/multisig/components/multisig-account/MultisigAccount.tsx @@ -2,7 +2,6 @@ import { useAppDispatch, useAppSelector } from '@/custom-hooks/StateHooks'; import useGetChainInfo from '@/custom-hooks/useGetChainInfo'; import { getDelegations as getMultisigDelegations, - getMultisigAccounts, getMultisigBalances, multisigByAddress, resetBroadcastTxnRes, @@ -10,6 +9,8 @@ import { resetsignTransactionRes, resetUpdateTxnState, setVerifyDialogOpen, + getSequenceNumber, + resetSequenceNumber, } from '@/store/features/multisig/multisigSlice'; import { useEffect, useState } from 'react'; import MultisigAccountHeader from './MultisigAccountHeader'; @@ -71,6 +72,10 @@ const MultisigAccount = ({ ); const members = multisigAccount.pubkeys.map((pubkey) => pubkey.address); + const broadcastTxnStatus = useAppSelector( + (state) => state.multisig.broadcastTxnRes.status + ); + const { name: multisigName, created_at: createdTime } = multisigAccount.account; @@ -99,7 +104,6 @@ const MultisigAccount = ({ }) ); dispatch(multisigByAddress({ address: multisigAddress })); - dispatch(getMultisigAccounts(walletAddress)); } }, [chainID]); @@ -110,6 +114,20 @@ const MultisigAccount = ({ dispatch(resetsignTransactionRes()); }, []); + useEffect(() => { + if (multisigAddress) { + dispatch( + getSequenceNumber({ baseURLs: restURLs, chainID, multisigAddress }) + ); + } + }, [broadcastTxnStatus, multisigAddress]); + + useEffect(() => { + return () => { + dispatch(resetSequenceNumber()); + }; + }, []); + const createNewTxn = () => { if (!isAccountVerified()) { dispatch(setVerifyDialogOpen(true)); @@ -195,12 +213,6 @@ const MultisigAccountInfo = ({ const [availableBalance, setAvailableBalance] = useState(0); const [hasIBCTokens, setHasIBCTokens] = useState(false); - const multisigAccount = useAppSelector( - (state) => state.multisig.multisigAccount - ); - const multisigAccounts = useAppSelector( - (state) => state.multisig.multisigAccounts - ); const totalStaked = useAppSelector( (state) => state.multisig?.delegations.totalStaked ); @@ -217,8 +229,22 @@ const MultisigAccountInfo = ({ currency.coinDecimals, currency.coinMinimalDenom ); - const { txnCounts = {} } = multisigAccounts; - const actionsRequired = txnCounts?.[multisigAccount?.account?.address] || 0; + const txnsCount = useAppSelector((state) => state.multisig.txns.Count); + + const getCount = (option: string) => { + let count = 0; + /* eslint-disable @typescript-eslint/no-explicit-any */ + txnsCount && + txnsCount.forEach((t: any) => { + if (t?.computed_status?.toLowerCase() === option.toLowerCase()) { + count = t?.count; + } + }); + + return count; + }; + + const actionsRequired = getCount('to-sign') + getCount('to-broadcast'); useEffect(() => { setAvailableBalance( diff --git a/frontend/src/app/(routes)/multisig/components/multisig-account/Transactions.tsx b/frontend/src/app/(routes)/multisig/components/multisig-account/Transactions.tsx index 02e1b5b45..81b89f90e 100644 --- a/frontend/src/app/(routes)/multisig/components/multisig-account/Transactions.tsx +++ b/frontend/src/app/(routes)/multisig/components/multisig-account/Transactions.tsx @@ -44,6 +44,9 @@ const Transactions = ({ (state) => state.multisig.signTransactionRes ); const updateTxStatus = useAppSelector((state) => state.multisig.updateTxnRes); + const updateTxnSequencesStatus = useAppSelector( + (state) => state.multisig.updateTxnSequences + ); const handleTxnsTypeChange = (type: string) => { setTxnsType(type); @@ -110,7 +113,8 @@ const Transactions = ({ if ( signTxStatus.status === TxStatus.IDLE || updateTxStatus.status === TxStatus.IDLE || - updateTxStatus.status === TxStatus.REJECTED + updateTxStatus.status === TxStatus.REJECTED || + updateTxnSequencesStatus.status === TxStatus.IDLE ) { if (['failed', 'history', 'completed'].includes(txnsType)) { fetchTxns('history'); @@ -118,7 +122,11 @@ const Transactions = ({ fetchTxns('current'); } } - }, [signTxStatus.status, updateTxStatus.status]); + }, [ + signTxStatus.status, + updateTxStatus.status, + updateTxnSequencesStatus.status, + ]); // To reset state after singing or broadcasting txn useFetchTxns(); @@ -244,13 +252,24 @@ const TransactionsList = ({ setErrMsg(errMsg); setViewErrorDialogOpen(true); }; + const currentSequenceNumber = useAppSelector( + (state) => state.multisig.multisigAccountSequenceNumber.value + ); const sortedTxns = [...txns].sort((a, b) => { - const dateA = new Date( - txnsType === 'to-broadcast' ? a.signed_at : a.created_at - ).getTime(); - const dateB = new Date( - txnsType === 'to-broadcast' ? b.signed_at : b.created_at - ).getTime(); + const dateA = + txnsType === 'to-broadcast' + ? a.txn_sequence !== null + ? a.txn_sequence + : new Date(a.signed_at).getTime() + : new Date(a.created_at).getTime(); + + const dateB = + txnsType === 'to-broadcast' + ? b.txn_sequence !== null + ? b.txn_sequence + : new Date(b.signed_at).getTime() + : new Date(b.created_at).getTime(); + return txnsType === 'to-broadcast' ? dateA - dateB : dateB - dateA; }); @@ -267,7 +286,22 @@ const TransactionsList = ({ isHistory={isHistory} onViewError={onViewError} allowRepeat={txnsType === 'completed'} - disableBroadcast={txnsType === 'to-broadcast' && index > 0} + broadcastInfo={{ + disable: + txnsType === 'to-broadcast' && + currentSequenceNumber !== null && + txn.txn_sequence !== null && + currentSequenceNumber !== txn.txn_sequence, + isSequenceLess: + txn.txn_sequence !== null && + currentSequenceNumber !== null && + txn.txn_sequence < currentSequenceNumber, + isSequenceGreater: + txn.txn_sequence !== null && + currentSequenceNumber !== null && + txn.txn_sequence > currentSequenceNumber, + isSequenceAvailable: Number(txn?.txn_sequence) ? true : false, + }} /> ))} = (props) => { dispatch(resetUpdateTxnState()); dispatch(resetBroadcastTxnRes()); dispatch(resetsignTransactionRes()); + dispatch(resetSequenceNumber()); + dispatch(resetUpdateTxnSequences()); }, []); return ( diff --git a/frontend/src/app/(routes)/multisig/utils/multisigSigning.ts b/frontend/src/app/(routes)/multisig/utils/multisigSigning.ts index f1a43051d..568caff94 100644 --- a/frontend/src/app/(routes)/multisig/utils/multisigSigning.ts +++ b/frontend/src/app/(routes)/multisig/utils/multisigSigning.ts @@ -25,7 +25,9 @@ const signTransaction = async ( unSignedTxn: Txn, walletAddress: string, rpcURLs: string[], - toBeBroadcastedCount: number + toBeBroadcastedCount: number, + partiallySignedCount: number, + txnSequence: number | null ) => { try { window.wallet.defaultOptions = { @@ -45,10 +47,13 @@ const signTransaction = async ( if (!multisigAcc) { throw new Error('Multisig account does not exist on chain'); } + const updatedTxnSequence = + txnSequence ?? + multisigAcc?.sequence + toBeBroadcastedCount + partiallySignedCount; const signerData = { accountNumber: multisigAcc?.accountNumber, - sequence: multisigAcc?.sequence + toBeBroadcastedCount, + sequence: updatedTxnSequence, chainId: chainID, }; @@ -67,6 +72,7 @@ const signTransaction = async ( txId: unSignedTxn.id || NaN, address: multisigAddress, signature: toBase64(signatures[0]), + txnSequence: updatedTxnSequence }; return payload; diff --git a/frontend/src/store/features/multisig/multisigService.ts b/frontend/src/store/features/multisig/multisigService.ts index 34092f03d..a24ea0257 100644 --- a/frontend/src/store/features/multisig/multisigService.ts +++ b/frontend/src/store/features/multisig/multisigService.ts @@ -28,6 +28,9 @@ const SIGNATURE_PARAMS_STRING = (queryParams: QueryParams): string => const CREATE_ACCOUNT = (queryParams: QueryParams): string => `/multisig` + SIGNATURE_PARAMS_STRING(queryParams); +const UPDATE_TXN_SEQUENCES = (address: string) => + `${BASE_URL}/multisig/${address}/reset-txns`; + const SIGN_URL = ( queryParams: QueryParams, address: string, @@ -102,6 +105,14 @@ export const getAccountAllMultisigTxns = ( return Axios.get(uri); }; +export const updateTxnSequences = ( + queryParams: QueryParams, + address: string +): Promise => + Axios.delete( + UPDATE_TXN_SEQUENCES(address) + SIGNATURE_PARAMS_STRING(queryParams), + ); + export const deleteTx = ( queryParams: QueryParams, address: string, @@ -147,4 +158,5 @@ export default { verifyUser, getAccountAllMultisigTxns, getStargateClient, + updateTxnSequences }; diff --git a/frontend/src/store/features/multisig/multisigSlice.ts b/frontend/src/store/features/multisig/multisigSlice.ts index 59fa950c0..8a5da68e8 100644 --- a/frontend/src/store/features/multisig/multisigSlice.ts +++ b/frontend/src/store/features/multisig/multisigSlice.ts @@ -33,9 +33,9 @@ import { MultisigAddressPubkey, MultisigState, QueryParams, - SignTxInputs, Txn, UpdateTxnInputs, + UpdateTxnSequencesInputs, } from '@/types/multisig'; import { getRandomNumber, @@ -158,6 +158,15 @@ const initialState: MultisigState = { error: '', }, verifyDialogOpen: false, + multisigAccountSequenceNumber: { + value: null, + status: TxStatus.INIT, + error: '', + }, + updateTxnSequences: { + status: TxStatus.INIT, + error: '', + }, }; declare let window: WalletWindow; @@ -285,6 +294,55 @@ export const multisigByAddress = createAsyncThunk( } ); +export const getSequenceNumber = createAsyncThunk( + 'multisig/getSequenceNumber', + async ( + data: { + baseURLs: string[]; + multisigAddress: string; + chainID: string; + }, + { rejectWithValue } + ) => { + try { + const response = await authService.accountInfo( + data.baseURLs, + data.multisigAddress, + data.chainID + ); + const currentSequnce = get(response, 'data.account.sequence', null); + return { + data: { + sequenceNumber: currentSequnce ? Number(currentSequnce) : null, + }, + }; + } catch (error) { + if (error instanceof AxiosError) + return rejectWithValue({ message: error.message }); + return rejectWithValue({ message: ERR_UNKNOWN }); + } + } +); + +export const updateTxnSequences = createAsyncThunk( + 'multisig/update-txn-sequences', + async (data: UpdateTxnSequencesInputs, { rejectWithValue }) => { + try { + const response = await multisigService.updateTxnSequences( + data.queryParams, + data.data.address + ); + trackEvent('MULTISIG', 'UPDATE_TXN_SEQUENCES', SUCCESS); + return response.data; + } catch (error) { + trackEvent('MULTISIG', 'UPDATE_TXN_SEQUENCES', FAILED); + if (error instanceof AxiosError) + return rejectWithValue({ message: error.message }); + return rejectWithValue({ message: ERR_UNKNOWN }); + } + } +); + export const getMultisigBalances = createAsyncThunk( 'multisig/multisigBalance', async (data: GetMultisigBalancesInputs, { rejectWithValue }) => { @@ -524,6 +582,8 @@ export const signTransaction = createAsyncThunk( walletAddress: string; rpcURLs: string[]; toBeBroadcastedCount: number; + partiallySignedCount: number; + txnSequence: number | null; }, { rejectWithValue, dispatch } ) => { @@ -534,7 +594,9 @@ export const signTransaction = createAsyncThunk( data.unSignedTxn, data.walletAddress, data.rpcURLs, - data.toBeBroadcastedCount + data.toBeBroadcastedCount, + data.partiallySignedCount, + data.txnSequence ); const authToken = getAuthToken(COSMOS_CHAIN_ID); @@ -548,6 +610,7 @@ export const signTransaction = createAsyncThunk( { signer: payload.signer, signature: payload.signature, + txn_sequence: payload.txnSequence, } ); trackEvent('MULTISIG', 'SIGN_TXN', SUCCESS); @@ -571,28 +634,6 @@ export const signTransaction = createAsyncThunk( } ); -export const signTx = createAsyncThunk( - 'multisig/signTx', - async (data: SignTxInputs, { rejectWithValue }) => { - try { - const response = await multisigService.signTx( - data.queryParams, - data.data.address, - data.data.txId, - { - signer: data.data.signer, - signature: data.data.signature, - } - ); - return response.data; - } catch (error) { - if (error instanceof AxiosError) - return rejectWithValue({ message: error.message }); - return rejectWithValue({ message: ERR_UNKNOWN }); - } - } -); - export const importMultisigAccount = createAsyncThunk( 'multisig/importMultisigAccount', async ( @@ -673,6 +714,13 @@ export const multisigSlice = createSlice({ resetMultisigAccountData: (state) => { state.multisigAccountData = initialState.multisigAccountData; }, + resetSequenceNumber: (state) => { + state.multisigAccountSequenceNumber = + initialState.multisigAccountSequenceNumber; + }, + resetUpdateTxnSequences: (state) => { + state.updateTxnSequences = initialState.updateTxnSequences; + }, setVerifyDialogOpen: (state, action: PayloadAction) => { state.verifyDialogOpen = action.payload; }, @@ -775,6 +823,44 @@ export const multisigSlice = createSlice({ state.multisigAccount.status = TxStatus.REJECTED; state.multisigAccount.error = payload.message || ''; }); + builder + .addCase(getSequenceNumber.pending, (state) => { + state.multisigAccountSequenceNumber.status = TxStatus.PENDING; + state.multisigAccountSequenceNumber.value = null; + state.multisigAccount.error = ''; + }) + .addCase(getSequenceNumber.fulfilled, (state, action) => { + state.multisigAccountSequenceNumber.status = TxStatus.IDLE; + state.multisigAccountSequenceNumber.value = + action.payload.data.sequenceNumber; + state.multisigAccount.error = ''; + }) + .addCase(getSequenceNumber.rejected, (state, action) => { + const payload = action.payload as { message: string }; + state.multisigAccount.status = TxStatus.REJECTED; + state.multisigAccountSequenceNumber.value = null; + state.multisigAccount.error = payload.message || ''; + }); + builder + .addCase(updateTxnSequences.pending, (state) => { + state.updateTxnSequences = { + status: TxStatus.PENDING, + error: '', + }; + }) + .addCase(updateTxnSequences.fulfilled, (state) => { + state.updateTxnSequences = { + status: TxStatus.IDLE, + error: '', + }; + }) + .addCase(updateTxnSequences.rejected, (state, action) => { + const payload = action.payload as { message: string }; + state.updateTxnSequences = { + status: TxStatus.REJECTED, + error: payload.message || '', + }; + }); builder .addCase(getMultisigBalances.pending, (state) => { state.balance.status = TxStatus.PENDING; @@ -878,18 +964,6 @@ export const multisigSlice = createSlice({ const payload = action.payload as { message: string }; state.txns.error = payload.message || ''; }); - builder - .addCase(signTx.pending, (state) => { - state.signTxRes.status = TxStatus.PENDING; - }) - .addCase(signTx.fulfilled, (state) => { - state.signTxRes.status = TxStatus.IDLE; - }) - .addCase(signTx.rejected, (state, action) => { - state.signTxRes.status = TxStatus.REJECTED; - const payload = action.payload as { message: string }; - state.signTxRes.error = payload.message || ''; - }); builder .addCase(signTransaction.pending, (state) => { state.signTransactionRes.status = TxStatus.PENDING; @@ -948,6 +1022,8 @@ export const { resetBroadcastTxnRes, resetsignTransactionRes, setVerifyDialogOpen, + resetSequenceNumber, + resetUpdateTxnSequences, } = multisigSlice.actions; export default multisigSlice.reducer; diff --git a/frontend/src/types/multisig.d.ts b/frontend/src/types/multisig.d.ts index 426cef9bc..4ecfd837c 100644 --- a/frontend/src/types/multisig.d.ts +++ b/frontend/src/types/multisig.d.ts @@ -14,6 +14,7 @@ interface UpdateTxPayload { interface SignTxPayload { signer: string; signature: string; + txn_sequence: number; } interface Fee { @@ -68,6 +69,13 @@ interface DeleteTxnInputs { }; } +interface UpdateTxnSequencesInputs { + queryParams: QueryParams; + data: { + address: string; + }; +} + interface MultisigAddressPubkey { address: string; multisig_address: string; @@ -149,6 +157,15 @@ interface MultisigState { memo: string; }; }; + multisigAccountSequenceNumber: { + value: number | null; + status: TxStatus; + error: string; + }; + updateTxnSequences: { + status: TxStatus; + error: string; + }; } interface VerifyAccountRes { @@ -206,6 +223,7 @@ interface Txn { signed_at: string; pubkeys?: MultisigAddressPubkey[]; threshold?: number; + txn_sequence: number | null; } interface TxnCount { @@ -217,7 +235,7 @@ interface Txns { list: Txn[]; status: TxStatus; error: string; - Count: TxnCount[] + Count: TxnCount[]; } interface SignTxInputs { diff --git a/frontend/src/utils/errors.ts b/frontend/src/utils/errors.ts index 4e5fd6ba6..77cd20214 100644 --- a/frontend/src/utils/errors.ts +++ b/frontend/src/utils/errors.ts @@ -27,10 +27,18 @@ export const INSUFFICIENT_BALANCE = 'Insufficient balance'; export const NOT_MULTISIG_MEMBER_ERROR = 'Cannot import account: You are not a member of the multisig account'; export const NOT_MULTISIG_ACCOUNT_ERROR = 'Not a multisig account'; -export const CHAIN_NOT_SELECTED_ERROR = 'Please select at least one network from the left'; -export const MSG_NOT_SELECTED_ERROR = 'Please select at least one transaction message from the left'; +export const CHAIN_NOT_SELECTED_ERROR = + 'Please select at least one network from the left'; +export const MSG_NOT_SELECTED_ERROR = + 'Please select at least one transaction message from the left'; export const PERMISSION_NOT_SELECTED_ERROR = 'Atleast one permission must be selected'; export const FAILED_TO_FETCH = 'Failed to fetch'; export const NETWORK_ERROR = 'Network error'; -export const ERR_TXN_NOT_FOUND = 'TXN not found'; \ No newline at end of file +export const ERR_TXN_NOT_FOUND = 'TXN not found'; +export const CANNOT_BROADCAST_ERROR = + 'Cannot broadcast, There is transaction that need to be broadcasted before this.'; +export const FAILED_TO_UPDATE_SEQUENCE = 'Failed to update sequence'; +export const UPDATED_SEQUENCE_SUCCESSFULLY = 'Successfully updated sequence'; +export const ADMIN_ONLY_ALLOWED = 'Delete action is limited to the admin only'; +export const FAILED_TO_BROADCAST_TRY_AGAIN = 'Failed to broadcast. Please try again.'; diff --git a/server/handler/transactions.go b/server/handler/transactions.go index 19f8c7e5e..c184296eb 100644 --- a/server/handler/transactions.go +++ b/server/handler/transactions.go @@ -35,7 +35,7 @@ func (h *Handler) CreateTransaction(c echo.Context) error { } row := h.DB.QueryRow(`SELECT address,threshold,chain_id,pubkey_type,name,created_by,created_at - FROM multisig_accounts WHERE "address"=$1`, address) + FROM multisig_accounts WHERE "address"=$1`, address) var addr schema.MultisigAccount if err := row.Scan(&addr.Address, &addr.Threshold, &addr.ChainID, &addr.PubkeyType, &addr.Name, &addr.CreatedBy, &addr.CreatedAt); err != nil { @@ -72,9 +72,9 @@ func (h *Handler) CreateTransaction(c echo.Context) error { } var id int - err = h.DB.QueryRow(`INSERT INTO "transactions"("multisig_address","fee","status","last_updated","messages","memo", "title", "created_at") - VALUES - ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING "id"`, + err = h.DB.QueryRow(`INSERT INTO "transactions"("multisig_address","fee","status","last_updated","messages","memo", "title", "created_at", "txn_sequence") + VALUES + ($1,$2,$3,$4,$5,$6,$7,$8, NULL) RETURNING "id"`, address, feebz, model.Pending, time.Now(), msgsbz, req.Memo, req.Title, time.Now(), ).Scan(&id) if err != nil { @@ -102,11 +102,16 @@ func (h *Handler) GetTransactions(c echo.Context) error { }) } - //count of transaction status - + // Count of transaction status var rows1 *sql.Rows - rows1, err = h.DB.Query(`SELECT CASE WHEN t.status = 'FAILED' THEN 'failed' WHEN t.status = 'SUCCESS' THEN 'completed' WHEN jsonb_array_length(t.signatures) >= a.threshold THEN 'to-broadcast' ELSE 'to-sign' END AS computed_status, COUNT(*) AS count FROM transactions t JOIN multisig_accounts a ON t.multisig_address = a.address WHERE t.multisig_address = $1 GROUP BY computed_status`, address) - + rows1, err = h.DB.Query(`SELECT CASE WHEN t.status = 'FAILED' THEN 'failed' + WHEN t.status = 'SUCCESS' THEN 'completed' + WHEN jsonb_array_length(t.signatures) >= a.threshold THEN 'to-broadcast' + ELSE 'to-sign' END AS computed_status, COUNT(*) AS count + FROM transactions t + JOIN multisig_accounts a ON t.multisig_address = a.address + WHERE t.multisig_address = $1 + GROUP BY computed_status`, address) if err != nil { if rows1 != nil && sql.ErrNoRows == rows1.Err() { return c.JSON(http.StatusBadRequest, model.ErrorResponse{ @@ -115,7 +120,6 @@ func (h *Handler) GetTransactions(c echo.Context) error { Log: rows1.Err().Error(), }) } - return c.JSON(http.StatusInternalServerError, model.ErrorResponse{ Status: "error", Message: "failed to query transaction", @@ -140,17 +144,34 @@ func (h *Handler) GetTransactions(c echo.Context) error { txCount = append(txCount, txC) } - //ends here - + // Query for transactions status := utils.GetStatus(c.QueryParam("status")) var rows *sql.Rows if status == model.Pending { - rows, err = h.DB.Query(`SELECT t.id,COALESCE(t.signed_at, '0001-01-01 00:00:00'::timestamp) AS signed_at,t.multisig_address,t.status,t.created_at,t.last_updated,t.memo,t.signatures,t.messages,t.hash,t.err_msg,t.fee, m.threshold, - json_agg(jsonb_build_object('pubkey', p.pubkey, 'address', p.address, 'multisig_address',p.multisig_address)) AS pubkeys FROM transactions t JOIN multisig_accounts m ON t.multisig_address = m.address JOIN pubkeys p ON t.multisig_address = p.multisig_address WHERE t.multisig_address=$1 and t.status='PENDING' GROUP BY t.id, t.multisig_address, m.threshold, t.messages LIMIT $2 OFFSET $3`, + rows, err = h.DB.Query(`SELECT t.id, COALESCE(t.signed_at, '0001-01-01 00:00:00'::timestamp) AS signed_at, + t.multisig_address, t.status, t.created_at, t.last_updated, t.memo, + t.signatures, t.messages, t.hash, t.err_msg, t.fee, t.txn_sequence, + m.threshold, json_agg(jsonb_build_object('pubkey', p.pubkey, + 'address', p.address, 'multisig_address', p.multisig_address)) AS pubkeys + FROM transactions t + JOIN multisig_accounts m ON t.multisig_address = m.address + JOIN pubkeys p ON t.multisig_address = p.multisig_address + WHERE t.multisig_address=$1 AND t.status='PENDING' + GROUP BY t.id, t.multisig_address, m.threshold, t.messages + LIMIT $2 OFFSET $3`, address, limit, (page-1)*limit) } else { - rows, err = h.DB.Query(`SELECT t.id,COALESCE(t.signed_at, '0001-01-01 00:00:00'::timestamp) AS signed_at,t.multisig_address,t.status,t.created_at,t.last_updated,t.memo,t.signatures,t.messages,t.hash,t.err_msg,t.fee, m.threshold, - json_agg(jsonb_build_object('pubkey', p.pubkey, 'address', p.address, 'multisig_address',p.multisig_address)) AS pubkeys FROM transactions t JOIN multisig_accounts m ON t.multisig_address = m.address JOIN pubkeys p ON t.multisig_address = p.multisig_address WHERE t.multisig_address=$1 and t.status <> 'PENDING' GROUP BY t.id, t.multisig_address, m.threshold, t.messages LIMIT $2 OFFSET $3`, + rows, err = h.DB.Query(`SELECT t.id, COALESCE(t.signed_at, '0001-01-01 00:00:00'::timestamp) AS signed_at, + t.multisig_address, t.status, t.created_at, t.last_updated, t.memo, + t.signatures, t.messages, t.hash, t.err_msg, t.fee, t.txn_sequence, + m.threshold, json_agg(jsonb_build_object('pubkey', p.pubkey, + 'address', p.address, 'multisig_address', p.multisig_address)) AS pubkeys + FROM transactions t + JOIN multisig_accounts m ON t.multisig_address = m.address + JOIN pubkeys p ON t.multisig_address = p.multisig_address + WHERE t.multisig_address=$1 AND t.status <> 'PENDING' + GROUP BY t.id, t.multisig_address, m.threshold, t.messages + LIMIT $2 OFFSET $3`, address, limit, (page-1)*limit) } if err != nil { @@ -161,7 +182,6 @@ func (h *Handler) GetTransactions(c echo.Context) error { Log: rows.Err().Error(), }) } - return c.JSON(http.StatusInternalServerError, model.ErrorResponse{ Status: "error", Message: "failed to query transaction", @@ -174,6 +194,7 @@ func (h *Handler) GetTransactions(c echo.Context) error { for rows.Next() { var transaction schema.AllTransactionResult var signedAt time.Time + var txnSequence sql.NullInt32 // Use sql.NullInt32 to handle NULL if err := rows.Scan( &transaction.ID, @@ -188,6 +209,7 @@ func (h *Handler) GetTransactions(c echo.Context) error { &transaction.Hash, &transaction.ErrMsg, &transaction.Fee, + &txnSequence, // Scan into sql.NullInt32 &transaction.Threshold, &transaction.Pubkeys, ); err != nil { @@ -198,6 +220,14 @@ func (h *Handler) GetTransactions(c echo.Context) error { }) } + // Convert sql.NullInt32 to *int + if txnSequence.Valid { + txnSeq := int(txnSequence.Int32) + transaction.TxnSequence = &txnSeq + } else { + transaction.TxnSequence = nil + } + if signedAt.IsZero() { transaction.SignedAt = time.Time{} // Set it to zero time if not set } else { @@ -370,8 +400,9 @@ type Signature struct { } type SignTxReq struct { - Signature string `json:"signature"` - Signer string `json:"signer"` + Signature string `json:"signature"` + Signer string `json:"signer"` + TxnSequence *int `json:"txn_sequence"` } func (h *Handler) SignTransaction(c echo.Context) error { @@ -381,10 +412,11 @@ func (h *Handler) SignTransaction(c echo.Context) error { if err != nil { return c.JSON(http.StatusBadRequest, model.ErrorResponse{ Status: "error", - Message: "invalid transaction id ", + Message: "invalid transaction id", }) } + // Parse request req := &SignTxReq{} if err := c.Bind(req); err != nil { return c.JSON(http.StatusBadRequest, model.ErrorResponse{ @@ -393,18 +425,17 @@ func (h *Handler) SignTransaction(c echo.Context) error { }) } + // Fetch existing transaction signatures row := h.DB.QueryRow(`SELECT signatures FROM transactions WHERE id=$1 AND multisig_address=$2`, txId, address) - var transaction schema.Transaction - if err := row.Scan( - &transaction.Signatures, - ); err != nil { + if err := row.Scan(&transaction.Signatures); err != nil { return c.JSON(http.StatusBadRequest, model.ErrorResponse{ Status: "error", Message: err.Error(), }) } + // Unmarshal existing signatures var signatures []Signature if err := json.Unmarshal(transaction.Signatures, &signatures); err != nil { return c.JSON(http.StatusBadRequest, model.ErrorResponse{ @@ -413,39 +444,32 @@ func (h *Handler) SignTransaction(c echo.Context) error { }) } + // Update or add new signature var result []Signature - if len(signatures) == 0 { - result = append(result, Signature{ - Address: req.Signer, - Signature: req.Signature, - }) - } else { - exists := false - for _, sig := range signatures { - if sig.Address == req.Signer { - exists = true - result = append(result, Signature{ - Address: req.Signer, - Signature: req.Signature, - }) - } else { - - result = append(result, Signature{ - Address: sig.Address, - Signature: sig.Signature, - }) - } - - } - - if !exists { + exists := false + for _, sig := range signatures { + if sig.Address == req.Signer { + exists = true result = append(result, Signature{ Address: req.Signer, Signature: req.Signature, }) + } else { + result = append(result, Signature{ + Address: sig.Address, + Signature: sig.Signature, + }) } } + if !exists { + result = append(result, Signature{ + Address: req.Signer, + Signature: req.Signature, + }) + } + + // Marshal updated signatures bz, err := json.Marshal(result) if err != nil { return c.JSON(http.StatusBadRequest, model.ErrorResponse{ @@ -454,7 +478,13 @@ func (h *Handler) SignTransaction(c echo.Context) error { }) } - _, err = h.DB.Exec("UPDATE transactions SET signatures=$1, signed_at=$2 WHERE id=$3", bz, time.Now().UTC(), id) + // Update transaction in database, including txn_sequence + _, err = h.DB.Exec( + `UPDATE transactions + SET signatures=$1, signed_at=$2, txn_sequence=$3 + WHERE id=$4`, + bz, time.Now().UTC(), req.TxnSequence, txId, + ) if err != nil { return c.JSON(http.StatusBadRequest, model.ErrorResponse{ Status: "error", @@ -525,7 +555,7 @@ func (h *Handler) DeleteTransaction(c echo.Context) error { var signedAt time.Time var status string var transaction schema.Transaction - err = h.DB.QueryRow(`SELECT signed_at,status, signatures FROM transactions WHERE id=$1 AND multisig_address=$2`, txId, address).Scan(&signedAt, &status, &transaction.Signatures) + err = h.DB.QueryRow(`SELECT signed_at, status, signatures, txn_sequence FROM transactions WHERE id=$1 AND multisig_address=$2`, txId, address).Scan(&signedAt, &status, &transaction.Signatures, &transaction.TxnSequence) if err != nil { if err == sql.ErrNoRows { return c.JSON(http.StatusNotFound, model.ErrorResponse{ @@ -545,9 +575,14 @@ func (h *Handler) DeleteTransaction(c echo.Context) error { }) } - // Clear signatures for transactions with signed_at > txSignedAt - if !signedAt.IsZero() && status == "PENDING" && len(transaction.Signatures) > 0 { - _, err = h.DB.Exec(`UPDATE transactions SET signatures='[]'::jsonb, signed_at = '0001-01-01 00:00:00' WHERE multisig_address=$1 AND signed_at > $2 and status='PENDING'`, address, signedAt) + // Clear signatures for transactions with txn_sequence > *transaction.TxnSequence, and set sequence to null + if transaction.TxnSequence != nil && status == "PENDING" && len(transaction.Signatures) > 0 { + _, err = h.DB.Exec( + `UPDATE transactions + SET signatures='[]'::jsonb, signed_at = '0001-01-01 00:00:00', txn_sequence = NULL + WHERE multisig_address=$1 AND txn_sequence > $2 AND status='PENDING'`, + address, *transaction.TxnSequence, + ) if err != nil { return c.JSON(http.StatusInternalServerError, model.ErrorResponse{ Status: "error", @@ -561,3 +596,29 @@ func (h *Handler) DeleteTransaction(c echo.Context) error { Status: "transaction deleted", }) } + +func (h *Handler) ResetPendingTransactions(c echo.Context) error { + address := c.Param("address") + + // Update all PENDING transactions for the given address + _, err := h.DB.Exec( + `UPDATE transactions + SET signatures = '[]'::jsonb, + txn_sequence = NULL, + signed_at = '0001-01-01 00:00:00' + WHERE multisig_address = $1 AND status = 'PENDING'`, + address, + ) + if err != nil { + return c.JSON(http.StatusInternalServerError, model.ErrorResponse{ + Status: "error", + Message: "failed to reset pending transactions", + Log: err.Error(), + }) + } + + return c.JSON(http.StatusOK, model.SuccessResponse{ + Status: "success", + Message: "pending transactions reset successfully", + }) +} diff --git a/server/schema/transactions.go b/server/schema/transactions.go index 98d5e508a..151123d2a 100644 --- a/server/schema/transactions.go +++ b/server/schema/transactions.go @@ -18,6 +18,7 @@ type Transaction struct { LastUpdated time.Time `pg:"last_updated,use_zero" json:"last_updated"` CreatedAt time.Time `pg:"created_at,use_zero" json:"created_at"` SignedAt time.Time `pg:"signed_at,use_zero" sql:"-" json:"signed_at,omitempty"` + TxnSequence *int `pg:"txn_sequence" json:"txn_sequence"` } type TransactionCount struct { @@ -41,4 +42,5 @@ type AllTransactionResult struct { Threshold int `pg:"threshold" json:"threshold"` Pubkeys json.RawMessage `pg:"pubkeys" json:"pubkeys"` SignedAt time.Time `pg:"signed_at,use_zero" sql:"-" json:"signed_at,omitempty"` + TxnSequence *int `pg:"txn_sequence" json:"txn_sequence"` } diff --git a/server/schema/update_schema.sql b/server/schema/update_schema.sql index 46b74c741..f64656bf1 100644 --- a/server/schema/update_schema.sql +++ b/server/schema/update_schema.sql @@ -25,3 +25,16 @@ BEGIN ADD COLUMN signed_at TIMESTAMP DEFAULT NULL; END IF; END $$; + +DO $$ +BEGIN + -- Check and add the txn_sequence column if it doesn't exist + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'transactions' AND column_name = 'txn_sequence' + ) THEN + ALTER TABLE transactions + ADD COLUMN txn_sequence INTEGER DEFAULT NULL; + END IF; +END $$; \ No newline at end of file diff --git a/server/server.go b/server/server.go index ab8e832c4..add9946d1 100644 --- a/server/server.go +++ b/server/server.go @@ -96,6 +96,7 @@ func main() { e.GET("/txns/:chainId/:address", h.GetAllTransactions) e.GET("/txns/:chainId/:address/:txhash", h.GetChainTxHash) e.GET("/search/txns/:txhash", h.GetTxHash) + e.DELETE("/multisig/:address/reset-txns", h.ResetPendingTransactions, m.AuthMiddleware, m.IsMultisigMember) // users e.POST("/users/:address/signature", h.CreateUserSignature)