Skip to content

Commit 578ae43

Browse files
committed
feat: activity service
1 parent 3ccdf24 commit 578ae43

File tree

13 files changed

+403
-27
lines changed

13 files changed

+403
-27
lines changed
+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { useMemo } from 'react';
2+
3+
import {
4+
useStacksSignerAddressFromAccountIndex,
5+
useStacksSigners,
6+
} from '@/store/keychains/stacks/stacks-keychains.read';
7+
8+
import { UnifiedAccountIdentifier } from '@leather.io/services';
9+
10+
import {
11+
useBitcoinAccountServiceRequest,
12+
useTotalBitcoinAccountServiceRequests,
13+
} from './use-bitcoin-account-service-requests';
14+
15+
export function useTotalUnifiedAccountIdentifiers() {
16+
const btcRequests = useTotalBitcoinAccountServiceRequests();
17+
const signers = useStacksSigners();
18+
return useMemo(
19+
() =>
20+
!btcRequests || !signers
21+
? []
22+
: btcRequests.map(
23+
req =>
24+
({
25+
...req.account,
26+
stxAddress: signers
27+
.fromAccountIndex(req.account.fingerprint, req.account.accountIndex)
28+
// TODO: throw error when no matching Stacks address
29+
.map(signer => signer.address)[0]!,
30+
}) satisfies UnifiedAccountIdentifier
31+
),
32+
[btcRequests, signers]
33+
);
34+
}
35+
36+
export function useUnifiedAccountIdentifier(fingerprint: string, accountIndex: number) {
37+
const btcRequest = useBitcoinAccountServiceRequest(fingerprint, accountIndex);
38+
const stxAddress = useStacksSignerAddressFromAccountIndex(fingerprint, accountIndex) ?? '';
39+
if (!stxAddress) {
40+
throw new Error('Stacks address not found');
41+
}
42+
return {
43+
...btcRequest.account,
44+
stxAddress,
45+
} satisfies UnifiedAccountIdentifier;
46+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {
2+
useTotalUnifiedAccountIdentifiers,
3+
useUnifiedAccountIdentifier,
4+
} from '@/hooks/use-account-unified-identifier';
5+
import { toFetchState } from '@/shared/fetch-state';
6+
import { useSettings } from '@/store/settings/settings';
7+
import { QueryFunctionContext, useQuery } from '@tanstack/react-query';
8+
9+
import { UnifiedAccountIdentifier, getActivityService } from '@leather.io/services';
10+
11+
export function useTotalActivity() {
12+
const accountIdentifiers = useTotalUnifiedAccountIdentifiers();
13+
return toFetchState(useAggregateActivityQuery(accountIdentifiers));
14+
}
15+
16+
export function useAccountActivity(fingerprint: string, accountIndex: number) {
17+
const accountIdentifier = useUnifiedAccountIdentifier(fingerprint, accountIndex);
18+
return toFetchState(useAccountActivityQuery(accountIdentifier));
19+
}
20+
21+
export function useAccountActivityQuery(account: UnifiedAccountIdentifier) {
22+
const { fiatCurrencyPreference } = useSettings();
23+
return useQuery({
24+
queryKey: ['activity-service-get-account-activity', account, fiatCurrencyPreference],
25+
queryFn: ({ signal }: QueryFunctionContext) =>
26+
getActivityService().getAccountActivity(account, signal),
27+
refetchOnReconnect: false,
28+
refetchOnWindowFocus: false,
29+
refetchOnMount: true,
30+
retryOnMount: false,
31+
staleTime: 1 * 1000,
32+
gcTime: 1 * 1000,
33+
});
34+
}
35+
36+
export function useAggregateActivityQuery(accounts: UnifiedAccountIdentifier[]) {
37+
const { fiatCurrencyPreference } = useSettings();
38+
return useQuery({
39+
queryKey: ['activity-service-get-aggregate-activity', accounts, fiatCurrencyPreference],
40+
queryFn: ({ signal }: QueryFunctionContext) =>
41+
getActivityService().getAggregateActivity(accounts, signal),
42+
refetchOnReconnect: false,
43+
refetchOnWindowFocus: false,
44+
refetchOnMount: true,
45+
retryOnMount: false,
46+
staleTime: 1 * 1000,
47+
gcTime: 1 * 1000,
48+
});
49+
}

Diff for: packages/constants/src/index.ts

+20
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type {
33
AccountDisplayPreferenceInfo,
44
BitcoinUnit,
55
BitcoinUnitInfo,
6+
BtcCryptoAssetInfo,
7+
StxCryptoAssetInfo,
68
} from '@leather.io/models';
79

810
export const gaiaUrl = 'https://hub.blockstack.org';
@@ -112,3 +114,21 @@ export const accountDisplayPreferencesKeyedByType: Record<
112114
name: 'Stacks address',
113115
},
114116
};
117+
118+
export const btcCryptoAsset: BtcCryptoAssetInfo = {
119+
chain: 'bitcoin',
120+
protocol: 'nativeBtc',
121+
symbol: 'BTC',
122+
category: 'fungible',
123+
decimals: 8,
124+
hasMemo: false,
125+
};
126+
127+
export const stxCryptoAsset: StxCryptoAssetInfo = {
128+
chain: 'stacks',
129+
protocol: 'nativeStx',
130+
symbol: 'STX',
131+
category: 'fungible',
132+
decimals: 6,
133+
hasMemo: false,
134+
};

