Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: address monitor and btc tx notifications #6095

Merged
merged 1 commit into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
969 changes: 414 additions & 555 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
79 changes: 79 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,79 @@
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,
}) satisfies 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');
if (!payment.address) return undefined;
return {
accountIndex: index,
address: payment.address,
chain: 'bitcoin',
isCurrent: index === currentAccountIndex,
} satisfies 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,10 @@ const selectCurrentTaprootAccount = createSelector(
(taprootKeychain, accountIndex) => taprootKeychain(accountIndex)
);

export function useGenerateTaprootAccount() {
return useSelector(selectCurrentNetworkTaprootAccountBuilder);
}

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

import { createBitcoinTransactionMonitor } 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;
}

const monitors: AddressMonitor[] = [];

export async function initAddressMonitor() {
const addresses = await readMonitoredAddressStore();
monitors.push(createBitcoinTransactionMonitor(addresses));
}

export async function syncAddressMonitor(addresses: MonitoredAddress[]) {
await writeMonitoredAddressStore(addresses);
monitors.forEach(monitor => monitor.syncAddresses(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
Loading