diff --git a/.github/workflows/cd-iam-review.yml b/.github/workflows/cd-iam-review.yml index e34d2fd810..105d10f106 100644 --- a/.github/workflows/cd-iam-review.yml +++ b/.github/workflows/cd-iam-review.yml @@ -21,8 +21,6 @@ jobs: - name: Lerna Bootstrap run: lerna bootstrap - name: Run Tests - env: - RPC_URL: ${{ secrets.GOERLI_RPC_URL }} run: | yarn test:iam yarn test:identity diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e6f402ba9..df80a90875 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,5 @@ jobs: run: lerna bootstrap - name: Run Tests run: yarn test - env: - RPC_URL: ${{ secrets.GOERLI_RPC_URL }} - name: Run Linter run: yarn lint diff --git a/.github/workflows/manual-iam-staging.yml b/.github/workflows/manual-iam-staging.yml index 631930f372..943aa09fba 100644 --- a/.github/workflows/manual-iam-staging.yml +++ b/.github/workflows/manual-iam-staging.yml @@ -25,8 +25,6 @@ jobs: - name: Lerna Bootstrap run: lerna bootstrap - name: Run Tests - env: - RPC_URL: ${{ secrets.GOERLI_RPC_URL }} run: | yarn test:iam yarn test:identity diff --git a/app/__test-fixtures__/databaseStorageFixtures.ts b/app/__test-fixtures__/databaseStorageFixtures.ts index 8f72541b90..841946614e 100644 --- a/app/__test-fixtures__/databaseStorageFixtures.ts +++ b/app/__test-fixtures__/databaseStorageFixtures.ts @@ -39,6 +39,11 @@ export const ensStampFixture: Stamp = { credential, }; +export const pohStampFixture: Stamp = { + provider: "Poh", + credential, +}; + export const twitterStampFixture: Stamp = { provider: "Twitter", credential, diff --git a/app/__test-fixtures__/verifiableCredentialResults.ts b/app/__test-fixtures__/verifiableCredentialResults.ts index 75ee776e08..9d85035e75 100644 --- a/app/__test-fixtures__/verifiableCredentialResults.ts +++ b/app/__test-fixtures__/verifiableCredentialResults.ts @@ -36,3 +36,16 @@ export const SUCCESFUL_ENS_RESULT: VerifiableCredentialRecord = { challenge: credential, credential: credential, }; + +export const SUCCESFUL_POH_RESULT: VerifiableCredentialRecord = { + record: { + type: "Poh", + address: "0xcF323CE817E25b4F784bC1e14c9A99A525fDC50f", + version: "0.0.0", + poh: "Is registered", + }, + signature: + "0xbdbac10fdb0921e73df7575e47cbda484be550c36570bc146bed90c5dcb7435e64178cb263864f48af1ad6eeee1ee94c9a0794a3812ae861f8898a973233abea1c", + challenge: credential, + credential: credential, +}; diff --git a/app/__tests__/components/ProviderCards/PohCard.test.tsx b/app/__tests__/components/ProviderCards/PohCard.test.tsx new file mode 100644 index 0000000000..a833262a43 --- /dev/null +++ b/app/__tests__/components/ProviderCards/PohCard.test.tsx @@ -0,0 +1,199 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor, waitForElementToBeRemoved } from "@testing-library/react"; +import { PohCard } from "../../../components/ProviderCards"; + +import { UserContext, UserContextState } from "../../../context/userContext"; +import { mockAddress, mockWallet } from "../../../__test-fixtures__/onboardHookValues"; +import { STAMP_PROVIDERS } from "../../../config/providers"; +import { pohStampFixture } from "../../../__test-fixtures__/databaseStorageFixtures"; +import { SUCCESFUL_POH_RESULT } from "../../../__test-fixtures__/verifiableCredentialResults"; +import { fetchVerifiableCredential } from "@dpopp/identity/dist/commonjs"; + +jest.mock("@dpopp/identity/dist/commonjs", () => ({ + fetchVerifiableCredential: jest.fn(), +})); +jest.mock("../../../utils/onboard.ts"); + +const mockHandleConnection = jest.fn(); +const mockCreatePassport = jest.fn(); +const handleAddStamp = jest.fn(); +const mockUserContext: UserContextState = { + loggedIn: true, + passport: undefined, + isLoadingPassport: false, + allProvidersState: { + Poh: { + providerSpec: STAMP_PROVIDERS.Poh, + stamp: undefined, + }, + }, + handleAddStamp: handleAddStamp, + handleCreatePassport: mockCreatePassport, + handleConnection: mockHandleConnection, + address: mockAddress, + wallet: mockWallet, + signer: undefined, + walletLabel: mockWallet.label, +}; + +describe("when user has not verfied with PohProvider", () => { + it("should display a verification button", () => { + render( + + + + ); + + const verifyButton = screen.queryByRole("button", { + name: /Verify/, + }); + + expect(verifyButton).toBeInTheDocument(); + }); +}); + +describe("when user has verified with PohProvider", () => { + it("should display is verified", () => { + render( + + + + ); + + const verified = screen.queryByText(/Verified/); + + expect(verified).toBeInTheDocument(); + }); +}); + +describe("when the verify button is clicked", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("and when a successful POH result is returned", () => { + beforeEach(() => { + (fetchVerifiableCredential as jest.Mock).mockResolvedValue(SUCCESFUL_POH_RESULT); + }); + + it("the modal displays the verify button", async () => { + render( + + + + ); + + const initialVerifyButton = screen.queryByRole("button", { + name: /Verify/, + }); + + fireEvent.click(initialVerifyButton!); + + const verifyModal = await screen.findByRole("dialog"); + const verifyModalButton = screen.getByTestId("modal-verify"); + + expect(verifyModal).toBeInTheDocument(); + + await waitFor(() => { + expect(verifyModalButton).toBeInTheDocument(); + }); + }); + + it("clicking verify adds the stamp", async () => { + render( + + + + ); + + const initialVerifyButton = screen.queryByRole("button", { + name: /Verify/, + }); + + // Click verify button on ens card + fireEvent.click(initialVerifyButton!); + + // Wait to see the verify button on the modal + await waitFor(() => { + const verifyModalButton = screen.getByTestId("modal-verify"); + expect(verifyModalButton).toBeInTheDocument(); + }); + + const finalVerifyButton = screen.queryByRole("button", { + name: /Verify/, + }); + + // Click the verify button on modal + fireEvent.click(finalVerifyButton!); + + expect(handleAddStamp).toBeCalled(); + }); + + it("clicking cancel closes the modal and a stamp should not be added", async () => { + (fetchVerifiableCredential as jest.Mock).mockResolvedValue(SUCCESFUL_POH_RESULT); + render( + + + + ); + + const initialVerifyButton = screen.queryByRole("button", { + name: /Verify/, + }); + + fireEvent.click(initialVerifyButton!); + + // Wait to see the cancel button on the modal + let modalCancelButton: HTMLElement | null = null; + await waitFor(() => { + modalCancelButton = screen.queryByRole("button", { + name: /Cancel/, + }); + expect(modalCancelButton).toBeInTheDocument(); + }); + + fireEvent.click(modalCancelButton!); + + expect(handleAddStamp).not.toBeCalled(); + + await waitForElementToBeRemoved(modalCancelButton); + expect(modalCancelButton).not.toBeInTheDocument(); + }); + }); + + describe("and when a failed POH result is returned", () => { + it("modal displays a failed message", async () => { + (fetchVerifiableCredential as jest.Mock).mockRejectedValue("ERROR"); + render( + + + + ); + + const initialVerifyButton = screen.queryByRole("button", { + name: /Verify/, + }); + + fireEvent.click(initialVerifyButton!); + + const verifyModal = await screen.findByRole("dialog"); + const verifyModalText = screen.getByText("The Proof of Humanity Status for this address Is not Registered"); + + expect(verifyModal).toBeInTheDocument(); + + await waitFor(() => { + expect(verifyModalText).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/app/__tests__/pages/Dashboard.test.tsx b/app/__tests__/pages/Dashboard.test.tsx index ddd234df55..d4ab510bb1 100644 --- a/app/__tests__/pages/Dashboard.test.tsx +++ b/app/__tests__/pages/Dashboard.test.tsx @@ -29,6 +29,10 @@ const mockUserContext: UserContextState = { providerSpec: STAMP_PROVIDERS.Ens, stamp: undefined, }, + Poh: { + providerSpec: STAMP_PROVIDERS.Poh, + stamp: undefined, + }, Twitter: { providerSpec: STAMP_PROVIDERS.Twitter, stamp: undefined, diff --git a/app/components/CardList.tsx b/app/components/CardList.tsx index 9fb411082f..40f3d38f00 100644 --- a/app/components/CardList.tsx +++ b/app/components/CardList.tsx @@ -2,7 +2,7 @@ import React from "react"; // --- Identity Providers -import { GoogleCard, SimpleCard, EnsCard, TwitterCard } from "./ProviderCards"; +import { GoogleCard, SimpleCard, EnsCard, PohCard, TwitterCard } from "./ProviderCards"; export const CardList = (): JSX.Element => { return ( @@ -13,6 +13,7 @@ export const CardList = (): JSX.Element => { + diff --git a/app/components/ProviderCards/PohCard.tsx b/app/components/ProviderCards/PohCard.tsx new file mode 100644 index 0000000000..e5d7ddcf06 --- /dev/null +++ b/app/components/ProviderCards/PohCard.tsx @@ -0,0 +1,93 @@ +// --- React Methods +import React, { useContext, useState } from "react"; + +// --- Identity tools +import { fetchVerifiableCredential } from "@dpopp/identity/dist/commonjs"; + +// pull context +import { UserContext } from "../../context/userContext"; + +import { PROVIDER_ID, Stamp } from "@dpopp/types"; + +const iamUrl = process.env.NEXT_PUBLIC_DPOPP_IAM_URL || ""; + +// --- import components +import { Card } from "../Card"; +import { VerifyModal } from "../VerifyModal"; +import { useDisclosure } from "@chakra-ui/react"; + +const providerId: PROVIDER_ID = "Poh"; + +export default function PohCard(): JSX.Element { + const { address, signer, handleAddStamp, allProvidersState } = useContext(UserContext); + const { isOpen, onOpen, onClose } = useDisclosure(); + const [credentialResponse, SetCredentialResponse] = useState(undefined); + const [credentialResponseIsLoading, setCredentialResponseIsLoading] = useState(false); + const [pohVerified, SetPohVerified] = useState(undefined); + + const handleFetchCredential = (): void => { + setCredentialResponseIsLoading(true); + fetchVerifiableCredential( + iamUrl, + { + type: "Poh", + version: "0.0.0", + address: address || "", + proofs: { + valid: address ? "true" : "false", + }, + }, + signer as { signMessage: (message: string) => Promise } + ) + .then((verified: { record: any; credential: any }): void => { + SetPohVerified(verified.record?.poh); + SetCredentialResponse({ + provider: "Poh", + credential: verified.credential, + }); + }) + .catch((e: any): void => {}) + .finally((): void => { + setCredentialResponseIsLoading(false); + }); + }; + + const handleUserVerify = (): void => { + if (credentialResponse) { + handleAddStamp(credentialResponse); + } + onClose(); + }; + + const issueCredentialWidget = ( + <> + + {`The Proof of Humanity Status for this address ${pohVerified || "Is not Registered"}`}} + isLoading={credentialResponseIsLoading} + /> + + ); + + return ( + + ); +} diff --git a/app/components/ProviderCards/index.tsx b/app/components/ProviderCards/index.tsx index fbda710dbd..d44701625d 100644 --- a/app/components/ProviderCards/index.tsx +++ b/app/components/ProviderCards/index.tsx @@ -1,4 +1,5 @@ export { default as GoogleCard } from "./GoogleCard"; export { default as SimpleCard } from "./SimpleCard"; export { default as EnsCard } from "./EnsCard"; +export { default as PohCard } from "./PohCard"; export { default as TwitterCard } from "./TwitterCard"; diff --git a/app/config/providers.ts b/app/config/providers.ts index 4749e0e834..7fb8cd7fef 100644 --- a/app/config/providers.ts +++ b/app/config/providers.ts @@ -23,6 +23,10 @@ export const STAMP_PROVIDERS: Readonly = { name: "Ens", description: "Ens name", }, + Poh: { + name: "POH", + description: "Proof of Humanity", + }, Twitter: { name: "Twitter", description: "Twitter name", diff --git a/app/context/userContext.tsx b/app/context/userContext.tsx index 68bee8cef3..9198fb0f4c 100644 --- a/app/context/userContext.tsx +++ b/app/context/userContext.tsx @@ -38,6 +38,10 @@ const startingAllProvidersState: AllProvidersState = { providerSpec: STAMP_PROVIDERS.Ens, stamp: undefined, }, + Poh: { + providerSpec: STAMP_PROVIDERS.Poh, + stamp: undefined, + }, Twitter: { providerSpec: STAMP_PROVIDERS.Twitter, stamp: undefined, diff --git a/iam/__tests__/poh.test.ts b/iam/__tests__/poh.test.ts new file mode 100644 index 0000000000..25fdf50b2e --- /dev/null +++ b/iam/__tests__/poh.test.ts @@ -0,0 +1,70 @@ +// ---- Test subject +import { RequestPayload } from "@dpopp/types"; +import { PohProvider } from "../src/providers/poh"; + +const mockIsRegistered = jest.fn(); + +jest.mock("ethers", () => { + return { + Contract: jest.fn().mockImplementation(() => { + return { + isRegistered: mockIsRegistered, + }; + }), + }; +}); + +const MOCK_ADDRESS = "0x738488886dd94725864ae38252a90be1ab7609c7"; + +describe("Attempt verification", function () { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should return true for an address registered with proof of humanity", async () => { + mockIsRegistered.mockResolvedValueOnce(true); + const poh = new PohProvider(); + const verifiedPayload = await poh.verify({ + address: MOCK_ADDRESS, + } as RequestPayload); + + expect(mockIsRegistered).toBeCalledWith(MOCK_ADDRESS); + expect(verifiedPayload).toEqual({ + valid: true, + record: { + poh: "Is registered", + }, + }); + }); + + it("should return false for an address not registered with proof of humanity", async () => { + mockIsRegistered.mockResolvedValueOnce(false); + const UNREGISTERED_ADDRESS = "0xUNREGISTERED"; + + const poh = new PohProvider(); + const verifiedPayload = await poh.verify({ + address: UNREGISTERED_ADDRESS, + } as RequestPayload); + + expect(mockIsRegistered).toBeCalledWith(UNREGISTERED_ADDRESS); + expect(verifiedPayload).toEqual({ + valid: false, + }); + }); + + it("should return error response when isRegistered call errors", async () => { + mockIsRegistered.mockRejectedValueOnce("some error"); + const UNREGISTERED_ADDRESS = "0xUNREGISTERED"; + + const poh = new PohProvider(); + const verifiedPayload = await poh.verify({ + address: UNREGISTERED_ADDRESS, + } as RequestPayload); + + expect(mockIsRegistered).toBeCalledWith(UNREGISTERED_ADDRESS); + expect(verifiedPayload).toEqual({ + valid: false, + error: [JSON.stringify("some error")], + }); + }); +}); diff --git a/iam/src/index.ts b/iam/src/index.ts index 78f5a76941..24b869fdb2 100644 --- a/iam/src/index.ts +++ b/iam/src/index.ts @@ -34,6 +34,7 @@ import { SimpleProvider } from "./providers/simple"; import { GoogleProvider } from "./providers/google"; import { TwitterProvider } from "./providers/twitter"; import { EnsProvider } from "./providers/ens"; +import { PohProvider } from "./providers/poh"; // Initiate providers - new Providers should be registered in this array... const providers = new Providers([ @@ -42,6 +43,7 @@ const providers = new Providers([ new GoogleProvider(), new TwitterProvider(), new EnsProvider(), + new PohProvider(), ]); // create the app and run on port diff --git a/iam/src/providers/poh.ts b/iam/src/providers/poh.ts new file mode 100644 index 0000000000..0ca8f0fd94 --- /dev/null +++ b/iam/src/providers/poh.ts @@ -0,0 +1,69 @@ +// ----- Types +import type { Provider, ProviderOptions } from "../types"; +import type { RequestPayload, VerifiedPayload } from "@dpopp/types"; + +// ----- Ethers library +import { Contract } from "ethers"; +import { StaticJsonRpcProvider } from "@ethersproject/providers"; + +// Proof of humanity contract address +const POH_CONTRACT_ADDRESS = "0xC5E9dDebb09Cd64DfaCab4011A0D5cEDaf7c9BDb"; + +// Proof of humanity Contract ABI +const POH_ABI = [ + { + constant: true, + inputs: [{ internalType: "address", name: "_submissionID", type: "address" }], + name: "isRegistered", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + payable: false, + stateMutability: "view", + type: "function", + }, +]; + +// set the network rpc url based on env +export const RPC_URL = process.env.RPC_URL; + +// Export a Poh Provider to carry out Proof of Humanity account is registered and active check and return a record object +export class PohProvider implements Provider { + // Give the provider a type so that we can select it with a payload + type = "Poh"; + // Options can be set here and/or via the constructor + _options = {}; + + // construct the provider instance with supplied options + constructor(options: ProviderOptions = {}) { + this._options = { ...this._options, ...options }; + } + + // verify that the proof object contains valid === "true" + async verify(payload: RequestPayload): Promise { + const { address } = payload; + try { + // define a provider using the rpc url + const provider: StaticJsonRpcProvider = new StaticJsonRpcProvider(RPC_URL); + + // load Proof of humanity contract + const readContract = new Contract(POH_CONTRACT_ADDRESS, POH_ABI, provider); + + // Checks to see if the address is registered with proof of humanity + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const valid: boolean = await readContract.isRegistered(address); + + return { + valid, + record: valid + ? { + poh: "Is registered", + } + : undefined, + }; + } catch (e) { + return { + valid: false, + error: [JSON.stringify(e)], + }; + } + } +} diff --git a/types/src/index.d.ts b/types/src/index.d.ts index 498d5046af..349b25f7b6 100644 --- a/types/src/index.d.ts +++ b/types/src/index.d.ts @@ -119,4 +119,4 @@ export type Passport = { // Passport DID export type DID = string; -export type PROVIDER_ID = "Google" | "Simple" | "Ens" | "Twitter"; +export type PROVIDER_ID = "Google" | "Simple" | "Ens" | "Poh" | "Twitter";