diff --git a/public/html/index.html b/public/html/index.html index 87b1295a388..b7acb05e531 100644 --- a/public/html/index.html +++ b/public/html/index.html @@ -9,13 +9,6 @@ - = ( + event: Key, + // Ensures if we have an event with no payload, the second arg can be empty, + // rather than `undefined` + ...message: null extends E[Key] ? [data?: never] : [data: E[Key]] +) => void; + +type SubTypeFn = ( + event: Key, + fn: (message: E[Key]) => void +) => void; + +type MessageFn = (message: E[Key]) => void; + +interface PubSubType { + publish: PubTypeFn; + subscribe: SubTypeFn; + unsubscribe: SubTypeFn; +} + +export function PublishSubscribe(): PubSubType { + const handlers: { [key: string]: MessageFn[] } = {}; + + return { + publish(event, msg?) { + handlers[event].forEach(h => h(msg)); + }, + + subscribe(event, callback) { + const list = handlers[event] ?? []; + list.push(callback); + handlers[event] = list; + }, + + unsubscribe(event, callback) { + let list = handlers[event] ?? []; + list = list.filter(h => h !== callback); + handlers[event] = list; + }, + }; +} + +// Global app events. Only add events if your feature isn't capable of +//communicating internally. +export interface GlobalAppEvents { + ledgerStacksTxSigned: { + unsignedTx: string; + signedTx: StacksTransaction; + }; + ledgerStacksTxCancelled: null; +} + +export const appEvents = PublishSubscribe(); + +// function listenForNextEvent(name: T): Promise { +// return new Promise(resolve => { +// appEvents.subscribe(name, msg => { +// appEvents.unsubscribe(name, resolve); +// resolve(msg); +// }); +// }); +// } diff --git a/src/app/components/info-label.tsx b/src/app/components/info-label.tsx index e757df1b164..ff0fec092d3 100644 --- a/src/app/components/info-label.tsx +++ b/src/app/components/info-label.tsx @@ -3,7 +3,7 @@ import { ReactNode } from 'react'; import { Flex, FlexProps, Stack, styled } from 'leather-styles/jsx'; interface InfoLabelProps extends FlexProps { - children: ReactNode | undefined; + children: ReactNode; title: string; } export function InfoLabel({ children, title, ...rest }: InfoLabelProps) { diff --git a/src/app/features/ledger/flows/stacks-tx-signing/ledger-sign-tx-container.tsx b/src/app/features/ledger/flows/stacks-tx-signing/ledger-sign-tx-container.tsx index 5a276872568..ba06a4727d9 100644 --- a/src/app/features/ledger/flows/stacks-tx-signing/ledger-sign-tx-container.tsx +++ b/src/app/features/ledger/flows/stacks-tx-signing/ledger-sign-tx-container.tsx @@ -9,6 +9,7 @@ import { logger } from '@shared/logger'; import { RouteUrls } from '@shared/route-urls'; import { useScrollLock } from '@app/common/hooks/use-scroll-lock'; +import { appEvents } from '@app/common/publish-subscribe'; import { delay } from '@app/common/utils'; import { BaseDrawer } from '@app/components/drawer/base-drawer'; import { @@ -25,7 +26,6 @@ import { useActionCancellableByUser, } from '@app/features/ledger/utils/stacks-ledger-utils'; import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/stacks-account.hooks'; -import { useTransactionBroadcast } from '@app/store/transactions/transaction.hooks'; import { useLedgerAnalytics } from '../../hooks/use-ledger-analytics.hook'; import { useLedgerNavigate } from '../../hooks/use-ledger-navigate'; @@ -39,19 +39,19 @@ export function LedgerSignStacksTxContainer() { const ledgerAnalytics = useLedgerAnalytics(); useScrollLock(true); const account = useCurrentStacksAccount(); - const hwWalletTxBroadcast = useTransactionBroadcast(); + // const hwWalletTxBroadcast = useTransactionBroadcast(); const canUserCancelAction = useActionCancellableByUser(); const verifyLedgerPublicKey = useVerifyMatchingLedgerStacksPublicKey(); - const [unsignedTransaction, setUnsignedTransaction] = useState(null); + const [unsignedTx, setUnsignedTx] = useState(null); const hasUserSkippedBuggyAppWarning = useMemo(() => createWaitForUserToSeeWarningScreen(), []); useEffect(() => { const tx = get(location.state, 'tx'); - if (tx) setUnsignedTransaction(tx); + if (tx) setUnsignedTx(tx); }, [location.state]); - useEffect(() => () => setUnsignedTransaction(null), []); + useEffect(() => () => setUnsignedTx(null), []); const [latestDeviceResponse, setLatestDeviceResponse] = useLedgerResponseState(); @@ -87,6 +87,7 @@ export function LedgerSignStacksTxContainer() { const response = await hasUserSkippedBuggyAppWarning.wait(); if (response === 'cancelled-operation') { + appEvents.publish('ledgerStacksTxCancelled'); ledgerNavigate.cancelLedgerAction(); return; } @@ -97,12 +98,12 @@ export function LedgerSignStacksTxContainer() { ledgerNavigate.toConnectionSuccessStep('stacks'); await delay(1000); - if (!unsignedTransaction) throw new Error('No unsigned tx'); + if (!unsignedTx) throw new Error('No unsigned tx'); ledgerNavigate.toAwaitingDeviceOperation({ hasApprovedOperation: false }); const resp = await signLedgerTransaction(stacksApp)( - Buffer.from(unsignedTransaction, 'hex'), + Buffer.from(unsignedTx, 'hex'), account.index ); @@ -127,12 +128,14 @@ export function LedgerSignStacksTxContainer() { await delay(1000); - const signedTx = signTransactionWithSignature(unsignedTransaction, resp.signatureVRS); + const signedTx = signTransactionWithSignature(unsignedTx, resp.signatureVRS); ledgerAnalytics.transactionSignedOnLedgerSuccessfully(); try { - await hwWalletTxBroadcast({ signedTx }); - navigate(RouteUrls.Home); + appEvents.publish('ledgerStacksTxSigned', { + unsignedTx, + signedTx, + }); } catch (e) { ledgerNavigate.toBroadcastErrorStep(e instanceof Error ? e.message : 'Unknown error'); return; @@ -147,7 +150,7 @@ export function LedgerSignStacksTxContainer() { const allowUserToGoBack = get(location.state, 'goBack'); const ledgerContextValue: LedgerTxSigningContext = { - transaction: unsignedTransaction ? deserializeTransaction(unsignedTransaction) : null, + transaction: unsignedTx ? deserializeTransaction(unsignedTx) : null, signTransaction, latestDeviceResponse, awaitingDeviceConnection, diff --git a/src/app/features/ledger/flows/stacks-tx-signing/stacks-tx-signing-event-listeners.ts b/src/app/features/ledger/flows/stacks-tx-signing/stacks-tx-signing-event-listeners.ts new file mode 100644 index 00000000000..dcb27c3b1f6 --- /dev/null +++ b/src/app/features/ledger/flows/stacks-tx-signing/stacks-tx-signing-event-listeners.ts @@ -0,0 +1,17 @@ +import type { StacksTransaction } from '@stacks/transactions'; + +import { GlobalAppEvents, appEvents } from '@app/common/publish-subscribe'; + +export async function listenForStacksTxLedgerSigning( + unsignedTx: string +): Promise { + return new Promise(resolve => { + function handler(msg: GlobalAppEvents['ledgerStacksTxSigned']) { + if (msg.unsignedTx === unsignedTx) { + appEvents.unsubscribe('ledgerStacksTxSigned', handler); + resolve(msg.signedTx); + } + } + appEvents.subscribe('ledgerStacksTxSigned', handler); + }); +} diff --git a/src/app/pages/home/home.tsx b/src/app/pages/home/home.tsx index d910d52ea1d..6517de3204e 100644 --- a/src/app/pages/home/home.tsx +++ b/src/app/pages/home/home.tsx @@ -9,7 +9,6 @@ import { useRouteHeader } from '@app/common/hooks/use-route-header'; import { Header } from '@app/components/header'; import { ActivityList } from '@app/features/activity-list/activity-list'; import { AssetsList } from '@app/features/asset-list/asset-list'; -import { InAppMessages } from '@app/features/hiro-messages/in-app-messages'; import { homePageModalRoutes } from '@app/routes/app-routes'; import { ModalBackgroundWrapper } from '@app/routes/components/modal-background-wrapper'; @@ -26,7 +25,6 @@ export function Home() { useRouteHeader( <> -
); diff --git a/src/app/pages/rpc-sign-stacks-transaction/use-rpc-sign-stacks-transaction.ts b/src/app/pages/rpc-sign-stacks-transaction/use-rpc-sign-stacks-transaction.ts index fc2cdf18a03..315fbe27478 100644 --- a/src/app/pages/rpc-sign-stacks-transaction/use-rpc-sign-stacks-transaction.ts +++ b/src/app/pages/rpc-sign-stacks-transaction/use-rpc-sign-stacks-transaction.ts @@ -10,7 +10,7 @@ import { closeWindow } from '@shared/utils'; import { useDefaultRequestParams } from '@app/common/hooks/use-default-request-search-params'; import { useRejectIfLedgerWallet } from '@app/common/rpc-helpers'; -import { useSignTransactionSoftwareWallet } from '@app/store/transactions/transaction.hooks'; +import { useSignStacksTransaction } from '@app/store/transactions/transaction.hooks'; function useRpcSignStacksTransactionParams() { useRejectIfLedgerWallet('stx_signTransaction'); @@ -38,7 +38,7 @@ function useRpcSignStacksTransactionParams() { export function useRpcSignStacksTransaction() { const { origin, requestId, tabId, stacksTransaction, isMultisig } = useRpcSignStacksTransactionParams(); - const signSoftwareWalletTx = useSignTransactionSoftwareWallet(); + const signStacksTx = useSignStacksTransaction(); const wasSignedByOtherOwners = isMultisig && (stacksTransaction.auth.spendingCondition as MultiSigSpendingCondition).fields?.length > 0; @@ -49,11 +49,11 @@ export function useRpcSignStacksTransaction() { disableNonceSelection: wasSignedByOtherOwners, stacksTransaction, isMultisig, - onSignStacksTransaction(fee: number, nonce: number) { + async onSignStacksTransaction(fee: number, nonce: number) { stacksTransaction.setFee(fee); stacksTransaction.setNonce(nonce); - const signedTransaction = signSoftwareWalletTx(stacksTransaction); + const signedTransaction = await signStacksTx(stacksTransaction); if (!signedTransaction) { throw new Error('Error signing stacks transaction'); } diff --git a/src/app/pages/send/send-crypto-asset-form/family/stacks/hooks/use-stacks-broadcast-transaction.tsx b/src/app/pages/send/send-crypto-asset-form/family/stacks/hooks/use-stacks-broadcast-transaction.tsx index 6f735bfb0b4..cb2718c68b8 100644 --- a/src/app/pages/send/send-crypto-asset-form/family/stacks/hooks/use-stacks-broadcast-transaction.tsx +++ b/src/app/pages/send/send-crypto-asset-form/family/stacks/hooks/use-stacks-broadcast-transaction.tsx @@ -11,7 +11,7 @@ import { isString } from '@shared/utils'; import { LoadingKeys } from '@app/common/hooks/use-loading'; import { useSubmitTransactionCallback } from '@app/common/hooks/use-submit-stx-transaction'; -import { useSignTransactionSoftwareWallet } from '@app/store/transactions/transaction.hooks'; +import { useSignStacksTransaction } from '@app/store/transactions/transaction.hooks'; import { useStacksTransactionSummary } from './use-stacks-transaction-summary'; @@ -20,7 +20,7 @@ export function useStacksBroadcastTransaction( token: CryptoCurrencies, decimals?: number ) { - const signSoftwareWalletTx = useSignTransactionSoftwareWallet(); + const signSoftwareWalletTx = useSignStacksTransaction(); const [isBroadcasting, setIsBroadcasting] = useState(false); const { formSentSummaryTxState } = useStacksTransactionSummary(token); const navigate = useNavigate(); @@ -69,7 +69,7 @@ export function useStacksBroadcastTransaction( async function broadcastTransaction(unsignedTx: StacksTransaction) { if (!unsignedTx) return; - const signedTx = signSoftwareWalletTx(unsignedTx); + const signedTx = await signSoftwareWalletTx(unsignedTx); if (!signedTx) return; await broadcastTransactionAction(signedTx); } diff --git a/src/app/pages/send/send-crypto-asset-form/form/stacks-sip10/sip10-token-send-form.tsx b/src/app/pages/send/send-crypto-asset-form/form/stacks-sip10/sip10-token-send-form.tsx index 65fc550a811..1ce202aac31 100644 --- a/src/app/pages/send/send-crypto-asset-form/form/stacks-sip10/sip10-token-send-form.tsx +++ b/src/app/pages/send/send-crypto-asset-form/form/stacks-sip10/sip10-token-send-form.tsx @@ -18,7 +18,6 @@ export function Sip10TokenSendForm() { interface Sip10TokenSendFormLoaderProps { children: (data: { symbol: string; contractId: string }) => React.JSX.Element; } - function Sip10TokenSendFormLoader({ children }: Sip10TokenSendFormLoaderProps) { const { symbol, contractId } = useParams(); if (!symbol || !contractId) { diff --git a/src/app/pages/send/send-crypto-asset-form/form/stx/use-stx-send-form.tsx b/src/app/pages/send/send-crypto-asset-form/form/stx/use-stx-send-form.tsx index 7b2bf317285..ae39961fc94 100644 --- a/src/app/pages/send/send-crypto-asset-form/form/stx/use-stx-send-form.tsx +++ b/src/app/pages/send/send-crypto-asset-form/form/stx/use-stx-send-form.tsx @@ -9,13 +9,11 @@ import { StacksSendFormValues } from '@shared/models/form.model'; import { useStxBalance } from '@app/common/hooks/balance/stx/use-stx-balance'; import { convertAmountToBaseUnit } from '@app/common/money/calculate-money'; -import { useWalletType } from '@app/common/use-wallet-type'; import { stxAmountValidator, stxAvailableBalanceValidator, } from '@app/common/validation/forms/amount-validators'; import { stxFeeValidator } from '@app/common/validation/forms/fee-validators'; -import { useLedgerNavigate } from '@app/features/ledger/hooks/use-ledger-navigate'; import { useUpdatePersistedSendFormValues } from '@app/features/popup-send-form-restoration/use-update-persisted-send-form-values'; import { useCalculateStacksTxFees } from '@app/query/stacks/fees/fees.hooks'; import { useStacksValidateFeeByNonce } from '@app/query/stacks/mempool/mempool.hooks'; @@ -34,8 +32,6 @@ export function useStxSendForm() { const generateTx = useGenerateStxTokenTransferUnsignedTx(); const { onFormStateChange } = useUpdatePersistedSendFormValues(); - const { whenWallet } = useWalletType(); - const ledgerNavigate = useLedgerNavigate(); const sendFormNavigate = useSendFormNavigate(); const { changeFeeByNonce } = useStacksValidateFeeByNonce(); @@ -88,10 +84,7 @@ export function useStxSendForm() { const tx = await generateTx(values); if (!tx) return logger.error('Attempted to generate unsigned tx, but tx is undefined'); - whenWallet({ - software: () => sendFormNavigate.toConfirmAndSignStxTransaction(tx, showFeeChangeWarning), - ledger: () => ledgerNavigate.toConnectAndSignTransactionStep(tx), - })(); + sendFormNavigate.toConfirmAndSignStxTransaction(tx, showFeeChangeWarning); }, }; } diff --git a/src/app/store/transactions/fees.hooks.ts b/src/app/store/transactions/fees.hooks.ts index 54282002578..23359844bca 100644 --- a/src/app/store/transactions/fees.hooks.ts +++ b/src/app/store/transactions/fees.hooks.ts @@ -10,11 +10,11 @@ import { LoadingKeys } from '@app/common/hooks/use-loading'; import { useSubmitTransactionCallback } from '@app/common/hooks/use-submit-stx-transaction'; import { useRawTxIdState } from '@app/store/transactions/raw.hooks'; -import { useSignTransactionSoftwareWallet } from './transaction.hooks'; +import { useSignStacksTransaction } from './transaction.hooks'; export const useReplaceByFeeSoftwareWalletSubmitCallBack = () => { const [, setTxId] = useRawTxIdState(); - const signTx = useSignTransactionSoftwareWallet(); + const signTx = useSignStacksTransaction(); const navigate = useNavigate(); const submitTransaction = useSubmitTransactionCallback({ @@ -24,8 +24,11 @@ export const useReplaceByFeeSoftwareWalletSubmitCallBack = () => { return useCallback( async (rawTx: StacksTransaction) => { if (!rawTx) return; - const signedTx = signTx(rawTx); - if (!signedTx) return; + const signedTx = await signTx(rawTx); + if (!signedTx) { + logger.warn('Error signing transaction when replacing by fee'); + return; + } await submitTransaction({ onSuccess() { setTxId(null); diff --git a/src/app/store/transactions/transaction.hooks.ts b/src/app/store/transactions/transaction.hooks.ts index ddf87100197..cdc2ab9ac38 100644 --- a/src/app/store/transactions/transaction.hooks.ts +++ b/src/app/store/transactions/transaction.hooks.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react'; import { useAsync } from 'react-async-hook'; import toast from 'react-hot-toast'; +import { bytesToHex } from '@noble/hashes/utils'; import { TransactionTypes } from '@stacks/connect'; import { FungibleConditionCode, @@ -27,6 +28,9 @@ import { GenerateUnsignedTransactionOptions, generateUnsignedTransaction, } from '@app/common/transactions/stacks/generate-unsigned-txs'; +import { useWalletType } from '@app/common/use-wallet-type'; +import { listenForStacksTxLedgerSigning } from '@app/features/ledger/flows/stacks-tx-signing/stacks-tx-signing-event-listeners'; +import { useLedgerNavigate } from '@app/features/ledger/hooks/use-ledger-navigate'; import { useNextNonce } from '@app/query/stacks/nonce/account-nonces.hooks'; import { useCurrentAccountStxAddressState, @@ -91,26 +95,7 @@ export function useUnsignedPrepareTransactionDetails(values: StacksTransactionFo return useMemo(() => unsignedStacksTransaction, [unsignedStacksTransaction]); } -export function useSignTransactionSoftwareWallet() { - const account = useCurrentStacksAccount(); - return useCallback( - (tx: StacksTransaction) => { - if (account?.type !== 'software') { - [toast.error, logger.error].forEach(fn => - fn('Cannot use this method to sign a non-software wallet transaction') - ); - return; - } - const signer = new TransactionSigner(tx); - if (!account) return null; - signer.signOrigin(createStacksPrivateKey(account.stxPrivateKey)); - return tx; - }, - [account] - ); -} - -export function useTransactionBroadcast() { +export function useStacksTransactionBroadcast() { const submittedTransactionsActions = useSubmittedTransactionsActions(); const { tabId } = useDefaultRequestParams(); const requestToken = useTransactionRequest(); @@ -155,7 +140,7 @@ export function useSoftwareWalletTransactionRequestBroadcast() { const { tabId } = useDefaultRequestParams(); const requestToken = useTransactionRequest(); const account = useCurrentStacksAccount(); - const txBroadcast = useTransactionBroadcast(); + const txBroadcast = useStacksTransactionBroadcast(); return async (values: StacksTransactionFormValues) => { if (!stacksTxBaseState) return; @@ -227,3 +212,39 @@ function useUnsignedStacksTransaction(values: StacksTransactionFormValues) { return tx.result; } + +export function useSignTransactionSoftwareWallet() { + const account = useCurrentStacksAccount(); + return useCallback( + (tx: StacksTransaction) => { + if (account?.type !== 'software') { + [toast.error, logger.error].forEach(fn => + fn('Cannot use this method to sign a non-software wallet transaction') + ); + return; + } + const signer = new TransactionSigner(tx); + if (!account) return null; + signer.signOrigin(createStacksPrivateKey(account.stxPrivateKey)); + return tx; + }, + [account] + ); +} + +export function useSignStacksTransaction() { + const { whenWallet } = useWalletType(); + const ledgerNavigate = useLedgerNavigate(); + const signSoftwareTx = useSignTransactionSoftwareWallet(); + + return (tx: StacksTransaction) => + whenWallet({ + async ledger(tx: StacksTransaction) { + ledgerNavigate.toConnectAndSignTransactionStep(tx); + return listenForStacksTxLedgerSigning(bytesToHex(tx.serialize())); + }, + async software(tx: StacksTransaction) { + return signSoftwareTx(tx); + }, + })(tx); +} diff --git a/src/shared/utils/type-utils.ts b/src/shared/utils/type-utils.ts index d426dc3a336..d3e203f9fe5 100644 --- a/src/shared/utils/type-utils.ts +++ b/src/shared/utils/type-utils.ts @@ -5,3 +5,9 @@ type Primitive = null | undefined | string | number | boolean | symbol | bigint; export type LiteralUnion = | LiteralType | (BaseType & Record); + +export type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( + k: infer I +) => void + ? I + : never; diff --git a/theme/semantic-tokens.ts b/theme/semantic-tokens.ts index 2636852533e..ff24e119668 100644 --- a/theme/semantic-tokens.ts +++ b/theme/semantic-tokens.ts @@ -70,10 +70,10 @@ export const semanticTokens = defineSemanticTokens({ value: { base: '{colors.lightModeBrown.1}', _dark: '{colors.darkModeBrown.1}' }, }, disabled: { - value: { base: '{colors.blue.100}', _dark: '{colors.blue.100}' }, + value: { base: '{colors.blue.100}', _dark: '{colors.blue.300}' }, }, warning: { - value: { base: '{colors.yellow.100}', _dark: '{colors.yellow.100}' }, + value: { base: '{colors.yellow.100}', _dark: '{colors.yellow.300}' }, }, 'notification-text': { value: { base: '{colors.lightModeBrown.12}', _dark: '{colors.darkModeBrown.12}' },