Skip to content

Commit

Permalink
feat(llm): 🥅 display human readable errors when the send flow fails (#…
Browse files Browse the repository at this point in the history
…8081)

* feat(llm): add a Collapsible component

* feat(llm): add a copy button component

* feat(llm): add the SendBroadcastError screen

* feat(llm): catch the send flow broadcast errors

* chore: update change log

* fix(llm): linting

* chore(llm): import `Icons` from native-ui instead of the individual icons

* chore(llm): use the LLM alias for newArch components

* fix(llm): only clamp the technical error messages to 3 lines

* fix(llm): lower button opacity when pressed

* chore(llm): remove unecessary styles

* fix(llm): use a press event on the collapsable toggle

* fix(llm): show the currency info in the erorr

* fix(llm): error typo

* fix(llm): tx broadcast error network name

* chore(llm): extract urls from `createTransactionBroadcastError`

* chore(lld): extract urls from createTransactionBroadcastError
  • Loading branch information
thesan authored Oct 30, 2024
1 parent 027b0dc commit ed24bfd
Show file tree
Hide file tree
Showing 14 changed files with 353 additions and 8 deletions.
6 changes: 6 additions & 0 deletions .changeset/eighty-geckos-smash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"live-mobile": minor
"@ledgerhq/live-common": patch
---

Display human readable errors when the send flow fails
7 changes: 7 additions & 0 deletions apps/ledger-live-desktop/src/config/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,13 @@ export const urls = {
"https://shop.ledger.com?utm_source=live&utm_medium=draw&utm_campaign=ledger_sync_lns_uncompatible&utm_content=to_shop",
learnMoreLedgerSync:
"https://www.ledger.com/blog-ledger-sync-synchronize-your-crypto-accounts-effortless-private-and-secure",

// Node errors
txBroadcastErrors: {
badTxns: "https://support.ledger.com/article/5129526865821-zd",
blobsLimit: "https://support.ledger.com/article/17830974229661-zd",
txnMempoolConflict: "https://support.ledger.com/article/14593285242525-zd",
},
};

