Skip to content

Commit

Permalink
feat: activity service
Browse files Browse the repository at this point in the history
  • Loading branch information
alexp3y committed Feb 7, 2025
1 parent 3ccdf24 commit 191c6ec
Show file tree
Hide file tree
Showing 9 changed files with 237 additions and 19 deletions.
20 changes: 20 additions & 0 deletions packages/constants/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type {
AccountDisplayPreferenceInfo,
BitcoinUnit,
BitcoinUnitInfo,
BtcCryptoAssetInfo,
StxCryptoAssetInfo,
} from '@leather.io/models';

export const gaiaUrl = 'https://hub.blockstack.org';
Expand Down Expand Up @@ -112,3 +114,21 @@ export const accountDisplayPreferencesKeyedByType: Record<
name: 'Stacks address',
},
};

export const btcCryptoAsset: BtcCryptoAssetInfo = {
chain: 'bitcoin',
protocol: 'nativeBtc',
symbol: 'BTC',
category: 'fungible',
decimals: 8,
hasMemo: false,
};

export const stxCryptoAsset: StxCryptoAssetInfo = {
chain: 'stacks',
protocol: 'nativeStx',
symbol: 'STX',
category: 'fungible',
decimals: 6,
hasMemo: false,
};
42 changes: 42 additions & 0 deletions packages/models/src/activity.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { CryptoAssetInfo } from './crypto-assets/crypto-asset-info.model';
import { Money } from './money.model';

export const ActivityTypes = {
tokenTransfer: 'tokenTransfer',
contractCall: 'contractCall',
};
export type ActivityType = keyof typeof ActivityTypes;

export interface BaseActivity {
readonly type: ActivityType;
readonly txid: string;
readonly timestamp: number;
readonly status: 'pending' | 'success' | 'failed';
}

export const TokenTransferDirections = {
outbound: 'outbound',
inbound: 'inbound',
};
export type TokenTransferDirection = keyof typeof TokenTransferDirections;

export interface TokenTransferActivity extends BaseActivity {
readonly type: 'tokenTransfer';
readonly asset: CryptoAssetInfo;
readonly direction: TokenTransferDirection;
readonly amount: {
crypto: Money;
fiat: Money;
};
}

export interface ContractCallActivity extends BaseActivity {
readonly type: 'contractCall';
}

export type Activity = TokenTransferActivity | ContractCallActivity;

// Token
// Collectible
// Swap
// Smart Contract Activity
1 change: 1 addition & 0 deletions packages/models/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ export * from './settings.model';
export * from './transactions/bitcoin-transaction.model';
export * from './transactions/stacks-transaction.model';
export * from './utxo.model';
export * from './activity.model';
40 changes: 40 additions & 0 deletions packages/services/src/activity/activity.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Activity } from '@leather.io/models';
import { isDefined } from '@leather.io/utils';

import { UnifiedAccountIdentifier } from '../shared/bitcoin.types';
import { BitcoinTransactionsService } from '../transactions/bitcoin-transactions.service';
import { StacksTransactionsService } from '../transactions/stacks-transactions.service';
import {
mapBitcoinTxToActivity,
mapStacksTxToActivity,
sortActivityByTimestampDesc,
} from './activity.utils';

export interface ActivityService {
getAccountActivity(account: UnifiedAccountIdentifier, signal?: AbortSignal): Promise<Activity[]>;
}

export function createActivityService(
stacksTransactionsService: StacksTransactionsService,
bitcoinTransactionsService: BitcoinTransactionsService
): ActivityService {
/*
* Gets unified activity list sorted by timestamp
*/
async function getAccountActivity(account: UnifiedAccountIdentifier, signal?: AbortSignal) {
const [stacksTransactions, bitcoinTransactions] = await Promise.all([
stacksTransactionsService.getTotalTransactions(account.stxAddress, signal),
bitcoinTransactionsService.getTotalTransactions(account, signal),
]);
return [
...stacksTransactions.map(mapStacksTxToActivity),
...bitcoinTransactions.map(mapBitcoinTxToActivity),
]
.filter(isDefined)
.sort(sortActivityByTimestampDesc);
}

return {
getAccountActivity,
};
}
50 changes: 50 additions & 0 deletions packages/services/src/activity/activity.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { btcCryptoAsset, stxCryptoAsset } from '@leather.io/constants';
import { Activity, StacksTx, TokenTransferActivity } from '@leather.io/models';
import { createMoney } from '@leather.io/utils';

import { LeatherApiBitcoinTransaction } from '../infrastructure/api/leather/leather-api.client';

