feat: init layout

This commit is contained in:
bartosz-lipinski 2020-11-16 22:59:53 -06:00
parent 6388877c95
commit a56a5d432a
49 changed files with 31511 additions and 1 deletions

1
.env.production Normal file
View File

@ -0,0 +1 @@
GENERATE_SOURCEMAP = false

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.idea

View File

@ -1 +1,3 @@
# oyster-lending
## ⚠️ Warning
Any content produced by Solana, or developer resources that Solana provides, are for educational and inspiration purposes only. Solana does not encourage, induce or sanction the deployment of any such applications in violation of applicable laws or regulations.

17
craco.config.js Normal file
View File

@ -0,0 +1,17 @@
const CracoLessPlugin = require("craco-less");
module.exports = {
plugins: [
{
plugin: CracoLessPlugin,
options: {
lessLoaderOptions: {
lessOptions: {
modifyVars: { "@primary-color": "#2abdd2" },
javascriptEnabled: true,
},
},
},
},
],
};

16150
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

76
package.json Normal file
View File

@ -0,0 +1,76 @@
{
"name": "token-lending-ui",
"version": "0.1.0",
"private": true,
"dependencies": {
"@ant-design/pro-layout": "^6.5.14",
"@craco/craco": "^5.7.0",
"@project-serum/serum": "^0.13.11",
"@project-serum/sol-wallet-adapter": "^0.1.1",
"@solana/spl-token": "0.0.11",
"@solana/spl-token-swap": "0.0.2",
"@solana/web3.js": "^0.86.2",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.5.0",
"@testing-library/user-event": "^7.2.1",
"@types/echarts": "^4.9.0",
"@types/react-router-dom": "^5.1.6",
"antd": "^4.6.6",
"bn.js": "^5.1.3",
"bs58": "^4.0.1",
"buffer-layout": "^1.2.0",
"craco-less": "^1.17.0",
"echarts": "^4.9.0",
"eventemitter3": "^4.0.7",
"identicon.js": "^2.3.3",
"jazzicon": "^1.5.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-github-btn": "^1.2.0",
"react-router-dom": "^5.2.0",
"react-scripts": "3.4.3",
"typescript": "^4.0.0"
},
"scripts": {
"start": "craco start",
"build": "craco build",
"test": "craco test",
"eject": "react-scripts eject",
"localnet:update": "solana-localnet update",
"localnet:up": "rm client/util/store/config.json; set -x; solana-localnet down; set -e; solana-localnet up",
"localnet:down": "solana-localnet down",
"localnet:logs": "solana-localnet logs -f",
"predeploy": "git pull --ff-only && yarn && yarn build",
"deploy": "gh-pages -d build",
"deploy:ar": "arweave deploy-dir build --key-file ",
"format:fix": "prettier --write \"**/*.+(js|jsx|ts|tsx|json|css|md)\""
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"homepage": ".",
"devDependencies": {
"@types/bn.js": "^4.11.6",
"@types/bs58": "^4.0.1",
"@types/identicon.js": "^2.3.0",
"@types/jest": "^24.9.1",
"@types/node": "^12.12.62",
"@types/react": "^16.9.50",
"@types/react-dom": "^16.9.8",
"arweave-deploy": "^1.9.1",
"gh-pages": "^3.1.0",
"prettier": "^2.1.2"
}
}

1
public/CNAME Normal file
View File

@ -0,0 +1 @@
swap.projectserum.com

78
public/index.html Normal file
View File

@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/logo.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Serum token swap on Solana" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Swap | Serum</title>
<style type="text/css">
#root {
height: 100%;
}
#root::before {
content: "";
position: absolute;
top: 0;
left: 0;
min-width: 100%;
min-height: 100%;
filter: grayscale(100%);
background-repeat: no-repeat;
background-size: cover;
}
.App {
position: relative;
height: 100%;
text-align: center;
min-width: 100%;
display: flex;
flex-direction: column;
}
</style>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.1/css/all.min.css"
integrity="sha512-+4zCK9k+qNFUR5X+cKL9EIR+ZOhtIloNl9GIKS57V1MyNsYpYcUrUeQc9vNfzsWfV28IaLL3i96P9sdNyeRssA=="
crossorigin="anonymous"
/>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<script
async
src="https://platform.twitter.com/widgets.js"
charset="utf-8"
></script>
</body>
</html>

BIN
public/logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

25
public/manifest.json Normal file
View File

