diff --git a/src/__tests__/document-store/grant-role.test.ts b/src/__tests__/document-store/grant-role.test.ts new file mode 100644 index 0000000..db02acb --- /dev/null +++ b/src/__tests__/document-store/grant-role.test.ts @@ -0,0 +1,388 @@ +import './fixtures'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ethers as ethersV5, Wallet as WalletV5 } from 'ethers'; +import { Wallet as WalletV6, Network, ethers as ethersV6 } from 'ethersV6'; +import * as coreModule from '../../core'; + +import { documentStoreGrantRole } from '../../document-store/grant-role'; +import { + MOCK_DOCUMENT_STORE_ADDRESS, + MOCK_TRANSFERABLE_DOCUMENT_STORE_ADDRESS, + MOCK_TT_DOCUMENT_STORE_ADDRESS, + mockDocumentStoreContract, + mockTransferableDocumentStoreContract, + mockTTDocumentStoreContract, + PRIVATE_KEY, + providerV5, + providerV6, +} from './fixtures'; +import { getEthersContractFromProvider } from '../../utils/ethers'; +import { CHAIN_ID } from '../../utils'; +import { supportInterfaceIds } from '../../document-store/supportInterfaceIds'; + +interface ProviderInfo { + Provider: any; + ethersVersion: 'v5' | 'v6'; + contractType: 'DocumentStore' | 'TransferableDocumentStore'; +} + +const providers: ProviderInfo[] = [ + { + Provider: providerV5, + ethersVersion: 'v5', + contractType: 'DocumentStore', + }, + { + Provider: providerV5, + ethersVersion: 'v5', + contractType: 'TransferableDocumentStore', + }, + { + Provider: providerV6, + ethersVersion: 'v6', + contractType: 'DocumentStore', + }, + { + Provider: providerV6, + ethersVersion: 'v6', + contractType: 'TransferableDocumentStore', + }, +]; + +describe('Grant Document Store Role', () => { + const mockRole = '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + const mockAccount = '0x1234567890123456789012345678901234567890'; + const mockChainId = CHAIN_ID.local; + + describe.each(providers)( + 'Grant role with $contractType and ethers version $ethersVersion', + ({ Provider, ethersVersion, contractType }) => { + const isTransferable = contractType === 'TransferableDocumentStore'; + const mockContract = isTransferable + ? mockTransferableDocumentStoreContract + : mockDocumentStoreContract; + const mockTxResponse = isTransferable + ? 'transferable_document_store_grant_role_tx_hash' + : 'document_store_grant_role_tx_hash'; + + const mockDocumentStoreAddress = isTransferable + ? MOCK_TRANSFERABLE_DOCUMENT_STORE_ADDRESS + : MOCK_DOCUMENT_STORE_ADDRESS; + + let wallet: ethersV5.Wallet | ethersV6.Wallet; + + beforeAll(() => { + const mockContractConstructor = (mockContract: any) => vi.fn(() => mockContract); + vi.mocked(getEthersContractFromProvider).mockReturnValue( + mockContractConstructor(mockContract), + ); + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(coreModule, 'checkSupportsInterface').mockImplementation( + async (address, interfaceId) => { + if (isTransferable) { + return interfaceId === supportInterfaceIds.ITransferableDocumentStore; + } + return interfaceId === supportInterfaceIds.IDocumentStore; + }, + ); + mockContract.callStatic.grantRole.mockResolvedValue(true); + mockContract.grantRole.staticCall.mockResolvedValue(true); + + if (ethersVersion === 'v5') { + wallet = new WalletV5(PRIVATE_KEY, Provider as any) as ethersV5.Wallet; + vi.spyOn(wallet, 'getChainId').mockResolvedValue(mockChainId as unknown as number); + } else { + wallet = new WalletV6(PRIVATE_KEY, Provider as any); + vi.spyOn(Provider, 'getNetwork').mockResolvedValue({ + chainId: mockChainId, + } as unknown as Network); + } + }); + + it('should grant role successfully', async () => { + const result = await documentStoreGrantRole( + mockDocumentStoreAddress, + mockRole, + mockAccount, + wallet, + { + chainId: mockChainId, + }, + ); + expect(result).toEqual(mockTxResponse); + expect(coreModule.checkSupportsInterface).toHaveBeenCalled(); + }); + + it('should grant role with explicit contract type', async () => { + const result = await documentStoreGrantRole( + mockDocumentStoreAddress, + mockRole, + mockAccount, + wallet, + { + chainId: mockChainId, + isTransferable, + }, + ); + expect(result).toEqual(mockTxResponse); + expect(coreModule.checkSupportsInterface).not.toHaveBeenCalled(); + }); + + it('should grant role without chainId option', async () => { + const result = await documentStoreGrantRole( + mockDocumentStoreAddress, + mockRole, + mockAccount, + wallet, + { + isTransferable, + }, + ); + expect(result).toEqual(mockTxResponse); + }); + + it('should grant role with gas options', async () => { + const result = await documentStoreGrantRole( + mockDocumentStoreAddress, + mockRole, + mockAccount, + wallet, + { + chainId: mockChainId, + maxFeePerGas: '1000000000', + maxPriorityFeePerGas: '1000000000', + isTransferable, + }, + ); + expect(result).toEqual(mockTxResponse); + }); + + it('should throw when document store address is missing', async () => { + await expect( + documentStoreGrantRole('', mockRole, mockAccount, wallet, { chainId: mockChainId }), + ).rejects.toThrow('Document store address is required'); + }); + + it('should throw when provider is missing', async () => { + const signerWithoutProvider = new (ethersVersion === 'v5' ? WalletV5 : WalletV6)( + '0x'.padEnd(66, '1'), + ); + await expect( + documentStoreGrantRole( + mockDocumentStoreAddress, + mockRole, + mockAccount, + signerWithoutProvider, + { + chainId: mockChainId, + }, + ), + ).rejects.toThrow('Provider is required'); + }); + + it('should throw when role is missing', async () => { + await expect( + documentStoreGrantRole(mockDocumentStoreAddress, '', mockAccount, wallet, { + chainId: mockChainId, + }), + ).rejects.toThrow('Role is required'); + }); + + it('should throw when account is missing', async () => { + await expect( + documentStoreGrantRole(mockDocumentStoreAddress, mockRole, '', wallet, { + chainId: mockChainId, + }), + ).rejects.toThrow('Account is required'); + }); + + it('should throw when callStatic fails', async () => { + const mockError = new Error('callStatic error'); + mockContract.callStatic.grantRole.mockRejectedValue(mockError); + mockContract.grantRole.staticCall.mockRejectedValue(mockError); + await expect( + documentStoreGrantRole(mockDocumentStoreAddress, mockRole, mockAccount, wallet, { + chainId: mockChainId, + isTransferable, + }), + ).rejects.toThrow('Pre-check (callStatic) for grant-role failed'); + }); + + it('should fallback to TT Document Store when ERC-165 interfaces not supported', async () => { + vi.spyOn(coreModule, 'checkSupportsInterface').mockResolvedValue(false); + const mockContractConstructor = (mockContract: any) => vi.fn(() => mockContract); + vi.mocked(getEthersContractFromProvider).mockReturnValue( + mockContractConstructor(mockContract), + ); + const result = await documentStoreGrantRole( + mockDocumentStoreAddress, + mockRole, + mockAccount, + wallet, + { + chainId: mockChainId, + }, + ); + expect(result).toBeDefined(); + expect(coreModule.checkSupportsInterface).toHaveBeenCalledTimes(2); + }); + + it('should handle invalid role format gracefully', async () => { + const invalidRole = 'invalid-role'; + mockContract.callStatic.grantRole.mockRejectedValue(new Error('Invalid role format')); + mockContract.grantRole.staticCall.mockRejectedValue(new Error('Invalid role format')); + await expect( + documentStoreGrantRole(mockDocumentStoreAddress, invalidRole, mockAccount, wallet, { + chainId: mockChainId, + isTransferable, + }), + ).rejects.toThrow('Pre-check (callStatic) for grant-role failed'); + }); + + it('should handle already granted role', async () => { + mockContract.callStatic.grantRole.mockRejectedValue(new Error('Role already granted')); + mockContract.grantRole.staticCall.mockRejectedValue(new Error('Role already granted')); + await expect( + documentStoreGrantRole(mockDocumentStoreAddress, mockRole, mockAccount, wallet, { + chainId: mockChainId, + isTransferable, + }), + ).rejects.toThrow('Pre-check (callStatic) for grant-role failed'); + }); + + it('should work with different role and account addresses', async () => { + const differentRole = '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + const differentAccount = '0x9876543210987654321098765432109876543210'; + const result = await documentStoreGrantRole( + mockDocumentStoreAddress, + differentRole, + differentAccount, + wallet, + { + chainId: mockChainId, + isTransferable, + }, + ); + expect(result).toEqual(mockTxResponse); + }); + }, + ); + + describe('TT Document Store (Fallback)', () => { + let wallet: ethersV5.Wallet; + + beforeEach(() => { + vi.clearAllMocks(); + wallet = new WalletV5(PRIVATE_KEY, providerV5 as any); + vi.spyOn(wallet, 'getChainId').mockResolvedValue(mockChainId as unknown as number); + const mockContractConstructor = (mockContract: any) => vi.fn(() => mockContract); + vi.mocked(getEthersContractFromProvider).mockReturnValue( + mockContractConstructor(mockTTDocumentStoreContract), + ); + mockTTDocumentStoreContract.callStatic.grantRole.mockResolvedValue(true); + mockTTDocumentStoreContract.grantRole.staticCall.mockResolvedValue(true); + }); + + it('should auto-detect TT Document Store as fallback when ERC-165 interfaces not supported', async () => { + vi.spyOn(coreModule, 'checkSupportsInterface').mockResolvedValue(false); + const result = await documentStoreGrantRole( + MOCK_TT_DOCUMENT_STORE_ADDRESS, + mockRole, + mockAccount, + wallet, + { + chainId: mockChainId, + }, + ); + expect(result).toEqual('tt_document_store_grant_role_tx_hash'); + expect(coreModule.checkSupportsInterface).toHaveBeenCalledWith( + MOCK_TT_DOCUMENT_STORE_ADDRESS, + supportInterfaceIds.IDocumentStore, + wallet.provider, + ); + expect(coreModule.checkSupportsInterface).toHaveBeenCalledWith( + MOCK_TT_DOCUMENT_STORE_ADDRESS, + supportInterfaceIds.ITransferableDocumentStore, + wallet.provider, + ); + }); + + it('should grant role with TT Document Store (ethers v5)', async () => { + vi.spyOn(coreModule, 'checkSupportsInterface').mockResolvedValue(false); + const result = await documentStoreGrantRole( + MOCK_TT_DOCUMENT_STORE_ADDRESS, + mockRole, + mockAccount, + wallet, + { + chainId: mockChainId, + }, + ); + expect(result).toEqual('tt_document_store_grant_role_tx_hash'); + }); + + it('should grant role with TT Document Store (ethers v6)', async () => { + const walletV6 = new WalletV6(PRIVATE_KEY, providerV6 as any); + vi.spyOn(providerV6, 'getNetwork').mockResolvedValue({ + chainId: mockChainId, + } as unknown as Network); + vi.spyOn(coreModule, 'checkSupportsInterface').mockResolvedValue(false); + const result = await documentStoreGrantRole( + MOCK_TT_DOCUMENT_STORE_ADDRESS, + mockRole, + mockAccount, + walletV6, + { + chainId: mockChainId, + }, + ); + expect(result).toEqual('tt_document_store_grant_role_tx_hash'); + }); + + it('should handle callStatic failure for TT Document Store', async () => { + vi.spyOn(coreModule, 'checkSupportsInterface').mockResolvedValue(false); + const mockError = new Error('TT callStatic error'); + mockTTDocumentStoreContract.callStatic.grantRole.mockRejectedValue(mockError); + mockTTDocumentStoreContract.grantRole.staticCall.mockRejectedValue(mockError); + await expect( + documentStoreGrantRole(MOCK_TT_DOCUMENT_STORE_ADDRESS, mockRole, mockAccount, wallet, { + chainId: mockChainId, + }), + ).rejects.toThrow('Pre-check (callStatic) for grant-role failed'); + }); + + it('should grant role TT Document Store with gas options', async () => { + vi.spyOn(coreModule, 'checkSupportsInterface').mockResolvedValue(false); + const result = await documentStoreGrantRole( + MOCK_TT_DOCUMENT_STORE_ADDRESS, + mockRole, + mockAccount, + wallet, + { + chainId: mockChainId, + maxFeePerGas: '2000000000', + maxPriorityFeePerGas: '1500000000', + }, + ); + expect(result).toEqual('tt_document_store_grant_role_tx_hash'); + }); + + it('should handle already granted role in TT Document Store', async () => { + vi.spyOn(coreModule, 'checkSupportsInterface').mockResolvedValue(false); + mockTTDocumentStoreContract.callStatic.grantRole.mockRejectedValue( + new Error('Role already granted'), + ); + mockTTDocumentStoreContract.grantRole.staticCall.mockRejectedValue( + new Error('Role already granted'), + ); + await expect( + documentStoreGrantRole(MOCK_TT_DOCUMENT_STORE_ADDRESS, mockRole, mockAccount, wallet, { + chainId: mockChainId, + }), + ).rejects.toThrow('Pre-check (callStatic) for grant-role failed'); + }); + }); +}); diff --git a/src/__tests__/document-store/revoke-role.test.ts b/src/__tests__/document-store/revoke-role.test.ts new file mode 100644 index 0000000..dc4addb --- /dev/null +++ b/src/__tests__/document-store/revoke-role.test.ts @@ -0,0 +1,392 @@ +import './fixtures'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ethers as ethersV5, Wallet as WalletV5 } from 'ethers'; +import { Wallet as WalletV6, Network, ethers as ethersV6 } from 'ethersV6'; +import * as coreModule from '../../core'; + +import { documentStoreRevokeRole } from '../../document-store/revoke-role'; +import { + MOCK_DOCUMENT_STORE_ADDRESS, + MOCK_TRANSFERABLE_DOCUMENT_STORE_ADDRESS, + MOCK_TT_DOCUMENT_STORE_ADDRESS, + mockDocumentStoreContract, + mockTransferableDocumentStoreContract, + mockTTDocumentStoreContract, + PRIVATE_KEY, + providerV5, + providerV6, +} from './fixtures'; +import { getEthersContractFromProvider } from '../../utils/ethers'; +import { CHAIN_ID } from '../../utils'; +import { supportInterfaceIds } from '../../document-store/supportInterfaceIds'; + +interface ProviderInfo { + Provider: any; + ethersVersion: 'v5' | 'v6'; + contractType: 'DocumentStore' | 'TransferableDocumentStore'; +} + +const providers: ProviderInfo[] = [ + { + Provider: providerV5, + ethersVersion: 'v5', + contractType: 'DocumentStore', + }, + { + Provider: providerV5, + ethersVersion: 'v5', + contractType: 'TransferableDocumentStore', + }, + { + Provider: providerV6, + ethersVersion: 'v6', + contractType: 'DocumentStore', + }, + { + Provider: providerV6, + ethersVersion: 'v6', + contractType: 'TransferableDocumentStore', + }, +]; + +describe('Revoke Document Store Role', () => { + const mockRole = '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + const mockAccount = '0x1234567890123456789012345678901234567890'; + const mockChainId = CHAIN_ID.local; + + describe.each(providers)( + 'Revoke role with $contractType and ethers version $ethersVersion', + ({ Provider, ethersVersion, contractType }) => { + const isTransferable = contractType === 'TransferableDocumentStore'; + const mockContract = isTransferable + ? mockTransferableDocumentStoreContract + : mockDocumentStoreContract; + const mockTxResponse = isTransferable + ? 'transferable_document_store_revoke_role_tx_hash' + : 'document_store_revoke_role_tx_hash'; + + let wallet: ethersV5.Wallet | ethersV6.Wallet; + + const mockDocumentStoreAddress = isTransferable + ? MOCK_TRANSFERABLE_DOCUMENT_STORE_ADDRESS + : MOCK_DOCUMENT_STORE_ADDRESS; + + beforeAll(() => { + vi.clearAllMocks(); + const mockContractConstructor = (mockContract: any) => vi.fn(() => mockContract); + vi.mocked(getEthersContractFromProvider).mockReturnValue( + mockContractConstructor(mockContract), + ); + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(coreModule, 'checkSupportsInterface').mockImplementation( + async (address, interfaceId) => { + if (isTransferable) { + return interfaceId === supportInterfaceIds.ITransferableDocumentStore; + } + return interfaceId === supportInterfaceIds.IDocumentStore; + }, + ); + mockContract.callStatic.revokeRole.mockResolvedValue(true); + mockContract.revokeRole.staticCall.mockResolvedValue(true); + + if (ethersVersion === 'v5') { + wallet = new WalletV5(PRIVATE_KEY, Provider as any) as ethersV5.Wallet; + vi.spyOn(wallet, 'getChainId').mockResolvedValue(mockChainId as unknown as number); + } else { + wallet = new WalletV6(PRIVATE_KEY, Provider as any); + vi.spyOn(Provider, 'getNetwork').mockResolvedValue({ + chainId: mockChainId, + } as unknown as Network); + } + }); + + it('should revoke role successfully', async () => { + const result = await documentStoreRevokeRole( + mockDocumentStoreAddress, + mockRole, + mockAccount, + wallet, + { + chainId: mockChainId, + }, + ); + expect(result).toEqual(mockTxResponse); + expect(coreModule.checkSupportsInterface).toHaveBeenCalled(); + }); + + it('should revoke role with explicit contract type', async () => { + const result = await documentStoreRevokeRole( + mockDocumentStoreAddress, + mockRole, + mockAccount, + wallet, + { + chainId: mockChainId, + isTransferable, + }, + ); + expect(result).toEqual(mockTxResponse); + expect(coreModule.checkSupportsInterface).not.toHaveBeenCalled(); + }); + + it('should revoke role without chainId option', async () => { + const result = await documentStoreRevokeRole( + mockDocumentStoreAddress, + mockRole, + mockAccount, + wallet, + { + isTransferable, + }, + ); + expect(result).toEqual(mockTxResponse); + }); + + it('should revoke role with gas options', async () => { + const result = await documentStoreRevokeRole( + mockDocumentStoreAddress, + mockRole, + mockAccount, + wallet, + { + chainId: mockChainId, + maxFeePerGas: '1000000000', + maxPriorityFeePerGas: '1000000000', + isTransferable, + }, + ); + expect(result).toEqual(mockTxResponse); + }); + + it('should throw when document store address is missing', async () => { + await expect( + documentStoreRevokeRole('', mockRole, mockAccount, wallet, { chainId: mockChainId }), + ).rejects.toThrow('Document store address is required'); + }); + + it('should throw when provider is missing', async () => { + const signerWithoutProvider = new (ethersVersion === 'v5' ? WalletV5 : WalletV6)( + '0x'.padEnd(66, '1'), + ); + await expect( + documentStoreRevokeRole( + mockDocumentStoreAddress, + mockRole, + mockAccount, + signerWithoutProvider, + { + chainId: mockChainId, + }, + ), + ).rejects.toThrow('Provider is required'); + }); + + it('should throw when role is missing', async () => { + await expect( + documentStoreRevokeRole(mockDocumentStoreAddress, '', mockAccount, wallet, { + chainId: mockChainId, + }), + ).rejects.toThrow('Role is required'); + }); + + it('should throw when account is missing', async () => { + await expect( + documentStoreRevokeRole(mockDocumentStoreAddress, mockRole, '', wallet, { + chainId: mockChainId, + }), + ).rejects.toThrow('Account is required'); + }); + + it('should throw when callStatic fails', async () => { + const mockError = new Error('callStatic error'); + mockContract.callStatic.revokeRole.mockRejectedValue(mockError); + mockContract.revokeRole.staticCall.mockRejectedValue(mockError); + await expect( + documentStoreRevokeRole(mockDocumentStoreAddress, mockRole, mockAccount, wallet, { + chainId: mockChainId, + isTransferable, + }), + ).rejects.toThrow('Pre-check (callStatic) for revoke-role failed'); + }); + + it('should fallback to TT Document Store when ERC-165 interfaces not supported', async () => { + vi.spyOn(coreModule, 'checkSupportsInterface').mockResolvedValue(false); + const mockContractConstructor = (mockContract: any) => vi.fn(() => mockContract); + vi.mocked(getEthersContractFromProvider).mockReturnValue( + mockContractConstructor(mockTTDocumentStoreContract), + ); + const result = await documentStoreRevokeRole( + mockDocumentStoreAddress, + mockRole, + mockAccount, + wallet, + { + chainId: mockChainId, + }, + ); + expect(result).toBeDefined(); + expect(coreModule.checkSupportsInterface).toHaveBeenCalledTimes(2); + vi.mocked(getEthersContractFromProvider).mockReturnValue( + mockContractConstructor(mockContract), + ); + }); + + it('should handle invalid role format gracefully', async () => { + const invalidRole = 'invalid-role'; + mockContract.callStatic.revokeRole.mockRejectedValue(new Error('Invalid role format')); + mockContract.revokeRole.staticCall.mockRejectedValue(new Error('Invalid role format')); + await expect( + documentStoreRevokeRole(mockDocumentStoreAddress, invalidRole, mockAccount, wallet, { + chainId: mockChainId, + isTransferable, + }), + ).rejects.toThrow('Pre-check (callStatic) for revoke-role failed'); + }); + + it('should handle role not granted error', async () => { + mockContract.callStatic.revokeRole.mockRejectedValue(new Error('Role not granted')); + mockContract.revokeRole.staticCall.mockRejectedValue(new Error('Role not granted')); + await expect( + documentStoreRevokeRole(mockDocumentStoreAddress, mockRole, mockAccount, wallet, { + chainId: mockChainId, + isTransferable, + }), + ).rejects.toThrow('Pre-check (callStatic) for revoke-role failed'); + }); + + it('should work with different role and account addresses', async () => { + const differentRole = '0x1111111111111111111111111111111111111111111111111111111111111111'; + const differentAccount = '0x9876543210987654321098765432109876543210'; + const result = await documentStoreRevokeRole( + mockDocumentStoreAddress, + differentRole, + differentAccount, + wallet, + { + chainId: mockChainId, + isTransferable, + }, + ); + expect(result).toEqual(mockTxResponse); + }); + }, + ); + + describe('TT Document Store (Fallback)', () => { + let wallet: ethersV5.Wallet; + + beforeEach(() => { + vi.clearAllMocks(); + wallet = new WalletV5(PRIVATE_KEY, providerV5 as any); + vi.spyOn(wallet, 'getChainId').mockResolvedValue(mockChainId as unknown as number); + const mockContractConstructor = (mockContract: any) => vi.fn(() => mockContract); + vi.mocked(getEthersContractFromProvider).mockReturnValue( + mockContractConstructor(mockTTDocumentStoreContract), + ); + mockTTDocumentStoreContract.callStatic.revokeRole.mockResolvedValue(true); + mockTTDocumentStoreContract.revokeRole.staticCall.mockResolvedValue(true); + }); + + it('should auto-detect TT Document Store as fallback when ERC-165 interfaces not supported', async () => { + vi.spyOn(coreModule, 'checkSupportsInterface').mockResolvedValue(false); + const result = await documentStoreRevokeRole( + MOCK_TT_DOCUMENT_STORE_ADDRESS, + mockRole, + mockAccount, + wallet, + { + chainId: mockChainId, + }, + ); + expect(result).toEqual('tt_document_store_revoke_role_tx_hash'); + expect(coreModule.checkSupportsInterface).toHaveBeenCalledWith( + MOCK_TT_DOCUMENT_STORE_ADDRESS, + supportInterfaceIds.IDocumentStore, + wallet.provider, + ); + expect(coreModule.checkSupportsInterface).toHaveBeenCalledWith( + MOCK_TT_DOCUMENT_STORE_ADDRESS, + supportInterfaceIds.ITransferableDocumentStore, + wallet.provider, + ); + }); + + it('should revoke role with TT Document Store (ethers v5)', async () => { + vi.spyOn(coreModule, 'checkSupportsInterface').mockResolvedValue(false); + const result = await documentStoreRevokeRole( + MOCK_TT_DOCUMENT_STORE_ADDRESS, + mockRole, + mockAccount, + wallet, + { + chainId: mockChainId, + }, + ); + expect(result).toEqual('tt_document_store_revoke_role_tx_hash'); + }); + + it('should revoke role with TT Document Store (ethers v6)', async () => { + const walletV6 = new WalletV6(PRIVATE_KEY, providerV6 as any); + vi.spyOn(providerV6, 'getNetwork').mockResolvedValue({ + chainId: mockChainId, + } as unknown as Network); + vi.spyOn(coreModule, 'checkSupportsInterface').mockResolvedValue(false); + const result = await documentStoreRevokeRole( + MOCK_TT_DOCUMENT_STORE_ADDRESS, + mockRole, + mockAccount, + walletV6, + { + chainId: mockChainId, + }, + ); + expect(result).toEqual('tt_document_store_revoke_role_tx_hash'); + }); + + it('should handle callStatic failure for TT Document Store', async () => { + vi.spyOn(coreModule, 'checkSupportsInterface').mockResolvedValue(false); + const mockError = new Error('TT callStatic error'); + mockTTDocumentStoreContract.callStatic.revokeRole.mockRejectedValue(mockError); + mockTTDocumentStoreContract.revokeRole.staticCall.mockRejectedValue(mockError); + await expect( + documentStoreRevokeRole(MOCK_TT_DOCUMENT_STORE_ADDRESS, mockRole, mockAccount, wallet, { + chainId: mockChainId, + }), + ).rejects.toThrow('Pre-check (callStatic) for revoke-role failed'); + }); + + it('should revoke role TT Document Store with gas options', async () => { + vi.spyOn(coreModule, 'checkSupportsInterface').mockResolvedValue(false); + const result = await documentStoreRevokeRole( + MOCK_TT_DOCUMENT_STORE_ADDRESS, + mockRole, + mockAccount, + wallet, + { + chainId: mockChainId, + maxFeePerGas: '2000000000', + maxPriorityFeePerGas: '1500000000', + }, + ); + expect(result).toEqual('tt_document_store_revoke_role_tx_hash'); + }); + + it('should handle role not granted error in TT Document Store', async () => { + vi.spyOn(coreModule, 'checkSupportsInterface').mockResolvedValue(false); + mockTTDocumentStoreContract.callStatic.revokeRole.mockRejectedValue( + new Error('Role not granted'), + ); + mockTTDocumentStoreContract.revokeRole.staticCall.mockRejectedValue( + new Error('Role not granted'), + ); + await expect( + documentStoreRevokeRole(MOCK_TT_DOCUMENT_STORE_ADDRESS, mockRole, mockAccount, wallet, { + chainId: mockChainId, + }), + ).rejects.toThrow('Pre-check (callStatic) for revoke-role failed'); + }); + }); +}); diff --git a/src/document-store/grant-role.ts b/src/document-store/grant-role.ts new file mode 100644 index 0000000..d09a823 --- /dev/null +++ b/src/document-store/grant-role.ts @@ -0,0 +1,118 @@ +import { + Signer as SignerV6, + Contract as ContractV6, + ContractTransaction as ContractTransactionV6, +} from 'ethersV6'; +import { + Contract as ContractV5, + ContractTransaction as ContractTransactionV5, + Signer as SignerV5, +} from 'ethers'; +import { checkSupportsInterface } from '../core'; +import { supportInterfaceIds } from './supportInterfaceIds'; +import { TT_DOCUMENT_STORE_ABI } from './tt-document-store-abi'; +import { getEthersContractFromProvider, isV6EthersProvider } from '../utils/ethers'; +import { + DocumentStore__factory, + TransferableDocumentStore__factory, +} from '@trustvc/document-store'; +import { getTxOptions } from '../token-registry-functions/utils'; +import { CommandOptions } from './types'; + +/** + * Grants a role to an account on the DocumentStore contract. + * Supports both Ethers v5 and v6 signers. + * Supports three types of document stores: + * 1. DocumentStore (ERC-165 compliant) + * 2. TransferableDocumentStore (ERC-165 compliant) + * 3. TT Document Store (legacy, no ERC-165 support - used as fallback) + * @param {string} documentStoreAddress - The address of the DocumentStore contract. + * @param {string} role - The role to grant (e.g., 'ISSUER', 'REVOKER', 'ADMIN'). + * @param {string} account - The account to grant the role to. + * @param {SignerV5 | SignerV6} signer - Signer instance (Ethers v5 or v6) that authorizes the grant role transaction. + * @param {CommandOptions} options - Optional transaction metadata including gas values and chain ID. + * @returns {Promise} A promise resolving to the transaction result from the grant role call. + * @throws {Error} If the document store address or signer provider is not provided. + * @throws {Error} If the role is invalid. + * @throws {Error} If the `callStatic.grantRole` fails as a pre-check. + */ +export const documentStoreGrantRole = async ( + documentStoreAddress: string, + role: string, + account: string, + signer: SignerV5 | SignerV6, + options: CommandOptions = {}, +): Promise => { + if (!documentStoreAddress) throw new Error('Document store address is required'); + if (!signer.provider) throw new Error('Provider is required'); + if (!role) throw new Error('Role is required'); + if (!account) throw new Error('Account is required'); + + const { chainId, maxFeePerGas, maxPriorityFeePerGas, isTransferable } = options; + + let isDocumentStore = !isTransferable; + let isTransferableDocumentStore = isTransferable; + let isTTDocumentStore = false; + + // Detect contract type by checking interface support + if (isTransferable === undefined) { + [isDocumentStore, isTransferableDocumentStore] = await Promise.all([ + checkSupportsInterface( + documentStoreAddress, + supportInterfaceIds.IDocumentStore, + signer.provider, + ), + checkSupportsInterface( + documentStoreAddress, + supportInterfaceIds.ITransferableDocumentStore, + signer.provider, + ), + ]); + + // If neither DocumentStore nor TransferableDocumentStore is supported, + // fallback to TT Document Store (legacy contract without ERC-165) + if (!isDocumentStore && !isTransferableDocumentStore) { + isTTDocumentStore = true; + } + } + + // Get the appropriate Contract class based on provider version + const Contract = getEthersContractFromProvider(signer.provider); + + // Connect to the appropriate DocumentStore contract based on interface detection + let documentStoreAbi; + if (isTTDocumentStore) { + documentStoreAbi = TT_DOCUMENT_STORE_ABI; + } else { + const DocumentStoreFactory = isTransferableDocumentStore + ? TransferableDocumentStore__factory + : DocumentStore__factory; + documentStoreAbi = DocumentStoreFactory.abi; + } + + const documentStoreContract: ContractV5 | ContractV6 = new Contract( + documentStoreAddress, + documentStoreAbi, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + signer as any, + ); + // Check callStatic (dry run) to ensure transaction will succeed + try { + const isV6 = isV6EthersProvider(signer.provider); + + if (isV6) { + await (documentStoreContract as ContractV6).grantRole.staticCall(role, account); + } else { + await (documentStoreContract as ContractV5).callStatic.grantRole(role, account); + } + } catch (e) { + console.error('callStatic failed:', e); + throw new Error('Pre-check (callStatic) for grant-role failed'); + } + + // Get transaction options (gas settings) + const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); + + // Send the actual transaction + return await documentStoreContract.grantRole(role, account, txOptions); +}; diff --git a/src/document-store/index.ts b/src/document-store/index.ts index 28ac477..c8bd451 100644 --- a/src/document-store/index.ts +++ b/src/document-store/index.ts @@ -1,8 +1,9 @@ -export { documentStoreIssue, IssueOptions } from './issue'; -export { documentStoreRevoke, RevokeOptions } from './revoke'; -// export { revokeDocumentStoreRole } from './revoke-role'; -// export { grantDocumentStoreRole } from './grant-role'; -export { deployDocumentStore, DeployOptions } from './deploy'; +export { documentStoreIssue } from './issue'; +export { documentStoreRevoke } from './revoke'; +export { documentStoreRevokeRole } from './revoke-role'; +export { documentStoreGrantRole } from './grant-role'; +export { documentStoreTransferOwnership } from './transferOwnership'; +export { deployDocumentStore } from './deploy'; export { supportInterfaceIds } from './supportInterfaceIds'; export { DocumentStore__factory, diff --git a/src/document-store/issue.ts b/src/document-store/issue.ts index c0f6a31..d0a4d6d 100644 --- a/src/document-store/issue.ts +++ b/src/document-store/issue.ts @@ -13,12 +13,11 @@ import { Signer as SignerV5, } from 'ethers'; import { getEthersContractFromProvider, isV6EthersProvider } from '../utils/ethers'; -import { CHAIN_ID } from '../utils'; -import { GasValue } from '../token-registry-functions/types'; import { getTxOptions } from '../token-registry-functions/utils'; import { checkSupportsInterface } from '../core'; import { supportInterfaceIds } from './supportInterfaceIds'; import { TT_DOCUMENT_STORE_ABI } from './tt-document-store-abi'; +import { CommandOptions } from './types'; /** * Issues a document hash to the DocumentStore contract. @@ -30,25 +29,18 @@ import { TT_DOCUMENT_STORE_ABI } from './tt-document-store-abi'; * @param {string} documentStoreAddress - The address of the DocumentStore contract. * @param {string} documentHash - The hash of the document to issue (must be a valid hex string). * @param {SignerV5 | SignerV6} signer - Signer instance (Ethers v5 or v6) that authorizes the issue transaction. - * @param {IssueOptions} options - Optional transaction metadata including gas values and chain ID. + * @param {CommandOptions} options - Optional transaction metadata including gas values and chain ID. * @returns {Promise} A promise resolving to the transaction result from the issue call. * @throws {Error} If the document store address or signer provider is not provided. * @throws {Error} If the document hash is invalid. * @throws {Error} If the `callStatic.issue` fails as a pre-check. */ -export interface IssueOptions { - chainId?: CHAIN_ID; - maxFeePerGas?: GasValue; - maxPriorityFeePerGas?: GasValue; - isTransferable?: boolean; -} - const documentStoreIssue = async ( documentStoreAddress: string, documentHash: string, signer: SignerV5 | SignerV6, - options: IssueOptions = {}, + options: CommandOptions = {}, ): Promise => { if (!documentStoreAddress) throw new Error('Document store address is required'); if (!signer.provider) throw new Error('Provider is required'); @@ -82,12 +74,6 @@ const documentStoreIssue = async ( } } - if (!isDocumentStore && !isTransferableDocumentStore && !isTTDocumentStore) { - throw new Error( - 'Contract does not support DocumentStore, TransferableDocumentStore, or TT Document Store interface', - ); - } - // Get the appropriate Contract class based on provider version const Contract = getEthersContractFromProvider(signer.provider); diff --git a/src/document-store/revoke-role.ts b/src/document-store/revoke-role.ts new file mode 100644 index 0000000..5c43d93 --- /dev/null +++ b/src/document-store/revoke-role.ts @@ -0,0 +1,118 @@ +import { + Signer as SignerV6, + Contract as ContractV6, + ContractTransactionResponse as ContractTransactionV6, +} from 'ethersV6'; +import { + Contract as ContractV5, + ContractTransaction as ContractTransactionV5, + Signer as SignerV5, +} from 'ethers'; +import { checkSupportsInterface } from '../core'; +import { supportInterfaceIds } from './supportInterfaceIds'; +import { TT_DOCUMENT_STORE_ABI } from './tt-document-store-abi'; +import { getEthersContractFromProvider, isV6EthersProvider } from '../utils/ethers'; +import { + DocumentStore__factory, + TransferableDocumentStore__factory, +} from '@trustvc/document-store'; +import { getTxOptions } from '../token-registry-functions/utils'; +import { CommandOptions } from './types'; + +/** + * Revokes a role from an account on the DocumentStore contract. + * Supports both Ethers v5 and v6 signers. + * Supports three types of document stores: + * 1. DocumentStore (ERC-165 compliant) + * 2. TransferableDocumentStore (ERC-165 compliant) + * 3. TT Document Store (legacy, no ERC-165 support - used as fallback) + * @param {string} documentStoreAddress - The address of the DocumentStore contract. + * @param {string} role - The role to revoke (e.g., 'ISSUER', 'REVOKER', 'ADMIN'). + * @param {string} account - The account to revoke the role from. + * @param {SignerV5 | SignerV6} signer - Signer instance (Ethers v5 or v6) that authorizes the revoke role transaction. + * @param {CommandOptions} options - Optional transaction metadata including gas values and chain ID. + * @returns {Promise} A promise resolving to the transaction result from the revoke role call. + * @throws {Error} If the document store address or signer provider is not provided. + * @throws {Error} If the role is invalid. + * @throws {Error} If the `callStatic.revokeRole` fails as a pre-check. + */ +export const documentStoreRevokeRole = async ( + documentStoreAddress: string, + role: string, + account: string, + signer: SignerV5 | SignerV6, + options: CommandOptions = {}, +): Promise => { + if (!documentStoreAddress) throw new Error('Document store address is required'); + if (!signer.provider) throw new Error('Provider is required'); + if (!role) throw new Error('Role is required'); + if (!account) throw new Error('Account is required'); + + const { chainId, maxFeePerGas, maxPriorityFeePerGas, isTransferable } = options; + + let isDocumentStore = !isTransferable; + let isTransferableDocumentStore = isTransferable; + let isTTDocumentStore = false; + + // Detect contract type by checking interface support + if (isTransferable === undefined) { + [isDocumentStore, isTransferableDocumentStore] = await Promise.all([ + checkSupportsInterface( + documentStoreAddress, + supportInterfaceIds.IDocumentStore, + signer.provider, + ), + checkSupportsInterface( + documentStoreAddress, + supportInterfaceIds.ITransferableDocumentStore, + signer.provider, + ), + ]); + + // If neither DocumentStore nor TransferableDocumentStore is supported, + // fallback to TT Document Store (legacy contract without ERC-165) + if (!isDocumentStore && !isTransferableDocumentStore) { + isTTDocumentStore = true; + } + } + + // Get the appropriate Contract class based on provider version + const Contract = getEthersContractFromProvider(signer.provider); + + // Connect to the appropriate DocumentStore contract based on interface detection + let documentStoreAbi; + if (isTTDocumentStore) { + documentStoreAbi = TT_DOCUMENT_STORE_ABI; + } else { + const DocumentStoreFactory = isTransferableDocumentStore + ? TransferableDocumentStore__factory + : DocumentStore__factory; + documentStoreAbi = DocumentStoreFactory.abi; + } + + const documentStoreContract: ContractV5 | ContractV6 = new Contract( + documentStoreAddress, + documentStoreAbi, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + signer as any, + ); + // Check callStatic (dry run) to ensure transaction will succeed + try { + const isV6 = isV6EthersProvider(signer.provider); + + if (isV6) { + await (documentStoreContract as ContractV6).revokeRole.staticCall(role, account); + } else { + await (documentStoreContract as ContractV5).callStatic.revokeRole(role, account); + } + } catch (e) { + console.error('callStatic failed:', e); + throw new Error('Pre-check (callStatic) for revoke-role failed'); + } + + // Get transaction options (gas settings) + const txOptions = await getTxOptions(signer, chainId, maxFeePerGas, maxPriorityFeePerGas); + + // Send the actual transaction + return await documentStoreContract.revokeRole(role, account, txOptions); +}; diff --git a/src/document-store/revoke.ts b/src/document-store/revoke.ts index fa17f1e..225e41f 100644 --- a/src/document-store/revoke.ts +++ b/src/document-store/revoke.ts @@ -82,12 +82,6 @@ const documentStoreRevoke = async ( } } - if (!isDocumentStore && !isTransferableDocumentStore && !isTTDocumentStore) { - throw new Error( - 'Contract does not support DocumentStore, TransferableDocumentStore, or TT Document Store interface', - ); - } - // Get the appropriate Contract class based on provider version const Contract = getEthersContractFromProvider(signer.provider); diff --git a/src/document-store/transferOwnership.ts b/src/document-store/transferOwnership.ts new file mode 100644 index 0000000..e8464fd --- /dev/null +++ b/src/document-store/transferOwnership.ts @@ -0,0 +1,74 @@ +import { Signer as SignerV6, ContractTransaction as ContractTransactionV6 } from 'ethersV6'; +import { ContractTransaction as ContractTransactionV5, Signer as SignerV5 } from 'ethers'; +import { documentStoreRevokeRole } from './revoke-role'; + +import { documentStoreGrantRole } from './grant-role'; +import { getRoleString } from './document-store-roles'; +import { CommandOptions } from './types'; + +/** + * Transfers ownership of a DocumentStore contract to a new owner. + * Supports both Ethers v5 and v6 signers. + * Supports three types of document stores: + * 1. DocumentStore (ERC-165 compliant) + * 2. TransferableDocumentStore (ERC-165 compliant) + * 3. TT Document Store (legacy, no ERC-165 support - used as fallback) + * @param {string} documentStoreAddress - The address of the DocumentStore contract. + * @param {string} account - The account to transfer ownership to. + * @param {SignerV5 | SignerV6} signer - Signer instance (Ethers v5 or v6) that authorizes the transfer ownership transaction. + * @param {CommandOptions} options - Optional transaction metadata including gas values and chain ID. + * @returns {Promise<{grantTransaction: Promise; revokeTransaction: Promise}>} A promise resolving to the transaction result from the grant and revoke role calls. + * @throws {Error} If the document store address or signer provider is not provided. + * @throws {Error} If the role is invalid. + * @throws {Error} If the `callStatic.revokeRole` fails as a pre-check. + */ +export const documentStoreTransferOwnership = async ( + documentStoreAddress: string, + account: string, + signer: SignerV5 | SignerV6, + options: CommandOptions = {}, +): Promise<{ + grantTransaction: Promise; + revokeTransaction: Promise; +}> => { + if (!documentStoreAddress) throw new Error('Document store address is required'); + if (!signer.provider) throw new Error('Provider is required'); + if (!account) throw new Error('Account is required'); + + const ownerAddress = await signer.getAddress(); + const roleString = await getRoleString(documentStoreAddress, 'admin', { + provider: signer.provider, + }); + //call the transferOwnership function of the document store contract + + //call grant and revoke function here + const grantTransaction = documentStoreGrantRole( + documentStoreAddress, + roleString, + account, + signer, + options, + ); + //check if the grant transaction is successful + const grantTransactionResult = await grantTransaction; + if (!grantTransactionResult) { + //add custom error message not proceeding eith the revoke transaction + throw new Error('Grant transaction failed, not proceeding with revoke transaction'); + //return the grant transaction result + } + //call revoke function here + const revokeTransaction = documentStoreRevokeRole( + documentStoreAddress, + roleString, + ownerAddress, + signer, + options, + ); + //check if the revoke transaction is successful + const revokeTransactionResult = await revokeTransaction; + if (!revokeTransactionResult) { + throw new Error('Revoke transaction failed'); + //return the revoke transaction result + } + return { grantTransaction, revokeTransaction }; +}; diff --git a/src/document-store/types.ts b/src/document-store/types.ts new file mode 100644 index 0000000..c811512 --- /dev/null +++ b/src/document-store/types.ts @@ -0,0 +1,9 @@ +import { CHAIN_ID } from '../utils'; +import { GasValue } from '../token-registry-functions/types'; + +export interface CommandOptions { + chainId?: CHAIN_ID; + maxFeePerGas?: GasValue; + maxPriorityFeePerGas?: GasValue; + isTransferable?: boolean; +} diff --git a/src/index.ts b/src/index.ts index eac5718..974c908 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,8 +21,8 @@ import { } from './token-registry-v5'; import { deployDocumentStore, - // grantDocumentStoreRole, - // revokeDocumentStoreRole, + documentStoreGrantRole, + documentStoreRevokeRole, documentStoreIssue, documentStoreRevoke, DocumentStore__factory, @@ -57,8 +57,8 @@ export { v5GetEventFromReceipt, v5ComputeInterfaceId, deployDocumentStore, - // grantDocumentStoreRole, - // revokeDocumentStoreRole, + documentStoreGrantRole, + documentStoreRevokeRole, documentStoreIssue, documentStoreRevoke, DocumentStore__factory,