From 1e1a32dbb9e5a77b553bf818b8243eb5af6e3bd3 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Tue, 7 Apr 2026 12:07:47 -0500 Subject: [PATCH] show mempool records as pending transactions --- .../src/records/pending_transaction.rs | 4 +- .../sage-database/src/tables/mempool_items.rs | 110 +++++++++++++++++- .../sage-database/src/tables/transactions.rs | 6 +- crates/sage/src/endpoints/data.rs | 22 +++- src/bindings.ts | 2 +- src/components/TransactionColumns.tsx | 31 +++-- src/components/TransactionListView.tsx | 90 ++++++++++++-- src/pages/Transactions.tsx | 53 ++++++--- 8 files changed, 270 insertions(+), 48 deletions(-) diff --git a/crates/sage-api/src/records/pending_transaction.rs b/crates/sage-api/src/records/pending_transaction.rs index b286fc982..67ff7f51a 100644 --- a/crates/sage-api/src/records/pending_transaction.rs +++ b/crates/sage-api/src/records/pending_transaction.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::Amount; +use crate::{Amount, TransactionCoinRecord}; #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] @@ -9,4 +9,6 @@ pub struct PendingTransactionRecord { pub transaction_id: String, pub fee: Amount, pub submitted_at: Option, + pub spent: Vec, + pub created: Vec, } diff --git a/crates/sage-database/src/tables/mempool_items.rs b/crates/sage-database/src/tables/mempool_items.rs index 6c5541990..883066553 100644 --- a/crates/sage-database/src/tables/mempool_items.rs +++ b/crates/sage-database/src/tables/mempool_items.rs @@ -1,7 +1,8 @@ use chia_wallet_sdk::prelude::*; -use sqlx::{SqliteConnection, SqliteExecutor, query}; +use sqlx::{Row, SqliteConnection, SqliteExecutor, query}; -use crate::{Convert, Database, DatabaseTx, Result}; +use crate::tables::transactions::create_transaction_coin; +use crate::{Convert, Database, DatabaseTx, Result, TransactionCoin}; #[derive(Debug, Clone)] pub struct MempoolItem { @@ -11,6 +12,15 @@ pub struct MempoolItem { pub submitted_timestamp: Option, } +#[derive(Debug, Clone)] +pub struct MempoolItemWithCoins { + pub hash: Bytes32, + pub fee: u64, + pub submitted_timestamp: Option, + pub spent: Vec, + pub created: Vec, +} + impl Database { pub async fn mempool_items_to_submit( &self, @@ -31,6 +41,10 @@ impl Database { pub async fn mempool_items(&self) -> Result> { mempool_items(&self.pool).await } + + pub async fn mempool_items_with_coins(&self) -> Result> { + mempool_items_with_coins(&self.pool).await + } } impl DatabaseTx<'_> { @@ -330,3 +344,95 @@ async fn mempool_items(conn: impl SqliteExecutor<'_>) -> Result }) .collect() } + +async fn mempool_items_with_coins( + conn: impl SqliteExecutor<'_>, +) -> Result> { + use std::collections::HashMap; + + let rows = sqlx::query( + " + SELECT + mempool_items.hash AS item_hash, + mempool_items.fee AS item_fee, + mempool_items.submitted_timestamp, + coins.puzzle_hash, + coins.parent_coin_hash, + coins.amount, + mempool_coins.is_input, + mempool_coins.is_output, + p2_puzzles.hash AS p2_puzzle_hash, + assets.hash AS asset_hash, + assets.name AS asset_name, + assets.ticker AS asset_ticker, + assets.precision AS asset_precision, + assets.icon_url AS asset_icon_url, + assets.kind AS asset_kind, + assets.description AS asset_description, + assets.is_visible AS asset_is_visible, + assets.is_sensitive_content AS asset_is_sensitive_content, + assets.hidden_puzzle_hash AS asset_hidden_puzzle_hash + FROM mempool_items + INNER JOIN mempool_coins ON mempool_coins.mempool_item_id = mempool_items.id + INNER JOIN coins ON coins.id = mempool_coins.coin_id + INNER JOIN assets ON assets.id = coins.asset_id + LEFT JOIN p2_puzzles ON p2_puzzles.id = coins.p2_puzzle_id + ORDER BY mempool_items.submitted_timestamp DESC, mempool_items.hash ASC + ", + ) + .fetch_all(conn) + .await?; + + // Group rows by mempool item hash, preserving order + let mut order: Vec = Vec::new(); + #[allow(clippy::type_complexity)] + let mut items: HashMap< + Bytes32, + (u64, Option, Vec, Vec), + > = HashMap::new(); + + for row in &rows { + let item_hash: Bytes32 = row.get::, _>("item_hash").convert()?; + let is_input: bool = row.get("is_input"); + let is_output: bool = row.get("is_output"); + + let transaction_coin = create_transaction_coin(row)?; + + if !items.contains_key(&item_hash) { + let fee: u64 = row.get::, _>("item_fee").convert()?; + let submitted_timestamp: Option = row.get("submitted_timestamp"); + order.push(item_hash); + items.insert( + item_hash, + ( + fee, + submitted_timestamp.map(|ts| ts as u64), + Vec::new(), + Vec::new(), + ), + ); + } + + let entry = items.get_mut(&item_hash).unwrap(); + if is_input { + entry.2.push(transaction_coin.clone()); + } + if is_output { + entry.3.push(transaction_coin); + } + } + + Ok(order + .into_iter() + .map(|hash| { + let (fee, submitted_timestamp, spent, created) = items.remove(&hash).unwrap(); + MempoolItemWithCoins { + hash, + fee, + submitted_timestamp, + spent, + created, + } + }) + .collect()) +} diff --git a/crates/sage-database/src/tables/transactions.rs b/crates/sage-database/src/tables/transactions.rs index 7eed8ae07..bc3b4cc9a 100644 --- a/crates/sage-database/src/tables/transactions.rs +++ b/crates/sage-database/src/tables/transactions.rs @@ -1,7 +1,7 @@ use chia_wallet_sdk::prelude::*; use sqlx::{Row, SqliteExecutor}; -use crate::{Asset, Convert, Database, Result}; +use crate::{Asset, AssetKind, Convert, Database, Result}; #[derive(Debug, Clone)] pub struct Transaction { @@ -35,7 +35,7 @@ impl Database { } // Helper function to create a TransactionCoin from a database row -fn create_transaction_coin(row: &sqlx::sqlite::SqliteRow) -> Result { +pub(crate) fn create_transaction_coin(row: &sqlx::sqlite::SqliteRow) -> Result { let coin = Coin::new( row.get::, _>("parent_coin_hash").convert()?, row.get::, _>("puzzle_hash").convert()?, @@ -55,7 +55,7 @@ fn create_transaction_coin(row: &sqlx::sqlite::SqliteRow) -> Result, _>("asset_kind") .map(Convert::convert) .transpose()? - .unwrap_or(crate::AssetKind::Token), + .unwrap_or(AssetKind::Token), hidden_puzzle_hash: row .get::>, _>("asset_hidden_puzzle_hash") .convert()?, diff --git a/crates/sage/src/endpoints/data.rs b/crates/sage/src/endpoints/data.rs index 26631044c..3735380e4 100644 --- a/crates/sage/src/endpoints/data.rs +++ b/crates/sage/src/endpoints/data.rs @@ -494,14 +494,26 @@ impl Sage { let transactions = wallet .db - .mempool_items() + .mempool_items_with_coins() .await? .into_iter() - .map(|tx| { + .map(|item| { + let spent = item + .spent + .into_iter() + .map(|c| self.transaction_coin(c)) + .collect::>>()?; + let created = item + .created + .into_iter() + .map(|c| self.transaction_coin(c)) + .collect::>>()?; Result::Ok(PendingTransactionRecord { - transaction_id: hex::encode(tx.hash), - fee: Amount::u64(tx.fee), - submitted_at: tx.submitted_timestamp, + transaction_id: hex::encode(item.hash), + fee: Amount::u64(item.fee), + submitted_at: item.submitted_timestamp, + spent, + created, }) }) .collect::>>()?; diff --git a/src/bindings.ts b/src/bindings.ts index 45ad7bbf5..737380219 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -2030,7 +2030,7 @@ export type OptionAssets = { underlying_asset: Asset; underlying_amount: Amount; export type OptionRecord = { launcher_id: string; name: string | null; visible: boolean; coin_id: string; address: string; amount: Amount; underlying_asset: Asset; underlying_amount: Amount; underlying_coin_id: string; strike_asset: Asset; strike_amount: Amount; expiration_seconds: number; created_height: number | null; created_timestamp: number | null } export type OptionSortMode = "name" | "created_height" | "expiration_seconds" export type PeerRecord = { ip_addr: string; port: number; peak_height: number; user_managed: boolean } -export type PendingTransactionRecord = { transaction_id: string; fee: Amount; submitted_at: number | null } +export type PendingTransactionRecord = { transaction_id: string; fee: Amount; submitted_at: number | null; spent: TransactionCoinRecord[]; created: TransactionCoinRecord[] } /** * Perform database maintenance operations */ diff --git a/src/components/TransactionColumns.tsx b/src/components/TransactionColumns.tsx index 4267ea649..809a8739b 100644 --- a/src/components/TransactionColumns.tsx +++ b/src/components/TransactionColumns.tsx @@ -19,15 +19,18 @@ import { AssetIcon } from './AssetIcon'; import { AssetLink } from './AssetLink'; export interface FlattenedTransaction { - transactionHeight: number; type: AssetKind; iconUrl?: string | null; amount: string; address: string | null; itemId: string; displayName: string; + transactionHeight: number; timestamp: number | null; precision: number; + groupKey: string; // unique group identifier: String(height) for confirmed, transactionId for pending + isPending?: boolean; + transactionId?: string; } export const columns: ColumnDef[] = [ @@ -39,17 +42,27 @@ export const columns: ColumnDef[] = [ enableSorting: false, size: 140, cell: ({ row, table }) => { - // Get all rows data const rows = table.options.data as FlattenedTransaction[]; - // Check if this is the first row for this transaction height + // Show only once per group (first row in the group) const isFirstInGroup = - rows.findIndex( - (tx) => tx.transactionHeight === row.original.transactionHeight, - ) === rows.indexOf(row.original); + rows.findIndex((tx) => tx.groupKey === row.original.groupKey) === + rows.indexOf(row.original); - // Only show block number for first row in group - return isFirstInGroup ? ( + if (!isFirstInGroup) return null; + + if (row.original.isPending) { + return ( +
+
+ + Pending + +
+ ); + } + + return ( [] = [ {formatTimestamp(row.original?.timestamp, 'short', 'short') || row.getValue('transactionHeight')} - ) : null; + ); }, }, { diff --git a/src/components/TransactionListView.tsx b/src/components/TransactionListView.tsx index 1f70590bc..374700885 100644 --- a/src/components/TransactionListView.tsx +++ b/src/components/TransactionListView.tsx @@ -5,23 +5,91 @@ import { Row, SortingState } from '@tanstack/react-table'; import BigNumber from 'bignumber.js'; import { motion } from 'framer-motion'; import { useState } from 'react'; -import { TransactionRecord } from '../bindings'; +import { PendingTransactionRecord, TransactionRecord } from '../bindings'; import { Loading } from './Loading'; import { columns, FlattenedTransaction } from './TransactionColumns'; export function TransactionListView({ transactions, + pendingTransactions = [], onSortingChange, isLoading = false, summarized = true, }: { transactions: TransactionRecord[]; + pendingTransactions?: PendingTransactionRecord[]; onSortingChange?: (ascending: boolean) => void; isLoading?: boolean; summarized?: boolean; }) { const [sorting, setSorting] = useState([]); + const flattenedPending: FlattenedTransaction[] = pendingTransactions.flatMap( + (transaction) => { + const created = transaction.created.map( + (coin): FlattenedTransaction => ({ + type: coin.asset.kind, + address: coin.address, + displayName: getAssetDisplayName( + coin.asset.name, + coin.asset.ticker, + coin.asset.kind, + ), + itemId: coin.asset.asset_id ?? 'XCH', + amount: coin.amount.toString(), + transactionHeight: 0, + iconUrl: coin.asset.icon_url ?? '', + timestamp: null, + precision: coin.asset.precision, + groupKey: transaction.transaction_id, + isPending: true, + transactionId: transaction.transaction_id, + }), + ); + + const spent = transaction.spent.map( + (coin): FlattenedTransaction => ({ + type: coin.asset.kind, + address: coin.address, + displayName: getAssetDisplayName( + coin.asset.name, + coin.asset.ticker, + coin.asset.kind, + ), + itemId: coin.asset.asset_id ?? 'XCH', + amount: BigNumber(coin.amount).negated().toString(), + transactionHeight: 0, + iconUrl: coin.asset.icon_url ?? '', + timestamp: null, + precision: coin.asset.precision, + groupKey: transaction.transaction_id, + isPending: true, + transactionId: transaction.transaction_id, + }), + ); + + if (!summarized) { + return [...created, ...spent]; + } + + const allCoins = [...created, ...spent]; + const summaryMap = new Map(); + allCoins.forEach((coin) => { + const existing = summaryMap.get(coin.itemId); + const amount = BigNumber(coin.amount); + if (existing) { + summaryMap.set(coin.itemId, { + ...existing, + amount: BigNumber(existing.amount).plus(amount).toString(), + }); + } else { + summaryMap.set(coin.itemId, { ...coin, amount: amount.toString() }); + } + }); + return [...summaryMap.values()]; + }, + ); + const flattenedTransactions = transactions.flatMap((transaction) => { const created = transaction.created.map( (coin): FlattenedTransaction => ({ @@ -38,6 +106,7 @@ export function TransactionListView({ iconUrl: coin.asset.icon_url ?? '', timestamp: transaction.timestamp, precision: coin.asset.precision, + groupKey: String(transaction.height), }), ); @@ -56,6 +125,7 @@ export function TransactionListView({ iconUrl: coin.asset.icon_url ?? '', timestamp: transaction.timestamp, precision: coin.asset.precision, + groupKey: String(transaction.height), }), ); @@ -90,17 +160,19 @@ export function TransactionListView({ return [...summaryMap.values()]; }); + const allFlattenedTransactions = [ + ...flattenedPending, + ...flattenedTransactions, + ]; + // Function to determine if a row is the first in a transaction group const getRowStyles = (row: Row) => { - const currentHeight = row.original.transactionHeight; - const rowIndex = flattenedTransactions.indexOf(row.original); + const currentKey = row.original.groupKey; + const rowIndex = allFlattenedTransactions.indexOf(row.original); - // If it's not the first row, check if the previous row has a different transaction height if (rowIndex > 0) { - const prevHeight = flattenedTransactions[rowIndex - 1].transactionHeight; - - // If this row has a different height than the previous one, it's the start of a new transaction group - if (currentHeight !== prevHeight) { + const prevKey = allFlattenedTransactions[rowIndex - 1].groupKey; + if (currentKey !== prevKey) { return { className: 'border-t-2 border-t-neutral-300 dark:border-t-neutral-700', @@ -126,7 +198,7 @@ export function TransactionListView({ ) : ( { setSorting(updatedSort); diff --git a/src/pages/Transactions.tsx b/src/pages/Transactions.tsx index a87425146..a54acba78 100644 --- a/src/pages/Transactions.tsx +++ b/src/pages/Transactions.tsx @@ -19,13 +19,21 @@ import { Trans } from '@lingui/react/macro'; import { AnimatePresence, motion } from 'framer-motion'; import { Info } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { events, TransactionRecord } from '../bindings'; +import { + commands, + events, + PendingTransactionRecord, + TransactionRecord, +} from '../bindings'; export function Transactions() { const { addError } = useErrors(); const [params, setParams] = useTransactionsParams(); const { page, pageSize, search, ascending, summarized } = params; const [transactions, setTransactions] = useState([]); + const [pendingTransactions, setPendingTransactions] = useState< + PendingTransactionRecord[] + >([]); const [totalTransactions, setTotalTransactions] = useState(0); const [isLoading, setIsLoading] = useState(false); const optionsRef = useRef(null); @@ -42,15 +50,19 @@ export function Transactions() { if (showLoader) setIsLoading(true); try { - const result = await queryTransactions({ - search, - ascending, - page, - pageSize, - }); + const [result, pendingResult] = await Promise.all([ + queryTransactions({ + search, + ascending, + page, + pageSize, + }), + commands.getPendingTransactions({}), + ]); setTransactions(result.transactions); setTotalTransactions(result.total); + setPendingTransactions(pendingResult.transactions); } catch (error: unknown) { addError(error as CustomError); } finally { @@ -71,6 +83,7 @@ export function Transactions() { case 'did_info': case 'nft_data': case 'puzzle_batch_synced': + case 'transaction_failed': updateTransactions(false); } }); @@ -154,17 +167,20 @@ export function Transactions() { - {transactions.length === 0 && !isLoading && !isPaginationLoading && ( - - - )} + {transactions.length === 0 && + pendingTransactions.length === 0 && + !isLoading && + !isPaginationLoading && ( + + + )}
{ setIsPaginationLoading(true); setParams({ ascending: value, page: 1 });