diff --git a/dapp/.env b/dapp/.env new file mode 100644 index 000000000..50d08e6aa --- /dev/null +++ b/dapp/.env @@ -0,0 +1 @@ +VITE_USE_TESTNET=true diff --git a/dapp/manifest-ledger-live-app.json b/dapp/manifest-ledger-live-app.json index a03b1bb09..29f63c113 100644 --- a/dapp/manifest-ledger-live-app.json +++ b/dapp/manifest-ledger-live-app.json @@ -9,7 +9,7 @@ "manifestVersion": "1", "branch": "stable", "categories": ["buy"], - "currencies": ["bitcoin", "bitcoin_testnet"], + "currencies": ["bitcoin", "bitcoin_testnet", "ethereum", "ethereum_goerli"], "content": { "shortDescription": { "en": "Bitcoin Liquid Staking" @@ -18,6 +18,6 @@ "en": "Bitcoin Liquid Staking" } }, - "permissions": [], + "permissions": ["account.request"], "domains": ["http://*"] } diff --git a/dapp/src/DApp.tsx b/dapp/src/DApp.tsx index 26d5b96be..6f5e4a9bc 100644 --- a/dapp/src/DApp.tsx +++ b/dapp/src/DApp.tsx @@ -1,15 +1,21 @@ import React from "react" -import { ChakraProvider, Button, Box } from "@chakra-ui/react" -import { useDetectThemeMode } from "./hooks" -import { LedgerWalletAPIProvider } from "./providers" +import { ChakraProvider, Box, Text } from "@chakra-ui/react" +import { useDetectThemeMode, useWalletContext } from "./hooks" import theme from "./theme" +import { LedgerWalletAPIProvider, WalletContextProvider } from "./contexts" +import Navbar from "./components/Navbar" function DApp() { useDetectThemeMode() + + const { btcAccount, ethAccount } = useWalletContext() + return ( - + +

Ledger live - Acre dApp

- + {btcAccount && Account: {btcAccount.address}} + {ethAccount && Account: {ethAccount.address}}
) } @@ -17,9 +23,11 @@ function DApp() { function DAppProviders() { return ( - - - + + + + + ) } diff --git a/dapp/src/assets/bitcoin.svg b/dapp/src/assets/bitcoin.svg new file mode 100644 index 000000000..8b99c75c1 --- /dev/null +++ b/dapp/src/assets/bitcoin.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dapp/src/assets/ethereum.svg b/dapp/src/assets/ethereum.svg new file mode 100644 index 000000000..0a528dc23 --- /dev/null +++ b/dapp/src/assets/ethereum.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dapp/src/assets/info.svg b/dapp/src/assets/info.svg new file mode 100644 index 000000000..5842e381c --- /dev/null +++ b/dapp/src/assets/info.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/dapp/src/components/Navbar/ConnectWallet.tsx b/dapp/src/components/Navbar/ConnectWallet.tsx new file mode 100644 index 000000000..9e761ea9c --- /dev/null +++ b/dapp/src/components/Navbar/ConnectWallet.tsx @@ -0,0 +1,77 @@ +import React from "react" +import { Box, Button, Image, Text } from "@chakra-ui/react" +import { Account } from "@ledgerhq/wallet-api-client" +import BitcoinIcon from "../../assets/bitcoin.svg" +import EthereumIcon from "../../assets/ethereum.svg" +import InfoIcon from "../../assets/info.svg" +import { BITCOIN } from "../../constants" +import { + useRequestBitcoinAccount, + useRequestEthereumAccount, + useWalletContext, +} from "../../hooks" +import { formatSatoshiAmount, truncateAddress } from "../../utils" + +export type ConnectButtonsProps = { + leftIcon: string + rightIcon: string + account: Account | undefined + requestAccount: () => Promise +} + +function ConnectButton({ + leftIcon, + rightIcon, + account, + requestAccount, +}: ConnectButtonsProps) { + const styles = !account ? { color: "error", borderColor: "error" } : undefined + return ( + + ) +} + +export default function ConnectWallet() { + const { requestAccount: requestBitcoinAccount } = useRequestBitcoinAccount() + const { requestAccount: requestEthereumAccount } = useRequestEthereumAccount() + const { btcAccount, ethAccount } = useWalletContext() + + return ( + + + Balance + + {!btcAccount || btcAccount?.balance.isZero() + ? "0.00" + : formatSatoshiAmount(btcAccount.balance.toString())} + + {BITCOIN.symbol} + + { + await requestBitcoinAccount() + }} + /> + { + await requestEthereumAccount() + }} + /> + + ) +} diff --git a/dapp/src/components/Navbar/index.tsx b/dapp/src/components/Navbar/index.tsx new file mode 100644 index 000000000..ef0221cc7 --- /dev/null +++ b/dapp/src/components/Navbar/index.tsx @@ -0,0 +1,11 @@ +import React from "react" +import { Box } from "@chakra-ui/react" +import ConnectWallet from "./ConnectWallet" + +export default function Navbar() { + return ( + + + + ) +} diff --git a/dapp/src/constants/currency.ts b/dapp/src/constants/currency.ts new file mode 100644 index 000000000..f45406c7f --- /dev/null +++ b/dapp/src/constants/currency.ts @@ -0,0 +1,19 @@ +import { Currency } from "../types" + +export const BITCOIN: Currency = { + name: "Bitcoin", + symbol: "BTC", + decimals: 8, +} + +export const ETHEREUM: Currency = { + name: "Ethereum", + symbol: "ETH", + decimals: 18, +} + +export const CURRENCY_ID_BITCOIN = + import.meta.env.VITE_USE_TESTNET === "true" ? "bitcoin_testnet" : "bitcoin" + +export const CURRENCY_ID_ETHEREUM = + import.meta.env.VITE_USE_TESTNET === "true" ? "ethereum_goerli" : "ethereum" diff --git a/dapp/src/constants/index.ts b/dapp/src/constants/index.ts new file mode 100644 index 000000000..68cb50031 --- /dev/null +++ b/dapp/src/constants/index.ts @@ -0,0 +1 @@ +export * from "./currency" diff --git a/dapp/src/providers/LedgerWalletAPIProvider.tsx b/dapp/src/contexts/LedgerWalletAPIProvider.tsx similarity index 93% rename from dapp/src/providers/LedgerWalletAPIProvider.tsx rename to dapp/src/contexts/LedgerWalletAPIProvider.tsx index 3581cad6f..492f4ee45 100644 --- a/dapp/src/providers/LedgerWalletAPIProvider.tsx +++ b/dapp/src/contexts/LedgerWalletAPIProvider.tsx @@ -19,7 +19,7 @@ type LedgerWalletAPIProviderProps = { children: React.ReactElement } -export default function LedgerWalletAPIProvider({ +export function LedgerWalletAPIProvider({ children, }: LedgerWalletAPIProviderProps): JSX.Element { const transport = getWalletAPITransport() diff --git a/dapp/src/contexts/WalletContext.tsx b/dapp/src/contexts/WalletContext.tsx new file mode 100644 index 000000000..f327ce0bd --- /dev/null +++ b/dapp/src/contexts/WalletContext.tsx @@ -0,0 +1,38 @@ +import { Account } from "@ledgerhq/wallet-api-client" +import React, { createContext, useMemo, useState } from "react" + +type WalletContextValue = { + btcAccount: Account | undefined + setBtcAccount: React.Dispatch> + ethAccount: Account | undefined + setEthAccount: React.Dispatch> +} + +export const WalletContext = createContext( + undefined, +) + +export function WalletContextProvider({ + children, +}: { + children: React.ReactNode +}): React.ReactElement { + const [btcAccount, setBtcAccount] = useState(undefined) + const [ethAccount, setEthAccount] = useState(undefined) + + const contextValue: WalletContextValue = useMemo( + () => ({ + btcAccount, + setBtcAccount, + ethAccount, + setEthAccount, + }), + [btcAccount, setBtcAccount, ethAccount, setEthAccount], + ) + + return ( + + {children} + + ) +} diff --git a/dapp/src/contexts/index.tsx b/dapp/src/contexts/index.tsx new file mode 100644 index 000000000..3ea3058ed --- /dev/null +++ b/dapp/src/contexts/index.tsx @@ -0,0 +1,2 @@ +export * from "./WalletContext" +export * from "./LedgerWalletAPIProvider" diff --git a/dapp/src/hooks/index.ts b/dapp/src/hooks/index.ts index dfc730d5e..038388476 100644 --- a/dapp/src/hooks/index.ts +++ b/dapp/src/hooks/index.ts @@ -1 +1,4 @@ export * from "./useDetectThemeMode" +export * from "./useRequestBitcoinAccount" +export * from "./useRequestEthereumAccount" +export * from "./useWalletContext" diff --git a/dapp/src/hooks/useRequestBitcoinAccount.ts b/dapp/src/hooks/useRequestBitcoinAccount.ts new file mode 100644 index 000000000..971a3f9ef --- /dev/null +++ b/dapp/src/hooks/useRequestBitcoinAccount.ts @@ -0,0 +1,20 @@ +import { useRequestAccount } from "@ledgerhq/wallet-api-client-react" +import { useCallback, useContext, useEffect } from "react" +import { CURRENCY_ID_BITCOIN } from "../constants" +import { UseRequestAccountReturn } from "../types" +import { WalletContext } from "../contexts" + +export function useRequestBitcoinAccount(): UseRequestAccountReturn { + const walletContext = useContext(WalletContext) + const { account, requestAccount } = useRequestAccount() + + useEffect(() => { + walletContext?.setBtcAccount(account || undefined) + }, [account, walletContext]) + + const requestBitcoinAccount = useCallback(async () => { + await requestAccount({ currencyIds: [CURRENCY_ID_BITCOIN] }) + }, [requestAccount]) + + return { requestAccount: requestBitcoinAccount } +} diff --git a/dapp/src/hooks/useRequestEthereumAccount.ts b/dapp/src/hooks/useRequestEthereumAccount.ts new file mode 100644 index 000000000..ebeb1f268 --- /dev/null +++ b/dapp/src/hooks/useRequestEthereumAccount.ts @@ -0,0 +1,20 @@ +import { useRequestAccount } from "@ledgerhq/wallet-api-client-react" +import { useCallback, useContext, useEffect } from "react" +import { CURRENCY_ID_ETHEREUM } from "../constants" +import { UseRequestAccountReturn } from "../types" +import { WalletContext } from "../contexts" + +export function useRequestEthereumAccount(): UseRequestAccountReturn { + const walletContext = useContext(WalletContext) + const { account, requestAccount } = useRequestAccount() + + useEffect(() => { + walletContext?.setEthAccount(account || undefined) + }, [account, walletContext]) + + const requestEthereumAccount = useCallback(async () => { + await requestAccount({ currencyIds: [CURRENCY_ID_ETHEREUM] }) + }, [requestAccount]) + + return { requestAccount: requestEthereumAccount } +} diff --git a/dapp/src/hooks/useWalletContext.ts b/dapp/src/hooks/useWalletContext.ts new file mode 100644 index 000000000..0da19204b --- /dev/null +++ b/dapp/src/hooks/useWalletContext.ts @@ -0,0 +1,12 @@ +import { useContext } from "react" +import { WalletContext } from "../contexts" + +export function useWalletContext() { + const context = useContext(WalletContext) + + if (!context) { + throw new Error("WalletContext used outside of WalletContext component") + } + + return context +} diff --git a/dapp/src/providers/index.ts b/dapp/src/providers/index.ts deleted file mode 100644 index c8f56e2c2..000000000 --- a/dapp/src/providers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as LedgerWalletAPIProvider } from "./LedgerWalletAPIProvider" diff --git a/dapp/src/theme/Button.ts b/dapp/src/theme/Button.ts index ffdf107ba..ac143f407 100644 --- a/dapp/src/theme/Button.ts +++ b/dapp/src/theme/Button.ts @@ -2,11 +2,18 @@ import { mode } from "@chakra-ui/theme-tools" import type { StyleFunctionProps } from "@chakra-ui/styled-system" const Button = { + baseStyle: { + rounded: "none", + }, variants: { solid: (props: StyleFunctionProps) => ({ backgroundColor: mode("black", "purple")(props), color: "white", }), + outline: (props: StyleFunctionProps) => ({ + color: mode("black", "grey.80")(props), + borderColor: mode("black", "grey.50")(props), + }), }, } diff --git a/dapp/src/theme/index.ts b/dapp/src/theme/index.ts index 16aa51d34..980c42838 100644 --- a/dapp/src/theme/index.ts +++ b/dapp/src/theme/index.ts @@ -1,9 +1,18 @@ -import { extendTheme } from "@chakra-ui/react" +import { StyleFunctionProps, extendTheme } from "@chakra-ui/react" +import { mode } from "@chakra-ui/theme-tools" import Button from "./Button" import { colors } from "./utils" const defaultTheme = { colors, + styles: { + global: (props: StyleFunctionProps) => ({ + body: { + backgroundColor: mode("lightGrey", "darkGrey")(props), + color: mode("black", "grey.80")(props), + }, + }), + }, components: { Button, }, diff --git a/dapp/src/theme/utils/colors.ts b/dapp/src/theme/utils/colors.ts index aea5a8394..5ea1f1720 100644 --- a/dapp/src/theme/utils/colors.ts +++ b/dapp/src/theme/utils/colors.ts @@ -4,4 +4,11 @@ export const colors = { white: "#FFF", black: "#000", purple: "#7D00FF", + error: "#F00", + grey: { + 50: "rgba(255, 255, 255, 0.50)", + 80: "rgba(255, 255, 255, 0.80)", + }, + lightGrey: "#ECECEC", + darkGrey: "#1A1B1D", } diff --git a/dapp/src/types/currency.ts b/dapp/src/types/currency.ts new file mode 100644 index 000000000..f5d8f5419 --- /dev/null +++ b/dapp/src/types/currency.ts @@ -0,0 +1,5 @@ +export type Currency = { + name: string + symbol: string + decimals: number +} diff --git a/dapp/src/types/index.ts b/dapp/src/types/index.ts new file mode 100644 index 000000000..1e77e81e7 --- /dev/null +++ b/dapp/src/types/index.ts @@ -0,0 +1,2 @@ +export * from "./ledger-live-app" +export * from "./currency" diff --git a/dapp/src/types/ledger-live-app.ts b/dapp/src/types/ledger-live-app.ts new file mode 100644 index 000000000..c63368d3e --- /dev/null +++ b/dapp/src/types/ledger-live-app.ts @@ -0,0 +1,7 @@ +import { WalletAPIClient } from "@ledgerhq/wallet-api-client" + +type RequestAccountParams = Parameters + +export type UseRequestAccountReturn = { + requestAccount: (...params: RequestAccountParams) => Promise +} diff --git a/dapp/src/utils/address.ts b/dapp/src/utils/address.ts new file mode 100644 index 000000000..7ed3caa1a --- /dev/null +++ b/dapp/src/utils/address.ts @@ -0,0 +1,3 @@ +export function truncateAddress(address: string): string { + return `${address.slice(0, 6)}…${address.slice(-5)}` +} diff --git a/dapp/src/utils/index.ts b/dapp/src/utils/index.ts new file mode 100644 index 000000000..613e0f071 --- /dev/null +++ b/dapp/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./numbers" +export * from "./address" diff --git a/dapp/src/utils/numbers.ts b/dapp/src/utils/numbers.ts new file mode 100644 index 000000000..dcd077dfd --- /dev/null +++ b/dapp/src/utils/numbers.ts @@ -0,0 +1,43 @@ +const toLocaleString = (value: number): string => + value.toLocaleString("default", { maximumFractionDigits: 2 }) + +/** + * Convert a fixed point bigint with precision `fixedPointDecimals` to a + * floating point number truncated to `desiredDecimals`. + * If `formattedAmount` is less than the minimum amount to display + * for the specified precision return information about this. + * + * This function is based on the solution used by the Taho extension. + * More info: https://github.com/tahowallet/extension/blob/main/background/lib/fixed-point.ts#L216-L239 + */ +export function bigIntToUserAmount( + fixedPoint: bigint, + fixedPointDecimals: number, + desiredDecimals = 2, +): string { + const fixedPointDesiredDecimalsAmount = + fixedPoint / + 10n ** BigInt(Math.max(1, fixedPointDecimals - desiredDecimals)) + + const formattedAmount = + Number(fixedPointDesiredDecimalsAmount) / + 10 ** Math.min(desiredDecimals, fixedPointDecimals) + const minAmountToDisplay = + 1 / 10 ** Math.min(desiredDecimals, fixedPointDecimals) + + if (minAmountToDisplay > formattedAmount) { + return `<0.${"0".repeat(desiredDecimals - 1)}1` + } + + return toLocaleString(formattedAmount) +} +export const formatTokenAmount = ( + amount: number | string, + decimals = 18, + desiredDecimals = 2, +) => bigIntToUserAmount(BigInt(amount), decimals, desiredDecimals) + +export const formatSatoshiAmount = ( + amount: number | string, + desiredDecimals = 2, +) => formatTokenAmount(amount, 8, desiredDecimals)