Skip to content

Commit

Permalink
feat: support bitcoin ledger, closes #2893
Browse files Browse the repository at this point in the history
  • Loading branch information
kyranjamie committed Nov 28, 2023
1 parent b7a34a9 commit 8a81f58
Show file tree
Hide file tree
Showing 114 changed files with 1,532 additions and 896 deletions.
3 changes: 1 addition & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
{
"prettier.documentSelectors": ["src/**/*.{ts,tsx}", "*.{js,json}"],
"vitest.include": ["src/**/*.spec.ts"],
"githubPullRequests.ignoredPullRequestBranches": ["dev"],
"editor.folding": false
"githubPullRequests.ignoredPullRequestBranches": ["dev"]
}
35 changes: 6 additions & 29 deletions src/app/common/authentication/use-finish-auth-request.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useCallback } from 'react';

import { bytesToHex } from '@stacks/common';
import {
createWalletGaiaConfig,
getOrCreateWalletConfig,
Expand All @@ -16,13 +15,13 @@ import { useAuthRequestParams } from '@app/common/hooks/auth/use-auth-request-pa
import { useOnboardingState } from '@app/common/hooks/auth/use-onboarding-state';
import { useKeyActions } from '@app/common/hooks/use-key-actions';
import { useWalletType } from '@app/common/use-wallet-type';
import { useNativeSegwitNetworkSigners } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
import { useTaprootNetworkSigners } from '@app/store/accounts/blockchain/bitcoin/taproot-account.hooks';
import {
useLegacyStacksWallet,
useStacksAccounts,
} from '@app/store/accounts/blockchain/stacks/stacks-account.hooks';

import { useGetLegacyAuthBitcoinAddresses } from './use-legacy-auth-bitcoin-addresses';

export function useFinishAuthRequest() {
const { decodedAuthRequest, authRequest, appIcon, appName } = useOnboardingState();
const keyActions = useKeyActions();
Expand All @@ -36,8 +35,7 @@ export function useFinishAuthRequest() {
// TODO: It would be good to separate out finishing auth by the wallet vs an app
// so that the additional data we provide apps can be removed from our onboarding.
// Currently, these create errors unless early returns are used in the keychain code.
const deriveAllNativeSegWitNetworkSigners = useNativeSegwitNetworkSigners();
const deriveAllTaprootNetworkSigners = useTaprootNetworkSigners();
const getLegacyAuthBitcoinData = useGetLegacyAuthBitcoinAddresses();

return useCallback(
async (accountIndex: number) => {
Expand Down Expand Up @@ -75,35 +73,15 @@ export function useFinishAuthRequest() {
},
});

const taprootAccount = deriveAllTaprootNetworkSigners(accountIndex);
const nativeSegwitAccount = deriveAllNativeSegWitNetworkSigners(accountIndex);

const authResponse = await makeAuthResponse({
gaiaHubUrl: gaiaUrl,
appDomain: appURL.origin,
transitPublicKey: decodedAuthRequest.public_keys[0],
scopes: decodedAuthRequest.scopes,
account: account,
additionalData: {
btcAddress: {
p2tr: {
mainnet: taprootAccount.mainnet.payment.address,
testnet: taprootAccount.testnet.payment.address,
regtest: taprootAccount.regtest.payment.address,
signet: taprootAccount.signet.payment.address,
},
p2wpkh: {
mainnet: nativeSegwitAccount.mainnet.payment.address,
testnet: nativeSegwitAccount.testnet.payment.address,
regtest: nativeSegwitAccount.regtest.payment.address,
signet: nativeSegwitAccount.signet.payment.address,
},
},
btcPublicKey: {
p2tr: bytesToHex(taprootAccount.mainnet.keychain.publicKey!),
p2wpkh: bytesToHex(nativeSegwitAccount.mainnet.keychain.publicKey!),
},
walletProvider: 'hiro-wallet',
...getLegacyAuthBitcoinData(accountIndex),
walletProvider: 'leather',
},
});
keyActions.switchAccount(accountIndex);
Expand All @@ -127,8 +105,7 @@ export function useFinishAuthRequest() {
wallet,
appIcon,
appName,
deriveAllTaprootNetworkSigners,
deriveAllNativeSegWitNetworkSigners,
getLegacyAuthBitcoinData,
keyActions,
]
);
Expand Down
35 changes: 35 additions & 0 deletions src/app/common/authentication/use-legacy-auth-bitcoin-addresses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { bytesToHex } from '@noble/hashes/utils';

import { useNativeSegwitNetworkSigners } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
import { useTaprootNetworkSigners } from '@app/store/accounts/blockchain/bitcoin/taproot-account.hooks';

export function useGetLegacyAuthBitcoinAddresses() {
const deriveAllNativeSegWitNetworkSigners = useNativeSegwitNetworkSigners();
const deriveAllTaprootNetworkSigners = useTaprootNetworkSigners();

return (accountIndex: number) => {
const taprootAccount = deriveAllTaprootNetworkSigners(accountIndex);
const nativeSegwitAccount = deriveAllNativeSegWitNetworkSigners(accountIndex);

return {
btcAddress: {
p2tr: {
mainnet: taprootAccount?.mainnet?.payment.address,
testnet: taprootAccount?.testnet?.payment.address,
regtest: taprootAccount?.regtest?.payment.address,
signet: taprootAccount?.signet?.payment.address,
},
p2wpkh: {
mainnet: nativeSegwitAccount?.mainnet?.payment.address,
testnet: nativeSegwitAccount?.testnet?.payment.address,
regtest: nativeSegwitAccount?.regtest?.payment.address,
signet: nativeSegwitAccount?.signet?.payment.address,
},
},
btcPublicKey: {
p2tr: bytesToHex(taprootAccount?.mainnet?.keychain.publicKey!),
p2wpkh: bytesToHex(nativeSegwitAccount?.mainnet?.keychain.publicKey!),
},
};
};
}
6 changes: 3 additions & 3 deletions src/app/common/hooks/use-explorer-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ import { openInNewTab } from '../utils/open-in-new-tab';
export interface HandleOpenTxLinkArgs {
blockchain: Blockchains;
suffix?: string;
txId: string;
txid: string;
}
export function useExplorerLink() {
const { mode } = useCurrentNetworkState();
const handleOpenTxLink = useCallback(
({ blockchain, suffix, txId }: HandleOpenTxLinkArgs) =>
openInNewTab(makeTxExplorerLink({ blockchain, mode, suffix, txId })),
({ blockchain, suffix, txid }: HandleOpenTxLinkArgs) =>
openInNewTab(makeTxExplorerLink({ blockchain, mode, suffix, txid })),
[mode]
);

Expand Down
2 changes: 1 addition & 1 deletion src/app/common/hooks/use-location-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import get from 'lodash.get';

import { isUndefined } from '@shared/utils';

type LocationState = string | undefined | number | Location;
type LocationState = string | boolean | undefined | number | Location;

export function useLocationState<T extends LocationState>(propName: string): T;
export function useLocationState<T extends LocationState>(
Expand Down
1 change: 1 addition & 0 deletions src/app/common/hooks/use-submit-stx-transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function useSubmitTransactionCallback({ loadingKey }: UseSubmitTransactio
txId: safelyFormatHexTxid(response.txid),
});
toast.success('Transaction submitted!');

void analytics.track('broadcast_transaction', { symbol: 'stx' });
onSuccess(safelyFormatHexTxid(response.txid));
setIsIdle();
Expand Down
27 changes: 11 additions & 16 deletions src/app/common/psbt/use-psbt-request-params.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';

import { ensureArray, undefinedIfLengthZero } from '@shared/utils';

import { useRejectIfLedgerWallet } from '@app/common/rpc-helpers';

import { useDefaultRequestParams } from '../hooks/use-default-request-search-params';
import { initialSearchParams } from '../initial-search-params';
import { getPsbtPayloadFromToken } from './requests';

export function usePsbtRequestSearchParams() {
const [searchParams] = useSearchParams();
const { origin, tabId } = useDefaultRequestParams();
const requestToken = searchParams.get('request');
const requestToken = initialSearchParams.get('request');

if (!requestToken) throw new Error('Cannot decode psbt without request token');

Expand All @@ -33,23 +30,21 @@ export function usePsbtRequestSearchParams() {
}

export function useRpcSignPsbtParams() {
useRejectIfLedgerWallet('signPsbt');

const [searchParams] = useSearchParams();
const { origin, tabId } = useDefaultRequestParams();
const broadcast = searchParams.get('broadcast');
const psbtHex = searchParams.get('hex');
const requestId = searchParams.get('requestId');
const signAtIndex = searchParams.getAll('signAtIndex');
const broadcast = initialSearchParams.get('broadcast');
const psbtHex = initialSearchParams.get('hex');
const requestId = initialSearchParams.get('requestId');
const signAtIndex = initialSearchParams.getAll('signAtIndex');

return useMemo(() => {
return {
return useMemo(
() => ({
broadcast: broadcast === 'true',
origin,
psbtHex,
requestId,
signAtIndex: undefinedIfLengthZero(ensureArray(signAtIndex).map(h => Number(h))),
tabId: tabId ?? 0,
};
}, [broadcast, origin, psbtHex, requestId, signAtIndex, tabId]);
}),
[broadcast, origin, psbtHex, requestId, signAtIndex, tabId]
);
}
14 changes: 11 additions & 3 deletions src/app/common/publish-subscribe.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Transaction } from '@scure/btc-signer';
import type { StacksTransaction } from '@stacks/transactions';

type PubTypeFn<E> = <Key extends string & keyof E>(
Expand All @@ -19,7 +20,7 @@ interface PubSubType<E> {
subscribe: SubTypeFn<E>;
unsubscribe: SubTypeFn<E>;
}
function PublishSubscribe<E>(): PubSubType<E> {
function createPublishSubscribe<E>(): PubSubType<E> {
const handlers: { [key: string]: MessageFn<any>[] } = {};

return {
Expand All @@ -42,7 +43,7 @@ function PublishSubscribe<E>(): PubSubType<E> {
}

// Global app events. Only add events if your feature isn't capable of
//communicating internally.
// communicating internally.
export interface GlobalAppEvents {
ledgerStacksTxSigned: {
unsignedTx: string;
Expand All @@ -51,6 +52,13 @@ export interface GlobalAppEvents {
ledgerStacksTxSigningCancelled: {
unsignedTx: string;
};
ledgerBitcoinTxSigned: {
unsignedPsbt: string;
signedPsbt: Transaction;
};
ledgerBitcoinTxSigningCancelled: {
unsignedPsbt: string;
};
}

export const appEvents = PublishSubscribe<GlobalAppEvents>();
export const appEvents = createPublishSubscribe<GlobalAppEvents>();
22 changes: 11 additions & 11 deletions src/app/common/transactions/bitcoin/use-generate-bitcoin-tx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,18 @@ import { UtxoResponseItem } from '@app/query/bitcoin/bitcoin-client';
import { useBitcoinScureLibNetworkConfig } from '@app/store/accounts/blockchain/bitcoin/bitcoin-keychain';
import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';

interface GenerateNativeSegwitTxValues {
interface GenerateNativeSegwitSingleRecipientTxValues {
amount: Money;
recipient: string;
}
export function useGenerateSignedNativeSegwitTx() {
export function useGenerateUnsignedNativeSegwitSingleRecipientTx() {
const signer = useCurrentAccountNativeSegwitIndexZeroSigner();

const networkMode = useBitcoinScureLibNetworkConfig();

return useCallback(
(
values: GenerateNativeSegwitTxValues,
async (
values: GenerateNativeSegwitSingleRecipientTxValues,
feeRate: number,
utxos: UtxoResponseItem[],
isSendingMax?: boolean
Expand Down Expand Up @@ -56,8 +56,9 @@ export function useGenerateSignedNativeSegwitTx() {
if (outputs.length > 2)
throw new Error('Address reuse mode: wallet should have max 2 outputs');

inputs.forEach(input => {
const p2wpkh = btc.p2wpkh(signer.publicKey, networkMode);
const p2wpkh = btc.p2wpkh(signer.publicKey, networkMode);

for (const input of inputs) {
tx.addInput({
txid: input.txid,
index: input.vout,
Expand All @@ -68,7 +69,8 @@ export function useGenerateSignedNativeSegwitTx() {
amount: BigInt(input.value),
},
});
});
}

outputs.forEach(output => {
// When coin selection returns output with no address we assume it is
// a change output
Expand All @@ -78,16 +80,14 @@ export function useGenerateSignedNativeSegwitTx() {
}
tx.addOutputAddress(values.recipient, BigInt(output.value), networkMode);
});
signer.sign(tx);
tx.finalize();

return { hex: tx.hex, fee };
return { hex: tx.hex, fee, psbt: tx.toPSBT(), inputs };
} catch (e) {
// eslint-disable-next-line no-console
console.log('Error signing bitcoin transaction', e);
return null;
}
},
[networkMode, signer]
[networkMode, signer.address, signer.publicKey]
);
}
8 changes: 4 additions & 4 deletions src/app/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,19 @@ interface MakeTxExplorerLinkArgs {
blockchain: Blockchains;
mode: BitcoinNetworkModes;
suffix?: string;
txId: string;
txid: string;
}
export function makeTxExplorerLink({
blockchain,
mode,
suffix = '',
txId,
txid,
}: MakeTxExplorerLinkArgs) {
switch (blockchain) {
case 'bitcoin':
return `https://mempool.space/${mode !== 'mainnet' ? mode + '/' : ''}tx/${txId}`;
return `https://mempool.space/${mode !== 'mainnet' ? mode + '/' : ''}tx/${txid}`;
case 'stacks':
return `https://explorer.hiro.so/txid/${txId}?chain=${mode}${suffix}`;
return `https://explorer.hiro.so/txid/${txid}?chain=${mode}${suffix}`;
default:
return '';
}
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/account/account-list-item-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { truncateMiddle } from '@app/ui/utils/truncate-middle';

import { CaptionDotSeparator } from '../caption-dot-separator';
import { Flag } from '../layout/flag';
import { StacksAccountLoader } from '../stacks-account-loader';
import { StacksAccountLoader } from '../loaders/stacks-account-loader';
import { BitcoinNativeSegwitAccountLoader } from './bitcoin-account-loader';

interface AccountListItemLayoutProps extends StackProps {
Expand Down
13 changes: 1 addition & 12 deletions src/app/components/account/account-name.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,10 @@
import { memo } from 'react';

import { BoxProps, styled } from 'leather-styles/jsx';

import { useAccountDisplayName } from '@app/common/hooks/account/use-account-names';
import { StacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.models';
import { styled } from 'leather-styles/jsx';

interface AccountNameLayoutProps {
children: React.ReactNode;
}
export const AccountNameLayout = memo(({ children }: AccountNameLayoutProps) => (
<styled.p textStyle="label.01">{children}</styled.p>
));

interface AccountNameProps extends BoxProps {
account: StacksAccount;
}
export const AccountName = memo(({ account }: AccountNameProps) => {
const name = useAccountDisplayName(account);
return <AccountNameLayout>{name}</AccountNameLayout>;
});
Loading

0 comments on commit 8a81f58

Please sign in to comment.