export const vaultSigner = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import SuccessDisplay from "~/renderer/components/SuccessDisplay";
import { OperationDetails } from "~/renderer/drawers/OperationDetails";
import { setDrawer } from "~/renderer/drawers/Provider";
import { multiline } from "~/renderer/styles/helpers";
import { urls } from "~/config/urls";
import { StepProps } from "../types";
import NodeError from "./Confirmation/NodeError";
import ErrorDisplay from "~/renderer/components/ErrorDisplay";
Expand Down Expand Up @@ -89,7 +90,7 @@ function StepConfirmation({
/>
{signed ? (
<NodeError
error={createTransactionBroadcastError(error, {
error={createTransactionBroadcastError(error, urls, {
coin: ticker,
network: String(mainAccount?.currency.name),
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import SendSummary from "~/screens/SendFunds/04-Summary";
import SelectDevice from "~/screens/SelectDevice";
import SendConnectDevice from "~/screens/ConnectDevice";
import SendValidationSuccess from "~/screens/SendFunds/07-ValidationSuccess";
import SendBroadcastError from "~/screens/SendFunds/07-SendBroadcastError";
import SendValidationError from "~/screens/SendFunds/07-ValidationError";
import { getStackNavigatorConfig } from "~/navigation/navigatorConfig";
import StepHeader from "../StepHeader";
Expand Down Expand Up @@ -190,6 +191,11 @@ export default function SendFundsNavigator() {
gestureEnabled: false,
}}
/>
<Stack.Screen
name={ScreenName.SendBroadcastError}
component={SendBroadcastError}
options={{ headerLeft: () => null, headerTitle: () => null }}
/>
<Stack.Screen
name={ScreenName.SendValidationError}
component={SendValidationError}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Device } from "@ledgerhq/live-common/hw/actions/types";
import type { Operation } from "@ledgerhq/types-live";
import type { Transaction, TransactionStatus } from "@ledgerhq/live-common/generated/types";
import type { Transaction as EvmTransaction, GasOptions } from "@ledgerhq/coin-evm/types/index";
import type { TransactionBroadcastError } from "@ledgerhq/live-common/errors/transactionBroadcastErrors";
import type {
CardanoAccount,
Transaction as CardanoTransaction,
Expand Down Expand Up @@ -114,6 +115,14 @@ export type SendFundsNavigatorStackParamList = {
transaction: Transaction;
result: Operation;
};
[ScreenName.SendBroadcastError]:
| undefined
| {
error: TransactionBroadcastError;
account?: AccountLike;
accountId?: string;
parentId?: string;
};
[ScreenName.SendValidationError]:
| undefined
| {
Expand Down
1 change: 1 addition & 0 deletions apps/ledger-live-mobile/src/const/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export enum ScreenName {
SendSelectDevice = "SendSelectDevice",
SendSelectRecipient = "SendSelectRecipient",
SendSummary = "SendSummary",
SendBroadcastError = "SendBroadcastError",
SendValidationError = "SendValidationError",
TransactionAlreadyValidatedError = "TransactionAlreadyValidatedError",
SendValidationSuccess = "SendValidationSuccess",
Expand Down
14 changes: 14 additions & 0 deletions apps/ledger-live-mobile/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,20 @@
"title": "{{message}}",
"description": "Something went wrong. Please retry. If the problem persists, please save your logs using the button below and provide them to Ledger Support."
},
"TransactionBroadcastError": {
"title": "Transaction Broadcast Unsuccessfull",
"description": "Your transaction failed to broadcast on the {{network}} network. Your {{coin}} have not been transferred and remain in your account.",
"needHelp": "Need help ?",
"helpCenter": {
"title": "Help center",
"desc": "Visit our Help center or contact us via the widget for assistance.",
"cta": "Get help"
},
"technical": {
"title": "Technical error",
"cta": "Save logs"
}
},
"FeeEstimationFailed": {
"title": "Sorry, fee estimation failed",
"description": "Try setting the fee manually (status: {{status}})."
Expand Down
24 changes: 23 additions & 1 deletion apps/ledger-live-mobile/src/logic/screenTransactionHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import {
formatOperation,
formatAccount,
} from "@ledgerhq/live-common/account/index";
import {
createTransactionBroadcastError,
TransactionBroadcastError,
} from "@ledgerhq/live-common/errors/transactionBroadcastErrors";
import { formatTransaction } from "@ledgerhq/live-common/transaction/index";
import { getAccountBridge } from "@ledgerhq/live-common/bridge/index";
import { execAndWaitAtLeast } from "@ledgerhq/live-common/promise";
Expand All @@ -24,6 +28,7 @@ import { StackNavigationProp } from "@react-navigation/stack";
import { updateAccountWithUpdater } from "../actions/accounts";
import logger from "../logger";
import { ScreenName } from "~/const";
import { urls } from "~/utils/urls";
import type {
StackNavigatorNavigation,
StackNavigatorRoute,
Expand Down Expand Up @@ -260,7 +265,14 @@ export function useSignedTxHandler({
throw transactionSignError;
}

const operation = await broadcast(signedOperation);
const operation = await broadcast(signedOperation).catch((err: Error) => {
const currency = mainAccount.currency;
throw createTransactionBroadcastError(err, urls, {
network: currency.name,
coin: currency.ticker,
});
});

log(
"transaction-summary",
`✔️ broadcasted! optimistic operation: ${formatOperation(mainAccount)(operation)}`,
Expand All @@ -282,6 +294,16 @@ export function useSignedTxHandler({
logger.critical(error as Error);
}

if (
error instanceof TransactionBroadcastError &&
route.name === ScreenName.SendConnectDevice
) {
return (navigation as StackNavigationProp<{ [key: string]: object }>).replace(
ScreenName.SendBroadcastError,
{ ...route.params, error },
);
}

(navigation as StackNavigationProp<{ [key: string]: object }>).replace(
route.name.replace("ConnectDevice", "ValidationError"),
{ ...route.params, error },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React, { memo, ReactNode, useCallback, useState } from "react";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
runOnJS,
} from "react-native-reanimated";
import styled from "styled-components/native";
import { Flex, Icons, Text } from "@ledgerhq/native-ui";
import { FlexBoxProps } from "@ledgerhq/native-ui/lib/components/Layout/Flex/index";

export default memo(Collapsible);

type Props = FlexBoxProps & {
title: ReactNode;
children: ReactNode;
collapsed?: boolean;
};

function Collapsible({ title, children, collapsed = false, ...titleContainerProps }: Props) {
const [isCollapsed, setIsCollapsed] = useState(collapsed);
const collapseAnimation = useSharedValue(collapsed ? 0 : 1);

const toggleCollapsed = useCallback(() => {
const value = Math.round(collapseAnimation.value + 1) % 2;

const { onStart, onDone }: { onStart?: () => void; onDone: () => void } =
value === 0
? { onDone: () => setIsCollapsed(true) }
: { onStart: () => setIsCollapsed(false), onDone: () => {} };

onStart?.();

collapseAnimation.value = withTiming(value, { duration: 200 }, finished => {
if (finished) {
runOnJS(onDone)();
}
});
}, [collapseAnimation]);

const animatedChevron = useAnimatedStyle(() => ({
transform: [{ rotate: `${collapseAnimation.value * 90}deg` }],
}));
const animateContent = useAnimatedStyle(() => ({
maxHeight: `${collapseAnimation.value * 100}%`,
}));

const header = typeof title === "string" ? <Text>{title}</Text> : title;

return (
<>
<Flex {...titleContainerProps}>
<Toggle activeOpacity={1} onPress={toggleCollapsed}>
{header}
<Animated.View style={animatedChevron}>
<Icons.ChevronRight />
</Animated.View>
</Toggle>
</Flex>

<Animated.View style={[animateContent, { overflow: "hidden" }]}>
{!isCollapsed && children}
</Animated.View>
</>
);
}

const Toggle = styled.TouchableOpacity`
flex-direction: row;
align-items: center;
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import Clipboard from "@react-native-clipboard/clipboard";
import React, { memo, useCallback, useMemo } from "react";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
import styled from "styled-components/native";
import { Button, Icons } from "@ledgerhq/native-ui";
import { ButtonProps } from "@ledgerhq/native-ui/components/cta/Button";

export default memo(CopyButton);

type Props = Omit<ButtonProps, "Icon" | "isNewIcon" | "onPress"> & {
text: string;
transitionDuration?: number;
};

function CopyButton({ text, ...props }: Props) {
const transition = useSharedValue(0);
const handleCopy = useCallback(() => {
Clipboard.setString(text);

transition.value = withTiming(1, { duration: 200 });
setTimeout(() => (transition.value = withTiming(0, { duration: 200 })), 1200);
}, [text, transition]);

const copyIconAnimation = useAnimatedStyle(() => ({
opacity: 1 - transition.value,
}));

const checkIconAnimation = useAnimatedStyle(() => ({
opacity: transition.value,
}));

const icon = useMemo(
() => (
<IconContainer>
<Animated.View style={copyIconAnimation}>
<Icons.Copy />
</Animated.View>
<Animated.View style={[checkIconAnimation, checkIconStyle]}>
<Icons.Check />
</Animated.View>
</IconContainer>
),
[copyIconAnimation, checkIconAnimation],
);

return <Button {...props} Icon={icon} isNewIcon onPress={handleCopy} />;
}

const IconContainer = styled.View`
position: relative;
`;
const checkIconStyle = {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
} as const;
Loading

0 comments on commit ed24bfd

Please sign in to comment.