commit 3790ecd0d212329caea756b5c40cd91c0083545f Author: Evgeny Kuzyakov Date: Tue Aug 23 16:21:20 2022 -0700 Initial template based on wiki diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7d618c --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# 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* + +#IDE +.idea + +target +neardev + +# OS X +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/package.json b/package.json new file mode 100644 index 0000000..af31e89 --- /dev/null +++ b/package.json @@ -0,0 +1,66 @@ +{ + "name": "frontend", + "version": "0.1.0", + "homepage": "/", + "private": true, + "dependencies": { + "@testing-library/jest-dom": "^4.2.4", + "@testing-library/react": "^9.3.2", + "@testing-library/user-event": "^7.1.2", + "big.js": "^6.1.1", + "bn.js": "^5.1.1", + "bootstrap": "^5.0.1", + "bootstrap-icons": "^1.5.0", + "chart.js": "^3.3.2", + "chartjs-adapter-moment": "^1.0.0", + "collections": "^5.1.12", + "error-polyfill": "^0.1.2", + "local-storage": "^2.0.0", + "moment": "^2.29.1", + "near-api-js": "^0.45.1", + "node-sass": "^4.0.0", + "react": "^16.13.1", + "react-chartjs-2": "^3.0.3", + "react-compound-timer": "^1.2.0", + "react-datepicker": "^4.1.1", + "react-diff-viewer": "^3.1.1", + "react-dom": "^16.13.1", + "react-infinite-scroller": "^1.2.4", + "react-markdown": "^7.1.0", + "react-markdown-editor-lite": "^1.3.1", + "react-router-dom": "^5.2.0", + "react-scripts": "3.4.0", + "react-select": "^4.3.1", + "react-singleton-hook": "^3.1.1", + "react-tooltip": "^4.2.13", + "react-uuid": "^1.0.2", + "remark-gfm": "^3.0.1", + "swr": "^0.5.6", + "timeago-react": "^3.0.3" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "deploy": "yarn build" + }, + "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" + ] + }, + "devDependencies": { + "prettier": "^2.2.1" + } +} diff --git a/public/.nojekyll b/public/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/public/CNAME b/public/CNAME new file mode 100644 index 0000000..dd4395c --- /dev/null +++ b/public/CNAME @@ -0,0 +1 @@ +viewer diff --git a/public/_redirects b/public/_redirects new file mode 100644 index 0000000..ad37e2c --- /dev/null +++ b/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..a70196f Binary files /dev/null and b/public/favicon.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..87405e9 --- /dev/null +++ b/public/index.html @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + viewer + + + +
+ + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..b30609b --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "viewer", + "name": "viewer", + "icons": [ + { + "src": "favicon.png", + "sizes": "256x256", + "type": "image/png" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#333333", + "background_color": "#ffffff" +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/src/App.js b/src/App.js new file mode 100644 index 0000000..c73490f --- /dev/null +++ b/src/App.js @@ -0,0 +1,159 @@ +import React, { useCallback, useEffect, useState } from "react"; +import "error-polyfill"; +import "bootstrap-icons/font/bootstrap-icons.css"; +import "bootstrap/dist/js/bootstrap.bundle"; +import "./App.scss"; +import { BrowserRouter as Router, Link, Route, Switch } from "react-router-dom"; +import { NearConfig, TGas, useNearPromise } from "./data/near"; +import MainPage from "./pages/MainPage"; +import { OneNear } from "./data/utils"; +import { useAccount } from "./data/account"; + +export const refreshAllowanceObj = {}; + +function App(props) { + const [connected, setConnected] = useState(false); + const [signedIn, setSignedIn] = useState(false); + const [signedAccountId, setSignedAccountId] = useState(null); + + const _near = useNearPromise(); + const account = useAccount(); + + const requestSignIn = useCallback( + async (e) => { + e && e.preventDefault(); + const appTitle = "viewer"; + const near = await _near; + + await near.walletConnection.requestSignIn( + NearConfig.contractName, + appTitle + ); + return false; + }, + [_near] + ); + + const logOut = useCallback(async () => { + const near = await _near; + near.walletConnection.signOut(); + near.accountId = null; + setSignedIn(false); + setSignedAccountId(null); + }, [_near]); + + const donate = async (e) => { + e.preventDefault(); + await account.near.contract.donate( + {}, + TGas.mul(10).toFixed(), + OneNear.toFixed() + ); + }; + + const refreshAllowance = useCallback(async () => { + alert( + "You're out of access key allowance. Need sign in again to refresh it" + ); + await logOut(); + await requestSignIn(); + }, [logOut, requestSignIn]); + refreshAllowanceObj.refreshAllowance = refreshAllowance; + + useEffect(() => { + _near.then((near) => { + setSignedIn(!!near.accountId); + setSignedAccountId(near.accountId); + setConnected(true); + }); + }, [_near]); + + const passProps = { + refreshAllowance: () => refreshAllowance(), + signedAccountId, + signedIn, + connected, + }; + + const header = !connected ? ( +
+ Connecting...{" "} +
+ ) : signedIn ? ( +
+ + + +
+ ) : ( +
+ +
+ ); + + return ( +
+ + + + + + + + + +
+ ); +} + +export default App; diff --git a/src/App.scss b/src/App.scss new file mode 100644 index 0000000..f25f3fb --- /dev/null +++ b/src/App.scss @@ -0,0 +1,27 @@ +$primary: #333; + +@import "node_modules/bootstrap/scss/bootstrap"; + +body, html { + background: #fbfbfb; + -webkit-font-smoothing: antialiased; +} + +.pointer { + cursor: pointer; +} + +.flex-buttons { + display: flex; + flex-flow: row wrap; + + .btn { + flex: 1 1; + } +} + +.btn-success { + .text-secondary { + color: #fff !important; + }; +} diff --git a/src/data/account.js b/src/data/account.js new file mode 100644 index 0000000..42f8c58 --- /dev/null +++ b/src/data/account.js @@ -0,0 +1,51 @@ +import { singletonHook } from "react-singleton-hook"; +import { useEffect, useState } from "react"; +import { useNearPromise } from "./near"; +import { keysToCamel } from "./utils"; + +const defaultAccount = { + loading: true, + accountId: null, + state: null, + near: null, +}; + +const loadAccount = async (near, setAccount) => { + const accountId = near.accountId; + const account = { + loading: false, + accountId, + state: null, + near, + refresh: async () => await loadAccount(near, setAccount), + }; + if (accountId) { + const [rawAccount, state] = await Promise.all([ + near.contract.get_account({ + account_id: accountId, + }), + near.account.state(), + ]); + account.account = keysToCamel(rawAccount); + account.state = state; + } + + setAccount(account); +}; + +export const useAccount = singletonHook(defaultAccount, () => { + const [account, setAccount] = useState(defaultAccount); + const _near = useNearPromise(); + + useEffect(() => { + _near.then(async (near) => { + try { + await loadAccount(near, setAccount); + } catch (e) { + console.error(e); + } + }); + }, [_near]); + + return account; +}); diff --git a/src/data/near.js b/src/data/near.js new file mode 100644 index 0000000..4ad386d --- /dev/null +++ b/src/data/near.js @@ -0,0 +1,208 @@ +import * as nearAPI from "near-api-js"; +import { singletonHook } from "react-singleton-hook"; +import Big from "big.js"; +import { refreshAllowanceObj } from "../App"; +import { useEffect, useState } from "react"; + +export const TGas = Big(10).pow(12); +export const MaxGasPerTransaction = TGas.mul(300); +export const StorageCostPerByte = Big(10).pow(19); +export const TokenStorageDeposit = StorageCostPerByte.mul(125); +export const BridgeTokenStorageDeposit = StorageCostPerByte.mul(1250); + +export const randomPublicKey = nearAPI.utils.PublicKey.from( + "ed25519:8fWHD35Rjd78yeowShh9GwhRudRtLLsGCRjZtgPjAtw9" +); + +// const isLocalhost = window.location.hostname === "localhost"; + +export const IsMainnet = false; +const TestnetContract = "v0.social08.testnet"; +const TestNearConfig = { + networkId: "testnet", + nodeUrl: "https://rpc.testnet.near.org", + archivalNodeUrl: "https://rpc.testnet.internal.near.org", + contractName: TestnetContract, + walletUrl: "https://wallet.testnet.near.org", + storageCostPerByte: StorageCostPerByte, + wrapNearAccountId: "wrap.testnet", +}; +const MainnetContract = "thewiki.near"; +export const MainNearConfig = { + networkId: "mainnet", + nodeUrl: "https://rpc.mainnet.near.org", + archivalNodeUrl: "https://rpc.mainnet.internal.near.org", + contractName: MainnetContract, + walletUrl: "https://wallet.near.org", + storageCostPerByte: StorageCostPerByte, + wrapNearAccountId: "wrap.near", +}; + +export const NearConfig = IsMainnet ? MainNearConfig : TestNearConfig; +export const LsKey = NearConfig.contractName + ":v01:"; + +function wrapContract(account, contractId, options) { + const nearContract = new nearAPI.Contract(account, contractId, options); + const { viewMethods = [], changeMethods = [] } = options; + const contract = { + account, + contractId, + }; + viewMethods.forEach((methodName) => { + contract[methodName] = nearContract[methodName]; + }); + changeMethods.forEach((methodName) => { + contract[methodName] = async (...args) => { + try { + return await nearContract[methodName](...args); + } catch (e) { + const msg = e.toString(); + if (msg.indexOf("does not have enough balance") !== -1) { + return await refreshAllowanceObj.refreshAllowance(); + } + throw e; + } + }; + }); + return contract; +} + +async function _initNear() { + const keyStore = new nearAPI.keyStores.BrowserLocalStorageKeyStore(); + const nearConnection = await nearAPI.connect( + Object.assign({ deps: { keyStore } }, NearConfig) + ); + const _near = {}; + + _near.nearArchivalConnection = nearAPI.Connection.fromConfig({ + networkId: NearConfig.networkId, + provider: { + type: "JsonRpcProvider", + args: { url: NearConfig.archivalNodeUrl }, + }, + signer: { type: "InMemorySigner", keyStore }, + }); + + _near.keyStore = keyStore; + _near.nearConnection = nearConnection; + + _near.walletConnection = new nearAPI.WalletConnection( + nearConnection, + NearConfig.contractName + ); + _near.accountId = _near.walletConnection.getAccountId(); + _near.account = _near.walletConnection.account(); + + _near.contract = wrapContract(_near.account, NearConfig.contractName, { + viewMethods: [ + "storage_balance_of", + "get", + "get_num_accounts", + "get_accounts_paged", + ], + changeMethods: ["set", "grant_write_permission", "storage_deposit"], + }); + + _near.fetchBlockHash = async () => { + const block = await nearConnection.connection.provider.block({ + finality: "final", + }); + return nearAPI.utils.serialize.base_decode(block.header.hash); + }; + + _near.fetchBlockHeight = async () => { + const block = await nearConnection.connection.provider.block({ + finality: "final", + }); + return block.header.height; + }; + + _near.fetchNextNonce = async () => { + const accessKeys = await _near.account.getAccessKeys(); + return accessKeys.reduce( + (nonce, accessKey) => Math.max(nonce, accessKey.access_key.nonce + 1), + 1 + ); + }; + + _near.sendTransactions = async (items, callbackUrl) => { + let [nonce, blockHash] = await Promise.all([ + _near.fetchNextNonce(), + _near.fetchBlockHash(), + ]); + + const transactions = []; + let actions = []; + let currentReceiverId = null; + let currentTotalGas = Big(0); + items.push([null, null]); + items.forEach(([receiverId, action]) => { + const actionGas = + action && action.functionCall ? Big(action.functionCall.gas) : Big(0); + const newTotalGas = currentTotalGas.add(actionGas); + if ( + receiverId !== currentReceiverId || + newTotalGas.gt(MaxGasPerTransaction) + ) { + if (currentReceiverId !== null) { + transactions.push( + nearAPI.transactions.createTransaction( + _near.accountId, + randomPublicKey, + currentReceiverId, + nonce++, + actions, + blockHash + ) + ); + actions = []; + } + currentTotalGas = actionGas; + currentReceiverId = receiverId; + } else { + currentTotalGas = newTotalGas; + } + actions.push(action); + }); + return await _near.walletConnection.requestSignTransactions( + transactions, + callbackUrl + ); + }; + + _near.archivalViewCall = async (blockId, contractId, methodName, args) => { + args = args || {}; + const result = await _near.nearArchivalConnection.provider.query({ + request_type: "call_function", + account_id: contractId, + method_name: methodName, + args_base64: Buffer.from(JSON.stringify(args)).toString("base64"), + block_id: blockId, + }); + + return ( + result.result && + result.result.length > 0 && + JSON.parse(Buffer.from(result.result).toString()) + ); + }; + + return _near; +} + +const defaultNearPromise = Promise.resolve(_initNear()); +export const useNearPromise = singletonHook(defaultNearPromise, () => { + return defaultNearPromise; +}); + +const defaultNear = null; +export const useNear = singletonHook(defaultNear, () => { + const [near, setNear] = useState(defaultNear); + const _near = useNearPromise(); + + useEffect(() => { + _near.then(setNear); + }, [_near]); + + return near; +}); diff --git a/src/data/utils.js b/src/data/utils.js new file mode 100644 index 0000000..de6ccd4 --- /dev/null +++ b/src/data/utils.js @@ -0,0 +1,159 @@ +import Big from "big.js"; +import { + BridgeTokenStorageDeposit, + NearConfig, + TokenStorageDeposit, +} from "./near"; +import React from "react"; +import Timer from "react-compound-timer"; + +const MinAccountIdLen = 2; +const MaxAccountIdLen = 64; +const ValidAccountRe = /^(([a-z\d]+[-_])*[a-z\d]+\.)*([a-z\d]+[-_])*[a-z\d]+$/; +export const OneNear = Big(10).pow(24); +const AccountSafetyMargin = OneNear.div(2); + +export const Loading = ( +