diff --git a/package.json b/package.json index cbbd004..6d4371f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "web3-hub", - "version": "0.6.7", + "version": "0.7.10", "description": "Make web3 more easily with agnostic library", "source": "src/index.ts", "exports": { diff --git a/src/cardano/index.ts b/src/cardano/index.ts index 6baa94a..c31060f 100644 --- a/src/cardano/index.ts +++ b/src/cardano/index.ts @@ -36,4 +36,12 @@ export class CardanoProvider implements ProviderEntity { async joinPool(address: string, poolId: number, amount: number): Promise { throw new Error('Method not implemented.'); } + + bondExtra(address: string, amount: number): Promise { + throw new Error('Method not implemented.'); + } + + claim(address: string): Promise { + throw new Error('Method not implemented.'); + } } diff --git a/src/entities/provider-entity.ts b/src/entities/provider-entity.ts index 8245f6a..0c0c85f 100644 --- a/src/entities/provider-entity.ts +++ b/src/entities/provider-entity.ts @@ -1,9 +1,11 @@ -import type { Account, Address, Balance } from '@/types' +import type { Account, Address, Balance, Hash } from '@/types' export interface ProviderEntity { connect(): Promise - getBalance(address: string): Promise - signMessage(address: string, message: string): Promise + getBalance(address: Address): Promise + signMessage(address: Address, message: string): Promise signatureVerify(message: string, signature: string, address: Address): boolean - joinPool(address: string, poolId: number, amount: number): Promise + joinPool(address: Address, poolId: number, amount: number): Promise + bondExtra(address: Address, amount: number): Promise + claim(address: Address): Promise } diff --git a/src/icp/connect.spec.ts b/src/icp/connect.spec.ts new file mode 100644 index 0000000..7f68f20 --- /dev/null +++ b/src/icp/connect.spec.ts @@ -0,0 +1,46 @@ +import { NoAvailableAccountsError, NoProviderAvailableError } from '@/errors'; +import { UserRejectedError } from '@/errors/user-rejected-error'; +import { describe, expect, it, vi } from 'vitest'; +import { web3Window } from '..'; +import { connect } from './connect'; + +vi.mock('@/types', () => ({ + web3Window: { + ic: { + plug: { + requestConnect: vi.fn(), + principalId: 'wil-123', + }, + }, + }, +})) + +const principalId = 'wil-123'; + +describe('Connect case', () => { + it('should successfully connects and retrieves an account', async () => { + web3Window.ic.plug.requestConnect.mockResolvedValue(true) + const account = await connect() + + expect(account).toEqual(principalId) + }) + + it('should throws error when no provider is available', async () => { + web3Window.ic.plug.requestConnect.mockResolvedValue(false) + + await expect(connect()).rejects.toThrow(NoProviderAvailableError) + }) + + it('should throws error when no accounts are available', async () => { + web3Window.ic.plug.requestConnect.mockResolvedValue(true) + web3Window.ic.plug.principalId = undefined; + + await expect(connect()).rejects.toThrow(NoAvailableAccountsError) + }) + + it('should throws error when the user rejects the connection', async () => { + web3Window.ic.plug.requestConnect.mockRejectedValue(new Error('The agent creation was rejected')) + + await expect(connect()).rejects.toThrow(UserRejectedError) + }) +}) diff --git a/src/icp/connect.ts b/src/icp/connect.ts new file mode 100644 index 0000000..c6335a9 --- /dev/null +++ b/src/icp/connect.ts @@ -0,0 +1,24 @@ +import { NoAvailableAccountsError, NoProviderAvailableError } from '@/errors'; +import { UserRejectedError } from '@/errors/user-rejected-error'; +import { web3Window } from '@/types'; +import type { PrincipalAddress } from './types'; + +export async function connect(): Promise { + try { + const hasConnected = await web3Window.ic.plug.requestConnect(); + if (!hasConnected) + throw new NoProviderAvailableError() + + const account: PrincipalAddress | undefined = web3Window.ic.plug.principalId; + if (!account || account.length === 0) + throw new NoAvailableAccountsError() + + return account + } + catch (error) { + if ((error as Error).message.toLowerCase().includes('the agent creation was rejected')) + throw new UserRejectedError() + + throw error + } +} diff --git a/src/icp/index.ts b/src/icp/index.ts new file mode 100644 index 0000000..b96f6d2 --- /dev/null +++ b/src/icp/index.ts @@ -0,0 +1,35 @@ +import type { ProviderEntity } from '@/entities/provider-entity'; +import type { Account, Balance } from '..'; +import { connect } from './connect'; + +export class InternetComputerProvider implements ProviderEntity { + async connect(): Promise { + const principalAddress = await connect() + + return [{ name: 'Principal Account', address: principalAddress }] + } + + getBalance(address: string): Promise { + throw new Error('Method not implemented.'); + } + + signMessage(address: string, message: string): Promise { + throw new Error('Method not implemented.'); + } + + signatureVerify(message: string, signature: string, address: string): boolean { + throw new Error('Method not implemented.'); + } + + joinPool(address: string, poolId: number, amount: number): Promise { + throw new Error('Method not implemented.'); + } + + bondExtra(address: string, amount: number): Promise { + throw new Error('Method not implemented.'); + } + + claim(address: string): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/src/icp/types.ts b/src/icp/types.ts new file mode 100644 index 0000000..a0ebd06 --- /dev/null +++ b/src/icp/types.ts @@ -0,0 +1 @@ +export type PrincipalAddress = string diff --git a/src/index.ts b/src/index.ts index 4a2c188..86816ac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from './cardano/index' +export * from './icp/index' export * from './networks' export * from './substrate/dot/index' export * from './substrate/ksm/index' diff --git a/src/networks.ts b/src/networks.ts index ab2afe6..3825533 100644 --- a/src/networks.ts +++ b/src/networks.ts @@ -19,12 +19,19 @@ export const Networks = { decimals: 5, minStakeAmount: 1, // TODO: Validate min stake amount }, + icp: { + id: 4, + name: 'Internet Computer', + decimals: 8, + minStakeAmount: 1, // TODO: Validate min stake amount + }, } export enum Network { POLKADOT = 'dot', KUSAMA = 'ksm', CARDANO = 'ada', + INTERNET_COMPUTER = 'icp', } export type NetworkKey = keyof typeof Networks diff --git a/src/substrate/bond-extra.spec.ts b/src/substrate/bond-extra.spec.ts new file mode 100644 index 0000000..27ba064 --- /dev/null +++ b/src/substrate/bond-extra.spec.ts @@ -0,0 +1,50 @@ +import { UserRejectedError } from '@/errors/user-rejected-error' +import { Networks } from '@/networks' +import { web3FromAddress } from '@polkadot/extension-dapp' +import { describe, expect, it, vi } from 'vitest' +import { bondExtra } from './bond-extra' + +vi.mock('@/networks', () => ({ + Networks: { + validNetwork: { decimals: 10 }, + }, +})) + +vi.mock('@polkadot/extension-dapp', () => ({ + web3FromAddress: vi.fn(), +})) + +const address = 'wil123' +const amount = 100 +const validHash = 'hash123' + +describe('Bond Extra value case', () => { + it('should call bondExtra function with correct params', async () => { + const injector = { signer: 'signer' }; + (web3FromAddress as any).mockResolvedValue(injector) + + const bondExtraMock = vi.fn().mockImplementation(() => ({ + signAndSend: vi.fn().mockResolvedValue({ toString: () => validHash }), + })) + + const api: any = { tx: { nominationPools: { bondExtra: bondExtraMock } } } + const hash = await bondExtra('validNetwork' as any, api, address, amount) + + expect(web3FromAddress).toHaveBeenCalledWith(address) + expect(bondExtraMock).toHaveBeenCalledWith({ + FreeBalance: 100 * 10 ** (Networks as any).validNetwork.decimals, + }) + expect(hash).toEqual(validHash) + }) + + it('should throw error when the tx is rejected by user', async () => { + (web3FromAddress as any).mockResolvedValue({ signer: 'signer' }) + + const bondExtraMock = vi.fn().mockImplementation(() => ({ + signAndSend: vi.fn().mockRejectedValue(new Error('Rejected by user')), + })) + const api: any = { tx: { nominationPools: { bondExtra: bondExtraMock } } } + + await expect(bondExtra('validNetwork' as any, api, address, amount)).rejects.toThrow(UserRejectedError) + }) +}) diff --git a/src/substrate/bond-extra.ts b/src/substrate/bond-extra.ts new file mode 100644 index 0000000..5f7be43 --- /dev/null +++ b/src/substrate/bond-extra.ts @@ -0,0 +1,26 @@ +import { UserRejectedError } from '@/errors/user-rejected-error'; +import type { Network } from '@/networks'; +import { Networks } from '@/networks'; +import type { Address } from '@/types'; +import type { ApiPromise } from '@polkadot/api'; +import { web3FromAddress } from '@polkadot/extension-dapp'; + +export async function bondExtra(network: Network, api: ApiPromise, address: Address, amount: number) { + try { + const precision = Networks[network].decimals + const injector = await web3FromAddress(address) + const tx = api.tx.nominationPools.bondExtra({ + FreeBalance: amount * 10 ** precision, + }); + const signer = { signer: injector.signer } + + const hash = await tx.signAndSend(address, signer) + return hash.toString() + } + catch (error) { + if ((error as Error).message.toLowerCase().includes('rejected by user')) + throw new UserRejectedError() + + throw error + } +} diff --git a/src/substrate/claim.spec.ts b/src/substrate/claim.spec.ts new file mode 100644 index 0000000..3276e83 --- /dev/null +++ b/src/substrate/claim.spec.ts @@ -0,0 +1,53 @@ +import { UserRejectedError } from '@/errors/user-rejected-error'; +import { web3FromAddress } from '@polkadot/extension-dapp'; +import { describe, expect, it, vi } from 'vitest'; +import { claim } from './claim'; + +vi.mock('@polkadot/extension-dapp', () => ({ + web3FromAddress: vi.fn(), +})) + +const address = 'wil123' + +describe('Claim case', () => { + it('should successfully submit claim tx and return hash', async () => { + const injector = { signer: 'signer' }; + (web3FromAddress as any).mockResolvedValue(injector) + + const signAndSendMock = vi.fn().mockResolvedValue({ toString: () => 'hash123' }) + const claimPayoutMock = vi.fn().mockImplementation(() => ({ + signAndSend: signAndSendMock, + })) + + const api: any = { + tx: { + nominationPools: { + claimPayout: claimPayoutMock, + }, + }, + } + + const hash = await claim(api, address) + + expect(web3FromAddress).toHaveBeenCalledWith(address) + expect(claimPayoutMock).toHaveBeenCalled() + expect(signAndSendMock).toHaveBeenCalledWith(address, { signer: injector.signer }) + expect(hash).toBe('hash123') + }) + + it('should throw error when the user reject tx', async () => { + (web3FromAddress as any).mockResolvedValue({ signer: 'signer' }) + + const api: any = { + tx: { + nominationPools: { + claimPayout: vi.fn().mockImplementation(() => ({ + signAndSend: vi.fn().mockRejectedValue(new Error('Rejected by user')), + })), + }, + }, + } + + await expect(claim(api, address)).rejects.toThrow(UserRejectedError) + }) +}) diff --git a/src/substrate/claim.ts b/src/substrate/claim.ts new file mode 100644 index 0000000..d0f14f0 --- /dev/null +++ b/src/substrate/claim.ts @@ -0,0 +1,21 @@ +import { UserRejectedError } from '@/errors/user-rejected-error'; +import type { Address } from '@/types'; +import type { ApiPromise } from '@polkadot/api'; +import { web3FromAddress } from '@polkadot/extension-dapp'; + +export async function claim(api: ApiPromise, address: Address) { + try { + const injector = await web3FromAddress(address) + const tx = api.tx.nominationPools.claimPayout() + const signer = { signer: injector.signer } + + const hash = await tx.signAndSend(address, signer) + return hash.toString() + } + catch (error) { + if ((error as Error).message.toLowerCase().includes('rejected by user')) + throw new UserRejectedError() + + throw error + } +} diff --git a/src/substrate/index.ts b/src/substrate/index.ts index 0accd39..05d58a0 100644 --- a/src/substrate/index.ts +++ b/src/substrate/index.ts @@ -1,7 +1,11 @@ import type { ProviderEntity } from '@/entities/provider-entity'; +import { InvalidNetworkError } from '@/errors/invalid-network-error'; import type { Network } from '@/networks'; +import { Networks } from '@/networks'; import type { Account, Address, Balance } from '@/types'; import { ApiPromise, WsProvider } from '@polkadot/api'; +import { bondExtra } from './bond-extra'; +import { claim } from './claim'; import { connect } from './connect'; import { getBalance } from './get-balance'; import { joinPool } from './join-pool'; @@ -17,6 +21,9 @@ export class SubstrateProvider implements ProviderEntity { rpcProvider: string constructor(network: Network, appName: string, rpcProvider: string) { + if (!Networks[network]) + throw new InvalidNetworkError() + this.network = network this.appName = appName this.rpcProvider = rpcProvider @@ -61,4 +68,16 @@ export class SubstrateProvider implements ProviderEntity { return joinPool(this.network, api, address, poolId, amount) } + + async bondExtra(address: string, amount: number): Promise { + const api = await this.createProvider() + + return bondExtra(this.network, api, address, amount) + } + + async claim(address: string): Promise { + const api = await this.createProvider() + + return claim(api, address) + } } diff --git a/src/substrate/join-pool.spec.ts b/src/substrate/join-pool.spec.ts index 8fabece..7668254 100644 --- a/src/substrate/join-pool.spec.ts +++ b/src/substrate/join-pool.spec.ts @@ -1,4 +1,3 @@ -import { InvalidNetworkError } from '@/errors/invalid-network-error' import { MinAmountError } from '@/errors/min-amount-error' import { UserRejectedError } from '@/errors/user-rejected-error' import { web3FromAddress } from '@polkadot/extension-dapp' @@ -26,14 +25,6 @@ const address = 'wil123' const network = Network.POLKADOT describe('Join pool case', () => { - it('throws InvalidNetworkError for an unsupported network', async () => { - const poolId = 1 - const amount = 10 - - await expect(joinPool('invalidNetwork' as any, {} as any, address, poolId, amount)) - .rejects.toThrow(InvalidNetworkError); - }); - it('throws MinAmountError if the amount is below the minimum stake amount', async () => { const poolId = 1 const invalidAmount = 9 diff --git a/src/substrate/join-pool.ts b/src/substrate/join-pool.ts index 4efa296..ade81ee 100644 --- a/src/substrate/join-pool.ts +++ b/src/substrate/join-pool.ts @@ -1,4 +1,3 @@ -import { InvalidNetworkError } from '@/errors/invalid-network-error'; import { MinAmountError } from '@/errors/min-amount-error'; import { UserRejectedError } from '@/errors/user-rejected-error'; import type { Network } from '@/networks'; @@ -8,10 +7,6 @@ import { web3FromAddress } from '@polkadot/extension-dapp'; export async function joinPool(network: Network, api: ApiPromise, address: string, poolId: number, amount: number) { try { - const selectedNetwork = Networks[network] - if (!selectedNetwork) - throw new InvalidNetworkError() - if (amount < Networks[network].minStakeAmount) throw new MinAmountError() diff --git a/src/types.ts b/src/types.ts index 27d4012..18f15ea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,7 @@ import type { NetworkKey } from './networks' import type { SubstrateProviderProps } from './substrate/types' export type Address = string +export type Hash = string export interface Account { name?: string @@ -20,6 +21,7 @@ export type ProviderBuilderProps = T extends 'dot' | 'ksm' export type Web3Window = { injectedWeb3: any cardano?: any + ic: any } & Window & typeof globalThis export const web3Window = (window as Web3Window) diff --git a/src/web3-provider.ts b/src/web3-provider.ts index 4c0c0df..cdb4eab 100644 --- a/src/web3-provider.ts +++ b/src/web3-provider.ts @@ -2,6 +2,7 @@ import { CardanoProvider } from './cardano'; import type { CardanoProviderProps } from './cardano/types'; import type { ProviderEntity } from './entities/provider-entity'; import { InvalidNetworkError } from './errors/invalid-network-error'; +import { InternetComputerProvider } from './icp'; import type { NetworkData, NetworkKey } from './networks'; import { getNetworkKeyById, isValidNetwork } from './networks'; import { PolkadotProvider } from './substrate/dot'; @@ -33,6 +34,8 @@ export class Web3Provider { return new KusamaProvider(props as SubstrateProviderProps) case 'ada': return new CardanoProvider(props as CardanoProviderProps) + case 'icp': + return new InternetComputerProvider() default: throw new InvalidNetworkError() }