diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 977a65502..8af888826 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -55,7 +55,7 @@ "@rabby-wallet/eth-walletconnect-keyring": "2.1.3", "@rabby-wallet/object-multiplex": "workspace:^", "@rabby-wallet/persist-store": "workspace:^", - "@rabby-wallet/rabby-api": "0.7.12", + "@rabby-wallet/rabby-api": "0.7.14", "@rabby-wallet/rabby-security-engine": "^1.1.17", "@rabby-wallet/rabby-swap": "0.0.36", "@rabby-wallet/service-address": "workspace:^", diff --git a/apps/mobile/src/assets/icons/sign/tx/bg.svg b/apps/mobile/src/assets/icons/sign/tx/bg.svg new file mode 100644 index 000000000..342f7daed --- /dev/null +++ b/apps/mobile/src/assets/icons/sign/tx/bg.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/mobile/src/assets/icons/sign/tx/gas-dark.svg b/apps/mobile/src/assets/icons/sign/tx/gas-dark.svg new file mode 100644 index 000000000..fd37eff34 --- /dev/null +++ b/apps/mobile/src/assets/icons/sign/tx/gas-dark.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/mobile/src/assets/icons/sign/tx/gas-light.svg b/apps/mobile/src/assets/icons/sign/tx/gas-light.svg new file mode 100644 index 000000000..7aedae140 --- /dev/null +++ b/apps/mobile/src/assets/icons/sign/tx/gas-light.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/mobile/src/assets/icons/sign/tx/pay-for-gas.png b/apps/mobile/src/assets/icons/sign/tx/pay-for-gas.png new file mode 100644 index 000000000..b54f91a76 Binary files /dev/null and b/apps/mobile/src/assets/icons/sign/tx/pay-for-gas.png differ diff --git a/apps/mobile/src/assets/icons/sign/tx/rabby.svg b/apps/mobile/src/assets/icons/sign/tx/rabby.svg new file mode 100644 index 000000000..21bfa4e1e --- /dev/null +++ b/apps/mobile/src/assets/icons/sign/tx/rabby.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/mobile/src/assets/locales/en/messages.json b/apps/mobile/src/assets/locales/en/messages.json index 89ba1f70d..d3f045eac 100644 --- a/apps/mobile/src/assets/locales/en/messages.json +++ b/apps/mobile/src/assets/locales/en/messages.json @@ -34,7 +34,7 @@ "gasLimitNotEnough": "Gas limit is less than 21000. Transaction can't be submitted", "gasLimitLessThanExpect": "Gas limit is low. There is 1% chance that the transaction may fail.", "gasLimitLessThanGasUsed": "Gas limit is too low. There is 95% chance that the transaction may fail.", - "nativeTokenNotEngouthForGas": "You do not have enough gas in your wallet", + "nativeTokenNotEngouthForGas": "Gas balance is not enough for transaction", "nonceLowerThanExpect": "Nonce is too low, the minimum should be {{0}}", "canOnlyUseImportedAddress": "You can only use imported addresses to sign", "multiSigChainNotMatch": "Multi-signature addresses are not on this chain and cannot initiate transactions", @@ -298,6 +298,12 @@ "ledgerConnected": "Ledger is connected", "importedByLedger": "Imported by Ledger", "signAndSubmitButton": "Sign and Create", + "gasless": { + "unavailable": "Gas balance is not enough for this transaction", + "notEnough": "Gas balance is not enough", + "GetFreeGasToSign": "Get Free Gas to sign", + "rabbyPayGas": "Rabby'll pay for the gas needed – just sign on" + }, "walletConnect": { "connectedButCantSign": "Connected but unable to sign.", "switchToCorrectAddress": "Please switch to the correct address in mobile wallet", diff --git a/apps/mobile/src/components/Approval/components/FooterBar/ActionsContainer.tsx b/apps/mobile/src/components/Approval/components/FooterBar/ActionsContainer.tsx index 5c19ba2cb..5a427c1c7 100644 --- a/apps/mobile/src/components/Approval/components/FooterBar/ActionsContainer.tsx +++ b/apps/mobile/src/components/Approval/components/FooterBar/ActionsContainer.tsx @@ -50,6 +50,7 @@ export interface Props { children?: React.ReactNode; chain?: Chain; submitText?: string; + gasLess?: boolean; } export const ActionsContainer: React.FC< diff --git a/apps/mobile/src/components/Approval/components/FooterBar/FooterBar.tsx b/apps/mobile/src/components/Approval/components/FooterBar/FooterBar.tsx index a2de31a39..abd869789 100644 --- a/apps/mobile/src/components/Approval/components/FooterBar/FooterBar.tsx +++ b/apps/mobile/src/components/Approval/components/FooterBar/FooterBar.tsx @@ -19,6 +19,7 @@ import { useApprovalSecurityEngine } from '../../hooks/useApprovalSecurityEngine import SecurityLevelTagNoText from '../SecurityEngine/SecurityLevelTagNoText'; import { AccountInfo } from './AccountInfo'; import { ActionGroup, Props as ActionGroupProps } from './ActionGroup'; +import { GasLessNotEnough, GasLessToSign } from './GasLessComponents'; interface Props extends Omit { chain?: Chain; @@ -31,6 +32,10 @@ interface Props extends Omit { isTestnet?: boolean; engineResults?: Result[]; onIgnoreAllRules(): void; + useGasLess?: boolean; + showGasLess?: boolean; + enableGasLess?: () => void; + canUseGasLess?: boolean; } const getStyles = (colors: AppColorsVariants) => @@ -159,7 +164,12 @@ export const FooterBar: React.FC = ({ engineResults = [], hasUnProcessSecurityResult, hasShadow = false, + showGasLess = false, + useGasLess = false, + canUseGasLess = false, onIgnoreAllRules, + enableGasLess, + ...props }) => { const [account, setAccount] = React.useState(); @@ -317,7 +327,13 @@ export const FooterBar: React.FC = ({ account={account} isTestnet={props.isTestnet} /> - + {securityLevel && hasUnProcessSecurityResult && ( = ({ )} + + {showGasLess && + (!securityLevel || !hasUnProcessSecurityResult) && + (canUseGasLess ? ( + { + enableGasLess?.(); + }} + /> + ) : ( + + ))} ); diff --git a/apps/mobile/src/components/Approval/components/FooterBar/GasLessComponents.tsx b/apps/mobile/src/components/Approval/components/FooterBar/GasLessComponents.tsx new file mode 100644 index 000000000..c96c56667 --- /dev/null +++ b/apps/mobile/src/components/Approval/components/FooterBar/GasLessComponents.tsx @@ -0,0 +1,398 @@ +import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react'; +import { default as RcIconGasLight } from '@/assets/icons/sign/tx/gas-light.svg'; +import { default as RcIconGasDark } from '@/assets/icons/sign/tx/gas-dark.svg'; + +import { useTranslation } from 'react-i18next'; +import { default as RcIconLogo } from '@/assets/icons/sign/tx/rabby.svg'; + +import { createGetStyles } from '@/utils/styles'; +import { useThemeColors } from '@/hooks/theme'; + +import { + View, + Text, + ImageBackground, + TouchableOpacity, + TextStyle, + ViewStyle, + DimensionValue, + StyleSheet, +} from 'react-native'; +import { makeThemeIcon } from '@/hooks/makeThemeIcon'; +import LinearGradient from 'react-native-linear-gradient'; +import { StyleProp } from 'react-native'; +import Animated, { + Easing, + interpolate, + useAnimatedStyle, + useSharedValue, + withDelay, + withRepeat, + withSequence, + withTiming, +} from 'react-native-reanimated'; +import { renderText } from '@/utils/renderNode'; +import { colord } from 'colord'; + +const RcIconGas = makeThemeIcon(RcIconGasLight, RcIconGasDark); + +export function GasLessNotEnough() { + const { t } = useTranslation(); + const colors = useThemeColors(); + const styles = useMemo(() => getStyles(colors), [colors]); + return ( + + + + + {t('page.signFooterBar.gasless.unavailable')} + + + ); +} + +function FreeGasReady() { + // const { t } = useTranslation(); + // const colors = useThemeColors(); + // const styles = useMemo(() => getStyles(colors), [colors]); + + return ( + + + + ); +} + +export function GasLessToSign({ + handleFreeGas, + gasLessEnable, +}: { + handleFreeGas: () => void; + gasLessEnable: boolean; +}) { + const { t } = useTranslation(); + const colors = useThemeColors(); + const styles = useMemo(() => getStyles(colors), [colors]); + + const hiddenAnimated = useSharedValue(0); + + const toSignStyle = useAnimatedStyle(() => ({ + display: hiddenAnimated.value !== 1 ? 'flex' : 'none', + })); + + const confirmedStyled = useAnimatedStyle(() => ({ + display: hiddenAnimated.value === 1 ? 'flex' : 'none', + })); + + const [animated, setAnimated] = useState(false); + + const startAnimation = React.useCallback(() => { + setAnimated(true); + handleFreeGas(); + hiddenAnimated.value = withDelay( + 900, + withTiming(1, { + duration: 0, + }), + ); + }, [hiddenAnimated, handleFreeGas]); + + if (gasLessEnable && !animated) { + return ; + } + + return ( + <> + + + + + + {t('page.signFooterBar.gasless.notEnough')} + + + + + {t('page.signFooterBar.gasless.GetFreeGasToSign')} + + + + + + + + + + ); +} + +export const GasLessAnimatedWrapper = ( + props: PropsWithChildren<{ + gasLess?: boolean; + title: string; + titleStyle: StyleProp; + buttonStyle: StyleProp; + showOrigin: boolean; + type?: 'submit' | 'process'; + }>, +) => { + const colors = useThemeColors(); + + const blueBgXValue = useSharedValue(-200); + + const logoXValue = useSharedValue(-10); + + const logoYValue = useSharedValue(0); + + const hiddenAnimated = useSharedValue(0); + + const overlayStyle = useAnimatedStyle( + () => ({ + position: 'absolute', + opacity: 0.5, + width: '100%', + height: '100%', + top: 0, + backgroundColor: colors['neutral-bg-1'], + left: (interpolate(logoXValue.value, [-10, 100], [0, 105]) + + '%') as DimensionValue, //(overlayValue.value + '%') as DimensionValue, + }), + [colors], + ); + + const logoStyle = useAnimatedStyle(() => ({ + alignItems: 'center', + justifyContent: 'center', + width: 16, + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: (logoXValue.value + '%') as DimensionValue, + transform: [{ translateY: logoYValue.value }], + })); + + const blueBgStyle = useAnimatedStyle(() => ({ + width: '200%', + height: '100%', + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + backgroundColor: colors['blue-default'], + left: (blueBgXValue.value + '%') as DimensionValue, + })); + + const processBgColor = useMemo( + () => colord(colors['blue-default']).alpha(0.5).toRgbString(), + [colors], + ); + + const bgStyle = useAnimatedStyle( + () => + logoXValue.value > -10 + ? { + backgroundColor: processBgColor, + } + : {}, + [processBgColor], + ); + + const start = React.useCallback(() => { + blueBgXValue.value = withTiming(-100, { + duration: 900, + easing: Easing.linear, + }); + + logoXValue.value = withTiming(100, { + duration: 900, + easing: Easing.linear, + }); + + const config = { + duration: 75, + easing: Easing.linear, + }; + + logoYValue.value = withRepeat( + withSequence( + withTiming(-16, config), + withTiming(0, config), + withTiming(16, config), + withTiming(0, config), + ), + 3, + true, + ); + + hiddenAnimated.value = withDelay( + 910, + withTiming(1, { + duration: 0, + }), + ); + }, [blueBgXValue, hiddenAnimated, logoXValue, logoYValue]); + + const showOriginButtonStyle = useAnimatedStyle(() => ({ + display: hiddenAnimated.value === 1 ? 'flex' : 'none', + })); + + const showAnimatedButtonStyle = useAnimatedStyle(() => ({ + display: hiddenAnimated.value === 1 ? 'none' : 'flex', + })); + + useEffect(() => { + if (props.gasLess) { + start(); + } + }, [start, props.gasLess]); + + if (props.showOrigin) { + return <>{props.children}; + } + + return ( + <> + + {props.children} + + + + + + + + + {renderText(props.title, { + style: StyleSheet.flatten([ + props.titleStyle, + props.gasLess ? { color: colors['neutral-title-2'] } : {}, + ]), + })} + + + + + ); +}; + +const getStyles = createGetStyles(colors => ({ + securityLevelTip: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 15, + backgroundColor: colors['neutral-card-2'], + color: colors['neutral-card-2'], + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 4, + position: 'relative', + }, + tipTriangle: { + position: 'absolute', + top: -13, + left: 110, + width: 0, + height: 0, + backgroundColor: 'transparent', + borderStyle: 'solid', + borderLeftWidth: 5, + borderRightWidth: 5, + borderTopWidth: 5, + borderBottomWidth: 8, + borderLeftColor: 'transparent', + borderRightColor: 'transparent', + borderTopColor: 'transparent', + borderBottomColor: colors['neutral-card-2'], + }, + text: { + flex: 1, + color: colors['neutral-title-1'], + fontSize: 12, + fontWeight: '500', + }, + imageBackground: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + backgroundColor: 'pink', + }, + image: { + resizeMode: 'contain', + width: 100, + }, + container: { + flexDirection: 'row', + alignItems: 'center', + }, + gasToSign: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + backgroundColor: colors['neutral-card-2'], + color: colors['neutral-card-2'], + }, + gasText: { + color: colors['neutral-title-1'], + }, + linearGradient: { + marginHorizontal: 'auto', + paddingHorizontal: 10, + paddingVertical: 7, + borderRadius: 6, + }, + linearGradientText: { + fontSize: 11, + color: colors['neutral-title-2'], + cursor: 'pointer', + }, +})); diff --git a/apps/mobile/src/components/Approval/components/FooterBar/ProcessActions.tsx b/apps/mobile/src/components/Approval/components/FooterBar/ProcessActions.tsx index 8eb12be1c..23317bc81 100644 --- a/apps/mobile/src/components/Approval/components/FooterBar/ProcessActions.tsx +++ b/apps/mobile/src/components/Approval/components/FooterBar/ProcessActions.tsx @@ -6,6 +6,7 @@ import { StyleSheet, View } from 'react-native'; import { Button } from '@/components'; import { AppColorsVariants } from '@/constant/theme'; import { useThemeColors } from '@/hooks/theme'; +import { GasLessAnimatedWrapper } from './GasLessComponents'; const getStyles = (colors: AppColorsVariants) => StyleSheet.create({ @@ -32,6 +33,7 @@ export const ProcessActions: React.FC = ({ disabledProcess, tooltipContent, submitText, + gasLess, }) => { const { t } = useTranslation(); const colors = useThemeColors(); @@ -43,15 +45,30 @@ export const ProcessActions: React.FC = ({ // @ts-expect-error content={tooltipContent}> -