Add transfer frontend (#93)
This commit is contained in:
parent
065747ec2a
commit
8feb20b7b8
51 changed files with 11921 additions and 0 deletions
5
apps/transfers/frontend/.env.example
Normal file
5
apps/transfers/frontend/.env.example
Normal file
|
@ -0,0 +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_TRANSFERS_CONTRACT_ADDRESS=wasm1jfgr0vgunezkhfmdy7krrupu6yjhx224nxtjptll2ylkkqhyzeshrspu9
|
7
apps/transfers/frontend/.eslintrc.json
Normal file
7
apps/transfers/frontend/.eslintrc.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"plugins": ["react-compiler"],
|
||||
"rules": {
|
||||
"react-compiler/react-compiler": "error"
|
||||
}
|
||||
}
|
36
apps/transfers/frontend/.gitignore
vendored
Normal file
36
apps/transfers/frontend/.gitignore
vendored
Normal file
|
@ -0,0 +1,36 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
11
apps/transfers/frontend/.prettierrc
Normal file
11
apps/transfers/frontend/.prettierrc
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"quoteProps": "consistent",
|
||||
"singleQuote": true,
|
||||
"semi": false,
|
||||
"singleAttributePerLine": true,
|
||||
"tailwindFunctions": ["tw", "twJoin", "twMerge"],
|
||||
"tailwindPreserveWhitespace": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all"
|
||||
}
|
43
apps/transfers/frontend/README.md
Normal file
43
apps/transfers/frontend/README.md
Normal file
|
@ -0,0 +1,43 @@
|
|||
# Transfer App
|
||||
|
||||
This is an example frontend that illustrates how to interact with a Transfer Quartz App.
|
||||
|
||||
This example offers:
|
||||
|
||||
- Deposit amounts into a balance
|
||||
- Withdraw the whole deposit
|
||||
- Transfer amounts between wallet addresses in a private-preserving way
|
||||
- Query your encrypted balance to capture changes
|
||||
- Switch between Keplr wallets
|
||||
|
||||
## Requirements
|
||||
|
||||
In order to get started, you will need:
|
||||
|
||||
- [Node.js](https://nodejs.org/) LTS (v20.x)
|
||||
- `npm`
|
||||
- A [Keplr](https://www.keplr.app/) Wallet
|
||||
|
||||
## Development
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
```
|
||||
|
||||
The App requires some environment variables to fully work. Be sure to set up those accordingly to your local environment.
|
||||
|
||||
You should start from the template:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
Run the app:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
And now everything is up & running 🎉
|
8
apps/transfers/frontend/next.config.mjs
Normal file
8
apps/transfers/frontend/next.config.mjs
Normal file
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
reactCompiler: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
5709
apps/transfers/frontend/package-lock.json
generated
Normal file
5709
apps/transfers/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
47
apps/transfers/frontend/package.json
Normal file
47
apps/transfers/frontend/package.json
Normal file
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "transfer-fe",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"generate": "node ./scripts/generate.js && node ./scripts/rebuild-component-index.js",
|
||||
"rebuild-component-index": "node ./scripts/rebuild-component-index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cosmjs/cosmwasm-stargate": "^0.32.4",
|
||||
"@cosmwasm/ts-codegen": "^1.11.1",
|
||||
"eciesjs": "^0.4.7",
|
||||
"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",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@keplr-wallet/types": "0.12.103",
|
||||
"@netlify/plugin-nextjs": "^5.3.3",
|
||||
"@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",
|
||||
"babel-plugin-react-compiler": "0.0.0-experimental-696af53-20240625",
|
||||
"eslint": "^8",
|
||||
"eslint-plugin-react-compiler": "^0.0.0",
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "npm:types-react@rc",
|
||||
"@types/react-dom": "npm:types-react-dom@rc"
|
||||
}
|
||||
}
|
8
apps/transfers/frontend/postcss.config.mjs
Normal file
8
apps/transfers/frontend/postcss.config.mjs
Normal file
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
BIN
apps/transfers/frontend/public/favicon.png
Normal file
BIN
apps/transfers/frontend/public/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.9 KiB |
BIN
apps/transfers/frontend/public/images/moroccan-flower.png
Normal file
BIN
apps/transfers/frontend/public/images/moroccan-flower.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
105
apps/transfers/frontend/scripts/generate.js
Normal file
105
apps/transfers/frontend/scripts/generate.js
Normal file
|
@ -0,0 +1,105 @@
|
|||
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()
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
43
apps/transfers/frontend/scripts/rebuild-component-index.js
Normal file
43
apps/transfers/frontend/scripts/rebuild-component-index.js
Normal file
|
@ -0,0 +1,43 @@
|
|||
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}`)
|
24
apps/transfers/frontend/scripts/templates/NewComponent.tsx
Normal file
24
apps/transfers/frontend/scripts/templates/NewComponent.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
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>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
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>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import { tw } from '@/lib/tw'
|
||||
|
||||
export const classNames = {
|
||||
container: tw`
|
||||
// styles here
|
||||
`,
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { NewComponent } from './NewComponent'
|
3
apps/transfers/frontend/src/app/globals.css
Normal file
3
apps/transfers/frontend/src/app/globals.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
66
apps/transfers/frontend/src/app/layout.tsx
Normal file
66
apps/transfers/frontend/src/app/layout.tsx
Normal file
|
@ -0,0 +1,66 @@
|
|||
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'
|
||||
|
||||
const bodyFont = Raleway({ subsets: ['latin'], variable: '--font-raleway' })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Cycles: Respect the Graph',
|
||||
description: 'The Open Clearing Protocol.',
|
||||
icons: [
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/png',
|
||||
sizes: '32x32',
|
||||
url: '/favicon.png',
|
||||
},
|
||||
],
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
url: 'https://example.com',
|
||||
title: 'Cycles: Respect the Graph',
|
||||
description: 'The Open Clearing Protocol.',
|
||||
siteName: 'Cycles',
|
||||
images: [
|
||||
{
|
||||
url: 'http://cycles.money/share-sheet-image.jpg',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
site: '@site',
|
||||
creator: '@creator',
|
||||
images: 'http://cycles.money/share-sheet-image.jpg',
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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>
|
||||
</html>
|
||||
)
|
||||
}
|
229
apps/transfers/frontend/src/app/page.tsx
Normal file
229
apps/transfers/frontend/src/app/page.tsx
Normal file
|
@ -0,0 +1,229 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
import {
|
||||
DepositModalWindow,
|
||||
Icon,
|
||||
Notifications,
|
||||
StyledText,
|
||||
TransferModalWindow,
|
||||
WithdrawModalWindow,
|
||||
} from '@/components'
|
||||
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'
|
||||
|
||||
function formatAmount(value: number) {
|
||||
return value.toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: chain.stakeCurrency?.coinDecimals,
|
||||
})
|
||||
}
|
||||
|
||||
// Safe method to get the balance amount from the decrypted data
|
||||
const retrieveBalance = (data: string) => {
|
||||
let balance = 0
|
||||
|
||||
if (!isEmpty(data)) {
|
||||
const json = JSON.parse(data)
|
||||
|
||||
balance = Number(json.balance ?? 0)
|
||||
}
|
||||
|
||||
return balance
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [requestBalanceResult, setRequestBalanceResult] =
|
||||
useState<FormActionResponse>()
|
||||
const [balance, setBalance] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [walletAddress, setWalletAddress] = useState(
|
||||
wallet.getAccount().address,
|
||||
)
|
||||
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)
|
||||
})
|
||||
},
|
||||
]
|
||||
|
||||
window.addEventListener(...params)
|
||||
|
||||
return () => window.removeEventListener(...params)
|
||||
}, [])
|
||||
|
||||
// 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])
|
||||
|
||||
// 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) => {
|
||||
console.log(event)
|
||||
if (!isEmpty(event?.events['wasm-store_balance.encrypted_balance'])) {
|
||||
setBalance(
|
||||
retrieveBalance(
|
||||
wallet.decrypt(
|
||||
event.events['wasm-store_balance.encrypted_balance'][0],
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
}, [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),
|
||||
})
|
||||
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 (
|
||||
<main
|
||||
className={tw`
|
||||
flex
|
||||
h-screen
|
||||
flex-col
|
||||
items-center
|
||||
justify-center
|
||||
bg-[url(/images/moroccan-flower.png)]
|
||||
p-12
|
||||
`}
|
||||
>
|
||||
<Notifications formActionResponse={requestBalanceResult} />
|
||||
<div
|
||||
className={tw`
|
||||
flex
|
||||
flex-col
|
||||
gap-2
|
||||
divide-y
|
||||
rounded-md
|
||||
border
|
||||
border-black/20
|
||||
bg-white
|
||||
p-5
|
||||
py-3
|
||||
shadow-2xl
|
||||
`}
|
||||
>
|
||||
<div className="flex w-full justify-between">
|
||||
<span className="font-bold">Balance:</span>
|
||||
<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>
|
||||
)}
|
||||
Get Balance
|
||||
</StyledText>
|
||||
|
||||
<div className="my-1 w-full border-black/20"></div>
|
||||
|
||||
<StyledText
|
||||
className="w-full bg-emerald-500 font-bold"
|
||||
variant="button.primary"
|
||||
onClick={() => setIsDepositModalOpen(true)}
|
||||
>
|
||||
<Icon name="piggy-bank" />
|
||||
Deposit
|
||||
</StyledText>
|
||||
<StyledText
|
||||
variant="button.primary"
|
||||
className="w-full font-bold"
|
||||
onClick={() => setIsTransferModalOpen(true)}
|
||||
>
|
||||
<Icon name="arrows-left-right" />
|
||||
Transfer
|
||||
</StyledText>
|
||||
<StyledText
|
||||
className="w-full bg-amber-500 font-bold"
|
||||
variant="button.primary"
|
||||
onClick={() => setIsWithdrawModalOpen(true)}
|
||||
>
|
||||
<Icon name="money-bills-simple" />
|
||||
Withdraw
|
||||
</StyledText>
|
||||
</div>
|
||||
|
||||
<DepositModalWindow
|
||||
isOpen={isDepositModalOpen}
|
||||
onClose={() => setIsDepositModalOpen(false)}
|
||||
/>
|
||||
<TransferModalWindow
|
||||
isOpen={isTransferModalOpen}
|
||||
onClose={() => setIsTransferModalOpen(false)}
|
||||
/>
|
||||
<WithdrawModalWindow
|
||||
isOpen={isWithdrawModalOpen}
|
||||
onClose={() => setIsWithdrawModalOpen(false)}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
27
apps/transfers/frontend/src/components/App.tsx
Normal file
27
apps/transfers/frontend/src/components/App.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
'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}
|
||||
</>
|
||||
)
|
||||
}
|
107
apps/transfers/frontend/src/components/DepositModalWindow.tsx
Normal file
107
apps/transfers/frontend/src/components/DepositModalWindow.tsx
Normal file
|
@ -0,0 +1,107 @@
|
|||
'use client'
|
||||
|
||||
import { ChangeEvent, useActionState, useState } from 'react'
|
||||
|
||||
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'
|
||||
|
||||
// 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,
|
||||
})
|
||||
|
||||
console.log(result)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messages: ['woo!'],
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
messages: ['Something went wrong'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function DepositModalWindow({
|
||||
isOpen,
|
||||
onClose,
|
||||
...otherProps
|
||||
}: ModalWindowProps) {
|
||||
const [amount, setAmount] = useState(0)
|
||||
const [formActionResponse, formAction, isLoading] = useActionState(
|
||||
handleDeposit,
|
||||
null,
|
||||
)
|
||||
|
||||
return (
|
||||
<ModalWindow
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
{...otherProps}
|
||||
>
|
||||
<LoadingSpinner isLoading={isLoading} />
|
||||
|
||||
<ModalWindow.Title className="bg-emerald-500">Deposit</ModalWindow.Title>
|
||||
|
||||
<form action={formAction}>
|
||||
<ModalWindow.Body className="space-y-3">
|
||||
<Notifications formActionResponse={formActionResponse} />
|
||||
|
||||
<StyledBox
|
||||
as="input"
|
||||
className={tw`
|
||||
focus:!border-emerald-500
|
||||
focus:!outline-emerald-500
|
||||
focus:!ring-emerald-500
|
||||
`}
|
||||
min={0}
|
||||
name="amount"
|
||||
placeholder="0.00"
|
||||
type="number"
|
||||
value={amount || ''}
|
||||
variant="input"
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setAmount(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</ModalWindow.Body>
|
||||
|
||||
<ModalWindow.Buttons>
|
||||
<StyledText
|
||||
as="button"
|
||||
className="bg-emerald-500"
|
||||
disabled={amount === 0}
|
||||
variant="button.primary"
|
||||
>
|
||||
Deposit
|
||||
</StyledText>
|
||||
<StyledText
|
||||
variant="button.secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</StyledText>
|
||||
</ModalWindow.Buttons>
|
||||
</form>
|
||||
</ModalWindow>
|
||||
)
|
||||
}
|
39
apps/transfers/frontend/src/components/Icon/Icon.tsx
Normal file
39
apps/transfers/frontend/src/components/Icon/Icon.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { twMerge } from 'tailwind-merge'
|
||||
import { IconProps } from './types'
|
||||
|
||||
const Icon = ({
|
||||
className,
|
||||
name,
|
||||
rotate,
|
||||
spin = false,
|
||||
variant = 'solid',
|
||||
...otherProps
|
||||
}: IconProps) => (
|
||||
<span
|
||||
className={twMerge(`!no-underline`, className)}
|
||||
{...otherProps}
|
||||
>
|
||||
<i
|
||||
className={twMerge(
|
||||
`
|
||||
fa
|
||||
fa-fw
|
||||
fa-${name}
|
||||
`,
|
||||
typeof rotate === 'string' && `fa-${rotate}`,
|
||||
typeof rotate === 'number' && `fa-rotate-${rotate}`,
|
||||
spin && `fa-spin`,
|
||||
variant.startsWith('sharp-')
|
||||
? `
|
||||
fa-sharp
|
||||
fa-${variant.replace('sharp-', '')}
|
||||
`
|
||||
: `
|
||||
fa-${variant}
|
||||
`,
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
|
||||
export { Icon }
|
9
apps/transfers/frontend/src/components/Icon/index.ts
Normal file
9
apps/transfers/frontend/src/components/Icon/index.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export { Icon } from "./Icon"
|
||||
export type {
|
||||
BrandIconName,
|
||||
IconName,
|
||||
IconProps,
|
||||
IconRotationOption,
|
||||
IconVariant,
|
||||
RegularIconName,
|
||||
} from "./types"
|
3799
apps/transfers/frontend/src/components/Icon/types.ts
Normal file
3799
apps/transfers/frontend/src/components/Icon/types.ts
Normal file
File diff suppressed because it is too large
Load diff
47
apps/transfers/frontend/src/components/LoadingSpinner.tsx
Normal file
47
apps/transfers/frontend/src/components/LoadingSpinner.tsx
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { Icon } from '@/components/Icon'
|
||||
import { ComponentProps } from 'react'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
interface LoadingSpinnerProps extends Omit<ComponentProps<'div'>, 'children'> {
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function LoadingSpinner({
|
||||
className,
|
||||
isLoading,
|
||||
...otherProps
|
||||
}: LoadingSpinnerProps) {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
`
|
||||
pointer-events-none
|
||||
absolute
|
||||
bottom-0
|
||||
left-0
|
||||
right-0
|
||||
top-0
|
||||
z-20
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
bg-appBgColor
|
||||
transition-all
|
||||
`,
|
||||
isLoading
|
||||
? `
|
||||
opacity-100
|
||||
`
|
||||
: `
|
||||
opacity-0
|
||||
`,
|
||||
className,
|
||||
)}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className="animate-spin">
|
||||
<Icon name="spinner" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
'use client'
|
||||
|
||||
import { ComponentProps, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { classNames } from './classNames'
|
||||
|
||||
export interface ModalWindowProps extends ComponentProps<'div'> {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ModalWindow({
|
||||
children,
|
||||
className,
|
||||
isOpen,
|
||||
onClose,
|
||||
...otherProps
|
||||
}: ModalWindowProps) {
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
|
||||
const [modalState, setModalState] = useState<
|
||||
'opening' | 'open' | 'closing' | 'closed'
|
||||
>('closed')
|
||||
|
||||
const windowContentsContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
function handleTransitionEnd() {
|
||||
if (modalState === 'closing') {
|
||||
setModalState('closed')
|
||||
onClose()
|
||||
}
|
||||
if (modalState === 'opening') {
|
||||
setModalState('open')
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setModalState('closing')
|
||||
}
|
||||
|
||||
function focusFirstElement() {
|
||||
const windowContentsContainer = windowContentsContainerRef.current
|
||||
|
||||
if (!windowContentsContainer) {
|
||||
return
|
||||
}
|
||||
|
||||
const firstFocusableElement = windowContentsContainer.querySelector(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
) as HTMLElement
|
||||
|
||||
if (firstFocusableElement) {
|
||||
firstFocusableElement.focus()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setModalState('opening')
|
||||
focusFirstElement()
|
||||
} else {
|
||||
setModalState('closing')
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isClient) {
|
||||
return null
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
className={classNames.backdrop({ modalState })}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={twMerge(classNames.container({ modalState }), className)}
|
||||
ref={windowContentsContainerRef}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
ModalWindow.Title = function ModalWindowTitle({
|
||||
children,
|
||||
className,
|
||||
}: ComponentProps<'header'>) {
|
||||
return <div className={twMerge(classNames.header, className)}>{children}</div>
|
||||
}
|
||||
|
||||
ModalWindow.Body = function ModalWindowBody({
|
||||
children,
|
||||
className,
|
||||
}: ComponentProps<'main'>) {
|
||||
return <div className={twMerge(classNames.body, className)}>{children}</div>
|
||||
}
|
||||
|
||||
ModalWindow.Buttons = function ModalWindowBody({
|
||||
children,
|
||||
className,
|
||||
}: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div className={twMerge(classNames.buttons, className)}>{children}</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
import { tw } from "@/lib/tw";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export const classNames = {
|
||||
backdrop: ({ modalState = 'closed' }) => twMerge(
|
||||
`
|
||||
fixed
|
||||
left-0
|
||||
top-0
|
||||
h-screen
|
||||
w-full
|
||||
transition-all
|
||||
z-10
|
||||
duration-500
|
||||
`,
|
||||
(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(
|
||||
`
|
||||
fixed
|
||||
left-1/2
|
||||
top-1/2
|
||||
bg-appBgColor
|
||||
rounded-lg
|
||||
overflow-hidden
|
||||
-translate-x-1/2
|
||||
z-10
|
||||
duration-500
|
||||
min-w-64
|
||||
shadow-xl
|
||||
`,
|
||||
(modalState === 'opening' || modalState === 'open')
|
||||
?
|
||||
`
|
||||
opacity-100
|
||||
-translate-y-1/2
|
||||
`
|
||||
:
|
||||
`
|
||||
opacity-0
|
||||
pointer-events-none
|
||||
-translate-y-full
|
||||
`
|
||||
),
|
||||
|
||||
header: tw`
|
||||
px-3
|
||||
py-1
|
||||
bg-accentColor
|
||||
text-white
|
||||
font-bold
|
||||
`,
|
||||
|
||||
body: tw`
|
||||
p-3
|
||||
`,
|
||||
|
||||
buttons: tw`
|
||||
flex
|
||||
flex-row-reverse
|
||||
gap-1
|
||||
p-3
|
||||
`
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ModalWindow, type ModalWindowProps } from './ModalWindow'
|
|
@ -0,0 +1,140 @@
|
|||
'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>
|
||||
)
|
||||
)
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
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
|
||||
`,
|
||||
),
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { Notifications } from './Notifications'
|
|
@ -0,0 +1,29 @@
|
|||
import { ComponentProps, ElementType } from 'react'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { classNames } from './classNames'
|
||||
|
||||
type StyledBoxVariant = keyof typeof classNames
|
||||
|
||||
type StyledBoxProps<T extends ElementType = 'div'> = Omit<
|
||||
ComponentProps<T>,
|
||||
'variant'
|
||||
> & {
|
||||
as?: T
|
||||
variant?: StyledBoxVariant
|
||||
}
|
||||
|
||||
export function StyledBox<T extends ElementType = 'div'>({
|
||||
as,
|
||||
className,
|
||||
variant,
|
||||
...otherProps
|
||||
}: StyledBoxProps<T>) {
|
||||
const Component = as || 'div'
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={twMerge(variant && classNames[variant], className)}
|
||||
{...otherProps}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
import { tw } from '@/lib/tw'
|
||||
|
||||
export const classNames = {
|
||||
container: tw`
|
||||
container
|
||||
mx-auto
|
||||
max-sm:px-6
|
||||
md:max-w-screen-sm
|
||||
lg:max-w-screen-md
|
||||
xl:max-w-screen-lg
|
||||
2xl:max-w-screen-xl
|
||||
`,
|
||||
|
||||
checkbox: tw`
|
||||
text-bgColor
|
||||
size-7
|
||||
rounded-md
|
||||
border-2
|
||||
border-borderColor
|
||||
!outline-accentColor
|
||||
!ring-accentColor
|
||||
checked:text-accentColor
|
||||
`,
|
||||
|
||||
input: tw`
|
||||
w-full
|
||||
rounded-md
|
||||
border
|
||||
border-borderColor
|
||||
bg-transparent
|
||||
px-3
|
||||
py-2
|
||||
!outline-accentColor
|
||||
!ring-accentColor
|
||||
focus:border-accentColor
|
||||
[appearance:textfield]
|
||||
`,
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { StyledBox } from './StyledBox'
|
|
@ -0,0 +1,29 @@
|
|||
import { ComponentProps, ElementType } from 'react'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { classNames } from './classNames'
|
||||
|
||||
type StyledTextVariant = keyof typeof classNames
|
||||
|
||||
type StyledTextProps<T extends ElementType = 'span'> = Omit<
|
||||
ComponentProps<T>,
|
||||
'variant'
|
||||
> & {
|
||||
as?: T
|
||||
variant?: StyledTextVariant
|
||||
}
|
||||
|
||||
export function StyledText<T extends ElementType = 'span'>({
|
||||
as,
|
||||
className,
|
||||
variant,
|
||||
...otherProps
|
||||
}: StyledTextProps<T>) {
|
||||
const Component = as || 'span'
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={twMerge(variant && classNames[variant], className)}
|
||||
{...otherProps}
|
||||
/>
|
||||
)
|
||||
}
|
153
apps/transfers/frontend/src/components/StyledText/classNames.tsx
Normal file
153
apps/transfers/frontend/src/components/StyledText/classNames.tsx
Normal file
|
@ -0,0 +1,153 @@
|
|||
import { tw } from '@/lib/tw'
|
||||
import { twJoin, twMerge } from 'tailwind-merge'
|
||||
|
||||
const buttonClassNames = tw`
|
||||
inline-flex
|
||||
w-min
|
||||
cursor-pointer
|
||||
items-center
|
||||
justify-center
|
||||
gap-2
|
||||
transition-all
|
||||
hover:scale-105
|
||||
disabled:pointer-events-none
|
||||
disabled:opacity-40
|
||||
`
|
||||
|
||||
const headingClassNames = tw`
|
||||
font-medium
|
||||
[&_span]:font-display
|
||||
[&_span]:font-medium
|
||||
[&_span]:italic
|
||||
[&_span]:text-accentColor
|
||||
`
|
||||
|
||||
export const classNames = {
|
||||
'button.primary': twMerge(
|
||||
buttonClassNames,
|
||||
`
|
||||
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
|
||||
`,
|
||||
),
|
||||
|
||||
'button.icon': twMerge(
|
||||
buttonClassNames,
|
||||
`
|
||||
size-10
|
||||
rounded-full
|
||||
bg-shadedBgColor
|
||||
hover:bg-accentColor
|
||||
hover:text-appBgColor
|
||||
`,
|
||||
),
|
||||
|
||||
'button.tool': tw`
|
||||
inline-flex
|
||||
items-center
|
||||
justify-center
|
||||
gap-2
|
||||
rounded-sm
|
||||
px-1
|
||||
py-0.5
|
||||
text-sm
|
||||
text-textColor
|
||||
hover:bg-textColor/15
|
||||
`,
|
||||
|
||||
'footnote': tw`
|
||||
text-sm
|
||||
text-fadedTextColor
|
||||
`,
|
||||
|
||||
'h1': twJoin(
|
||||
headingClassNames,
|
||||
`
|
||||
text-5xl
|
||||
`,
|
||||
),
|
||||
|
||||
'h2': twJoin(
|
||||
headingClassNames,
|
||||
`
|
||||
text-4xl
|
||||
`,
|
||||
),
|
||||
|
||||
'h3': twJoin(
|
||||
headingClassNames,
|
||||
`
|
||||
text-3xl
|
||||
`,
|
||||
),
|
||||
|
||||
'h4': twJoin(
|
||||
headingClassNames,
|
||||
`
|
||||
text-lg
|
||||
`,
|
||||
),
|
||||
|
||||
'label': tw`
|
||||
text-sm
|
||||
text-fadedTextColor
|
||||
`,
|
||||
|
||||
'link': twMerge(
|
||||
buttonClassNames,
|
||||
`
|
||||
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
|
||||
`,
|
||||
),
|
||||
|
||||
'logo': tw`
|
||||
inline-flex
|
||||
items-center
|
||||
justify-center
|
||||
gap-[0.35em]
|
||||
font-semibold
|
||||
uppercase
|
||||
tracking-[0.2em]
|
||||
[&_span]:inline-block
|
||||
[&_span]:border-l
|
||||
[&_span]:pl-[0.5em]
|
||||
[&_span]:font-light
|
||||
[&_span]:tracking-[0]
|
||||
`,
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { StyledText } from './StyledText'
|
144
apps/transfers/frontend/src/components/TransferModalWindow.tsx
Normal file
144
apps/transfers/frontend/src/components/TransferModalWindow.tsx
Normal file
|
@ -0,0 +1,144 @@
|
|||
'use client'
|
||||
import { PublicKey, encrypt } from 'eciesjs'
|
||||
import { ChangeEvent, useActionState, useState } from 'react'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
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'
|
||||
|
||||
// Encrypt the transfer data using the enclave public key
|
||||
function encryptMsg(data: {
|
||||
sender: string
|
||||
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
|
||||
const serializedState = JSON.stringify(data)
|
||||
// Encrypt the data
|
||||
const encryptedState = encrypt(
|
||||
pubkey.toHex(),
|
||||
Buffer.from(serializedState, 'utf-8'),
|
||||
)
|
||||
|
||||
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) {
|
||||
const [amount, setAmount] = useState(0)
|
||||
const [receiver, setRecipient] = useState('')
|
||||
const [formActionResponse, formAction, isLoading] = useActionState(
|
||||
handleTransfer,
|
||||
null,
|
||||
)
|
||||
|
||||
return (
|
||||
<ModalWindow
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
{...otherProps}
|
||||
>
|
||||
<LoadingSpinner isLoading={isLoading} />
|
||||
|
||||
<ModalWindow.Title>Transfer</ModalWindow.Title>
|
||||
|
||||
<form action={formAction}>
|
||||
<ModalWindow.Body className="space-y-3">
|
||||
<Notifications formActionResponse={formActionResponse} />
|
||||
|
||||
<StyledBox
|
||||
as="input"
|
||||
placeholder="recipient address"
|
||||
type="text"
|
||||
variant="input"
|
||||
name="receiver"
|
||||
value={receiver}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setRecipient(event.target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<StyledBox
|
||||
as="input"
|
||||
min={0}
|
||||
name="amount"
|
||||
placeholder="0.00"
|
||||
type="number"
|
||||
value={amount || ''}
|
||||
variant="input"
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setAmount(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</ModalWindow.Body>
|
||||
|
||||
<ModalWindow.Buttons>
|
||||
<StyledText
|
||||
as="button"
|
||||
disabled={amount === 0}
|
||||
variant="button.primary"
|
||||
>
|
||||
Transfer
|
||||
</StyledText>
|
||||
<StyledText
|
||||
variant="button.secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</StyledText>
|
||||
</ModalWindow.Buttons>
|
||||
</form>
|
||||
</ModalWindow>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
'use client'
|
||||
import { useActionState } from 'react'
|
||||
|
||||
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'
|
||||
|
||||
// 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,
|
||||
})
|
||||
|
||||
console.log(result)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messages: ['woo!'],
|
||||
}
|
||||
} 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}
|
||||
>
|
||||
<LoadingSpinner isLoading={isLoading} />
|
||||
|
||||
<ModalWindow.Title className="bg-amber-500">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>
|
||||
</ModalWindow.Body>
|
||||
<ModalWindow.Buttons>
|
||||
<StyledText
|
||||
as="button"
|
||||
className="bg-amber-500"
|
||||
variant="button.primary"
|
||||
>
|
||||
Withdraw
|
||||
</StyledText>
|
||||
<StyledText
|
||||
variant="button.secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</StyledText>
|
||||
</ModalWindow.Buttons>
|
||||
</form>
|
||||
</ModalWindow>
|
||||
)
|
||||
}
|
10
apps/transfers/frontend/src/components/index.ts
Normal file
10
apps/transfers/frontend/src/components/index.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
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'
|
57
apps/transfers/frontend/src/lib/chainConfig.ts
Normal file
57
apps/transfers/frontend/src/lib/chainConfig.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
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: 'My Testing Chain',
|
||||
stakeCurrency: {
|
||||
coinDenom: 'COSM',
|
||||
coinMinimalDenom: 'ucosm',
|
||||
coinDecimals: 6,
|
||||
coinGeckoId: 'regen',
|
||||
},
|
||||
bip44: {
|
||||
coinType: 118,
|
||||
},
|
||||
bech32Config: {
|
||||
bech32PrefixAccAddr: 'wasm',
|
||||
bech32PrefixAccPub: 'wasm' + 'pub',
|
||||
bech32PrefixValAddr: 'wasm' + 'valoper',
|
||||
bech32PrefixValPub: 'wasm' + 'valoperpub',
|
||||
bech32PrefixConsAddr: 'wasm' + 'valcons',
|
||||
bech32PrefixConsPub: 'wasm' + 'valconspub',
|
||||
},
|
||||
currencies: [
|
||||
{
|
||||
coinDenom: 'COSM',
|
||||
coinMinimalDenom: 'ucosm',
|
||||
coinDecimals: 6,
|
||||
coinGeckoId: 'regen',
|
||||
},
|
||||
],
|
||||
feeCurrencies: [
|
||||
{
|
||||
coinDenom: 'COSM',
|
||||
coinMinimalDenom: 'ucosm',
|
||||
coinDecimals: 6,
|
||||
coinGeckoId: 'regen',
|
||||
gasPriceStep: { low: 0.01, average: 0.025, high: 0.04 },
|
||||
},
|
||||
],
|
||||
}
|
28
apps/transfers/frontend/src/lib/contractMessageBuilders.ts
Normal file
28
apps/transfers/frontend/src/lib/contractMessageBuilders.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
// Transfer contract operation message formats
|
||||
// This is the whole definition of how the Tranfers contract expects to receive messages
|
||||
export const contractMessageBuilders: {
|
||||
deposit: () => {}
|
||||
getBalance: (address: string) => {
|
||||
get_balance: { address: string }
|
||||
}
|
||||
requestBalance: (pubkey: Uint8Array) => {}
|
||||
transfer: (ciphertext: string) => {
|
||||
transfer_request: {
|
||||
ciphertext: string
|
||||
digest: string
|
||||
}
|
||||
}
|
||||
withdraw: () => {}
|
||||
} = {
|
||||
deposit: () => 'deposit',
|
||||
getBalance: (address: string) => ({
|
||||
get_balance: { address },
|
||||
}),
|
||||
requestBalance: (pubkey: Uint8Array) => ({
|
||||
query_request: { emphemeral_pubkey: Buffer.from(pubkey).toString('hex') },
|
||||
}),
|
||||
transfer: (ciphertext: string) => ({
|
||||
transfer_request: { ciphertext, digest: '' },
|
||||
}),
|
||||
withdraw: () => 'withdraw',
|
||||
}
|
91
apps/transfers/frontend/src/lib/cosm.ts
Normal file
91
apps/transfers/frontend/src/lib/cosm.ts
Normal file
|
@ -0,0 +1,91 @@
|
|||
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: 'ucosm', amount: fundsAmount }],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
// Send message
|
||||
return signingCosmClient.signAndBroadcast(
|
||||
sender,
|
||||
executeTransferContractMsgs,
|
||||
{
|
||||
amount: coins(1, 'ucosm'),
|
||||
gas: '200000',
|
||||
},
|
||||
)
|
||||
}
|
||||
// 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,
|
||||
}
|
23
apps/transfers/frontend/src/lib/getEphemeralKeypair.ts
Normal file
23
apps/transfers/frontend/src/lib/getEphemeralKeypair.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
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)))
|
||||
}
|
3
apps/transfers/frontend/src/lib/tw.ts
Normal file
3
apps/transfers/frontend/src/lib/tw.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export function tw(strings: TemplateStringsArray, ...values: any[]) {
|
||||
return String.raw({ raw: strings }, ...values)
|
||||
}
|
10
apps/transfers/frontend/src/lib/types.ts
Normal file
10
apps/transfers/frontend/src/lib/types.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
export type FormActionResponse =
|
||||
| null
|
||||
| {
|
||||
success: true
|
||||
messages?: string[]
|
||||
}
|
||||
| {
|
||||
success: false
|
||||
messages: string[]
|
||||
}
|
68
apps/transfers/frontend/src/lib/wallet.ts
Normal file
68
apps/transfers/frontend/src/lib/wallet.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
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,
|
||||
}
|
34
apps/transfers/frontend/src/lib/wasmEventHandler.ts
Normal file
34
apps/transfers/frontend/src/lib/wasmEventHandler.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { WebsocketClient } from '@cosmjs/tendermint-rpc'
|
||||
import invariant from 'tiny-invariant'
|
||||
import { Listener } from 'xstream'
|
||||
|
||||
// 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'),
|
||||
)
|
||||
|
||||
// Listen to target query
|
||||
websocketClient
|
||||
.listen({
|
||||
jsonrpc: '2.0',
|
||||
method: 'subscribe',
|
||||
params: { query },
|
||||
// Just use some random UUID, we do not need to know which
|
||||
id: crypto.randomUUID(),
|
||||
})
|
||||
.subscribe(listener)
|
||||
|
||||
// Callback method to call in case of cleaning
|
||||
return () => {
|
||||
websocketClient.disconnect()
|
||||
}
|
||||
}
|
254
apps/transfers/frontend/tailwind.config.ts
Normal file
254
apps/transfers/frontend/tailwind.config.ts
Normal file
|
@ -0,0 +1,254 @@
|
|||
import formsPlugin from '@tailwindcss/forms'
|
||||
import typographyPlugin from '@tailwindcss/typography'
|
||||
import type { Config } from 'tailwindcss'
|
||||
import colors from 'tailwindcss/colors'
|
||||
import plugin from 'tailwindcss/plugin'
|
||||
|
||||
const accentColorBucket = colors.violet
|
||||
const neutralColorBucket = colors.stone
|
||||
const borderColor = neutralColorBucket[200]
|
||||
|
||||
const innerRingStartOpacity = 0.8
|
||||
const innerRingEndOpacity = 0.5
|
||||
const outerRingStartOpacity = 0.5
|
||||
const outerRingEndOpacity = 0.1
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
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,
|
||||
},
|
||||
outlineColor: {
|
||||
DEFAULT: borderColor,
|
||||
},
|
||||
colors: {
|
||||
accentColor: accentColorBucket[500],
|
||||
appBgColor: neutralColorBucket[50],
|
||||
borderColor,
|
||||
neutral: neutralColorBucket,
|
||||
textColor: neutralColorBucket[800],
|
||||
fadedTextColor: neutralColorBucket[500],
|
||||
shadedBgColor: accentColorBucket[100],
|
||||
},
|
||||
fontFamily: {
|
||||
body: ['var(--font-raleway)', 'sans-serif'],
|
||||
display: ['var(--font-bitter)', 'sans-serif'],
|
||||
icon: ["'Font Awesome 6 Pro'"],
|
||||
},
|
||||
animation: {
|
||||
animateInX: 'animateInX 0.5s ease-in-out both',
|
||||
animateInY: 'animateInY 0.5s ease-in-out both',
|
||||
animateOutX: 'animateOutX 0.5s ease-in-out both',
|
||||
animateOutY: 'animateOutY 0.5s ease-in-out both',
|
||||
float: 'float 2.5s ease-in-out infinite alternate',
|
||||
floatUp: 'floatUp 120s linear infinite',
|
||||
forceFieldSmallInner:
|
||||
'forceFieldSmallInner 2s ease-in-out infinite alternate',
|
||||
forceFieldSmallOuter:
|
||||
'forceFieldSmallOuter 2s ease-in-out infinite alternate',
|
||||
forceFieldInner: 'forceFieldInner 2s ease-in-out infinite alternate',
|
||||
forceFieldOuter: 'forceFieldOuter 2s ease-in-out infinite alternate',
|
||||
rotatingSphere: 'rotatingSphere 120s linear infinite',
|
||||
slowDriftLeft: 'driftLeft 60s linear infinite',
|
||||
slowerDriftLeft: 'driftLeft 120s linear infinite',
|
||||
slowestDriftLeft: 'driftLeft 180s linear infinite',
|
||||
slowTwinkle: 'twinkle 0.25s linear infinite alternate',
|
||||
slowerTwinkle: 'twinkle 0.5s linear infinite alternate',
|
||||
wobble: 'wobble 2s ease-in-out infinite alternate',
|
||||
},
|
||||
keyframes: {
|
||||
animateInX: {
|
||||
'0%': {
|
||||
opacity: '0',
|
||||
transform: 'translateX(100%)',
|
||||
},
|
||||
'100%': {
|
||||
opacity: '1',
|
||||
transform: 'translateX(0)',
|
||||
},
|
||||
},
|
||||
animateOutX: {
|
||||
'0%': {
|
||||
maxHeight: '200px',
|
||||
opacity: '1',
|
||||
transform: 'translateX(0%)',
|
||||
},
|
||||
'70%': {
|
||||
maxHeight: '200px',
|
||||
opacity: '0',
|
||||
transform: 'translateX(100%)',
|
||||
},
|
||||
'100%': {
|
||||
maxHeight: '0',
|
||||
opacity: '0',
|
||||
transform: 'translateX(100%)',
|
||||
},
|
||||
},
|
||||
|
||||
animateInY: {
|
||||
'0%': {
|
||||
opacity: '0',
|
||||
transform: 'translateY(100%)',
|
||||
},
|
||||
'100%': {
|
||||
opacity: '1',
|
||||
transform: 'translateY(0)',
|
||||
},
|
||||
},
|
||||
animateOutY: {
|
||||
'0%': {
|
||||
opacity: '1',
|
||||
transform: 'translateY(0%)',
|
||||
},
|
||||
'100%': {
|
||||
opacity: '0',
|
||||
transform: 'translateY(-100%)',
|
||||
},
|
||||
},
|
||||
|
||||
driftLeft: {
|
||||
'0%': {
|
||||
opacity: '0',
|
||||
transform: 'translateX(0)',
|
||||
},
|
||||
'5%, 95%': {
|
||||
opacity: '1',
|
||||
},
|
||||
'100%': {
|
||||
opacity: '0',
|
||||
transform: 'translateX(-120vw)',
|
||||
},
|
||||
},
|
||||
|
||||
float: {
|
||||
'0%': {
|
||||
transform: 'translateY(-3%)',
|
||||
},
|
||||
'100%': {
|
||||
transform: 'translateY(3%)',
|
||||
},
|
||||
},
|
||||
|
||||
floatUp: {
|
||||
'0%': {
|
||||
transform: 'translateX(0) translateY(0)',
|
||||
},
|
||||
'100%': {
|
||||
transform: 'translateX(10vw) translateY(-110vh) rotate(10deg)',
|
||||
},
|
||||
},
|
||||
|
||||
forceFieldSmallInner: {
|
||||
'0%': {
|
||||
opacity: String(innerRingStartOpacity),
|
||||
strokeWidth: '5px',
|
||||
},
|
||||
'100%': {
|
||||
opacity: String(innerRingEndOpacity),
|
||||
strokeWidth: '20px',
|
||||
},
|
||||
},
|
||||
|
||||
forceFieldSmallOuter: {
|
||||
'0%': {
|
||||
opacity: String(outerRingStartOpacity),
|
||||
strokeWidth: '10px',
|
||||
},
|
||||
'100%': {
|
||||
opacity: String(outerRingEndOpacity),
|
||||
strokeWidth: '30px',
|
||||
},
|
||||
},
|
||||
|
||||
forceFieldInner: {
|
||||
'0%': {
|
||||
opacity: String(innerRingStartOpacity),
|
||||
strokeWidth: '10px',
|
||||
},
|
||||
'100%': {
|
||||
opacity: String(innerRingEndOpacity),
|
||||
strokeWidth: '60px',
|
||||
},
|
||||
},
|
||||
|
||||
forceFieldOuter: {
|
||||
'0%': {
|
||||
opacity: String(outerRingStartOpacity),
|
||||
strokeWidth: '20px',
|
||||
},
|
||||
'100%': {
|
||||
opacity: String(outerRingEndOpacity),
|
||||
strokeWidth: '100px',
|
||||
},
|
||||
},
|
||||
|
||||
rotatingSphere: {
|
||||
'0%': {
|
||||
transform: 'rotate(0)',
|
||||
},
|
||||
'100%': {
|
||||
transform: 'rotate(359deg)',
|
||||
},
|
||||
},
|
||||
|
||||
twinkle: {
|
||||
'0%': {
|
||||
opacity: '0.9',
|
||||
},
|
||||
'50%': {
|
||||
opacity: '0.8',
|
||||
},
|
||||
'100%': {
|
||||
opacity: '1',
|
||||
},
|
||||
},
|
||||
|
||||
wobble: {
|
||||
'0%': {
|
||||
transform: 'rotate(-3deg)',
|
||||
},
|
||||
'100%': {
|
||||
transform: 'rotate(3deg)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
plugin(function ({ addBase, theme }) {
|
||||
addBase({
|
||||
'*': {
|
||||
scrollbarColor: `${theme('colors.accentColor')} transparent`,
|
||||
},
|
||||
'*::-webkit-scrollbar': {
|
||||
height: theme('spacing.2'),
|
||||
width: theme('spacing.2'),
|
||||
},
|
||||
'*::-webkit-scrollbar-track': {
|
||||
background: 'transparent',
|
||||
},
|
||||
'*::-webkit-scrollbar-thumb': {
|
||||
background: theme('colors.accentColor'),
|
||||
borderRadius: theme('spacing.8'),
|
||||
},
|
||||
'a, button, input, textarea': {
|
||||
touchAction: 'manipulation',
|
||||
},
|
||||
})
|
||||
}),
|
||||
formsPlugin,
|
||||
typographyPlugin,
|
||||
],
|
||||
}
|
||||
export default config
|
27
apps/transfers/frontend/tsconfig.json
Normal file
27
apps/transfers/frontend/tsconfig.json
Normal file
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
Loading…
Reference in a new issue