Skip to content

Commit e48655c

Browse files
authored
feat: asset live transactions
1 parent b134b9e commit e48655c

27 files changed

+351
-106
lines changed

src/features/assets/components/asset-details.tsx

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,12 @@ import {
1515
assetDefaultFrozenLabel,
1616
assetDetailsLabel,
1717
assetFreezeLabel,
18+
assetHistoricalTransactionsTabId,
19+
assetHistoricalTransactionsTabLabel,
1820
assetIdLabel,
1921
assetJsonLabel,
22+
assetLiveTransactionsTabId,
23+
assetLiveTransactionsTabLabel,
2024
assetManagerLabel,
2125
assetNameLabel,
2226
assetReserveLabel,
@@ -30,6 +34,8 @@ import { AssetMedia } from './asset-media'
3034
import { AssetTraits } from './asset-traits'
3135
import { AssetMetadata } from './asset-metadata'
3236
import { AssetTransactionHistory } from './asset-transaction-history'
37+
import { AssetLiveTransactions } from './asset-live-transactions'
38+
import { OverflowAutoTabsContent, Tabs, TabsList, TabsTrigger } from '@/features/common/components/tabs'
3339

3440
type Props = {
3541
asset: Asset
@@ -157,9 +163,28 @@ export function AssetDetails({ asset }: Props) {
157163
<Card className={cn('p-4')}>
158164
<CardContent className={cn('text-sm space-y-2')}>
159165
<h1 className={cn('text-2xl text-primary font-bold')}>{assetTransactionsLabel}</h1>
160-
<div className={cn('border-solid border-2 grid p-4')}>
161-
<AssetTransactionHistory assetId={asset.id} />
162-
</div>
166+
<Tabs defaultValue={assetLiveTransactionsTabId}>
167+
<TabsList aria-label={assetTransactionsLabel}>
168+
<TabsTrigger
169+
className={cn('data-[state=active]:border-primary data-[state=active]:border-b-2 w-48')}
170+
value={assetLiveTransactionsTabId}
171+
>
172+
{assetLiveTransactionsTabLabel}
173+
</TabsTrigger>
174+
<TabsTrigger
175+
className={cn('data-[state=active]:border-primary data-[state=active]:border-b-2 w-48')}
176+
value={assetHistoricalTransactionsTabId}
177+
>
178+
{assetHistoricalTransactionsTabLabel}
179+
</TabsTrigger>
180+
</TabsList>
181+
<OverflowAutoTabsContent value={assetLiveTransactionsTabId}>
182+
<AssetLiveTransactions assetId={asset.id} />
183+
</OverflowAutoTabsContent>
184+
<OverflowAutoTabsContent value={assetHistoricalTransactionsTabId}>
185+
<AssetTransactionHistory assetId={asset.id} />
186+
</OverflowAutoTabsContent>
187+
</Tabs>
163188
</CardContent>
164189
</Card>
165190
</>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { AssetId } from '../data/types'
2+
import { useCallback } from 'react'
3+
import { LiveTransactionsTable } from '@/features/transactions/components/live-transactions-table'
4+
import { assetTransactionsTableColumns } from '../utils/asset-transactions-table-columns'
5+
import { TransactionResult } from '@algorandfoundation/algokit-utils/types/indexer'
6+
import { JotaiStore } from '@/features/common/data/types'
7+
import { createTransactionAtom } from '@/features/transactions/data'
8+
import { atom } from 'jotai'
9+
import { getAssetIdsForTransaction } from '@/features/transactions/utils/get-asset-ids-for-transaction'
10+
import { extractTransactionsForAsset } from '../utils/extract-transactions-for-asset'
11+
12+
type Props = {
13+
assetId: AssetId
14+
}
15+
16+
export function AssetLiveTransactions({ assetId }: Props) {
17+
const mapper = useCallback(
18+
(store: JotaiStore, transactionResult: TransactionResult) => {
19+
return atom(async (get) => {
20+
const assetIdsForTransaction = getAssetIdsForTransaction(transactionResult)
21+
if (!assetIdsForTransaction.includes(assetId)) return []
22+
23+
const transaction = await get(createTransactionAtom(store, transactionResult))
24+
return extractTransactionsForAsset(transaction, assetId)
25+
})
26+
},
27+
[assetId]
28+
)
29+
return <LiveTransactionsTable mapper={mapper} columns={assetTransactionsTableColumns} />
30+
}
Lines changed: 2 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
11
import { LazyLoadDataTable } from '@/features/common/components/lazy-load-data-table'
22
import { AssetId } from '../data/types'
3-
import { Transaction, TransactionType } from '@/features/transactions/models'
4-
import { DisplayAlgo } from '@/features/common/components/display-algo'
5-
import { DisplayAssetAmount } from '@/features/common/components/display-asset-amount'
6-
import { cn } from '@/features/common/utils'
7-
import { TransactionLink } from '@/features/transactions/components/transaction-link'
8-
import { ellipseAddress } from '@/utils/ellipse-address'
9-
import { ColumnDef } from '@tanstack/react-table'
103
import { useFetchNextAssetTransactionsPage } from '../data/asset-transaction-history'
4+
import { assetTransactionsTableColumns } from '../utils/asset-transactions-table-columns'
115

126
type Props = {
137
assetId: AssetId
@@ -16,45 +10,5 @@ type Props = {
1610
export function AssetTransactionHistory({ assetId }: Props) {
1711
const fetchNextPage = useFetchNextAssetTransactionsPage(assetId)
1812

19-
return <LazyLoadDataTable columns={transactionsTableColumns} fetchNextPage={fetchNextPage} />
13+
return <LazyLoadDataTable columns={assetTransactionsTableColumns} fetchNextPage={fetchNextPage} />
2014
}
21-
22-
const transactionsTableColumns: ColumnDef<Transaction>[] = [
23-
{
24-
header: 'Transaction Id',
25-
accessorKey: 'id',
26-
cell: (c) => {
27-
const value = c.getValue<string>()
28-
return <TransactionLink transactionId={value} short={true} />
29-
},
30-
},
31-
{
32-
accessorKey: 'sender',
33-
header: 'From',
34-
cell: (c) => ellipseAddress(c.getValue<string>()),
35-
},
36-
{
37-
header: 'To',
38-
accessorFn: (transaction) => {
39-
if (transaction.type === TransactionType.Payment || transaction.type === TransactionType.AssetTransfer)
40-
return ellipseAddress(transaction.receiver)
41-
if (transaction.type === TransactionType.ApplicationCall) return transaction.applicationId
42-
if (transaction.type === TransactionType.AssetConfig) return transaction.assetId
43-
if (transaction.type === TransactionType.AssetFreeze) return ellipseAddress(transaction.address)
44-
},
45-
},
46-
{
47-
accessorKey: 'type',
48-
header: 'Type',
49-
},
50-
{
51-
header: 'Amount',
52-
accessorFn: (transaction) => transaction,
53-
cell: (c) => {
54-
const transaction = c.getValue<Transaction>()
55-
if (transaction.type === TransactionType.Payment) return <DisplayAlgo className={cn('justify-center')} amount={transaction.amount} />
56-
if (transaction.type === TransactionType.AssetTransfer)
57-
return <DisplayAssetAmount amount={transaction.amount} asset={transaction.asset} />
58-
},
59-
},
60-
]

src/features/assets/components/labels.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,7 @@ export const assetMetadataLabel = 'Asset Metadata'
2323
export const assetJsonLabel = 'Asset JSON'
2424

2525
export const assetTransactionsLabel = 'Asset Transactions'
26+
export const assetLiveTransactionsTabId = 'live-transactions'
27+
export const assetLiveTransactionsTabLabel = 'Live Transactions'
28+
export const assetHistoricalTransactionsTabId = 'historical-transactions'
29+
export const assetHistoricalTransactionsTabLabel = 'Historical Transactions'

src/features/assets/data/asset-metadata.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export const createAssetMetadataResultAtom = (assetResult: AssetResult) =>
7575
return null
7676
}
7777

78-
const results =
78+
let results =
7979
assetResult.params.manager && assetResult.params.manager !== ZERO_ADDRESS
8080
? await indexer
8181
.searchForTransactions()
@@ -85,19 +85,22 @@ export const createAssetMetadataResultAtom = (assetResult: AssetResult) =>
8585
.addressRole('sender')
8686
.limit(2) // Return 2 to cater for a destroy transaction and any potential eventual consistency delays between transactions and assets.
8787
.do()
88-
.then((res) => res.transactions as TransactionResult[]) // Implicitly newest to oldest when filtering with an address
89-
: // The asset has been destroyed or is an immutable asset.
90-
// Fetch the entire acfg transaction history and reverse the order, so it's newest to oldest
91-
await executePaginatedRequest(
92-
(res: TransactionSearchResults) => res.transactions,
93-
(nextToken) => {
94-
let s = indexer.searchForTransactions().assetID(assetResult.index).txType('acfg')
95-
if (nextToken) {
96-
s = s.nextToken(nextToken)
97-
}
98-
return s
99-
}
100-
).then((res) => res.reverse()) // reverse the order, so it's newest to oldest
88+
.then((res) => res.transactions as TransactionResult[]) // Implicitly newest to oldest when filtering with an address.
89+
: []
90+
if (results.length === 0) {
91+
// The asset has been destroyed, is an immutable asset, or the asset is mutable however has never been mutated.
92+
// Fetch the entire acfg transaction history and reverse the order, so it's newest to oldest.
93+
results = await executePaginatedRequest(
94+
(res: TransactionSearchResults) => res.transactions,
95+
(nextToken) => {
96+
let s = indexer.searchForTransactions().assetID(assetResult.index).txType('acfg')
97+
if (nextToken) {
98+
s = s.nextToken(nextToken)
99+
}
100+
return s
101+
}
102+
).then((res) => res.reverse()) // reverse the order, so it's newest to oldest
103+
}
101104

102105
const assetConfigTransactionResults = results.flatMap(flattenTransactionResult).filter((t) => {
103106
const isAssetConfigTransaction = t['tx-type'] === TransactionType.acfg

src/features/assets/data/asset-transaction-history.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { JotaiStore } from '@/features/common/data/types'
66
import { createTransactionsAtom, transactionResultsAtom } from '@/features/transactions/data'
77
import { atomEffect } from 'jotai-effect'
88
import { atom, useStore } from 'jotai'
9+
import { extractTransactionsForAsset } from '../utils/extract-transactions-for-asset'
910

1011
const fetchAssetTransactionResults = async (assetId: AssetId, pageSize: number, nextPageToken?: string) => {
1112
const results = (await indexer
@@ -47,9 +48,10 @@ const createAssetTransactionsAtom = (store: JotaiStore, assetId: AssetId, pageSi
4748
get(createSyncEffect(transactionResults))
4849

4950
const transactions = await get(createTransactionsAtom(store, transactionResults))
51+
const transactionsForAsset = transactions.flatMap((transaction) => extractTransactionsForAsset(transaction, assetId))
5052

5153
return {
52-
rows: transactions,
54+
rows: transactionsForAsset,
5355
nextPageToken: newNextPageToken,
5456
}
5557
})

src/features/assets/pages/asset-page.test.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
assetReserveLabel,
1818
assetTotalSupplyLabel,
1919
assetTraitsLabel,
20+
assetTransactionsLabel,
2021
assetUrlLabel,
2122
} from '../components/labels'
2223
import { useParams } from 'react-router-dom'
@@ -147,6 +148,10 @@ describe('asset-page', () => {
147148
{ term: 'Image Mimetype', description: 'image/png' },
148149
],
149150
})
151+
152+
const transactionTabList = component.getByRole('tablist', { name: assetTransactionsLabel })
153+
expect(transactionTabList).toBeTruthy()
154+
expect(transactionTabList.children.length).toBe(2)
150155
})
151156
}
152157
)
@@ -608,6 +613,9 @@ describe('asset-page', () => {
608613

609614
const assetTraitsCard = component.queryByText(assetTraitsLabel)
610615
expect(assetTraitsCard).toBeNull()
616+
617+
const transactionTabList = component.queryByRole('tablist', { name: assetTransactionsLabel })
618+
expect(transactionTabList).toBeNull()
611619
})
612620
}
613621
)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { InnerTransaction, Transaction, TransactionType } from '@/features/transactions/models'
2+
import { cn } from '@/features/common/utils'
3+
import { ellipseAddress } from '@/utils/ellipse-address'
4+
import { ColumnDef } from '@tanstack/react-table'
5+
import { DisplayAlgo } from '@/features/common/components/display-algo'
6+
import { DisplayAssetAmount } from '@/features/common/components/display-asset-amount'
7+
import { TransactionLink } from '@/features/transactions/components/transaction-link'
8+
import { ellipseId } from '@/utils/ellipse-id'
9+
import { asTo } from '@/features/common/mappers/to'
10+
11+
export const assetTransactionsTableColumns: ColumnDef<Transaction | InnerTransaction>[] = [
12+
{
13+
header: 'Transaction Id',
14+
accessorFn: (transaction) => transaction,
15+
cell: (c) => {
16+
const transaction = c.getValue<Transaction | InnerTransaction>()
17+
return 'innerId' in transaction ? (
18+
<TransactionLink
19+
className={cn('text-primary underline cursor-pointer grid gap-2')}
20+
transactionId={transaction.networkTransactionId}
21+
>
22+
<span>{ellipseId(transaction.id)}</span>
23+
<span>(Inner)</span>
24+
</TransactionLink>
25+
) : (
26+
<TransactionLink transactionId={transaction.id} short={true} />
27+
)
28+
},
29+
},
30+
{
31+
header: 'Round',
32+
accessorKey: 'confirmedRound',
33+
},
34+
{
35+
accessorKey: 'sender',
36+
header: 'From',
37+
cell: (c) => ellipseAddress(c.getValue<string>()),
38+
},
39+
{
40+
header: 'To',
41+
accessorFn: asTo,
42+
},
43+
{
44+
accessorKey: 'type',
45+
header: 'Type',
46+
},
47+
{
48+
header: 'Amount',
49+
accessorFn: (transaction) => transaction,
50+
cell: (c) => {
51+
const transaction = c.getValue<Transaction>()
52+
if (transaction.type === TransactionType.Payment) return <DisplayAlgo className={cn('justify-center')} amount={transaction.amount} />
53+
if (transaction.type === TransactionType.AssetTransfer)
54+
return <DisplayAssetAmount amount={transaction.amount} asset={transaction.asset} />
55+
},
56+
},
57+
]
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Transaction, TransactionType } from '@/features/transactions/models'
2+
import { flattenInnerTransactions } from '@/utils/flatten-inner-transactions'
3+
4+
export const extractTransactionsForAsset = (transaction: Transaction, assetIndex: number) => {
5+
const flattenedTransactions = flattenInnerTransactions(transaction)
6+
const results = []
7+
8+
for (const { transaction } of flattenedTransactions) {
9+
if (transaction.type === TransactionType.AssetConfig && transaction.assetId === assetIndex) {
10+
results.push(transaction)
11+
}
12+
if (transaction.type === TransactionType.AssetTransfer && transaction.asset.id === assetIndex) {
13+
results.push(transaction)
14+
}
15+
if (transaction.type === TransactionType.AssetFreeze && transaction.assetId === assetIndex) {
16+
results.push(transaction)
17+
}
18+
}
19+
return results
20+
}

src/features/blocks/components/transactions.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { DataTable } from '@/features/common/components/data-table'
66
import { TransactionLink } from '@/features/transactions/components/transaction-link'
77
import { DisplayAssetAmount } from '@/features/common/components/display-asset-amount'
88
import { GroupLink } from '@/features/groups/components/group-link'
9+
import { asTo } from '@/features/common/mappers/to'
910

1011
type Props = {
1112
transactions: Transaction[]
@@ -31,13 +32,7 @@ export const columns: ColumnDef<Transaction>[] = [
3132
},
3233
{
3334
header: 'To',
34-
accessorFn: (transaction) => {
35-
if (transaction.type === TransactionType.Payment || transaction.type === TransactionType.AssetTransfer)
36-
return ellipseAddress(transaction.receiver)
37-
if (transaction.type === TransactionType.ApplicationCall) return transaction.applicationId
38-
if (transaction.type === TransactionType.AssetConfig) return transaction.assetId
39-
if (transaction.type === TransactionType.AssetFreeze) return transaction.assetId
40-
},
35+
accessorFn: asTo,
4136
},
4237
{
4338
accessorKey: 'type',

0 commit comments

Comments
 (0)