feat(Frontend): Transfers FE improvements (#187)

This commit is contained in:
Juan Enrique Alcaraz 2024-09-03 11:30:46 +02:00 committed by GitHub
parent ed879d4d91
commit e7cd6b1151
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 7767 additions and 3440 deletions

20
.github/workflows/frontend.yml vendored Normal file
View file

@ -0,0 +1,20 @@
name: Frontend
on:
pull_request:
paths:
- .github/workflows/frontend.yml
- apps/transfers/frontend/**
defaults:
run:
working-directory: apps/transfers/frontend
jobs:
linting:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20.x"
check-latest: true
- run: npm ci
- run: npm run lint

View file

@ -1,7 +1,5 @@
NEXT_PUBLIC_CHAIN_ID=testing
NEXT_PUBLIC_CHAIN_RPC_URL=http://0.0.0.0:26657
NEXT_PUBLIC_CHAIN_REST_URL=http://0.0.0.0:1317
NEXT_PUBLIC_ENCLAVE_PUBLIC_KEY=02ef4f843722d9badf8f5571d8f20cd1a21022fe52b9257d3a235c85dfc0ce11c0
NEXT_PUBLIC_TARGET_CHAIN=localWasm
NEXT_PUBLIC_ENCLAVE_PUBLIC_KEY=02360955ff74750f6ea0b539f41cce89451f591e4c835d0a5406e6effa96dd169d
NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS=wasm1jfgr0vgunezkhfmdy7krrupu6yjhx224nxtjptll2ylkkqhyzeshrspu9
# E2E Testing

View file

@ -1,7 +1,7 @@
{
"extends": "next/core-web-vitals",
"plugins": ["react-compiler"],
"rules": {
"react-compiler/react-compiler": "error"
}
"extends": [
"next",
"plugin:tailwindcss/recommended",
"plugin:prettier/recommended"
]
}

View file

@ -1,11 +1,9 @@
{
"plugins": ["prettier-plugin-tailwindcss"],
"quoteProps": "consistent",
"singleQuote": true,
"semi": false,
"singleAttributePerLine": true,
"tailwindFunctions": ["tw", "twJoin", "twMerge"],
"tailwindPreserveWhitespace": true,
"tailwindFunctions": ["twJoin", "twMerge"],
"tabWidth": 2,
"trailingComma": "all"
}

7
apps/transfers/frontend/env.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_ENCLAVE_PUBLIC_KEY: string
NEXT_PUBLIC_TARGET_CHAIN: string
NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS: string
}
}

View file

@ -1,7 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
reactCompiler: true,
instrumentationHook: true
},
}

File diff suppressed because it is too large Load diff

View file

@ -1,53 +1,49 @@
{
"name": "transfer-fe",
"name": "transfers-fe",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "playwright test",
"test:ui": "playwright test --ui",
"generate": "node ./scripts/generate.js && node ./scripts/rebuild-component-index.js",
"rebuild-component-index": "node ./scripts/rebuild-component-index.js"
"test:ui": "playwright test --ui"
},
"dependencies": {
"@cosmjs/cosmwasm-stargate": "^0.32.4",
"@cosmwasm/ts-codegen": "^1.11.1",
"eciesjs": "^0.4.7",
"graz": "^0.1.19",
"lodash": "^4.17.21",
"next": "^15.0.0-rc.0",
"react": "^19.0.0-rc-f994737d14-20240522",
"react-dom": "^19.0.0-rc-f994737d14-20240522",
"next": "14.2.7",
"notistack": "3.0.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"tailwind-merge": "^2.3.0",
"zod": "^3.23.8"
"zustand": "4.5.4"
},
"devDependencies": {
"@keplr-wallet/types": "0.12.103",
"@netlify/plugin-nextjs": "^5.3.3",
"@playwright/test": "^1.46.1",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.13",
"@types/lodash": "^4.17.5",
"@types/node": "^20",
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc",
"@types/react": "18.3.4",
"@types/react-dom": "18.3.0",
"@types/unzipper": "^0.10.10",
"babel-plugin-react-compiler": "0.0.0-experimental-696af53-20240625",
"dotenv": "16.4.5",
"eslint": "^8",
"eslint-plugin-react-compiler": "^0.0.0",
"eslint-config-next": "^14.2.7",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-tailwindcss": "^3.17.4",
"pino-pretty": "^11.2.2",
"postcss": "^8",
"prettier": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.4",
"tiny-invariant": "^1.3.3",
"typescript": "^5",
"unzipper": "^0.12.3"
},
"overrides": {
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

View file

@ -1,105 +0,0 @@
const fs = require('fs')
const readline = require('node:readline')
const componentsDirectory = `${__dirname}/../src/components`
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
rl.question(
`What do you want to name your new component? `,
(newComponentName) => {
newComponentName = newComponentName || 'NewComponent'
rl.question(
`What tag name do you want to use for your new component? (default: div) `,
(tagName) => {
tagName = tagName || 'div'
rl.question(
`Is this a complex component? (Y/N, default: N)`,
(isComplex) => {
isComplex = isComplex === 'Y' ? true : false
const replacementsMap = {
'NewComponent': newComponentName,
'div': tagName,
'//[^\n]+': '',
}
if (isComplex) {
const componentDirectory = `${componentsDirectory}/${newComponentName}`
// Make sure components directory exists
fs.mkdirSync(componentsDirectory, { recursive: true })
// Create the new component directory and copy the template files
fs.cpSync(
`${__dirname}/templates/NewComponent`,
componentDirectory,
{
recursive: true,
},
)
const template = fs.readFileSync(
`${__dirname}/templates/NewComponent/NewComponent.tsx`,
'utf8',
)
const mergedTemplate = Object.entries(replacementsMap).reduce(
(acc, [key, value]) => {
return acc.replace(new RegExp(`${key}`, 'isg'), value)
},
template,
)
const newComponentFile = `${componentDirectory}/NewComponent.tsx`
fs.writeFileSync(newComponentFile, mergedTemplate)
fs.writeFileSync(
`${componentDirectory}/index.ts`,
`export { ${newComponentName} } from './${newComponentName}'\n`,
)
fs.renameSync(
newComponentFile,
`${componentDirectory}/${newComponentName}.tsx`,
)
} else {
const newComponentFile = `${componentsDirectory}/${newComponentName}.tsx`
fs.cpSync(
`${__dirname}/templates/NewComponent.tsx`,
newComponentFile,
)
const template = fs.readFileSync(
`${__dirname}/templates/NewComponent.tsx`,
'utf8',
)
const mergedTemplate = Object.entries(replacementsMap).reduce(
(acc, [key, value]) => {
return acc.replace(new RegExp(`${key}`, 'isg'), value)
},
template,
)
fs.writeFileSync(newComponentFile, mergedTemplate)
}
console.log(
`Component ${newComponentName} created at ${componentsDirectory}`,
)
rl.close()
},
)
},
)
},
)

View file

@ -1,43 +0,0 @@
const fs = require('fs')
const componentsDirectory = `${__dirname}/../src/components`
const indexFile = `${componentsDirectory}/index.ts`
// Get directories and files in the components directory
const filesAndDirectories = fs.readdirSync(componentsDirectory)
const [files, directories] = filesAndDirectories.reduce(
(acc, fileOrDirectory) => {
if (
fs.statSync(`${componentsDirectory}/${fileOrDirectory}`).isDirectory()
) {
acc[1].push(fileOrDirectory)
} else {
acc[0].push(fileOrDirectory)
}
return acc
},
[[], []],
)
// Write them
fs.writeFileSync(
indexFile,
filesAndDirectories
.reduce((acc, fileOrDirectory) => {
if (['.DS_Store', 'index.ts'].includes(fileOrDirectory)) {
return [...acc]
}
const componentName = fileOrDirectory.replace('.tsx', '')
return [...acc, `export { ${componentName} } from './${componentName}'`]
}, [])
.join('\n'),
{
flag: 'w',
},
)
console.log(`Rebuilt component index file at ${indexFile}`)

View file

@ -1,24 +0,0 @@
import { ComponentProps } from 'react'
import { twMerge } from 'tailwind-merge'
interface NewComponentProps extends ComponentProps<'div'> {}
export function NewComponent({
children,
className,
...otherProps
}: NewComponentProps) {
return (
<div
className={twMerge(
`
`,
className,
)}
{...otherProps}
>
{children}
</div>
)
}

View file

@ -1,20 +0,0 @@
import { ComponentProps } from 'react'
import { twMerge } from 'tailwind-merge'
import { classNames } from './classNames'
interface NewComponentProps extends ComponentProps<'div'> {}
export function NewComponent({
children,
className,
...otherProps
}: NewComponentProps) {
return (
<div
className={twMerge(classNames.container, className)}
{...otherProps}
>
{children}
</div>
)
}

View file

@ -1,7 +0,0 @@
import { tw } from '@/lib/tw'
export const classNames = {
container: tw`
// styles here
`,
}

View file

@ -1 +0,0 @@
export { NewComponent } from './NewComponent'

View file

@ -0,0 +1,32 @@
'use client'
import { useRouter } from 'next/navigation'
import { useSuggestChainAndConnect } from 'graz'
import chain from '@/config/chain'
import { StyledText } from '@/components/StyledText'
import { useGlobalState } from '@/state/useGlobalState'
export default function Landing() {
const router = useRouter()
const { suggestAndConnect } = useSuggestChainAndConnect({
onSuccess: () => router.replace('/set-seed'),
})
const connectWallet = () => {
useGlobalState.getState().setLoading(true)
suggestAndConnect({ chainInfo: chain })
}
return (
<main className="flex min-h-screen flex-col items-center gap-4 p-24">
<p>Connect your Keplr wallet to log in</p>
<StyledText
variant="button.primary"
onClick={connectWallet}
>
Connect Keplr
</StyledText>
</main>
)
}

View file

@ -0,0 +1,51 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { EnterSeedModal } from '@/components/EnterSeedModal'
import { generateMnemonic, saveMnemonic } from '@/lib/ephemeralKeypair'
import { StyledText } from '@/components/StyledText'
import { useGlobalState } from '@/state/useGlobalState'
const mnemonic = generateMnemonic()
export default function SetSeed() {
const router = useRouter()
const [isModalOpen, setIsModalOpen] = useState(false)
const acceptPhrase = () => {
useGlobalState.getState().setLoading(true)
saveMnemonic(mnemonic)
router.replace('/dashboard')
}
return (
<main className="flex min-h-screen flex-col items-center gap-8 p-24">
<h1>
This will be your recovery seed phrase for your public/private keys:
</h1>
<code className="rounded bg-slate-500 p-2 font-bold text-white">
{mnemonic}
</code>
<div className="flex flex-col gap-4">
<StyledText
variant="button.primary"
onClick={acceptPhrase}
>
Continue with the autogenerated seed phrase
</StyledText>
<StyledText
variant="button.secondary"
onClick={() => setIsModalOpen(true)}
>
I want to enter my own recovery phrase instead
</StyledText>
</div>
<EnterSeedModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
/>
</main>
)
}

View file

@ -2,22 +2,30 @@
import { useEffect, useState } from 'react'
import { isEmpty } from 'lodash'
import {
DepositModalWindow,
Icon,
Notifications,
StyledText,
TransferModalWindow,
WithdrawModalWindow,
} from '@/components'
useAccount,
useCosmWasmSigningClient,
useDisconnect,
useExecuteContract,
useQuerySmart,
} from 'graz'
import { tw } from '@/lib/tw'
import { wasmEventHandler } from '@/lib/wasmEventHandler'
import { FormActionResponse } from '@/lib/types'
import { chain } from '@/lib/chainConfig'
import { cosm } from '@/lib/cosm'
import { wallet } from '@/lib/wallet'
import { contractMessageBuilders } from '@/lib/contractMessageBuilders'
import { StyledText } from '@/components/StyledText'
import { Icon } from '@/components/Icon'
import { DepositModalWindow } from '@/components/DepositModalWindow'
import { TransferModalWindow } from '@/components/TransferModalWindow'
import { WithdrawModalWindow } from '@/components/WithdrawModalWindow'
import {
clearMnemonic,
decrypt,
getEphemeralKeypair,
} from '@/lib/ephemeralKeypair'
import chain from '@/config/chain'
import { useGlobalState } from '@/state/useGlobalState'
import { showError, showSuccess } from '@/lib/notifications'
function formatAmount(value: number) {
return value.toLocaleString('en-US', {
@ -41,97 +49,69 @@ const retrieveBalance = (data: string) => {
return balance
}
export default function Home() {
const [requestBalanceResult, setRequestBalanceResult] =
useState<FormActionResponse>()
export default function Dashboard() {
const [balance, setBalance] = useState(0)
const [isLoading, setIsLoading] = useState(false)
const [walletAddress, setWalletAddress] = useState(
wallet.getAccount().address,
)
const [loading, setLoading] = useState(false)
const { data } = useAccount()
const [isDepositModalOpen, setIsDepositModalOpen] = useState(false)
const [isTransferModalOpen, setIsTransferModalOpen] = useState(false)
const [isWithdrawModalOpen, setIsWithdrawModalOpen] = useState(false)
// Listen for Keplr wallet switches so we can refresh the Keplr & CosmWasm info
useEffect(() => {
const params: [string, () => void] = [
'keplr_keystorechange',
() => {
setIsLoading(true)
wallet
.refreshUser()
.then(cosm.init)
.finally(() => {
setWalletAddress(wallet.getAccount().address)
setIsLoading(false)
const { disconnect } = useDisconnect({
onSuccess: clearMnemonic,
})
const { data: signingClient } = useCosmWasmSigningClient()
const { executeContract } = useExecuteContract({
contractAddress: process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS!,
onError: (error: any) => {
setLoading(false)
showError(error.message)
},
]
})
const walletAddress = data?.bech32Address ?? ''
window.addEventListener(...params)
return () => window.removeEventListener(...params)
}, [])
const { data: encryptedBalance } = useQuerySmart<string, any>(
data && {
address: process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS,
queryMsg: contractMessageBuilders.getBalance(walletAddress),
},
)
// Set the current balance for the wallet. Whenever the wallet changes, we retrieve its balance
useEffect(() => {
cosm
.queryTransferContract({
messageBuilder: () => contractMessageBuilders.getBalance(walletAddress),
})
.then((data) => setBalance(retrieveBalance(wallet.decrypt(data))))
}, [walletAddress])
decrypt(encryptedBalance!)
.then((data) => setBalance(retrieveBalance(data)))
.catch((error: any) => showError(error.message))
}, [encryptedBalance])
// Listen for the response event from the blockchain when requesting the current wallet new balance
useEffect(() => {
return wasmEventHandler(
`execute._contract_address='${process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS}' AND wasm-store_balance.address='${walletAddress}'`,
{
next: (event) => {
next: (event: any) => {
console.log(event)
if (!isEmpty(event?.events['wasm-store_balance.encrypted_balance'])) {
setBalance(
retrieveBalance(
wallet.decrypt(
decrypt(
event.events['wasm-store_balance.encrypted_balance'][0],
),
),
)
).then((data) => {
setLoading(false)
setBalance(retrieveBalance(data))
showSuccess('Balance updated correctly')
})
}
},
},
)
}, [walletAddress])
// Request the current wallet new balance calling the transfer contract
async function requestBalance(): Promise<void> {
let result
try {
setIsLoading(true)
const response = await cosm.executeTransferContract({
messageBuilder: () =>
contractMessageBuilders.requestBalance(wallet.getKeypair().pubkey),
async function requestBalance() {
setLoading(true)
executeContract({
signingClient: signingClient!,
msg: contractMessageBuilders.requestBalance(
(await getEphemeralKeypair()).pubkey,
),
})
console.log(response)
setIsLoading(false)
result = {
success: true,
messages: ['woo!'],
}
} catch (error) {
console.error(error)
setIsLoading(false)
result = {
success: false,
messages: ['Something went wrong'],
}
} finally {
setRequestBalanceResult(result)
}
}
return (
@ -142,11 +122,9 @@ export default function Home() {
flex-col
items-center
justify-center
bg-[url(/images/moroccan-flower.png)]
p-12
`}
>
<Notifications formActionResponse={requestBalanceResult} />
<div
className={tw`
flex
@ -154,40 +132,41 @@ export default function Home() {
gap-2
divide-y
rounded-md
border
border-black/20
bg-white
p-5
py-3
shadow-2xl
shadow-md
outline
outline-1
outline-black/5
`}
>
<div className="flex w-full justify-between">
<span className="font-bold">Balance:</span>
{!loading ? (
<span className="font-bold">{formatAmount(balance)}</span>
</div>
<StyledText
className="bg-blue-500 font-bold"
variant="button.primary"
as="button"
disabled={isLoading}
onClick={requestBalance}
>
{!isLoading ? (
<Icon name="building-columns" />
) : (
<div className="animate-spin">
<Icon name="spinner" />
</div>
)}
</div>
<StyledText
className="justify-start font-bold"
variant="button.primary"
as="button"
disabled={loading}
onClick={requestBalance}
>
<Icon name="building-columns" />
Get Balance
</StyledText>
<div className="my-1 w-full border-black/20"></div>
<div className="my-1 w-full border-black/25"></div>
<StyledText
className="w-full bg-emerald-500 font-bold"
className="w-full justify-start bg-emerald-500"
variant="button.primary"
onClick={() => setIsDepositModalOpen(true)}
>
@ -196,20 +175,40 @@ export default function Home() {
</StyledText>
<StyledText
variant="button.primary"
className="w-full font-bold"
className="w-full justify-start bg-violet-500"
onClick={() => setIsTransferModalOpen(true)}
>
<Icon name="arrows-left-right" />
Transfer
</StyledText>
<StyledText
className="w-full bg-amber-500 font-bold"
className="w-full justify-start bg-amber-500"
variant="button.primary"
onClick={() => setIsWithdrawModalOpen(true)}
>
<Icon name="money-bills-simple" />
Withdraw
</StyledText>
<div className="my-1 w-full border-black/25"></div>
<StyledText
className="w-full justify-start"
variant="button.secondary"
onClick={() => {
const res = confirm(
'Disconnecting your account will remove your mnemonic and private key and you will lose access to your data unless you have them backed up. Are you sure you want to continue?',
)
if (!res) {
return
}
useGlobalState.getState().setLoading(true)
disconnect()
}}
>
<Icon name="door-open" />
Disconnect
</StyledText>
</div>
<DepositModalWindow

View file

@ -1,9 +1,9 @@
import { App } from '@/components'
import type { Metadata } from 'next'
import { Raleway } from 'next/font/google'
import Script from 'next/script'
import { twMerge } from 'tailwind-merge'
import './globals.css'
import Root from '@/components/Root'
const bodyFont = Raleway({ subsets: ['latin'], variable: '--font-raleway' })
@ -46,20 +46,15 @@ export default function RootLayout({
return (
<html lang="en">
<head>
<link
rel="shortcut icon"
href="/favicon.png"
type="image/png"
/>
<Script src="https://kit.fontawesome.com/ddaf2d7713.js" />
</head>
<body
className={twMerge(
`
overflow-x-hidden
bg-appBgColor
text-textColor
`,
bodyFont.className,
bodyFont.variable,
)}
>
<App>{children}</App>
<body className={(bodyFont.className, 'bg-gray-500/25')}>
<Root>{children}</Root>
</body>
</html>
)

View file

@ -0,0 +1,18 @@
import Link from 'next/link'
import { StyledText } from '@/components/StyledText'
export default function NotFound() {
return (
<main className="flex min-h-screen flex-col items-center gap-4 p-24">
<p>Page not found </p>
<StyledText
as={Link}
href="/"
variant="button.secondary"
>
Go Home
</StyledText>
</main>
)
}

View file

@ -1,27 +0,0 @@
'use client'
import { ReactNode, useEffect, useState } from 'react'
import { wallet } from '@/lib/wallet'
import { cosm } from '@/lib/cosm'
import { LoadingSpinner } from './LoadingSpinner'
// Root component
export function App({ children }: { children: ReactNode }) {
const [isInit, setIsInit] = useState(false)
useEffect(() => {
// Inititalize Keplr and CosmWasm wrappers
wallet
.init()
.then(cosm.init)
.then(() => setIsInit(true))
}, [])
return (
<>
<LoadingSpinner isLoading={!isInit} />
{isInit && children}
</>
)
}

View file

@ -1,70 +1,75 @@
'use client'
import { ChangeEvent, useActionState, useState } from 'react'
import { ChangeEvent, useState } from 'react'
import { useCosmWasmSigningClient, useExecuteContract } from 'graz'
import { isEmpty } from 'lodash'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { ModalWindow, ModalWindowProps } from '@/components/ModalWindow'
import { Notifications } from '@/components/Notifications'
import { StyledBox } from '@/components/StyledBox'
import { StyledText } from '@/components/StyledText'
import { contractMessageBuilders } from '@/lib/contractMessageBuilders'
import { cosm } from '@/lib/cosm'
import { tw } from '@/lib/tw'
import { FormActionResponse } from '@/lib/types'
import chain from '@/config/chain'
import { showError, showSuccess } from '@/lib/notifications'
import { Icon } from './Icon'
// Deposit the specified amount calling the Transfer contract
async function handleDeposit(
_: FormActionResponse,
formData: FormData,
): Promise<FormActionResponse> {
const amount = String(formData.get('amount'))
try {
const result = await cosm.executeTransferContract({
messageBuilder: contractMessageBuilders.deposit,
fundsAmount: amount,
export function DepositModalWindow(props: ModalWindowProps) {
const [amount, setAmount] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const { data: signingClient } = useCosmWasmSigningClient()
const { executeContract } = useExecuteContract({
contractAddress: process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS!,
onSuccess: (data) => {
console.log(data)
setLoading(false)
showSuccess('Deposit transaction sent successfully')
},
onError: (error: any) => {
setLoading(false)
showError(error.message)
},
})
console.log(result)
// Deposit the specified amount calling the Transfer contract
function handleDeposit() {
setError('')
return {
success: true,
messages: ['woo!'],
}
} catch (error) {
console.error(error)
if (amount <= 0) {
setError('Amount should be greater than zero.')
return {
success: false,
messages: ['Something went wrong'],
}
}
return
}
export function DepositModalWindow({
isOpen,
onClose,
...otherProps
}: ModalWindowProps) {
const [amount, setAmount] = useState(0)
const [formActionResponse, formAction, isLoading] = useActionState(
handleDeposit,
null,
)
setLoading(true)
executeContract({
signingClient: signingClient!,
msg: contractMessageBuilders.deposit(),
funds: [
{
denom: chain.currencies[0].coinMinimalDenom,
amount: String(amount),
},
],
})
}
return (
<ModalWindow
isOpen={isOpen}
onClose={onClose}
{...otherProps}
disableClosing={loading}
{...props}
>
<LoadingSpinner isLoading={isLoading} />
<LoadingSpinner isLoading={loading} />
<ModalWindow.Title className="bg-emerald-500">Deposit</ModalWindow.Title>
<ModalWindow.Title className="bg-emerald-500">
<Icon name="piggy-bank" /> Deposit
</ModalWindow.Title>
<form action={formAction}>
<ModalWindow.Body className="space-y-3">
<Notifications formActionResponse={formActionResponse} />
{!isEmpty(error) && (
<div className="font-bold text-red-500">{error}</div>
)}
<StyledBox
as="input"
@ -91,17 +96,17 @@ export function DepositModalWindow({
className="bg-emerald-500"
disabled={amount === 0}
variant="button.primary"
onClick={handleDeposit}
>
Deposit
</StyledText>
<StyledText
variant="button.secondary"
onClick={onClose}
onClick={props.onClose}
>
Cancel
</StyledText>
</ModalWindow.Buttons>
</form>
</ModalWindow>
)
}

View file

@ -0,0 +1,77 @@
'use client'
import { ChangeEvent, useState } from 'react'
import { useRouter } from 'next/navigation'
import { EnglishMnemonic } from '@cosmjs/crypto'
import { isEmpty } from 'lodash'
import { saveMnemonic } from '@/lib/ephemeralKeypair'
import { ModalWindow, ModalWindowProps } from '@/components/ModalWindow'
import { StyledBox } from './StyledBox'
import { StyledText } from './StyledText'
import { useGlobalState } from '@/state/useGlobalState'
export function EnterSeedModal({
isOpen,
onClose,
...otherProps
}: ModalWindowProps) {
const router = useRouter()
const [mnemonic, setMnemonic] = useState('')
const [error, setError] = useState('')
const submitSeed = () => {
try {
useGlobalState.getState().setLoading(true)
setError('')
const englishMnemonic = new EnglishMnemonic(mnemonic)
saveMnemonic(englishMnemonic.toString())
router.replace('/dashboard')
} catch (err: any) {
useGlobalState.getState().setLoading(false)
setError(err.message)
if (err.message !== 'Invalid mnemonic format') {
throw err
}
}
}
return (
<ModalWindow
isOpen={isOpen}
onClose={onClose}
{...otherProps}
>
<ModalWindow.Title>Enter recovery phrase</ModalWindow.Title>
<ModalWindow.Body className="space-y-3">
{!isEmpty(error) && (
<div className="font-bold text-red-500">{error}</div>
)}
<StyledBox
as="input"
min={0}
value={mnemonic}
variant="input"
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setMnemonic(event.target.value)
}
/>
</ModalWindow.Body>
<ModalWindow.Buttons>
<StyledText
as="button"
variant="button.primary"
onClick={submitSeed}
>
Continue
</StyledText>
<StyledText
variant="button.secondary"
onClick={onClose}
>
Cancel
</StyledText>
</ModalWindow.Buttons>
</ModalWindow>
)
}

View file

@ -1,4 +1,4 @@
export { Icon } from "./Icon"
export { Icon } from './Icon'
export type {
BrandIconName,
IconName,
@ -6,4 +6,4 @@ export type {
IconRotationOption,
IconVariant,
RegularIconName,
} from "./types"
} from './types'

View file

@ -15,7 +15,6 @@ export function LoadingSpinner({
<div
className={twMerge(
`
pointer-events-none
absolute
bottom-0
left-0
@ -25,7 +24,7 @@ export function LoadingSpinner({
flex
items-center
justify-center
bg-appBgColor
bg-gray-500/25
transition-all
`,
isLoading
@ -34,6 +33,7 @@ export function LoadingSpinner({
`
: `
opacity-0
pointer-events-none
`,
className,
)}

View file

@ -6,6 +6,7 @@ import { twMerge } from 'tailwind-merge'
import { classNames } from './classNames'
export interface ModalWindowProps extends ComponentProps<'div'> {
disableClosing?: boolean
isOpen: boolean
onClose: () => void
}
@ -13,6 +14,7 @@ export interface ModalWindowProps extends ComponentProps<'div'> {
export function ModalWindow({
children,
className,
disableClosing,
isOpen,
onClose,
...otherProps
@ -69,8 +71,8 @@ export function ModalWindow({
}, [isOpen])
useEffect(() => {
if (isOpen) {
function handleEscape(event: KeyboardEvent) {
if (!disableClosing && isOpen) {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose()
}
@ -78,11 +80,9 @@ export function ModalWindow({
window.addEventListener('keydown', handleEscape)
return () => {
window.removeEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape)
}
}
}, [isOpen, onClose])
}, [disableClosing, isOpen, onClose])
if (!isClient) {
return null
@ -92,9 +92,8 @@ export function ModalWindow({
<>
<div
className={classNames.backdrop({ modalState })}
onClick={handleClose}
{...(!disableClosing && { onClick: handleClose })}
/>
<div
className={twMerge(classNames.container({ modalState }), className)}
ref={windowContentsContainerRef}

View file

@ -1,8 +1,9 @@
import { tw } from "@/lib/tw";
import { twMerge } from "tailwind-merge";
import { tw } from '@/lib/tw'
import { twMerge } from 'tailwind-merge'
export const classNames = {
backdrop: ({ modalState = 'closed' }) => twMerge(
backdrop: ({ modalState = 'closed' }) =>
twMerge(
`
fixed
left-0
@ -13,49 +14,49 @@ export const classNames = {
z-10
duration-500
`,
(modalState === 'opening' || modalState === 'open')
?
`
modalState === 'opening' || modalState === 'open'
? `
opacity-100
backdrop-blur-md
pointer-events-auto
bg-shadedBgColor/20
`
:
`
: `
opacity-0
backdrop-blur-none
pointer-events-none
bg-transparent
`
`,
),
container: ({ modalState = 'closed' }) => twMerge(
container: ({ modalState = 'closed' }) =>
twMerge(
`
fixed
left-1/2
top-1/2
bg-appBgColor
rounded-lg
rounded-md
overflow-hidden
-translate-x-1/2
z-10
duration-500
min-w-64
shadow-xl
shadow-md
outline
outline-1
outline-black/5
`,
(modalState === 'opening' || modalState === 'open')
?
`
modalState === 'opening' || modalState === 'open'
? `
opacity-100
-translate-y-1/2
`
:
`
: `
opacity-0
pointer-events-none
-translate-y-full
`
`,
),
header: tw`
@ -75,5 +76,5 @@ export const classNames = {
flex-row-reverse
gap-1
p-3
`
`,
}

View file

@ -1,140 +0,0 @@
'use client'
import { Icon } from '@/components/Icon'
import { StyledText } from '@/components/StyledText'
import { FormActionResponse } from '@/lib/types'
import { uniqueId } from 'lodash'
import { ComponentProps, useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { twMerge } from 'tailwind-merge'
import { classNames } from './classNames'
interface NotificationsProps extends Omit<ComponentProps<'ul'>, 'children'> {
formActionResponse: FormActionResponse | undefined
}
type NotificationState = 'entering' | 'entered' | 'exiting' | 'exited'
export function Notifications({
className,
formActionResponse,
...otherProps
}: NotificationsProps) {
const [storedMessages, setStoredMessages] = useState<
{ id: number; message: string }[]
>([])
const [isClient, setIsClient] = useState(false)
const { success, messages } = formActionResponse || {
success: false,
messages: [],
}
useEffect(() => {
if (messages) {
setStoredMessages((currentStoredMessages) => [
...currentStoredMessages,
...messages.map((message) => ({
id: Number(uniqueId()),
message,
})),
])
}
}, [messages])
useEffect(() => {
setIsClient(true)
}, [])
function handleDismissMessage(id: number) {
setStoredMessages((currentStoredMessages) =>
currentStoredMessages.filter((message) => message.id !== id),
)
}
if (!isClient) {
return null
}
return createPortal(
<>
<div
className={classNames.backdrop({
hasMessages: storedMessages.length > 0,
success,
})}
/>
{messages && (
<ul
className={twMerge(classNames.container, className)}
{...otherProps}
>
{storedMessages.map(({ id, message }, index) => (
<Notifications.Notification
index={index}
key={id}
message={message}
success={success}
onDismiss={handleDismissMessage.bind(null, id)}
/>
))}
</ul>
)}
</>,
document.body,
)
}
Notifications.Notification = function Notification({
index,
message,
success,
onDismiss,
...otherProps
}: ComponentProps<'li'> & {
index: number
message: string
success: boolean
onDismiss: () => void
}) {
const [state, setState] = useState<NotificationState>('entering')
function handleAnimationEnd() {
if (state === 'entering') {
setState('entered')
}
if (state === 'exiting') {
setState('exited')
onDismiss()
}
}
return (
state !== 'exited' && (
<li
className={classNames.notificationContainer({ state })}
style={{
animationDelay: `${index * 0.1}s`,
}}
onAnimationEnd={handleAnimationEnd}
{...otherProps}
>
<div className={classNames.notificationSurface({ success })}>
<Icon name={success ? 'circle-check' : 'circle-exclamation'} />
<div>{message}</div>
<div>
<StyledText
as="button"
variant="button.tool"
onClick={() => setState('exiting')}
>
<Icon name="xmark" />
</StyledText>
</div>
</div>
</li>
)
)
}

View file

@ -1,90 +0,0 @@
import { tw } from '@/lib/tw'
import { twMerge } from 'tailwind-merge'
export const classNames = {
backdrop: ({ hasMessages = false, success = false }) =>
twMerge(
`
fixed
bottom-0
right-0
h-[20vh]
w-[50vw]
bg-gradient-to-tl
via-transparent
to-transparent
opacity-0
transition-opacity
duration-500
z-10
`,
success ? 'from-green-400/50' : 'from-red-400/50',
hasMessages &&
`
opacity-100
`,
),
container: tw`
fixed
bottom-0
right-0
flex
flex-col-reverse
gap-2
p-6
z-20
`,
notificationContainer: ({ state = 'entering' }) =>
twMerge(
`
w-[20rem]
overflow-hidden
transition-all
`,
state === 'entering' &&
`
animate-animateInX
`,
state === 'entered' &&
`
opacity-100
shadow-md
`,
state === 'exiting' &&
`
animate-animateOutX
`,
state === 'exited' &&
`
hidden
`,
),
notificationSurface: ({ success = false }) =>
twMerge(
`
grid
w-full
grid-cols-[auto,1fr,auto]
gap-3
rounded-md
border-2
px-3
py-3
text-sm
text-white
transition-all
`,
success
? `
border-green-400
bg-green-500
`
: `
border-red-400
bg-red-500
`,
),
}

View file

@ -1 +0,0 @@
export { Notifications } from './Notifications'

View file

@ -0,0 +1,37 @@
'use client'
import { useEffect, useState } from 'react'
import { SnackbarProvider } from 'notistack'
import chain from '@/config/chain'
import Middleware from './Middleware'
import GrazWrapper from './GrazWrapper'
import { LoadingWrapper } from './LoadingWrapper'
// Method to skip first render because Graz initial wallet status is 'disconnected' and NOT 'reconnecting'
// With this, we let the middleware initialize with the 'reconnecting' status
const SkipFirstRender = ({ children }: React.PropsWithChildren) => {
const [isNotFirstRender, setIsNotFirstRender] = useState(false)
useEffect(() => {
setIsNotFirstRender(true)
}, [])
return isNotFirstRender && children
}
// Global App stuff definition
export default function App({ children }: React.PropsWithChildren) {
return (
<GrazWrapper chains={[chain]}>
<SkipFirstRender>
<Middleware>{children}</Middleware>
</SkipFirstRender>
<LoadingWrapper />
<SnackbarProvider
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
preventDuplicate
/>
</GrazWrapper>
)
}

View file

@ -0,0 +1,30 @@
import { GrazProvider, WalletType } from 'graz'
import { ChainInfo } from '@keplr-wallet/types'
export default function GrazWrapper({
chains,
children,
}: {
chains: ChainInfo[]
children: React.ReactNode
}) {
return (
<GrazProvider
grazOptions={{
chains,
defaultWallet: WalletType.KEPLR,
chainsConfig: chains.reduce(
(acc, curr) => ({
...acc,
[curr.chainId]: {
gas: { denom: curr.feeCurrencies[0].coinDenom, price: '0' },
},
}),
{},
),
}}
>
{children}
</GrazProvider>
)
}

View file

@ -0,0 +1,8 @@
import { useGlobalState } from '@/state/useGlobalState'
import { LoadingSpinner } from '@/components/LoadingSpinner'
export const LoadingWrapper = () => {
const loading = useGlobalState((state) => state.loading)
return <LoadingSpinner isLoading={loading} />
}

View file

@ -0,0 +1,77 @@
'use client'
import { useEffect } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { useAccount } from 'graz'
import { getMnemonic } from '@/lib/ephemeralKeypair'
import { useGlobalState } from '@/state/useGlobalState'
enum SetupPhases {
// User is not connected to Keplr Wallet
WALLET_CONNECTION,
// No mnemonic in local storage
MNEMONIC_CREATION,
// User is connected and has mnemonic
FINISHED_SETUP,
}
const loginRoutes = ['/', '/set-seed']
const setupRoutesMapping: Record<SetupPhases, string> = {
[SetupPhases.WALLET_CONNECTION]: '/',
[SetupPhases.MNEMONIC_CREATION]: '/set-seed',
[SetupPhases.FINISHED_SETUP]: '',
}
// App routing middleware
// NOTE: Cannot use Nextjs middleware file because browser info is required
export default function Middleware({
children,
}: {
children: React.ReactNode
}) {
const router = useRouter()
const pathname = usePathname()
const { status, isDisconnected, isReconnecting } = useAccount()
const mnemonic = getMnemonic()
const isAnonPage = loginRoutes.includes(pathname)
const targetRoute =
setupRoutesMapping[
((): SetupPhases => {
if (isDisconnected) {
return SetupPhases.WALLET_CONNECTION
} else if (!mnemonic) {
return SetupPhases.MNEMONIC_CREATION
} else {
return SetupPhases.FINISHED_SETUP
}
})()
]
useEffect(() => {
if (isReconnecting) {
return
}
let redirectTo = targetRoute
if (!redirectTo && isAnonPage) {
redirectTo = '/dashboard'
}
// Redirect if the path is not the correct one
if (redirectTo && redirectTo !== pathname) {
useGlobalState.getState().setLoading(true)
router.replace(redirectTo)
} else {
useGlobalState.getState().setLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, pathname])
return (
!isReconnecting &&
(targetRoute === pathname || (!isAnonPage && !targetRoute)) &&
children
)
}

View file

@ -0,0 +1,11 @@
'use client'
import dynamic from 'next/dynamic'
// Dynamic import for app so no Graz code prerenders in server
// This avoids errors on first render
const App = dynamic(() => import('./App'), { ssr: false })
export default function Root({ children }: React.PropsWithChildren) {
return <App>{children}</App>
}

View file

@ -19,6 +19,7 @@ export const classNames = {
border-borderColor
!outline-accentColor
!ring-accentColor
accent-accentColor
checked:text-accentColor
`,

View file

@ -8,6 +8,7 @@ const buttonClassNames = tw`
items-center
justify-center
gap-2
font-bold
transition-all
hover:scale-105
disabled:pointer-events-none
@ -25,41 +26,17 @@ const headingClassNames = tw`
export const classNames = {
'button.primary': twMerge(
buttonClassNames,
`
whitespace-nowrap
rounded-md
bg-accentColor
px-3
py-1
text-white
`,
`whitespace-nowrap rounded-md bg-accentColor px-3 py-1 text-white`,
),
'button.secondary': twMerge(
buttonClassNames,
`
relative
z-10
whitespace-nowrap
rounded-md
border
bg-white
px-3
py-1
text-textColor
backdrop-blur-sm
`,
`relative z-10 whitespace-nowrap rounded-md border bg-gray-400 px-3 py-1 text-white backdrop-blur-sm`,
),
'button.icon': twMerge(
buttonClassNames,
`
size-10
rounded-full
bg-shadedBgColor
hover:bg-accentColor
hover:text-appBgColor
`,
`size-10 rounded-full bg-shadedBgColor hover:bg-accentColor hover:text-appBgColor`,
),
'button.tool': tw`
@ -80,33 +57,13 @@ export const classNames = {
text-fadedTextColor
`,
'h1': twJoin(
headingClassNames,
`
text-5xl
`,
),
'h1': twJoin(headingClassNames, `text-5xl`),
'h2': twJoin(
headingClassNames,
`
text-4xl
`,
),
'h2': twJoin(headingClassNames, `text-4xl`),
'h3': twJoin(
headingClassNames,
`
text-3xl
`,
),
'h3': twJoin(headingClassNames, `text-3xl`),
'h4': twJoin(
headingClassNames,
`
text-lg
`,
),
'h4': twJoin(headingClassNames, `text-lg`),
'label': tw`
text-sm
@ -115,25 +72,12 @@ export const classNames = {
'link': twMerge(
buttonClassNames,
`
text-accentColor
underline
underline-offset-4
transition-all
hover:underline-offset-4
`,
`text-accentColor underline underline-offset-4 transition-all hover:underline-offset-4`,
),
'link.subtle': twMerge(
buttonClassNames,
`
text-inherit
underline-offset-4
transition-all
hover:text-accentColor
hover:underline
hover:underline-offset-4
`,
`text-inherit underline-offset-4 transition-all hover:text-accentColor hover:underline hover:underline-offset-4`,
),
'logo': tw`

View file

@ -1,17 +1,20 @@
'use client'
import { PublicKey, encrypt } from 'eciesjs'
import { ChangeEvent, useActionState, useState } from 'react'
import invariant from 'tiny-invariant'
import { ChangeEvent, useState } from 'react'
import { useAccount, useCosmWasmSigningClient, useExecuteContract } from 'graz'
import { isEmpty } from 'lodash'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { ModalWindow, ModalWindowProps } from '@/components/ModalWindow'
import { Notifications } from '@/components/Notifications'
import { StyledBox } from '@/components/StyledBox'
import { StyledText } from '@/components/StyledText'
import { cosm } from '@/lib/cosm'
import { FormActionResponse } from '@/lib/types'
import { wallet } from '@/lib/wallet'
import { contractMessageBuilders } from '@/lib/contractMessageBuilders'
import chain from '@/config/chain'
import { tw } from '@/lib/tw'
import { showError, showSuccess } from '@/lib/notifications'
import { isValidAddress } from '@/lib/isValidAddress'
import { Icon } from './Icon'
// Encrypt the transfer data using the enclave public key
function encryptMsg(data: {
@ -19,11 +22,6 @@ function encryptMsg(data: {
receiver: string
amount: string
}): string {
invariant(
process.env.NEXT_PUBLIC_ENCLAVE_PUBLIC_KEY,
'NEXT_PUBLIC_ENCLAVE_PUBLIC_KEY must be defined',
)
// Create the public key from the hex
const pubkey = PublicKey.fromHex(process.env.NEXT_PUBLIC_ENCLAVE_PUBLIC_KEY)
// Convert the data into a JSON string
@ -37,68 +35,85 @@ function encryptMsg(data: {
return encryptedState.toString('hex')
}
// Transfer an amount between wallets calling the Transfer contract with an encrypted request
async function handleTransfer(
_: FormActionResponse,
formData: FormData,
): Promise<FormActionResponse> {
const receiver = String(formData.get('receiver'))
const amount = String(formData.get('amount'))
try {
const encryptedMsg = encryptMsg({
sender: wallet.getAccount().address,
receiver,
amount,
})
const result = await cosm.executeTransferContract({
messageBuilder: () => contractMessageBuilders.transfer(encryptedMsg),
})
console.log(result)
return {
success: true,
messages: ['woo!'],
}
} catch (error) {
console.error(error)
return {
success: false,
messages: ['Something went wrong'],
}
}
}
export function TransferModalWindow({
isOpen,
onClose,
...otherProps
}: ModalWindowProps) {
export function TransferModalWindow(props: ModalWindowProps) {
const [amount, setAmount] = useState(0)
const [receiver, setRecipient] = useState('')
const [formActionResponse, formAction, isLoading] = useActionState(
handleTransfer,
null,
)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const { data: wallet } = useAccount()
const { data: signingClient } = useCosmWasmSigningClient()
const { executeContract } = useExecuteContract({
contractAddress: process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS!,
onSuccess: (data) => {
console.log(data)
setLoading(false)
showSuccess('Transfer transaction sent successfully')
},
onError: (error: any) => {
setLoading(false)
showError(error.message)
},
})
// Transfer an amount between wallets calling the Transfer contract with an encrypted request
function handleTransfer() {
setError('')
if (!isValidAddress(receiver)) {
setError('Invalid recipient address format.')
return
}
if (amount <= 0) {
setError('Amount should be greater than zero.')
return
}
setLoading(true)
const encryptedMsg = encryptMsg({
sender: wallet?.bech32Address!,
receiver: String(receiver),
amount: String(amount),
})
executeContract({
signingClient: signingClient!,
msg: contractMessageBuilders.transfer(encryptedMsg),
funds: [
{
denom: chain.currencies[0].coinMinimalDenom,
amount: String(amount),
},
],
})
}
return (
<ModalWindow
isOpen={isOpen}
onClose={onClose}
{...otherProps}
disableClosing={loading}
{...props}
>
<LoadingSpinner isLoading={isLoading} />
<LoadingSpinner isLoading={loading} />
<ModalWindow.Title>Transfer</ModalWindow.Title>
<ModalWindow.Title className="bg-violet-500">
<Icon name="arrows-left-right" /> Transfer
</ModalWindow.Title>
<form action={formAction}>
<ModalWindow.Body className="space-y-3">
<Notifications formActionResponse={formActionResponse} />
{!isEmpty(error) && (
<div className="font-bold text-red-500">{error}</div>
)}
<StyledBox
as="input"
className={tw`
focus:!border-violet-500
focus:!outline-violet-500
focus:!ring-violet-500
`}
placeholder="recipient address"
type="text"
variant="input"
@ -111,6 +126,11 @@ export function TransferModalWindow({
<StyledBox
as="input"
className={tw`
focus:!border-violet-500
focus:!outline-violet-500
focus:!ring-violet-500
`}
min={0}
name="amount"
placeholder="0.00"
@ -126,19 +146,20 @@ export function TransferModalWindow({
<ModalWindow.Buttons>
<StyledText
as="button"
className="bg-violet-500"
disabled={amount === 0}
variant="button.primary"
onClick={handleTransfer}
>
Transfer
</StyledText>
<StyledText
variant="button.secondary"
onClick={onClose}
onClick={props.onClose}
>
Cancel
</StyledText>
</ModalWindow.Buttons>
</form>
</ModalWindow>
)
}

View file

@ -1,78 +1,70 @@
'use client'
import { useActionState } from 'react'
import { useState } from 'react'
import { useCosmWasmSigningClient, useExecuteContract } from 'graz'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { ModalWindow, ModalWindowProps } from '@/components/ModalWindow'
import { Notifications } from '@/components/Notifications'
import { StyledText } from '@/components/StyledText'
import { cosm } from '@/lib/cosm'
import { FormActionResponse } from '@/lib/types'
import { contractMessageBuilders } from '@/lib/contractMessageBuilders'
import { showError, showSuccess } from '@/lib/notifications'
import { Icon } from './Icon'
// Withdraw all funds from the wallet balance calling the Transfer contract
async function handleWithdraw(): Promise<FormActionResponse> {
try {
const result = await cosm.executeTransferContract({
messageBuilder: contractMessageBuilders.withdraw,
export function WithdrawModalWindow(props: ModalWindowProps) {
const [loading, setLoading] = useState(false)
const { data: signingClient } = useCosmWasmSigningClient()
const { executeContract } = useExecuteContract({
contractAddress: process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS!,
onSuccess: (data) => {
console.log(data)
setLoading(false)
showSuccess('Withdraw transaction sent successfully')
},
onError: (error: any) => {
setLoading(false)
showError(error.message)
},
})
console.log(result)
return {
success: true,
messages: ['woo!'],
// Withdraw all funds from the wallet balance calling the Transfer contract
function handleWithdraw() {
setLoading(true)
executeContract({
signingClient: signingClient!,
msg: contractMessageBuilders.withdraw(),
})
}
} catch (error) {
console.error(error)
return {
success: false,
messages: ['Something went wrong'],
}
}
}
export function WithdrawModalWindow({
isOpen,
onClose,
...otherProps
}: ModalWindowProps) {
const [formActionResponse, formAction, isLoading] = useActionState(
handleWithdraw,
null,
)
return (
<ModalWindow
isOpen={isOpen}
onClose={onClose}
{...otherProps}
disableClosing={loading}
{...props}
>
<LoadingSpinner isLoading={isLoading} />
<LoadingSpinner isLoading={loading} />
<ModalWindow.Title className="bg-amber-500">Withdraw</ModalWindow.Title>
<ModalWindow.Title className="bg-amber-500">
<Icon name="money-bills-simple" /> Withdraw
</ModalWindow.Title>
<form action={formAction}>
<ModalWindow.Body className="space-y-3">
<Notifications formActionResponse={formActionResponse} />
<p>This will return the entire remaining balance to your wallet.</p>
<p>Withdraw the entire balance back to your wallet.</p>
</ModalWindow.Body>
<ModalWindow.Buttons>
<StyledText
as="button"
className="bg-amber-500"
variant="button.primary"
onClick={handleWithdraw}
>
Withdraw
</StyledText>
<StyledText
variant="button.secondary"
onClick={onClose}
onClick={props.onClose}
>
Cancel
</StyledText>
</ModalWindow.Buttons>
</form>
</ModalWindow>
)
}

View file

@ -1,10 +0,0 @@
export { App } from './App'
export { DepositModalWindow } from './DepositModalWindow'
export { Icon } from './Icon'
export { LoadingSpinner } from './LoadingSpinner'
export { ModalWindow } from './ModalWindow'
export { Notifications } from './Notifications'
export { StyledBox } from './StyledBox'
export { StyledText } from './StyledText'
export { TransferModalWindow } from './TransferModalWindow'
export { WithdrawModalWindow } from './WithdrawModalWindow'

View file

@ -0,0 +1,19 @@
import { ChainInfo } from '@keplr-wallet/types'
import { localWasm } from './chains/localWasm'
import { localNeutron } from './chains/localNeutron'
const supportedChains: Record<string, ChainInfo> = {
doWasm: {
...localWasm,
chainName: 'Digital Ocean Testchain',
rpc: 'http://143.244.186.205:26657',
rest: 'http://143.244.186.205:1317',
},
localNeutron,
localWasm,
}
const chain = supportedChains[process.env.NEXT_PUBLIC_TARGET_CHAIN!]
export default chain

View file

@ -1,25 +1,11 @@
import { ChainInfo } from '@keplr-wallet/types'
import invariant from 'tiny-invariant'
invariant(
process.env.NEXT_PUBLIC_CHAIN_ID,
'NEXT_PUBLIC_CHAIN_ID must be defined',
)
invariant(
process.env.NEXT_PUBLIC_CHAIN_RPC_URL,
'NEXT_PUBLIC_CHAIN_RPC_URL must be defined',
)
invariant(
process.env.NEXT_PUBLIC_CHAIN_REST_URL,
'NEXT_PUBLIC_CHAIN_REST_URL must be defined',
)
// Testchain definition
export const chain: ChainInfo = {
rpc: process.env.NEXT_PUBLIC_CHAIN_RPC_URL,
rest: process.env.NEXT_PUBLIC_CHAIN_REST_URL,
chainId: process.env.NEXT_PUBLIC_CHAIN_ID,
chainName: 'Neutron Local Chain',
// Neutron local chain definition
export const localNeutron: ChainInfo = {
chainId: 'testing',
chainName: 'Local Neutron Testchain',
rpc: 'http://localhost:26657',
rest: 'http://localhost:1317',
stakeCurrency: {
coinDenom: 'NEUTRON',
coinMinimalDenom: 'untrn',

View file

@ -0,0 +1,42 @@
import { ChainInfo } from '@keplr-wallet/types'
// Wasm local chain definition
export const localWasm: ChainInfo = {
chainId: 'testing',
chainName: 'Local Wasm Testchain',
rpc: 'http://localhost:26657',
rest: 'http://localhost:1317',
bech32Config: {
bech32PrefixAccAddr: 'wasm',
bech32PrefixAccPub: 'wasmpub',
bech32PrefixValAddr: 'wasmvaloper',
bech32PrefixValPub: 'wasmvaloperpub',
bech32PrefixConsAddr: 'wasmvalcons',
bech32PrefixConsPub: 'wasmvalconspub',
},
currencies: [
{
coinDenom: 'COSM',
coinMinimalDenom: 'ucosm',
coinDecimals: 6,
},
{
coinDenom: 'ATOM',
coinMinimalDenom: 'uatom',
coinDecimals: 6,
},
],
feeCurrencies: [
{
coinDenom: 'COSM',
coinMinimalDenom: 'ucosm',
coinDecimals: 6,
},
],
stakeCurrency: {
coinDenom: 'ATOM',
coinMinimalDenom: 'uatom',
coinDecimals: 6,
},
bip44: { coinType: 118 },
}

View file

@ -0,0 +1,17 @@
import invariant from 'tiny-invariant'
export async function register() {
// Ensure all required env vars are set before starting the server
invariant(
process.env.NEXT_PUBLIC_ENCLAVE_PUBLIC_KEY,
'NEXT_PUBLIC_ENCLAVE_PUBLIC_KEY environment variable must be defined',
)
invariant(
process.env.NEXT_PUBLIC_TARGET_CHAIN,
'NEXT_PUBLIC_TARGET_CHAIN environment variable must be defined',
)
invariant(
process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS,
'NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS environment variable must be defined',
)
}

View file

@ -1,96 +0,0 @@
import { toUtf8 } from '@cosmjs/encoding'
import { Registry } from '@cosmjs/proto-signing'
import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate'
import { coins } from '@cosmjs/stargate'
import { MsgExecuteContract } from 'cosmjs-types/cosmwasm/wasm/v1/tx'
import invariant from 'tiny-invariant'
import { wallet } from './wallet'
const typeUrl = '/cosmwasm.wasm.v1.MsgExecuteContract'
const registry = new Registry([[typeUrl, MsgExecuteContract]])
// Cosm variables declaration. They will be set upon initialization.
let signingCosmClient: SigningCosmWasmClient;
// Setup the CosmWasm client.
const init = async () => {
invariant(
process.env.NEXT_PUBLIC_CHAIN_RPC_URL,
'NEXT_PUBLIC_CHAIN_RPC_URL must be defined',
)
// Initialize Cosm client.
signingCosmClient = await SigningCosmWasmClient.connectWithSigner(
process.env.NEXT_PUBLIC_CHAIN_RPC_URL,
wallet.getSigner(),
{ registry },
)
}
// Transfer contract execution message
const executeTransferContract = ({
messageBuilder,
fundsAmount,
}: {
messageBuilder: Function
fundsAmount?: string
}) => {
invariant(
process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS,
'NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS must be defined',
)
const sender = wallet.getAccount().address
// Prepare execution message to send
const executeTransferContractMsgs = [
{
typeUrl,
value: MsgExecuteContract.fromPartial({
sender,
contract: process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS,
msg: toUtf8(JSON.stringify(messageBuilder())),
...(fundsAmount && {
funds: [{ denom: 'untrn', amount: fundsAmount }],
}),
}),
},
]
// Send message
return signingCosmClient.signAndBroadcast(
sender,
executeTransferContractMsgs,
{
amount: coins(1, 'untrn'),
gas: '400000',
},
)
}
// Transfer contract query message
const queryTransferContract = ({
messageBuilder,
}: {
messageBuilder: Function
}) => {
invariant(
process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS,
'NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS must be defined',
)
// Send message
return signingCosmClient.queryContractSmart(
process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS,
messageBuilder(),
)
}
// Define the Cosm wrapper interface to be used
export const cosm = {
executeTransferContract,
init,
queryTransferContract,
}

View file

@ -0,0 +1,52 @@
import {
Bip39,
EnglishMnemonic,
Random,
Secp256k1,
Secp256k1Keypair,
} from '@cosmjs/crypto'
import { decrypt as _decrypt } from 'eciesjs'
import { isEmpty } from 'lodash'
// Generate a random Mnemonic
export function generateMnemonic() {
return Bip39.encode(Random.getBytes(32)).toString()
}
// Retrieve the Mnemonic from storage
export function getMnemonic() {
return localStorage.getItem('ephemeral-mnemonic')!
}
// Save Mnemonic into storage
export function saveMnemonic(mnemonic: string) {
localStorage.setItem('ephemeral-mnemonic', mnemonic)
}
// Clear stored mnemonic
export function clearMnemonic() {
localStorage.removeItem('ephemeral-mnemonic')
}
// Generate an ephemeral key pair to encryp/decrypt messages for the user from stored mnemonic
export async function getEphemeralKeypair(): Promise<Secp256k1Keypair> {
let privkeyFromMnemonic = Bip39.decode(new EnglishMnemonic(getMnemonic()))
// If mnemonic is not formed by 24 words, lets expand it
if (privkeyFromMnemonic.length < 32) {
const newPrivKey = new Uint8Array(32)
newPrivKey.set(privkeyFromMnemonic)
privkeyFromMnemonic = newPrivKey
}
return Secp256k1.makeKeypair(privkeyFromMnemonic)
}
// Decrypt data using the ephemeral private key
export async function decrypt(data?: string): Promise<string> {
if (isEmpty(data)) {
return ''
}
return _decrypt(
(await getEphemeralKeypair()).privkey,
Buffer.from(data!, 'hex'),
).toString()
}

View file

@ -1,23 +0,0 @@
import {
Bip39,
EnglishMnemonic,
Random,
Secp256k1,
Secp256k1Keypair,
} from '@cosmjs/crypto'
const storageKey = 'ephemeral-mnemonic'
// Retrieve from localstorage a mnemonic or create it if none exists to be used
// as input to generate an ephemeral key pair to encryp/decrypt messages for the user
export async function getEphemeralKeypair(): Promise<Secp256k1Keypair> {
const storedMnemonic = localStorage.getItem(storageKey)
const mnemonic =
storedMnemonic ?? Bip39.encode(Random.getBytes(32)).toString()
if (!storedMnemonic) {
localStorage.setItem(storageKey, mnemonic)
}
return Secp256k1.makeKeypair(Bip39.decode(new EnglishMnemonic(mnemonic)))
}

View file

@ -0,0 +1,11 @@
import { fromBech32 } from '@cosmjs/encoding'
export const isValidAddress = (address: string): boolean => {
try {
fromBech32(address)
return true
} catch {
return false
}
}

View file

@ -0,0 +1,40 @@
import { closeSnackbar, enqueueSnackbar, VariantType } from 'notistack'
import { Icon } from '@/components/Icon'
import { StyledText } from '@/components/StyledText'
type Notification = {
message: string
dismissible?: boolean
variant?: VariantType
}
export const showNotification = ({
message,
dismissible = false,
variant = 'info',
}: Notification) =>
enqueueSnackbar(message, {
variant,
...(dismissible && {
persist: true,
action: (snackbarId) => (
<StyledText
as="button"
variant="button.tool"
onClick={() => closeSnackbar(snackbarId)}
>
<Icon
className="text-white"
name="xmark"
/>
</StyledText>
),
}),
})
export const showSuccess = (message: string) =>
showNotification({ message, variant: 'success' })
export const showError = (message: string) =>
showNotification({ message, dismissible: true, variant: 'error' })

View file

@ -1,10 +0,0 @@
export type FormActionResponse =
| null
| {
success: true
messages?: string[]
}
| {
success: false
messages: string[]
}

View file

@ -1,68 +0,0 @@
import { AccountData, Keplr, Window } from '@keplr-wallet/types'
import { OfflineSigner } from '@cosmjs/proto-signing'
import { Secp256k1Keypair } from '@cosmjs/crypto'
import { decrypt as _decrypt } from 'eciesjs'
import { isEmpty } from 'lodash'
import invariant from 'tiny-invariant'
import { chain } from './chainConfig'
import { getEphemeralKeypair } from './getEphemeralKeypair'
// Wallet variables declaration. They will be set upon initialization
let keplr: Keplr | undefined
let signer: OfflineSigner
let account: AccountData
let keypair: Secp256k1Keypair
// Setup the Keprl Wallet wrapper
const init = async () => {
keplr = (window as Window).keplr
invariant(
process.env.NEXT_PUBLIC_CHAIN_ID,
'NEXT_PUBLIC_CHAIN_ID must be defined',
)
invariant(keplr, 'No Keplr wallet found')
// Generate user ephemeral key pair for decryption
keypair = await getEphemeralKeypair()
// Set chain info in Keplr
await keplr.experimentalSuggestChain(chain)
// Init chain in Keplr
await keplr.enable(process.env.NEXT_PUBLIC_CHAIN_ID)
await refreshUser()
}
// Decrypt data using the ephemeral private key
const decrypt = (data: string): string => {
let result = ''
if (!isEmpty(data)) {
result = _decrypt(
wallet.getKeypair().privkey,
Buffer.from(data, 'hex'),
).toString()
}
return result
}
// Get the current Keprl Wallet account
const getAccount = (): AccountData => account
// Get the autogenerated ephemeral keypair
const getKeypair = (): Secp256k1Keypair => keypair
// Get the current Keprl Wallet signer
const getSigner = (): OfflineSigner => signer
// Update new Keprl Wallet user info
const refreshUser = async (): Promise<void> => {
signer = keplr!.getOfflineSigner(process.env.NEXT_PUBLIC_CHAIN_ID!)
account = (await signer.getAccounts())[0]
}
// Define the Keprl Wallet wrapper interface to be used
export const wallet = {
decrypt,
init,
getAccount,
getKeypair,
getSigner,
refreshUser,
}

View file

@ -1,20 +1,15 @@
import { WebsocketClient } from '@cosmjs/tendermint-rpc'
import invariant from 'tiny-invariant'
import { Listener } from 'xstream'
import chain from '@/config/chain'
// Connect and listen to blockchain events
export const wasmEventHandler = (
query: string,
listener: Partial<Listener<any>>,
): (() => void) => {
invariant(
process.env.NEXT_PUBLIC_CHAIN_RPC_URL,
'NEXT_PUBLIC_CHAIN_RPC_URL must be defined',
)
// Create websocket connection to terdermint
const websocketClient = new WebsocketClient(
process.env.NEXT_PUBLIC_CHAIN_RPC_URL.replace('http', 'ws'),
)
const websocketClient = new WebsocketClient(chain.rpc.replace('http', 'ws'))
// Listen to target query
websocketClient

View file

@ -0,0 +1,11 @@
import { create } from 'zustand'
interface GlobalState {
loading: boolean
setLoading: (loading: boolean) => void
}
export const useGlobalState = create<GlobalState>((set) => ({
loading: true,
setLoading: (loading: boolean) => set({ loading }),
}))

View file

@ -4,7 +4,7 @@ import type { Config } from 'tailwindcss'
import colors from 'tailwindcss/colors'
import plugin from 'tailwindcss/plugin'
const accentColorBucket = colors.violet
const accentColorBucket = colors.blue
const neutralColorBucket = colors.stone
const borderColor = neutralColorBucket[200]
@ -21,11 +21,6 @@ const config: Config = {
darkMode: 'class',
theme: {
extend: {
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic':
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
},
borderColor: {
DEFAULT: borderColor,
},

View file

@ -22,6 +22,12 @@
},
"target": "ES2017"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"env.d.ts"
],
"exclude": ["node_modules"]
}