Skip to content

Commit aa3d64a

Browse files
committed
feat: address monitor and btc tx notifications
1 parent d84b503 commit aa3d64a

File tree

15 files changed

+937
-562
lines changed

15 files changed

+937
-562
lines changed

pnpm-lock.yaml

Lines changed: 414 additions & 555 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/generate-manifest.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ const manifest = {
6969
manifest_version: 3,
7070
author: 'Leather Wallet, LLC',
7171
description: 'Leather Bitcoin Wallet - Your Bitcoin Wallet for DeFi, NFTs, Ordinals, and dApps',
72-
permissions: ['contextMenus', 'storage', 'unlimitedStorage'],
72+
permissions: ['contextMenus', 'storage', 'unlimitedStorage', 'notifications'],
7373
commands: {
7474
_execute_browser_action: {
7575
suggested_key: {
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { useMemo } from 'react';
2+
3+
import type { HDKey } from '@scure/bip32';
4+
import type { P2Ret } from '@scure/btc-signer/payment';
5+
6+
import {
7+
type SupportedPaymentType,
8+
deriveAddressIndexZeroFromAccount,
9+
getNativeSegwitPaymentFromAddressIndex,
10+
getTaprootPaymentFromAddressIndex,
11+
} from '@leather.io/bitcoin';
12+
import type { BitcoinNetworkModes } from '@leather.io/models';
13+
import { createNullArrayOfLength, isDefined } from '@leather.io/utils';
14+
15+
import { useCurrentAccountIndex } from '@app/store/accounts/account';
16+
import { useGenerateNativeSegwitAccount } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
17+
import { useGenerateTaprootAccount } from '@app/store/accounts/blockchain/bitcoin/taproot-account.hooks';
18+
import { useStacksAccounts } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks';
19+
import { useCurrentNetworkId } from '@app/store/networks/networks.selectors';
20+
import type { MonitoredAddress } from '@background/monitors/address-monitor';
21+
22+
const paymentFnMap: Record<
23+
SupportedPaymentType,
24+
(keychain: HDKey, network: BitcoinNetworkModes) => P2Ret
25+
> = {
26+
p2tr: getTaprootPaymentFromAddressIndex,
27+
p2wpkh: getNativeSegwitPaymentFromAddressIndex,
28+
};
29+
30+
export function useMonitorableAddresses() {
31+
const currentAccountIndex = useCurrentAccountIndex();
32+
const currentNetworkId = useCurrentNetworkId();
33+
const createNativeSegwitAccount = useGenerateNativeSegwitAccount();
34+
const createTaprootAccount = useGenerateTaprootAccount();
35+
36+
const stacksAccounts = useStacksAccounts();
37+
38+
return useMemo(() => {
39+
if (!stacksAccounts || !currentNetworkId) return;
40+
41+
const stacksAddresses = stacksAccounts.map(
42+
account =>
43+
({
44+
accountIndex: account.index,
45+
address: account.address,
46+
chain: 'stacks',
47+
isCurrent: account.index === currentAccountIndex,
48+
}) satisfies MonitoredAddress
49+
);
50+
const btcAddresses = createNullArrayOfLength(stacksAccounts.length).flatMap((_, index) =>
51+
[createNativeSegwitAccount(index), createTaprootAccount(index)]
52+
.filter(isDefined)
53+
.map(account => {
54+
const addressIndexKeychain = deriveAddressIndexZeroFromAccount(account.keychain);
55+
if (account.type !== 'p2tr' && account.type !== 'p2wpkh') return undefined;
56+
const payment = paymentFnMap[account.type](addressIndexKeychain, 'mainnet');
57+
if (!payment.address) return undefined;
58+
return {
59+
accountIndex: index,
60+
address: payment.address,
61+
chain: 'bitcoin',
62+
isCurrent: index === currentAccountIndex,
63+
} satisfies MonitoredAddress;
64+
})
65+
.filter(isDefined)
66+
);
67+
// if one address array is empty and the other not, we're in an intermediate state
68+
return (stacksAddresses.length === 0 && btcAddresses.length > 0) ||
69+
(btcAddresses.length === 0 && stacksAddresses.length > 0)
70+
? undefined
71+
: [...stacksAddresses, ...btcAddresses];
72+
}, [
73+
createNativeSegwitAccount,
74+
createTaprootAccount,
75+
stacksAccounts,
76+
currentNetworkId,
77+
currentAccountIndex,
78+
]);
79+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { useEffect, useRef } from 'react';
2+
3+
import isEqual from 'lodash.isequal';
4+
5+
import { logger } from '@shared/logger';
6+
import { InternalMethods } from '@shared/message-types';
7+
import { sendMessage } from '@shared/messages';
8+
9+
import { useMonitorableAddresses } from '@app/features/address-monitor/use-monitorable-addresses';
10+
import type { MonitoredAddress } from '@background/monitors/address-monitor';
11+
12+
export function useSyncAddressMonitor() {
13+
const addresses = useMonitorableAddresses();
14+
const prevAddresses = useRef<MonitoredAddress[]>([]);
15+
16+
useEffect(() => {
17+
if (addresses && !isEqual(addresses, prevAddresses.current)) {
18+
prevAddresses.current = addresses;
19+
20+
logger.debug('Syncing Monitored Addresses: ', addresses);
21+
sendMessage({
22+
method: InternalMethods.AddressMonitorUpdated,
23+
payload: {
24+
addresses,
25+
},
26+
});
27+
}
28+
}, [addresses]);
29+
}

src/app/features/container/container.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { useOnWalletLock } from '@app/routes/hooks/use-on-wallet-lock';
1919
import { useAppDispatch, useHasStateRehydrated } from '@app/store';
2020
import { stxChainSlice } from '@app/store/chains/stx-chain.slice';
2121

22+
import { useSyncAddressMonitor } from '../address-monitor/use-sync-address-monitor';
2223
import { useRestoreFormState } from '../popup-send-form-restoration/use-restore-form-state';
2324

2425
export function Container() {
@@ -28,7 +29,7 @@ export function Container() {
2829
const dispatch = useAppDispatch();
2930

3031
const hasStateRehydrated = useHasStateRehydrated();
31-
32+
useSyncAddressMonitor();
3233
useOnWalletLock(() => closeWindow());
3334
useOnSignOut(() => closeWindow());
3435
useRestoreFormState();

src/app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const selectCurrentNetworkNativeSegwitAccountBuilder = createSelector(
4545
nativeSegwitKeychains[bitcoinNetworkToNetworkMode(network.chain.bitcoin.bitcoinNetwork)]
4646
);
4747

48-
function useNativeSegwitAccountBuilder() {
48+
export function useGenerateNativeSegwitAccount() {
4949
return useSelector(selectCurrentNetworkNativeSegwitAccountBuilder);
5050
}
5151

@@ -72,7 +72,7 @@ export function useNativeSegwitNetworkSigners() {
7272
}
7373

7474
export function useNativeSegwitSigner(accountIndex: number) {
75-
const account = useNativeSegwitAccountBuilder()(accountIndex);
75+
const account = useGenerateNativeSegwitAccount()(accountIndex);
7676
const extendedPublicKeyVersions = useBitcoinExtendedPublicKeyVersions();
7777

7878
return useMemo(() => {

src/app/store/accounts/blockchain/bitcoin/taproot-account.hooks.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ const selectCurrentTaprootAccount = createSelector(
4545
(taprootKeychain, accountIndex) => taprootKeychain(accountIndex)
4646
);
4747

48+
export function useGenerateTaprootAccount() {
49+
return useSelector(selectCurrentNetworkTaprootAccountBuilder);
50+
}
51+
4852
export function useTaprootAccount(accountIndex: number) {
4953
const generateTaprootAccount = useSelector(selectCurrentNetworkTaprootAccountBuilder);
5054
return useMemo(

src/background/background.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
isLegacyMessage,
1414
} from './messaging/legacy/legacy-external-message-handler';
1515
import { rpcMessageHandler } from './messaging/rpc-message-handler';
16+
import { initAddressMonitor } from './monitors/address-monitor';
1617

1718
initContextMenuActions();
1819
warnUsersAboutDevToolsDangers();
@@ -59,3 +60,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
5960
// Listener fn must return `true` to indicate the response will be async
6061
return true;
6162
});
63+
64+
initAddressMonitor().catch(e => {
65+
logger.error('Unable to Initialise Address Monitor: ', e);
66+
});

src/background/messaging/internal-methods/message-handler.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { logger } from '@shared/logger';
2+
import { InternalMethods } from '@shared/message-types';
23
import { BackgroundMessages } from '@shared/messages';
34

5+
import { syncAddressMonitor } from '@background/monitors/address-monitor';
6+
47
function validateMessagesAreFromExtension(sender: chrome.runtime.MessageSender) {
58
// Only respond to internal messages from our UI, not content scripts in other applications
69
return sender.url?.startsWith(chrome.runtime.getURL(''));
@@ -28,5 +31,16 @@ export async function internalBackgroundMessageHandler(
2831
return;
2932
}
3033
logger.debug('Internal message', message);
34+
35+
switch (message.method) {
36+
case InternalMethods.AddressMonitorUpdated:
37+
await syncAddressMonitor(message.payload.addresses);
38+
break;
39+
}
40+
41+
if (message.method.includes('bitcoinKeys/signOut')) {
42+
await syncAddressMonitor([]);
43+
}
44+
3145
sendResponse();
3246
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/* eslint-disable no-console */
2+
import { z } from 'zod';
3+
4+
import { createBitcoinTransactionMonitor } from './address-monitors/bitcoin-transaction-monitor';
5+
6+
const monitoredAddressSchema = z.object({
7+
chain: z.enum(['bitcoin', 'stacks']),
8+
accountIndex: z.number(),
9+
isCurrent: z.boolean(),
10+
address: z.string(),
11+
});
12+
13+
export type MonitoredAddress = z.infer<typeof monitoredAddressSchema>;
14+
15+
export interface AddressMonitor {
16+
syncAddresses(addresses: MonitoredAddress[]): void;
17+
}
18+
19+
const monitors: AddressMonitor[] = [];
20+
21+
export async function initAddressMonitor() {
22+
const addresses = await readMonitoredAddressStore();
23+
monitors.push(createBitcoinTransactionMonitor(addresses));
24+
}
25+
26+
export async function syncAddressMonitor(addresses: MonitoredAddress[]) {
27+
await writeMonitoredAddressStore(addresses);
28+
monitors.forEach(monitor => monitor.syncAddresses(addresses));
29+
}
30+
31+
const ADDRESS_MONITOR_STORE = 'addressMonitorStore';
32+
33+
async function readMonitoredAddressStore() {
34+
const result = await chrome.storage.local.get(ADDRESS_MONITOR_STORE);
35+
const addresses = result[ADDRESS_MONITOR_STORE] || [];
36+
return addresses;
37+
}
38+
39+
async function writeMonitoredAddressStore(addresses: MonitoredAddress[]) {
40+
await chrome.storage.local.set({
41+
[ADDRESS_MONITOR_STORE]: addresses,
42+
});
43+
}

0 commit comments

Comments
 (0)