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}
- submitDeposit()}>
+ submitDeposit()}>
{depositRequest.isSuccess ?
Submitted :
depositRequest.isPending ? (
@@ -79,6 +87,109 @@ const DepositEntry = (props: IDepositEntryProps): React.ReactElement => {
)}
+ {showTxDetails && (
+ setShowTxDetails(false)} size="lg" className="deposit-txs-modal">
+
+ Initiated Deposits
+
+
+ Some deposits have already been submitted for this validator:
+ {props.deposit.depositTxs.map((tx, index) => (
+
+ {index > 0 &&
}
+
+
+ Tx Hash:
+
+
+
+ {props.explorerUrl ?
+
{tx.tx_hash}
+ :
{tx.tx_hash}
+ }
+
+
+
+
+
+
+
+
+
+ Block Time:
+
+
+
+
{(window as any).explorer.renderRecentTime(tx.block_time)}
+
+
+
+
+
+
+
+
+ TX Origin:
+
+
+
+ {props.explorerUrl ?
+
{tx.tx_origin}
+ :
{tx.tx_origin}
+ }
+
+
+
+
+
+
+
+
+ TX Target:
+
+
+
+ {props.explorerUrl ?
+
{tx.tx_target}
+ :
{tx.tx_target}
+ }
+
+
+
+
+
+
+
+
+ Amount:
+
+
+
+ {toReadableAmount(tx.amount, 9, "ETH", 0)}
+
+
+
+
+ ))}
+
+
+ setErrorModal(null)}>Close
+
+
+ )}
+
);
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 ?
-
-
-
- Step 2: Upload deposit data file
-
-
) => {
- 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.
-
+
+
+
+ Step 2: Upload deposit data file
+
+
) => {
+ 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 : "");
+}