diff --git a/README.md b/README.md index e666227e..87086af8 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ npx -p @govtechsg/open-attestation-cli open-attestation | [Dns txt get](#dns-txt-record) | ❎ | ❎ | ❎ | | [Document store issue](#issue-document-to-document-store) | ✔ | ✔ | ✔ | | [Document store revoke](#revoke-document-in-document-store) | ✔ | ✔ | ✔ | +| [Document store grant ownership](#grant-role-on-document-store) | ✔ | ✔ | ✔ | +| [Document store revoke ownership](#revoke-role-on-document-store) | ✔ | ✔ | ✔ | | [Document store transfer ownership](#transfer-ownership-of-document-store) | ✔ | ✔ | ✔ | | [Token registry issue](#issue-document-to-token-registry) | ✔ | ✔ | ✔ | | [Token registry mint](#issue-document-to-token-registry) | ✔ | ✔ | ✔ | @@ -344,6 +346,42 @@ open-attestation document-store revoke --network sepolia --address 0x19f89607b52 ✔ success Document/Document Batch with hash 0x0c1a666aa55d17d26412bb57fbed96f40ec5a08e2f995a108faf45429ae3511f has been revoked on 0x19f89607b52268D0A19543e48F790c65750869c6 ``` +#### Grant role on document store + +Grant role on document store deployed on the blockchain to a wallet + +```bash +open-attestation document-store grant-role --address --account --role [options] +``` + +Roles options: "admin", "issuer", "revoker" + +Example - with private key set in `OA_PRIVATE_KEY` environment variable (recommended). [More options](#providing-the-wallet). + +```bash +open-attestation document-store grant-role --address 0x80732bF5CA47A85e599f3ac9572F602c249C8A28 --new-owner 0xf81ea9d2c0133de728d28b8d7f186bed61079997 --role admin --network sepolia + +✔ success Document store 0x80732bF5CA47A85e599f3ac9572F602c249C8A28's role of: admin has been granted to wallet 0xf81ea9d2c0133de728d28b8d7f186bed61079997 +``` + +#### Revoke role on document store + +Revoke role on document store deployed on the blockchain to a wallet + +Roles options: "admin", "issuer", "revoker" + +```bash +open-attestation document-store revoke-role --address --account --role [options] +``` + +Example - with private key set in `OA_PRIVATE_KEY` environment variable (recommended). [More options](#providing-the-wallet). + +```bash +open-attestation document-store revoke-role --address 0x80732bF5CA47A85e599f3ac9572F602c249C8A28 --new-owner 0xf81ea9d2c0133de728d28b8d7f186bed61079997 --role admin --network sepolia + +✔ success Document store 0x80732bF5CA47A85e599f3ac9572F602c249C8A28's role of: admin has been revoked from wallet 0xf81ea9d2c0133de728d28b8d7f186bed61079997 +``` + #### Transfer ownership of document store Transfer ownership of a document store deployed on the blockchain to another wallet diff --git a/package-lock.json b/package-lock.json index be37b8fe..41357e1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1897,9 +1897,9 @@ } }, "@govtechsg/document-store": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@govtechsg/document-store/-/document-store-2.4.0.tgz", - "integrity": "sha512-kbQaFzPdrGs3ZH2bPq9reX4bR1vRaytLf6UsgrYzrJiI43vLVAW3h0seNmR5kvbeauclUJI+hofG47kdtBrS6A==" + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@govtechsg/document-store/-/document-store-2.6.1.tgz", + "integrity": "sha512-Jr8dGyr8wBXMOSbUURoYfmz9jKiK2ntbjEYyI06pze6jzyiYLZXcepKVyjNk9PC/mLIlDCZgQWmre7ixBZWWjQ==" }, "@govtechsg/jsonld": { "version": "0.1.1", diff --git a/package.json b/package.json index 84f8ce04..341ed573 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ }, "dependencies": { "@govtechsg/dnsprove": "^2.3.0", - "@govtechsg/document-store": "^2.4.0", + "@govtechsg/document-store": "^2.6.1", "@govtechsg/oa-encryption": "^1.3.3", "@govtechsg/oa-verify": "^7.11.0", "@govtechsg/open-attestation": "^6.4.1", diff --git a/src/commands/document-store/document-store-command.type.ts b/src/commands/document-store/document-store-command.type.ts index a434ce6c..b9467f7e 100644 --- a/src/commands/document-store/document-store-command.type.ts +++ b/src/commands/document-store/document-store-command.type.ts @@ -17,3 +17,10 @@ export type DocumentStoreTransferOwnershipCommand = NetworkAndWalletSignerOption address: string; newOwner: string; }; + +export type DocumentStoreRoleCommand = NetworkAndWalletSignerOption & + GasOption & { + address: string; + account: string; + role: string; + }; diff --git a/src/commands/document-store/grant-role.ts b/src/commands/document-store/grant-role.ts new file mode 100644 index 00000000..27ee9cfe --- /dev/null +++ b/src/commands/document-store/grant-role.ts @@ -0,0 +1,57 @@ +import { Argv } from "yargs"; +import { error, info, success } from "signale"; +import { getLogger } from "../../logger"; +import { DocumentStoreRoleCommand } from "./document-store-command.type"; +import { grantDocumentStoreRole } from "../../implementations/document-store/grant-role"; +import { withGasPriceOption, withNetworkAndWalletSignerOption } from "../shared"; +import { getErrorMessage, getEtherscanAddress, addAddressPrefix } from "../../utils"; +import { rolesList } from "../../implementations/document-store/document-store-roles"; + +const { trace } = getLogger("document-store:grant-role"); + +export const command = "grant-role [options]"; + +export const describe = "grant role of the document store to a wallet"; + +export const builder = (yargs: Argv): Argv => + withGasPriceOption( + withNetworkAndWalletSignerOption( + yargs + .option("address", { + alias: "a", + description: "Address of document store to be granted role", + type: "string", + demandOption: true, + }) + .option("account", { + alias: ["h", "newOwner"], + description: "Address of wallet to transfer role to", + type: "string", + demandOption: true, + }) + .option("role", { + alias: "r", + description: "Role to be transferred", + type: "string", + options: rolesList, + demandOption: true, + }) + ) + ); + +export const handler = async (args: DocumentStoreRoleCommand): Promise => { + trace(`Args: ${JSON.stringify(args, null, 2)}`); + try { + info(`Granting role to wallet ${args.account}`); + const { transactionHash } = await grantDocumentStoreRole({ + ...args, + // add 0x automatically in front of the hash if it's not provided + account: addAddressPrefix(args.account), + }); + success(`Document store ${args.address}'s role of: ${args.role} has been granted to wallet ${args.account}`); + info(`Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transactionHash}`); + return args.address; + } catch (e) { + error(getErrorMessage(e)); + } +}; diff --git a/src/commands/document-store/revoke-role.ts b/src/commands/document-store/revoke-role.ts new file mode 100644 index 00000000..a70f1a7d --- /dev/null +++ b/src/commands/document-store/revoke-role.ts @@ -0,0 +1,57 @@ +import { Argv } from "yargs"; +import { error, info, success } from "signale"; +import { getLogger } from "../../logger"; +import { DocumentStoreRoleCommand } from "./document-store-command.type"; +import { revokeDocumentStoreRole } from "../../implementations/document-store/revoke-role"; +import { withGasPriceOption, withNetworkAndWalletSignerOption } from "../shared"; +import { getErrorMessage, getEtherscanAddress, addAddressPrefix } from "../../utils"; +import { rolesList } from "../../implementations/document-store/document-store-roles"; + +const { trace } = getLogger("document-store:revoke-role"); + +export const command = "revoke-role [options]"; + +export const describe = "revoke role of the document store to a wallet"; + +export const builder = (yargs: Argv): Argv => + withGasPriceOption( + withNetworkAndWalletSignerOption( + yargs + .option("address", { + alias: "a", + description: "Address of document store to be revoked role", + type: "string", + demandOption: true, + }) + .option("account", { + alias: ["h", "newOwner"], + description: "Address of wallet to revoke role from", + type: "string", + demandOption: true, + }) + .option("role", { + alias: "r", + description: "Role to be revoked", + type: "string", + options: rolesList, + demandOption: true, + }) + ) + ); + +export const handler = async (args: DocumentStoreRoleCommand): Promise => { + trace(`Args: ${JSON.stringify(args, null, 2)}`); + try { + info(`Revoking role from wallet ${args.account}`); + const { transactionHash } = await revokeDocumentStoreRole({ + ...args, + // add 0x automatically in front of the hash if it's not provided + account: addAddressPrefix(args.account), + }); + success(`Document store ${args.address}'s role of: ${args.role} has been revoked from wallet ${args.account}`); + info(`Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transactionHash}`); + return args.address; + } catch (e) { + error(getErrorMessage(e)); + } +}; diff --git a/src/commands/document-store/transfer-ownership.ts b/src/commands/document-store/transfer-ownership.ts index 06a0e254..cf811eca 100644 --- a/src/commands/document-store/transfer-ownership.ts +++ b/src/commands/document-store/transfer-ownership.ts @@ -2,9 +2,9 @@ import { Argv } from "yargs"; import { error, info, success } from "signale"; import { getLogger } from "../../logger"; import { DocumentStoreTransferOwnershipCommand } from "./document-store-command.type"; -import { transferDocumentStoreOwnershipToWallet } from "../../implementations/document-store/transfer-ownership"; import { withGasPriceOption, withNetworkAndWalletSignerOption } from "../shared"; import { getErrorMessage, getEtherscanAddress, addAddressPrefix } from "../../utils"; +import { transferDocumentStoreOwnership } from "../../implementations/document-store/transfer-ownership"; const { trace } = getLogger("document-store:transfer-ownership"); @@ -23,7 +23,7 @@ export const builder = (yargs: Argv): Argv => demandOption: true, }) .option("newOwner", { - alias: "h", + alias: ["h", "account"], description: "Address of new wallet to transfer ownership to", type: "string", demandOption: true, @@ -35,13 +35,27 @@ export const handler = async (args: DocumentStoreTransferOwnershipCommand): Prom trace(`Args: ${JSON.stringify(args, null, 2)}`); try { info(`Transferring ownership to wallet ${args.newOwner}`); - const { transactionHash } = await transferDocumentStoreOwnershipToWallet({ + const { address, newOwner } = args; + const { grantTransaction, revokeTransaction } = await transferDocumentStoreOwnership({ ...args, // add 0x automatically in front of the hash if it's not provided - newOwner: addAddressPrefix(args.newOwner), + newOwner: addAddressPrefix(newOwner), + address: addAddressPrefix(address), }); - success(`Ownership of document store ${args.address} has been transferred to new wallet ${args.newOwner}`); - info(`Find more details at ${getEtherscanAddress({ network: args.network })}/tx/${transactionHash}`); + + const grantTransactionHash = (await grantTransaction).transactionHash; + const revokeTransactionHash = (await revokeTransaction).transactionHash; + + success(`Ownership of document store ${args.address} has been transferred to wallet ${args.newOwner}`); + + info( + `Find more details at ${getEtherscanAddress({ + network: args.network, + })}/tx/${grantTransactionHash} (grant) and ${getEtherscanAddress({ + network: args.network, + })}/tx/${revokeTransactionHash} (revoke)` + ); + return args.address; } catch (e) { error(getErrorMessage(e)); diff --git a/src/implementations/document-store/document-store-roles.ts b/src/implementations/document-store/document-store-roles.ts new file mode 100644 index 00000000..6b20a500 --- /dev/null +++ b/src/implementations/document-store/document-store-roles.ts @@ -0,0 +1,16 @@ +import { DocumentStore } from "@govtechsg/document-store"; + +export const getRoleString = async (documentStore: DocumentStore, role: string): Promise => { + switch (role) { + case "admin": + return await documentStore.DEFAULT_ADMIN_ROLE(); + case "issuer": + return await documentStore.ISSUER_ROLE(); + case "revoker": + return await documentStore.REVOKER_ROLE(); + default: + throw new Error("Invalid role"); + } +}; + +export const rolesList = ["admin", "issuer", "revoker"]; diff --git a/src/implementations/document-store/grant-role.test.ts b/src/implementations/document-store/grant-role.test.ts new file mode 100644 index 00000000..0fbb4ac1 --- /dev/null +++ b/src/implementations/document-store/grant-role.test.ts @@ -0,0 +1,109 @@ +import { grantDocumentStoreRole } from "./grant-role"; + +import { Wallet } from "ethers"; +import { DocumentStoreFactory } from "@govtechsg/document-store"; +import { DocumentStoreRoleCommand } from "../../commands/document-store/document-store-command.type"; +import { addAddressPrefix } from "../../utils"; +import { join } from "path"; + +jest.mock("@govtechsg/document-store"); + +const deployParams: DocumentStoreRoleCommand = { + account: "0xabcd", + role: "issuer", + address: "0x1234", + network: "sepolia", + key: "0000000000000000000000000000000000000000000000000000000000000001", + dryRun: false, +}; + +// TODO the following test is very fragile and might break on every interface change of DocumentStoreFactory +// ideally must setup ganache, and run the function over it +describe("document-store", () => { + // increase timeout because ethers is throttling + jest.setTimeout(30000); + describe("grant document store issuer role to wallet", () => { + const mockedDocumentStoreFactory: jest.Mock = DocumentStoreFactory as any; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore mock static method + const mockedConnect: jest.Mock = mockedDocumentStoreFactory.connect; + const mockedGrantRole = jest.fn(); + const mockedCallStaticGrantRole = jest.fn().mockResolvedValue(undefined); + + beforeEach(() => { + delete process.env.OA_PRIVATE_KEY; + mockedDocumentStoreFactory.mockReset(); + mockedConnect.mockReset(); + mockedCallStaticGrantRole.mockClear(); + mockedConnect.mockReturnValue({ + grantRole: mockedGrantRole, + DEFAULT_ADMIN_ROLE: jest.fn().mockResolvedValue("ADMIN"), + ISSUER_ROLE: jest.fn().mockResolvedValue("ISSUER"), + REVOKER_ROLE: jest.fn().mockResolvedValue("REVOKER"), + callStatic: { + grantRole: mockedCallStaticGrantRole, + }, + }); + mockedGrantRole.mockReturnValue({ + hash: "hash", + wait: () => Promise.resolve({ transactionHash: "transactionHash" }), + }); + }); + it("should pass in the correct params and return the deployed instance", async () => { + const instance = await grantDocumentStoreRole(deployParams); + + const passedSigner: Wallet = mockedConnect.mock.calls[0][1]; + + expect(passedSigner.privateKey).toBe(`0x${deployParams.key}`); + expect(mockedConnect.mock.calls[0][0]).toEqual(deployParams.address); + expect(mockedCallStaticGrantRole).toHaveBeenCalledTimes(1); + expect(mockedGrantRole.mock.calls[0][0]).toEqual("ISSUER"); + expect(mockedGrantRole.mock.calls[0][1]).toEqual(deployParams.account); + expect(instance).toStrictEqual({ transactionHash: "transactionHash" }); + }); + + it("should accept account without 0x prefix and return deployed instance", async () => { + const instance = await grantDocumentStoreRole({ + ...deployParams, + account: addAddressPrefix("abcd"), + }); + + const passedSigner: Wallet = mockedConnect.mock.calls[0][1]; + + expect(passedSigner.privateKey).toBe(`0x${deployParams.key}`); + expect(mockedConnect.mock.calls[0][0]).toEqual(deployParams.address); + expect(mockedCallStaticGrantRole).toHaveBeenCalledTimes(1); + expect(mockedGrantRole.mock.calls[0][0]).toEqual("ISSUER"); + expect(mockedGrantRole.mock.calls[0][1]).toEqual(deployParams.account); + + expect(instance).toStrictEqual({ transactionHash: "transactionHash" }); + }); + + it("should take in the key from environment variable", async () => { + process.env.OA_PRIVATE_KEY = "0000000000000000000000000000000000000000000000000000000000000002"; + await grantDocumentStoreRole({ + account: "0xabcd", + address: "0x1234", + network: "sepolia", + dryRun: false, + role: "admin", + }); + + const passedSigner: Wallet = mockedConnect.mock.calls[0][1]; + expect(passedSigner.privateKey).toBe(`0x${process.env.OA_PRIVATE_KEY}`); + }); + it("should take in the key from key file", async () => { + await grantDocumentStoreRole({ + account: "0xabcd", + address: "0x1234", + network: "sepolia", + keyFile: join(__dirname, "..", "..", "..", "examples", "sample-key"), + dryRun: false, + role: "admin", + }); + + const passedSigner: Wallet = mockedConnect.mock.calls[0][1]; + expect(passedSigner.privateKey).toBe(`0x0000000000000000000000000000000000000000000000000000000000000003`); + }); + }); +}); diff --git a/src/implementations/document-store/grant-role.ts b/src/implementations/document-store/grant-role.ts new file mode 100644 index 00000000..4daa9367 --- /dev/null +++ b/src/implementations/document-store/grant-role.ts @@ -0,0 +1,37 @@ +import { DocumentStoreFactory } from "@govtechsg/document-store"; +import signale from "signale"; +import { getLogger } from "../../logger"; +import { DocumentStoreRoleCommand } from "../../commands/document-store/document-store-command.type"; +import { getWalletOrSigner } from "../utils/wallet"; +import { dryRunMode } from "../utils/dryRun"; +import { TransactionReceipt } from "@ethersproject/providers"; +import { getRoleString } from "./document-store-roles"; + +const { trace } = getLogger("document-store:transfer-ownership"); + +export const grantDocumentStoreRole = async ({ + address, + account, + network, + dryRun, + role, + ...rest +}: DocumentStoreRoleCommand): Promise => { + const wallet = await getWalletOrSigner({ network, ...rest }); + const documentStore = await DocumentStoreFactory.connect(address, wallet); + const roleString = await getRoleString(documentStore, role); + if (dryRun) { + await dryRunMode({ + estimatedGas: await documentStore.estimateGas.grantRole(roleString, account), + network, + }); + process.exit(0); + } + signale.await(`Sending transaction to pool`); + await documentStore.callStatic.grantRole(roleString, account); + const transaction = await documentStore.grantRole(roleString, account); + trace(`Tx hash: ${transaction.hash}`); + trace(`Block Number: ${transaction.blockNumber}`); + signale.await(`Waiting for transaction ${transaction.hash} to be mined`); + return transaction.wait(); +}; diff --git a/src/implementations/document-store/revoke-role.test.ts b/src/implementations/document-store/revoke-role.test.ts new file mode 100644 index 00000000..5c4e0d0e --- /dev/null +++ b/src/implementations/document-store/revoke-role.test.ts @@ -0,0 +1,109 @@ +import { revokeDocumentStoreRole } from "./revoke-role"; + +import { Wallet } from "ethers"; +import { DocumentStoreFactory } from "@govtechsg/document-store"; +import { DocumentStoreRoleCommand } from "../../commands/document-store/document-store-command.type"; +import { addAddressPrefix } from "../../utils"; +import { join } from "path"; + +jest.mock("@govtechsg/document-store"); + +const deployParams: DocumentStoreRoleCommand = { + account: "0xabcd", + role: "issuer", + address: "0x1234", + network: "sepolia", + key: "0000000000000000000000000000000000000000000000000000000000000001", + dryRun: false, +}; + +// TODO the following test is very fragile and might break on every interface change of DocumentStoreFactory +// ideally must setup ganache, and run the function over it +describe("document-store", () => { + // increase timeout because ethers is throttling + jest.setTimeout(30000); + describe("revoke document store issuer role to wallet", () => { + const mockedDocumentStoreFactory: jest.Mock = DocumentStoreFactory as any; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore mock static method + const mockedConnect: jest.Mock = mockedDocumentStoreFactory.connect; + const mockedRevokeRole = jest.fn(); + const mockedCallStaticRevokeRole = jest.fn().mockResolvedValue(undefined); + + beforeEach(() => { + delete process.env.OA_PRIVATE_KEY; + mockedDocumentStoreFactory.mockReset(); + mockedConnect.mockReset(); + mockedCallStaticRevokeRole.mockClear(); + mockedConnect.mockReturnValue({ + revokeRole: mockedRevokeRole, + DEFAULT_ADMIN_ROLE: jest.fn().mockResolvedValue("ADMIN"), + ISSUER_ROLE: jest.fn().mockResolvedValue("ISSUER"), + REVOKER_ROLE: jest.fn().mockResolvedValue("REVOKER"), + callStatic: { + revokeRole: mockedCallStaticRevokeRole, + }, + }); + mockedRevokeRole.mockReturnValue({ + hash: "hash", + wait: () => Promise.resolve({ transactionHash: "transactionHash" }), + }); + }); + it("should pass in the correct params and return the deployed instance", async () => { + const instance = await revokeDocumentStoreRole(deployParams); + + const passedSigner: Wallet = mockedConnect.mock.calls[0][1]; + + expect(passedSigner.privateKey).toBe(`0x${deployParams.key}`); + expect(mockedConnect.mock.calls[0][0]).toEqual(deployParams.address); + expect(mockedCallStaticRevokeRole).toHaveBeenCalledTimes(1); + expect(mockedRevokeRole.mock.calls[0][0]).toEqual("ISSUER"); + expect(mockedRevokeRole.mock.calls[0][1]).toEqual(deployParams.account); + expect(instance).toStrictEqual({ transactionHash: "transactionHash" }); + }); + + it("should accept account without 0x prefix and return deployed instance", async () => { + const instance = await revokeDocumentStoreRole({ + ...deployParams, + account: addAddressPrefix("abcd"), + }); + + const passedSigner: Wallet = mockedConnect.mock.calls[0][1]; + + expect(passedSigner.privateKey).toBe(`0x${deployParams.key}`); + expect(mockedConnect.mock.calls[0][0]).toEqual(deployParams.address); + expect(mockedCallStaticRevokeRole).toHaveBeenCalledTimes(1); + expect(mockedRevokeRole.mock.calls[0][0]).toEqual("ISSUER"); + expect(mockedRevokeRole.mock.calls[0][1]).toEqual(deployParams.account); + + expect(instance).toStrictEqual({ transactionHash: "transactionHash" }); + }); + + it("should take in the key from environment variable", async () => { + process.env.OA_PRIVATE_KEY = "0000000000000000000000000000000000000000000000000000000000000002"; + await revokeDocumentStoreRole({ + account: "0xabcd", + address: "0x1234", + network: "sepolia", + dryRun: false, + role: "admin", + }); + + const passedSigner: Wallet = mockedConnect.mock.calls[0][1]; + expect(passedSigner.privateKey).toBe(`0x${process.env.OA_PRIVATE_KEY}`); + }); + it("should take in the key from key file", async () => { + await revokeDocumentStoreRole({ + account: "0xabcd", + address: "0x1234", + network: "sepolia", + keyFile: join(__dirname, "..", "..", "..", "examples", "sample-key"), + dryRun: false, + role: "admin", + }); + + const passedSigner: Wallet = mockedConnect.mock.calls[0][1]; + expect(passedSigner.privateKey).toBe(`0x0000000000000000000000000000000000000000000000000000000000000003`); + }); + }); +}); diff --git a/src/implementations/document-store/revoke-role.ts b/src/implementations/document-store/revoke-role.ts new file mode 100644 index 00000000..7200d226 --- /dev/null +++ b/src/implementations/document-store/revoke-role.ts @@ -0,0 +1,37 @@ +import { DocumentStoreFactory } from "@govtechsg/document-store"; +import signale from "signale"; +import { getLogger } from "../../logger"; +import { DocumentStoreRoleCommand } from "../../commands/document-store/document-store-command.type"; +import { getWalletOrSigner } from "../utils/wallet"; +import { dryRunMode } from "../utils/dryRun"; +import { TransactionReceipt } from "@ethersproject/providers"; +import { getRoleString } from "./document-store-roles"; + +const { trace } = getLogger("document-store:transfer-ownership"); + +export const revokeDocumentStoreRole = async ({ + address, + account, + network, + dryRun, + role, + ...rest +}: DocumentStoreRoleCommand): Promise => { + const wallet = await getWalletOrSigner({ network, ...rest }); + const documentStore = await DocumentStoreFactory.connect(address, wallet); + const roleString = await getRoleString(documentStore, role); + if (dryRun) { + await dryRunMode({ + estimatedGas: await documentStore.estimateGas.revokeRole(roleString, account), + network, + }); + process.exit(0); + } + signale.await(`Sending transaction to pool`); + await documentStore.callStatic.revokeRole(roleString, account); + const transaction = await documentStore.revokeRole(roleString, account); + trace(`Tx hash: ${transaction.hash}`); + trace(`Block Number: ${transaction.blockNumber}`); + signale.await(`Waiting for transaction ${transaction.hash} to be mined`); + return transaction.wait(); +}; diff --git a/src/implementations/document-store/transfer-ownership.test.ts b/src/implementations/document-store/transfer-ownership.test.ts index 8bb9b758..e21e12ee 100644 --- a/src/implementations/document-store/transfer-ownership.test.ts +++ b/src/implementations/document-store/transfer-ownership.test.ts @@ -1,15 +1,15 @@ -import { transferDocumentStoreOwnershipToWallet } from "./transfer-ownership"; - import { Wallet } from "ethers"; import { DocumentStoreFactory } from "@govtechsg/document-store"; import { DocumentStoreTransferOwnershipCommand } from "../../commands/document-store/document-store-command.type"; import { addAddressPrefix } from "../../utils"; import { join } from "path"; +import { transferDocumentStoreOwnership } from "./transfer-ownership"; jest.mock("@govtechsg/document-store"); const deployParams: DocumentStoreTransferOwnershipCommand = { newOwner: "0xabcd", + // role: "issuer", address: "0x1234", network: "sepolia", key: "0000000000000000000000000000000000000000000000000000000000000001", @@ -21,44 +21,57 @@ const deployParams: DocumentStoreTransferOwnershipCommand = { describe("document-store", () => { // increase timeout because ethers is throttling jest.setTimeout(30000); - describe("transferDocumentStoreOwnershipToWallet", () => { + describe("transfer document store owner role to wallet", () => { const mockedDocumentStoreFactory: jest.Mock = DocumentStoreFactory as any; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore mock static method const mockedConnect: jest.Mock = mockedDocumentStoreFactory.connect; - const mockedTransfer = jest.fn(); - const mockCallStaticTransferOwnership = jest.fn().mockResolvedValue(undefined); + const mockedGrantRole = jest.fn(); + const mockedRevokeRole = jest.fn(); + const mockedCallStaticGrantRole = jest.fn().mockResolvedValue(undefined); + const mockedCallStaticRevokeRole = jest.fn().mockResolvedValue(undefined); beforeEach(() => { delete process.env.OA_PRIVATE_KEY; mockedDocumentStoreFactory.mockReset(); mockedConnect.mockReset(); - mockCallStaticTransferOwnership.mockClear(); + mockedCallStaticGrantRole.mockClear(); + mockedCallStaticRevokeRole.mockClear(); mockedConnect.mockReturnValue({ - transferOwnership: mockedTransfer, + grantRole: mockedGrantRole, + revokeRole: mockedRevokeRole, + DEFAULT_ADMIN_ROLE: jest.fn().mockResolvedValue("ADMIN"), callStatic: { - transferOwnership: mockCallStaticTransferOwnership, + grantRole: mockedCallStaticGrantRole, + revokeRole: mockedCallStaticRevokeRole, }, }); - mockedTransfer.mockReturnValue({ + mockedGrantRole.mockReturnValue({ + hash: "hash", + wait: () => Promise.resolve({ transactionHash: "transactionHash" }), + }); + mockedRevokeRole.mockReturnValue({ hash: "hash", wait: () => Promise.resolve({ transactionHash: "transactionHash" }), }); }); it("should pass in the correct params and return the deployed instance", async () => { - const instance = await transferDocumentStoreOwnershipToWallet(deployParams); + const instance = await transferDocumentStoreOwnership(deployParams); const passedSigner: Wallet = mockedConnect.mock.calls[0][1]; expect(passedSigner.privateKey).toBe(`0x${deployParams.key}`); expect(mockedConnect.mock.calls[0][0]).toEqual(deployParams.address); - expect(mockCallStaticTransferOwnership).toHaveBeenCalledTimes(1); - expect(mockedTransfer.mock.calls[0][0]).toEqual(deployParams.newOwner); - expect(instance).toStrictEqual({ transactionHash: "transactionHash" }); + expect(mockedCallStaticGrantRole).toHaveBeenCalledTimes(1); + expect(mockedGrantRole.mock.calls[0][0]).toEqual("ADMIN"); + expect(mockedGrantRole.mock.calls[0][1]).toEqual(deployParams.newOwner); + + expect(await instance.grantTransaction).toStrictEqual({ transactionHash: "transactionHash" }); + expect(await instance.revokeTransaction).toStrictEqual({ transactionHash: "transactionHash" }); }); - it("should accept newOwner without 0x prefix and return deployed instance", async () => { - const instance = await transferDocumentStoreOwnershipToWallet({ + it("should accept account without 0x prefix and return deployed instance", async () => { + const instance = await transferDocumentStoreOwnership({ ...deployParams, newOwner: addAddressPrefix("abcd"), }); @@ -67,14 +80,17 @@ describe("document-store", () => { expect(passedSigner.privateKey).toBe(`0x${deployParams.key}`); expect(mockedConnect.mock.calls[0][0]).toEqual(deployParams.address); - expect(mockCallStaticTransferOwnership).toHaveBeenCalledTimes(1); - expect(mockedTransfer.mock.calls[0][0]).toEqual(deployParams.newOwner); - expect(instance).toStrictEqual({ transactionHash: "transactionHash" }); + expect(mockedCallStaticGrantRole).toHaveBeenCalledTimes(1); + expect(mockedGrantRole.mock.calls[0][0]).toEqual("ADMIN"); + expect(mockedGrantRole.mock.calls[0][1]).toEqual(deployParams.newOwner); + + expect(await instance.grantTransaction).toStrictEqual({ transactionHash: "transactionHash" }); + expect(await instance.revokeTransaction).toStrictEqual({ transactionHash: "transactionHash" }); }); it("should take in the key from environment variable", async () => { process.env.OA_PRIVATE_KEY = "0000000000000000000000000000000000000000000000000000000000000002"; - await transferDocumentStoreOwnershipToWallet({ + await transferDocumentStoreOwnership({ newOwner: "0xabcd", address: "0x1234", network: "sepolia", @@ -85,7 +101,7 @@ describe("document-store", () => { expect(passedSigner.privateKey).toBe(`0x${process.env.OA_PRIVATE_KEY}`); }); it("should take in the key from key file", async () => { - await transferDocumentStoreOwnershipToWallet({ + await transferDocumentStoreOwnership({ newOwner: "0xabcd", address: "0x1234", network: "sepolia", diff --git a/src/implementations/document-store/transfer-ownership.ts b/src/implementations/document-store/transfer-ownership.ts index a9200ef5..5ccd52ba 100644 --- a/src/implementations/document-store/transfer-ownership.ts +++ b/src/implementations/document-store/transfer-ownership.ts @@ -1,36 +1,51 @@ import { DocumentStoreFactory } from "@govtechsg/document-store"; -import signale from "signale"; +import signale, { success, info } from "signale"; import { getLogger } from "../../logger"; import { DocumentStoreTransferOwnershipCommand } from "../../commands/document-store/document-store-command.type"; import { getWalletOrSigner } from "../utils/wallet"; import { dryRunMode } from "../utils/dryRun"; import { TransactionReceipt } from "@ethersproject/providers"; +import { getRoleString } from "./document-store-roles"; +import { BigNumber } from "ethers"; +import { getEtherscanAddress } from "../../utils"; const { trace } = getLogger("document-store:transfer-ownership"); -export const transferDocumentStoreOwnershipToWallet = async ({ +export const transferDocumentStoreOwnership = async ({ address, newOwner, network, dryRun, ...rest -}: DocumentStoreTransferOwnershipCommand): Promise => { +}: DocumentStoreTransferOwnershipCommand): Promise<{ + grantTransaction: Promise; + revokeTransaction: Promise; +}> => { const wallet = await getWalletOrSigner({ network, ...rest }); + const ownerAddress = await wallet.getAddress(); + const documentStore = await DocumentStoreFactory.connect(address, wallet); + const roleString = await getRoleString(documentStore, "admin"); if (dryRun) { - const documentStore = await DocumentStoreFactory.connect(address, wallet); + const grantRoleGas: BigNumber = await documentStore.estimateGas.grantRole(roleString, newOwner); + const revokeRoleGas: BigNumber = await documentStore.estimateGas.revokeRole(roleString, ownerAddress); await dryRunMode({ - estimatedGas: await documentStore.estimateGas.transferOwnership(newOwner), + estimatedGas: grantRoleGas.add(revokeRoleGas), network, }); process.exit(0); } - signale.await(`Sending transaction to pool`); - const documentStore = await DocumentStoreFactory.connect(address, wallet); - await documentStore.callStatic.transferOwnership(newOwner); - const transaction = await documentStore.transferOwnership(newOwner); - trace(`Tx hash: ${transaction.hash}`); - trace(`Block Number: ${transaction.blockNumber}`); - signale.await(`Waiting for transaction ${transaction.hash} to be mined`); - return transaction.wait(); + await documentStore.callStatic.grantRole(roleString, newOwner); + const grantTransaction = await documentStore.grantRole(roleString, newOwner); + success(`Document store ${address}'s ownership has been granted to wallet ${newOwner}`); + info(`Transaction details at: ${getEtherscanAddress({ network: network })}/tx/${grantTransaction.hash}`); + trace(`Tx hash: ${grantTransaction.hash}`); + trace(`Block Number: ${grantTransaction.blockNumber}`); + await documentStore.callStatic.revokeRole(roleString, ownerAddress); + const revokeTransaction = await documentStore.revokeRole(roleString, ownerAddress); + success(`Document store ${address}'s ownership has been revoked from wallet ${ownerAddress}`); + info(`Transaction details at: ${getEtherscanAddress({ network: network })}/tx/${revokeTransaction.hash}`); + trace(`Tx hash: ${revokeTransaction.hash}`); + trace(`Block Number: ${revokeTransaction.blockNumber}`); + return { revokeTransaction: revokeTransaction.wait(), grantTransaction: grantTransaction.wait() }; };