@ -0,0 +1,25 @@
{
"short_name": "Swap | Serum",
"name": "Swap | Serum",
"icons": [
{
"src": "icon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

202
src/App.less Normal file
View File

@ -0,0 +1,202 @@
@import "~antd/dist/antd.dark.less";
@import "./ant-custom.less";
body {
--row-highlight: @background-color-base;
}
.App-logo {
background-image: url("data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9JzMwMHB4JyB3aWR0aD0nMzAwcHgnICBmaWxsPSIjZDgzYWViIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2ZXJzaW9uPSIxLjEiIHg9IjBweCIgeT0iMHB4IiB2aWV3Qm94PSIwIDAgMTAwIDEwMCIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMTAwIDEwMCIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHBhdGggZD0iTTQwLjM2LDUwLjkxYzAuMDA3LTguMTc0LDMuODM2LTExLjUyNSw3LjA0OC0xMi44OThjNi41NTEtMi44MDEsMTYuNzksMC4xNDEsMjMuODA5LDYuODQyICBjMS4yNDYsMS4xODksMi4zNjEsMi4zMDksMy4zNjUsMy4zNjhjLTUuNjg0LTguMzcyLTE1LjAyNS0xNy41NjYtMjkuMDY0LTE4Ljg1OWMtNy43OTQtMC43MTYtMTMuNzk0LDIuNzk5LTE2LjAzMyw5LjQwOCAgYy0yLjY0OSw3LjgyMSwwLjM0MSwxOS4zMDUsMTEuMTgxLDI2LjEyMmM2LjE1MywzLjg2OSwxMi4zLDYuODY5LDE3LjM0MSw5LjA0NWMtMC41NTEtMC4zNTQtMS4xMDUtMC43MTYtMS42Ni0xLjA5MSAgQzQ1LjczMyw2NS42NjIsNDAuMzU0LDU4LjI4MSw0MC4zNiw1MC45MXoiPjwvcGF0aD48cGF0aCBkPSJNNjAuMDI3LDYzLjc2MWMtMC4wNzgtNC43MTUsMS44OTgtOC4yNSw1LjQyMi05LjY5OGM0LjEzOS0xLjcsOS40OS0wLjAwNCwxMy42MzMsNC4zMjMgIGMwLjY5MSwwLjcyMywxLjMwMywxLjQ1MywxLjg3NSwyLjE4NGMtMS42NzQtMy42OTktNC41MS03Ljk1OC0xMS4xMjEtMTQuMjY5Yy02LjM3MS02LjA4MS0xNS44NzktOC45MTItMjEuNjQyLTYuNDUgIGMtMy44MTIsMS42MjktNS44MjksNS40NTQtNS44MzQsMTEuMDYxYy0wLjAxLDExLjgxNSwxNi4zMTIsMjEuNjQ2LDI1LjA3MiwyNi4wNzJDNjMuNzc1LDczLjc0Niw2MC4xMTUsNjkuMTY4LDYwLjAyNyw2My43NjF6Ij48L3BhdGg+PHBhdGggZD0iTTI3LjU5MSwzOC4xM2MyLjU1Ni03LjU0NSw5LjMzMS0xMS41NjgsMTguMTExLTEwLjc1OGMxMS41MjksMS4wNjEsMjAuMDE1LDcuMTQ4LDI2LjAxMywxMy45MiAgQzYxLjUsMjYuMDU0LDQ4Ljk2MywyMC4zMzksNDguODE3LDIwLjI3NGMtMy4yOTYtMS42ODgtNi43OTctMi41MzEtMTAuNDU3LTIuNTMxYy0xMi43NzQsMC0yMy4xNjcsMTAuNTgtMjMuMTY3LDIzLjU4MyAgYzAsNy45NjEsNC4yMDEsMTUuNTIxLDExLjIzOCwyMC4yMjJjMy43ODksMi41MywxMS40ODgsNS44MjQsMjAuMDQ2LDkuMDM4Yy0yLjI1NC0xLjIxNS00LjU2NC0yLjU0Ny02Ljg3NS00ICBDMjcuODg1LDU5LjIxOSwyNC42OSw0Ni42OTQsMjcuNTkxLDM4LjEzeiI+PC9wYXRoPjxwYXRoIGQ9Ik03Ny42MzcsNTkuNzY5Yy0zLjU2OC0zLjcyOS04LjA1Ny01LjI0Mi0xMS40MjgtMy44NTVjLTIuNzIxLDEuMTE4LTQuMjQ2LDMuOTY3LTQuMTgyLDcuODE0ICBjMC4xNDgsOS4wMzUsMTEuMzEzLDE1LjMxOCwxMy41ODgsMTYuNTkyYzMuNDg5LDEuOTU0LDcuNjI1LDIuMDg3LDcuOTA0LDEuOTM4czAuMjc5LTAuMTQ5LDAuNTMxLTAuNjUxICBjMC42Ni0xLjMwOSwxLjA1My00LjI3NSwwLjM2MS04Ljk2NkM4My43NzcsNjkuNDg5LDgyLjA5Niw2NC40MjcsNzcuNjM3LDU5Ljc2OXoiPjwvcGF0aD48L3N2Zz4=");
height: 32px;
pointer-events: none;
background-repeat: no-repeat;
background-size: 32px;
width: 32px;
}
.Banner {
min-height: 30px;
width: 100%;
background-color: #fff704;
display: flex;
flex-direction: column;
justify-content: center;
// z-index: 10;
}
.Banner-description {
color: black;
text-align: center;
width: 100%;
}
.App-Bar {
display: grid;
grid-template-columns: 1fr 120px;
-webkit-box-pack: justify;
justify-content: space-between;
-webkit-box-align: center;
align-items: center;
flex-direction: row;
width: 100%;
top: 0px;
position: relative;
padding: 1rem;
z-index: 2;
.ant-menu-horizontal {
border-bottom-color: transparent;
background-color: transparent;
line-height: inherit;
font-size: 16px;
margin: 0 10px;
.ant-menu-item {
margin: 0 10px;
color: lightgrey;
height: 35px;
line-height: 35px;
border-width: 0px !important;
}
.ant-menu-item:hover {
color: white;
border-width: 0px !important;
}
.ant-menu-item-selected {
font-weight: bold;
}
}
}
.App-Bar-left {
box-sizing: border-box;
margin: 0px;
min-width: 0px;
display: flex;
padding: 0px;
-webkit-box-align: center;
align-items: center;
width: fit-content;
}
.App-Bar-right {
display: flex;
flex-direction: row;
-webkit-box-align: center;
align-items: center;
justify-self: flex-end;
}
.ant-tabs-nav-scroll {
display: flex;
justify-content: center;
}
.discord {
font-size: 30px;
color: #7289da;
}
.discord:hover {
color: #8ea1e1;
}
.telegram {
color: #32afed;
font-size: 28px;
background-color: white;
border-radius: 30px;
display: flex;
width: 27px;
height: 27px;
}
.telegram:hover {
color: #2789de !important;
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
.social-buttons {
margin-top: auto;
margin-left: auto;
margin-bottom: 0.5rem;
margin-right: 1rem;
gap: 0.3rem;
display: flex;
}
.wallet-wrapper {
background: @background-color-base;
padding-left: 0.7rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
white-space: nowrap;
}
.wallet-key {
background: @background-color-base;
padding: 0.1rem 0.5rem 0.1rem 0.7rem;
margin-left: 0.3rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
}
.flash-positive {
color: rgba(0, 255, 0, 0.6);
}
.flash-negative {
color: rgba(255, 0, 0, 0.6);
}
.ant-table-cell {
padding: 6px 16px !important;
}
.ant-table {
margin: 0px 30px;
}
.ant-menu-inline-collapsed > .ant-menu-item {
padding-left: 16px !important;
}
.ant-pagination-options {
display: none;
}
.ant-table-container table > thead > tr th {
text-align: center;
}
.ant-notification {
a {
color: blue;
text-decoration: underline;
cursor: pointer;
}
}
@media only screen and (max-width: 600px) {
.exchange-card {
width: 360px;
}
}

9
src/App.test.tsx Normal file
View File

@ -0,0 +1,9 @@
import React from "react";
import { render } from "@testing-library/react";
import App from "./App";
test("renders learn react link", () => {
const { getByText } = render(<App />);
const linkElement = getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

12
src/App.tsx Normal file
View File

@ -0,0 +1,12 @@
import React from "react";
import "./App.less";
import { Routes } from "./routes";
function App() {
return (
<Routes />
);
}
export default App;

4
src/ant-custom.less Normal file
View File

@ -0,0 +1,4 @@
@import "~antd/dist/antd.css";
@import "~antd/dist/antd.dark.less";
@primary-color: #ff00a8;
@popover-background: #1a2029;

View File

@ -0,0 +1,51 @@
import React from "react";
import { Button, Popover } from "antd";
import { useWallet } from "../../contexts/wallet";
import { CurrentUserBadge } from "../CurrentUserBadge";
import { SettingOutlined } from "@ant-design/icons";
import { Settings } from "../Settings";
export const AppBar = (props: { left?: JSX.Element; right?: JSX.Element }) => {
const { connected, wallet } = useWallet();
const TopBar = (
<div className="App-Bar-right">
<CurrentUserBadge />
<div>
{!connected && (
<Button
type="text"
size="large"
onClick={connected ? wallet.disconnect : wallet.connect}
style={{ color: "#2abdd2" }}
>
Connect
</Button>
)}
{connected && (
<Popover
placement="bottomRight"
title="Wallet public key"
trigger="click"
></Popover>
)}
</div>
<Popover
placement="topRight"
title="Settings"
content={<Settings />}
trigger="click"
>
<Button
shape="circle"
size="large"
type="text"
icon={<SettingOutlined />}
/>
</Popover>
{props.right}
</div>
);
return TopBar;
};

View File

@ -0,0 +1,30 @@
import React from "react";
import { useWallet } from "../../contexts/wallet";
import { shortenAddress } from "../../utils/utils";
import { Identicon } from "../Identicon";
import { useNativeAccount } from "../../contexts/accounts";
import { LAMPORTS_PER_SOL } from "@solana/web3.js";
export const CurrentUserBadge = (props: {}) => {
const { wallet } = useWallet();
const { account } = useNativeAccount();
if (!wallet || !wallet.publicKey) {
return null;
}
return (
<div className="wallet-wrapper">
<span>
{((account?.lamports || 0) / LAMPORTS_PER_SOL).toFixed(6)} SOL
</span>
<div className="wallet-key">
{shortenAddress(`${wallet.publicKey}`)}
<Identicon
address={wallet.publicKey.toBase58()}
style={{ marginLeft: "0.5rem", display: 'flex' }}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,43 @@
import React from "react";
import { Typography } from "antd";
import { shortenAddress } from "../../utils/utils";
import { PublicKey } from "@solana/web3.js";
export const ExplorerLink = (props: {
address: string | PublicKey;
type: string;
code?: boolean;
style?: React.CSSProperties;
length?: number;
}) => {
const { type, code } = props;
const address =
typeof props.address === "string"
? props.address
: props.address?.toBase58();
if (!address) {
return null;
}
const length = props.length ?? 9;
return (
<a
href={`https://explorer.solana.com/${type}/${address}`}
// eslint-disable-next-line react/jsx-no-target-blank
target="_blank"
title={address}
style={props.style}
>
{code ? (
<Typography.Text style={props.style} code>
{shortenAddress(address, length)}
</Typography.Text>
) : (
shortenAddress(address, length)
)}
</a>
);
};

View File

@ -0,0 +1,20 @@
import { Button, Popover } from "antd";
import React from "react";
import { InfoCircleOutlined } from "@ant-design/icons";
export const Info = (props: {
text: React.ReactElement;
style?: React.CSSProperties;
}) => {
return (
<Popover
trigger="hover"
content={<div style={{ width: 300 }}>{props.text}</div>}
>
<Button type="text" shape="circle">
<InfoCircleOutlined style={props.style} />
</Button>
</Popover>
);
};

View File

@ -0,0 +1,31 @@
import React, { useEffect, useRef } from "react";
import Jazzicon from "jazzicon";
import bs58 from "bs58";
import "./style.less";
export const Identicon = (props: {
address?: string;
style?: React.CSSProperties;
className?: string;
}) => {
const { address, style } = props;
const ref = useRef<HTMLDivElement>();
useEffect(() => {
if (address && ref.current) {
ref.current.innerHTML = "";
ref.current.className = props.className || "";
ref.current.appendChild(
Jazzicon(
style?.width || 16,
parseInt(bs58.decode(address).toString("hex").slice(5, 15), 16)
)
);
}
}, [address, style, props.className]);
return (
<div className="identicon-wrapper" ref={ref as any} style={props.style} />
);
};

View File

@ -0,0 +1,8 @@
.identicon-wrapper {
display: flex;
height: 1rem;
width: 1rem;
border-radius: 1.125rem;
margin: 0.2rem 0.2rem 0.2rem 0.1rem;
/* background-color: ${({ theme }) => theme.bg4}; */
}

View File

@ -0,0 +1,36 @@
import React from "react";
import { Input } from "antd";
export class NumericInput extends React.Component<any, any> {
onChange = (e: any) => {
const { value } = e.target;
const reg = /^-?\d*(\.\d*)?$/;
if ((!isNaN(value) && reg.test(value)) || value === "" || value === "-") {
this.props.onChange(value);
}
};
// '.' at the end or only '-' in the input box.
onBlur = () => {
const { value, onBlur, onChange } = this.props;
let valueTemp = value;
if (value.charAt(value.length - 1) === "." || value === "-") {
valueTemp = value.slice(0, -1);
}
onChange(valueTemp.replace(/0*(\d+)/, "$1"));
if (onBlur) {
onBlur();
}
};
render() {
return (
<Input
{...this.props}
onChange={this.onChange}
onBlur={this.onBlur}
maxLength={25}
/>
);
}
}

View File

@ -0,0 +1,54 @@
import React, { useState } from "react";
import "./../../App.less";
import GitHubButton from "react-github-btn";
import { Menu } from 'antd';
import {
PieChartOutlined,
GithubOutlined,
BankOutlined,
LogoutOutlined
} from '@ant-design/icons';
import BasicLayout, { DefaultFooter, PageContainer } from '@ant-design/pro-layout';
import { AppBar } from "./../AppBar";
import { Link } from "react-router-dom";
export const AppLayout = (props: any) => {
return (
<div className="App">
<div className="Banner">
<div className="Banner-description">
Oyster Lending is unaudited software. Use at your own risk.
</div>
</div>
<BasicLayout title="Oyster Lending"
navTheme="realDark"
headerTheme="dark"
theme="dark"
layout="side"
primaryColor="#d83aeb"
logo={<div className="App-logo" />}
rightContentRender={() => <AppBar />}
links={[
<div title="Fork"><GithubOutlined /></div>
]}
menuContentRender={() => {
return <Menu theme="dark" defaultSelectedKeys={['1']} mode="inline">
<Menu.Item key="1" icon={<BankOutlined />}>
Deposit
</Menu.Item>
<Menu.Item key="2" icon={<LogoutOutlined />}>
Borrow
</Menu.Item>
<Menu.Item key="3" icon={<PieChartOutlined />}>
Dashboard
</Menu.Item>
</Menu>
}}
>
{props.children}
</BasicLayout>
</div>
);
}

View File

@ -0,0 +1,38 @@
import React from "react";
import { Select } from "antd";
import { ENDPOINTS, useConnectionConfig } from "../../contexts/connection";
import { useWallet, WALLET_PROVIDERS } from "../../contexts/wallet";
export const Settings = () => {
const { providerUrl, setProvider } = useWallet();
const { endpoint, setEndpoint } = useConnectionConfig();
return (
<>
<div style={{ display: "grid" }}>
Network:{" "}
<Select
onSelect={setEndpoint}
value={endpoint}
style={{ marginRight: 8 }}
>
{ENDPOINTS.map(({ name, endpoint }) => (
<Select.Option value={endpoint} key={endpoint}>
{name}
</Select.Option>
))}
</Select>
</div>
<div style={{ display: "grid" }}>
Wallet:{" "}
<Select onSelect={setProvider} value={providerUrl}>
{WALLET_PROVIDERS.map(({ name, url }) => (
<Select.Option value={url} key={url}>
{name}
</Select.Option>
))}
</Select>
</div>
</>
);
};

View File

@ -0,0 +1,64 @@
import { Identicon } from "../Identicon";
import React from "react";
import { getTokenIcon } from "../../utils/utils";
import { useConnectionConfig } from "../../contexts/connection";
export const TokenIcon = (props: {
mintAddress: string;
style?: React.CSSProperties;
className?: string;
}) => {
const { tokenMap } = useConnectionConfig();
const icon = getTokenIcon(tokenMap, props.mintAddress);
if (icon) {
return (
<img
alt="Token icon"
className={props.className}
key={props.mintAddress}
width="20"
height="20"
src={icon}
style={{
marginRight: "0.5rem",
marginTop: "0.11rem",
borderRadius: "1rem",
backgroundColor: "white",
backgroundClip: "padding-box",
...props.style,
}}
/>
);
}
return (
<Identicon
address={props.mintAddress}
style={{
marginRight: "0.5rem",
width: 20,
height: 20,
marginTop: 2,
...props.style,
}}
/>
);
};
export const PoolIcon = (props: {
mintA: string;
mintB: string;
style?: React.CSSProperties;
className?: string;
}) => {
return (
<div className={props.className} style={{ display: "flex" }}>
<TokenIcon
mintAddress={props.mintA}
style={{ marginRight: "-0.5rem", ...props.style }}
/>
<TokenIcon mintAddress={props.mintB} />
</div>
);
};

80
src/constants/ids.tsx Normal file
View File

@ -0,0 +1,80 @@
import { PublicKey } from "@solana/web3.js";
export const WRAPPED_SOL_MINT = new PublicKey(
"So11111111111111111111111111111111111111112"
);
let TOKEN_PROGRAM_ID = new PublicKey(
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
);
let SWAP_PROGRAM_ID: PublicKey;
let SWAP_PROGRAM_LEGACY_IDS: PublicKey[];
export const SWAP_HOST_FEE_ADDRESS = process.env.REACT_APP_SWAP_HOST_FEE_ADDRESS
? new PublicKey(`${process.env.REACT_APP_SWAP_HOST_FEE_ADDRESS}`)
: undefined;
export const SWAP_PROGRAM_OWNER_FEE_ADDRESS = new PublicKey(
"HfoTxFR1Tm6kGmWgYWD6J7YHVy1UwqSULUGVLXkJqaKN"
);
console.debug(`Host address: ${SWAP_HOST_FEE_ADDRESS?.toBase58()}`);
console.debug(`Owner address: ${SWAP_PROGRAM_OWNER_FEE_ADDRESS?.toBase58()}`);
// legacy pools are used to show users contributions in those pools to allow for withdrawals of funds
export const PROGRAM_IDS = [
{
name: "mainnet-beta",
swap: () => ({
current: new PublicKey("9qvG1zUp8xF1Bi4m6UdRNby1BAAuaDrUxSpv4CmRRMjL"),
legacy: [],
}),
},
{
name: "testnet",
swap: () => ({
current: new PublicKey("2n2dsFSgmPcZ8jkmBZLGUM2nzuFqcBGQ3JEEj6RJJcEg"),
legacy: [
new PublicKey("9tdctNJuFsYZ6VrKfKEuwwbPp4SFdFw3jYBZU8QUtzeX"),
new PublicKey("CrRvVBS4Hmj47TPU3cMukurpmCUYUrdHYxTQBxncBGqw"),
],
}),
},
{
name: "devnet",
swap: () => ({
current: new PublicKey("BSfTAcBdqmvX5iE2PW88WFNNp2DHhLUaBKk5WrnxVkcJ"),
legacy: [
new PublicKey("H1E1G7eD5Rrcy43xvDxXCsjkRggz7MWNMLGJ8YNzJ8PM"),
new PublicKey("CMoteLxSPVPoc7Drcggf3QPg3ue8WPpxYyZTg77UGqHo"),
new PublicKey("EEuPz4iZA5reBUeZj6x1VzoiHfYeHMppSCnHZasRFhYo"),
],
}),
},
{
name: "localnet",
swap: () => ({
current: new PublicKey("5rdpyt5iGfr68qt28hkefcFyF4WtyhTwqKDmHSBG8GZx"),
legacy: [],
}),
},
];
export const setProgramIds = (envName: string) => {
let instance = PROGRAM_IDS.find((env) => env.name === envName);
if (!instance) {
return;
}
let swap = instance.swap();
SWAP_PROGRAM_ID = swap.current;
SWAP_PROGRAM_LEGACY_IDS = swap.legacy;
};
export const programIds = () => {
return {
token: TOKEN_PROGRAM_ID,
swap: SWAP_PROGRAM_ID,
swap_legacy: SWAP_PROGRAM_LEGACY_IDS,
};
};

686
src/contexts/accounts.tsx Normal file
View File

@ -0,0 +1,686 @@
import React, { useCallback, useContext, useEffect, useState } from "react";
import { useConnection } from "./connection";
import { useWallet } from "./wallet";
import { AccountInfo, Connection, PublicKey } from "@solana/web3.js";
import { programIds, SWAP_HOST_FEE_ADDRESS, WRAPPED_SOL_MINT } from "./../constants/ids";
import { AccountLayout, u64, MintInfo, MintLayout } from "@solana/spl-token";
import { TokenAccount } from "./../models";
import { notify } from "./../utils/notifications";
import { chunks } from "./../utils/utils";
import { EventEmitter } from "./../utils/eventEmitter";
const AccountsContext = React.createContext<any>(null);
const accountEmitter = new EventEmitter();
const pendingMintCalls = new Map<string, Promise<MintInfo>>();
const mintCache = new Map<string, MintInfo>();
const pendingAccountCalls = new Map<string, Promise<TokenAccount>>();
const accountsCache = new Map<string, TokenAccount>();
const pendingCalls = new Map<string, Promise<ParsedAccountBase>>();
const genericCache = new Map<string, ParsedAccountBase>();
const getAccountInfo = async (connection: Connection, pubKey: PublicKey) => {
const info = await connection.getAccountInfo(pubKey);
if (info === null) {
throw new Error("Failed to find mint account");
}
return tokenAccountFactory(pubKey, info);
};
const getMintInfo = async (connection: Connection, pubKey: PublicKey) => {
const info = await connection.getAccountInfo(pubKey);
if (info === null) {
throw new Error("Failed to find mint account");
}
const data = Buffer.from(info.data);
return deserializeMint(data);
};
export interface ParsedAccountBase {
pubkey: PublicKey;
account: AccountInfo<Buffer>;
info: any; // TODO: change to unkown
}
export interface ParsedAccount<T> extends ParsedAccountBase {
info: T;
}
export type AccountParser = (
pubkey: PublicKey,
data: AccountInfo<Buffer>
) => ParsedAccountBase;
export const MintParser = (pubKey: PublicKey, info: AccountInfo<Buffer>) => {
const buffer = Buffer.from(info.data);
const data = deserializeMint(buffer);
const details = {
pubkey: pubKey,
account: {
...info,
},
info: data,
} as ParsedAccountBase;
return details;
};
export const TokenAccountParser = tokenAccountFactory;
export const GenericAccountParser = (
pubKey: PublicKey,
info: AccountInfo<Buffer>
) => {
const buffer = Buffer.from(info.data);
const details = {
pubkey: pubKey,
account: {
...info,
},
info: buffer,
} as ParsedAccountBase;
return details;
};
export const keyToAccountParser = new Map<string, AccountParser>();
export const cache = {
query: async (
connection: Connection,
pubKey: string | PublicKey,
parser?: AccountParser
) => {
let id: PublicKey;
if (typeof pubKey === "string") {
id = new PublicKey(pubKey);
} else {
id = pubKey;
}
const address = id.toBase58();
let account = genericCache.get(address);
if (account) {
return account;
}
let query = pendingCalls.get(address);
if (query) {
return query;
}
query = connection.getAccountInfo(id).then((data) => {
if (!data) {
throw new Error("Account not found");
}
return cache.add(id, data, parser);
}) as Promise<TokenAccount>;
pendingCalls.set(address, query as any);
return query;
},
add: (id: PublicKey, obj: AccountInfo<Buffer>, parser?: AccountParser) => {
const address = id.toBase58();
const deserialize = parser ? parser : keyToAccountParser.get(address);
if (!deserialize) {
throw new Error(
"Deserializer needs to be registered or passed as a parameter"
);
}
cache.registerParser(id, deserialize);
pendingCalls.delete(address);
const account = deserialize(id, obj);
genericCache.set(address, account);
return account;
},
get: (pubKey: string | PublicKey) => {
let key: string;
if (typeof pubKey !== "string") {
key = pubKey.toBase58();
} else {
key = pubKey;
}
return genericCache.get(key);
},
registerParser: (pubkey: PublicKey, parser: AccountParser) => {
keyToAccountParser.set(pubkey.toBase58(), parser);
},
queryAccount: async (connection: Connection, pubKey: string | PublicKey) => {
let id: PublicKey;
if (typeof pubKey === "string") {
id = new PublicKey(pubKey);
} else {
id = pubKey;
}
const address = id.toBase58();
let account = accountsCache.get(address);
if (account) {
return account;
}
let query = pendingAccountCalls.get(address);
if (query) {
return query;
}
query = getAccountInfo(connection, id).then((data) => {
pendingAccountCalls.delete(address);
accountsCache.set(address, data);
return data;
}) as Promise<TokenAccount>;
pendingAccountCalls.set(address, query as any);
return query;
},
addAccount: (pubKey: PublicKey, obj: AccountInfo<Buffer>) => {
const account = tokenAccountFactory(pubKey, obj);
accountsCache.set(account.pubkey.toBase58(), account);
return account;
},
getAccount: (pubKey: string | PublicKey) => {
let key: string;
if (typeof pubKey !== "string") {
key = pubKey.toBase58();
} else {
key = pubKey;
}
return accountsCache.get(key);
},
queryMint: async (connection: Connection, pubKey: string | PublicKey) => {
let id: PublicKey;
if (typeof pubKey === "string") {
id = new PublicKey(pubKey);
} else {
id = pubKey;
}
const address = id.toBase58();
let mint = mintCache.get(address);
if (mint) {
return mint;
}
let query = pendingMintCalls.get(address);
if (query) {
return query;
}
query = getMintInfo(connection, id).then((data) => {
pendingAccountCalls.delete(address);
mintCache.set(address, data);
return data;
}) as Promise<MintInfo>;
pendingAccountCalls.set(address, query as any);
return query;
},
getMint: (pubKey: string | PublicKey) => {
let key: string;
if (typeof pubKey !== "string") {
key = pubKey.toBase58();
} else {
key = pubKey;
}
return mintCache.get(key);
},
addMint: (pubKey: PublicKey, obj: AccountInfo<Buffer>) => {
const mint = deserializeMint(obj.data);
mintCache.set(pubKey.toBase58(), mint);
return mint;
},
};
export const getCachedAccount = (
predicate: (account: TokenAccount) => boolean
) => {
for (const account of accountsCache.values()) {
if (predicate(account)) {
return account as TokenAccount;
}
}
};
function tokenAccountFactory(pubKey: PublicKey, info: AccountInfo<Buffer>) {
const buffer = Buffer.from(info.data);
const data = deserializeAccount(buffer);
const details = {
pubkey: pubKey,
account: {
...info,
},
info: data,
} as TokenAccount;
return details;
}
function wrapNativeAccount(
pubkey: PublicKey,
account?: AccountInfo<Buffer>
): TokenAccount | undefined {
if (!account) {
return undefined;
}
return {
pubkey: pubkey,
account,
info: {
mint: WRAPPED_SOL_MINT,
owner: pubkey,
amount: new u64(account.lamports),
delegate: null,
delegatedAmount: new u64(0),
isInitialized: true,
isFrozen: false,
isNative: true,
rentExemptReserve: null,
closeAuthority: null,
},
};
}
const UseNativeAccount = () => {
const connection = useConnection();
const { wallet } = useWallet();
const [nativeAccount, setNativeAccount] = useState<AccountInfo<Buffer>>();
useEffect(() => {
if (!connection || !wallet?.publicKey) {
return;
}
connection.getAccountInfo(wallet.publicKey).then((acc) => {
if (acc) {
setNativeAccount(acc);
}
});
connection.onAccountChange(wallet.publicKey, (acc) => {
if (acc) {
setNativeAccount(acc);
}
});
}, [setNativeAccount, wallet, wallet.publicKey, connection]);
return { nativeAccount };
};
const PRECACHED_OWNERS = new Set<string>();
const precacheUserTokenAccounts = async (
connection: Connection,
owner?: PublicKey
) => {
if (!owner) {
return;
}
// used for filtering account updates over websocket
PRECACHED_OWNERS.add(owner.toBase58());
// user accounts are update via ws subscription
const accounts = await connection.getTokenAccountsByOwner(owner, {
programId: programIds().token,
});
accounts.value
.map((info) => {
const data = deserializeAccount(info.account.data);
// need to query for mint to get decimals
// TODO: move to web3.js for decoding on the client side... maybe with callback
const details = {
pubkey: info.pubkey,
account: {
...info.account,
},
info: data,
} as TokenAccount;
return details;
})
.forEach((acc) => {
accountsCache.set(acc.pubkey.toBase58(), acc);
});
};
export function AccountsProvider({ children = null as any }) {
const connection = useConnection();
const { wallet, connected } = useWallet();
const [tokenAccounts, setTokenAccounts] = useState<TokenAccount[]>([]);
const [userAccounts, setUserAccounts] = useState<TokenAccount[]>([]);
const { nativeAccount } = UseNativeAccount();
const selectUserAccounts = useCallback(() => {
return [...accountsCache.values()].filter(
(a) => a.info.owner.toBase58() === wallet.publicKey.toBase58()
);
}, [wallet]);
useEffect(() => {
setUserAccounts(
[
wrapNativeAccount(wallet.publicKey, nativeAccount),
...tokenAccounts,
].filter((a) => a !== undefined) as TokenAccount[]
);
}, [nativeAccount, wallet, tokenAccounts]);
const publicKey = wallet?.publicKey;
useEffect(() => {
if (!connection || !publicKey) {
setTokenAccounts([]);
} else {
// cache host accounts to avoid query during swap
precacheUserTokenAccounts(connection, SWAP_HOST_FEE_ADDRESS);
precacheUserTokenAccounts(connection, publicKey).then(() => {
setTokenAccounts(selectUserAccounts());
});
// This can return different types of accounts: token-account, mint, multisig
// TODO: web3.js expose ability to filter. discuss filter syntax
const tokenSubID = connection.onProgramAccountChange(
programIds().token,
(info) => {
// TODO: fix type in web3.js
const id = (info.accountId as unknown) as string;
// TODO: do we need a better way to identify layout (maybe a enum identifing type?)
if (info.accountInfo.data.length === AccountLayout.span) {
const data = deserializeAccount(info.accountInfo.data);
// TODO: move to web3.js for decoding on the client side... maybe with callback
const details = {
pubkey: new PublicKey((info.accountId as unknown) as string),
account: {
...info.accountInfo,
},
info: data,
} as TokenAccount;
if (
PRECACHED_OWNERS.has(details.info.owner.toBase58()) ||
accountsCache.has(id)
) {
accountsCache.set(id, details);
setTokenAccounts(selectUserAccounts());
accountEmitter.raiseAccountUpdated(id);
}
} else if (info.accountInfo.data.length === MintLayout.span) {
if (mintCache.has(id)) {
const data = Buffer.from(info.accountInfo.data);
const mint = deserializeMint(data);
mintCache.set(id, mint);
accountEmitter.raiseAccountUpdated(id);
}
accountEmitter.raiseAccountUpdated(id);
}
if (genericCache.has(id)) {
cache.add(new PublicKey(id), info.accountInfo);
}
},
"singleGossip"
);
return () => {
connection.removeProgramAccountChangeListener(tokenSubID);
};
}
}, [connection, connected, publicKey, selectUserAccounts]);
return (
<AccountsContext.Provider
value={{
userAccounts,
nativeAccount,
}}
>
{children}
</AccountsContext.Provider>
);
}
export function useNativeAccount() {
const context = useContext(AccountsContext);
return {
account: context.nativeAccount as AccountInfo<Buffer>,
};
}
export const getMultipleAccounts = async (
connection: any,
keys: string[],
commitment: string
) => {
const result = await Promise.all(
chunks(keys, 99).map((chunk) =>
getMultipleAccountsCore(connection, chunk, commitment)
)
);
const array = result
.map(
(a) =>
a.array.map((acc) => {
const { data, ...rest } = acc;
const obj = {
...rest,
data: Buffer.from(data[0], "base64"),
} as AccountInfo<Buffer>;
return obj;
}) as AccountInfo<Buffer>[]
)
.flat();
return { keys, array };
};
const getMultipleAccountsCore = async (
connection: any,
keys: string[],
commitment: string
) => {
const args = connection._buildArgs([keys], commitment, "base64");
const unsafeRes = await connection._rpcRequest("getMultipleAccounts", args);
if (unsafeRes.error) {
throw new Error(
"failed to get info about account " + unsafeRes.error.message
);
}
if (unsafeRes.result.value) {
const array = unsafeRes.result.value as AccountInfo<string[]>[];
return { keys, array };
}
// TODO: fix
throw new Error();
};
export function useMint(key?: string | PublicKey) {
const connection = useConnection();
const [mint, setMint] = useState<MintInfo>();
const id = typeof key === "string" ? key : key?.toBase58();
useEffect(() => {
if (!id) {
return;
}
cache
.queryMint(connection, id)
.then(setMint)
.catch((err) =>
notify({
message: err.message,
type: "error",
})
);
const dispose = accountEmitter.onAccount((e) => {
const event = e;
if (event.id === id) {
cache.queryMint(connection, id).then(setMint);
}
});
return () => {
dispose();
};
}, [connection, id]);
return mint;
}
export function useUserAccounts() {
const context = useContext(AccountsContext);
return {
userAccounts: context.userAccounts as TokenAccount[],
};
}
export function useAccount(pubKey?: PublicKey) {
const connection = useConnection();
const [account, setAccount] = useState<TokenAccount>();
const key = pubKey?.toBase58();
useEffect(() => {
const query = async () => {
try {
if (!key) {
return;
}
const acc = await cache.queryAccount(connection, key).catch((err) =>
notify({
message: err.message,
type: "error",
})
);
if (acc) {
setAccount(acc);
}
} catch (err) {
console.error(err);
}
};
query();
const dispose = accountEmitter.onAccount((e) => {
const event = e;
if (event.id === key) {
query();
}
});
return () => {
dispose();
};
}, [connection, key]);
return account;
}
export const useSelectedAccount = (account: string) => {
const { userAccounts } = useUserAccounts();
const index = userAccounts.findIndex(
(acc) => acc.pubkey.toBase58() === account
);
if (index !== -1) {
return userAccounts[index];
}
return;
};
export const useAccountByMint = (mint: string) => {
const { userAccounts } = useUserAccounts();
const index = userAccounts.findIndex(
(acc) => acc.info.mint.toBase58() === mint
);
if (index !== -1) {
return userAccounts[index];
}
return;
};
// TODO: expose in spl package
const deserializeAccount = (data: Buffer) => {
const accountInfo = AccountLayout.decode(data);
accountInfo.mint = new PublicKey(accountInfo.mint);
accountInfo.owner = new PublicKey(accountInfo.owner);
accountInfo.amount = u64.fromBuffer(accountInfo.amount);
if (accountInfo.delegateOption === 0) {
accountInfo.delegate = null;
accountInfo.delegatedAmount = new u64(0);
} else {
accountInfo.delegate = new PublicKey(accountInfo.delegate);
accountInfo.delegatedAmount = u64.fromBuffer(accountInfo.delegatedAmount);
}
accountInfo.isInitialized = accountInfo.state !== 0;
accountInfo.isFrozen = accountInfo.state === 2;
if (accountInfo.isNativeOption === 1) {
accountInfo.rentExemptReserve = u64.fromBuffer(accountInfo.isNative);
accountInfo.isNative = true;
} else {
accountInfo.rentExemptReserve = null;
accountInfo.isNative = false;
}
if (accountInfo.closeAuthorityOption === 0) {
accountInfo.closeAuthority = null;
} else {
accountInfo.closeAuthority = new PublicKey(accountInfo.closeAuthority);
}
return accountInfo;
};
// TODO: expose in spl package
const deserializeMint = (data: Buffer) => {
if (data.length !== MintLayout.span) {
throw new Error("Not a valid Mint");
}
const mintInfo = MintLayout.decode(data);
if (mintInfo.mintAuthorityOption === 0) {
mintInfo.mintAuthority = null;
} else {
mintInfo.mintAuthority = new PublicKey(mintInfo.mintAuthority);
}
mintInfo.supply = u64.fromBuffer(mintInfo.supply);
mintInfo.isInitialized = mintInfo.isInitialized !== 0;
if (mintInfo.freezeAuthorityOption === 0) {
mintInfo.freezeAuthority = null;
} else {
mintInfo.freezeAuthority = new PublicKey(mintInfo.freezeAuthority);
}
return mintInfo as MintInfo;
};

262
src/contexts/connection.tsx Normal file
View File

@ -0,0 +1,262 @@
import { KnownToken, useLocalStorageState } from "./../utils/utils";
import {
Account,
clusterApiUrl,
Connection,
Transaction,
TransactionInstruction,
} from "@solana/web3.js";
import React, { useContext, useEffect, useMemo, useState } from "react";
import { setProgramIds } from "./../constants/ids";
import { notify } from "./../utils/notifications";
import { ExplorerLink } from "../components/ExplorerLink";
export type ENV = "mainnet-beta" | "testnet" | "devnet" | "localnet";
export const ENDPOINTS = [
{
name: "mainnet-beta" as ENV,
endpoint: "https://solana-api.projectserum.com/",
},
{ name: "testnet" as ENV, endpoint: clusterApiUrl("testnet") },
{ name: "devnet" as ENV, endpoint: clusterApiUrl("devnet") },
{ name: "localnet" as ENV, endpoint: "http://127.0.0.1:8899" },
];
const DEFAULT = ENDPOINTS[0].endpoint;
const DEFAULT_SLIPPAGE = 0.25;
interface ConnectionConfig {
connection: Connection;
sendConnection: Connection;
endpoint: string;
slippage: number;
setSlippage: (val: number) => void;
env: ENV;
setEndpoint: (val: string) => void;
tokens: KnownToken[];
tokenMap: Map<string, KnownToken>;
}
const ConnectionContext = React.createContext<ConnectionConfig>({
endpoint: DEFAULT,
setEndpoint: () => {},
slippage: DEFAULT_SLIPPAGE,
setSlippage: (val: number) => {},
connection: new Connection(DEFAULT, "recent"),
sendConnection: new Connection(DEFAULT, "recent"),
env: ENDPOINTS[0].name,
tokens: [],
tokenMap: new Map<string, KnownToken>(),
});
export function ConnectionProvider({ children = undefined as any }) {
const [endpoint, setEndpoint] = useLocalStorageState(
"connectionEndpts",
ENDPOINTS[0].endpoint
);
const [slippage, setSlippage] = useLocalStorageState(
"slippage",
DEFAULT_SLIPPAGE.toString()
);
const connection = useMemo(() => new Connection(endpoint, "recent"), [
endpoint,
]);
const sendConnection = useMemo(() => new Connection(endpoint, "recent"), [
endpoint,
]);
const env =
ENDPOINTS.find((end) => end.endpoint === endpoint)?.name ||
ENDPOINTS[0].name;
const [tokens, setTokens] = useState<KnownToken[]>([]);
const [tokenMap, setTokenMap] = useState<Map<string, KnownToken>>(new Map());
useEffect(() => {
// fetch token files
window
.fetch(
`https://raw.githubusercontent.com/solana-labs/token-list/main/src/tokens/${env}.json`
)
.then((res) => {
return res.json();
})
.then((list: KnownToken[]) => {
const knownMints = list.reduce((map, item) => {
map.set(item.mintAddress, item);
return map;
}, new Map<string, KnownToken>());
setTokenMap(knownMints);
setTokens(list);
});
}, [env]);
setProgramIds(env);
// The websocket library solana/web3.js uses closes its websocket connection when the subscription list
// is empty after opening its first time, preventing subsequent subscriptions from receiving responses.
// This is a hack to prevent the list from every getting empty
useEffect(() => {
const id = connection.onAccountChange(new Account().publicKey, () => {});
return () => {
connection.removeAccountChangeListener(id);
};
}, [connection]);
useEffect(() => {
const id = connection.onSlotChange(() => null);
return () => {
connection.removeSlotChangeListener(id);
};
}, [connection]);
useEffect(() => {
const id = sendConnection.onAccountChange(
new Account().publicKey,
() => {}
);
return () => {
sendConnection.removeAccountChangeListener(id);
};
}, [sendConnection]);
useEffect(() => {
const id = sendConnection.onSlotChange(() => null);
return () => {
sendConnection.removeSlotChangeListener(id);
};
}, [sendConnection]);
return (
<ConnectionContext.Provider
value={{
endpoint,
setEndpoint,
slippage: parseFloat(slippage),
setSlippage: (val) => setSlippage(val.toString()),
connection,
sendConnection,
tokens,
tokenMap,
env,
}}
>
{children}
</ConnectionContext.Provider>
);
}
export function useConnection() {
return useContext(ConnectionContext).connection as Connection;
}
export function useSendConnection() {
return useContext(ConnectionContext)?.sendConnection;
}
export function useConnectionConfig() {
const context = useContext(ConnectionContext);
return {
endpoint: context.endpoint,
setEndpoint: context.setEndpoint,
env: context.env,
tokens: context.tokens,
tokenMap: context.tokenMap,
};
}
export function useSlippageConfig() {
const { slippage, setSlippage } = useContext(ConnectionContext);
return { slippage, setSlippage };
}
const getErrorForTransaction = async (connection: Connection, txid: string) => {
// wait for all confirmation before geting transaction
await connection.confirmTransaction(txid, "max");
const tx = await connection.getParsedConfirmedTransaction(txid);
const errors: string[] = [];
if (tx?.meta && tx.meta.logMessages) {
tx.meta.logMessages.forEach((log) => {
const regex = /Error: (.*)/gm;
let m;
while ((m = regex.exec(log)) !== null) {
// This is necessary to avoid infinite loops with zero-width matches
if (m.index === regex.lastIndex) {
regex.lastIndex++;
}
if (m.length > 1) {
errors.push(m[1]);
}
}
});
}
return errors;
};
export const sendTransaction = async (
connection: Connection,
wallet: any,
instructions: TransactionInstruction[],
signers: Account[],
awaitConfirmation = true
) => {
let transaction = new Transaction();
instructions.forEach((instruction) => transaction.add(instruction));
transaction.recentBlockhash = (
await connection.getRecentBlockhash("max")
).blockhash;
transaction.setSigners(
// fee payied by the wallet owner
wallet.publicKey,
...signers.map((s) => s.publicKey)
);
if (signers.length > 0) {
transaction.partialSign(...signers);
}
transaction = await wallet.signTransaction(transaction);
const rawTransaction = transaction.serialize();
let options = {
skipPreflight: true,
commitment: "singleGossip",
};
const txid = await connection.sendRawTransaction(rawTransaction, options);
if (awaitConfirmation) {
const status = (
await connection.confirmTransaction(
txid,
options && (options.commitment as any)
)
).value;
if (status?.err) {
const errors = await getErrorForTransaction(connection, txid);
notify({
message: "Transaction failed...",
description: (
<>
{errors.map((err) => (
<div>{err}</div>
))}
<ExplorerLink address={txid} type="transaction" />
</>
),
type: "error",
});
throw new Error(
`Raw transaction ${txid} failed (${JSON.stringify(status)})`
);
}
}
return txid;
};

367
src/contexts/market.tsx Normal file
View File

@ -0,0 +1,367 @@
import React, { useCallback, useContext, useEffect, useState } from "react";
import { MINT_TO_MARKET } from "./../models/marketOverrides";
import {
STABLE_COINS,
} from "./../utils/utils";
import { useConnectionConfig } from "./connection";
import {
cache,
getMultipleAccounts,
MintParser,
ParsedAccountBase,
} from "./accounts";
import { Market, MARKETS, Orderbook, TOKEN_MINTS } from "@project-serum/serum";
import { AccountInfo, Connection, PublicKey } from "@solana/web3.js";
import { useMemo } from "react";
import { EventEmitter } from "./../utils/eventEmitter";
interface RecentPoolData {
pool_identifier: string;
volume24hA: number;
}
export interface MarketsContextState {
midPriceInUSD: (mint: string) => number;
marketEmitter: EventEmitter;
accountsToObserve: Map<string, number>;
marketByMint: Map<string, SerumMarket>;
subscribeToMarket: (mint: string) => () => void;
}
const REFRESH_INTERVAL = 30_000;
const MarketsContext = React.createContext<MarketsContextState | null>(null);
const marketEmitter = new EventEmitter();
export function MarketProvider({ children = null as any }) {
const { endpoint } = useConnectionConfig();
const accountsToObserve = useMemo(() => new Map<string, number>(), []);
const connection = useMemo(() => new Connection(endpoint, "recent"), [
endpoint,
]);
// TODO: identify which markets to query ...
const mints = useMemo(() => [] as PublicKey[], []);
const marketByMint = useMemo(() => {
return [
...new Set(mints).values(),
].reduce((acc, key) => {
const mintAddress = key.toBase58();
const SERUM_TOKEN = TOKEN_MINTS.find(
(a) => a.address.toBase58() === mintAddress
);
const marketAddress = MINT_TO_MARKET[mintAddress];
const marketName = `${SERUM_TOKEN?.name}/USDC`;
const marketInfo = MARKETS.find(
(m) => m.name === marketName || m.address.toBase58() === marketAddress
);
if (marketInfo) {
acc.set(mintAddress, {
marketInfo,
});
}
return acc;
}, new Map<string, SerumMarket>()) as Map<string, SerumMarket>;
}, [mints]);
useEffect(() => {
let timer = 0;
const updateData = async () => {
await refreshAccounts(connection, [...accountsToObserve.keys()]);
marketEmitter.raiseMarketUpdated(new Set([...marketByMint.keys()]));
timer = window.setTimeout(() => updateData(), REFRESH_INTERVAL);
};
const initalQuery = async () => {
const reverseSerumMarketCache = new Map<string, string>();
[...marketByMint.keys()].forEach((mint) => {
const m = marketByMint.get(mint);
if (m) {
reverseSerumMarketCache.set(m.marketInfo.address.toBase58(), mint);
}
});
const allMarkets = [...marketByMint.values()].map((m) => {
return m.marketInfo.address.toBase58();
});
await getMultipleAccounts(
connection,
// only query for markets that are not in cahce
allMarkets.filter((a) => cache.get(a) === undefined),
"single"
).then(({ keys, array }) => {
allMarkets.forEach(() => {});
return array.map((item, index) => {
const marketAddress = keys[index];
const mintAddress = reverseSerumMarketCache.get(marketAddress);
if (mintAddress) {
const market = marketByMint.get(mintAddress);
if (market) {
const programId = market.marketInfo.programId;
const id = market.marketInfo.address;
cache.add(id, item, (id, acc) => {
const decoded = Market.getLayout(programId).decode(acc.data);
const details = {
pubkey: id,
account: {
...acc,
},
info: decoded,
} as ParsedAccountBase;
cache.registerParser(details.info.baseMint, MintParser);
cache.registerParser(details.info.quoteMint, MintParser);
cache.registerParser(details.info.bids, OrderBookParser);
cache.registerParser(details.info.asks, OrderBookParser);
return details;
});
}
}
return item;
});
});
const toQuery = new Set<string>();
allMarkets.forEach((m) => {
const market = cache.get(m);
if (!market) {
return;
}
const decoded = market;
if (!cache.get(decoded.info.baseMint)) {
toQuery.add(decoded.info.baseMint.toBase58());
}
if (!cache.get(decoded.info.baseMint)) {
toQuery.add(decoded.info.quoteMint.toBase58());
}
toQuery.add(decoded.info.bids.toBase58());
toQuery.add(decoded.info.asks.toBase58());
});
await refreshAccounts(connection, [...toQuery.keys()]);
marketEmitter.raiseMarketUpdated(new Set([...marketByMint.keys()]));
// start update loop
updateData();
};
initalQuery();
return () => {
window.clearTimeout(timer);
};
}, [marketByMint, accountsToObserve, connection]);
const midPriceInUSD = useCallback(
(mintAddress: string) => {
return getMidPrice(
marketByMint.get(mintAddress)?.marketInfo.address.toBase58(),
mintAddress
);
},
[marketByMint]
);
const subscribeToMarket = useCallback(
(mintAddress: string) => {
const info = marketByMint.get(mintAddress);
const market = cache.get(info?.marketInfo.address.toBase58() || "");
if (!market) {
return () => {};
}
// TODO: get recent volume
const bid = market.info.bids.toBase58();
const ask = market.info.asks.toBase58();
accountsToObserve.set(bid, (accountsToObserve.get(bid) || 0) + 1);
accountsToObserve.set(ask, (accountsToObserve.get(ask) || 0) + 1);
// TODO: add event queue to query for last trade
return () => {
accountsToObserve.set(bid, (accountsToObserve.get(bid) || 0) - 1);
accountsToObserve.set(ask, (accountsToObserve.get(ask) || 0) - 1);
// cleanup
[...accountsToObserve.keys()].forEach((key) => {
if ((accountsToObserve.get(key) || 0) <= 0) {
accountsToObserve.delete(key);
}
});
};
},
[marketByMint, accountsToObserve]
);
return (
<MarketsContext.Provider
value={{
midPriceInUSD,
marketEmitter,
accountsToObserve,
marketByMint,
subscribeToMarket,
}}
>
{children}
</MarketsContext.Provider>
);
}
export const useMarkets = () => {
const context = useContext(MarketsContext);
return context as MarketsContextState;
};
export const useMidPriceInUSD = (mint: string) => {
const { midPriceInUSD, subscribeToMarket, marketEmitter } = useContext(
MarketsContext
) as MarketsContextState;
const [price, setPrice] = useState<number>(0);
useEffect(() => {
let subscription = subscribeToMarket(mint);
const update = () => {
if (midPriceInUSD) {
setPrice(midPriceInUSD(mint));
}
};
update();
const dispose = marketEmitter.onMarket(update);
return () => {
subscription();
dispose();
};
}, [midPriceInUSD, mint, marketEmitter, subscribeToMarket]);
return { price, isBase: price === 1.0 };
};
const OrderBookParser = (id: PublicKey, acc: AccountInfo<Buffer>) => {
const decoded = Orderbook.LAYOUT.decode(acc.data);
const details = {
pubkey: id,
account: {
...acc,
},
info: decoded,
} as ParsedAccountBase;
return details;
};
const getMidPrice = (marketAddress?: string, mintAddress?: string) => {
const SERUM_TOKEN = TOKEN_MINTS.find(
(a) => a.address.toBase58() === mintAddress
);
if (STABLE_COINS.has(SERUM_TOKEN?.name || "")) {
return 1.0;
}
if (!marketAddress) {
return 0.0;
}
const marketInfo = cache.get(marketAddress);
if (!marketInfo) {
return 0.0;
}
const decodedMarket = marketInfo.info;
const baseMintDecimals =
cache.get(decodedMarket.baseMint)?.info.decimals || 0;
const quoteMintDecimals =
cache.get(decodedMarket.quoteMint)?.info.decimals || 0;
const market = new Market(
decodedMarket,
baseMintDecimals,
quoteMintDecimals,
undefined,
decodedMarket.programId
);
const bids = cache.get(decodedMarket.bids)?.info;
const asks = cache.get(decodedMarket.asks)?.info;
if (bids && asks) {
const bidsBook = new Orderbook(market, bids.accountFlags, bids.slab);
const asksBook = new Orderbook(market, asks.accountFlags, asks.slab);
const bestBid = bidsBook.getL2(1);
const bestAsk = asksBook.getL2(1);
if (bestBid.length > 0 && bestAsk.length > 0) {
return (bestBid[0][0] + bestAsk[0][0]) / 2.0;
}
}
return 0;
};
const refreshAccounts = async (connection: Connection, keys: string[]) => {
if (keys.length === 0) {
return [];
}
return getMultipleAccounts(connection, keys, "single").then(
({ keys, array }) => {
return array.map((item, index) => {
const address = keys[index];
return cache.add(new PublicKey(address), item);
});
}
);
};
interface SerumMarket {
marketInfo: {
address: PublicKey;
name: string;
programId: PublicKey;
deprecated: boolean;
};
// 1st query
marketAccount?: AccountInfo<Buffer>;
// 2nd query
mintBase?: AccountInfo<Buffer>;
mintQuote?: AccountInfo<Buffer>;
bidAccount?: AccountInfo<Buffer>;
askAccount?: AccountInfo<Buffer>;
eventQueue?: AccountInfo<Buffer>;
swap?: {
dailyVolume: number;
};
midPrice?: (mint?: PublicKey) => number;
}

89
src/contexts/wallet.tsx Normal file
View File

@ -0,0 +1,89 @@
import React, { useContext, useEffect, useMemo, useState } from "react";
import Wallet from "@project-serum/sol-wallet-adapter";
import { notify } from "./../utils/notifications";
import { useConnectionConfig } from "./connection";
import { useLocalStorageState } from "./../utils/utils";
import { SolongAdapter } from "./../wallet-adapters/solong_adapter";
export const WALLET_PROVIDERS = [
{ name: "sollet.io", url: "https://www.sollet.io" },
{ name: "solongwallet.com", url: "http://solongwallet.com" },
{ name: "solflare.com", url: "https://solflare.com/access-wallet" },
];
const WalletContext = React.createContext<any>(null);
export function WalletProvider({ children = null as any }) {
const { endpoint } = useConnectionConfig();
const [providerUrl, setProviderUrl] = useLocalStorageState(
"walletProvider",
"https://www.sollet.io"
);
const wallet = useMemo(() => {
console.log("use new provider:", providerUrl, " endpoint:", endpoint);
if (providerUrl === "http://solongwallet.com") {
return new SolongAdapter(providerUrl, endpoint);
} else {
return new Wallet(providerUrl, endpoint);
}
}, [providerUrl, endpoint]);
const [connected, setConnected] = useState(false);
useEffect(() => {
console.log("trying to connect");
wallet.on("connect", () => {
console.log("connected");
setConnected(true);
let walletPublicKey = wallet.publicKey.toBase58();
let keyToDisplay =
walletPublicKey.length > 20
? `${walletPublicKey.substring(0, 7)}.....${walletPublicKey.substring(
walletPublicKey.length - 7,
walletPublicKey.length
)}`
: walletPublicKey;
notify({
message: "Wallet update",
description: "Connected to wallet " + keyToDisplay,
});
});
wallet.on("disconnect", () => {
setConnected(false);
notify({
message: "Wallet update",
description: "Disconnected from wallet",
});
});
return () => {
wallet.disconnect();
setConnected(false);
};
}, [wallet]);
return (
<WalletContext.Provider
value={{
wallet,
connected,
providerUrl,
setProviderUrl,
providerName:
WALLET_PROVIDERS.find(({ url }) => url === providerUrl)?.name ??
providerUrl,
}}
>
{children}
</WalletContext.Provider>
);
}
export function useWallet() {
const context = useContext(WalletContext);
return {
connected: context.connected,
wallet: context.wallet,
providerUrl: context.providerUrl,
setProvider: context.setProviderUrl,
providerName: context.providerName,
};
}

0
src/hooks/.keep Normal file
View File

13
src/index.css Normal file
View File

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}

16
src/index.tsx Normal file
View File

@ -0,0 +1,16 @@
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById("root")
);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

9
src/models/account.ts Normal file
View File

@ -0,0 +1,9 @@
import { AccountInfo, PublicKey } from "@solana/web3.js";
import { AccountInfo as TokenAccountInfo } from "@solana/spl-token";
export interface TokenAccount {
pubkey: PublicKey;
account: AccountInfo<Buffer>;
info: TokenAccountInfo;
}

1
src/models/index.ts Normal file
View File

@ -0,0 +1 @@
export * from "./account";

View File

@ -0,0 +1,2 @@
// use to override serum market to use specifc mint
export const MINT_TO_MARKET: { [key: string]: string } = {};

1
src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

27
src/routes.tsx Normal file
View File

@ -0,0 +1,27 @@
import { HashRouter, Route } from "react-router-dom";
import React from "react";
import { WalletProvider } from "./contexts/wallet";
import { ConnectionProvider } from "./contexts/connection";
import { AccountsProvider } from "./contexts/accounts";
import { MarketProvider } from "./contexts/market";
import { AppLayout } from "./components/Layout";
export function Routes() {
return (
<>
<HashRouter basename={"/"}>
<ConnectionProvider>
<WalletProvider>
<AccountsProvider>
<MarketProvider>
<AppLayout>
<Route exact path="/" />
</AppLayout>
</MarketProvider>
</AccountsProvider>
</WalletProvider>
</ConnectionProvider>
</HashRouter>
</>
);
}

146
src/serviceWorker.ts Normal file
View File

@ -0,0 +1,146 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === "localhost" ||
// [::1] is the IPv6 localhost address.
window.location.hostname === "[::1]" ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
type Config = {
onSuccess?: (registration: ServiceWorkerRegistration) => void;
onUpdate?: (registration: ServiceWorkerRegistration) => void;
};
export function register(config?: Config) {
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener("load", () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
"This web app is being served cache-first by a service " +
"worker. To learn more, visit https://bit.ly/CRA-PWA"
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === "installed") {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
"New content is available and will be used when all " +
"tabs for this page are closed. See https://bit.ly/CRA-PWA."
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log("Content is cached for offline use.");
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch((error) => {
console.error("Error during service worker registration:", error);
});
}
function checkValidServiceWorker(swUrl: string, config?: Config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { "Service-Worker": "script" },
})
.then((response) => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get("content-type");
if (
response.status === 404 ||
(contentType != null && contentType.indexOf("javascript") === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
"No internet connection found. App is running in offline mode."
);
});
}
export function unregister() {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.ready
.then((registration) => {
registration.unregister();
})
.catch((error) => {
console.error(error.message);
});
}
}

5
src/setupTests.ts Normal file
View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom/extend-expect";

9
src/types/buffer-layout.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
declare module "buffer-layout" {
const bl: any;
export = bl;
}
declare module "jazzicon" {
const jazzicon: any;
export = jazzicon;
}

4
src/types/sol-wallet-adapter.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module "@project-serum/sol-wallet-adapter" {
const adapter: any;
export = adapter;
}

41
src/utils/eventEmitter.ts Normal file
View File

@ -0,0 +1,41 @@
import { EventEmitter as Emitter } from "eventemitter3";
export class AccountUpdateEvent {
static type = "AccountUpdate";
id: string;
constructor(id: string) {
this.id = id;
}
}
export class MarketUpdateEvent {
static type = "MarketUpdate";
ids: Set<string>;
constructor(ids: Set<string>) {
this.ids = ids;
}
}
export class EventEmitter {
private emitter = new Emitter();
onMarket(callback: (args: MarketUpdateEvent) => void) {
this.emitter.on(MarketUpdateEvent.type, callback);
return () => this.emitter.removeListener(MarketUpdateEvent.type, callback);
}
onAccount(callback: (args: AccountUpdateEvent) => void) {
this.emitter.on(AccountUpdateEvent.type, callback);
return () => this.emitter.removeListener(AccountUpdateEvent.type, callback);
}
raiseAccountUpdated(id: string) {
this.emitter.emit(AccountUpdateEvent.type, new AccountUpdateEvent(id));
}
raiseMarketUpdated(ids: Set<string>) {
this.emitter.emit(MarketUpdateEvent.type, new MarketUpdateEvent(ids));
}
}

View File

@ -0,0 +1,33 @@
import React from "react";
import { notification } from "antd";
// import Link from '../components/Link';
export function notify({
message = "",
description = undefined as any,
txid = "",
type = "info",
placement = "bottomLeft",
}) {
if (txid) {
// <Link
// external
// to={'https://explorer.solana.com/tx/' + txid}
// style={{ color: '#0000ff' }}
// >
// View transaction {txid.slice(0, 8)}...{txid.slice(txid.length - 8)}
// </Link>
description = <></>;
}
(notification as any)[type]({
message: <span style={{ color: "black" }}>{message}</span>,
description: (
<span style={{ color: "black", opacity: 0.5 }}>{description}</span>
),
placement,
style: {
backgroundColor: "white",
},
});
}

150
src/utils/utils.ts Normal file
View File

@ -0,0 +1,150 @@
import { useCallback, useState } from "react";
import { MintInfo } from "@solana/spl-token";
import { TokenAccount } from "./../models";
export interface KnownToken {
tokenSymbol: string;
tokenName: string;
icon: string;
mintAddress: string;
}
export type KnownTokenMap = Map<string, KnownToken>;
export function useLocalStorageState(key: string, defaultState?: string) {
const [state, setState] = useState(() => {
// NOTE: Not sure if this is ok
const storedState = localStorage.getItem(key);
if (storedState) {
return JSON.parse(storedState);
}
return defaultState;
});
const setLocalStorageState = useCallback(
(newState) => {
const changed = state !== newState;
if (!changed) {
return;
}
setState(newState);
if (newState === null) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, JSON.stringify(newState));
}
},
[state, key]
);
return [state, setLocalStorageState];
}
// shorten the checksummed version of the input address to have 4 characters at start and end
export function shortenAddress(address: string, chars = 4): string {
return `${address.slice(0, chars)}...${address.slice(-chars)}`;
}
export function getTokenName(
map: KnownTokenMap,
mintAddress: string,
shorten = true
): string {
const knownSymbol = map.get(mintAddress)?.tokenSymbol;
if (knownSymbol) {
return knownSymbol;
}
return shorten ? `${mintAddress.substring(0, 5)}...` : mintAddress;
}
export function getTokenIcon(
map: KnownTokenMap,
mintAddress: string
): string | undefined {
return map.get(mintAddress)?.icon;
}
export function isKnownMint(map: KnownTokenMap, mintAddress: string) {
return !!map.get(mintAddress);
}
export const STABLE_COINS = new Set(["USDC", "wUSDC", "USDT"]);
export function chunks<T>(array: T[], size: number): T[][] {
return Array.apply<number, T[], T[][]>(
0,
new Array(Math.ceil(array.length / size))
).map((_, index) => array.slice(index * size, (index + 1) * size));
}
export function convert(
account?: TokenAccount | number,
mint?: MintInfo,
rate: number = 1.0
): number {
if (!account) {
return 0;
}
const amount =
typeof account === "number" ? account : account.info.amount?.toNumber();
const precision = Math.pow(10, mint?.decimals || 0);
return (amount / precision) * rate;
}
var SI_SYMBOL = ["", "k", "M", "G", "T", "P", "E"];
const abbreviateNumber = (number: number, precision: number) => {
let tier = (Math.log10(number) / 3) | 0;
let scaled = number;
let suffix = SI_SYMBOL[tier];
if (tier !== 0) {
let scale = Math.pow(10, tier * 3);
scaled = number / scale;
}
return scaled.toFixed(precision) + suffix;
};
const format = (val: number, precision: number, abbr: boolean) =>
abbr ? abbreviateNumber(val, precision) : val.toFixed(precision);
export function formatTokenAmount(
account?: TokenAccount,
mint?: MintInfo,
rate: number = 1.0,
prefix = "",
suffix = "",
precision = 6,
abbr = false
): string {
if (!account) {
return "";
}
return `${[prefix]}${format(
convert(account, mint, rate),
precision,
abbr
)}${suffix}`;
}
export const formatUSD = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
export const formatNumber = new Intl.NumberFormat("en-US", {
style: "decimal",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
export const formatPct = new Intl.NumberFormat("en-US", {
style: "percent",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});

0
src/views/.keep Normal file
View File

View File

@ -0,0 +1,59 @@
import EventEmitter from "eventemitter3";
import { PublicKey } from "@solana/web3.js";
import { notify } from "./../utils/notifications";
export class SolongAdapter extends EventEmitter {
_publicKey: any;
_onProcess: boolean;
constructor(providerUrl: string, network: string) {
super();
this._publicKey = null;
this._onProcess = false;
this.connect = this.connect.bind(this);
}
get publicKey() {
return this._publicKey;
}
async signTransaction(transaction: any) {
return (window as any).solong.signTransaction(transaction);
}
connect() {
if (this._onProcess) {
return;
}
if ((window as any).solong === undefined) {
notify({
message: "Solong Error",
description: "Please install solong wallet from Chrome ",
});
return;
}
this._onProcess = true;
console.log("solong helper select account");
(window as any).solong
.selectAccount()
.then((account: any) => {
this._publicKey = new PublicKey(account);
console.log("window solong select:", account, "this:", this);
this.emit("connect", this._publicKey);
})
.catch(() => {
this.disconnect();
})
.finally(() => {
this._onProcess = false;
});
}
disconnect() {
if (this._publicKey) {
this._publicKey = null;
this.emit("disconnect");
}
}
}

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"downlevelIteration": true,
"resolveJsonModule": true,
"noEmit": true,
"typeRoots": ["./types"],
"jsx": "react",
"isolatedModules": true
},
"include": ["src"]
}

12509
yarn.lock Normal file

File diff suppressed because it is too large Load Diff