Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add walletconnect provider #114

5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
18 changes: 16 additions & 2 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/helpers/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
1 change: 1 addition & 0 deletions src/helpers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || [];
Expand Down
10 changes: 6 additions & 4 deletions src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -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
];
161 changes: 161 additions & 0 deletions src/providers/walletconnectNotify.ts
Original file line number Diff line number Diff line change
@@ -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<void>(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';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this one is for new app

Suggested change
return 'ed2fd071-65e1-440d-95c5-7d58884eae43';
return 'f3c55113-06fb-47c7-87da-28ee21b9114a';

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes - please feel free to adjust the notification types per your cloud configuration

} else {
return null;
}
}

// Generate a notification body per the event
function getNotificationBody(event, space) {
switch (event) {
case 'proposal/create':
ChaituVR marked this conversation as resolved.
Show resolved Hide resolved
return `A new proposal has been created for ${space.name}`;
case 'proposal/end':
return `A proposal has closed for ${space.name}`;
Comment on lines +39 to +40
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I talked to team about this, better we should be jusing just 'proposal/start' instead of created/end

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 [];
}
Comment on lines +58 to +61
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we catching and suppressing this error? If we can't get subscribers then the functions that call this should fail as well, not proceed with an empty list.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the other providers like discord and xmtp don't throw and just end up being a noop so I did the same here

}

// 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<string, string>();
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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

accounts has a max length of 500, so we need to call /notify in a loop.

Also has a rate limit of 2 per second so you may need to add some artificial timing e.g. with date math and await new Promise(resolve => setTimeout(resolve, time))

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll refactor to fix this

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);
}
ChaituVR marked this conversation as resolved.
Show resolved Hide resolved
}

// Transform proposal event into notification format.
async function formatMessage(event, proposal) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async function formatMessage(event, proposal) {
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');
Comment on lines +144 to +145
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is resulting in https://snapshot.org/?app=walletconnect#/thanku.eth/proposal/0x8c2a67398c9912f7c2ba15a68caf94388ea98a88074079103d48e3eae83e2ea7 I think it is better to add string at the end of URL

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this is fine

Copy link
Contributor

@chris13524 chris13524 Jan 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the purpose of ?app=walletconnect @devceline ?

Reason for new URL was to properly parse and construct the string to properly use query params. If we appended at end of the URL it also wouldn't be a valid query param as it would actually be part of the fragment. What would the string at the end of the URL be used for?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would the string at the end of the URL be used for?

It is used by snapshot to know that someone voted from web3inbox, changed it to web3inbox in the other PR, hope it is fine


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);
}
36 changes: 35 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"

[email protected]:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
Expand Down Expand Up @@ -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==

[email protected]:
version "2.0.0"
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
Expand Down Expand Up @@ -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"

[email protected]:
version "0.2.0"
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
Expand Down Expand Up @@ -2524,7 +2558,7 @@ [email protected]:
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==
Expand Down