diff --git a/frontend/src/app/(routes)/multisig/components/common/BroadCastTxn.tsx b/frontend/src/app/(routes)/multisig/components/common/BroadCastTxn.tsx index 82beaae44..77d573500 100644 --- a/frontend/src/app/(routes)/multisig/components/common/BroadCastTxn.tsx +++ b/frontend/src/app/(routes)/multisig/components/common/BroadCastTxn.tsx @@ -8,11 +8,15 @@ import { } from '@/store/features/multisig/multisigSlice'; import { RootState } from '@/store/store'; import { MultisigAddressPubkey, Txn } from '@/types/multisig'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { FAILED_TO_BROADCAST_ERROR } from '@/utils/errors'; import useVerifyAccount from '@/custom-hooks/useVerifyAccount'; import CustomButton from '@/components/common/CustomButton'; import { useRouter } from 'next/navigation'; +import CustomDialog from '@/components/common/CustomDialog'; +import { Dialog, DialogContent } from '@mui/material'; +import Image from 'next/image'; +import { DELETE_ILLUSTRATION } from '@/constants/image-names'; interface BroadCastTxnProps { txn: Txn; @@ -23,6 +27,11 @@ interface BroadCastTxnProps { isMember: boolean; disableBroadcast?: boolean; isOverview?: boolean; + broadcastInfo?: { + disable: boolean; + isSequenceLess: boolean; + isSequenceGreater: boolean; + }; } const BroadCastTxn: React.FC = (props) => { @@ -35,6 +44,7 @@ const BroadCastTxn: React.FC = (props) => { isMember, disableBroadcast, isOverview, + broadcastInfo, } = props; const dispatch = useAppDispatch(); const { getChainInfo } = useGetChainInfo(); @@ -88,20 +98,110 @@ const BroadCastTxn: React.FC = (props) => { }) ); }; + const [pendingSeqOpen, setPendingSeqOpen] = useState(false); + const [seqNotSyncOpen, setSeqNotSyncOpen] = useState(false); + const handleBroadcast = () => { + if (isOverview) { + router.push(`/multisig/${chainName}/${multisigAddress}`); + } else if (broadcastInfo) { + if (broadcastInfo.isSequenceLess) { + // alert('Sequence is not in sync'); + setSeqNotSyncOpen(true); + } else if (broadcastInfo.isSequenceGreater) { + setPendingSeqOpen(true); + setSeqNotSyncOpen(true); + // alert( + // 'There is a transaction that needs to be broadcasted before this' + // ); + } else if (!broadcastInfo.disable) { + broadcastTxn(); + } + } + }; return ( - { - if (isOverview) { - router.push(`/multisig/${chainName}/${multisigAddress}`); - } else { - broadcastTxn(); - } - }} - btnDisabled={!isMember || disableBroadcast} - btnStyles="w-[115px]" - /> + <> + + setSeqNotSyncOpen(false)} + onUpdateSequence={() => console.log('update')} + /> + ); }; export default BroadCastTxn; + +const DialogSequenceMissMatch = ({ + open, + onClose, + onUpdateSequence, +}: { + open: boolean; + onClose: () => void; + onUpdateSequence: () => void; +}) => { + return ( + + +
+ +
+
+
+
+ ! +
+
+
+
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. +
+ +
+
+
+
+
+ ); +}; diff --git a/frontend/src/app/(routes)/multisig/components/common/SignTxn.tsx b/frontend/src/app/(routes)/multisig/components/common/SignTxn.tsx index bf2ee18a5..c17fb9959 100644 --- a/frontend/src/app/(routes)/multisig/components/common/SignTxn.tsx +++ b/frontend/src/app/(routes)/multisig/components/common/SignTxn.tsx @@ -30,6 +30,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,7 +67,9 @@ const SignTxn: React.FC = (props) => { unSignedTxn, walletAddress, rpcURLs, + txnSequence: unSignedTxn.txn_sequence, toBeBroadcastedCount, + partiallySignedCount: partiallySigned?.length }) ); }; diff --git a/frontend/src/app/(routes)/multisig/components/common/TxnsCard.tsx b/frontend/src/app/(routes)/multisig/components/common/TxnsCard.tsx index 4319fcdb9..162e61e99 100644 --- a/frontend/src/app/(routes)/multisig/components/common/TxnsCard.tsx +++ b/frontend/src/app/(routes)/multisig/components/common/TxnsCard.tsx @@ -42,6 +42,7 @@ export const TxnsCard = ({ allowRepeat, disableBroadcast, isOverview, + broadcastInfo, }: { txn: Txn; currency: Currency; @@ -53,6 +54,11 @@ export const TxnsCard = ({ allowRepeat?: boolean; disableBroadcast?: boolean; isOverview?: boolean; + broadcastInfo?: { + disable: boolean; + isSequenceLess: boolean; + isSequenceGreater: boolean; + }; }) => { const dispatch = useAppDispatch(); const { getChainInfo } = useGetChainInfo(); @@ -274,6 +280,7 @@ export const TxnsCard = ({ isMember={isMember} disableBroadcast={disableBroadcast} isOverview={isOverview} + broadcastInfo={broadcastInfo} /> ) : ( pubkey.address); + const broadcastTxnStatus = useAppSelector( + (state) => state.multisig.broadcastTxnRes.status + ); + const { name: multisigName, created_at: createdTime } = multisigAccount.account; @@ -110,6 +116,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)); 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..decfa5cd5 100644 --- a/frontend/src/app/(routes)/multisig/components/multisig-account/Transactions.tsx +++ b/frontend/src/app/(routes)/multisig/components/multisig-account/Transactions.tsx @@ -1,5 +1,8 @@ import { useAppDispatch, useAppSelector } from '@/custom-hooks/StateHooks'; -import { getTxns } from '@/store/features/multisig/multisigSlice'; +import { + getSequenceNumber, + getTxns, +} from '@/store/features/multisig/multisigSlice'; import { Txn } from '@/types/multisig'; import React, { useEffect, useState } from 'react'; import { TxStatus } from '@/types/enums'; @@ -8,6 +11,7 @@ import useFetchTxns from '@/custom-hooks/multisig/useFetchTxns'; import NoData from '@/components/common/NoData'; import TransactionsLoading from '../loaders/TransactionsLoading'; import DialogTxnFailed from './DialogTxnFailed'; +import useGetChainInfo from '@/custom-hooks/useGetChainInfo'; const TXNS_TYPES = [ { option: 'to-sign', value: 'To be Signed' }, @@ -28,6 +32,8 @@ const Transactions = ({ threshold: number; }) => { const dispatch = useAppDispatch(); + const { getChainInfo } = useGetChainInfo(); + const { restURLs } = getChainInfo(chainID); const txnsState = useAppSelector((state) => state.multisig.txns.list); const [txnsList, setTxnsList] = useState([]); @@ -244,12 +250,15 @@ 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 + txnsType === 'to-broadcast' ? a.txn_sequence ?? a.signed_at : a.created_at ).getTime(); const dateB = new Date( - txnsType === 'to-broadcast' ? b.signed_at : b.created_at + txnsType === 'to-broadcast' ? b.txn_sequence ?? b.signed_at : b.created_at ).getTime(); return txnsType === 'to-broadcast' ? dateA - dateB : dateB - dateA; }); @@ -267,7 +276,25 @@ const TransactionsList = ({ isHistory={isHistory} onViewError={onViewError} allowRepeat={txnsType === 'completed'} - disableBroadcast={txnsType === 'to-broadcast' && index > 0} + disableBroadcast={ + txnsType === 'to-broadcast' && + currentSequenceNumber !== txn.txn_sequence + } + 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, + }} /> ))} { 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/custom-hooks/common/useInitApp.ts b/frontend/src/custom-hooks/common/useInitApp.ts index 15195fdcd..b379995d3 100644 --- a/frontend/src/custom-hooks/common/useInitApp.ts +++ b/frontend/src/custom-hooks/common/useInitApp.ts @@ -46,7 +46,7 @@ const useInitApp = () => { const nameToChainIDs = useAppSelector( (state: RootState) => state.wallet.nameToChainIDs ); - const chainIDs = Object.values(nameToChainIDs); + const chainIDs = ["mantra-dukong-1"] // Object.values(nameToChainIDs); const walletState = useAppSelector((state) => state.wallet); const isWalletConnected = useAppSelector( @@ -57,74 +57,74 @@ const useInitApp = () => { const fetchedChains = useRef<{ [key: string]: boolean }>({}); const validatorsFetchedChains = useRef<{ [key: string]: boolean }>({}); - useEffect(() => { - if (chainIDs.length > 0 && isWalletConnected) { - chainIDs.forEach((chainID) => { - if (!fetchedChains.current[chainID]) { - const { address, baseURL, restURLs } = getChainInfo(chainID); - - if (isWalletConnected && address.length) { - const authzGranterAddress = convertAddress(chainID, authzAddress); - const { minimalDenom } = getDenomInfo(chainID); - const chainRequestData = { - baseURLs: restURLs, - address: isAuthzMode ? authzGranterAddress : address, - chainID, - }; - - // Fetch delegations - dispatch( - isAuthzMode - ? getAuthzDelegations(chainRequestData) - : getDelegations(chainRequestData) - ); - - // Fetch available balances - dispatch( - isAuthzMode - ? getAuthzBalances({ ...chainRequestData, baseURL }) - : getBalances({ ...chainRequestData, baseURL }) - ); - - // Fetch rewards - dispatch( - isAuthzMode - ? getAuthzDelegatorTotalRewards({ - ...chainRequestData, - baseURL, - denom: minimalDenom, - }) - : getDelegatorTotalRewards({ - ...chainRequestData, - baseURL, - denom: minimalDenom, - }) - ); - - // Fetch unbonding delegations - dispatch( - isAuthzMode - ? getAuthzUnbonding(chainRequestData) - : getUnbonding(chainRequestData) - ); - - // Mark chain as fetched - fetchedChains.current[chainID] = true; - } - } - }); - } - }, [ - isWalletConnected, - isAuthzMode, - chainIDs, - getChainInfo, - convertAddress, - getDenomInfo, - authzAddress, - dispatch, - walletState, - ]); + // useEffect(() => { + // if (chainIDs.length > 0 && isWalletConnected) { + // chainIDs.forEach((chainID) => { + // if (!fetchedChains.current[chainID]) { + // const { address, baseURL, restURLs } = getChainInfo(chainID); + + // if (isWalletConnected && address.length) { + // const authzGranterAddress = convertAddress(chainID, authzAddress); + // const { minimalDenom } = getDenomInfo(chainID); + // const chainRequestData = { + // baseURLs: restURLs, + // address: isAuthzMode ? authzGranterAddress : address, + // chainID, + // }; + + // // Fetch delegations + // dispatch( + // isAuthzMode + // ? getAuthzDelegations(chainRequestData) + // : getDelegations(chainRequestData) + // ); + + // // Fetch available balances + // dispatch( + // isAuthzMode + // ? getAuthzBalances({ ...chainRequestData, baseURL }) + // : getBalances({ ...chainRequestData, baseURL }) + // ); + + // // Fetch rewards + // dispatch( + // isAuthzMode + // ? getAuthzDelegatorTotalRewards({ + // ...chainRequestData, + // baseURL, + // denom: minimalDenom, + // }) + // : getDelegatorTotalRewards({ + // ...chainRequestData, + // baseURL, + // denom: minimalDenom, + // }) + // ); + + // // Fetch unbonding delegations + // dispatch( + // isAuthzMode + // ? getAuthzUnbonding(chainRequestData) + // : getUnbonding(chainRequestData) + // ); + + // // Mark chain as fetched + // fetchedChains.current[chainID] = true; + // } + // } + // }); + // } + // }, [ + // isWalletConnected, + // isAuthzMode, + // chainIDs, + // getChainInfo, + // convertAddress, + // getDenomInfo, + // authzAddress, + // dispatch, + // walletState, + // ]); useEffect(() => { if (chainIDs.length > 0) { @@ -141,9 +141,9 @@ const useInitApp = () => { } }, [chainIDs, walletState]); - useFetchPriceInfo(); - useInitFeegrant({ chainIDs, shouldFetch: isFeegrantModeEnabled }); - useInitAuthz({ chainIDs, shouldFetch: fetchAuthz(isAuthzMode) }); + // useFetchPriceInfo(); + // useInitFeegrant({ chainIDs, shouldFetch: isFeegrantModeEnabled }); + // useInitAuthz({ chainIDs, shouldFetch: fetchAuthz(isAuthzMode) }); }; export default useInitApp; diff --git a/frontend/src/store/features/multisig/multisigSlice.ts b/frontend/src/store/features/multisig/multisigSlice.ts index 59fa950c0..85ac1a200 100644 --- a/frontend/src/store/features/multisig/multisigSlice.ts +++ b/frontend/src/store/features/multisig/multisigSlice.ts @@ -158,6 +158,11 @@ const initialState: MultisigState = { error: '', }, verifyDialogOpen: false, + multisigAccountSequenceNumber: { + value: null, + status: TxStatus.INIT, + error: '', + }, }; declare let window: WalletWindow; @@ -285,6 +290,36 @@ 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 getMultisigBalances = createAsyncThunk( 'multisig/multisigBalance', async (data: GetMultisigBalancesInputs, { rejectWithValue }) => { @@ -524,6 +559,8 @@ export const signTransaction = createAsyncThunk( walletAddress: string; rpcURLs: string[]; toBeBroadcastedCount: number; + partiallySignedCount: number; + txnSequence: number | null; }, { rejectWithValue, dispatch } ) => { @@ -534,7 +571,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 +587,7 @@ export const signTransaction = createAsyncThunk( { signer: payload.signer, signature: payload.signature, + txn_sequence: payload.txnSequence, } ); trackEvent('MULTISIG', 'SIGN_TXN', SUCCESS); @@ -571,27 +611,27 @@ 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 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', @@ -673,6 +713,10 @@ export const multisigSlice = createSlice({ resetMultisigAccountData: (state) => { state.multisigAccountData = initialState.multisigAccountData; }, + resetSequenceNumber: (state) => { + state.multisigAccountSequenceNumber = + initialState.multisigAccountSequenceNumber; + }, setVerifyDialogOpen: (state, action: PayloadAction) => { state.verifyDialogOpen = action.payload; }, @@ -775,6 +819,24 @@ 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(getMultisigBalances.pending, (state) => { state.balance.status = TxStatus.PENDING; @@ -878,18 +940,18 @@ 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(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 +1010,7 @@ export const { resetBroadcastTxnRes, resetsignTransactionRes, setVerifyDialogOpen, + resetSequenceNumber, } = multisigSlice.actions; export default multisigSlice.reducer; diff --git a/frontend/src/types/multisig.d.ts b/frontend/src/types/multisig.d.ts index 426cef9bc..cc70c8c07 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 { @@ -149,6 +150,11 @@ interface MultisigState { memo: string; }; }; + multisigAccountSequenceNumber: { + value: number | null; + status: TxStatus; + error: string; + }; } interface VerifyAccountRes { @@ -206,6 +212,7 @@ interface Txn { signed_at: string; pubkeys?: MultisigAddressPubkey[]; threshold?: number; + txn_sequence: number | null; } interface TxnCount { @@ -217,7 +224,7 @@ interface Txns { list: Txn[]; status: TxStatus; error: string; - Count: TxnCount[] + Count: TxnCount[]; } interface SignTxInputs { 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/server.go b/server/server.go index ab8e832c4..f65c3a289 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.POST("/multisig/:address/reset-txns", h.ResetPendingTransactions, m.AuthMiddleware) // users e.POST("/users/:address/signature", h.CreateUserSignature)