This commit is contained in:
Filipe Medeiros 2021-11-25 23:09:58 +00:00
parent 1dfef630de
commit f25791eff8
34 changed files with 623 additions and 299 deletions

View file

@ -3,7 +3,7 @@ import { tools } from 'nanocurrency-web'
import { FC, useMemo } from 'react'
import useSWR from 'swr'
import { useAddress } from '../lib/context/addressContext'
import { useAccount, useAccounts } from '../lib/context/accountContext'
import { usePreferences } from '../lib/context/preferencesContext'
import { ShowCurrencyPreference } from '../lib/db/preferences'
import fetcher from '../lib/fetcher'
@ -26,24 +26,7 @@ const Balance: FC<Props> = ({ className }) => {
fetcher,
})
const { address } = useAddress()
const params = useMemo(
() => ({
method: 'POST',
headers: [['Content-Type', 'application/json']],
body: JSON.stringify({
action: 'account_balance',
account: address,
}),
}),
[address]
)
const { data: account } = useSWR<{
balance: string
pending: string
}>(address !== undefined ? ['https://mynano.ninja/api/node', params] : null)
const account = useAccount()
const {
preferences: { showCurrencyDash },
@ -63,7 +46,7 @@ const Balance: FC<Props> = ({ className }) => {
return (
<div
className={clsx(
'bg-purple-500 dark:text-gray-900 text-purple-100 py-4 px-7 rounded shadow-lg',
'bg-purple-500 dark:text-gray-900 text-purple-50 py-4 px-7 rounded shadow-lg',
className
)}
onClick={() =>

View file

@ -15,9 +15,10 @@ import { useRouter } from 'next/router'
import { FC, useState } from 'react'
import { checkBiometrics } from '../lib/biometrics'
import { useAddress } from '../lib/context/addressContext'
import { useAccount, useAccounts } from '../lib/context/accountContext'
import { usePreferences } from '../lib/context/preferencesContext'
import { getEncryptedSeed } from '../lib/db/encryptedSeeds'
import decryptSeed from '../lib/decryptSeed'
import useIsWelcoming from '../lib/hooks/useIsWelcoming'
export interface Props {
@ -29,23 +30,23 @@ const BottomMenu: FC<Props> = ({ className }) => {
preferences: { leftHanded },
} = usePreferences()
const { push, pathname } = useRouter()
const { address } = useAddress()
const account = useAccount()
const [confirmCopyAddress, setConfirmCopyAddress] = useState(false)
const [confirmCopySeed, setConfirmCopySeed] = useState(false)
const onCopy = (seed?: string) => {
if (address !== undefined || seed !== undefined) {
navigator.clipboard.writeText(seed ?? address!)
seed !== undefined
? setConfirmCopySeed(true)
: setConfirmCopyAddress(true)
setTimeout(
() =>
seed !== undefined
? setConfirmCopySeed(false)
: setConfirmCopyAddress(false),
1500
)
const onCopySeed = async () => {
const seed = await decryptSeed('os')
navigator.clipboard.writeText(seed)
setConfirmCopySeed(true)
setTimeout(() => setConfirmCopySeed(false), 1500)
}
const onCopyAddress = () => {
if (account !== undefined) {
navigator.clipboard.writeText(account.address)
setConfirmCopyAddress(true)
setTimeout(() => setConfirmCopyAddress(false), 1500)
}
}
@ -66,7 +67,7 @@ const BottomMenu: FC<Props> = ({ className }) => {
className="bg-purple-500 p-1 h-12 rounded hover:bg-purple-400 disabled:hover:bg-purple-500 shadow-lg"
onClick={() => push('/dashboard')}
>
<HomeIcon className="h-full text-white dark:text-gray-900" />
<HomeIcon className="h-full text-purple-50 dark:text-gray-900" />
</button>
)}
@ -88,34 +89,22 @@ const BottomMenu: FC<Props> = ({ className }) => {
className={clsx(
'p-1 h-7 rounded shadow-lg',
confirmCopySeed
? 'bg-white'
? 'bg-purple-50'
: 'bg-purple-500 hover:bg-purple-400 disabled:hover:bg-purple-500'
)}
onClick={async () => {
const {
// @ts-expect-error
response: { signature: sig },
} = (await checkBiometrics())!
const encryptedSeed = (await getEncryptedSeed('os'))!
const decryptedSeed = AES.decrypt(
encryptedSeed,
sig.toString()
).toString(enc.Utf8)
onCopy(decryptedSeed)
}}
onClick={onCopySeed}
>
{confirmCopySeed ? (
<CheckIcon className="h-full text-purple-500" />
) : (
<KeyIcon className="h-full text-white dark:text-gray-900" />
<KeyIcon className="h-full text-purple-50 dark:text-gray-900" />
)}
</button>
<button
disabled={isWelcoming}
className="bg-purple-500 p-1 h-7 rounded hover:bg-purple-400 disabled:hover:bg-purple-500 shadow-lg"
>
<LibraryIcon className="h-full text-white dark:text-gray-900" />
<LibraryIcon className="h-full text-purple-50 dark:text-gray-900" />
</button>
</div>
<div className="flex flex-col h-16 justify-between">
@ -124,22 +113,22 @@ const BottomMenu: FC<Props> = ({ className }) => {
className={clsx(
'p-1 h-7 rounded shadow-lg',
confirmCopyAddress
? 'bg-white'
? 'bg-purple-50'
: 'bg-purple-500 hover:bg-purple-400 disabled:hover:bg-purple-500'
)}
onClick={() => onCopy()}
onClick={onCopyAddress}
>
{confirmCopyAddress ? (
<CheckIcon className="h-full text-purple-500" />
) : (
<DocumentDuplicateIcon className="h-full text-white dark:text-gray-900" />
<DocumentDuplicateIcon className="h-full text-purple-50 dark:text-gray-900" />
)}
</button>
<button
disabled={isWelcoming}
className="bg-purple-500 p-1 h-7 rounded hover:bg-purple-400 disabled:hover:bg-purple-500 shadow-lg"
>
<DownloadIcon className="h-full text-white dark:text-gray-900" />
<DownloadIcon className="h-full text-purple-50 dark:text-gray-900" />
</button>
</div>
</div>
@ -155,7 +144,7 @@ const BottomMenu: FC<Props> = ({ className }) => {
)}
onClick={() => push('/readQrCode')}
>
<UploadIcon className="h-full text-white dark:text-gray-900 w-full" />
<UploadIcon className="h-full text-purple-50 dark:text-gray-900 w-full" />
</button>
<div className="border-purple-500 border-t-2 border-b-2 py-1 px-3 h-16 shadow-lg">
@ -172,7 +161,7 @@ const BottomMenu: FC<Props> = ({ className }) => {
)}
onClick={() => push('/myQrCode')}
>
<DownloadIcon className="h-full text-white dark:text-gray-900 w-full" />
<DownloadIcon className="h-full text-purple-50 dark:text-gray-900 w-full" />
</button>
</div>
@ -181,13 +170,13 @@ const BottomMenu: FC<Props> = ({ className }) => {
disabled={isWelcoming}
className="bg-purple-500 p-1 h-7 rounded hover:bg-purple-400 disabled:hover:bg-purple-500 shadow-md"
>
<UploadIcon className="h-full text-white dark:text-gray-900" />
<UploadIcon className="h-full text-purple-50 dark:text-gray-900" />
</button>
<button
disabled={isWelcoming}
className="bg-purple-500 p-1 h-7 rounded hover:bg-purple-400 disabled:hover:bg-purple-500 shadow-lg"
>
<RssIcon className="h-full text-white dark:text-gray-900" />
<RssIcon className="h-full text-purple-50 dark:text-gray-900" />
</button>
</div>
</div>

View file

@ -21,7 +21,7 @@ const Layout: FC<Props> = ({ children }) => {
return (
<div
className="dark:text-white bg-purple-50 dark:bg-gray-900 relative w-screen h-screen pt-4 pb-4 px-5 grid justify-center gap-4"
className="dark:text-purple-50 bg-purple-50 dark:bg-gray-900 relative w-screen h-screen pt-4 pb-4 px-5 grid justify-center gap-4"
style={{
gridTemplate:
'"top-menu" auto "balance" auto "main" 1fr "bottom-menu" auto / 1fr',
@ -50,7 +50,7 @@ const Layout: FC<Props> = ({ children }) => {
<Balance />
</div>
<main
className="overflow-auto bg-purple-500 rounded border-t-8 border-b-8 border-purple-500 shadow-md py-6 px-3"
className="overflow-auto bg-purple-500 rounded border-t-8 border-b-8 border-purple-500 shadow-md py-4 px-4"
style={{ gridArea: 'main' }}
>
{children}

View file

@ -31,7 +31,7 @@ const PreferencesMenu: FC<Props> = () => {
<div className="relative z-20 justify-center" ref={menuRef}>
<button
className={clsx(
'w-10 p-1 rounded bg-purple-500 shadow-md text-white dark:text-gray-900 hover:cursor-pointer hover:bg-purple-400 transition-colors dark text-white:dark:text-gray-900'
'w-10 p-1 rounded bg-purple-500 shadow-md text-purple-50 dark:text-gray-900 hover:cursor-pointer hover:bg-purple-400 transition-colors dark text-purple-50:dark:text-gray-900'
)}
onClick={toggleMenu}
>
@ -52,10 +52,10 @@ const PreferencesMenu: FC<Props> = () => {
<button
disabled={!showMenu}
className={clsx(
'p-1 rounded dark:hover:text-purple-300 transition-colors duration-100 w-full text-white',
'p-1 rounded transition-colors duration-100 w-full',
biometricsAuth
? 'dark:text-purple-400 dark:bg-gray-900 dark:hover:text-purple-400 shadow-md hover:bg-purple-400 bg-purple-100 text-purple-400'
: 'hover:text-purple-400 bg-purple-400 dark:text-gray-900',
? 'dark:text-purple-400 dark:bg-gray-900 dark:hover:text-purple-400 shadow-md hover:bg-purple-400 bg-purple-50 text-purple-400'
: 'hover:text-purple-400 bg-purple-400 dark:text-gray-900 text-purple-50 dark:hover:text-purple-300',
showMenu ? 'hover:cursor-pointer' : 'cursor-default'
)}
onClick={() => {
@ -71,10 +71,10 @@ const PreferencesMenu: FC<Props> = () => {
<button
disabled={!showMenu}
className={clsx(
'p-1 rounded dark:hover:text-purple-300 transition-colors duration-100 w-full text-white',
'p-1 rounded transition-colors duration-100 w-full',
darkMode
? 'dark:text-purple-400 dark:bg-gray-900 dark:hover:text-purple-400 shadow-md hover:bg-purple-400 bg-purple-100 text-purple-400'
: 'hover:text-purple-400 bg-purple-400 dark:text-gray-900',
? 'dark:text-purple-400 dark:bg-gray-900 dark:hover:text-purple-400 shadow-md hover:bg-purple-400 bg-purple-50 text-purple-400'
: 'hover:text-purple-400 bg-purple-400 dark:text-gray-900 text-purple-50 dark:hover:text-purple-300',
showMenu ? 'hover:cursor-pointer' : 'cursor-default'
)}
onClick={() => {
@ -89,10 +89,10 @@ const PreferencesMenu: FC<Props> = () => {
<button
disabled={!showMenu}
className={clsx(
'p-1 rounded dark:hover:text-purple-300 transition-colors duration-100 w-full text-white dark:text-gray-900',
'p-1 rounded transition-colors duration-100 w-full',
leftHanded
? 'dark:text-purple-400 dark:bg-gray-900 dark:hover:text-purple-400 shadow-md hover:bg-purple-400 bg-purple-100 text-purple-400'
: 'hover:text-purple-400 bg-purple-400 dark:text-gray-900',
? 'dark:text-purple-400 dark:bg-gray-900 dark:hover:text-purple-400 shadow-md hover:bg-purple-400 bg-purple-50 text-purple-400'
: 'hover:text-purple-400 bg-purple-400 dark:text-gray-900 dark:hover:text-purple-300 text-purple-50',
showMenu ? 'hover:cursor-pointer' : 'cursor-default'
)}
onClick={() => {

View file

@ -1,13 +1,13 @@
import { DownloadIcon, UploadIcon } from '@heroicons/react/solid'
import clsx from 'clsx'
import { tools } from 'nanocurrency-web'
import { FC, useCallback, useMemo, useState } from 'react'
import { FC, useCallback, useMemo } from 'react'
import useSwr from 'swr'
import useSwrInfinite from 'swr/infinite'
import { useAddress } from '../lib/context/addressContext'
import { useAccount } from '../lib/context/accountContext'
import fetcher from '../lib/fetcher'
import receiveNano from '../lib/nano/receiveNano'
import useReceiveNano from '../lib/hooks/useReceiveNano'
import {
AccountHistoryResponse,
AccountPendingResponse,
@ -31,25 +31,25 @@ const mockAddressBook: Record<string, { displayName: string }> = {
}
const RecentTransactions: FC<Props> = ({ className }) => {
const { address } = useAddress()
const account = useAccount()
const params = useCallback(
(cursor: string | undefined) => ({
method: 'POST',
headers: [['Content-Type', 'application/json']],
body: JSON.stringify({
action: 'account_history',
account: address,
account: account?.address,
count: 20,
head: cursor,
}),
}),
[address]
[account]
)
const { data: historyPages, setSize } =
useSwrInfinite<AccountHistoryResponse>(
(_, prevHistory) =>
address === undefined
account === undefined
? null
: prevHistory === null
? 'no-cursor'
@ -64,13 +64,13 @@ const RecentTransactions: FC<Props> = ({ className }) => {
const paramsPending = useMemo(
() => ({
action: 'accounts_pending',
accounts: [address],
accounts: [account?.address],
count: '20',
}),
[address]
[account]
)
const { data: pendingTxnHashes } = useSwr<AccountPendingResponse>(
address !== undefined ? address : null,
account !== undefined ? account.address : null,
() =>
fetcher<AccountPendingResponse>('https://mynano.ninja/api/node', {
method: 'POST',
@ -107,7 +107,7 @@ const RecentTransactions: FC<Props> = ({ className }) => {
const txns = useMemo(
() =>
historyPages?.flatMap(({ history }) =>
(history !== '' ? history : []).map(txn => {
(history !== '' ? history ?? [] : []).map(txn => {
return {
send: txn.type === 'send',
account: txn.account,
@ -117,7 +117,7 @@ const RecentTransactions: FC<Props> = ({ className }) => {
receivable: false,
}
})
),
) ?? [],
[historyPages]
)
@ -137,76 +137,137 @@ const RecentTransactions: FC<Props> = ({ className }) => {
[pendingTxns]
)
const txnsWithPending = useMemo(
() => [...(mappedPendingTxns ?? []), ...(txns ?? [])],
[txns, mappedPendingTxns]
)
const receiveNano = useReceiveNano()
if (historyPages === undefined || address === undefined) return null
if (historyPages === undefined || account === undefined) return null
const hasTxns = txnsWithPending.length > 0
const hasPendingTxns = (mappedPendingTxns ?? []).length > 0
const hasTxns = (txns ?? []).length > 0
return (
<div className={clsx('flex flex-col gap-6 w-full items-center', className)}>
<h2 className="text-2xl font-semibold text-white">recent transactions</h2>
{hasTxns ? (
<ol className="flex flex-col gap-3 w-full">
{txnsWithPending.map(txn => (
<li
key={txn.hash}
className={clsx(
'bg-white shadow rounded px-3 py-3 flex items-center justify-between gap-2 text-black border-r-4',
txn.send
? 'border-yellow-500'
: txn.receivable
? 'border-blue-500'
: 'border-green-500'
)}
>
<button
className="contents"
onClick={() => receiveNano(address, txn.hash, txn.amount)}
>
{txn.send ? (
<UploadIcon className="w-6 text-yellow-500 flex-shrink-0" />
) : (
<DownloadIcon
className={clsx(
'w-6 flex-shrink-0',
txn.receivable ? 'text-blue-500' : 'text-green-500'
)}
/>
<div className={clsx('flex flex-col gap-6 w-full', className)}>
{hasPendingTxns && (
<section className="flex flex-col gap-3 w-full items-center">
<h2 className="text-2xl font-semibold text-purple-50">pending</h2>
<ol className="flex flex-col gap-3 w-full">
{mappedPendingTxns.map(txn => (
<li
key={txn.hash}
className={clsx(
'bg-purple-50 shadow rounded px-3 py-3 flex items-center justify-between gap-2 text-black border-r-4',
txn.send
? 'border-yellow-500'
: txn.receivable
? 'border-blue-500'
: 'border-green-500'
)}
<div className="overflow-hidden overflow-ellipsis text-left flex-1 whitespace-nowrap">
{Intl.DateTimeFormat([], {
day: '2-digit',
month: '2-digit',
year: '2-digit',
}).format(txn.timestamp * 1000)}{' '}
-{' '}
{mockAddressBook[txn.account]?.displayName ?? (
<span className="text-xs">{txn.account}</span>
)}
</div>
<span className="flex-shrink-0 font-medium">
Ӿ{' '}
{rawToNanoDisplay(txn.amount) === 'small' ? (
'<.01'
) : rawToNanoDisplay(txn.amount).startsWith('0.') ? (
<>
<span className="text-sm font-semibold">0</span>
{rawToNanoDisplay(txn.amount).substring(1)}
</>
>
<button
className="contents"
onClick={() => receiveNano(txn.hash, txn.amount)}
>
{txn.send ? (
<UploadIcon className="w-6 text-yellow-500 flex-shrink-0" />
) : (
rawToNanoDisplay(txn.amount)
<DownloadIcon
className={clsx(
'w-6 flex-shrink-0',
txn.receivable ? 'text-blue-500' : 'text-green-500'
)}
/>
)}
</span>
</button>
</li>
))}
</ol>
) : (
<div className="text-center pt-8">
<div className="overflow-hidden overflow-ellipsis text-left flex-1 whitespace-nowrap">
{Intl.DateTimeFormat([], {
day: '2-digit',
month: '2-digit',
year: '2-digit',
}).format(txn.timestamp * 1000)}{' '}
-{' '}
{mockAddressBook[txn.account]?.displayName ?? (
<span className="text-xs">{txn.account}</span>
)}
</div>
<span className="flex-shrink-0 font-medium">
Ӿ{' '}
{rawToNanoDisplay(txn.amount) === 'small' ? (
'<.01'
) : rawToNanoDisplay(txn.amount).startsWith('0.') ? (
<>
<span className="text-sm font-semibold">0</span>
{rawToNanoDisplay(txn.amount).substring(1)}
</>
) : (
rawToNanoDisplay(txn.amount)
)}
</span>
</button>
</li>
))}
</ol>
</section>
)}
{hasPendingTxns && hasTxns && <hr />}
{hasTxns && (
<section className="flex flex-col gap-3 w-full items-center">
<h2 className="text-2xl font-semibold text-purple-50">
recent transactions
</h2>
<ol className="flex flex-col gap-3 w-full">
{txns.map(txn => (
<li
key={txn.hash}
className={clsx(
'bg-purple-50 shadow rounded px-3 py-3 flex items-center justify-between gap-2 text-black border-r-4',
txn.send
? 'border-yellow-500'
: txn.receivable
? 'border-blue-500'
: 'border-green-500'
)}
>
<button className="contents" onClick={() => {}}>
{txn.send ? (
<UploadIcon className="w-6 text-yellow-500 flex-shrink-0" />
) : (
<DownloadIcon
className={clsx(
'w-6 flex-shrink-0',
txn.receivable ? 'text-blue-500' : 'text-green-500'
)}
/>
)}
<div className="overflow-hidden overflow-ellipsis text-left flex-1 whitespace-nowrap">
{Intl.DateTimeFormat([], {
day: '2-digit',
month: '2-digit',
year: '2-digit',
}).format(txn.timestamp * 1000)}{' '}
-{' '}
{mockAddressBook[txn.account]?.displayName ?? (
<span className="text-xs">{txn.account}</span>
)}
</div>
<span className="flex-shrink-0 font-medium">
Ӿ{' '}
{rawToNanoDisplay(txn.amount) === 'small' ? (
'<.01'
) : rawToNanoDisplay(txn.amount).startsWith('0.') ? (
<>
<span className="text-sm font-semibold">0</span>
{rawToNanoDisplay(txn.amount).substring(1)}
</>
) : (
rawToNanoDisplay(txn.amount)
)}
</span>
</button>
</li>
))}
</ol>
</section>
)}
{!hasPendingTxns && !hasTxns && (
<div className="text-center pt-8 text-purple-50">
<p className="pb-4">no transactions yet...</p>
<p>
get your first nano

View file

@ -0,0 +1,81 @@
import {
FC,
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react'
import { AccountInfoCache } from '../types'
export interface AddressContextValue {
currAccount: AccountInfoCache | undefined
accounts: { [key: number]: AccountInfoCache } | undefined
setAccount: (info: AccountInfoCache) => void
removeAccount: (index: number) => void
}
const addressContext = createContext<AddressContextValue | undefined>(undefined)
export const useAccounts = () => {
const contextValue = useContext(addressContext)
if (contextValue === undefined)
throw new Error('`useAddress` must be used insisde a context `Provider`')
return contextValue
}
/**
* @param index index of the account you want, or no index if you want the current account
* @returns the requested account, or undefined if no account was found
*/
export const useAccount = (index?: number) => {
const contextValue = useAccounts()
return index !== undefined
? contextValue.accounts?.[index]
: contextValue.currAccount
}
export const AddressProvider: FC<{
initialAccounts?: { [key: number]: AccountInfoCache } | undefined
initialAccountIndex?: number
}> = ({ children, initialAccounts, initialAccountIndex }) => {
const [accounts, setAccounts] = useState<
{ [key: number]: AccountInfoCache } | undefined
>(initialAccounts)
useEffect(() => {
setAccounts(initialAccounts)
}, [initialAccounts])
const [currAccountIndex, setCurrAccountIndex] = useState<number>(
initialAccountIndex ?? 0
)
useEffect(() => {
setCurrAccountIndex(initialAccountIndex ?? 0)
}, [initialAccountIndex])
const setAccount = useCallback((account: AccountInfoCache) => {
setAccounts(prev => ({ ...prev, [account.index]: account }))
}, [])
const removeAccount = useCallback((index: number) => {
setAccounts(prev => {
const next = { ...prev }
delete next[index]
return next
})
}, [])
return (
<addressContext.Provider
value={{
accounts,
setAccount,
removeAccount,
currAccount: accounts?.[currAccountIndex],
}}
>
{children}
</addressContext.Provider>
)
}

View file

@ -1,31 +0,0 @@
import { FC, createContext, useContext, useEffect, useState } from 'react'
export interface AddressContextValue {
address: string | undefined
setAddress: (address: string) => void
}
const addressContext = createContext<AddressContextValue | undefined>(undefined)
export const useAddress = () => {
const contextValue = useContext(addressContext)
if (contextValue === undefined)
throw new Error('`useAddress` must be used insisde a context `Provider`')
return contextValue
}
export const AddressProvider: FC<{ address?: string }> = ({
children,
address: initialAddress,
}) => {
const [address, setAddress] = useState<string | undefined>(initialAddress)
useEffect(() => {
setAddress(initialAddress)
}, [initialAddress])
return (
<addressContext.Provider value={{ address, setAddress }}>
{children}
</addressContext.Provider>
)
}

View file

@ -1,8 +1,9 @@
import Dexie, { Table } from 'dexie'
interface Account {
import { AccountInfoCache } from '../types'
interface Account extends AccountInfoCache {
index: number
account: string
}
class Accounts extends Dexie {
@ -18,8 +19,11 @@ class Accounts extends Dexie {
const db = new Accounts()
export const addAccount = (index: number, account: string) =>
db.accounts.add({ account, index })
export const addAccount = (index: number, account: AccountInfoCache) =>
db.accounts.add({ ...account, index })
export const putAccount = (index: number, account: AccountInfoCache) =>
db.accounts.put({ ...account, index })
export const removeAccount = (index: number) => db.accounts.delete(index)
@ -27,7 +31,7 @@ export const getAccount = (index: number) =>
db.accounts
.where({ index })
.first()
.then(res => res?.account)
.then(res => (res === undefined ? undefined : res))
export const hasAccount = async (index: number) =>
(await db.accounts.where({ index }).count()) > 0

View file

@ -4,13 +4,15 @@ import { checkBiometrics } from './biometrics'
import { getEncryptedSeed } from './db/encryptedSeeds'
const decryptSeed = async (id: 'os' | 'pin') => {
const encryptedSeed = (await getEncryptedSeed(id))?.encryptedSeed
const encryptedSeed = await getEncryptedSeed(id)
const {
// @ts-expect-error
response: { signature: sig },
} = await checkBiometrics()
console.log({ sig })
return AES.decrypt(encryptedSeed!, sig.toString()).toString(enc.Utf8)
const decryptedSeed = AES.decrypt(encryptedSeed!, sig.toString()).toString(
enc.Utf8
)
return decryptedSeed
}
export default decryptSeed

14
lib/encryptSeed.ts Normal file
View file

@ -0,0 +1,14 @@
import { AES } from 'crypto-js'
import { checkBiometrics } from './biometrics'
const encryptSeed = async (seed: string, id: 'os' | 'pin' = 'os') => {
const {
// @ts-expect-error
response: { signature: sig },
} = await checkBiometrics()
const encryptedSeed = AES.encrypt(seed, sig.toString()).toString()
return encryptedSeed
}
export default encryptSeed

View file

@ -5,7 +5,10 @@ import { getPreference } from '../db/preferences'
const useDarkMode = (darkMode?: boolean) => {
useEffect(() => {
const setDarkModeClass = async () => {
const isDark = darkMode ?? (await getPreference('darkMode')) === true
const isDark =
darkMode ??
(await getPreference('darkMode')) ??
window.matchMedia('(prefers-color-scheme: dark)').matches
const htmlClasses = document.querySelector('html')?.classList
if (isDark) htmlClasses?.add('dark')
else htmlClasses?.remove('dark')

View file

@ -4,7 +4,6 @@ import colors from 'tailwindcss/colors'
import { usePreferences } from '../context/preferencesContext'
import genTxnUrl from '../nano/getTxnUrl'
import { prefersDarkMode } from '../preferences/darkMode'
const useDrawQrCode = ({ raw, address }: { raw?: string; address: string }) => {
const canvasRef = useRef<HTMLCanvasElement>(null)

View file

@ -1,12 +1,12 @@
import { useEffect, useState } from 'react'
import { useAddress } from '../context/addressContext'
import { useAccounts } from '../context/accountContext'
const useListenToTxn = () => {
const { address } = useAddress()
const { accounts } = useAccounts()
const [mostRecentTxn, setMostRecentTxn] = useState<any | undefined>(undefined)
useEffect(() => {
if (address !== undefined) {
if (accounts !== undefined) {
const ws = new WebSocket('wss://ws.mynano.ninja/')
ws.onopen = () => {
@ -16,7 +16,7 @@ const useListenToTxn = () => {
action: 'subscribe',
topic: 'confirmation',
options: {
accounts: [address],
accounts: [accounts],
},
})
)
@ -29,7 +29,7 @@ const useListenToTxn = () => {
return () => ws.close()
}
}, [address])
}, [accounts])
return mostRecentTxn
}

View file

@ -1,7 +1,7 @@
import jsqr from 'jsqr'
import { useEffect, useRef, useState } from 'react'
const useReadQrFromVideo = () => {
const useReadQrFromVideo = (onQrCodeRead: (content: string) => void) => {
const videoRef = useRef<HTMLVideoElement>(null)
const [videoLive, setVideoLive] = useState(false)
useEffect(() => {
@ -36,7 +36,7 @@ const useReadQrFromVideo = () => {
)
if (qr !== null) {
console.log(qr) // todo
onQrCodeRead(qr.data)
return
}

View file

@ -0,0 +1,47 @@
import { computeWork, hashBlock } from 'nanocurrency'
import { useCallback } from 'react'
import { useAccount } from '../context/accountContext'
import fetcher from '../fetcher'
import receiveNano from '../nano/receiveNano'
const useReceiveNano = () => {
const account = useAccount()
const receive = useCallback(
async (hash: string, amount: string) => {
if (account === undefined) return
console.log('signing receive')
const signedBlock = await receiveNano(
{
transactionHash: hash,
walletBalanceRaw: account.balance ?? '0',
toAddress: account.address,
representativeAddress: account.representative ?? account.address,
frontier:
account.frontier ??
'0000000000000000000000000000000000000000000000000000000000000000',
amountRaw: amount,
work: (await computeWork(account.frontier ?? account.publicKey))!,
},
account.index
)
console.log('finished signing')
return fetcher('https://mynano.ninja/api/node', {
method: 'POST',
headers: [['Content-Type', 'application/json']],
body: JSON.stringify({
action: 'process',
json_block: 'true',
subtype: 'receive',
block: signedBlock,
}),
})
},
[account]
)
return receive
}
export default useReceiveNano

49
lib/hooks/useSendNano.ts Normal file
View file

@ -0,0 +1,49 @@
import { computeWork } from 'nanocurrency'
import { useCallback } from 'react'
import { useAccount } from '../context/accountContext'
import fetcher from '../fetcher'
import sendNano from '../nano/sendNano'
const useSendNano = () => {
const account = useAccount()
const send = useCallback(
async (to: string, amount: string) => {
if (
account === undefined ||
account.balance === null ||
account.representative === null ||
account.frontier === null
)
return
const signedBlock = sendNano(
{
walletBalanceRaw: account.balance,
fromAddress: account.address,
toAddress: to,
representativeAddress: account.representative,
frontier: account.frontier,
amountRaw: amount,
work: (await computeWork(account.frontier))!,
},
account.index
)
return fetcher('https://mynano.ninja/api/node', {
method: 'POST',
headers: [['Content-Type', 'application/json']],
body: JSON.stringify({
action: 'process',
json_block: 'true',
subtype: 'send',
block: signedBlock,
}),
})
},
[account]
)
return send
}
export default useSendNano

View file

@ -0,0 +1,32 @@
import { useCallback, useState } from 'react'
import { getAccount, putAccount } from '../db/accounts'
import fetchAccountInfo from '../nano/fetchAccountInfo'
import { AccountInfoCache } from '../types'
import useSetup from './useSetup'
const useSetupAccounts = (skip?: boolean) => {
const [accounts, setAccounts] = useState<
{ [key: number]: AccountInfoCache } | undefined
>(undefined)
const setupAddress = useCallback(async () => {
const dbAccount = await getAccount(0)
if (dbAccount !== undefined) {
setAccounts({ 0: dbAccount })
const infoRes = await fetchAccountInfo(dbAccount.address)
const freshAccountInfo = {
...dbAccount,
frontier: 'error' in infoRes ? null : infoRes.confirmed_frontier,
representative:
'error' in infoRes ? null : infoRes.confirmed_representative,
}
setAccounts({ 0: freshAccountInfo })
putAccount(dbAccount.index, freshAccountInfo)
}
}, [setAccounts])
const { settingUp } = useSetup(setupAddress, skip)
return { accounts, settingUp }
}
export default useSetupAccounts

View file

@ -1,16 +0,0 @@
import { useCallback, useState } from 'react'
import { getAccount } from '../db/accounts'
import useSetup from './useSetup'
const useSetupAddress = (skip?: boolean) => {
const [address, setAddress] = useState<string | undefined>(undefined)
const setupAddress = useCallback(async () => {
const dbAddress = await getAccount(0)
if (dbAddress !== undefined) setAddress(dbAddress)
}, [setAddress])
const { settingUp } = useSetup(setupAddress, skip)
return { address, settingUp }
}
export default useSetupAddress

View file

@ -4,14 +4,16 @@ import { useRouter } from 'next/router'
import { useCallback, useState } from 'react'
import { checkBiometrics, registerBiometrics } from '../biometrics'
import { useAddress } from '../context/addressContext'
import { useAccounts } from '../context/accountContext'
import { addAccount } from '../db/accounts'
import { addEncryptedSeed, hasEncryptedSeed } from '../db/encryptedSeeds'
import addressFromSeed from '../nano/addressFromSeed'
import encryptSeed from '../encryptSeed'
import accountAtIndex from '../nano/accountAtIndex'
import fetchAccountInfo from '../nano/fetchAccountInfo'
import useSetup from './useSetup'
const useSetupSeed = (skip?: boolean) => {
const { setAddress } = useAddress()
const { setAccount } = useAccounts()
const [seed, setSeed] = useState<
{ seed: string; mnemonic: string } | undefined
>(undefined)
@ -29,20 +31,28 @@ const useSetupSeed = (skip?: boolean) => {
wallet.generate(),
registerBiometrics(),
])
const { address, publicKey } = accountAtIndex(generatedSeed, 0)
const infoRes = await fetchAccountInfo(address)
const account = {
frontier: 'error' in infoRes ? null : infoRes.confirmed_frontier,
representative:
'error' in infoRes ? null : infoRes.confirmed_representative,
balance: '0',
index: 0,
address,
publicKey,
}
setSeed({ seed: generatedSeed, mnemonic })
setAddress(addressFromSeed(generatedSeed, 0))
await addAccount(0, addressFromSeed(generatedSeed, 0))
setAccount(account)
await addAccount(0, account)
} catch {}
}, [replace, setAddress])
}, [replace, setAccount])
const storeSeed = useCallback(async () => {
if (seed === undefined) return
const {
// @ts-expect-error
response: { signature: sig },
} = await checkBiometrics()
const encryptedSeed = AES.encrypt(seed.seed, sig.toString()).toString()
await addEncryptedSeed('os', encryptedSeed)
await addEncryptedSeed('os', await encryptSeed(seed.seed))
}, [seed])
const { settingUp, lazy } = useSetup(setupSeed, skip)

View file

@ -0,0 +1,6 @@
import { wallet } from 'nanocurrency-web'
const accountAtIndex = (seed: string, index: number) =>
wallet.accounts(seed, index, index)[0]
export default accountAtIndex

View file

@ -1,6 +0,0 @@
import { wallet } from 'nanocurrency-web'
const addressFromSeed = (seed: string, index: number) =>
wallet.accounts(seed, index, index)[0].address
export default addressFromSeed

View file

@ -0,0 +1,16 @@
import fetcher from '../fetcher'
import { AccountInfoResponse } from '../types'
const fetchAccountInfo = (address: string) =>
fetcher('https://mynano.ninja/api/node', {
method: 'POST',
headers: [['Content-Type', 'application/json']],
body: JSON.stringify({
action: 'account_info',
account: address,
representative: 'true',
include_confirmed: 'true',
}),
}) as Promise<AccountInfoResponse>
export default fetchAccountInfo

View file

@ -1,56 +1,19 @@
import { computeWork, hashBlock } from 'nanocurrency'
import { block, wallet } from 'nanocurrency-web'
import { block } from 'nanocurrency-web'
import decryptSeed from '../decryptSeed'
import fetcher from '../fetcher'
import accountAtIndex from './accountAtIndex'
const receiveNano = async (myAddress: string, hash: string, amount: string) => {
const accountInfo: any = await fetcher('https://mynano.ninja/api/node', {
method: 'POST',
headers: [['Content-Type', 'application/json']],
body: JSON.stringify({
action: 'account_info',
account: myAddress,
}),
})
const sendNano = async (
blockData: Parameters<typeof block['receive']>[0],
index: number
) => {
const { privateKey } = accountAtIndex(
await decryptSeed('os'), // inline to minimize it's time in memoty (doesn't create a scoped var)
index
)
if ('error' in accountInfo) {
const seed = await decryptSeed('os')
console.log('start work')
const work = (await computeWork(wallet.accounts(seed, 0, 0)[0].publicKey, {
workThreshold: 'fffffe0000000000',
}))!
console.log('end work')
const data = {
walletBalanceRaw: '0',
toAddress: myAddress,
representativeAddress: myAddress,
frontier:
'0000000000000000000000000000000000000000000000000000000000000000',
amountRaw: amount,
transactionHash: hash,
work,
}
console.log(seed)
const signedBlock = block.receive(
data,
wallet.accounts(seed, 0, 0)[0].privateKey
)
console.log(signedBlock)
const res: any = await fetcher('https://mynano.ninja/api/node', {
method: 'POST',
headers: [['Content-Type', 'application/json']],
body: JSON.stringify({
action: 'process',
json_block: 'true',
subtype: 'open',
block: signedBlock,
}),
})
console.log(res)
}
const signedBlock = block.receive(blockData, privateKey)
return signedBlock
}
export default receiveNano
export default sendNano

56
lib/nano/receiveNano2.ts Normal file
View file

@ -0,0 +1,56 @@
import { computeWork, hashBlock } from 'nanocurrency'
import { block, wallet } from 'nanocurrency-web'
import decryptSeed from '../decryptSeed'
import fetcher from '../fetcher'
const receiveNano = async (myAddress: string, hash: string, amount: string) => {
const accountInfo: any = await fetcher('https://mynano.ninja/api/node', {
method: 'POST',
headers: [['Content-Type', 'application/json']],
body: JSON.stringify({
action: 'account_info',
account: myAddress,
}),
})
if ('error' in accountInfo) {
const seed = await decryptSeed('os')
console.log('start work')
const work = (await computeWork(wallet.accounts(seed, 0, 0)[0].publicKey, {
workThreshold: 'fffffe0000000000',
}))!
console.log('end work')
const data = {
walletBalanceRaw: '0',
toAddress: myAddress,
representativeAddress: myAddress,
frontier:
'0000000000000000000000000000000000000000000000000000000000000000',
amountRaw: amount,
transactionHash: hash,
work,
}
console.log(seed)
const signedBlock = block.receive(
data,
wallet.accounts(seed, 0, 0)[0].privateKey
)
console.log(signedBlock)
const res: any = await fetcher('https://mynano.ninja/api/node', {
method: 'POST',
headers: [['Content-Type', 'application/json']],
body: JSON.stringify({
action: 'process',
json_block: 'true',
subtype: 'open',
block: signedBlock,
}),
})
console.log(res)
}
}
export default receiveNano

19
lib/nano/sendNano.ts Normal file
View file

@ -0,0 +1,19 @@
import { block } from 'nanocurrency-web'
import decryptSeed from '../decryptSeed'
import accountAtIndex from './accountAtIndex'
const sendNano = async (
blockData: Parameters<typeof block['send']>[0],
index: number
) => {
const { privateKey } = accountAtIndex(
await decryptSeed('os'), // inline to minimize it's time in memoty (doesn't create a scoped var)
index
)
const signedBlock = block.send(blockData, privateKey)
return signedBlock
}
export default sendNano

View file

@ -43,3 +43,31 @@ export interface BlocksInfoResponse {
}
}
}
export type AccountInfoResponse =
| {
frontier: string
open_block: string
representative_block: string
balance: string
modified_timestamp: string
block_count: string
account_version: string
confirmation_height: string
confirmation_height_frontier: string
confirmed_balance: '11999999999999999918751838129509869131'
confirmed_height: '22966'
confirmed_frontier: '80A6745762493FA21A22718ABFA4F635656A707B48B3324198AC7F3938DE6D4F'
representative: 'nano_1gyeqc6u5j3oaxbe5qy1hyz3q745a318kh8h9ocnpan7fuxnq85cxqboapu5'
confirmed_representative: 'nano_1gyeqc6u5j3oaxbe5qy1hyz3q745a318kh8h9ocnpan7fuxnq85cxqboapu5'
}
| { error: 'Account not found' }
export interface AccountInfoCache {
address: string
index: number
publicKey: string
frontier: string | null
representative: string | null
balance: string | null
}

View file

@ -4,12 +4,12 @@ import { SWRConfig } from 'swr'
import 'tailwindcss/tailwind.css'
import Layout from '../components/Layout'
import { AddressProvider } from '../lib/context/addressContext'
import { AddressProvider } from '../lib/context/accountContext'
import { PreferencesProvider } from '../lib/context/preferencesContext'
import fetcher from '../lib/fetcher'
import useDarkMode from '../lib/hooks/useDarkMode'
import useProtectedRoutes from '../lib/hooks/useProtectedRoutes'
import useSetupAddress from '../lib/hooks/useSetupAddress'
import useSetupAccounts from '../lib/hooks/useSetupAccounts'
import useSetupChallenge from '../lib/hooks/useSetupChallenge'
import useSetupSw from '../lib/hooks/useSetupSw'
import '../styles/global.css'
@ -19,7 +19,7 @@ const MyApp: FC<AppProps> = ({ Component, pageProps }) => {
useSetupSw()
useDarkMode()
const { address } = useSetupAddress()
const { accounts } = useSetupAccounts()
const validatingCredential = useProtectedRoutes()
@ -27,7 +27,7 @@ const MyApp: FC<AppProps> = ({ Component, pageProps }) => {
return (
<SWRConfig value={{ fetcher, provider: () => new Map() }}>
<AddressProvider address={address}>
<AddressProvider initialAccounts={accounts} initialAccountIndex={0}>
<PreferencesProvider>
<Layout>
<Component {...pageProps} />

View file

@ -13,7 +13,7 @@ class MyDocument extends Document {
render() {
return (
<Html>
<Html className="dark">
<Head>
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/images/icon-small.png" />

View file

@ -1,11 +1,11 @@
import type { NextPage } from 'next'
import { useAddress } from '../lib/context/addressContext'
import { useAccount } from '../lib/context/accountContext'
import useDrawQrCode from '../lib/hooks/useDrawQrCode'
const MyQrCode: NextPage = () => {
const { address } = useAddress()
const canvasRef = useDrawQrCode({ address: address ?? '' })
const account = useAccount()
const canvasRef = useDrawQrCode({ address: account?.address ?? '' })
return (
<div className="grid gap-6 place-content-center h-full w-full">

View file

@ -1,14 +1,20 @@
import clsx from 'clsx'
import type { NextPage } from 'next'
import { useCallback } from 'react'
import useReadQrFromVideo from '../lib/hooks/useReadQrFromVideo'
const ReadQrCode: NextPage = () => {
const { videoLive, videoRef } = useReadQrFromVideo()
const onQrCodeRead = useCallback((url: string) => {
console.log(url)
}, [])
const { videoLive, videoRef } = useReadQrFromVideo(onQrCodeRead)
return (
<div className="flex flex-col gap-4 items-center justify-center w-full h-full">
<h1 className="text-3xl font-semibold text-center text-white">scan!</h1>
<h1 className="text-3xl font-semibold text-center text-purple-50">
scan!
</h1>
<video
className={clsx('rounded shadow-md', { hidden: !videoLive })}
ref={videoRef}

View file

@ -4,7 +4,7 @@ import { useRouter } from 'next/router'
const Welcome: NextPage = () => {
const { push } = useRouter()
return (
<div className="w-full text-center flex flex-col justify-center h-full">
<div className="w-full text-center flex flex-col justify-center h-full text-purple-50">
<h1 className="text-4xl mb-3 font-bold">hey!</h1>
<p className="text-xl font-medium mb-2">
do you already have
@ -22,11 +22,11 @@ const Welcome: NextPage = () => {
and will only be decrypted for a few moments to send nano
</aside>
<div className="flex flex-col gap-3 justify-center w-full mb-6">
<button className="dark:bg-gray-900 bg-white dark:hover:bg-gray-800 py-2 px-5 rounded text-lg font-bold shadow-lg hover:shadow-md active:shadow transition-all duration-100">
<button className="dark:bg-gray-900 bg-purple-50 dark:hover:bg-gray-800 py-2 px-5 rounded text-lg font-bold shadow-lg hover:shadow-md active:shadow transition-all duration-100 text-gray-900">
i have a passphrase/seed
</button>
<button
className="dark:bg-gray-900 bg-white dark:hover:bg-gray-800 py-2 px-5 rounded text-lg font-bold shadow-lg hover:shadow-md active:shadow transition-all duration-100"
className="dark:bg-gray-900 bg-purple-50 dark:hover:bg-gray-800 py-2 px-5 rounded text-lg font-bold shadow-lg hover:shadow-md active:shadow transition-all duration-100 text-gray-900"
onClick={() => push('/welcome/new')}
>
what&apos;s a passphrase?

View file

@ -4,11 +4,12 @@ import { useRouter } from 'next/router'
const Done: NextPage = () => {
const { push } = useRouter()
return (
<div className="flex flex-col h-full justify-start items-center text-center px-4 gap-2">
<div className="flex flex-col h-full justify-start items-center text-center px-4 gap-2 text-purple-50">
<h1 className="text-9xl font-extrabold">3</h1>
<p className="text-3xl">you&apos;re done!</p>
<p className="text-xl">
all the buttons are now enabled and you can start using zep and nano!
all the buttons are now enabled and you can start using <b>zep</b> and{' '}
<b>nano</b>!
</p>
</div>
)

View file

@ -5,7 +5,7 @@ import { useEffect, useState } from 'react'
import useSetupSeed from '../../../lib/hooks/useSetupSeed'
const New: NextPage = () => {
const { lazy, seed, storeSeed } = useSetupSeed()
const { lazy, seed, storeSeed } = useSetupSeed(true)
const { push } = useRouter()
const [storing, setStoring] = useState(false)
useEffect(() => {
@ -18,8 +18,13 @@ const New: NextPage = () => {
copyAndGoToStore()
}, [seed, push])
const onStoreClick = async () => {
await storeSeed()
push('/welcome/new/done')
}
return (
<div className="flex flex-col h-full justify-start items-center text-center px-4 gap-2">
<div className="flex flex-col h-full justify-start items-center text-center px-4 gap-2 text-purple-50">
<h1 className="text-9xl font-extrabold">{!storing ? 1 : 2}</h1>
{!storing ? (
<>
@ -27,7 +32,7 @@ const New: NextPage = () => {
generate a <b>passphrase</b> and copy it to your clipboard
</p>
<button
className="dark:bg-gray-900 dark:text-purple-100 py-2 px-5 rounded text-2xl"
className="dark:bg-gray-900 dark:text-purple-100 py-2 px-5 rounded text-xl bg-purple-50 font-bold text-gray-900"
onClick={lazy}
>
generate passphrase
@ -45,8 +50,8 @@ const New: NextPage = () => {
store the <b>passphrase</b> securely in zep
</p>
<button
className="dark:bg-gray-900 dark:text-purple-100 py-2 px-5 rounded text-2xl"
onClick={storeSeed}
className="dark:bg-gray-900 dark:text-purple-100 py-2 px-5 rounded text-xl bg-purple-50 font-bold text-gray-900"
onClick={onStoreClick}
>
store passphrase
</button>

3
seeds.json Normal file
View file

@ -0,0 +1,3 @@
[
"expand target gospel goose oppose acquire genius hurdle trade size huge pact square silk canal bar curve shallow pistol push crowd glory slice news"
]