diff --git a/src/commands/config/create.ts b/src/commands/config/create.ts index 2eefa4ad..c95d51ce 100644 --- a/src/commands/config/create.ts +++ b/src/commands/config/create.ts @@ -6,6 +6,7 @@ import { create } from "../../implementations/config/create"; import { getLogger } from "../../logger"; import { highlight } from "../../utils"; import { supportedNetwork } from "../networks"; +import { withWalletOption } from "../shared"; import { CreateConfigCommand } from "./config.type"; const { trace } = getLogger("config:create"); @@ -15,20 +16,14 @@ export const command = "create [options]"; export const describe = "Create a config file"; export const builder = (yargs: Argv): Argv => - yargs - .option("output-dir", { + withWalletOption( + yargs.option("output-dir", { alias: "od", description: "Write output to a directory", type: "string", demandOption: true, }) - // encrypted wallet path is referenced from command.shared.ts as we need additional properties for this instance. - .option("encrypted-wallet-path", { - type: "string", - description: "Path to wallet.json file", - normalize: true, - demandOption: true, - }); + ); export const handler = async (args: CreateConfigCommand): Promise => { trace(`Args: ${JSON.stringify(args, null, 2)}`); diff --git a/src/implementations/deploy/token-registry/token-registry.test.ts b/src/implementations/deploy/token-registry/token-registry.test.ts index a5db0946..290558d7 100644 --- a/src/implementations/deploy/token-registry/token-registry.test.ts +++ b/src/implementations/deploy/token-registry/token-registry.test.ts @@ -12,77 +12,78 @@ const deployParams: DeployTokenRegistryCommand = { dryRun: false, }; -describe("deploy Token Registry", () => { - const mockedEthersContract: jest.Mock = Contract as any; - // eslint-disable-next-line jest/prefer-spy-on - mockedEthersContract.prototype.deploy = jest.fn(); - const mockedDeploy: jest.Mock = mockedEthersContract.prototype.deploy; +describe("token-registry", () => { + describe("deployTokenRegistry", () => { + const mockedEthersContract: jest.Mock = Contract as any; + // eslint-disable-next-line jest/prefer-spy-on + mockedEthersContract.prototype.deploy = jest.fn(); + const mockedDeploy: jest.Mock = mockedEthersContract.prototype.deploy; - // increase timeout because ethers is throttling - jest.setTimeout(30000); + // increase timeout because ethers is throttling + jest.setTimeout(30000); - beforeEach(() => { - mockedDeploy.mockReset(); - mockedDeploy.mockResolvedValue({ - hash: "hash", - blockNumber: "blockNumber", - wait: () => - Promise.resolve({ - events: [ - { - topics: [ - "0x3588ebb5c75fdf91927f8472318f41513ee567c2612a5ce52ac840dcf6f162f5", // deployment - "0x000000000000000000000000426c58c2b29111eafc53bdcb9c99dc7714fdb262", - "0x000000000000000000000000e5c75026d5f636c89cc77583b6bce7c99f512763", - "0x0000000000000000000000008d366250a96debe81c8619459a503a0eebe33ca6", - ], - data: "0x000000000000000000000000878a327daa390bc602ae259d3a374610356b6485000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000008d366250a96debe81c8619459a503a0eebe33ca60000000000000000000000000000000000000000000000000000000000000011563420546f6b656e20526567697374727900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000034d54540000000000000000000000000000000000000000000000000000000000", - args: [ - "0xd6C249d0756059E21Ef4Aef4711B69b76927BEA7", - "0xC78BA1a49663Ef8b920F36B036E91Ab40D8F26D6", - "0x8d366250A96deBE81C8619459a503a0eEBE33ca6", - "0x878A327daA390Bc602Ae259D3A374610356b6485", - "0x000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000008d366250a96debe81c8619459a503a0eebe33ca60000000000000000000000000000000000000000000000000000000000000011563420546f6b656e20526567697374727900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000034d54540000000000000000000000000000000000000000000000000000000000", - ] as unknown as DeploymentEvent, - }, - ], - }), + beforeEach(() => { + mockedDeploy.mockReset(); + mockedDeploy.mockResolvedValue({ + hash: "hash", + blockNumber: "blockNumber", + wait: () => + Promise.resolve({ + events: [ + { + topics: [ + "0x3588ebb5c75fdf91927f8472318f41513ee567c2612a5ce52ac840dcf6f162f5", // deployment + "0x000000000000000000000000426c58c2b29111eafc53bdcb9c99dc7714fdb262", + "0x000000000000000000000000e5c75026d5f636c89cc77583b6bce7c99f512763", + "0x0000000000000000000000008d366250a96debe81c8619459a503a0eebe33ca6", + ], + data: "0x000000000000000000000000878a327daa390bc602ae259d3a374610356b6485000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000008d366250a96debe81c8619459a503a0eebe33ca60000000000000000000000000000000000000000000000000000000000000011563420546f6b656e20526567697374727900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000034d54540000000000000000000000000000000000000000000000000000000000", + args: [ + "0xd6C249d0756059E21Ef4Aef4711B69b76927BEA7", + "0xC78BA1a49663Ef8b920F36B036E91Ab40D8F26D6", + "0x8d366250A96deBE81C8619459a503a0eEBE33ca6", + "0x878A327daA390Bc602Ae259D3A374610356b6485", + "0x000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000008d366250a96debe81c8619459a503a0eebe33ca60000000000000000000000000000000000000000000000000000000000000011563420546f6b656e20526567697374727900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000034d54540000000000000000000000000000000000000000000000000000000000", + ] as unknown as DeploymentEvent, + }, + ], + }), + }); }); - }); - it("should pass in the correct params and return the deployed instance", async () => { - await deployTokenRegistry(deployParams); + it("should pass in the correct params and return the deployed instance", async () => { + await deployTokenRegistry(deployParams); - const expectedInitParams = encodeInitParams({ - name: deployParams.registryName, - symbol: deployParams.registrySymbol, - deployer: "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", - }); + const expectedInitParams = encodeInitParams({ + name: deployParams.registryName, + symbol: deployParams.registrySymbol, + deployer: "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", + }); - expect(mockedDeploy.mock.calls[0][0]).toEqual("0xC78BA1a49663Ef8b920F36B036E91Ab40D8F26D6"); - expect(mockedDeploy.mock.calls[0][1]).toEqual(expectedInitParams); + expect(mockedDeploy.mock.calls[0][0]).toEqual("0xC78BA1a49663Ef8b920F36B036E91Ab40D8F26D6"); + expect(mockedDeploy.mock.calls[0][1]).toEqual(expectedInitParams); - // price should be any length string of digits - // expect(mockedDeploy.mock.calls[0][2].gasPrice.toString()).toStrictEqual(expect.stringMatching(/\d+/)); - // expect(instance.contractAddress).toBe("contractAddress"); // TODO - }); + // price should be any length string of digits + // expect(mockedDeploy.mock.calls[0][2].gasPrice.toString()).toStrictEqual(expect.stringMatching(/\d+/)); + // expect(instance.contractAddress).toBe("contractAddress"); // TODO + }); - it("should allow errors to bubble up", async () => { - mockedDeploy.mockRejectedValue(new Error("An Error")); - await expect(deployTokenRegistry(deployParams)).rejects.toThrow("An Error"); - }); + it("should allow errors to bubble up", async () => { + mockedDeploy.mockRejectedValue(new Error("An Error")); + await expect(deployTokenRegistry(deployParams)).rejects.toThrow("An Error"); + }); - it("should throw when keys are not found anywhere", async () => { - delete process.env.OA_PRIVATE_KEY; - await expect( - deployTokenRegistry({ - registryName: "Test", - registrySymbol: "Tst", - network: "goerli", - dryRun: false, - }) - ).rejects.toThrow( - "No private key found in OA_PRIVATE_KEY, key, key-file, please supply at least one or supply an encrypted wallet path, or provide aws kms signer information" - ); + it("should throw when keys are not found anywhere", async () => { + await expect( + deployTokenRegistry({ + registryName: "Test", + registrySymbol: "Tst", + network: "goerli", + dryRun: false, + }) + ).rejects.toThrow( + "No private key found in OA_PRIVATE_KEY, key, key-file, please supply at least one or supply an encrypted wallet path, or provide aws kms signer information" + ); + }); }); }); diff --git a/src/implementations/document-store/issue.test.ts b/src/implementations/document-store/issue.test.ts index dffc6c3c..c2ab352a 100644 --- a/src/implementations/document-store/issue.test.ts +++ b/src/implementations/document-store/issue.test.ts @@ -3,7 +3,6 @@ import { Wallet } from "ethers"; import { DocumentStoreFactory } from "@govtechsg/document-store"; import { DocumentStoreIssueCommand } from "../../commands/document-store/document-store-command.type"; import { addAddressPrefix } from "../../utils"; -import { join } from "path"; jest.mock("@govtechsg/document-store"); @@ -15,103 +14,70 @@ const deployParams: DocumentStoreIssueCommand = { 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("issue document-store", () => { +describe("document-store", () => { // increase timeout because ethers is throttling jest.setTimeout(30000); - 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 mockedIssue = jest.fn(); - const mockCallStaticIssue = jest.fn().mockResolvedValue(undefined); - - beforeEach(() => { - delete process.env.OA_PRIVATE_KEY; - mockedDocumentStoreFactory.mockReset(); - mockedConnect.mockReset(); - mockCallStaticIssue.mockClear(); - mockedConnect.mockReturnValue({ - issue: mockedIssue, - callStatic: { - issue: mockCallStaticIssue, - }, - }); - mockedIssue.mockReturnValue({ - hash: "hash", - wait: () => Promise.resolve({ transactionHash: "transactionHash" }), + describe("issueDocumentStore", () => { + 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 mockedIssue = jest.fn(); + const mockCallStaticIssue = jest.fn().mockResolvedValue(undefined); + + beforeEach(() => { + delete process.env.OA_PRIVATE_KEY; + mockedDocumentStoreFactory.mockReset(); + mockedConnect.mockReset(); + mockCallStaticIssue.mockClear(); + mockedConnect.mockReturnValue({ + issue: mockedIssue, + callStatic: { + issue: mockCallStaticIssue, + }, + }); + mockedIssue.mockReturnValue({ + hash: "hash", + wait: () => Promise.resolve({ transactionHash: "transactionHash" }), + }); }); - }); - it("should take in the key from environment variable", async () => { - process.env.OA_PRIVATE_KEY = "0000000000000000000000000000000000000000000000000000000000000002"; + it("should take in the key from environment variable", async () => { + process.env.OA_PRIVATE_KEY = "0000000000000000000000000000000000000000000000000000000000000002"; - await issueToDocumentStore({ - hash: "0xabcd", - address: "0x1234", - network: "goerli", - dryRun: false, - }); - - const passedSigner: Wallet = mockedConnect.mock.calls[0][1]; - expect(passedSigner.privateKey).toBe(`0x${process.env.OA_PRIVATE_KEY}`); - }); + await issueToDocumentStore({ + hash: "0xabcd", + address: "0x1234", + network: "goerli", + dryRun: false, + }); - it("should pass in the correct params and return the deployed instance", async () => { - const instance = await issueToDocumentStore(deployParams); + const passedSigner: Wallet = mockedConnect.mock.calls[0][1]; + expect(passedSigner.privateKey).toBe(`0x${process.env.OA_PRIVATE_KEY}`); + }); - const passedSigner: Wallet = mockedConnect.mock.calls[0][1]; + it("should pass in the correct params and return the deployed instance", async () => { + const instance = await issueToDocumentStore(deployParams); - expect(passedSigner.privateKey).toBe(`0x${deployParams.key}`); - expect(mockedConnect.mock.calls[0][0]).toEqual(deployParams.address); - expect(mockCallStaticIssue).toHaveBeenCalledTimes(1); - expect(mockedIssue.mock.calls[0][0]).toEqual(deployParams.hash); - expect(instance).toStrictEqual({ transactionHash: "transactionHash" }); - }); + const passedSigner: Wallet = mockedConnect.mock.calls[0][1]; - it("should take in the key from key file", async () => { - await issueToDocumentStore({ - hash: "0xabcd", - address: "0x1234", - network: "goerli", - keyFile: join(__dirname, "..", "..", "..", "examples", "sample-key"), - dryRun: false, + expect(passedSigner.privateKey).toBe(`0x${deployParams.key}`); + expect(mockedConnect.mock.calls[0][0]).toEqual(deployParams.address); + expect(mockCallStaticIssue).toHaveBeenCalledTimes(1); + expect(mockedIssue.mock.calls[0][0]).toEqual(deployParams.hash); + expect(instance).toStrictEqual({ transactionHash: "transactionHash" }); }); - const passedSigner: Wallet = mockedConnect.mock.calls[0][1]; - expect(passedSigner.privateKey).toBe(`0x0000000000000000000000000000000000000000000000000000000000000003`); - }); - - it("should accept hash without 0x prefix and return deployed instance", async () => { - const instance = await issueToDocumentStore({ ...deployParams, hash: addAddressPrefix("abcd") }); + it("should accept hash without 0x prefix and return deployed instance", async () => { + const instance = await issueToDocumentStore({ ...deployParams, hash: addAddressPrefix("abcd") }); - const passedSigner: Wallet = mockedConnect.mock.calls[0][1]; + 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(mockCallStaticIssue).toHaveBeenCalledTimes(1); - expect(mockedIssue.mock.calls[0][0]).toEqual(deployParams.hash); - expect(instance).toStrictEqual({ transactionHash: "transactionHash" }); - }); - - it("should allow errors to bubble up", async () => { - mockedConnect.mockImplementation(() => { - throw new Error("An Error"); + expect(passedSigner.privateKey).toBe(`0x${deployParams.key}`); + expect(mockedConnect.mock.calls[0][0]).toEqual(deployParams.address); + expect(mockCallStaticIssue).toHaveBeenCalledTimes(1); + expect(mockedIssue.mock.calls[0][0]).toEqual(deployParams.hash); + expect(instance).toStrictEqual({ transactionHash: "transactionHash" }); }); - await expect(issueToDocumentStore(deployParams)).rejects.toThrow("An Error"); - }); - - it("should throw when keys are not found anywhere", async () => { - await expect( - issueToDocumentStore({ - hash: "0xabcd", - address: "0x1234", - network: "goerli", - dryRun: false, - }) - ).rejects.toThrow( - "No private key found in OA_PRIVATE_KEY, key, key-file, please supply at least one or supply an encrypted wallet path, or provide aws kms signer information" - ); }); }); diff --git a/src/implementations/document-store/revoke.test.ts b/src/implementations/document-store/revoke.test.ts index 74989a13..4c3d8c97 100644 --- a/src/implementations/document-store/revoke.test.ts +++ b/src/implementations/document-store/revoke.test.ts @@ -4,7 +4,6 @@ import { Wallet } from "ethers"; import { DocumentStoreFactory } from "@govtechsg/document-store"; import { DocumentStoreRevokeCommand } from "../../commands/document-store/document-store-command.type"; import { addAddressPrefix } from "../../utils"; -import { join } from "path"; jest.mock("@govtechsg/document-store"); @@ -16,8 +15,6 @@ const deployParams: DocumentStoreRevokeCommand = { 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); @@ -68,51 +65,5 @@ describe("document-store", () => { expect(mockedRevoke.mock.calls[0][0]).toEqual(deployParams.hash); expect(instance).toStrictEqual({ transactionHash: "transactionHash" }); }); - - it("should take in the key from environment variable", async () => { - process.env.OA_PRIVATE_KEY = "0000000000000000000000000000000000000000000000000000000000000002"; - await revokeToDocumentStore({ - hash: "0xabcd", - address: "0x1234", - network: "goerli", - dryRun: false, - }); - - 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 revokeToDocumentStore({ - hash: "0xabcd", - address: "0x1234", - network: "goerli", - keyFile: join(__dirname, "..", "..", "..", "examples", "sample-key"), - dryRun: false, - }); - - const passedSigner: Wallet = mockedConnect.mock.calls[0][1]; - expect(passedSigner.privateKey).toBe(`0x0000000000000000000000000000000000000000000000000000000000000003`); - }); - - it("should allow errors to bubble up", async () => { - mockedConnect.mockImplementation(() => { - throw new Error("An Error"); - }); - await expect(revokeToDocumentStore(deployParams)).rejects.toThrow("An Error"); - }); - - it("should throw when keys are not found anywhere", async () => { - await expect( - revokeToDocumentStore({ - hash: "0xabcd", - address: "0x1234", - network: "goerli", - dryRun: false, - }) - ).rejects.toThrow( - "No private key found in OA_PRIVATE_KEY, key, key-file, please supply at least one or supply an encrypted wallet path, or provide aws kms signer information" - ); - }); }); }); diff --git a/src/implementations/document-store/transfer-ownership.test.ts b/src/implementations/document-store/transfer-ownership.test.ts index f07f36ad..e5e7ab94 100644 --- a/src/implementations/document-store/transfer-ownership.test.ts +++ b/src/implementations/document-store/transfer-ownership.test.ts @@ -4,7 +4,6 @@ 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"; jest.mock("@govtechsg/document-store"); @@ -16,8 +15,6 @@ const deployParams: DocumentStoreTransferOwnershipCommand = { 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); @@ -71,30 +68,5 @@ describe("document-store", () => { expect(mockedTransfer.mock.calls[0][0]).toEqual(deployParams.newOwner); expect(instance).toStrictEqual({ transactionHash: "transactionHash" }); }); - - it("should take in the key from environment variable", async () => { - process.env.OA_PRIVATE_KEY = "0000000000000000000000000000000000000000000000000000000000000002"; - await transferDocumentStoreOwnershipToWallet({ - newOwner: "0xabcd", - address: "0x1234", - network: "goerli", - dryRun: false, - }); - - 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 transferDocumentStoreOwnershipToWallet({ - newOwner: "0xabcd", - address: "0x1234", - network: "goerli", - keyFile: join(__dirname, "..", "..", "..", "examples", "sample-key"), - dryRun: false, - }); - - const passedSigner: Wallet = mockedConnect.mock.calls[0][1]; - expect(passedSigner.privateKey).toBe(`0x0000000000000000000000000000000000000000000000000000000000000003`); - }); }); }); diff --git a/src/implementations/testsHelpers.ts b/src/implementations/testsHelpers.ts new file mode 100644 index 00000000..c3a8c73d --- /dev/null +++ b/src/implementations/testsHelpers.ts @@ -0,0 +1,230 @@ +import { BaseContract, BigNumber, constants, providers } from "ethers"; + +export const AddressZero = constants.AddressZero; +export const BurnAddress = "0x000000000000000000000000000000000000dEaD"; +export const EmptyTokenID = "0x0000000000000000000000000000000000000000000000000000000000000000"; +type defaultMockType = jest.Mock; +type SmartContractDataTypes = BigNumber | number | boolean | string; // bytes and unused not included +type EthersResponseType = SmartContractDataTypes | Error; + +export const mockResolve = (value?: EthersResponseType): defaultMockType => { + const fn = jest.fn(); + if (value instanceof Error) { + fn.mockRejectedValue(value); + } else { + fn.mockResolvedValue(value); + } + return fn; +}; + +export const mockReturn = (value?: EthersResponseType): defaultMockType => { + const fn = jest.fn(); + if (value instanceof Error) { + fn.mockImplementation(() => { + throw value; + }); + } else { + fn.mockRejectedValue(value); + } + return fn; +}; + +export const getMockCode = (): defaultMockType => { + const fn = jest.fn(); + fn.mockResolvedValue(true); + return fn; +}; + +export interface ValidContractMockParameters { + supportInterfaceValue?: boolean | Error; +} + +interface MockContractInterface { + supportInterface: jest.Mock; + callStatic: { + supportInterface: jest.Mock; + }; +} + +export const getMockContract = ({ + supportInterfaceValue = true, +}: ValidContractMockParameters): MockContractInterface => { + const supportInterface = mockResolve(supportInterfaceValue); + return { + supportInterface, + callStatic: { + supportInterface, + }, + }; +}; + +export interface TokenRegistryMockParameters extends ValidContractMockParameters { + ownerOfValue?: string | Error; + address?: string; + titleEscrowFactoryAddress?: string; +} + +interface MockTokenRegistryInterface { + ownerOf: jest.Mock; + genesis: jest.Mock; + titleEscrowFactory: jest.Mock; + supportInterfaces: jest.Mock; + callStatic: { + ownerOf: jest.Mock; + genesis: jest.Mock; + titleEscrowFactory: jest.Mock; + supportInterfaces: jest.Mock; + }; +} + +export const getMockTokenRegistry = ({ + ownerOfValue = AddressZero, + supportInterfaceValue = true, + address = AddressZero, + titleEscrowFactoryAddress = AddressZero, +}: TokenRegistryMockParameters): MockTokenRegistryInterface => { + const validContract = getMockContract({ supportInterfaceValue }); + const ownerOf = mockResolve(ownerOfValue); + const genesis = mockResolve(BigNumber.from(0)); + const titleEscrowFactory = mockResolve(titleEscrowFactoryAddress); + const contractFunctions = { + ownerOf, + genesis, + titleEscrowFactory, + }; + const mockTokenRegistry = { + ...contractFunctions, + address: address, + callStatic: contractFunctions, + }; + return mergeMockSmartContract({ base: validContract, override: mockTokenRegistry }); +}; + +export interface TokenRegistryMockParameters extends ValidContractMockParameters { + getAddressValue?: string | Error; +} + +interface MockTitleEscrowFactoryInterface { + getAddress: jest.Mock; + supportInterfaces: jest.Mock; + callStatic: { + getAddress: jest.Mock; + supportInterfaces: jest.Mock; + }; +} + +export const getMockTitleEscrowFactory = ({ + getAddressValue = AddressZero, + supportInterfaceValue = true, +}: TokenRegistryMockParameters): MockTitleEscrowFactoryInterface => { + const validContract = getMockContract({ supportInterfaceValue }); + const getAddress = mockResolve(getAddressValue); + const contractFunctions = { + getAddress, + }; + const mockTokenRegistry = { + ...contractFunctions, + callStatic: contractFunctions, + }; + return mergeMockSmartContract({ base: validContract, override: mockTokenRegistry }); +}; + +export interface TitleEscrowMockParameters extends ValidContractMockParameters { + beneficiaryValue?: string | Error; + holderValue?: string | Error; + nomineeValue?: string | Error; + activeValue?: boolean | Error; +} + +interface MockTitleEscrowInterface { + active: jest.Mock; + beneficiary: jest.Mock; + holder: jest.Mock; + nominee: jest.Mock; + supportInterfaces: jest.Mock; + callStatic: { + active: jest.Mock; + beneficiary: jest.Mock; + holder: jest.Mock; + nominee: jest.Mock; + supportInterfaces: jest.Mock; + }; +} + +export const getMockTitleEscrow = ({ + beneficiaryValue = AddressZero, + holderValue = AddressZero, + nomineeValue = AddressZero, + activeValue = true, + supportInterfaceValue = true, +}: TitleEscrowMockParameters): MockTitleEscrowInterface => { + const validContract = getMockContract({ supportInterfaceValue }); + const active = mockResolve(activeValue); + const beneficiary = mockResolve(beneficiaryValue); + const holder = mockResolve(holderValue); + const nominee = mockResolve(nomineeValue); + const contractFunctions = { + active, + beneficiary, + holder, + nominee, + }; + const mockTitleEscrow = { + ...contractFunctions, + callStatic: contractFunctions, + }; + return mergeMockSmartContract({ base: validContract, override: mockTitleEscrow }); +}; + +export const initMockGetCode = (fn?: jest.Mock): void => { + if (!fn) { + const fn = jest.fn(); + fn.mockResolvedValue(`0x`); + } + jest.spyOn(providers.BaseProvider.prototype, "getCode").mockImplementation(fn); +}; +export interface WalletMockParameters { + codeValue?: string | Error; +} + +export const getValidWalletContract = ({ + codeValue = `0x`, +}: WalletMockParameters): { provider: { getCode: jest.Mock } } => { + const getCode = mockResolve(codeValue); + return { + provider: { + getCode, + }, + }; +}; + +export interface MergeObjectParameters { + base: any; + override: any; +} + +export const mergeMockSmartContract = ({ base, override }: MergeObjectParameters): any => { + override = mergeMockBaseContract(base, override, "functions"); + override = mergeMockBaseContract(base, override, "callStatic"); + override = mergeMockBaseContract(base, override, "estimateGas"); + override = mergeMockBaseContract(base, override, "populateTransaction"); + override = mergeMockBaseContract(base, override, "filters"); + override = mergeMockBaseContract(base, override, "_runningEvents"); + override = mergeMockBaseContract(base, override, "_wrappedEmits"); + return { + ...base, + ...override, + }; +}; + +const mergeMockBaseContract = (base: any, override: any, keyName: key): any => { + if (keyName in override && keyName in base) { + if (typeof base[keyName] === "object" && typeof override[keyName] === "object") { + override[keyName] = { + ...base[keyName], + ...override[keyName], + }; + } + } + return override; +}; diff --git a/src/implementations/title-escrow/acceptSurrendered.test.ts b/src/implementations/title-escrow/acceptSurrendered.test.ts index 6ec16afb..3fc1b649 100644 --- a/src/implementations/title-escrow/acceptSurrendered.test.ts +++ b/src/implementations/title-escrow/acceptSurrendered.test.ts @@ -1,43 +1,77 @@ -import { TradeTrustToken__factory } from "@govtechsg/token-registry/contracts"; +import { + TitleEscrowFactory__factory, + TitleEscrow__factory, + TradeTrustToken__factory, +} from "@govtechsg/token-registry/contracts"; import { Wallet } from "ethers"; - import { BaseTitleEscrowCommand as TitleEscrowSurrenderDocumentCommand } from "../../commands/title-escrow/title-escrow-command.type"; +import { + AddressZero, + getMockTitleEscrow, + getMockTitleEscrowFactory, + getMockTokenRegistry, + initMockGetCode, + mergeMockSmartContract, +} from "../testsHelpers"; import { acceptSurrendered } from "./acceptSurrendered"; jest.mock("@govtechsg/token-registry/contracts"); const acceptSurrenderedDocumentParams: TitleEscrowSurrenderDocumentCommand = { - tokenRegistry: "0x1122", - tokenId: "0x12345", + tokenRegistry: "0x0000000000000000000000000000000000000001", + tokenId: "0x0000000000000000000000000000000000000000000000000000000000000001", network: "goerli", dryRun: false, }; +const walletAddress = `0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf`; + describe("title-escrow", () => { describe("accepts surrendered transferable record", () => { const mockedTradeTrustTokenFactory: jest.Mock = TradeTrustToken__factory as any; + const mockedTitleEscrowFactory: jest.Mock = TitleEscrow__factory as any; + const mockedTitleEscrowFactoryFactory: jest.Mock = TitleEscrowFactory__factory as any; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore mock static method const mockedConnectERC721: jest.Mock = mockedTradeTrustTokenFactory.connect; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore mock static method + const mockedConnectTitleEscrowFactory: jest.Mock = mockedTitleEscrowFactoryFactory.connect; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore mock static method + const mockedConnectTitleEscrow: jest.Mock = mockedTitleEscrowFactory.connect; const mockBurnToken = jest.fn(); const mockCallStaticBurnToken = jest.fn().mockResolvedValue(undefined); + const tokenRegistryAddress = acceptSurrenderedDocumentParams.tokenRegistry; + const mockBaseTokenRegistry = getMockTokenRegistry({ + ownerOfValue: tokenRegistryAddress, + address: tokenRegistryAddress, + titleEscrowFactoryAddress: AddressZero, + }); + let mockTokenRegistry = mockBaseTokenRegistry; + const mockTitleEscrowFactory = getMockTitleEscrowFactory({}); + const mockTitleEscrow = getMockTitleEscrow({ holderValue: walletAddress, beneficiaryValue: walletAddress }); + beforeEach(() => { delete process.env.OA_PRIVATE_KEY; mockedTradeTrustTokenFactory.mockReset(); mockedConnectERC721.mockReset(); - mockBurnToken.mockReturnValue({ hash: "hash", wait: () => Promise.resolve({ transactionHash: "transactionHash" }), }); - - mockedConnectERC721.mockReturnValue({ + const mockCustomTokenRegistry = { burn: mockBurnToken, callStatic: { burn: mockCallStaticBurnToken, }, - }); + }; + initMockGetCode(); + mockTokenRegistry = mergeMockSmartContract({ base: mockBaseTokenRegistry, override: mockCustomTokenRegistry }); + mockedConnectERC721.mockReturnValue(mockTokenRegistry); + mockedConnectTitleEscrow.mockReturnValue(mockTitleEscrow); + mockedConnectTitleEscrowFactory.mockReturnValue(mockTitleEscrowFactory); mockBurnToken.mockClear(); mockCallStaticBurnToken.mockClear(); }); @@ -47,12 +81,9 @@ describe("title-escrow", () => { ...acceptSurrenderedDocumentParams, key: privateKey, }); - const passedSigner: Wallet = mockedConnectERC721.mock.calls[0][1]; - expect(passedSigner.privateKey).toBe(`0x${privateKey}`); expect(mockedConnectERC721).toHaveBeenCalledWith(acceptSurrenderedDocumentParams.tokenRegistry, passedSigner); - expect(mockCallStaticBurnToken).toHaveBeenCalledTimes(1); expect(mockBurnToken).toHaveBeenCalledTimes(1); }); }); diff --git a/src/implementations/title-escrow/acceptSurrendered.ts b/src/implementations/title-escrow/acceptSurrendered.ts index 010805f8..62b0f596 100644 --- a/src/implementations/title-escrow/acceptSurrendered.ts +++ b/src/implementations/title-escrow/acceptSurrendered.ts @@ -2,10 +2,9 @@ import signale from "signale"; import { getLogger } from "../../logger"; import { getWalletOrSigner } from "../utils/wallet"; import { BaseTitleEscrowCommand as TitleEscrowSurrenderDocumentCommand } from "../../commands/title-escrow/title-escrow-command.type"; - import { dryRunMode } from "../utils/dryRun"; import { TransactionReceipt } from "@ethersproject/providers"; -import { TradeTrustToken__factory } from "@govtechsg/token-registry/dist/contracts"; +import { connectToTokenRegistry, validateSurrenderMethod } from "./helpers"; const { trace } = getLogger("title-escrow:acceptSurrendered"); @@ -17,7 +16,8 @@ export const acceptSurrendered = async ({ ...rest }: TitleEscrowSurrenderDocumentCommand): Promise => { const wallet = await getWalletOrSigner({ network, ...rest }); - const tokenRegistryInstance = await TradeTrustToken__factory.connect(address, wallet); + const tokenRegistryInstance = await connectToTokenRegistry({ address, wallet }); + await validateSurrenderMethod({ tokenRegistry: tokenRegistryInstance, tokenId, wallet }); if (dryRun) { await dryRunMode({ estimatedGas: await tokenRegistryInstance.estimateGas.burn(tokenId), @@ -25,9 +25,7 @@ export const acceptSurrendered = async ({ }); process.exit(0); } - signale.await(`Sending transaction to pool`); - await tokenRegistryInstance.callStatic.burn(tokenId); const transaction = await tokenRegistryInstance.burn(tokenId); trace(`Tx hash: ${transaction.hash}`); trace(`Block Number: ${transaction.blockNumber}`); diff --git a/src/implementations/title-escrow/endorseNominatedBeneficiary.test.ts b/src/implementations/title-escrow/endorseNominatedBeneficiary.test.ts index ee1ef662..a7e4ddc4 100644 --- a/src/implementations/title-escrow/endorseNominatedBeneficiary.test.ts +++ b/src/implementations/title-escrow/endorseNominatedBeneficiary.test.ts @@ -2,14 +2,15 @@ import { TitleEscrow__factory, TradeTrustToken__factory } from "@govtechsg/token import { Wallet } from "ethers"; import { TitleEscrowNominateBeneficiaryCommand } from "../../commands/title-escrow/title-escrow-command.type"; +import { getMockTitleEscrow, getMockTokenRegistry, initMockGetCode, mergeMockSmartContract } from "../testsHelpers"; import { endorseNominatedBeneficiary } from "./endorseNominatedBeneficiary"; jest.mock("@govtechsg/token-registry/contracts"); const endorseNominatedBeneficiaryParams: TitleEscrowNominateBeneficiaryCommand = { - tokenId: "0xzyxw", - tokenRegistry: "0x1234", - newBeneficiary: "0x1232", + tokenId: "0x0000000000000000000000000000000000000000000000000000000000000001", + tokenRegistry: "0x0000000000000000000000000000000000000001", + newBeneficiary: "0x0000000000000000000000000000000000000002", network: "goerli", dryRun: false, }; @@ -25,33 +26,50 @@ describe("title-escrow", () => { // @ts-ignore mock static method const mockedConnectTokenFactory: jest.Mock = mockedTokenFactory.connect; - const mockedTitleEscrowAddress = "0x2133"; + const walletAddress = `0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf`; + + const mockedTitleEscrowAddress = "0x0000000000000000000000000000000000000003"; const mockedOwnerOf = jest.fn(); mockedOwnerOf.mockReturnValue(mockedTitleEscrowAddress); const mockTransferOwners = jest.fn(); const mockCallStaticTransferOwners = jest.fn().mockResolvedValue(undefined); - const mockedBeneficiary = "0xdssfs"; + const mockedBeneficiary = "0x0000000000000000000000000000000000000004"; const mockGetBeneficiary = jest.fn(); mockGetBeneficiary.mockReturnValue(mockedBeneficiary); - mockedConnectERC721.mockReturnValue({ - ownerOf: mockedOwnerOf, + const mockBaseTokenRegistry = getMockTokenRegistry({ + ownerOfValue: mockedTitleEscrowAddress, + address: endorseNominatedBeneficiaryParams.tokenRegistry, + }); + const mockTokenRegistry = mockBaseTokenRegistry; + const mockBaseTitleEscrow = getMockTitleEscrow({ + holderValue: walletAddress, + beneficiaryValue: walletAddress, + nomineeValue: endorseNominatedBeneficiaryParams.newBeneficiary, }); - mockedConnectTokenFactory.mockReturnValue({ + mockedConnectERC721.mockReturnValue(mockTokenRegistry); + + const customMockTitleEscrow = { transferBeneficiary: mockTransferOwners, - beneficiary: mockGetBeneficiary, + // beneficiary: mockGetBeneficiary, callStatic: { transferBeneficiary: mockCallStaticTransferOwners, }, - }); + }; + + const mockTitleEscrow = mergeMockSmartContract({ base: mockBaseTitleEscrow, override: customMockTitleEscrow }); + + mockedConnectTokenFactory.mockReturnValue(mockTitleEscrow); mockTransferOwners.mockReturnValue({ hash: "hash", wait: () => Promise.resolve({ transactionHash: "transactionHash" }), }); + initMockGetCode(); + beforeEach(() => { delete process.env.OA_PRIVATE_KEY; mockedTradeTrustTokenFactory.mockClear(); @@ -74,7 +92,6 @@ describe("title-escrow", () => { expect(passedSigner.privateKey).toBe(`0x${privateKey}`); expect(mockedConnectERC721).toHaveBeenCalledWith(endorseNominatedBeneficiaryParams.tokenRegistry, passedSigner); - expect(mockedOwnerOf).toHaveBeenCalledWith(endorseNominatedBeneficiaryParams.tokenId); expect(mockedConnectTokenFactory).toHaveBeenCalledWith(mockedTitleEscrowAddress, passedSigner); expect(mockCallStaticTransferOwners).toHaveBeenCalledTimes(1); expect(mockTransferOwners).toHaveBeenCalledTimes(1); @@ -85,10 +102,10 @@ describe("title-escrow", () => { await expect( endorseNominatedBeneficiary({ ...endorseNominatedBeneficiaryParams, - newBeneficiary: "0xdssfs", + newBeneficiary: walletAddress, key: privateKey, }) - ).rejects.toThrow(`new beneficiary address is the same as the current beneficiary address`); + ).rejects.toThrow(`Destination wallet already has the rights as beneficiary`); }); }); }); diff --git a/src/implementations/title-escrow/endorseNominatedBeneficiary.ts b/src/implementations/title-escrow/endorseNominatedBeneficiary.ts index df952f28..4bb031d1 100644 --- a/src/implementations/title-escrow/endorseNominatedBeneficiary.ts +++ b/src/implementations/title-escrow/endorseNominatedBeneficiary.ts @@ -1,7 +1,7 @@ import signale from "signale"; import { getLogger } from "../../logger"; import { getWalletOrSigner } from "../utils/wallet"; -import { connectToTitleEscrow, validateNominateBeneficiary } from "./helpers"; +import { connectToTitleEscrow, validateTransferBeneficiary } from "./helpers"; import { TitleEscrowNominateBeneficiaryCommand } from "../../commands/title-escrow/title-escrow-command.type"; import { dryRunMode } from "../utils/dryRun"; @@ -23,7 +23,8 @@ export const endorseNominatedBeneficiary = async ({ const wallet = await getWalletOrSigner({ network, ...rest }); const titleEscrow = await connectToTitleEscrow({ tokenId, address, wallet }); const nominatedBeneficiary = newBeneficiary; - await validateNominateBeneficiary({ beneficiaryNominee: nominatedBeneficiary, titleEscrow }); + const walletAddress = await wallet.getAddress(); + await validateTransferBeneficiary({ to: nominatedBeneficiary, titleEscrow, walletAddress }); if (dryRun) { await dryRunMode({ estimatedGas: await titleEscrow.estimateGas.transferBeneficiary(nominatedBeneficiary), diff --git a/src/implementations/title-escrow/helpers.ts b/src/implementations/title-escrow/helpers.ts index 41593019..88ef978b 100644 --- a/src/implementations/title-escrow/helpers.ts +++ b/src/implementations/title-escrow/helpers.ts @@ -1,62 +1,240 @@ import { TitleEscrow, + TitleEscrowFactory, + TitleEscrowFactory__factory, TitleEscrow__factory, TradeTrustToken, TradeTrustToken__factory, } from "@govtechsg/token-registry/contracts"; -import { Wallet, constants } from "ethers"; +import { Wallet, constants, Contract } from "ethers"; +import { isAddress } from "ethers/lib/utils"; import signale from "signale"; import { ConnectedSigner } from "../utils/wallet"; +export const BurnAddress = "0x000000000000000000000000000000000000dEaD"; + interface ConnectToTitleEscrowArgs { tokenId: string; address: string; - wallet: Wallet | ConnectedSigner; + wallet: UserWallet; +} + +interface ConnectToTokenRegistryArgs { + address: string; + wallet: UserWallet; +} + +type UserWallet = Wallet | ConnectedSigner; + +export const assertAddressIsSmartContract = async ( + address: string, + account: Wallet | ConnectedSigner +): Promise => { + const code = await account.provider.getCode(address); + const isContract = code !== "0x" && code !== "0x0"; // Ganache uses 0x0 instead + if (!isContract) throw new Error(`Address ${address} is not a valid Contract`); +}; + +interface ERC165Contract extends Contract { + supportsInterface: (interfaceId: string) => Promise; } +export const supportsInterface = async ( + contractInstance: ERC165Contract, + interfaceId: string +): Promise => { + let isSameInterfaceType; + try { + isSameInterfaceType = await contractInstance.supportsInterface(interfaceId); + return isSameInterfaceType; + } catch (e) { + if (e instanceof Error) { + if (e.message.includes("revert") || e.message.includes("cannot estimate gas")) { + return false; + } + } + } +}; + +const isJsonString = (jsonString: string): boolean => { + try { + JSON.parse(jsonString); + } catch (e) { + return false; + } + return true; +}; + +export const getTitleEscrowAddress = async (tokenRegistry: TradeTrustToken, tokenId: string): Promise => { + try { + return await tokenRegistry.ownerOf(tokenId); + } catch (e: any) { + if (e?.code === "CALL_EXCEPTION" && e?.reason === "missing revert data in call exception" && e?.data === "0x") { + const providerError = e?.error; + if (providerError?.code === "SERVER_ERROR" && providerError?.reason === "processing response error") { + if (isJsonString(providerError?.body)) { + const VMError: any = JSON.parse(providerError?.body); + if ( + VMError?.error?.message === + "VM Exception while processing transaction: revert ERC721: owner query for nonexistent token" + ) { + throw new Error(`Unminted Token`); + } + } + } + } + throw e; + } +}; + +export const connectToTitleEscrowAddress = async (address: string, wallet: UserWallet): Promise => { + await assertAddressIsSmartContract(address, wallet); + const titleEscrow = TitleEscrow__factory.connect(address, wallet); + // const isTitleEscrow = await supportsInterface(titleEscrow, "0x8a198f04") + // if(!isTitleEscrow) throw new Error(`Address ${titleEscrowAddress} is not a supported escrow contract`) + return titleEscrow; +}; + export const connectToTitleEscrow = async ({ tokenId, address, wallet, }: ConnectToTitleEscrowArgs): Promise => { - const tokenRegistry: TradeTrustToken = await TradeTrustToken__factory.connect(address, wallet); - const titleEscrowAddress = await tokenRegistry.ownerOf(tokenId); - return await TitleEscrow__factory.connect(titleEscrowAddress, wallet); + await assertAddressIsSmartContract(address, wallet); + const tokenRegistry: TradeTrustToken = await connectToTokenRegistry({ address, wallet }); + const titleEscrowAddress = await getTitleEscrowAddress(tokenRegistry, tokenId); + if (titleEscrowAddress === BurnAddress) throw new Error(`Title Escrow has already been shredded`); + if (titleEscrowAddress === address) throw new Error(`Title Escrow has already been surrendered`); + await assertAddressIsSmartContract(titleEscrowAddress, wallet); + const titleEscrow = connectToTitleEscrowAddress(titleEscrowAddress, wallet); + return titleEscrow; +}; + +export const connectToTokenRegistry = async ({ + address, + wallet, +}: ConnectToTokenRegistryArgs): Promise => { + await assertAddressIsSmartContract(address, wallet); + const tokenRegistryInstance: TradeTrustToken = await TradeTrustToken__factory.connect(address, wallet); + // const isTokenRegistry = await supportsInterface(tokenRegistryInstance, "0x8a198f04") + // if(!isTokenRegistry) throw new Error(`Address ${address} is not a supported token registry contract`) + await tokenRegistryInstance.callStatic.genesis(); + return tokenRegistryInstance; +}; + +// State Checks + +export const validateActiveTitleEscrow = async (titleEscrow: TitleEscrow): Promise => { + const activeEscrow = await titleEscrow.active(); + if (!activeEscrow) throw new Error(`Inactive Title Escrow`); +}; + +// Permissions check + +export const EscrowRoles = { + beneficiary: "beneficiary", + holder: "holder", + nominee: "nominee", +} as const; + +export type EscrowRolesType = typeof EscrowRoles[keyof typeof EscrowRoles]; + +const hasTransferRights = async ( + titleEscrow: TitleEscrow, + walletAddress: string, + expectedPermissions: string[] +): Promise => { + for (const roles of expectedPermissions) { + let rightsHolder = ""; + let results = true; + switch (roles) { + case EscrowRoles.beneficiary: + rightsHolder = await titleEscrow.beneficiary(); + results = results && rightsHolder === walletAddress; + break; + case EscrowRoles.holder: + rightsHolder = await titleEscrow.holder(); + results = results && rightsHolder === walletAddress; + break; + case EscrowRoles.nominee: + rightsHolder = await titleEscrow.nominee(); + results = results && rightsHolder === walletAddress; + break; + default: + throw new Error(`Unimplemented role: ${roles}`); + } + if (!results) return false; + } + return true; +}; + +interface validateTransferArgs { + titleEscrow: TitleEscrow; + to: string; + walletAddress: string; +} + +export const validateTransferHolder = async ({ + titleEscrow, + to, + walletAddress, +}: validateTransferArgs): Promise => { + if (!isAddress(walletAddress)) { + ("Destination Holder is not a valid address"); + } + await validateActiveTitleEscrow(titleEscrow); + const haveRights = await hasTransferRights(titleEscrow, walletAddress, [EscrowRoles.holder]); + if (!haveRights) throw new Error(`Wallet lack the rights for the transfer operation`); + const isHolder = await hasTransferRights(titleEscrow, to, [EscrowRoles.holder]); + if (isHolder) throw new Error(`Destination wallet already has the rights of holdership`); +}; + +export const validateTransferBeneficiary = async ({ + titleEscrow, + to, + walletAddress, +}: validateTransferArgs): Promise => { + if (!isAddress(walletAddress)) { + ("Destination Beneficiary is not a valid address"); + } + await validateActiveTitleEscrow(titleEscrow); + const haveRights = await hasTransferRights(titleEscrow, walletAddress, [EscrowRoles.beneficiary, EscrowRoles.holder]); + if (!haveRights) throw new Error(`Wallet lack the rights for the transfer operation`); + const isBeneficiary = await hasTransferRights(titleEscrow, to, [EscrowRoles.beneficiary]); + if (isBeneficiary) throw new Error(`Destination wallet already has the rights as beneficiary`); + const isNominated = await hasTransferRights(titleEscrow, to, [EscrowRoles.nominee]); + if (!isNominated) throw new Error(`Destination wallet has not been nominated`); +}; + +export const validateNominateBeneficiary = async ({ + walletAddress, + to, + titleEscrow, +}: validateTransferArgs): Promise => { + if (!isAddress(walletAddress)) { + ("Destination Nominee is not a valid address"); + } + await validateActiveTitleEscrow(titleEscrow); + const haveRights = await hasTransferRights(titleEscrow, walletAddress, [EscrowRoles.beneficiary]); + if (!haveRights) throw new Error(`Wallet lack the rights for the transfer operation`); + const isBeneficiary = await hasTransferRights(titleEscrow, to, [EscrowRoles.beneficiary]); + if (isBeneficiary) throw new Error(`Destination wallet already has the rights as beneficiary`); }; interface validateEndorseChangeOwnerArgs { newHolder: string; newOwner: string; titleEscrow: TitleEscrow; + walletAddress: string; } export const validateEndorseChangeOwner = async ({ newHolder, newOwner, titleEscrow, + walletAddress, }: validateEndorseChangeOwnerArgs): Promise => { - const beneficiary = await titleEscrow.beneficiary(); - const holder = await titleEscrow.holder(); - if (newOwner === beneficiary && newHolder === holder) { - const error = "new owner and new holder addresses are the same as the current owner and holder addresses"; - signale.error(error); - throw new Error(error); - } -}; - -interface validateNominateBeneficiaryArgs { - beneficiaryNominee: string; - titleEscrow: TitleEscrow; -} -export const validateNominateBeneficiary = async ({ - beneficiaryNominee, - titleEscrow, -}: validateNominateBeneficiaryArgs): Promise => { - const beneficiary = await titleEscrow.beneficiary(); - if (beneficiaryNominee === beneficiary) { - const error = "new beneficiary address is the same as the current beneficiary address"; - signale.error(error); - throw new Error(error); - } + await validateTransferHolder({ titleEscrow, to: newHolder, walletAddress }); + await validateTransferBeneficiary({ titleEscrow, to: newOwner, walletAddress }); }; interface validateEndorseTransferOwnerArgs { @@ -74,3 +252,46 @@ export const validateEndorseTransferOwner = ({ throw new Error(error); } }; + +interface validateSurrenderArgs { + titleEscrow: TitleEscrow; + walletAddress: string; +} + +interface validateAcceptSurrenderArgs { + tokenRegistry: TradeTrustToken; + tokenId: string; + wallet: UserWallet; +} + +export const validateSurrender = async ({ titleEscrow, walletAddress }: validateSurrenderArgs): Promise => { + const haveRights = await hasTransferRights(titleEscrow, walletAddress, [EscrowRoles.holder, EscrowRoles.beneficiary]); + if (!haveRights) throw new Error(`Wallet lack the rights for the transfer operation`); +}; + +export const connectToTitleEscrowFactory = async ( + tokenRegistry: TradeTrustToken, + wallet: UserWallet +): Promise => { + const titleEscrowFactoryAddress = await tokenRegistry.titleEscrowFactory(); + await assertAddressIsSmartContract(titleEscrowFactoryAddress, wallet); + const titleEscrowFactory = TitleEscrowFactory__factory.connect(titleEscrowFactoryAddress, wallet); + return titleEscrowFactory; +}; + +export const validateSurrenderMethod = async ({ + tokenRegistry, + tokenId, + wallet, +}: validateAcceptSurrenderArgs): Promise => { + const ownerOfTitleEscrow = await getTitleEscrowAddress(tokenRegistry, tokenId); + if (ownerOfTitleEscrow !== tokenRegistry.address) throw new Error(`Title Escrow has not been surrendered`); + const titleEscrowFactory = await connectToTitleEscrowFactory(tokenRegistry, wallet); + // const isTitleEscrowFactory = await supportsInterface(validateAcceptSurrenderArgs, "?") + // if(!isTitleEscrowFactory) throw new Error(`Address ${address} is not a supported title escrow factory contract`) + const titleEscrowAddress = await titleEscrowFactory.getAddress(tokenRegistry.address, tokenId); + const walletAddress = await wallet.getAddress(); + const titleEscrow = await connectToTitleEscrowAddress(titleEscrowAddress, wallet); + const haveRights = await hasTransferRights(titleEscrow, walletAddress, [EscrowRoles.holder, EscrowRoles.beneficiary]); + if (!haveRights) throw new Error(`Wallet lack the rights for the transfer operation`); +}; diff --git a/src/implementations/title-escrow/nominateBeneficiary.test.ts b/src/implementations/title-escrow/nominateBeneficiary.test.ts index eb755c91..12acf77e 100644 --- a/src/implementations/title-escrow/nominateBeneficiary.test.ts +++ b/src/implementations/title-escrow/nominateBeneficiary.test.ts @@ -2,14 +2,15 @@ import { TitleEscrow__factory, TradeTrustToken__factory } from "@govtechsg/token import { Wallet } from "ethers"; import { TitleEscrowNominateBeneficiaryCommand } from "../../commands/title-escrow/title-escrow-command.type"; +import { getMockTitleEscrow, getMockTokenRegistry, initMockGetCode, mergeMockSmartContract } from "../testsHelpers"; import { nominateBeneficiary } from "./nominateBeneficiary"; jest.mock("@govtechsg/token-registry/contracts"); const nominateBeneficiaryParams: TitleEscrowNominateBeneficiaryCommand = { - newBeneficiary: "0fosui", - tokenId: "0xzyxw", - tokenRegistry: "0x1234", + newBeneficiary: "0x0000000000000000000000000000000000000002", + tokenId: "0x0000000000000000000000000000000000000000000000000000000000000001", + tokenRegistry: "0x0000000000000000000000000000000000000001", network: "goerli", dryRun: false, }; @@ -24,28 +25,30 @@ describe("title-escrow", () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore mock static method const mockedConnectTokenFactory: jest.Mock = mockedTokenFactory.connect; - const mockedOwnerOf = jest.fn(); + // const mockedOwnerOf = jest.fn(); const mockNominateBeneficiary = jest.fn(); - const mockedTitleEscrowAddress = "0x2133"; - const mockedBeneficiary = "0xdssfs"; - const mockedHolder = "0xdsfls"; + const mockedTitleEscrowAddress = "0x0000000000000000000000000000000000000003"; + const mockedBeneficiary = "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"; + const mockedHolder = "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"; const mockGetBeneficiary = jest.fn(); const mockGetHolder = jest.fn(); const mockCallStaticNominateBeneficiary = jest.fn().mockResolvedValue(undefined); mockGetBeneficiary.mockResolvedValue(mockedBeneficiary); mockGetHolder.mockResolvedValue(mockedHolder); - mockedConnectERC721.mockReturnValue({ - ownerOf: mockedOwnerOf, - }); - mockedConnectTokenFactory.mockReturnValue({ + const mockBaseTokenRegistry = getMockTokenRegistry({ ownerOfValue: mockedTitleEscrowAddress }); + mockedConnectERC721.mockReturnValue(mockBaseTokenRegistry); + const mockBaseTitleEscrow = getMockTitleEscrow({ beneficiaryValue: mockedBeneficiary, holderValue: mockedHolder }); + const mockCustomTitleEscrow = { nominate: mockNominateBeneficiary, - beneficiary: mockGetBeneficiary, - holder: mockGetHolder, + // beneficiary: mockGetBeneficiary, + // holder: mockGetHolder, callStatic: { nominate: mockCallStaticNominateBeneficiary, }, - }); - mockedOwnerOf.mockReturnValue(mockedTitleEscrowAddress); + }; + initMockGetCode(); + const mockTitleEscrow = mergeMockSmartContract({ base: mockBaseTitleEscrow, override: mockCustomTitleEscrow }); + mockedConnectTokenFactory.mockReturnValue(mockTitleEscrow); mockNominateBeneficiary.mockReturnValue({ hash: "hash", wait: () => Promise.resolve({ transactionHash: "transactionHash" }), @@ -57,7 +60,6 @@ describe("title-escrow", () => { mockedConnectERC721.mockClear(); mockedTokenFactory.mockClear(); mockedConnectTokenFactory.mockClear(); - mockedOwnerOf.mockClear(); mockNominateBeneficiary.mockClear(); mockGetBeneficiary.mockClear(); mockGetHolder.mockClear(); @@ -72,24 +74,22 @@ describe("title-escrow", () => { }); const passedSigner: Wallet = mockedConnectERC721.mock.calls[0][1]; - expect(passedSigner.privateKey).toBe(`0x${privateKey}`); expect(mockedConnectERC721).toHaveBeenCalledWith(nominateBeneficiaryParams.tokenRegistry, passedSigner); - expect(mockedOwnerOf).toHaveBeenCalledWith(nominateBeneficiaryParams.tokenId); expect(mockedConnectTokenFactory).toHaveBeenCalledWith(mockedTitleEscrowAddress, passedSigner); expect(mockCallStaticNominateBeneficiary).toHaveBeenCalledTimes(1); expect(mockNominateBeneficiary).toHaveBeenCalledTimes(1); }); it("should throw an error if new owner addresses is the same as current owner", async () => { - mockGetBeneficiary.mockReturnValue(nominateBeneficiaryParams.newBeneficiary); const privateKey = "0000000000000000000000000000000000000000000000000000000000000001"; await expect( nominateBeneficiary({ ...nominateBeneficiaryParams, + newBeneficiary: mockedBeneficiary, key: privateKey, }) - ).rejects.toThrow("new beneficiary address is the same as the current beneficiary address"); + ).rejects.toThrow("Destination wallet already has the rights as beneficiary"); }); }); }); diff --git a/src/implementations/title-escrow/nominateBeneficiary.ts b/src/implementations/title-escrow/nominateBeneficiary.ts index 7bddd11b..a5f68a46 100644 --- a/src/implementations/title-escrow/nominateBeneficiary.ts +++ b/src/implementations/title-escrow/nominateBeneficiary.ts @@ -19,17 +19,16 @@ export const nominateBeneficiary = async ({ }: TitleEscrowNominateBeneficiaryCommand): Promise => { const wallet = await getWalletOrSigner({ network, ...rest }); const titleEscrow = await connectToTitleEscrow({ tokenId, address, wallet }); + const walletAddress = await wallet.getAddress(); + await validateNominateBeneficiary({ to: newBeneficiary, titleEscrow, walletAddress }); if (dryRun) { - await validateNominateBeneficiary({ beneficiaryNominee: newBeneficiary, titleEscrow }); await dryRunMode({ estimatedGas: await titleEscrow.estimateGas.nominate(newBeneficiary), network, }); process.exit(0); } - signale.await(`Sending transaction to pool`); - await validateNominateBeneficiary({ beneficiaryNominee: newBeneficiary, titleEscrow }); await titleEscrow.callStatic.nominate(newBeneficiary); const transaction = await titleEscrow.nominate(newBeneficiary); trace(`Tx hash: ${transaction.hash}`); diff --git a/src/implementations/title-escrow/rejectSurrendered.test.ts b/src/implementations/title-escrow/rejectSurrendered.test.ts index e8ab72b1..744d0a14 100644 --- a/src/implementations/title-escrow/rejectSurrendered.test.ts +++ b/src/implementations/title-escrow/rejectSurrendered.test.ts @@ -1,39 +1,74 @@ -import { TitleEscrow__factory, TradeTrustToken__factory } from "@govtechsg/token-registry/contracts"; +import { + TitleEscrowFactory__factory, + TitleEscrow__factory, + TradeTrustToken__factory, +} from "@govtechsg/token-registry/contracts"; import { Wallet } from "ethers"; import { BaseTitleEscrowCommand as TitleEscrowSurrenderDocumentCommand } from "../../commands/title-escrow/title-escrow-command.type"; +import { + getMockTitleEscrow, + getMockTitleEscrowFactory, + getMockTokenRegistry, + initMockGetCode, + mergeMockSmartContract, +} from "../testsHelpers"; import { rejectSurrendered } from "./rejectSurrendered"; jest.mock("@govtechsg/token-registry/contracts"); const rejectSurrenderedDocumentParams: TitleEscrowSurrenderDocumentCommand = { - tokenRegistry: "0x1122", - tokenId: "0x12345", + tokenRegistry: "0x0000000000000000000000000000000000000001", + tokenId: "0x0000000000000000000000000000000000000000000000000000000000000001", network: "goerli", dryRun: false, }; +const walletAddress = `0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf`; + describe("title-escrow", () => { describe("rejects surrendered transferable record", () => { const mockedTradeTrustTokenFactory: jest.Mock = TradeTrustToken__factory as any; + const mockedTitleEscrowFactory: jest.Mock = TitleEscrow__factory as any; + const mockedTitleEscrowFactoryFactory: jest.Mock = TitleEscrowFactory__factory as any; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore mock static method const mockedConnectERC721: jest.Mock = mockedTradeTrustTokenFactory.connect; - const mockedTitleEscrowFactory: jest.Mock = TitleEscrow__factory as any; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore mock static method const mockedConnectTitleEscrowFactory: jest.Mock = mockedTitleEscrowFactory.connect; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore mock static method + const mockedConnectTitleEscrowFactoryFactory: jest.Mock = mockedTitleEscrowFactoryFactory.connect; - const mockedBeneficiary = jest.fn(); - const mockedHolder = jest.fn(); const mockRestoreTitle = jest.fn(); - const mockTransferEvent = jest.fn(); - const mockQueryFilter = jest.fn(); const mockCallStaticRestoreTitle = jest.fn().mockResolvedValue(undefined); - const mockedLastTitleEscrowAddress = "0xMockedLastTitleEscrowAddress"; - const mockedLastBeneficiary = "0xMockedLastBeneficiaryAddress"; - const mockedLastHolder = "0xMockedLastHolderAddress"; + const mockedLastTitleEscrowAddress = "0x0000000000000000000000000000000000000003"; + initMockGetCode(); + + const mockBaseTokenRegistry = getMockTokenRegistry({ + ownerOfValue: rejectSurrenderedDocumentParams.tokenRegistry, + address: rejectSurrenderedDocumentParams.tokenRegistry, + }); + + const mockCustomTokenRegistry = { + restore: mockRestoreTitle, + callStatic: { + restore: mockCallStaticRestoreTitle, + }, + }; + const mockTokenRegistry = mergeMockSmartContract({ + base: mockBaseTokenRegistry, + override: mockCustomTokenRegistry, + }); + const mockTitleEscrow = getMockTitleEscrow({ beneficiaryValue: walletAddress, holderValue: walletAddress }); + const mockTitleEscrowFactory = getMockTitleEscrowFactory({ getAddressValue: mockedLastTitleEscrowAddress }); + + mockRestoreTitle.mockReturnValue({ + hash: "hash", + wait: () => Promise.resolve({ transactionHash: "transactionHash" }), + }); beforeEach(() => { delete process.env.OA_PRIVATE_KEY; @@ -41,40 +76,10 @@ describe("title-escrow", () => { mockedConnectERC721.mockReset(); mockedTitleEscrowFactory.mockReset(); mockedConnectTitleEscrowFactory.mockReset(); - - mockedBeneficiary.mockReturnValue(mockedLastBeneficiary); - mockedHolder.mockReturnValue(mockedLastHolder); - mockRestoreTitle.mockReturnValue({ - hash: "hash", - wait: () => Promise.resolve({ transactionHash: "transactionHash" }), - }); - mockTransferEvent.mockReturnValue({ - address: "0x1122", - topics: ["0x00000", null, null, "0x12345"], - }); - mockQueryFilter.mockReturnValue([ - { - args: [mockedLastTitleEscrowAddress, "0x1122"], - }, - ]); - - mockedConnectTitleEscrowFactory.mockReturnValue({ - beneficiary: mockedBeneficiary, - holder: mockedHolder, - }); - mockedConnectERC721.mockReturnValue({ - restore: mockRestoreTitle, - filters: { Transfer: mockTransferEvent }, - queryFilter: mockQueryFilter, - callStatic: { - restore: mockCallStaticRestoreTitle, - }, - }); - mockedBeneficiary.mockClear(); - mockedHolder.mockClear(); + mockedConnectERC721.mockReturnValue(mockTokenRegistry); + mockedConnectTitleEscrowFactory.mockReturnValue(mockTitleEscrow); + mockedConnectTitleEscrowFactoryFactory.mockReturnValue(mockTitleEscrowFactory); mockRestoreTitle.mockClear(); - mockTransferEvent.mockClear(); - mockQueryFilter.mockClear(); mockCallStaticRestoreTitle.mockClear(); }); @@ -84,9 +89,7 @@ describe("title-escrow", () => { ...rejectSurrenderedDocumentParams, key: privateKey, }); - const passedSigner: Wallet = mockedConnectERC721.mock.calls[0][1]; - expect(passedSigner.privateKey).toBe(`0x${privateKey}`); expect(mockedConnectERC721).toHaveBeenCalledWith(rejectSurrenderedDocumentParams.tokenRegistry, passedSigner); expect(mockCallStaticRestoreTitle).toHaveBeenCalledTimes(1); diff --git a/src/implementations/title-escrow/rejectSurrendered.ts b/src/implementations/title-escrow/rejectSurrendered.ts index 098fb0ef..d86cb8e0 100644 --- a/src/implementations/title-escrow/rejectSurrendered.ts +++ b/src/implementations/title-escrow/rejectSurrendered.ts @@ -1,10 +1,11 @@ -import { TradeTrustToken, TradeTrustToken__factory } from "@govtechsg/token-registry/contracts"; +import { TradeTrustToken } from "@govtechsg/token-registry/contracts"; import signale from "signale"; import { getLogger } from "../../logger"; import { getWalletOrSigner } from "../utils/wallet"; import { BaseTitleEscrowCommand as TitleEscrowSurrenderDocumentCommand } from "../../commands/title-escrow/title-escrow-command.type"; import { dryRunMode } from "../utils/dryRun"; import { TransactionReceipt } from "@ethersproject/providers"; +import { connectToTokenRegistry, validateSurrenderMethod } from "./helpers"; const { trace } = getLogger("title-escrow:acceptSurrendered"); @@ -16,7 +17,8 @@ export const rejectSurrendered = async ({ ...rest }: TitleEscrowSurrenderDocumentCommand): Promise => { const wallet = await getWalletOrSigner({ network, ...rest }); - const tokenRegistryInstance: TradeTrustToken = await TradeTrustToken__factory.connect(address, wallet); + const tokenRegistryInstance: TradeTrustToken = await connectToTokenRegistry({ address, wallet }); + await validateSurrenderMethod({ tokenRegistry: tokenRegistryInstance, tokenId, wallet }); if (dryRun) { await dryRunMode({ estimatedGas: await tokenRegistryInstance.estimateGas.restore(tokenId), diff --git a/src/implementations/title-escrow/surrenderDocument.test.ts b/src/implementations/title-escrow/surrenderDocument.test.ts index bc01d275..42524a8c 100644 --- a/src/implementations/title-escrow/surrenderDocument.test.ts +++ b/src/implementations/title-escrow/surrenderDocument.test.ts @@ -2,17 +2,18 @@ import { TitleEscrow__factory, TradeTrustToken__factory } from "@govtechsg/token import { Wallet } from "ethers"; import { BaseTitleEscrowCommand as TitleEscrowSurrenderDocumentCommand } from "../../commands/title-escrow/title-escrow-command.type"; +import { getMockTitleEscrow, getMockTokenRegistry, initMockGetCode, mergeMockSmartContract } from "../testsHelpers"; import { surrenderDocument } from "./surrenderDocument"; jest.mock("@govtechsg/token-registry/contracts"); const surrenderDocumentParams: TitleEscrowSurrenderDocumentCommand = { - tokenRegistry: "0x1122", - tokenId: "0x12345", + tokenRegistry: "0x0000000000000000000000000000000000000001", + tokenId: "0x0000000000000000000000000000000000000000000000000000000000000001", network: "goerli", dryRun: false, }; - +const walletAddress = `0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf`; describe("title-escrow", () => { describe("surrender transferable record", () => { const mockedTradeTrustTokenFactory: jest.Mock = TradeTrustToken__factory as any; @@ -23,10 +24,14 @@ describe("title-escrow", () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore mock static method const mockedConnectTitleEscrowFactory: jest.Mock = mockedTitleEscrowFactory.connect; - const mockedOwnerOf = jest.fn(); + // const mockedOwnerOf = jest.fn(); const mockSurrender = jest.fn(); const mockCallStaticSurrender = jest.fn().mockResolvedValue(undefined); - const mockedTitleEscrowAddress = "0x2133"; + const mockedTitleEscrowAddress = "0x0000000000000000000000000000000000000003"; + initMockGetCode(); + + const mockBaseTokenRegistry = getMockTokenRegistry({ ownerOfValue: mockedTitleEscrowAddress }); + const mockTokenRegistry = mockBaseTokenRegistry; beforeEach(() => { delete process.env.OA_PRIVATE_KEY; @@ -34,23 +39,22 @@ describe("title-escrow", () => { mockedConnectERC721.mockReset(); mockedTitleEscrowFactory.mockReset(); mockedConnectTitleEscrowFactory.mockReset(); - - mockedOwnerOf.mockReturnValue(mockedTitleEscrowAddress); mockSurrender.mockReturnValue({ hash: "hash", wait: () => Promise.resolve({ transactionHash: "transactionHash" }), }); - mockedConnectERC721.mockReturnValue({ - ownerOf: mockedOwnerOf, - }); - mockedConnectTitleEscrowFactory.mockReturnValue({ + + mockedConnectERC721.mockReturnValue(mockTokenRegistry); + + const mockBaseTitleEscrow = getMockTitleEscrow({ beneficiaryValue: walletAddress, holderValue: walletAddress }); + const mockCustomTitleEscrow = { surrender: mockSurrender, callStatic: { surrender: mockCallStaticSurrender, }, - }); - - mockedOwnerOf.mockClear(); + }; + const mockTitleEscrow = mergeMockSmartContract({ base: mockBaseTitleEscrow, override: mockCustomTitleEscrow }); + mockedConnectTitleEscrowFactory.mockReturnValue(mockTitleEscrow); mockSurrender.mockClear(); mockCallStaticSurrender.mockClear(); }); @@ -65,7 +69,6 @@ describe("title-escrow", () => { expect(passedSigner.privateKey).toBe(`0x${privateKey}`); expect(mockedConnectERC721).toHaveBeenCalledWith(surrenderDocumentParams.tokenRegistry, passedSigner); - expect(mockedOwnerOf).toHaveBeenCalledWith(surrenderDocumentParams.tokenId); expect(mockedConnectTitleEscrowFactory).toHaveBeenCalledWith(mockedTitleEscrowAddress, passedSigner); expect(mockCallStaticSurrender).toHaveBeenCalledTimes(1); expect(mockSurrender).toHaveBeenCalledTimes(1); diff --git a/src/implementations/title-escrow/surrenderDocument.ts b/src/implementations/title-escrow/surrenderDocument.ts index 00d62a00..7dfc0e9e 100644 --- a/src/implementations/title-escrow/surrenderDocument.ts +++ b/src/implementations/title-escrow/surrenderDocument.ts @@ -1,7 +1,7 @@ import signale from "signale"; import { getLogger } from "../../logger"; import { getWalletOrSigner } from "../utils/wallet"; -import { connectToTitleEscrow } from "./helpers"; +import { connectToTitleEscrow, validateSurrender } from "./helpers"; import { BaseTitleEscrowCommand as TitleEscrowSurrenderDocumentCommand } from "../../commands/title-escrow/title-escrow-command.type"; import { dryRunMode } from "../utils/dryRun"; import { TransactionReceipt } from "@ethersproject/providers"; @@ -17,7 +17,8 @@ export const surrenderDocument = async ({ }: TitleEscrowSurrenderDocumentCommand): Promise => { const wallet = await getWalletOrSigner({ network, ...rest }); const titleEscrow = await connectToTitleEscrow({ tokenId, address, wallet }); - + const walletAddress = await wallet.getAddress(); + await validateSurrender({ titleEscrow, walletAddress }); if (dryRun) { await dryRunMode({ estimatedGas: await titleEscrow.estimateGas.surrender(), diff --git a/src/implementations/title-escrow/transferHolder.test.ts b/src/implementations/title-escrow/transferHolder.test.ts index 7b6ff058..5832ec4d 100644 --- a/src/implementations/title-escrow/transferHolder.test.ts +++ b/src/implementations/title-escrow/transferHolder.test.ts @@ -2,47 +2,54 @@ import { TitleEscrow__factory, TradeTrustToken__factory } from "@govtechsg/token import { Wallet } from "ethers"; import { TitleEscrowTransferHolderCommand } from "../../commands/title-escrow/title-escrow-command.type"; +import { getMockTitleEscrow, getMockTokenRegistry, initMockGetCode, mergeMockSmartContract } from "../testsHelpers"; import { transferHolder } from "./transferHolder"; jest.mock("@govtechsg/token-registry/contracts"); const transferHolderParams: TitleEscrowTransferHolderCommand = { newHolder: "0xabcd", - tokenId: "0xzyxw", - tokenRegistry: "0x1234", + tokenId: "0x0000000000000000000000000000000000000000000000000000000000000001", + tokenRegistry: "0x0000000000000000000000000000000000000001", network: "goerli", dryRun: false, }; +const walletAddress = `0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf`; + describe("title-escrow", () => { describe("change holder of transferable record", () => { const mockedTradeTrustTokenFactory: jest.Mock = TradeTrustToken__factory as any; + const mockedTokenFactory: jest.Mock = TitleEscrow__factory as any; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore mock static method const mockedConnectERC721: jest.Mock = mockedTradeTrustTokenFactory.connect; - - const mockedTokenFactory: jest.Mock = TitleEscrow__factory as any; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore mock static method const mockedConnectTokenFactory: jest.Mock = mockedTokenFactory.connect; - const mockedOwnerOf = jest.fn(); + // const mockedOwnerOf = jest.fn(); const mockTransferHolder = jest.fn(); const mockCallStaticTransferHolder = jest.fn().mockResolvedValue(undefined); - const mockedTitleEscrowAddress = "0x2133"; - mockedOwnerOf.mockReturnValue(mockedTitleEscrowAddress); + const mockedTitleEscrowAddress = "0x0000000000000000000000000000000000000003"; mockTransferHolder.mockReturnValue({ hash: "hash", wait: () => Promise.resolve({ transactionHash: "transactionHash" }), }); - mockedConnectERC721.mockReturnValue({ - ownerOf: mockedOwnerOf, - }); - mockedConnectTokenFactory.mockReturnValue({ + + initMockGetCode(); + + const mockBaseTokenRegistry = getMockTokenRegistry({ ownerOfValue: mockedTitleEscrowAddress }); + mockedConnectERC721.mockReturnValue(mockBaseTokenRegistry); + + const mockBaseTitleEscrow = getMockTitleEscrow({ beneficiaryValue: walletAddress, holderValue: walletAddress }); + const mockCustomTitleEscrow = { transferHolder: mockTransferHolder, callStatic: { transferHolder: mockCallStaticTransferHolder, }, - }); + }; + const mockTitleEscrow = mergeMockSmartContract({ base: mockBaseTitleEscrow, override: mockCustomTitleEscrow }); + mockedConnectTokenFactory.mockReturnValue(mockTitleEscrow); beforeEach(() => { delete process.env.OA_PRIVATE_KEY; @@ -50,7 +57,6 @@ describe("title-escrow", () => { mockedConnectERC721.mockClear(); mockedTokenFactory.mockClear(); mockedConnectTokenFactory.mockClear(); - mockedOwnerOf.mockClear(); mockTransferHolder.mockClear(); mockCallStaticTransferHolder.mockClear(); }); @@ -66,7 +72,6 @@ describe("title-escrow", () => { expect(passedSigner.privateKey).toBe(`0x${privateKey}`); expect(mockedConnectERC721).toHaveBeenCalledWith(transferHolderParams.tokenRegistry, passedSigner); - expect(mockedOwnerOf).toHaveBeenCalledWith(transferHolderParams.tokenId); expect(mockedConnectTokenFactory).toHaveBeenCalledWith(mockedTitleEscrowAddress, passedSigner); expect(mockCallStaticTransferHolder).toHaveBeenCalledTimes(1); expect(mockTransferHolder).toHaveBeenCalledTimes(1); diff --git a/src/implementations/title-escrow/transferHolder.ts b/src/implementations/title-escrow/transferHolder.ts index aaa21687..86edc5f0 100644 --- a/src/implementations/title-escrow/transferHolder.ts +++ b/src/implementations/title-escrow/transferHolder.ts @@ -1,7 +1,7 @@ import signale from "signale"; import { getLogger } from "../../logger"; import { getWalletOrSigner } from "../utils/wallet"; -import { connectToTitleEscrow } from "./helpers"; +import { connectToTitleEscrow, validateTransferHolder } from "./helpers"; import { TitleEscrowTransferHolderCommand } from "../../commands/title-escrow/title-escrow-command.type"; import { dryRunMode } from "../utils/dryRun"; @@ -19,6 +19,12 @@ export const transferHolder = async ({ }: TitleEscrowTransferHolderCommand): Promise => { const wallet = await getWalletOrSigner({ network, ...rest }); const titleEscrow = await connectToTitleEscrow({ tokenId, address, wallet }); + const walletAddress = await wallet.getAddress(); + await validateTransferHolder({ + walletAddress, + titleEscrow, + to, + }); if (dryRun) { await dryRunMode({ estimatedGas: await titleEscrow.estimateGas.transferHolder(to), diff --git a/src/implementations/title-escrow/transferOwners.test.ts b/src/implementations/title-escrow/transferOwners.test.ts index 9f411473..7bde12d2 100644 --- a/src/implementations/title-escrow/transferOwners.test.ts +++ b/src/implementations/title-escrow/transferOwners.test.ts @@ -2,19 +2,25 @@ import { TitleEscrow__factory, TradeTrustToken__factory } from "@govtechsg/token import { Wallet } from "ethers"; import { TitleEscrowEndorseTransferOfOwnersCommand } from "../../commands/title-escrow/title-escrow-command.type"; +import { getMockTitleEscrow, getMockTokenRegistry, initMockGetCode, mergeMockSmartContract } from "../testsHelpers"; import { transferOwners } from "./transferOwners"; jest.mock("@govtechsg/token-registry/contracts"); const endorseChangeOwnersParams: TitleEscrowEndorseTransferOfOwnersCommand = { - newHolder: "0xabcd", - newOwner: "0fosui", - tokenId: "0xzyxw", - tokenRegistry: "0x1234", + newHolder: "0x0000000000000000000000000000000000000004", + newOwner: "0x0000000000000000000000000000000000000004", + tokenId: "0x0000000000000000000000000000000000000000000000000000000000000001", + tokenRegistry: "0x0000000000000000000000000000000000000001", network: "goerli", dryRun: false, }; +const mockedTitleEscrowAddress = "0x0000000000000000000000000000000000000003"; +// const mockedBeneficiary = "0x0000000000000000000000000000000000000004"; +// const mockedHolder = "0xdsfls"; +const walletAddress = `0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf`; + describe("title-escrow", () => { describe("endorse change of owners of transferable record", () => { const mockedTradeTrustTokenFactory: jest.Mock = TradeTrustToken__factory as any; @@ -24,45 +30,42 @@ describe("title-escrow", () => { const mockedTokenFactory: jest.Mock = TitleEscrow__factory as any; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore mock static method - const mockedConnectTokenFactory: jest.Mock = mockedTokenFactory.connect; - const mockedOwnerOf = jest.fn(); + const mockTransferOwners = jest.fn(); const mockCallStaticTransferOwners = jest.fn().mockResolvedValue(undefined); - const mockedTitleEscrowAddress = "0x2133"; - const mockedBeneficiary = "0xdssfs"; - const mockedHolder = "0xdsfls"; - const mockGetBeneficiary = jest.fn(); - const mockGetHolder = jest.fn(); - mockGetBeneficiary.mockReturnValue(mockedBeneficiary); - mockGetHolder.mockReturnValue(mockedHolder); - mockedConnectERC721.mockReturnValue({ - ownerOf: mockedOwnerOf, + + const mockBaseTokenRegistry = getMockTokenRegistry({ ownerOfValue: mockedTitleEscrowAddress }); + const mockTokenRegistry = mockBaseTokenRegistry; + mockedConnectERC721.mockReturnValue(mockTokenRegistry); + + const mockBaseTitleEscrow = getMockTitleEscrow({ + beneficiaryValue: walletAddress, + holderValue: walletAddress, + nomineeValue: endorseChangeOwnersParams.newOwner, }); - mockedConnectTokenFactory.mockReturnValue({ + const mockCustomTitleEscrow = { transferOwners: mockTransferOwners, - beneficiary: mockGetBeneficiary, - holder: mockGetHolder, callStatic: { transferOwners: mockCallStaticTransferOwners, }, - }); - mockedOwnerOf.mockReturnValue(mockedTitleEscrowAddress); + }; + const mockTitleEscrow = mergeMockSmartContract({ base: mockBaseTitleEscrow, override: mockCustomTitleEscrow }); + mockedConnectTokenFactory.mockReturnValue(mockTitleEscrow); mockTransferOwners.mockReturnValue({ hash: "hash", wait: () => Promise.resolve({ transactionHash: "transactionHash" }), }); + initMockGetCode(); + beforeEach(() => { delete process.env.OA_PRIVATE_KEY; mockedTradeTrustTokenFactory.mockClear(); mockedConnectERC721.mockClear(); mockedTokenFactory.mockClear(); mockedConnectTokenFactory.mockClear(); - mockedOwnerOf.mockClear(); mockTransferOwners.mockClear(); - mockGetBeneficiary.mockClear(); - mockGetHolder.mockClear(); mockCallStaticTransferOwners.mockClear(); }); @@ -77,24 +80,21 @@ describe("title-escrow", () => { expect(passedSigner.privateKey).toBe(`0x${privateKey}`); expect(mockedConnectERC721).toHaveBeenCalledWith(endorseChangeOwnersParams.tokenRegistry, passedSigner); - expect(mockedOwnerOf).toHaveBeenCalledWith(endorseChangeOwnersParams.tokenId); expect(mockedConnectTokenFactory).toHaveBeenCalledWith(mockedTitleEscrowAddress, passedSigner); - expect(mockGetBeneficiary).toHaveBeenCalledTimes(1); - expect(mockGetHolder).toHaveBeenCalledTimes(1); expect(mockCallStaticTransferOwners).toHaveBeenCalledTimes(1); expect(mockTransferOwners).toHaveBeenCalledTimes(1); }); it("should throw an error if new owner and new holder addresses are the same as current owner and holder addressses", async () => { - mockGetBeneficiary.mockReturnValue(endorseChangeOwnersParams.newOwner); - mockGetHolder.mockReturnValue(endorseChangeOwnersParams.newHolder); const privateKey = "0000000000000000000000000000000000000000000000000000000000000001"; await expect( transferOwners({ ...endorseChangeOwnersParams, + newOwner: walletAddress, + newHolder: walletAddress, key: privateKey, }) - ).rejects.toThrow("new owner and new holder addresses are the same as the current owner and holder addresses"); + ).rejects.toThrow("Destination wallet already has the rights of holdership"); }); }); }); diff --git a/src/implementations/title-escrow/transferOwners.ts b/src/implementations/title-escrow/transferOwners.ts index 0d9286b0..33ca7d0c 100644 --- a/src/implementations/title-escrow/transferOwners.ts +++ b/src/implementations/title-escrow/transferOwners.ts @@ -1,7 +1,7 @@ import signale from "signale"; import { getLogger } from "../../logger"; import { getWalletOrSigner } from "../utils/wallet"; -import { connectToTitleEscrow, validateEndorseChangeOwner } from "./helpers"; +import { connectToTitleEscrow, validateEndorseChangeOwner as validateEndorseChangeOwners } from "./helpers"; import { TitleEscrowEndorseTransferOfOwnersCommand } from "../../commands/title-escrow/title-escrow-command.type"; import { dryRunMode } from "../utils/dryRun"; @@ -20,7 +20,8 @@ export const transferOwners = async ({ }: TitleEscrowEndorseTransferOfOwnersCommand): Promise => { const wallet = await getWalletOrSigner({ network, ...rest }); const titleEscrow = await connectToTitleEscrow({ tokenId, address, wallet }); - await validateEndorseChangeOwner({ newHolder, newOwner, titleEscrow }); + const walletAddress = await wallet.getAddress(); + await validateEndorseChangeOwners({ newHolder, newOwner, titleEscrow, walletAddress }); if (dryRun) { await dryRunMode({ estimatedGas: await titleEscrow.estimateGas.transferOwners(newOwner, newHolder), diff --git a/src/implementations/token-registry/issue.test.ts b/src/implementations/token-registry/issue.test.ts index b40342fe..cf52f440 100644 --- a/src/implementations/token-registry/issue.test.ts +++ b/src/implementations/token-registry/issue.test.ts @@ -3,15 +3,16 @@ import { Wallet } from "ethers"; import { TokenRegistryIssueCommand } from "../../commands/token-registry/token-registry-command.type"; import { addAddressPrefix } from "../../utils"; +import { getMockTokenRegistry, initMockGetCode, mergeMockSmartContract } from "../testsHelpers"; import { issueToTokenRegistry } from "./issue"; jest.mock("@govtechsg/token-registry/contracts"); const deployParams: TokenRegistryIssueCommand = { - beneficiary: "0xabcd", - holder: "0xabce", - tokenId: "0xzyxw", - address: "0x1234", + address: "0x0000000000000000000000000000000000000001", + beneficiary: "0x0x0000000000000000000000000000000000000002", + holder: "0x0000000000000000000000000000000000000003", + tokenId: "0x0000000000000000000000000000000000000000000000000000000000000001", network: "goerli", dryRun: false, }; @@ -31,19 +32,26 @@ describe("token-registry", () => { wait: () => Promise.resolve({ transactionHash: "transactionHash" }), }); - const mockTtErc721Contract = { + const mockBaseTokenRegistry = getMockTokenRegistry({}); + const mockCustomTokenRegistry = { mint: mockedIssue, callStatic: { mint: mockCallStaticSafeMint, }, }; + const mockTokenRegistry = mergeMockSmartContract({ + base: mockBaseTokenRegistry, + override: mockCustomTokenRegistry, + }); + + initMockGetCode(); beforeEach(() => { delete process.env.OA_PRIVATE_KEY; mockedTradeTrustTokenFactory.mockClear(); mockCallStaticSafeMint.mockClear(); mockedConnectERC721.mockReset(); - mockedConnectERC721.mockResolvedValue(mockTtErc721Contract); + mockedConnectERC721.mockResolvedValue(mockTokenRegistry); }); it("should pass in the correct params and return the deployed instance", async () => { @@ -86,13 +94,5 @@ describe("token-registry", () => { "No private key found in OA_PRIVATE_KEY, key, key-file, please supply at least one or supply an encrypted wallet path, or provide aws kms signer information" ); }); - - it("should allow errors to bubble up", async () => { - process.env.OA_PRIVATE_KEY = "0000000000000000000000000000000000000000000000000000000000000002"; - mockedConnectERC721.mockImplementation(() => { - throw new Error("An Error"); - }); - await expect(issueToTokenRegistry(deployParams)).rejects.toThrow("An Error"); - }); }); }); diff --git a/src/implementations/token-registry/issue.ts b/src/implementations/token-registry/issue.ts index 1b845468..cc750d39 100644 --- a/src/implementations/token-registry/issue.ts +++ b/src/implementations/token-registry/issue.ts @@ -1,10 +1,11 @@ -import { TradeTrustToken, TradeTrustToken__factory } from "@govtechsg/token-registry/contracts"; +import { TradeTrustToken } from "@govtechsg/token-registry/contracts"; import signale from "signale"; import { getLogger } from "../../logger"; import { getWalletOrSigner } from "../utils/wallet"; import { TokenRegistryIssueCommand } from "../../commands/token-registry/token-registry-command.type"; import { dryRunMode } from "../utils/dryRun"; import { TransactionReceipt } from "@ethersproject/providers"; +import { connectToTokenRegistry } from "../title-escrow/helpers"; const { trace } = getLogger("token-registry:issue"); @@ -18,7 +19,7 @@ export const issueToTokenRegistry = async ({ ...rest }: TokenRegistryIssueCommand): Promise => { const wallet = await getWalletOrSigner({ network, ...rest }); - const tokenRegistry: TradeTrustToken = await TradeTrustToken__factory.connect(address, wallet); + const tokenRegistry: TradeTrustToken = await connectToTokenRegistry({ address, wallet }); if (dryRun) { await dryRunMode({