Diff for: packages/models/src/activity.model.ts

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { CryptoAssetInfo } from './crypto-assets/crypto-asset-info.model';
2+
import { Money } from './money.model';
3+
4+
export const ActivityTypes = {
5+
tokenTransfer: 'tokenTransfer',
6+
contractCall: 'contractCall',
7+
};
8+
export type ActivityType = keyof typeof ActivityTypes;
9+
10+
export interface BaseActivity {
11+
readonly type: ActivityType;
12+
readonly txid: string;
13+
readonly timestamp: number;
14+
readonly status: 'pending' | 'success' | 'failed';
15+
}
16+
17+
export const TokenTransferDirections = {
18+
outbound: 'outbound',
19+
inbound: 'inbound',
20+
};
21+
export type TokenTransferDirection = keyof typeof TokenTransferDirections;
22+
23+
export interface TokenTransferActivity extends BaseActivity {
24+
readonly type: 'tokenTransfer';
25+
readonly asset: CryptoAssetInfo;
26+
readonly direction: TokenTransferDirection;
27+
readonly amount: {
28+
crypto: Money;
29+
fiat: Money;
30+
};
31+
}
32+
33+
export interface ContractCallActivity extends BaseActivity {
34+
readonly type: 'contractCall';
35+
}
36+
37+
export type Activity = TokenTransferActivity | ContractCallActivity;
38+
39+
// Token
40+
// Collectible
41+
// Swap
42+
// Smart Contract Activity

Diff for: packages/models/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ export * from './settings.model';
1616
export * from './transactions/bitcoin-transaction.model';
1717
export * from './transactions/stacks-transaction.model';
1818
export * from './utxo.model';
19+
export * from './activity.model';

Diff for: packages/services/src/activity/activity.service.ts

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Activity } from '@leather.io/models';
2+
import { isDefined } from '@leather.io/utils';
3+
4+
import { UnifiedAccountIdentifier } from '../shared/bitcoin.types';
5+
import { BitcoinTransactionsService } from '../transactions/bitcoin-transactions.service';
6+
import { StacksTransactionsService } from '../transactions/stacks-transactions.service';
7+
import {
8+
mapBitcoinTxToActivity,
9+
mapStacksTxToActivity,
10+
sortActivityByTimestampDesc,
11+
} from './activity.utils';
12+
13+
export interface ActivityService {
14+
getAccountActivity(account: UnifiedAccountIdentifier, signal?: AbortSignal): Promise<Activity[]>;
15+
getAggregateActivity(
16+
account: UnifiedAccountIdentifier[],
17+
signal?: AbortSignal
18+
): Promise<Activity[]>;
19+
}
20+
21+
export function createActivityService(
22+
stacksTransactionsService: StacksTransactionsService,
23+
bitcoinTransactionsService: BitcoinTransactionsService
24+
): ActivityService {
25+
/*
26+
* Gets unified activity list for an account sorted by timestamp
27+
*/
28+
async function getAccountActivity(account: UnifiedAccountIdentifier, signal?: AbortSignal) {
29+
const [stacksTransactions, bitcoinTransactions] = await Promise.all([
30+
stacksTransactionsService.getTotalTransactions(account.stxAddress, signal),
31+
bitcoinTransactionsService.getTotalTransactions(account, signal),
32+
]);
33+
return [
34+
...stacksTransactions.map(mapStacksTxToActivity),
35+
...bitcoinTransactions.map(mapBitcoinTxToActivity),
36+
]
37+
.filter(isDefined)
38+
.sort(sortActivityByTimestampDesc);
39+
}
40+
41+
/*
42+
* Gets unified activity list for an account sorted by timestamp
43+
*/
44+
async function getAggregateActivity(accounts: UnifiedAccountIdentifier[], signal?: AbortSignal) {
45+
const accountActivities = await Promise.all(
46+
accounts.map(identifier => getAccountActivity(identifier, signal))
47+
);
48+
return accountActivities.flat().sort(sortActivityByTimestampDesc);
49+
}
50+
51+
return {
52+
getAccountActivity,
53+
getAggregateActivity,
54+
};
55+
}

