diff --git a/cmd/dora-explorer/main.go b/cmd/dora-explorer/main.go index 58f12c5c..9db88d45 100644 --- a/cmd/dora-explorer/main.go +++ b/cmd/dora-explorer/main.go @@ -160,7 +160,7 @@ func startFrontend(webserver *http.Server) { router.HandleFunc("/validators", handlers.Validators).Methods("GET") router.HandleFunc("/validators/activity", handlers.ValidatorsActivity).Methods("GET") router.HandleFunc("/validators/deposits", handlers.Deposits).Methods("GET") - router.HandleFunc("/validators/deposits/submit", handlers.SubmitDeposit).Methods("GET") + router.HandleFunc("/validators/deposits/submit", handlers.SubmitDeposit).Methods("GET", "POST") router.HandleFunc("/validators/initiated_deposits", handlers.InitiatedDeposits).Methods("GET") router.HandleFunc("/validators/included_deposits", handlers.IncludedDeposits).Methods("GET") router.HandleFunc("/validators/voluntary_exits", handlers.VoluntaryExits).Methods("GET") diff --git a/db/deposits.go b/db/deposits.go index dc0debe4..a203f99b 100644 --- a/db/deposits.go +++ b/db/deposits.go @@ -169,6 +169,18 @@ func GetDepositTxsFiltered(offset uint64, limit uint32, finalizedBlock uint64, f fmt.Fprintf(&sql, " %v publickey = $%v", filterOp, len(args)) filterOp = "AND" } + if len(filter.PublicKeys) > 0 { + fmt.Fprintf(&sql, " %v publickey IN (", filterOp) + for i, pubKey := range filter.PublicKeys { + if i > 0 { + fmt.Fprintf(&sql, ", ") + } + args = append(args, pubKey) + fmt.Fprintf(&sql, "$%v", len(args)) + } + fmt.Fprintf(&sql, ")") + filterOp = "AND" + } if filter.MinAmount > 0 { args = append(args, filter.MinAmount*utils.GWEI.Uint64()) fmt.Fprintf(&sql, " %v amount >= $%v", filterOp, len(args)) diff --git a/dbtypes/other.go b/dbtypes/other.go index d8842cdc..391f1ca5 100644 --- a/dbtypes/other.go +++ b/dbtypes/other.go @@ -55,6 +55,7 @@ type DepositTxFilter struct { Address []byte TargetAddress []byte PublicKey []byte + PublicKeys [][]byte ValidatorName string MinAmount uint64 MaxAmount uint64 diff --git a/handlers/submit_deposit.go b/handlers/submit_deposit.go index ef23df16..c00b3adb 100644 --- a/handlers/submit_deposit.go +++ b/handlers/submit_deposit.go @@ -1,12 +1,17 @@ package handlers import ( + "encoding/json" "errors" + "fmt" "net/http" "time" + "github.com/ethereum/go-ethereum/common" "github.com/sirupsen/logrus" + "github.com/ethpandaops/dora/db" + "github.com/ethpandaops/dora/dbtypes" "github.com/ethpandaops/dora/services" "github.com/ethpandaops/dora/templates" "github.com/ethpandaops/dora/types/models" @@ -25,6 +30,20 @@ func SubmitDeposit(w http.ResponseWriter, r *http.Request) { return } + query := r.URL.Query() + if query.Has("ajax") { + err := handleSubmitDepositPageDataAjax(w, r) + if err != nil { + handlePageError(w, r, err) + } + return + } + + if r.Method != http.MethodGet { + handlePageError(w, r, errors.New("invalid method")) + return + } + pageData, pageError := getSubmitDepositPageData() if pageError != nil { handlePageError(w, r, pageError) @@ -82,3 +101,73 @@ func buildSubmitDepositPageData() (*models.SubmitDepositPageData, time.Duration) return pageData, 1 * time.Hour } + +func handleSubmitDepositPageDataAjax(w http.ResponseWriter, r *http.Request) error { + query := r.URL.Query() + var pageData interface{} + + switch query.Get("ajax") { + case "load_deposits": + if r.Method != http.MethodPost { + return fmt.Errorf("invalid method") + } + + var hexPubkeys []string + err := json.NewDecoder(r.Body).Decode(&hexPubkeys) + if err != nil { + return fmt.Errorf("failed to decode request body: %v", err) + } + + pubkeys := make([][]byte, 0, len(hexPubkeys)) + for i, hexPubkey := range hexPubkeys { + pubkey := common.FromHex(hexPubkey) + if len(pubkey) != 48 { + return fmt.Errorf("invalid pubkey length (%d) for pubkey %v", len(pubkey), i) + } + + pubkeys = append(pubkeys, pubkey) + } + + depositSyncState := dbtypes.DepositIndexerState{} + db.GetExplorerState("indexer.depositstate", &depositSyncState) + + deposits, depositCount, err := db.GetDepositTxsFiltered(0, 1000, depositSyncState.FinalBlock, &dbtypes.DepositTxFilter{ + PublicKeys: pubkeys, + WithOrphaned: 0, + }) + if err != nil { + return fmt.Errorf("failed to get deposits: %v", err) + } + + result := models.SubmitDepositPageDataDeposits{ + Deposits: make([]models.SubmitDepositPageDataDeposit, 0, len(deposits)), + Count: depositCount, + HaveMore: depositCount > 1000, + } + + for _, deposit := range deposits { + result.Deposits = append(result.Deposits, models.SubmitDepositPageDataDeposit{ + Pubkey: fmt.Sprintf("0x%x", deposit.PublicKey), + Amount: deposit.Amount, + BlockNumber: deposit.BlockNumber, + BlockHash: fmt.Sprintf("0x%x", deposit.BlockRoot), + BlockTime: deposit.BlockTime, + TxOrigin: common.BytesToAddress(deposit.TxSender).String(), + TxTarget: common.BytesToAddress(deposit.TxTarget).String(), + TxHash: fmt.Sprintf("0x%x", deposit.TxHash), + }) + } + + pageData = result + default: + return errors.New("invalid ajax request") + } + + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(pageData) + if err != nil { + logrus.WithError(err).Error("error encoding index data") + http.Error(w, "Internal server error", http.StatusServiceUnavailable) + } + return nil +} diff --git a/templates/submit_deposit/submit_deposit.html b/templates/submit_deposit/submit_deposit.html index e6eec9b4..8a522638 100644 --- a/templates/submit_deposit/submit_deposit.html +++ b/templates/submit_deposit/submit_deposit.html @@ -51,6 +51,22 @@

submitDepositConfig: { genesisForkVersion: "0x{{ printf "%x" .GenesisForkVersion }}", depositContract: "0x{{ printf "%x" .DepositContract }}", + loadDepositTxs: function(pubkeys) { + return fetch("?ajax=load_deposits", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(pubkeys) + }).then((response) => { + if (!response.ok) { + throw new Error("Failed to load deposits: " + response.statusText); + } + return response; + }).then((response) => { + return response.json(); + }); + } } } ); diff --git a/types/models/submit_deposit.go b/types/models/submit_deposit.go index a9446fd0..0089a8f5 100644 --- a/types/models/submit_deposit.go +++ b/types/models/submit_deposit.go @@ -8,3 +8,20 @@ type SubmitDepositPageData struct { GenesisForkVersion []byte `json:"genesisforkversion"` DepositContract []byte `json:"depositcontract"` } + +type SubmitDepositPageDataDeposits struct { + Deposits []SubmitDepositPageDataDeposit `json:"deposits"` + Count uint64 `json:"count"` + HaveMore bool `json:"havemore"` +} + +type SubmitDepositPageDataDeposit struct { + Pubkey string `json:"pubkey"` + Amount uint64 `json:"amount"` + BlockNumber uint64 `json:"block"` + BlockHash string `json:"block_hash"` + BlockTime uint64 `json:"block_time"` + TxOrigin string `json:"tx_origin"` + TxTarget string `json:"tx_target"` + TxHash string `json:"tx_hash"` +} diff --git a/ui-package/src/components/SubmitDepositsForm/DepositEntry.tsx b/ui-package/src/components/SubmitDepositsForm/DepositEntry.tsx index a9bd9180..d4bacbde 100644 --- a/ui-package/src/components/SubmitDepositsForm/DepositEntry.tsx +++ b/ui-package/src/components/SubmitDepositsForm/DepositEntry.tsx @@ -4,17 +4,20 @@ import { useState } from 'react'; import { Modal } from 'react-bootstrap'; import { IDeposit } from './DepositsTable'; +import { toReadableAmount } from '../../utils/ReadableAmount'; interface IDepositEntryProps { deposit: IDeposit; depositContract: string; + explorerUrl?: string; } const DepositContractAbi = [{"inputs":[],"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"bytes","name":"pubkey","type":"bytes"},{"indexed":false,"internalType":"bytes","name":"withdrawal_credentials","type":"bytes"},{"indexed":false,"internalType":"bytes","name":"amount","type":"bytes"},{"indexed":false,"internalType":"bytes","name":"signature","type":"bytes"},{"indexed":false,"internalType":"bytes","name":"index","type":"bytes"}],"name":"DepositEvent","type":"event"},{"inputs":[{"internalType":"bytes","name":"pubkey","type":"bytes"},{"internalType":"bytes","name":"withdrawal_credentials","type":"bytes"},{"internalType":"bytes","name":"signature","type":"bytes"},{"internalType":"bytes32","name":"deposit_data_root","type":"bytes32"}],"name":"deposit","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[],"name":"get_deposit_count","outputs":[{"internalType":"bytes","name":"","type":"bytes"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"get_deposit_root","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes4","name":"interfaceId","type":"bytes4"}],"name":"supportsInterface","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"pure","type":"function"}]; const DepositEntry = (props: IDepositEntryProps): React.ReactElement => { - const { address: walletAddress, chain } = useAccount(); + const { address: walletAddress, chain, isConnected } = useAccount(); const [errorModal, setErrorModal] = useState(null); + const [showTxDetails, setShowTxDetails] = useState(false); const depositRequest = useWriteContract(); window.setTimeout(() => { @@ -50,9 +53,14 @@ const DepositEntry = (props: IDepositEntryProps): React.ReactElement => { } + {props.deposit.depositTxs.length > 0 ? + setShowTxDetails(true)}> + + + : null} - + + + )} + ); diff --git a/ui-package/src/components/SubmitDepositsForm/DepositsTable.tsx b/ui-package/src/components/SubmitDepositsForm/DepositsTable.tsx index 4a058073..26601b89 100644 --- a/ui-package/src/components/SubmitDepositsForm/DepositsTable.tsx +++ b/ui-package/src/components/SubmitDepositsForm/DepositsTable.tsx @@ -4,11 +4,13 @@ import {ContainerType, ByteVectorType, UintNumberType, ValueOf} from "@chainsafe import bls from "@chainsafe/bls/herumi"; import DepositEntry from './DepositEntry'; +import { IDepositTx } from './SubmitDepositsFormProps'; interface IDepositsTableProps { file: File; genesisForkVersion: string; depositContract: string; + loadDepositTxs(pubkeys: string[]): Promise<{deposits: IDepositTx[], count: number, havemore: boolean}>; } export interface IDeposit { @@ -23,6 +25,12 @@ export interface IDeposit { deposit_cli_version: string; validity: boolean; + depositTxs?: IDepositTx[]; +} + +export interface IDepositTxStats { + count: number; + havemore: boolean; } const DepositMessage = new ContainerType({ @@ -49,14 +57,20 @@ type SigningData = ValueOf; const DepositsTable = (props: IDepositsTableProps): React.ReactElement => { const [deposits, setDeposits] = useState(null); const [parseError, setParseError] = useState(null); + const [loadDepositsError, setLoadDepositsError] = useState(null); + const [depositTxStats, setDepositTxStats] = useState(null); useEffect(() => { - parseDeposits().then((deposits) => { - setDeposits(deposits); + parseDeposits().then((res) => { + setDeposits(res.deposits); setParseError(null); + setLoadDepositsError(res.loadDepositsErr); + setDepositTxStats(res.depositStats); }).catch((error) => { setParseError(error.message); setDeposits(null); + setLoadDepositsError(null); + setDepositTxStats(null); }); }, [props.file]); @@ -86,6 +100,20 @@ const DepositsTable = (props: IDepositsTableProps): React.ReactElement => { + {loadDepositsError ? +
+ Failed to load deposit transactions:
+ {loadDepositsError}
+ Duplicate deposits may not be displayed correctly! +
+ : null} + + {depositTxStats && depositTxStats.count > 0 ? +
+ We've found {depositTxStats.havemore ? "more than " : ""}{depositTxStats.count} deposit transactions matching your validator pubkeys. Double check each deposit to avoid double deposits. +
+ : null} + {!deposits ?

Loading...

: deposits.length === 0 ?

No deposits found

: (
@@ -111,10 +139,25 @@ const DepositsTable = (props: IDepositsTableProps): React.ReactElement => { ); - async function parseDeposits(): Promise { + async function parseDeposits(): Promise<{deposits: IDeposit[], loadDepositsErr: string, depositStats: IDepositTxStats}> { try { const text = await props.file.text(); - const json = JSON.parse(text); + const json: IDeposit[] = JSON.parse(text); + + let pubkeys = json.map((deposit: IDeposit) => deposit.pubkey); + let depositTxs = []; + let loadDepositsErr = null; + let depositStats: IDepositTxStats = null; + try { + let depositsRes = await props.loadDepositTxs(pubkeys); + depositTxs = depositsRes.deposits; + depositStats = { + count: depositsRes.count, + havemore: depositsRes.havemore + }; + } catch (error) { + loadDepositsErr = error.toString(); + } // compute signing domain const forkData: ForkData = ForkData.fromJson({ @@ -126,10 +169,15 @@ const DepositsTable = (props: IDepositsTableProps): React.ReactElement => { signingDomain.set([0x03, 0x00, 0x00, 0x00]); signingDomain.set(forkDataRoot.slice(0, 28), 4); - return json.map((deposit: IDeposit) => { - deposit.validity = verifyDeposit(deposit, signingDomain); - return deposit; - }); + return { + deposits: json.map((deposit: IDeposit) => { + deposit.validity = verifyDeposit(deposit, signingDomain); + deposit.depositTxs = depositTxs.filter((tx: IDepositTx) => tx.pubkey === deposit.pubkey); + return deposit; + }), + loadDepositsErr: loadDepositsErr, + depositStats: depositStats + }; } catch (error) { console.error(error); throw error; @@ -155,6 +203,7 @@ const DepositsTable = (props: IDepositsTableProps): React.ReactElement => { return signature.verify(pubkey, signingDataRoot); } + } export default DepositsTable; diff --git a/ui-package/src/components/SubmitDepositsForm/SubmitDepositsForm.scss b/ui-package/src/components/SubmitDepositsForm/SubmitDepositsForm.scss index dcdfd306..1753a14c 100644 --- a/ui-package/src/components/SubmitDepositsForm/SubmitDepositsForm.scss +++ b/ui-package/src/components/SubmitDepositsForm/SubmitDepositsForm.scss @@ -5,3 +5,10 @@ word-break: break-word; } } + +.deposit-txs-modal { + .tx-details-label { + width: 150px; + font-weight: bold; + } +} diff --git a/ui-package/src/components/SubmitDepositsForm/SubmitDepositsForm.tsx b/ui-package/src/components/SubmitDepositsForm/SubmitDepositsForm.tsx index 66b6cd46..6395a65f 100644 --- a/ui-package/src/components/SubmitDepositsForm/SubmitDepositsForm.tsx +++ b/ui-package/src/components/SubmitDepositsForm/SubmitDepositsForm.tsx @@ -11,6 +11,8 @@ const SubmitDepositsForm = (props: ISubmitDepositsFormProps): React.ReactElement const { address: walletAddress, isConnected, chain } = useAccount(); const [file, setFile] = useState(null); + const [refreshIdx, setRefreshIdx] = useState(0); + return (
@@ -36,28 +38,33 @@ const SubmitDepositsForm = (props: ISubmitDepositsFormProps): React.ReactElement
- {isConnected && chain ? -
-
- - ) => { - if (e.target.files) { - setFile(e.target.files[0]); - } - }} - /> -

The deposit data file is usually called deposit_data-[timestamp].json and is located in your /staking-deposit-cli/validator_keys directory.

-
+
+
+ + ) => { + if (e.target.files) { + setFile(e.target.files[0]); + setRefreshIdx(refreshIdx + 1); + } + }} + /> +

The deposit data file is usually called deposit_data-[timestamp].json and is located in your /staking-deposit-cli/validator_keys directory.

- : null} +
{file ? - + : null}
diff --git a/ui-package/src/components/SubmitDepositsForm/SubmitDepositsFormProps.ts b/ui-package/src/components/SubmitDepositsForm/SubmitDepositsFormProps.ts index 1a9900e6..12e0c475 100644 --- a/ui-package/src/components/SubmitDepositsForm/SubmitDepositsFormProps.ts +++ b/ui-package/src/components/SubmitDepositsForm/SubmitDepositsFormProps.ts @@ -8,5 +8,16 @@ export interface ISubmitDepositsFormProps { explorerLink?: string; genesisForkVersion: string; depositContract: string; + loadDepositTxs(pubkeys: string[]): Promise<{deposits: IDepositTx[], count: number, havemore: boolean}>; +} + +export interface IDepositTx { + pubkey: string; + amount: number; + block: number; + block_hash: string; + block_time: number; + tx_origin: string; + tx_target: string; + tx_hash: string; } - \ No newline at end of file diff --git a/ui-package/src/utils/ReadableAmount.ts b/ui-package/src/utils/ReadableAmount.ts new file mode 100644 index 00000000..22734798 --- /dev/null +++ b/ui-package/src/utils/ReadableAmount.ts @@ -0,0 +1,28 @@ + + +export function toDecimalUnit(amount: number, decimals?: number): number { + let factor = Math.pow(10, typeof decimals === "number" ? decimals : 18); + return amount / factor; +} + +export function toReadableAmount(amount: number | bigint, decimals?: number, unit?: string, precision?: number): string { + if(typeof decimals !== "number") + decimals = 18; + if(typeof precision !== "number") + precision = 3; + if(!amount) + return "0"+ (unit ? " " + unit : ""); + if(typeof amount === "bigint") + amount = Number(amount); + + let decimalAmount = toDecimalUnit(amount, decimals); + let precisionFactor = Math.pow(10, precision); + let amountStr = (Math.round(decimalAmount * precisionFactor) / precisionFactor).toFixed(precision); + while (amountStr.endsWith("0")) { + amountStr = amountStr.slice(0, -1); + } + if(amountStr.endsWith(".")) + amountStr = amountStr.slice(0, -1); + + return amountStr + (unit ? " " + unit : ""); +}