Skip to content

Commit 191c6ec

Browse files
committed
feat: activity service
1 parent 3ccdf24 commit 191c6ec

File tree

9 files changed

+237
-19
lines changed

9 files changed

+237
-19
lines changed

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

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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+
}
16+
17+
export function createActivityService(
18+
stacksTransactionsService: StacksTransactionsService,
19+
bitcoinTransactionsService: BitcoinTransactionsService
20+
): ActivityService {
21+
/*
22+
* Gets unified activity list sorted by timestamp
23+
*/
24+
async function getAccountActivity(account: UnifiedAccountIdentifier, signal?: AbortSignal) {
25+
const [stacksTransactions, bitcoinTransactions] = await Promise.all([
26+
stacksTransactionsService.getTotalTransactions(account.stxAddress, signal),
27+
bitcoinTransactionsService.getTotalTransactions(account, signal),
28+
]);
29+
return [
30+
...stacksTransactions.map(mapStacksTxToActivity),
31+
...bitcoinTransactions.map(mapBitcoinTxToActivity),
32+
]
33+
.filter(isDefined)
34+
.sort(sortActivityByTimestampDesc);
35+
}
36+
37+
return {
38+
getAccountActivity,
39+
};
40+
}

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

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

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
}

Diff for: packages/services/src/shared/bitcoin.types.ts

+4
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ export interface BitcoinAccountServiceRequest {
1111
account: BitcoinAccountIdentifier;
1212
unprotectedUtxos: UtxoId[];
1313
}
14+
15+
export interface UnifiedAccountIdentifier extends BitcoinAccountIdentifier {
16+
stxAddress: string;
17+
}
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,48 @@
1+
import {
2+
LeatherApiBitcoinTransaction,
3+
LeatherApiClient,
4+
} from '../infrastructure/api/leather/leather-api.client';
15
import { BitcoinAccountIdentifier } from '../shared/bitcoin.types';
26

37
export interface BitcoinTransactionsService {
4-
getOutboundTransactions(account: BitcoinAccountIdentifier, signal?: AbortSignal): Promise<any[]>;
8+
getTotalTransactions(
9+
account: BitcoinAccountIdentifier,
10+
signal?: AbortSignal
11+
): Promise<LeatherApiBitcoinTransaction[]>;
12+
getConfirmedTransactions(
13+
account: BitcoinAccountIdentifier,
14+
signal?: AbortSignal
15+
): Promise<LeatherApiBitcoinTransaction[]>;
16+
getPendingTransactions(
17+
account: BitcoinAccountIdentifier,
18+
signal?: AbortSignal
19+
): Promise<LeatherApiBitcoinTransaction[]>;
520
}
621

7-
// TODO: WIP - Requires new Leather API Tx endpoint leveraging xpub lookups
8-
export function createBitcoinTransactionsService(): BitcoinTransactionsService {
9-
// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars
10-
async function getOutboundTransactions(account: BitcoinAccountIdentifier, signal?: AbortSignal) {
11-
return [];
22+
export function createBitcoinTransactionsService(
23+
leatherApiClient: LeatherApiClient
24+
): BitcoinTransactionsService {
25+
async function getTotalTransactions(account: BitcoinAccountIdentifier, signal?: AbortSignal) {
26+
const [trTransactions, nsTransactions] = await Promise.all([
27+
leatherApiClient.fetchBitcoinTransactions(account.taprootDescriptor, signal),
28+
leatherApiClient.fetchBitcoinTransactions(account.nativeSegwitDescriptor, signal),
29+
]);
30+
return [...trTransactions, ...nsTransactions];
1231
}
32+
33+
async function getConfirmedTransactions(account: BitcoinAccountIdentifier, signal?: AbortSignal) {
34+
const transactions = await getTotalTransactions(account, signal);
35+
return transactions.filter(tx => tx.confirmations > 0);
36+
}
37+
38+
async function getPendingTransactions(account: BitcoinAccountIdentifier, signal?: AbortSignal) {
39+
const transactions = await getTotalTransactions(account, signal);
40+
return transactions.filter(tx => !tx.confirmations);
41+
}
42+
1343
return {
14-
getOutboundTransactions,
44+
getTotalTransactions,
45+
getConfirmedTransactions,
46+
getPendingTransactions,
1547
};
1648
}

Diff for: packages/services/src/transactions/stacks-transactions.service.ts

+26
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,36 @@ import {
77
} from './stacks-transactions.utils';
88

99
export interface StacksTransactionsService {
10+
getTotalTransactions(address: string, signal?: AbortSignal): Promise<StacksTx[]>;
11+
getConfirmedTransactions(address: string, signal?: AbortSignal): Promise<StacksTx[]>;
1012
getPendingTransactions(address: string, signal?: AbortSignal): Promise<StacksTx[]>;
1113
}
1214

1315
export function createStacksTransactionsService(
1416
stacksApiClient: HiroStacksApiClient
1517
): StacksTransactionsService {
18+
/**
19+
* Gets total Stacks transactions (pending + confirmed) by address.
20+
*/
21+
async function getTotalTransactions(address: string, signal?: AbortSignal) {
22+
const [pendingTransactions, confirmedTransactions] = await Promise.all([
23+
getPendingTransactions(address, signal),
24+
getConfirmedTransactions(address, signal),
25+
]);
26+
return [...pendingTransactions, ...confirmedTransactions];
27+
}
28+
29+
/**
30+
* Gets confirmed Stacks transactions by address.
31+
*/
32+
async function getConfirmedTransactions(address: string, signal?: AbortSignal) {
33+
const confirmedTransactionsRes = await stacksApiClient.getAddressConfirmedTransactions(
34+
address,
35+
signal
36+
);
37+
return confirmedTransactionsRes.results;
38+
}
39+
1640
/**
1741
* Gets pending Stacks transactions from mempool by address.
1842
*/
@@ -27,6 +51,8 @@ export function createStacksTransactionsService(
2751
}
2852

2953
return {
54+
getTotalTransactions,
55+
getConfirmedTransactions,
3056
getPendingTransactions,
3157
};
3258
}

0 commit comments

Comments
 (0)