Diff for: packages/services/src/activity/activity.utils.ts

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { btcCryptoAsset, stxCryptoAsset } from '@leather.io/constants';
2+
import {
3+
Activity,
4+
ContractCallActivity,
5+
StacksTx,
6+
TokenTransferActivity,
7+
} from '@leather.io/models';
8+
import { createMoney } from '@leather.io/utils';
9+
10+
import { LeatherApiBitcoinTransaction } from '../infrastructure/api/leather/leather-api.client';
11+
12+
export function mapStacksTxToActivity(stacksTx: StacksTx): Activity | undefined {
13+
switch (stacksTx.tx_type) {
14+
case 'token_transfer':
15+
return mapStacksTxToTransferActivity(stacksTx);
16+
case 'contract_call':
17+
return mapStacksTxToContractCallActivity(stacksTx);
18+
default:
19+
return;
20+
}
21+
}
22+
23+
function mapStacksTxToTransferActivity(stacksTx: StacksTx): TokenTransferActivity {
24+
const stxTransferActivity: TokenTransferActivity = {
25+
type: 'tokenTransfer',
26+
asset: stxCryptoAsset,
27+
direction: 'outbound',
28+
amount: {
29+
crypto: createMoney(0, 'STX'),
30+
fiat: createMoney(0, 'USD'),
31+
},
32+
txid: stacksTx.tx_id,
33+
timestamp: 0,
34+
status: 'success',
35+
};
36+
return stxTransferActivity;
37+
}
38+
39+
function mapStacksTxToContractCallActivity(stacksTx: StacksTx): ContractCallActivity {
40+
const stxTransferActivity: ContractCallActivity = {
41+
type: 'contractCall',
42+
txid: stacksTx.tx_id,
43+
timestamp: 0,
44+
status: 'success',
45+
};
46+
return stxTransferActivity;
47+
}
48+
49+
export function mapBitcoinTxToActivity(bitcoinTx: LeatherApiBitcoinTransaction): Activity {
50+
// All bitcoin transactions will currently be considered transfers
51+
const btcTransferActivity: TokenTransferActivity = {
52+
type: 'tokenTransfer',
53+
asset: btcCryptoAsset,
54+
txid: bitcoinTx.txid,
55+
direction: 'outbound',
56+
amount: {
57+
crypto: createMoney(0, 'BTC'),
58+
fiat: createMoney(0, 'USD'),
59+
},
60+
timestamp: 0,
61+
status: 'success',
62+
};
63+
return btcTransferActivity;
64+
}
65+
66+
export function sortActivityByTimestampDesc(a: Activity, b: Activity) {
67+
return b.timestamp - a.timestamp;
68+
}

Diff for: packages/services/src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,6 @@ export * from './inversify.config';
1212
export * from './market-data/market-data.service';
1313
export * from './shared/bitcoin.types';
1414
export * from './transactions/stacks-transactions.service';
15+
export * from './transactions/bitcoin-transactions.service';
1516
export * from './utxos/utxos.service';
17+
export * from './activity/activity.service';

