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

Introduce Polkassembly API integration #79

Merged
merged 23 commits into from
Jun 7, 2023
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ ACCOUNT_SEED="twelve words seperated by spaces which seeds the account controlle

APPROVERS_GH_ORG="paritytech"
APPROVERS_GH_TEAM="tip-bot-approvers"

POLKASSEMBLY_ENDPOINT="https://test.polkassembly.io/api/v1/"
1 change: 1 addition & 0 deletions helm/values-parity-prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ common:
APP_HOST: "https://substrate-tip-bot.parity-prod.parity.io"
APPROVERS_GH_ORG: "paritytech"
APPROVERS_GH_TEAM: "tip-bot-approvers"
POLKASSEMBLY_ENDPOINT: "https://api.polkassembly.io/api/v1/"
secrets:
WEBHOOK_SECRET: ref+vault://kv/gitlab/parity/mirrors/substrate-tip-bot/opstooling-parity-prod#WEBHOOK_SECRET
PRIVATE_KEY: ref+vault://kv/gitlab/parity/mirrors/substrate-tip-bot/opstooling-parity-prod#PRIVATE_KEY
Expand Down
1 change: 1 addition & 0 deletions helm/values-parity-stg.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ common:
DATABASE_NAME: "substrate-tip-bot"
APPROVERS_GH_ORG: "paritytech-stg"
APPROVERS_GH_TEAM: "tip-bot-approvers"
POLKASSEMBLY_ENDPOINT: "https://test.polkassembly.io/api/v1/"
secrets:
WEBHOOK_SECRET: ref+vault://kv/gitlab/parity/mirrors/substrate-tip-bot/opstooling-parity-stg#WEBHOOK_SECRET
PRIVATE_KEY: ref+vault://kv/gitlab/parity/mirrors/substrate-tip-bot/opstooling-parity-stg#PRIVATE_KEY
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
"@polkadot/api": "^10.7.1",
"@polkadot/util": "^12.2.1",
"@polkadot/util-crypto": "^12.2.1",
"ethers": "^5.7.2",
"node-fetch": "^2.6.11",
"opstooling-integrations": "https://github.com/paritytech/opstooling-integrations#v2.0.0",
"opstooling-js": "https://github.com/paritytech/opstooling-js#v0.0.14",
"probot": "^12.2.8",
Expand All @@ -40,6 +42,7 @@
"@resolritter/tsc-files": "^1.1.4",
"@types/jest": "^29.4.0",
"@types/node": "^16.10.3",
"@types/node-fetch": "^2",
"dotenv": "^16.0.1",
"eslint-plugin-prettier": "^4.1.0",
"jest": "^29.4.2",
Expand Down
5 changes: 4 additions & 1 deletion src/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ApplicationFunction, Probot, run } from "probot";

