Skip to content

Commit

Permalink
feat: address monitor and btc tx notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
alexp3y committed Feb 4, 2025
1 parent 19ec3fe commit 56c4361
Show file tree
Hide file tree
Showing 14 changed files with 525 additions and 109 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@
"ts-node": "10.9.2",
"ts-unused-exports": "10.0.1",
"tsconfig-paths-webpack-plugin": "4.1.0",
"type-fest": "4.30.2",
"typescript": "5.4.5",
"vitest": "2.0.5",
"vm-browserify": "1.1.2",
Expand All @@ -370,8 +371,7 @@
"webpack-cli": "5.1.4",
"webpack-dev-server": "4.15.1",
"webpack-hot-middleware": "2.26.1",
"webpack-shell-plugin": "0.5.0",
"type-fest": "4.30.2"
"webpack-shell-plugin": "0.5.0"
},
"resolutions": {
"cross-spawn": "7.0.5",
Expand Down
200 changes: 100 additions & 100 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion scripts/generate-manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ const manifest = {
manifest_version: 3,
author: 'Leather Wallet, LLC',
description: 'Leather Bitcoin Wallet - Your Bitcoin Wallet for DeFi, NFTs, Ordinals, and dApps',
permissions: ['contextMenus', 'storage', 'unlimitedStorage'],
permissions: ['contextMenus', 'storage', 'unlimitedStorage', 'notifications'],
commands: {
_execute_browser_action: {
suggested_key: {
Expand Down
78 changes: 78 additions & 0 deletions src/app/features/address-monitor/use-monitorable-addresses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useMemo } from 'react';

import type { HDKey } from '@scure/bip32';
import type { P2Ret } from '@scure/btc-signer/payment';

import {
type SupportedPaymentType,
deriveAddressIndexZeroFromAccount,
getNativeSegwitPaymentFromAddressIndex,
getTaprootPaymentFromAddressIndex,
} from '@leather.io/bitcoin';
import type { BitcoinNetworkModes } from '@leather.io/models';
import { createNullArrayOfLength, isDefined } from '@leather.io/utils';

import { useCurrentAccountIndex } from '@app/store/accounts/account';
import { useGenerateNativeSegwitAccount } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
import { useGenerateTaprootAccount } from '@app/store/accounts/blockchain/bitcoin/taproot-account.hooks';
import { useStacksAccounts } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks';
import { useCurrentNetworkId } from '@app/store/networks/networks.selectors';
import type { MonitoredAddress } from '@background/monitors/address-monitor';

const paymentFnMap: Record<
SupportedPaymentType,
(keychain: HDKey, network: BitcoinNetworkModes) => P2Ret
> = {
p2tr: getTaprootPaymentFromAddressIndex,
p2wpkh: getNativeSegwitPaymentFromAddressIndex,
};

export function useMonitorableAddresses() {
const currentAccountIndex = useCurrentAccountIndex();
const currentNetworkId = useCurrentNetworkId();
const createNativeSegwitAccount = useGenerateNativeSegwitAccount();
const createTaprootAccount = useGenerateTaprootAccount();

const stacksAccounts = useStacksAccounts();

return useMemo(() => {
if (!stacksAccounts || !currentNetworkId) return;

const stacksAddresses = stacksAccounts.map(
account =>
({
accountIndex: account.index,
address: account.address,
chain: 'stacks',
isCurrent: account.index === currentAccountIndex,
}) as MonitoredAddress
);
const btcAddresses = createNullArrayOfLength(stacksAccounts.length).flatMap((_, index) =>
[createNativeSegwitAccount(index), createTaprootAccount(index)]
.filter(isDefined)
.map(account => {
const addressIndexKeychain = deriveAddressIndexZeroFromAccount(account.keychain);
if (account.type !== 'p2tr' && account.type !== 'p2wpkh') return undefined;
const payment = paymentFnMap[account.type](addressIndexKeychain, 'mainnet');
return {
accountIndex: index,
address: payment.address,
chain: 'bitcoin',
isCurrent: index === currentAccountIndex,
} as MonitoredAddress;
})
.filter(isDefined)
);
// if one address array is empty and the other not, we're in an intermediate state
return (stacksAddresses.length === 0 && btcAddresses.length > 0) ||
(btcAddresses.length === 0 && stacksAddresses.length > 0)
? undefined
: [...stacksAddresses, ...btcAddresses];
}, [
createNativeSegwitAccount,
createTaprootAccount,
stacksAccounts,
currentNetworkId,
currentAccountIndex,
]);
}
29 changes: 29 additions & 0 deletions src/app/features/address-monitor/use-sync-address-monitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useEffect, useRef } from 'react';

import isEqual from 'lodash.isequal';

import { logger } from '@shared/logger';
import { InternalMethods } from '@shared/message-types';
import { sendMessage } from '@shared/messages';

import { useMonitorableAddresses } from '@app/features/address-monitor/use-monitorable-addresses';
import type { MonitoredAddress } from '@background/monitors/address-monitor';

export function useSyncAddressMonitor() {
const addresses = useMonitorableAddresses();
const prevAddresses = useRef<MonitoredAddress[]>([]);

useEffect(() => {
if (addresses && !isEqual(addresses, prevAddresses.current)) {
prevAddresses.current = addresses;

logger.debug('Syncing Monitored Addresses: ', addresses);
sendMessage({
method: InternalMethods.AddressMonitorUpdated,
payload: {
addresses,
},
});
}
}, [addresses]);
}
3 changes: 2 additions & 1 deletion src/app/features/container/container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useOnWalletLock } from '@app/routes/hooks/use-on-wallet-lock';
import { useAppDispatch, useHasStateRehydrated } from '@app/store';
import { stxChainSlice } from '@app/store/chains/stx-chain.slice';

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

export function Container() {
Expand All @@ -28,7 +29,7 @@ export function Container() {
const dispatch = useAppDispatch();

const hasStateRehydrated = useHasStateRehydrated();

useSyncAddressMonitor();
useOnWalletLock(() => closeWindow());
useOnSignOut(() => closeWindow());
useRestoreFormState();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const selectCurrentNetworkNativeSegwitAccountBuilder = createSelector(
nativeSegwitKeychains[bitcoinNetworkToNetworkMode(network.chain.bitcoin.bitcoinNetwork)]
);

function useNativeSegwitAccountBuilder() {
export function useGenerateNativeSegwitAccount() {
return useSelector(selectCurrentNetworkNativeSegwitAccountBuilder);
}

Expand All @@ -72,7 +72,7 @@ export function useNativeSegwitNetworkSigners() {
}

export function useNativeSegwitSigner(accountIndex: number) {
const account = useNativeSegwitAccountBuilder()(accountIndex);
const account = useGenerateNativeSegwitAccount()(accountIndex);
const extendedPublicKeyVersions = useBitcoinExtendedPublicKeyVersions();

return useMemo(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ const selectCurrentTaprootAccount = createSelector(
(taprootKeychain, accountIndex) => taprootKeychain(accountIndex)
);

export function useGenerateTaprootAccount() {
const generateTaprootAccount = useSelector(selectCurrentNetworkTaprootAccountBuilder);
return useMemo(() => generateTaprootAccount, [generateTaprootAccount]);
}

export function useTaprootAccount(accountIndex: number) {
const generateTaprootAccount = useSelector(selectCurrentNetworkTaprootAccountBuilder);
return useMemo(
Expand Down
5 changes: 5 additions & 0 deletions src/background/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
isLegacyMessage,
} from './messaging/legacy/legacy-external-message-handler';
import { rpcMessageHandler } from './messaging/rpc-message-handler';
import { initAddressMonitor } from './monitors/address-monitor';

initContextMenuActions();
warnUsersAboutDevToolsDangers();
Expand Down Expand Up @@ -59,3 +60,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
// Listener fn must return `true` to indicate the response will be async
return true;
});

initAddressMonitor().catch(e => {
logger.error('Unable to Initialise Address Monitor: ', e);
});
14 changes: 14 additions & 0 deletions src/background/messaging/internal-methods/message-handler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { logger } from '@shared/logger';
import { InternalMethods } from '@shared/message-types';
import { BackgroundMessages } from '@shared/messages';

import { syncAddressMonitor } from '@background/monitors/address-monitor';

function validateMessagesAreFromExtension(sender: chrome.runtime.MessageSender) {
// Only respond to internal messages from our UI, not content scripts in other applications
return sender.url?.startsWith(chrome.runtime.getURL(''));
Expand Down Expand Up @@ -28,5 +31,16 @@ export async function internalBackgroundMessageHandler(
return;
}
logger.debug('Internal message', message);

switch (message.method) {
case InternalMethods.AddressMonitorUpdated:
await syncAddressMonitor(message.payload.addresses);
break;
}

if (message.method.includes('bitcoinKeys/signOut')) {
await syncAddressMonitor([]);
}

sendResponse();
}
54 changes: 54 additions & 0 deletions src/background/monitors/address-monitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/* eslint-disable no-console */
import { z } from 'zod';

import { BitcoinTransactionMonitor } from './address-monitors/bitcoin-transaction-monitor';

const monitoredAddressSchema = z.object({
chain: z.enum(['bitcoin', 'stacks']),
accountIndex: z.number(),
isCurrent: z.boolean(),
address: z.string(),
});

export type MonitoredAddress = z.infer<typeof monitoredAddressSchema>;

export interface AddressMonitor {
syncAddresses(addresses: MonitoredAddress[]): void;
}

class AddressMonitorContainer {
private _monitors: AddressMonitor[] = [];

init(addresses: MonitoredAddress[]) {
this._monitors = [new BitcoinTransactionMonitor(addresses)];
}
sync(addresses: MonitoredAddress[]) {
this._monitors.forEach(monitor => monitor.syncAddresses(addresses));
}
}

const monitorContainer = new AddressMonitorContainer();

export async function initAddressMonitor() {
const addresses = await readMonitoredAddressStore();
monitorContainer.init(addresses);
}

export async function syncAddressMonitor(addresses: MonitoredAddress[]) {
await writeMonitoredAddressStore(addresses);
monitorContainer.sync(addresses);
}

const ADDRESS_MONITOR_STORE = 'addressMonitorStore';

async function readMonitoredAddressStore() {
const result = await chrome.storage.local.get(ADDRESS_MONITOR_STORE);
const addresses = result[ADDRESS_MONITOR_STORE] || [];
return addresses;
}

async function writeMonitoredAddressStore(addresses: MonitoredAddress[]) {
await chrome.storage.local.set({
[ADDRESS_MONITOR_STORE]: addresses,
});
}
Loading

0 comments on commit 56c4361

Please sign in to comment.