Skip to content

Commit

Permalink
refactor(signing): support non-index zero input signing, closes #4620,
Browse files Browse the repository at this point in the history
  • Loading branch information
kyranjamie committed Dec 8, 2023
1 parent 0299525 commit d2edb18
Show file tree
Hide file tree
Showing 14 changed files with 634 additions and 107 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as btc from '@scure/btc-signer';
import { hexToBytes } from '@stacks/common';
import get from 'lodash.get';

import { BitcoinInputSigningConfig } from '@shared/crypto/bitcoin/signer-config';
import { logger } from '@shared/logger';
import { RouteUrls } from '@shared/route-urls';

Expand Down Expand Up @@ -45,7 +46,7 @@ function LedgerSignBitcoinTxContainer() {
const [unsignedTransaction, setUnsignedTransaction] = useState<null | btc.Transaction>(null);
const signLedger = useSignLedgerBitcoinTx();

const inputsToSign = useLocationStateWithCache<number[]>('inputsToSign');
const inputsToSign = useLocationStateWithCache<BitcoinInputSigningConfig[]>('inputsToSign');
const allowUserToGoBack = useLocationState<boolean>('goBack');

useEffect(() => {
Expand All @@ -68,12 +69,12 @@ function LedgerSignBitcoinTxContainer() {

try {
const versionInfo = await getBitcoinAppVersion(bitcoinApp);

ledgerAnalytics.trackDeviceVersionInfo(versionInfo);
setAwaitingDeviceConnection(false);

setLatestDeviceResponse(versionInfo as any);
} catch (e) {}
} catch (e) {
logger.error('Unable to get Ledger app version info', e);
}

ledgerNavigate.toDeviceBusyStep('Verifying public key on Ledger…');

Expand All @@ -84,7 +85,11 @@ function LedgerSignBitcoinTxContainer() {
ledgerNavigate.toAwaitingDeviceOperation({ hasApprovedOperation: false });

try {
const btcTx = await signLedger(bitcoinApp, unsignedTransaction.toPSBT(), inputsToSign);
const btcTx = await signLedger(
bitcoinApp,
unsignedTransaction.toPSBT(),
inputsToSign?.map(x => x.index)
);

if (!btcTx || !unsignedTransactionRaw) throw new Error('No tx returned');
ledgerNavigate.toAwaitingDeviceOperation({ hasApprovedOperation: true });
Expand Down
6 changes: 5 additions & 1 deletion src/app/features/ledger/hooks/use-ledger-navigate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { bytesToHex } from '@stacks/common';
import { ClarityValue, StacksTransaction } from '@stacks/transactions';

import { SupportedBlockchains } from '@shared/constants';
import { BitcoinInputSigningConfig } from '@shared/crypto/bitcoin/signer-config';
import { RouteUrls } from '@shared/route-urls';

import { immediatelyAttemptLedgerConnection } from './use-when-reattempt-ledger-connection';
Expand All @@ -30,7 +31,10 @@ export function useLedgerNavigate() {
});
},

toConnectAndSignBitcoinTransactionStep(psbt: Uint8Array, inputsToSign?: number[]) {
toConnectAndSignBitcoinTransactionStep(
psbt: Uint8Array,
inputsToSign?: BitcoinInputSigningConfig[]
) {
return navigate(RouteUrls.ConnectLedger, {
replace: true,
relative: 'route',
Expand Down
9 changes: 5 additions & 4 deletions src/app/features/psbt-signer/hooks/use-psbt-signer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useMemo } from 'react';
import { hexToBytes } from '@noble/hashes/utils';
import * as btc from '@scure/btc-signer';

import { BitcoinInputSigningConfig } from '@shared/crypto/bitcoin/signer-config';
import { logger } from '@shared/logger';
import { isString } from '@shared/utils';

Expand All @@ -14,7 +15,7 @@ import {
export type RawPsbt = ReturnType<typeof btc.RawPSBTV0.decode>;

interface SignPsbtArgs {
indexesToSign?: number[];
signingConfig: BitcoinInputSigningConfig[];
tx: btc.Transaction;
}
export function usePsbtSigner() {
Expand All @@ -23,9 +24,9 @@ export function usePsbtSigner() {

return useMemo(
() => ({
async signPsbt({ indexesToSign, tx }: SignPsbtArgs) {
addMissingTapInteralKeys(tx, indexesToSign);
return signBitcoinTx(tx.toPSBT(), indexesToSign);
async signPsbt({ signingConfig, tx }: SignPsbtArgs) {
addMissingTapInteralKeys(tx, signingConfig);
return signBitcoinTx(tx.toPSBT(), signingConfig);
},
getPsbtAsTransaction(psbt: string | Uint8Array) {
const bytes = isString(psbt) ? hexToBytes(psbt) : psbt;
Expand Down
8 changes: 6 additions & 2 deletions src/app/pages/psbt-request/use-psbt-request.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';

import { bytesToHex } from '@noble/hashes/utils';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';

import { finalizePsbt } from '@shared/actions/finalize-psbt';
import { RouteUrls } from '@shared/route-urls';

import { useAnalytics } from '@app/common/hooks/analytics/use-analytics';
import { usePsbtRequestSearchParams } from '@app/common/psbt/use-psbt-request-params';
import { usePsbtSigner } from '@app/features/psbt-signer/hooks/use-psbt-signer';
import { useGetAssumedZeroIndexSigningConfig } from '@app/store/accounts/blockchain/bitcoin/bitcoin.hooks';

export function usePsbtRequest() {
const [isLoading, setIsLoading] = useState(false);
Expand All @@ -17,6 +18,7 @@ export function usePsbtRequest() {
const { appName, origin, payload, requestToken, signAtIndex, tabId } =
usePsbtRequestSearchParams();
const { signPsbt, getRawPsbt, getPsbtAsTransaction } = usePsbtSigner();
const getDefaultSigningConfig = useGetAssumedZeroIndexSigningConfig();

return useMemo(() => {
return {
Expand All @@ -41,7 +43,8 @@ export function usePsbtRequest() {
const tx = getPsbtAsTransaction(payload.hex);

try {
const signedTx = await signPsbt({ indexesToSign: signAtIndex, tx });
const signingConfig = getDefaultSigningConfig(hexToBytes(payload.hex));
const signedTx = await signPsbt({ tx, signingConfig });

const signedPsbt = signedTx.toPSBT();

Expand Down Expand Up @@ -71,6 +74,7 @@ export function usePsbtRequest() {
tabId,
getPsbtAsTransaction,
signPsbt,
getDefaultSigningConfig,
navigate,
]);
}
8 changes: 7 additions & 1 deletion src/app/pages/rpc-sign-psbt/use-rpc-sign-psbt.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useNavigate } from 'react-router-dom';

import { RpcErrorCode } from '@btckit/types';
import { hexToBytes } from '@noble/hashes/utils';
import { bytesToHex } from '@stacks/common';

import { Money } from '@shared/models/money.model';
Expand All @@ -20,6 +21,7 @@ import {
useCalculateBitcoinFiatValue,
useCryptoCurrencyMarketData,
} from '@app/query/common/market-data/market-data.hooks';
import { useGetAssumedZeroIndexSigningConfig } from '@app/store/accounts/blockchain/bitcoin/bitcoin.hooks';

interface BroadcastSignedPsbtTxArgs {
addressNativeSegwitTotal: Money;
Expand All @@ -36,6 +38,7 @@ export function useRpcSignPsbt() {
const { refetch } = useCurrentNativeSegwitUtxos();
const btcMarketData = useCryptoCurrencyMarketData('BTC');
const calculateBitcoinFiatValue = useCalculateBitcoinFiatValue();
const getDefaultSigningConfig = useGetAssumedZeroIndexSigningConfig();

if (!requestId || !psbtHex || !origin) throw new Error('Invalid params in useRpcSignPsbt');

Expand Down Expand Up @@ -90,7 +93,10 @@ export function useRpcSignPsbt() {
const tx = getPsbtAsTransaction(psbtHex);

try {
const signedTx = await signPsbt({ tx, indexesToSign: signAtIndex });
const signedTx = await signPsbt({
tx,
signingConfig: getDefaultSigningConfig(hexToBytes(psbtHex), signAtIndex),
});

const psbt = signedTx.toPSBT();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import * as btc from '@scure/btc-signer';
import { AddressType, getAddressInfo } from 'bitcoin-address-validation';

import { BitcoinInputSigningConfig } from '@shared/crypto/bitcoin/signer-config';
import { logger } from '@shared/logger';
import { OrdinalSendFormValues } from '@shared/models/form.model';

import { determineUtxosForSpend } from '@app/common/transactions/bitcoin/coinselect/local-coin-selection';
import { createCounter } from '@app/common/utils/counter';
import { useCurrentNativeSegwitUtxos } from '@app/query/bitcoin/address/utxos-by-address.hooks';
import { TaprootUtxo } from '@app/query/bitcoin/bitcoin-client';
import { useBitcoinScureLibNetworkConfig } from '@app/store/accounts/blockchain/bitcoin/bitcoin-keychain';
Expand All @@ -29,6 +31,7 @@ export function useGenerateUnsignedOrdinalTx(taprootInput: TaprootUtxo) {

function formTaprootOrdinalTx(values: OrdinalSendFormValues) {
const inscriptionInput = taprootInput;

const taprootSigner = createTaprootSigner?.(inscriptionInput.addressIndex);
const nativeSegwitSigner = createNativeSegwitSigner?.(0);

Expand All @@ -42,6 +45,10 @@ export function useGenerateUnsignedOrdinalTx(taprootInput: TaprootUtxo) {
feeRate: values.feeRate,
});

const psbtInputCounter = createCounter();

const signingConfig: BitcoinInputSigningConfig[] = [];

if (!result.success) return null;

const { inputs, outputs } = result;
Expand All @@ -60,9 +67,14 @@ export function useGenerateUnsignedOrdinalTx(taprootInput: TaprootUtxo) {
amount: BigInt(taprootInput.value),
},
});
signingConfig.push({
derivationPath: taprootSigner.derivationPath,
index: psbtInputCounter.getValue(),
});
psbtInputCounter.increment();

// Fee-covering Native Segwit inputs
inputs.forEach(input =>
inputs.forEach(input => {
tx.addInput({
txid: input.txid,
index: input.vout,
Expand All @@ -71,15 +83,20 @@ export function useGenerateUnsignedOrdinalTx(taprootInput: TaprootUtxo) {
amount: BigInt(input.value),
script: nativeSegwitSigner.payment.script,
},
})
);
});
signingConfig.push({
derivationPath: nativeSegwitSigner.derivationPath,
index: psbtInputCounter.getValue(),
});
psbtInputCounter.increment();
});

// Recipient and change outputs
outputs.forEach(output => tx.addOutputAddress(output.address, output.value, networkMode));

tx.toPSBT();

return { hex: tx.hex, psbt: tx.toPSBT() };
return { psbt: tx.toPSBT(), signingConfig };
} catch (e) {
logger.error('Unable to sign transaction');
return null;
Expand All @@ -88,6 +105,7 @@ export function useGenerateUnsignedOrdinalTx(taprootInput: TaprootUtxo) {

function formNativeSegwitOrdinalTx(values: OrdinalSendFormValues) {
const nativeSegwitSigner = createNativeSegwitSigner?.(0);

const { feeRate, recipient } = values;
if (!nativeSegwitSigner || !nativeSegwitUtxos || !values.feeRate) return;

Expand Down Expand Up @@ -130,7 +148,7 @@ export function useGenerateUnsignedOrdinalTx(taprootInput: TaprootUtxo) {
tx.addOutputAddress(values.recipient, BigInt(output.value), networkMode);
});

return { hex: tx.hex, psbt: tx.toPSBT() };
return { psbt: tx.toPSBT(), signingConfig: undefined };
} catch (e) {
logger.error('Unable to sign transaction');
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export function useSendInscriptionForm() {
return;
}

const signedTx = await sign(resp.psbt);
const signedTx = await sign(resp.psbt, resp.signingConfig);

if (!signedTx) {
logger.error('No signed transaction returned');
Expand Down
68 changes: 46 additions & 22 deletions src/app/store/accounts/blockchain/bitcoin/bitcoin.hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,20 @@ import { Psbt } from 'bitcoinjs-lib';
import AppClient from 'ledger-bitcoin';

import { getBitcoinJsLibNetworkConfigByMode } from '@shared/crypto/bitcoin/bitcoin.network';
import { getTaprootAddress } from '@shared/crypto/bitcoin/bitcoin.utils';
import {
extractAddressIndexFromPath,
getTaprootAddress,
} from '@shared/crypto/bitcoin/bitcoin.utils';
import { getInputPaymentType } from '@shared/crypto/bitcoin/bitcoin.utils';
import { getTaprootAccountDerivationPath } from '@shared/crypto/bitcoin/p2tr-address-gen';
import { getNativeSegwitAccountDerivationPath } from '@shared/crypto/bitcoin/p2wpkh-address-gen';
import {
BitcoinInputSigningConfig,
getAssumedZeroIndexSigningConfig,
} from '@shared/crypto/bitcoin/signer-config';
import { logger } from '@shared/logger';
import { allSighashTypes } from '@shared/rpc/methods/sign-psbt';
import { makeNumberRange } from '@shared/utils';
import { isNumber, makeNumberRange } from '@shared/utils';

import { useWalletType } from '@app/common/use-wallet-type';
import { listenForBitcoinTxLedgerSigning } from '@app/features/ledger/flows/bitcoin-tx-signing/bitcoin-tx-signing-event-listeners';
Expand Down Expand Up @@ -62,23 +69,22 @@ export function useZeroIndexTaprootAddress(accIndex?: number) {
return address;
}

// This implementation assumes address re-use of the 0 index. Funds spread
// across multiple address indexes does not work here.
function useSignBitcoinSoftwareTx() {
const createNativeSegwitSigner = useCurrentAccountNativeSegwitSigner();
const createTaprootSigner = useCurrentAccountTaprootSigner();
const network = useCurrentNetwork();
return async (psbt: Uint8Array, inputsToSign?: number[]) => {
const nativeSegwitSigner = createNativeSegwitSigner?.(0);
const taprootSigner = createTaprootSigner?.(0);

if (!nativeSegwitSigner || !taprootSigner) throw new Error('Signers not available');

return async (psbt: Uint8Array, inputSigningConfig: BitcoinInputSigningConfig[]) => {
const tx = btc.Transaction.fromPSBT(psbt);

const inputIndexes = inputsToSign ?? makeNumberRange(tx.inputsLength);
inputSigningConfig.forEach(({ index, derivationPath }) => {
const nativeSegwitSigner = createNativeSegwitSigner?.(
extractAddressIndexFromPath(derivationPath)
);
const taprootSigner = createTaprootSigner?.(extractAddressIndexFromPath(derivationPath));

if (!nativeSegwitSigner || !taprootSigner) throw new Error('Signers not available');

inputIndexes.forEach(index => {
const input = tx.getInput(index);
const addressType = getInputPaymentType(index, input, network.chain.bitcoin.bitcoinNetwork);

Expand Down Expand Up @@ -199,11 +205,10 @@ export function useSignLedgerBitcoinTx() {

export function useAddTapInternalKeysIfMissing() {
const createTaprootSigner = useCurrentAccountTaprootSigner();
return (tx: btc.Transaction, inputIndexes?: number[]) => {
const taprootSigner = createTaprootSigner?.(0);
if (!taprootSigner) throw new Error('Taproot signer not found');

(inputIndexes ?? makeNumberRange(tx.inputsLength)).forEach(index => {
return (tx: btc.Transaction, inputIndexes: BitcoinInputSigningConfig[]) => {
inputIndexes.forEach(({ index, derivationPath }) => {
const taprootSigner = createTaprootSigner?.(extractAddressIndexFromPath(derivationPath));
if (!taprootSigner) throw new Error('Taproot signer not found');
const input = tx.getInput(index);
const witnessOutputScript =
input.witnessUtxo?.script && btc.OutScript.decode(input.witnessUtxo.script);
Expand All @@ -214,28 +219,47 @@ export function useAddTapInternalKeysIfMissing() {
};
}

export function useGetAssumedZeroIndexSigningConfig() {
const network = useCurrentNetwork();
const accountIndex = useCurrentAccountIndex();

return (psbt: Uint8Array, indexesToSign?: number[]) =>
getAssumedZeroIndexSigningConfig({
psbt,
network: network.chain.bitcoin.bitcoinNetwork,
indexesToSign,
}).forAccountIndex(accountIndex);
}

export function useSignBitcoinTx() {
const { whenWallet } = useWalletType();
const ledgerNavigate = useLedgerNavigate();
const signSoftwareTx = useSignBitcoinSoftwareTx();
const getDefaultSigningConfig = useGetAssumedZeroIndexSigningConfig();

/**
* Don't forget to finalize the tx once it's returned. You can broadcast with
* the hex value from `tx.hex` TODO: add support for signing specific inputs
* the hex value from `tx.hex`.
*/
return (psbt: Uint8Array, inputsToSign?: number[]) =>
whenWallet({
return (psbt: Uint8Array, inputsToSign?: BitcoinInputSigningConfig[] | number[]) => {
function getSigningConfig(inputsToSign?: BitcoinInputSigningConfig[] | number[]) {
if (!inputsToSign) return getDefaultSigningConfig(psbt);
if (inputsToSign.every(isNumber)) return getDefaultSigningConfig(psbt, inputsToSign);
return inputsToSign;
}

return whenWallet({
async ledger() {
// Because Ledger signing is a multi-step process that takes place over
// many routes, in order to achieve a consistent API between
// Ledger/software, we subscribe to the event that occurs when the
// unsigned tx is signed
ledgerNavigate.toConnectAndSignBitcoinTransactionStep(psbt, inputsToSign);
// ledgerNavigate.toConnectAndSignBitcoinTransactionStep(psbt, inputsToSign);
ledgerNavigate.toConnectAndSignBitcoinTransactionStep(psbt, getSigningConfig(inputsToSign));
return listenForBitcoinTxLedgerSigning(bytesToHex(psbt));
},
async software() {
return signSoftwareTx(psbt, inputsToSign);
return signSoftwareTx(psbt, getSigningConfig(inputsToSign));
},
})();
};
}
Loading

0 comments on commit d2edb18

Please sign in to comment.