import { updateAllBalances, updateBalance } from "./balance";
import { addMetricsRoute, recordTip } from "./metrics";
import { Polkassembly } from "./polkassembly/polkassembly";
import { tipUser } from "./tip";
import { ContributorAccount, State, TipRequest, TipSize } from "./types";
import { formatTipSize, getTipSize, parseContributorAccount } from "./util";
Expand Down Expand Up @@ -140,11 +141,13 @@ const main: AsyncApplicationFunction = async (bot: Probot, { getRouter }) => {

await cryptoWaitReady();
const keyring = new Keyring({ type: "sr25519" });
const botTipAccount = keyring.addFromUri(envVar("ACCOUNT_SEED"));
const state: State = {
bot,
allowedGitHubOrg: envVar("APPROVERS_GH_ORG"),
allowedGitHubTeam: envVar("APPROVERS_GH_TEAM"),
botTipAccount: keyring.addFromUri(envVar("ACCOUNT_SEED")),
botTipAccount,
polkassembly: new Polkassembly(envVar("POLKASSEMBLY_ENDPOINT"), { type: "polkadot", keyringPair: botTipAccount }),
};

bot.log.info("Tip bot was loaded!");
Expand Down
49 changes: 49 additions & 0 deletions src/polkassembly/polkassembly-moonbase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import "@polkadot/api-augment";
import { Wallet } from "ethers";

import { Polkassembly } from "./polkassembly";

/**
* This is a test suite that uses the production (non-test) Polkassembly API,
* and a Moonbase Alpha testnet to perform a single test case that
* creates a referendum and edits the metadata on Polkassembly.
*
* Moonbase is an EVM-compatible chain, so we're using an Ethereum signer.
* Currently, it's the only testnet with OpenGov and Polkassembly support.
* Related: https://github.com/paritytech/substrate-tip-bot/issues/46
*
* The tests are mostly manual because the code doesn't support sending
* Ethereum-signed blockchain transactions (only Ethereum-signed Polkassembly API calls).
* Also, Moonbase Alpha doesn't have the tipper tracks.
*
* To run:
* 1. Create a Moonbase Alpha account
* 2. Fund it (upwards of 20 DEV are needed)
* 3. Manually (in polkadot.js.org/apps) create a preimage and a referendum.
* Use any tack, for example Root. Tipper tracks are not available.
* 4. Un-skip the test, and edit the variables below.
*/
describe("Polkassembly with production API and Moonbase Alpha testnet", () => {
let polkassembly: Polkassembly;
const moonbaseMnemonic: string | undefined = undefined; // Edit before running.
const manuallyCreatedReferendumId: number | undefined = undefined; // Edit before running

beforeAll(() => {
if (moonbaseMnemonic === undefined || manuallyCreatedReferendumId === undefined) {
throw new Error("Variables needed. Read description above.");
}
const wallet = Wallet.fromMnemonic(moonbaseMnemonic);
polkassembly = new Polkassembly("https://api.polkassembly.io/api/v1/", { type: "ethereum", wallet });
});

test.skip("Edits a metadata of an existing referendum", async () => {
await polkassembly.loginOrSignup();
await polkassembly.editPost("moonbase", {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
postId: manuallyCreatedReferendumId!,
proposalType: "referendums_v2",
content: `Just testing, feel free to vote nay.\nToday is ${new Date().toISOString()}`,
title: "A mock referendum",
});
});
});
91 changes: 91 additions & 0 deletions src/polkassembly/polkassembly.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import "@polkadot/api-augment";
import { Keyring } from "@polkadot/api";
import type { KeyringPair } from "@polkadot/keyring/types";
import { cryptoWaitReady, randomAsU8a } from "@polkadot/util-crypto";

import { Polkassembly } from "./polkassembly";

describe("Polkassembly with a test endpoint", () => {
let keyringPair: KeyringPair;
let polkassembly: Polkassembly;

beforeAll(async () => {
await cryptoWaitReady();
});

beforeEach(() => {
const keyring = new Keyring({ type: "sr25519" });
// A random account for every test.
keyringPair = keyring.addFromSeed(randomAsU8a(32));
polkassembly = new Polkassembly("https://test.polkassembly.io/api/v1/", { type: "polkadot", keyringPair });
});

test("Can produce a signature", async () => {
await polkassembly.signMessage("something");
});

test("We are not logged in initially", () => {
expect(polkassembly.loggedIn).toBeFalsy();
});

test("We cannot log in without signing up first", async () => {
await expect(() => polkassembly.login()).rejects.toThrowError(
"Please sign up prior to logging in with a web3 address",
);
});

test("Can sign up", async () => {
await polkassembly.signup();
expect(polkassembly.loggedIn).toBeTruthy();
});

test("Can log in and logout, having signed up", async () => {
await polkassembly.signup();
expect(polkassembly.loggedIn).toBeTruthy();

polkassembly.logout();
expect(polkassembly.loggedIn).toBeFalsy();

await polkassembly.login();
expect(polkassembly.loggedIn).toBeTruthy();
});

test("Cannot sign up on different networks with the same address", async () => {
await polkassembly.signup();
expect(polkassembly.loggedIn).toBeTruthy();

polkassembly.logout();
expect(polkassembly.loggedIn).toBeFalsy();

await expect(() => polkassembly.signup()).rejects.toThrowError(
"There is already an account associated with this address, you cannot sign-up with this address",
);
expect(polkassembly.loggedIn).toBeFalsy();
});

test("Login-or-signup handles it all", async () => {
expect(polkassembly.loggedIn).toBeFalsy();

// Will sign up.
await polkassembly.loginOrSignup();
expect(polkassembly.loggedIn).toBeTruthy();

// Won't throw an error when trying again.
await polkassembly.loginOrSignup();
expect(polkassembly.loggedIn).toBeTruthy();

// Can log out.
polkassembly.logout();
expect(polkassembly.loggedIn).toBeFalsy();

// Can log back in.
await polkassembly.loginOrSignup();
expect(polkassembly.loggedIn).toBeTruthy();
});

test("Can retrieve a last referendum number on a track", async () => {
const result = await polkassembly.getLastReferendumNumber("moonbase", 0);
expect(typeof result).toEqual("number");
expect(result).toBeGreaterThan(0);
});
});
143 changes: 143 additions & 0 deletions src/polkassembly/polkassembly.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { KeyringPair } from "@polkadot/keyring/types";
import { stringToU8a } from "@polkadot/util";
import { Wallet } from "ethers";
import fetch from "node-fetch";

const headers = { "Content-Type": "application/json" };

export class Polkassembly {
private token: string | undefined;

constructor(
private endpoint: string,
private signer: { type: "polkadot"; keyringPair: KeyringPair } | { type: "ethereum"; wallet: Wallet }, // Ethereum type is used for EVM chains.
) {}

public get loggedIn(): boolean {
return this.token !== undefined;
}

public get address(): string {
return this.signer.type === "polkadot" ? this.signer.keyringPair.address : this.signer.wallet.address;
}

public async signup(): Promise<void> {
if (this.loggedIn) return;
const signupStartResponse = await fetch(`${this.endpoint}/auth/actions/addressSignupStart`, {
headers,
method: "POST",
body: JSON.stringify({ address: this.address }),
});
if (!signupStartResponse.ok) {
throw new Error(await signupStartResponse.text());
}
const signupStartBody = (await signupStartResponse.json()) as { signMessage: string };

const signupResponse = await fetch(`${this.endpoint}/auth/actions/addressSignupConfirm`, {
headers,
method: "POST",
body: JSON.stringify({
address: this.address,
signature: await this.signMessage(signupStartBody.signMessage),
wallet: this.signer.type === "polkadot" ? "polkadot-js" : "metamask",
}),
});
if (!signupResponse.ok) {
throw new Error(await signupResponse.text());
}
const signupBody = (await signupResponse.json()) as { token: string };
if (!signupBody.token) {
throw new Error("Signup unsuccessful, the authentication token is missing.");
}
this.token = signupBody.token;
}

public async login(): Promise<void> {
if (this.loggedIn) return;

const loginStartResponse = await fetch(`${this.endpoint}/auth/actions/addressLoginStart`, {
headers,
method: "POST",
body: JSON.stringify({ address: this.address }),
});
if (!loginStartResponse.ok) {
throw new Error(await loginStartResponse.text());
}
const loginStartBody = (await loginStartResponse.json()) as { signMessage: string };

const loginResponse = await fetch(`${this.endpoint}/auth/actions/addressLogin`, {
headers,
method: "POST",
body: JSON.stringify({
address: this.address,
signature: await this.signMessage(loginStartBody.signMessage),
wallet: this.signer.type === "polkadot" ? "polkadot-js" : "metamask",
}),
});
if (!loginResponse.ok) {
throw new Error(await loginResponse.text());
}
const loginBody = (await loginResponse.json()) as { token: string };
if (!loginBody.token) {
throw new Error("Login unsuccessful, the authentication token is missing.");
}
this.token = loginBody.token;
Copy link
Contributor

Choose a reason for hiding this comment

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

may be worth adding some debug logs to login and signup?

}

public logout(): void {
this.token = undefined;
}

public async loginOrSignup(): Promise<void> {
try {
await this.login();
} catch (e) {
if ((e as Error).message.includes("Please sign up")) {
}
await this.signup();
rzadp marked this conversation as resolved.
Show resolved Hide resolved
}
}

public async editPost(
network: string,
opts: {
postId: number;
title: string;
content: string;
proposalType: "referendums_v2";
},
): Promise<void> {
if (!this.token) {
throw new Error("Not logged in.");
}
const response = await fetch(`${this.endpoint}/auth/actions/editPost`, {
headers: { ...headers, "x-network": network, authorization: `Bearer ${this.token}` },
method: "POST",
body: JSON.stringify(opts),
});
if (!response.ok) {
throw new Error(await response.text());
}
}

async getLastReferendumNumber(network: string, trackNo: number): Promise<number | undefined> {
const response = await fetch(
`${this.endpoint}/listing/on-chain-posts?proposalType=referendums_v2&trackNo=${trackNo}&sortBy=newest`,
{ headers: { ...headers, "x-network": network }, method: "POST", body: JSON.stringify({}) },
);
if (!response.ok) {
throw new Error(await response.text());
}
const body = (await response.json()) as { posts: { post_id: number }[] };
return body.posts[0]?.post_id;
}

public async signMessage(message: string): Promise<string> {
const messageInUint8Array = stringToU8a(message);
if (this.signer.type === "ethereum") {
return await this.signer.wallet.signMessage(message);
}
const signedMessage = this.signer.keyringPair.sign(messageInUint8Array);
return "0x" + Buffer.from(signedMessage).toString("hex");
}
}
7 changes: 7 additions & 0 deletions src/testUtil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createTestKeyring } from "@polkadot/keyring";
import { randomAsU8a } from "@polkadot/util-crypto";

export const randomAddress = (): string => createTestKeyring().addFromSeed(randomAsU8a(32)).address;

export const logMock: any = console.log.bind(console); // eslint-disable-line @typescript-eslint/no-explicit-any
logMock.error = console.error.bind(console);
6 changes: 3 additions & 3 deletions src/tip-opengov.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,10 @@ describe("E2E opengov tip", () => {
.vote(referendumId, { Standard: { balance: new BN(1_000_000), vote: { aye: true, conviction: 1 } } })
.signAndSend(alice, { nonce: -1 });

// Going to sleep for 4 minutes, waiting for the referendum voting, enactment, and treasury spend period.
await new Promise((res) => setTimeout(res, 4 * 60_000));
// Going to sleep for 5 minutes, waiting for the referendum voting, enactment, and treasury spend period.
await new Promise((res) => setTimeout(res, 5 * 60_000));

// At the end, the balance of the contributor should increase.
expect((await getUserBalance(tipRequest.contributor.account.address)).eqn(2 * 10 ** 12)).toBeTruthy();
expect((await getUserBalance(tipRequest.contributor.account.address)).eq(new BN("2000000000000"))).toBeTruthy();
});
});
Loading