diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..7bde554 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +worker: npm run notifier diff --git a/package.json b/package.json index 33ad46c..652bf84 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "format": "prettier --write .", "lint": "eslint . --ext ts --ext tsx --ext js --ext jsx", "test": "jest", - "test-all": "yarn lint && yarn type-check && yarn test" + "test-all": "yarn lint && yarn type-check && yarn test", + "notifier": "TS_NODE_PROJECT=./tsconfig.commonjs.json ts-node scripts/governance-notifier.ts" }, "lint-staged": { "*.@(ts|tsx|js|jsx)": [ @@ -31,15 +32,18 @@ "@project-serum/sol-wallet-adapter": "^0.2.0", "@solana/spl-token": "^0.1.3", "@solana/web3.js": "^1.5.0", + "axios": "^0.21.1", "immer": "^9.0.1", "moment": "^2.29.1", "moment-timezone": "^0.5.33", "next": "latest", "next-themes": "^0.0.14", + "node-fetch": "^2.6.1", "rc-slider": "^9.7.2", "react": "^17.0.1", "react-countdown": "^2.3.2", "react-dom": "^17.0.1", + "ts-node": "^10.2.1", "zustand": "^3.4.1" }, "devDependencies": { diff --git a/scripts/api.ts b/scripts/api.ts new file mode 100644 index 0000000..5c3ec52 --- /dev/null +++ b/scripts/api.ts @@ -0,0 +1,151 @@ +import { PublicKey } from '@solana/web3.js' +import * as bs58 from 'bs58' +import { + Realm, + GovernanceAccountType, + GovernanceAccount, + GovernanceAccountClass, +} from '../models/accounts' +import { RpcContext } from '../models/api' +import { ParsedAccount, GOVERNANCE_SCHEMA } from '../models/serialisation' +import { deserializeBorsh } from '../utils/borsh' + +const fetch = require('node-fetch') + +export interface IWallet { + publicKey: PublicKey +} + +export class MemcmpFilter { + offset: number + bytes: Buffer + + constructor(offset: number, bytes: Buffer) { + this.offset = offset + this.bytes = bytes + } + + isMatch(buffer: Buffer) { + if (this.offset + this.bytes.length > buffer.length) { + return false + } + + for (let i = 0; i < this.bytes.length; i++) { + if (this.bytes[i] !== buffer[this.offset + i]) return false + } + + return true + } +} + +export const pubkeyFilter = ( + offset: number, + pubkey: PublicKey | undefined | null +) => (!pubkey ? undefined : new MemcmpFilter(offset, pubkey.toBuffer())) + +export async function getRealms(rpcContext: RpcContext) { + return getGovernanceAccountsImpl( + rpcContext.programId, + rpcContext.endpoint, + Realm, + GovernanceAccountType.Realm + ) +} + +export async function getGovernanceAccounts( + programId: PublicKey, + endpoint: string, + accountClass: GovernanceAccountClass, + accountTypes: GovernanceAccountType[], + filters: MemcmpFilter[] = [] +) { + if (accountTypes.length === 1) { + return getGovernanceAccountsImpl( + programId, + endpoint, + accountClass, + accountTypes[0], + filters + ) + } + + const all = await Promise.all( + accountTypes.map((at) => + getGovernanceAccountsImpl( + programId, + endpoint, + accountClass, + at, + filters + ) + ) + ) + + return all.reduce((res, r) => ({ ...res, ...r }), {}) as Record< + string, + ParsedAccount + > +} + +async function getGovernanceAccountsImpl( + programId: PublicKey, + endpoint: string, + accountClass: GovernanceAccountClass, + accountType: GovernanceAccountType, + filters: MemcmpFilter[] = [] +) { + const getProgramAccounts = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'getProgramAccounts', + params: [ + programId.toBase58(), + { + commitment: 'single', + encoding: 'base64', + filters: [ + { + memcmp: { + offset: 0, + bytes: bs58.encode([accountType]), + }, + }, + ...filters.map((f) => ({ + memcmp: { offset: f.offset, bytes: bs58.encode(f.bytes) }, + })), + ], + }, + ], + }), + }) + const rawAccounts = (await getProgramAccounts.json())['result'] + const accounts: Record> = {} + + for (const rawAccount of rawAccounts) { + try { + const account = { + pubkey: new PublicKey(rawAccount.pubkey), + account: { + ...rawAccount.account, + data: [], // There is no need to keep the raw data around once we deserialize it into TAccount + }, + info: deserializeBorsh( + GOVERNANCE_SCHEMA, + accountClass, + Buffer.from(rawAccount.account.data[0], 'base64') + ), + } + + accounts[account.pubkey.toBase58()] = account + } catch (ex) { + console.error(`Can't deserialize ${accountClass}`, ex) + } + } + + return accounts +} diff --git a/scripts/governance-notifier.ts b/scripts/governance-notifier.ts new file mode 100644 index 0000000..554a921 --- /dev/null +++ b/scripts/governance-notifier.ts @@ -0,0 +1,107 @@ +import { PublicKey } from '@solana/web3.js' +import axios from 'axios' +import { RealmInfo } from '../@types/types' +import { getAccountTypes, Governance, Proposal } from '../models/accounts' +import { ParsedAccount } from '../models/serialisation' +import { ENDPOINTS } from '../stores/useWalletStore' +import { getGovernanceAccounts, pubkeyFilter } from './api' + +const fiveMinutesSeconds = 5 * 60 + +// run every 5 mins, checks if a mngo governance proposal just opened in the last 5 mins +// and notifies on WEBHOOK_URL +async function runNotifier() { + const nowInSeconds = new Date().getTime() / 1000 + + const REALMS: RealmInfo[] = [ + { + symbol: 'MNGO', + programId: new PublicKey('GqTPL6qRf5aUuqscLh8Rg2HTxPUXfhhAXDptTLhp1t2J'), + realmId: new PublicKey('DPiH3H3c7t47BMxqTxLsuPQpEC6Kne8GA9VXbxpnZxFE'), + }, + ] + + const CLUSTER = 'mainnet-beta' + const ENDPOINT = ENDPOINTS.find((e) => e.name === CLUSTER) + + const realmInfo = REALMS.find((r) => r.symbol === 'MNGO') + + const governances = await getGovernanceAccounts( + realmInfo.programId, + ENDPOINT.url, + Governance, + getAccountTypes(Governance), + [pubkeyFilter(1, realmInfo.realmId)] + ) + + const governanceIds = Object.keys(governances).map((k) => new PublicKey(k)) + + const proposalsByGovernance = await Promise.all( + governanceIds.map((governanceId) => { + return getGovernanceAccounts( + realmInfo.programId, + ENDPOINT.url, + Proposal, + getAccountTypes(Proposal), + [pubkeyFilter(1, governanceId)] + ) + }) + ) + + const proposals: { + [proposal: string]: ParsedAccount + } = Object.assign({}, ...proposalsByGovernance) + + const realmGovernances = Object.fromEntries( + Object.entries(governances).filter(([_k, v]) => + v.info.realm.equals(realmInfo.realmId) + ) + ) + + const realmProposals = Object.fromEntries( + Object.entries(proposals).filter(([_k, v]) => + Object.keys(realmGovernances).includes(v.info.governance.toBase58()) + ) + ) + + console.log(`- scanning all proposals`) + let countJustOpened = 0 + let countSkipped = 0 + for (const k in realmProposals) { + const proposal = realmProposals[k] + + if ( + // voting is closed + proposal.info.votingCompletedAt + ) { + countSkipped++ + continue + } + + if ( + // not yet signed i.e. only in draft + !proposal.info.signingOffAt + ) { + countSkipped++ + continue + } + + if ( + // proposal opened in last 5 mins + nowInSeconds - proposal.info.signingOffAt.toNumber() <= + fiveMinutesSeconds + ) { + countJustOpened++ + const msg = `--- ${proposal.info.name} proposal just opened for voting` + console.log(msg) + if (process.env.WEBHOOK_URL) { + axios.post(process.env.WEBHOOK_URL, { msg }) + } + } + } + console.log( + `-- countJustOpened: ${countJustOpened}, countSkipped: ${countSkipped}` + ) +} + +setInterval(runNotifier, fiveMinutesSeconds * 1000) diff --git a/tsconfig.commonjs.json b/tsconfig.commonjs.json new file mode 100644 index 0000000..2c7b284 --- /dev/null +++ b/tsconfig.commonjs.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs" + } +} diff --git a/yarn.lock b/yarn.lock index c234927..1861bcd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -321,6 +321,18 @@ exec-sh "^0.3.2" minimist "^1.2.0" +"@cspotcode/source-map-consumer@0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@cspotcode/source-map-consumer/-/source-map-consumer-0.8.0.tgz#33bf4b7b39c178821606f669bbc447a6a629786b" + integrity sha512-41qniHzTU8yAGbCp04ohlmSrZf8bkf/iJsl3V0dRGsQN/5GFfx+LbCSsCpp2gqrqjTVg/K6O8ycoV35JIwAzAg== + +"@cspotcode/source-map-support@0.6.1": + version "0.6.1" + resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.6.1.tgz#118511f316e2e87ee4294761868e254d3da47960" + integrity sha512-DX3Z+T5dt1ockmPdobJS/FAsQPW4V4SrWEhD2iYQT2Cb2tQsiMnYxrcUH9By/Z3B+v0S5LMBkQtV/XOBbpLEOg== + dependencies: + "@cspotcode/source-map-consumer" "0.8.0" + "@csstools/convert-colors@^1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@csstools/convert-colors/-/convert-colors-1.4.0.tgz#ad495dc41b12e75d588c6db8b9834f08fa131eb7" @@ -899,6 +911,26 @@ "@babel/runtime" "^7.12.5" "@testing-library/dom" "^7.28.1" +"@tsconfig/node10@^1.0.7": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.8.tgz#c1e4e80d6f964fbecb3359c43bd48b40f7cadad9" + integrity sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg== + +"@tsconfig/node12@^1.0.7": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.9.tgz#62c1f6dee2ebd9aead80dc3afa56810e58e1a04c" + integrity sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw== + +"@tsconfig/node14@^1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.1.tgz#95f2d167ffb9b8d2068b0b235302fafd4df711f2" + integrity sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg== + +"@tsconfig/node16@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e" + integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA== + "@types/aria-query@^4.2.0": version "4.2.1" resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.1.tgz#78b5433344e2f92e8b306c06a5622c50c245bf6b" @@ -1145,6 +1177,11 @@ acorn-walk@^7.0.0, acorn-walk@^7.1.1: version "7.2.0" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" +acorn-walk@^8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.1.1.tgz#3ddab7f84e4a7e2313f6c414c5b7dac85f4e3ebc" + integrity sha512-FbJdceMlPHEAWJOILDk1fXD8lnTlEIWFkqtfk+MvmL5q/qlHfN7GEHcsFZWt/Tea9jRNPWUZG4G976nqAAmU9w== + acorn@^7.0.0, acorn@^7.1.1, acorn@^7.4.0: version "7.4.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" @@ -1153,6 +1190,11 @@ acorn@^8.1.0: version "8.2.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.2.1.tgz#0d36af126fb6755095879c1dc6fd7edf7d60a5fb" +acorn@^8.4.1: + version "8.4.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.4.1.tgz#56c36251fc7cabc7096adc18f05afe814321a28c" + integrity sha512-asabaBSkEKosYKMITunzX177CXxQ4Q8BSSzMTKD+FefUhipQC70gfW5SiUDhYQ3vk8G+81HqQk7Fv9OXwwn9KA== + aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" @@ -1222,6 +1264,11 @@ anymatch@^3.0.3, anymatch@~3.1.1: normalize-path "^3.0.0" picomatch "^2.0.4" +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -1385,6 +1432,13 @@ aws4@^1.8.0: version "1.11.0" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" +axios@^0.21.1: + version "0.21.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8" + integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA== + dependencies: + follow-redirects "^1.10.0" + babel-jest@^26.6.3: version "26.6.3" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-26.6.3.tgz#d87d25cb0037577a0c89f82e5755c5d293c01056" @@ -2010,6 +2064,11 @@ create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7: safe-buffer "^5.0.1" sha.js "^2.4.8" +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + cross-spawn@^6.0.0: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" @@ -2243,6 +2302,11 @@ diff-sequences@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + diffie-hellman@^5.0.0: version "5.0.3" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" @@ -2790,6 +2854,11 @@ flatten@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.3.tgz#c1283ac9f27b368abc1e36d1ff7b04501a30356b" +follow-redirects@^1.10.0: + version "1.14.2" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.2.tgz#cecb825047c00f5e66b142f90fed4f515dec789b" + integrity sha512-yLR6WaE2lbF0x4K2qE2p9PEXKLDjUjnR/xmjS3wHAYxtlsI9MLLBJUZirAHKzUZDGLxje7w/cXR49WOUo4rbsA== + for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -4204,6 +4273,11 @@ make-dir@^3.0.0, make-dir@^3.0.2: dependencies: semver "^6.0.0" +make-error@^1.1.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + makeerror@1.0.x: version "1.0.11" resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c" @@ -6325,6 +6399,24 @@ traverse-chain@~0.1.0: resolved "https://registry.yarnpkg.com/traverse-chain/-/traverse-chain-0.1.0.tgz#61dbc2d53b69ff6091a12a168fd7d433107e40f1" integrity sha1-YdvC1Ttp/2CRoSoWj9fUMxB+QPE= +ts-node@^10.2.1: + version "10.2.1" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.2.1.tgz#4cc93bea0a7aba2179497e65bb08ddfc198b3ab5" + integrity sha512-hCnyOyuGmD5wHleOQX6NIjJtYVIO8bPP8F2acWkB4W06wdlkgyvJtubO/I9NkI88hCFECbsEgoLc0VNkYmcSfw== + dependencies: + "@cspotcode/source-map-support" "0.6.1" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + yn "3.1.1" + ts-pnp@^1.1.6: version "1.2.0" resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.2.0.tgz#a500ad084b0798f1c3071af391e65912c86bca92" @@ -6760,6 +6852,11 @@ yargs@^15.4.1: y18n "^4.0.0" yargs-parser "^18.1.2" +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"