Diff for: packages/services/src/infrastructure/api/leather/leather-api.client.ts

+15-12
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,23 @@ const leatherApiUtxoSchema = z.object({
1515
vout: z.number(),
1616
});
1717

18-
const leatherApiTxVinLightSchema = z.object({
18+
const leatherApiBitcoinTransactionVinSchema = z.object({
1919
value: z.string(),
2020
n: z.number(),
2121
addresses: z.array(z.string()),
2222
isAddress: z.boolean(),
2323
isOwn: z.boolean(),
2424
});
2525

26-
const leatherApiTxVoutLightSchema = z.object({
26+
const leatherApiBitcoinTransactionVoutSchema = z.object({
2727
value: z.string(),
2828
n: z.number(),
2929
spent: z.boolean(),
3030
addresses: z.array(z.string()),
3131
isAddress: z.boolean(),
3232
isOwn: z.boolean(),
3333
});
34-
const leatherApiTxLightSchema = z.object({
34+
const leatherApiBitcoinTransactionSchema = z.object({
3535
txid: z.string(),
3636
blockHash: z.string().optional(),
3737
blockHeight: z.number(),
@@ -40,8 +40,8 @@ const leatherApiTxLightSchema = z.object({
4040
value: z.string(),
4141
valueIn: z.string(),
4242
fees: z.string(),
43-
vin: leatherApiTxVinLightSchema,
44-
vout: leatherApiTxVoutLightSchema,
43+
vin: leatherApiBitcoinTransactionVinSchema,
44+
vout: leatherApiBitcoinTransactionVoutSchema,
4545
});
4646

4747
const leatherApiFiatExchangeRatesSchema = z.object({
@@ -67,15 +67,18 @@ const mockFiatExchangeRatesResponse = {
6767
};
6868

6969
export type LeatherApiUtxo = z.infer<typeof leatherApiUtxoSchema>;
70-
export type LeatherApiTxLight = z.infer<typeof leatherApiTxLightSchema>;
70+
export type LeatherApiBitcoinTransaction = z.infer<typeof leatherApiBitcoinTransactionSchema>;
7171
export interface LeatherApiExchangeRates {
7272
[symbol: string]: number;
7373
}
7474

7575
export interface LeatherApiClient {
7676
fetchFiatExchangeRates(signal?: AbortSignal): Promise<LeatherApiExchangeRates>;
7777
fetchUtxos(descriptor: string, signal?: AbortSignal): Promise<LeatherApiUtxo[]>;
78-
fetchTxs(descriptor: string, signal?: AbortSignal): Promise<LeatherApiTxLight[]>;
78+
fetchBitcoinTransactions(
79+
descriptor: string,
80+
signal?: AbortSignal
81+
): Promise<LeatherApiBitcoinTransaction[]>;
7982
}
8083

8184
export function createLeatherApiClient(
@@ -110,16 +113,16 @@ export function createLeatherApiClient(
110113
);
111114
}
112115

113-
async function fetchTxs(descriptor: string, signal?: AbortSignal) {
116+
async function fetchBitcoinTransactions(descriptor: string, signal?: AbortSignal) {
114117
const network = settingsService.getSettings().network.chain.bitcoin.bitcoinNetwork;
115118
return await cacheService.fetchWithCache(
116-
['leather-txs', network, descriptor],
119+
['leather-bitcoin-transactions', network, descriptor],
117120
async () => {
118-
const res = await axios.get<LeatherApiUtxo[]>(
121+
const res = await axios.get<LeatherApiBitcoinTransaction[]>(
119122
`https://leather-bitcoin-balances.wallet-6d1.workers.dev/${network}/${descriptor}/txs`,
120123
{ signal }
121124
);
122-
return z.array(leatherApiTxLightSchema).parse(res.data);
125+
return z.array(leatherApiBitcoinTransactionSchema).parse(res.data);
123126
},
124127
{ ttl: HttpCacheTimeMs.fiveSeconds }
125128
);
@@ -128,6 +131,6 @@ export function createLeatherApiClient(
128131
return {
129132
fetchFiatExchangeRates,
130133
fetchUtxos,
131-
fetchTxs,
134+
fetchBitcoinTransactions,
132135
};
133136
}

0 commit comments

Comments
 (0)