diff --git a/apps/faucet/package.json b/apps/faucet/package.json index 13b9fb713..ac40aef38 100644 --- a/apps/faucet/package.json +++ b/apps/faucet/package.json @@ -18,6 +18,8 @@ "dependencies": { "@cosmjs/encoding": "^0.29.0", "@namada/components": "0.2.1", + "@namada/hooks": "0.2.1", + "@namada/integrations": "0.2.1", "@namada/utils": "0.2.1", "buffer": "^6.0.3", "dompurify": "^3.0.2", diff --git a/apps/faucet/src/App/App.components.ts b/apps/faucet/src/App/App.components.ts index 6009fd739..ea4de2696 100644 --- a/apps/faucet/src/App/App.components.ts +++ b/apps/faucet/src/App/App.components.ts @@ -1,5 +1,5 @@ -import styled, { createGlobalStyle } from "styled-components"; import { ColorMode, DesignConfiguration } from "@namada/utils"; +import styled, { createGlobalStyle } from "styled-components"; type GlobalStyleProps = { colorMode: ColorMode; @@ -120,6 +120,10 @@ export const BackgroundImage = styled.div<{ background-size: 120px; `; +export const InfoContainer = styled.div` + margin: 40px 20px; +`; + export const ContentContainer = styled.div` display: flex; flex-direction: column; diff --git a/apps/faucet/src/App/App.tsx b/apps/faucet/src/App/App.tsx index 0f000eefe..09094685b 100644 --- a/apps/faucet/src/App/App.tsx +++ b/apps/faucet/src/App/App.tsx @@ -1,7 +1,8 @@ -import React, { createContext, useEffect, useState } from "react"; +import React, { createContext, useCallback, useEffect, useState } from "react"; import { ThemeProvider } from "styled-components"; -import { Heading } from "@namada/components"; +import { ActionButton, Alert, Heading } from "@namada/components"; +import { Namada } from "@namada/integrations"; import { ColorMode, getTheme } from "@namada/utils"; import { @@ -13,10 +14,14 @@ import { ContentContainer, FaucetContainer, GlobalStyles, + InfoContainer, TopSection, } from "App/App.components"; import { FaucetForm } from "App/Faucet"; +import { chains } from "@namada/chains"; +import { useUntil } from "@namada/hooks"; +import { Account, AccountType } from "@namada/types"; import { requestSettings } from "utils"; import dotsBackground from "../../public/bg-dots.svg"; import { CallToActionCard } from "./CallToActionCard"; @@ -78,8 +83,21 @@ export const AppContext = createContext({ url, }); +enum ExtensionAttachStatus { + PendingDetection, + NotInstalled, + Installed, +} + export const App: React.FC = () => { const initialColorMode = "dark"; + const chain = chains.namada; + const integration = new Namada(chain); + const [extensionAttachStatus, setExtensionAttachStatus] = useState( + ExtensionAttachStatus.PendingDetection + ); + const [isExtensionConnected, setIsExtensionConnected] = useState(false); + const [accounts, setAccounts] = useState([]); const [colorMode, _] = useState(initialColorMode); const [isTestnetLive, setIsTestnetLive] = useState(true); const [settings, setSettings] = useState({ @@ -88,6 +106,20 @@ export const App: React.FC = () => { const [settingsError, setSettingsError] = useState(); const theme = getTheme(colorMode); + useUntil( + { + predFn: async () => Promise.resolve(integration.detect()), + onSuccess: () => { + setExtensionAttachStatus(ExtensionAttachStatus.Installed); + }, + onFail: () => { + setExtensionAttachStatus(ExtensionAttachStatus.NotInstalled); + }, + }, + { tries: 5, ms: 300 }, + [integration] + ); + useEffect(() => { const { startsAt } = settings; const now = new Date(); @@ -126,6 +158,32 @@ export const App: React.FC = () => { })(); }, []); + const handleConnectExtensionClick = useCallback(async (): Promise => { + if (integration) { + try { + const isIntegrationDetected = integration.detect(); + + if (!isIntegrationDetected) { + throw new Error("Extension not installed!"); + } + + await integration.connect(); + const accounts = await integration.accounts(); + if (accounts) { + setAccounts( + accounts.filter( + (account) => + !account.isShielded && account.type !== AccountType.Ledger + ) + ); + } + setIsExtensionConnected(true); + } catch (e) { + console.error(e); + } + } + }, [integration]); + return ( { - - Namada Faucet + + Namada Shielded Expedition Faucet - + {extensionAttachStatus === + ExtensionAttachStatus.PendingDetection && ( + + Detecting extension... + + )} + {extensionAttachStatus === ExtensionAttachStatus.NotInstalled && ( + + You must download the extension! + + )} + {isExtensionConnected && ( + + )} + {extensionAttachStatus === ExtensionAttachStatus.Installed && + !isExtensionConnected && ( + + + Connect to Namada Extension + + + )} diff --git a/apps/faucet/src/App/Faucet.tsx b/apps/faucet/src/App/Faucet.tsx index e9afddb77..3d548cc62 100644 --- a/apps/faucet/src/App/Faucet.tsx +++ b/apps/faucet/src/App/Faucet.tsx @@ -1,15 +1,26 @@ -import { ActionButton, Alert, AmountInput, Input } from "@namada/components"; import BigNumber from "bignumber.js"; import { sanitize } from "dompurify"; import React, { useCallback, useContext, useEffect, useState } from "react"; -import { useTheme } from "styled-components"; +import { + ActionButton, + Alert, + AmountInput, + Input, + Select, +} from "@namada/components"; +import { Namada } from "@namada/integrations"; +import { Account } from "@namada/types"; +import { bech32mValidation, shortenAddress } from "@namada/utils"; + import { TransferResponse, computePowSolution, requestChallenge, requestTransfer, -} from "utils"; +} from "../utils"; +import { AppContext } from "./App"; +import { InfoContainer } from "./App.components"; import { ButtonContainer, FaucetFormContainer, @@ -18,9 +29,6 @@ import { PreFormatted, } from "./Faucet.components"; -import { bech32mValidation } from "@namada/utils"; -import { AppContext } from "./App"; - enum Status { Pending, Completed, @@ -28,16 +36,30 @@ enum Status { } type Props = { + accounts: Account[]; + integration: Namada; isTestnetLive: boolean; }; const bech32mPrefix = "tnam"; -export const FaucetForm: React.FC = ({ isTestnetLive }) => { - const theme = useTheme(); +export const FaucetForm: React.FC = ({ + accounts, + integration, + isTestnetLive, +}) => { const { difficulty, settingsError, limit, tokens, url } = useContext(AppContext); - const [targetAddress, setTargetAddress] = useState(); + + const accountLookup = accounts.reduce( + (acc, account) => { + acc[account.address] = account; + return acc; + }, + {} as Record + ); + + const [account, setAccount] = useState(accounts[0]); const [tokenAddress, setTokenAddress] = useState(); const [amount, setAmount] = useState(undefined); const [error, setError] = useState(); @@ -45,6 +67,11 @@ export const FaucetForm: React.FC = ({ isTestnetLive }) => { const [statusText, setStatusText] = useState(); const [responseDetails, setResponseDetails] = useState(); + const accountsSelectData = accounts.map(({ alias, address }) => ({ + label: `${alias} - ${shortenAddress(address)}`, + value: address, + })); + useEffect(() => { if (tokens?.NAM) { setTokenAddress(tokens.NAM); @@ -55,14 +82,14 @@ export const FaucetForm: React.FC = ({ isTestnetLive }) => { Boolean(tokenAddress) && Boolean(amount) && (amount || 0) <= limit && - Boolean(targetAddress) && + Boolean(account) && status !== Status.Pending && typeof difficulty !== "undefined" && isTestnetLive; const handleSubmit = useCallback(async () => { if ( - !targetAddress || + !account || !amount || !tokenAddress || typeof difficulty === "undefined" @@ -72,7 +99,6 @@ export const FaucetForm: React.FC = ({ isTestnetLive }) => { } // Validate target and token inputs - const sanitizedTarget = sanitize(targetAddress); const sanitizedToken = sanitize(tokenAddress); if (!sanitizedToken) { @@ -81,15 +107,9 @@ export const FaucetForm: React.FC = ({ isTestnetLive }) => { return; } - if (!sanitizedTarget) { - setStatus(Status.Error); - setError("Invalid target!"); - return; - } - - if (!bech32mValidation(bech32mPrefix, sanitizedTarget)) { - setError("Invalid bech32m address for target!"); + if (!account) { setStatus(Status.Error); + setError("No account found!"); return; } @@ -102,9 +122,19 @@ export const FaucetForm: React.FC = ({ isTestnetLive }) => { setStatusText(undefined); try { - const { challenge, tag } = await requestChallenge(url).catch((e) => { - throw new Error(`Error requesting challenge: ${e}`); - }); + if (!account.publicKey) { + throw new Error("Account does not have a public key!"); + } + + const { challenge, tag } = + (await requestChallenge(url, account.publicKey).catch( + ({ message, code }) => { + throw new Error(`${code} - ${message}`); + } + )) || {}; + if (!tag || !challenge) { + throw new Error("Request challenge did not return a valid response"); + } const solution = computePowSolution(challenge, difficulty || 0); @@ -112,12 +142,24 @@ export const FaucetForm: React.FC = ({ isTestnetLive }) => { throw new Error("A solution was not computed!"); } + const signer = integration.signer(); + if (!signer) { + throw new Error("signer not defined"); + } + + const sig = await signer.sign(account.address, challenge); + if (!sig) { + throw new Error("Signature was rejected"); + } + const submitData = { solution, tag, challenge, + player_id: account.publicKey, + challenge_signature: sig.signature, transfer: { - target: sanitizedTarget, + target: account.address, token: sanitizedToken, amount: amount * 1_000_000, }, @@ -131,7 +173,6 @@ export const FaucetForm: React.FC = ({ isTestnetLive }) => { if (response.sent) { // Reset form if successful - setTargetAddress(undefined); setAmount(0); setError(undefined); setStatus(Status.Completed); @@ -147,7 +188,7 @@ export const FaucetForm: React.FC = ({ isTestnetLive }) => { setError(`${e}`); setStatus(Status.Error); } - }, [targetAddress, tokenAddress, amount]); + }, [account, tokenAddress, amount]); const handleFocus = (e: React.ChangeEvent): void => e.target.select(); @@ -156,12 +197,19 @@ export const FaucetForm: React.FC = ({ isTestnetLive }) => { {settingsError && {settingsError}} - setTargetAddress(e.target.value)} - /> + {accounts.length > 0 ? ( +