diff --git a/backend/src/accounts.ts b/backend/src/accounts.ts index 4d52174cc..678521462 100644 --- a/backend/src/accounts.ts +++ b/backend/src/accounts.ts @@ -3,9 +3,11 @@ import { sha256 } from "js-sha256"; import { AccountActivityAction, AccountActivityCursor, + AccountActivityElement, AccountListInfo, AccountTransactionsCount, Action, + ActivityConnection, Receipt, TransactionBaseInfo, } from "./types"; @@ -203,11 +205,7 @@ export const getIdsFromAccountChanges = ( const getActivityAction = ( actions: Action[], - { - transactionHash, - receiptId, - }: { transactionHash: string; receiptId?: string }, - isRefund: boolean + isRefund?: boolean ): AccountActivityAction => { if (actions.length === 0) { throw new Error("Unexpected zero-length array of actions"); @@ -215,98 +213,128 @@ const getActivityAction = ( if (actions.length !== 1) { return { type: "batch", - transactionHash, - actions: actions.map((action) => - getActivityAction([action], { transactionHash, receiptId }, isRefund) - ), + actions: actions.map((action) => getActivityAction([action], isRefund)), }; } switch (actions[0].kind) { case "AddKey": return { type: "access-key-created", - transactionHash, - receiptId, }; case "CreateAccount": return { type: "account-created", - transactionHash, - receiptId, }; case "DeleteAccount": return { type: "account-removed", - transactionHash, - receiptId, }; case "DeleteKey": return { type: "access-key-removed", - transactionHash, - receiptId, }; case "DeployContract": return { type: "contract-deployed", - transactionHash, - receiptId, }; case "FunctionCall": return { type: "call-method", - transactionHash, - receiptId, methodName: actions[0].args.method_name, }; case "Stake": return { type: "restake", - transactionHash, - receiptId, }; case "Transfer": return { type: isRefund ? "refund" : "transfer", - transactionHash, - receiptId, }; } }; +const withActivityConnection = ( + input: T, + source?: Receipt | TransactionBaseInfo +): T & ActivityConnection => { + if (!source) { + return { + ...input, + transactionHash: "", + }; + } + if ("receiptId" in source) { + return { + ...input, + transactionHash: source.originatedFromTransactionHash, + receiptId: source.receiptId, + }; + } + return { + ...input, + transactionHash: source.hash, + }; +}; + export const getAccountActivityAction = ( change: Awaited>[number], receiptsMapping: Map, transactionsMapping: Map, - blockHeightsMapping: Map -): AccountActivityAction => { + blockHeightsMapping: Map, + receiptRelations: Map< + string, + { parentReceiptId: string | null; childrenReceiptIds: string[] } + > +): AccountActivityElement["action"] => { switch (change.cause) { case "CONTRACT_REWARD": case "RECEIPT": const connectedReceipt = receiptsMapping.get(change.receiptId!)!; - return getActivityAction( - connectedReceipt.actions, + const relation = receiptRelations.get(change.receiptId!)!; + const parentReceipt = relation.parentReceiptId + ? receiptsMapping.get(relation.parentReceiptId)! + : undefined; + const childrenReceipts = relation.childrenReceiptIds.map( + (childrenReceiptId) => receiptsMapping.get(childrenReceiptId)! + ); + return withActivityConnection( { - receiptId: connectedReceipt.receiptId, - transactionHash: connectedReceipt.originatedFromTransactionHash, + ...getActivityAction( + connectedReceipt.actions, + !change.involvedAccountId + ), + parentAction: parentReceipt + ? withActivityConnection( + getActivityAction(parentReceipt.actions), + parentReceipt + ) + : undefined, + childrenActions: childrenReceipts.map((receipt) => + withActivityConnection(getActivityAction(receipt.actions), receipt) + ), }, - !change.involvedAccountId + connectedReceipt ); case "TRANSACTION": { const connectedTransaction = transactionsMapping.get( change.transactionHash! )!; - return getActivityAction( - connectedTransaction.actions, - { transactionHash: connectedTransaction.hash }, - !change.involvedAccountId + return withActivityConnection( + { + ...getActivityAction( + connectedTransaction.actions, + !change.involvedAccountId + ), + childrenActions: [], + }, + connectedTransaction ); } case "VALIDATORS_REWARD": const connectedBlock = blockHeightsMapping.get(change.blockTimestamp!)!; - return { + return withActivityConnection({ type: "validator-reward", blockHash: connectedBlock.hash, - }; + }); } }; diff --git a/backend/src/db-utils.ts b/backend/src/db-utils.ts index 320f25b7f..ba3e0b0b9 100644 --- a/backend/src/db-utils.ts +++ b/backend/src/db-utils.ts @@ -1150,6 +1150,18 @@ export const queryReceiptsByIds = async (ids: string[]) => { .execute(); }; +export const queryRelatedReceiptsIds = async (ids: string[]) => { + return indexerDatabase + .selectFrom("execution_outcome_receipts") + .select([ + "executed_receipt_id as executedReceiptId", + "produced_receipt_id as producedReceiptId", + ]) + .where("executed_receipt_id", "in", ids) + .orWhere("produced_receipt_id", "in", ids) + .execute(); +}; + export const queryContractInfo = async (accountId: string) => { // find the latest update in analytics db const latestUpdateResult = await analyticsDatabase diff --git a/backend/src/procedure-handlers.ts b/backend/src/procedure-handlers.ts index f76ce8d1c..3db660ef5 100644 --- a/backend/src/procedure-handlers.ts +++ b/backend/src/procedure-handlers.ts @@ -291,23 +291,32 @@ export const procedureHandlers: { transactions.getTransactionsByHashes(idsToFetch.transactionHashes), blocks.getBlockHeightsByTimestamps(idsToFetch.blocksTimestamps), ]); - return changes.map((change) => ({ - timestamp: nanosecondsToMilliseconds(BigInt(change.blockTimestamp)), - involvedAccountId: change.involvedAccountId, - direction: change.direction === "INBOUND" ? "inbound" : "outbound", - deltaAmount: change.deltaNonStakedAmount, - action: accounts.getAccountActivityAction( - change, - receiptsMapping, - transactionsMapping, - blocksMapping - ), - cursor: { - blockTimestamp: change.blockTimestamp, - shardId: change.shardId, - indexInChunk: change.indexInChunk, - }, - })); + const { + receiptsMapping: receiptsMappingWithRelated, + relations: receiptRelations, + } = await receipts.getRelatedReceiptsByIds(receiptsMapping); + return changes + .filter( + (change) => change.direction !== "INBOUND" || change.cause !== "RECEIPT" + ) + .map((change) => ({ + timestamp: nanosecondsToMilliseconds(BigInt(change.blockTimestamp)), + involvedAccountId: change.involvedAccountId, + direction: change.direction === "INBOUND" ? "inbound" : "outbound", + deltaAmount: change.deltaNonStakedAmount, + action: accounts.getAccountActivityAction( + change, + receiptsMappingWithRelated, + transactionsMapping, + blocksMapping, + receiptRelations + ), + cursor: { + blockTimestamp: change.blockTimestamp, + shardId: change.shardId, + indexInChunk: change.indexInChunk, + }, + })); }, // blocks diff --git a/backend/src/receipts.ts b/backend/src/receipts.ts index 41fec4820..a14cd73d3 100644 --- a/backend/src/receipts.ts +++ b/backend/src/receipts.ts @@ -4,6 +4,7 @@ import { queryIncludedReceiptsList, queryExecutedReceiptsList, queryReceiptsByIds, + queryRelatedReceiptsIds, } from "./db-utils"; import { @@ -95,14 +96,87 @@ export const getReceiptsByIds = async ( if (ids.length === 0) { return new Map(); } - const receiptActions = await queryReceiptsByIds(ids); - const receipts = groupReceiptActionsIntoReceipts(receiptActions); + const receiptRows = await queryReceiptsByIds(ids); + const receipts = groupReceiptActionsIntoReceipts(receiptRows); return receipts.reduce>((acc, receipt) => { acc.set(receipt.receiptId, receipt); return acc; }, new Map()); }; +export const getRelatedReceiptsByIds = async ( + prevReceiptsMapping: Map +): Promise<{ + receiptsMapping: Map; + relations: Map< + string, + { parentReceiptId: string | null; childrenReceiptIds: string[] } + >; +}> => { + const ids = [...prevReceiptsMapping.keys()]; + if (ids.length === 0) { + return { + receiptsMapping: prevReceiptsMapping, + relations: new Map(), + }; + } + const relatedResult = await queryRelatedReceiptsIds(ids); + const relations = ids.reduce< + Map< + string, + { parentReceiptId: string | null; childrenReceiptIds: string[] } + > + >((acc, id) => { + let relatedIds: { + parentReceiptId: string | null; + childrenReceiptIds: string[]; + } = { + parentReceiptId: null, + childrenReceiptIds: [], + }; + const parentRow = relatedResult.find((row) => row.producedReceiptId === id); + if (parentRow) { + relatedIds.parentReceiptId = parentRow.executedReceiptId; + } + const childrenRows = relatedResult.filter( + (row) => row.executedReceiptId === id + ); + if (childrenRows.length !== 0) { + relatedIds.childrenReceiptIds = childrenRows.map( + (row) => row.producedReceiptId + ); + } + acc.set(id, relatedIds); + return acc; + }, new Map()); + const prevReceiptIds = [...prevReceiptsMapping.keys()]; + const lookupIds = [...relations.values()].reduce>( + (acc, relation) => { + if ( + relation.parentReceiptId && + !prevReceiptIds.includes(relation.parentReceiptId) + ) { + acc.add(relation.parentReceiptId); + } + const filteredChildrenIds = relation.childrenReceiptIds.filter( + (id) => !prevReceiptIds.includes(id) + ); + filteredChildrenIds.forEach((id) => acc.add(id)); + return acc; + }, + new Set() + ); + const receiptRows = await queryReceiptsByIds([...lookupIds]); + const receipts = groupReceiptActionsIntoReceipts(receiptRows); + return { + receiptsMapping: receipts.reduce>((acc, receipt) => { + acc.set(receipt.receiptId, receipt); + return acc; + }, new Map(prevReceiptsMapping)), + relations, + }; +}; + export const getReceiptsCountInBlock = async ( blockHash: string ): Promise => { diff --git a/common/src/types/procedures.ts b/common/src/types/procedures.ts index a25a585b3..6bd2c1900 100644 --- a/common/src/types/procedures.ts +++ b/common/src/types/procedures.ts @@ -34,16 +34,22 @@ export type AccountActivityCursor = { indexInChunk: number; }; -export type AccountTransferAction = { - type: "transfer"; +export type ActivityConnectionActions = { + parentAction?: AccountActivityAction & ActivityConnection; + childrenActions?: (AccountActivityAction & ActivityConnection)[]; +}; + +export type ActivityConnection = { transactionHash: string; receiptId?: string; }; +export type AccountTransferAction = { + type: "transfer"; +}; + export type AccountRefundAction = { type: "refund"; - transactionHash: string; - receiptId?: string; }; export type AccountValidatorRewardAction = { @@ -53,52 +59,36 @@ export type AccountValidatorRewardAction = { export type AccountContractDeployedAction = { type: "contract-deployed"; - transactionHash: string; - receiptId?: string; }; export type AccountAccessKeyCreatedAction = { type: "access-key-created"; - transactionHash: string; - receiptId?: string; }; export type AccountAccessKeyRemovedAction = { type: "access-key-removed"; - transactionHash: string; - receiptId?: string; }; export type AccountCallMethodAction = { type: "call-method"; methodName: string; - transactionHash: string; - receiptId?: string; }; export type AccountRestakeAction = { type: "restake"; - transactionHash: string; - receiptId?: string; }; export type AccountAccountCreatedAction = { type: "account-created"; - transactionHash: string; - receiptId?: string; }; export type AccountAccountRemovedAction = { type: "account-removed"; - transactionHash: string; - receiptId?: string; }; export type AccountBatchAction = { type: "batch"; actions: AccountActivityAction[]; - transactionHash: string; - receiptId?: string; }; export type AccountActivityAction = @@ -124,7 +114,9 @@ export type AccountActivityElement = { timestamp: number; direction: "inbound" | "outbound"; deltaAmount: string; - action: AccountActivityAction; + action: AccountActivityAction & + ActivityConnectionActions & + ActivityConnection; }; export type AccountTransactionsCount = { diff --git a/frontend/src/components/beta/accounts/AccountActivityBadge.tsx b/frontend/src/components/beta/accounts/AccountActivityBadge.tsx index d5d1277b1..bac3ee0ad 100644 --- a/frontend/src/components/beta/accounts/AccountActivityBadge.tsx +++ b/frontend/src/components/beta/accounts/AccountActivityBadge.tsx @@ -5,6 +5,7 @@ import { AccountActivityAction } from "../../../types/common"; type Props = { action: AccountActivityAction; + href?: string; }; const Wrapper = styled("div", { @@ -17,6 +18,12 @@ const Wrapper = styled("div", { fontSize: 12, variants: { + as: { + a: { + cursor: "pointer", + }, + }, + type: { transfer: { backgroundColor: "#F0FFEE", @@ -55,10 +62,10 @@ const Wrapper = styled("div", { }, }); -const AccountActivityBadge: React.FC = React.memo(({ action }) => { +const AccountActivityBadge: React.FC = React.memo(({ action, href }) => { const { t } = useTranslation(); return ( - + {action.type === "call-method" ? action.methodName : t(`pages.account.activity.type.${action.type}`, { diff --git a/frontend/src/components/beta/accounts/AccountActivityView.tsx b/frontend/src/components/beta/accounts/AccountActivityView.tsx index 739a38226..80b223d7a 100644 --- a/frontend/src/components/beta/accounts/AccountActivityView.tsx +++ b/frontend/src/components/beta/accounts/AccountActivityView.tsx @@ -6,8 +6,10 @@ import { styled } from "../../../libraries/styles"; import AccountActivityBadge from "./AccountActivityBadge"; import { shortenString } from "../../../libraries/formatting"; import { + AccountActivityAction, AccountActivityCursor, AccountActivityElement, + ActivityConnection, } from "../../../types/common"; import { NearAmount } from "../../utils/NearAmount"; import ListHandler from "../../utils/ListHandler"; @@ -46,7 +48,10 @@ const TableRow = styled("tr", { height: 50, }); -const TableElement = styled("td"); +const TableElement = styled("td", { + verticalAlign: "top", + padding: 8, +}); const Amount = styled("div", { fontSize: 14, @@ -70,6 +75,15 @@ const DateTableElement = styled(TableElement, { const LinkPrefix = styled("span", { marginRight: 8 }); +const TypeGroup = styled("div", { + display: "flex", + alignItems: "center", + + "& + &": { + marginTop: 12, + }, +}); + const copyToClipboardStyle = { marginLeft: ".3em", fontSize: "1.5em", @@ -79,6 +93,14 @@ type RowProps = { item: AccountActivityElement; }; +const getLink = (action: AccountActivityAction & ActivityConnection) => { + return "blockHash" in action + ? `/blocks/${action.blockHash}` + : `/transactions/${action.transactionHash}${ + action.receiptId ? `#${action.receiptId}` : "" + }`; +}; + const ActivityItemRow: React.FC = ({ item }) => { const { t } = useTranslation(); const deltaAmount = JSBI.BigInt(item.deltaAmount); @@ -98,7 +120,33 @@ const ActivityItemRow: React.FC = ({ item }) => { {subindex === 0 ? name : null} - + {item.action.parentAction ? ( + + Parent: + + + ) : null} + + Self: + + {item.action.childrenActions && + item.action.childrenActions.length !== 0 ? ( + + Children: +
+ {item.action.childrenActions?.map((childAction, index) => ( + + ))} +
+
+ ) : null}
{!isDeltaAmountZero && subindex === 0 ? ( @@ -123,28 +171,18 @@ const ActivityItemRow: React.FC = ({ item }) => { : "TX" : "BL"} - + {shortenString( - "transactionHash" in item.action - ? item.action.transactionHash - : item.action.blockHash + "blockHash" in item.action + ? item.action.blockHash + : item.action.transactionHash )}