export function mapStacksTxToActivity(stacksTx: StacksTx): Activity | undefined {
switch (stacksTx.tx_type) {
case 'token_transfer':
return mapStacksTxToTransferActivity(stacksTx);
default:
return;
}
}

function mapStacksTxToTransferActivity(stacksTx: StacksTx): TokenTransferActivity {
const stxTransferActivity: TokenTransferActivity = {
type: 'tokenTransfer',
asset: stxCryptoAsset,
direction: 'outbound',
amount: {
crypto: createMoney(0, 'STX'),
fiat: createMoney(0, 'USD'),
},
txid: stacksTx.tx_id,
timestamp: 0,
status: 'success',
};
return stxTransferActivity;
}

export function mapBitcoinTxToActivity(bitcoinTx: LeatherApiBitcoinTransaction): Activity {
const btcTransferActivity: TokenTransferActivity = {
type: 'tokenTransfer',
asset: btcCryptoAsset,
txid: bitcoinTx.txid,
direction: 'outbound',
amount: {
crypto: createMoney(0, 'BTC'),
fiat: createMoney(0, 'USD'),
},
timestamp: 0,
status: 'success',
};
return btcTransferActivity;
}

export function sortActivityByTimestampDesc(a: Activity, b: Activity) {
return b.timestamp - a.timestamp;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,23 @@ const leatherApiUtxoSchema = z.object({
vout: z.number(),
});

const leatherApiTxVinLightSchema = z.object({
const leatherApiBitcoinTransactionVinSchema = z.object({
value: z.string(),
n: z.number(),
addresses: z.array(z.string()),
isAddress: z.boolean(),
isOwn: z.boolean(),
});

const leatherApiTxVoutLightSchema = z.object({
const leatherApiBitcoinTransactionVoutSchema = z.object({
value: z.string(),
n: z.number(),
spent: z.boolean(),
addresses: z.array(z.string()),
isAddress: z.boolean(),
isOwn: z.boolean(),
});
const leatherApiTxLightSchema = z.object({
const leatherApiBitcoinTransactionSchema = z.object({
txid: z.string(),
blockHash: z.string().optional(),
blockHeight: z.number(),
Expand All @@ -40,8 +40,8 @@ const leatherApiTxLightSchema = z.object({
value: z.string(),
valueIn: z.string(),
fees: z.string(),
vin: leatherApiTxVinLightSchema,
vout: leatherApiTxVoutLightSchema,
vin: leatherApiBitcoinTransactionVinSchema,
vout: leatherApiBitcoinTransactionVoutSchema,
});

const leatherApiFiatExchangeRatesSchema = z.object({
Expand All @@ -67,15 +67,18 @@ const mockFiatExchangeRatesResponse = {
};

export type LeatherApiUtxo = z.infer<typeof leatherApiUtxoSchema>;
export type LeatherApiTxLight = z.infer<typeof leatherApiTxLightSchema>;
export type LeatherApiBitcoinTransaction = z.infer<typeof leatherApiBitcoinTransactionSchema>;
export interface LeatherApiExchangeRates {
[symbol: string]: number;
}

export interface LeatherApiClient {
fetchFiatExchangeRates(signal?: AbortSignal): Promise<LeatherApiExchangeRates>;
fetchUtxos(descriptor: string, signal?: AbortSignal): Promise<LeatherApiUtxo[]>;
fetchTxs(descriptor: string, signal?: AbortSignal): Promise<LeatherApiTxLight[]>;
fetchBitcoinTransactions(
descriptor: string,
signal?: AbortSignal
): Promise<LeatherApiBitcoinTransaction[]>;
}

export function createLeatherApiClient(
Expand Down Expand Up @@ -110,16 +113,16 @@ export function createLeatherApiClient(
);
}

async function fetchTxs(descriptor: string, signal?: AbortSignal) {
async function fetchBitcoinTransactions(descriptor: string, signal?: AbortSignal) {
const network = settingsService.getSettings().network.chain.bitcoin.bitcoinNetwork;
return await cacheService.fetchWithCache(
['leather-txs', network, descriptor],
['leather-bitcoin-transactions', network, descriptor],
async () => {
const res = await axios.get<LeatherApiUtxo[]>(
const res = await axios.get<LeatherApiBitcoinTransaction[]>(
`https://leather-bitcoin-balances.wallet-6d1.workers.dev/${network}/${descriptor}/txs`,
{ signal }
);
return z.array(leatherApiTxLightSchema).parse(res.data);
return z.array(leatherApiBitcoinTransactionSchema).parse(res.data);
},
{ ttl: HttpCacheTimeMs.fiveSeconds }
);
Expand All @@ -128,6 +131,6 @@ export function createLeatherApiClient(
return {
fetchFiatExchangeRates,
fetchUtxos,
fetchTxs,
fetchBitcoinTransactions,
};
}
4 changes: 4 additions & 0 deletions packages/services/src/shared/bitcoin.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ export interface BitcoinAccountServiceRequest {
account: BitcoinAccountIdentifier;
unprotectedUtxos: UtxoId[];
}

export interface UnifiedAccountIdentifier extends BitcoinAccountIdentifier {
stxAddress: string;
}
46 changes: 39 additions & 7 deletions packages/services/src/transactions/bitcoin-transactions.service.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,48 @@
import {
LeatherApiBitcoinTransaction,
LeatherApiClient,
} from '../infrastructure/api/leather/leather-api.client';
import { BitcoinAccountIdentifier } from '../shared/bitcoin.types';

export interface BitcoinTransactionsService {
getOutboundTransactions(account: BitcoinAccountIdentifier, signal?: AbortSignal): Promise<any[]>;
getTotalTransactions(
account: BitcoinAccountIdentifier,
signal?: AbortSignal
): Promise<LeatherApiBitcoinTransaction[]>;
getConfirmedTransactions(
account: BitcoinAccountIdentifier,
signal?: AbortSignal
): Promise<LeatherApiBitcoinTransaction[]>;
getPendingTransactions(
account: BitcoinAccountIdentifier,
signal?: AbortSignal
): Promise<LeatherApiBitcoinTransaction[]>;
}

// TODO: WIP - Requires new Leather API Tx endpoint leveraging xpub lookups
export function createBitcoinTransactionsService(): BitcoinTransactionsService {
// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars
async function getOutboundTransactions(account: BitcoinAccountIdentifier, signal?: AbortSignal) {
return [];
export function createBitcoinTransactionsService(
leatherApiClient: LeatherApiClient
): BitcoinTransactionsService {
async function getTotalTransactions(account: BitcoinAccountIdentifier, signal?: AbortSignal) {
const [trTransactions, nsTransactions] = await Promise.all([
leatherApiClient.fetchBitcoinTransactions(account.taprootDescriptor, signal),
leatherApiClient.fetchBitcoinTransactions(account.nativeSegwitDescriptor, signal),
]);
return [...trTransactions, ...nsTransactions];
}

async function getConfirmedTransactions(account: BitcoinAccountIdentifier, signal?: AbortSignal) {
const transactions = await getTotalTransactions(account, signal);
return transactions.filter(tx => tx.confirmations > 0);
}

async function getPendingTransactions(account: BitcoinAccountIdentifier, signal?: AbortSignal) {
const transactions = await getTotalTransactions(account, signal);
return transactions.filter(tx => !tx.confirmations);
}

return {
getOutboundTransactions,
getTotalTransactions,
getConfirmedTransactions,
getPendingTransactions,
};
}
26 changes: 26 additions & 0 deletions packages/services/src/transactions/stacks-transactions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,36 @@ import {
} from './stacks-transactions.utils';

export interface StacksTransactionsService {
getTotalTransactions(address: string, signal?: AbortSignal): Promise<StacksTx[]>;
getConfirmedTransactions(address: string, signal?: AbortSignal): Promise<StacksTx[]>;
getPendingTransactions(address: string, signal?: AbortSignal): Promise<StacksTx[]>;
}

export function createStacksTransactionsService(
stacksApiClient: HiroStacksApiClient
): StacksTransactionsService {
/**
* Gets total Stacks transactions (pending + confirmed) by address.
*/
async function getTotalTransactions(address: string, signal?: AbortSignal) {
const [pendingTransactions, confirmedTransactions] = await Promise.all([
getPendingTransactions(address, signal),
getConfirmedTransactions(address, signal),
]);
return [...pendingTransactions, ...confirmedTransactions];
}

/**
* Gets confirmed Stacks transactions by address.
*/
async function getConfirmedTransactions(address: string, signal?: AbortSignal) {
const confirmedTransactionsRes = await stacksApiClient.getAddressConfirmedTransactions(
address,
signal
);
return confirmedTransactionsRes.results;
}

/**
* Gets pending Stacks transactions from mempool by address.
*/
Expand All @@ -27,6 +51,8 @@ export function createStacksTransactionsService(
}

return {
getTotalTransactions,
getConfirmedTransactions,
getPendingTransactions,
};
}

0 comments on commit 191c6ec

Please sign in to comment.