diff --git a/.env.example b/.env.example index 60c0ec2..ad90e9d 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,11 @@ SERVICE_PUSH_NOTIFICATIONS= SERVICE_PUSHER_BEAMS_INSTANCE_ID= SERVICE_PUSHER_BEAMS_SECRET_KEY= +# WalletConnect Provider +WALLETCONNECT_PROJECT_ID= +WALLETCONNECT_PROJECT_SECRET= +WALLETCONNECT_NOTIFY_SERVER_URL=https://notify.walletconnect.com + # Sentry SENTRY_DSN= SENTRY_TRACE_SAMPLE_RATE= diff --git a/package.json b/package.json index ae2ce78..80889c2 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@snapshot-labs/prettier-config": "^0.1.0-beta.7", "@types/express": "^4.17.11", "@types/node": "^18.0.0", + "@types/node-fetch": "^2.6.6", "eslint": "^8.47.0", "prettier": "^2.8.0" }, diff --git a/src/api.ts b/src/api.ts index d809020..bf4e6af 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,7 @@ import express from 'express'; -import { sendEvent } from './providers/webhook'; +import { send } from './providers/walletconnectNotify'; import { capture } from '@snapshot-labs/snapshot-sentry'; +import { getSubscribers } from './helpers/utils'; const router = express.Router(); @@ -14,9 +15,22 @@ router.get('/test', async (req, res) => { expire: 1647343155 }; + const proposal = { + id: '0x45121903be7c520701d8d5536d2de29577367f2c84f39602026dc09ef1da8346', + title: 'Proposal to Redirect Multichain Warchest to CAKE Burn', + start: 1695376800, + end: 1695463200, + state: 'closed', + space: { + id: 'cakevote.eth', + name: 'PancakeSwap', + avatar: 'ipfs://bafkreidd4kzjvr5hfbcazj5jqpvd5vz2lj467uhl2i3ejdllafnlx4itcy' + } + }; + try { new URL(url); - await sendEvent(event, url, method); + await send(event, proposal, ['caip 10 address']); return res.json({ url, success: true }); } catch (e: any) { diff --git a/src/helpers/mysql.ts b/src/helpers/mysql.ts index 44003af..7763e74 100644 --- a/src/helpers/mysql.ts +++ b/src/helpers/mysql.ts @@ -18,6 +18,7 @@ config.acquireTimeout = 60e3; config.timeout = 60e3; config.charset = 'utf8mb4'; bluebird.promisifyAll([Pool, Connection]); + const db = mysql.createPool(config); export default db; diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts index dd45ea0..5d19011 100644 --- a/src/helpers/utils.ts +++ b/src/helpers/utils.ts @@ -22,6 +22,7 @@ export async function getSubscribers(space) { address: true } }; + try { const result = await snapshot.utils.subgraphRequest(`${HUB_URL}/graphql`, query); subscriptions = result.subscriptions || []; diff --git a/src/providers/index.ts b/src/providers/index.ts index 6be9a93..318afca 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,12 +1,14 @@ import { send as webhook } from './webhook'; +import { send as walletconnect } from './walletconnectNotify'; import { send as discord } from './discord'; import { send as beams } from './beams'; import { send as xmtp } from './xmtp'; export default [ // Comment a line to disable a provider - webhook, - discord, - beams, - xmtp + // webhook + // discord, + // beams, + // xmtp + walletconnect ]; diff --git a/src/providers/walletconnectNotify.ts b/src/providers/walletconnectNotify.ts new file mode 100644 index 0000000..3ce96af --- /dev/null +++ b/src/providers/walletconnectNotify.ts @@ -0,0 +1,161 @@ +import fetch from 'node-fetch'; +import { capture } from '@snapshot-labs/snapshot-sentry'; + +const WALLETCONNECT_NOTIFY_SERVER_URL = process.env.WALLETCONNECT_NOTIFY_SERVER_URL; +const WALLETCONNECT_PROJECT_SECRET = process.env.WALLETCONNECT_PROJECT_SECRET; +const WALLETCONNECT_PROJECT_ID = process.env.WALLETCONNECT_PROJECT_ID; + +const AUTH_HEADER = { + Authorization: WALLETCONNECT_PROJECT_SECRET ? `Bearer ${WALLETCONNECT_PROJECT_SECRET}` : '' +}; + +// Rate limiting numbers: +const MAX_ACCOUNTS_PER_REQUEST = 500; +const PER_SECOND_RATE_LIMIT = 2; +const WAIT_ERROR_MARGIN = 0.25; + +// Rate limiting logic: +async function wait(seconds: number) { + return new Promise(resolve => { + setTimeout(resolve, seconds * 1_000); + }); +} + +// Match Snapshot event names to notification types +// That should be defined in the wc-notify-config.json +function getNotificationType(event) { + if (event.includes('proposal/')) { + return 'ed2fd071-65e1-440d-95c5-7d58884eae43'; + } else { + return null; + } +} + +// Generate a notification body per the event +function getNotificationBody(event, space) { + switch (event) { + case 'proposal/create': + return `A new proposal has been created for ${space.name}`; + case 'proposal/end': + return `A proposal has closed for ${space.name}`; + default: + return null; + } +} + +// Fetch subscribers from WalletConnect Notify server +export async function getSubscribersFromWalletConnect() { + const fetchSubscribersUrl = `${WALLETCONNECT_NOTIFY_SERVER_URL}/${WALLETCONNECT_PROJECT_ID}/subscribers`; + + try { + const subscribersRs = await fetch(fetchSubscribersUrl, { + headers: AUTH_HEADER + }); + + const subscribers: string[] = await subscribersRs.json(); + + return subscribers; + } catch (e) { + capture('[WalletConnect] failed to fetch subscribers'); + return []; + } +} + +// Find the CAIP10 of subscribers, since the Notify API requires CAIP10. +async function crossReferenceSubscribers(space: { id: string }, spaceSubscribers) { + const subscribersFromDb = spaceSubscribers; + const subscribersFromWalletConnect = await getSubscribersFromWalletConnect(); + + // optimistically reserve all subscribers from the db + const crossReferencedSubscribers = new Array(subscribersFromDb.length); + + // Create a hashmap for faster lookup + const addressPrefixMap = new Map(); + for (const subscriber of subscribersFromWalletConnect) { + const unprefixedAddress = subscriber.split(':').pop(); + if (unprefixedAddress) { + addressPrefixMap.set(unprefixedAddress, subscriber); + } + } + + for (const subscriber of subscribersFromDb) { + const crossReferencedAddress = addressPrefixMap.get(subscriber); + if (crossReferencedAddress) { + crossReferencedSubscribers.push(crossReferencedAddress); + } + } + + // remove empty elements from the array, since some might not have been found in WalletConnect Notify server + return crossReferencedSubscribers.filter(addresses => addresses); +} + +async function queueNotificationsToSend(notification, accounts: string[]) { + for (let i = 0; i < accounts.length; i += MAX_ACCOUNTS_PER_REQUEST) { + await sendNotification(notification, accounts.slice(i, i + MAX_ACCOUNTS_PER_REQUEST)); + const waitTime = 1 / PER_SECOND_RATE_LIMIT + WAIT_ERROR_MARGIN; + await wait(waitTime); + } +} + +export async function sendNotification(notification, accounts) { + const notifyUrl = `${WALLETCONNECT_NOTIFY_SERVER_URL}/${WALLETCONNECT_PROJECT_ID}/notify`; + + const body = { + accounts, + notification + }; + + try { + const notifyRs = await fetch(notifyUrl, { + method: 'POST', + headers: { + ...AUTH_HEADER, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }); + + const notifySuccess = await notifyRs.json(); + + return notifySuccess; + } catch (e) { + capture('[WalletConnect] failed to notify subscribers', e); + } +} + +// Transform proposal event into notification format. +async function formatMessage(event, proposal) { + const space = proposal.space; + if (!space) return null; + + const notificationType = getNotificationType(event.event); + const notificationBody = getNotificationBody(event.event, space); + + if (!notificationType) { + capture(`[WalletConnect] could not get matching notification type for event ${event.event}`); + return; + } + + if (!notificationBody) { + capture(`[WalletConnect] could not get matching notification body for event ${event.event}`); + return; + } + + const url = new URL(proposal.link); + url.searchParams.append('app', 'walletconnect'); + + return { + title: proposal.title, + body: notificationBody, + url: url.toString(), + icon: space.avatar, + type: notificationType + }; +} + +export async function send(event, proposal, subscribers) { + const crossReferencedSubscribers = await crossReferenceSubscribers(proposal.space, subscribers); + const notificationMessage = formatMessage(event, proposal); + + await queueNotificationsToSend(notificationMessage, crossReferencedSubscribers); +} diff --git a/yarn.lock b/yarn.lock index 3874e5a..bc5dcf1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -753,6 +753,14 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== +"@types/node-fetch@^2.6.6": + version "2.6.6" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.6.tgz#b72f3f4bc0c0afee1c0bc9cff68e041d01e3e779" + integrity sha512-95X8guJYhfqiuVVhRFxVQcf4hW/2bCuoPwDasMf/531STFoNoWTT7YDnWdXHEZKqAGUigmpG31r2FE70LwnzJw== + dependencies: + "@types/node" "*" + form-data "^4.0.0" + "@types/node@*": version "20.4.7" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.4.7.tgz#74d323a93f1391a63477b27b9aec56669c98b2ab" @@ -1095,6 +1103,11 @@ async-mutex@^0.4.0: dependencies: tslib "^2.4.0" +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + available-typed-arrays@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" @@ -1261,6 +1274,13 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -1376,6 +1396,11 @@ define-properties@^1.1.3, define-properties@^1.1.4, define-properties@^1.2.0: has-property-descriptors "^1.0.0" object-keys "^1.1.1" +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + depd@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" @@ -1897,6 +1922,15 @@ for-each@^0.3.3: dependencies: is-callable "^1.1.3" +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -2524,7 +2558,7 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@~2.1.24, mime-types@~2.1.34: +mime-types@^2.1.12, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==