diff --git a/tests/README.md b/tests/README.md index cbfd879..8102bbb 100644 --- a/tests/README.md +++ b/tests/README.md @@ -43,3 +43,5 @@ yarn test:stop:evm # stops and removes env started with --profile evm ## Notes - EVM tooling is only expected on chain v8+ presets. +- The suite requires a **chain v8 preset** (`envs/8.0`, `envs/latest`). The Polymesh SDK dropped v7 + support in v31 and throws on connecting to a v7 node, so `envs/7.2` can no longer be used here. diff --git a/tests/package.json b/tests/package.json index e4f39f8..4ce8bed 100644 --- a/tests/package.json +++ b/tests/package.json @@ -49,7 +49,7 @@ }, "dependencies": { "@polymeshassociation/local-signing-manager": "^4.1.1", - "@polymeshassociation/polymesh-sdk": "30.1.0", + "@polymeshassociation/polymesh-sdk": "31.0.0", "cross-fetch": "^4.1.0", "dotenv": "^16.5.0" }, diff --git a/tests/src/__tests__/rest/subsidy.ts b/tests/src/__tests__/rest/subsidy.ts index 7da717e..d2bb1a6 100644 --- a/tests/src/__tests__/rest/subsidy.ts +++ b/tests/src/__tests__/rest/subsidy.ts @@ -9,7 +9,6 @@ import { quitSubsidyParams, setSubsidyAllowanceParams, } from '~/rest/subsidy'; -import { isChainV7 } from '~/util'; const handles = ['subsidizer', 'beneficiary']; let factory: TestFactory; @@ -92,12 +91,8 @@ describe('Subsidy', () => { options: { processMode: ProcessMode.Submit, signer: beneficiary.signer }, }); - const quitTag = isChainV7(factory.polymeshSdk) - ? 'relayer.removePayingKey' - : 'relayer.removeSubsidy'; - const result = await restClient.subsidy.quitSubsidy(params); - expect(result).toEqual(assertTagPresent(expect, quitTag)); + expect(result).toEqual(assertTagPresent(expect, 'relayer.removeSubsidy')); }); }); diff --git a/tests/src/__tests__/sdk/accounts/balance.ts b/tests/src/__tests__/sdk/accounts/balance.ts new file mode 100644 index 0000000..7f590fe --- /dev/null +++ b/tests/src/__tests__/sdk/accounts/balance.ts @@ -0,0 +1,65 @@ +import { BigNumber, Polymesh } from '@polymeshassociation/polymesh-sdk'; +import { Account } from '@polymeshassociation/polymesh-sdk/types'; + +import { TestFactory } from '~/helpers'; + +let factory: TestFactory; + +/* + Chain v8 changed how a POLYX balance is derived. `free` is now what the Account can actually + spend, and the raw chain values are exposed as `reserved` and `frozen`. +*/ +describe('accountBalance', () => { + let sdk: Polymesh; + let account: Account; + + beforeAll(async () => { + factory = await TestFactory.create({}); + sdk = factory.polymeshSdk; + + account = sdk.accountManagement.getSigningAccount() as Account; + }); + + afterAll(async () => { + await factory.close(); + }); + + it('should expose the full POLYX balance breakdown', async () => { + const balance = await account.getBalance(); + + expect(balance).toEqual({ + free: expect.any(BigNumber), + locked: expect.any(BigNumber), + total: expect.any(BigNumber), + reserved: expect.any(BigNumber), + frozen: expect.any(BigNumber), + }); + }); + + it('should keep the balance components consistent', async () => { + const { free, locked, total, reserved, frozen } = await account.getBalance(); + + expect(total).toEqual(free.plus(locked)); + expect(locked).toEqual(total.minus(free)); + expect(free.gte(0)).toBe(true); + expect(reserved.gte(0)).toBe(true); + expect(frozen.gte(0)).toBe(true); + expect(locked.gte(reserved)).toBe(true); + }); + + it('should report a spendable free balance for a funded Account', async () => { + const { free, total } = await account.getBalance(); + + expect(free.gt(0)).toBe(true); + expect(free.lte(total)).toBe(true); + }); + + it('should return the same balance through accountManagement', async () => { + const [fromAccount, fromManagement] = await Promise.all([ + account.getBalance(), + sdk.accountManagement.getAccountBalance({ account }), + ]); + + expect(fromManagement).toEqual(fromAccount); + }); +}); diff --git a/tests/src/__tests__/sdk/accounts/staking.ts b/tests/src/__tests__/sdk/accounts/staking.ts index 508bcf9..62a012b 100644 --- a/tests/src/__tests__/sdk/accounts/staking.ts +++ b/tests/src/__tests__/sdk/accounts/staking.ts @@ -3,16 +3,17 @@ import { BigNumber, Polymesh } from '@polymeshassociation/polymesh-sdk'; import { Account } from '@polymeshassociation/polymesh-sdk/types'; import { TestFactory } from '~/helpers'; -import { isChainV7 } from '~/util'; let factory: TestFactory; -const handles = ['stash', 'controller', 'payee']; +const handles = ['stash', 'payee']; describe('staking', () => { let sdk: Polymesh; let stash: Account; - let controller: Account; let payee: Account; + let unbondedAccount: Account; + + const bondAmount = new BigNumber(10); beforeAll(async () => { factory = await TestFactory.create({ handles }); @@ -22,21 +23,21 @@ describe('staking', () => { const stashAddress = factory.signingManager.addAccount({ mnemonic: stashMnemonic, }); - const controllerMnemonic = LocalSigningManager.generateAccount(); - const controllerAddress = factory.signingManager.addAccount({ - mnemonic: controllerMnemonic, - }); const payeeMnemonic = LocalSigningManager.generateAccount(); const payeeAddress = factory.signingManager.addAccount({ mnemonic: payeeMnemonic, }); + const unbondedMnemonic = LocalSigningManager.generateAccount(); + const unbondedAddress = factory.signingManager.addAccount({ + mnemonic: unbondedMnemonic, + }); - await factory.createIdentityForAddresses([stashAddress, controllerAddress, payeeAddress]); + await factory.createIdentityForAddresses([stashAddress, payeeAddress, unbondedAddress]); - [stash, controller, payee] = await Promise.all([ + [stash, payee, unbondedAccount] = await Promise.all([ sdk.accountManagement.getAccount({ address: stashAddress }), - sdk.accountManagement.getAccount({ address: controllerAddress }), sdk.accountManagement.getAccount({ address: payeeAddress }), + sdk.accountManagement.getAccount({ address: unbondedAddress }), ]); }); @@ -44,11 +45,16 @@ describe('staking', () => { await factory.close(); }); + it('should return a null payee for an Account that has never bonded', async () => { + const currentPayee = await unbondedAccount.staking.getPayee(); + + expect(currentPayee).toBeNull(); + }); + it('should allow an account to bond polyx', async () => { const bondTx = await sdk.staking.bond( { - amount: new BigNumber(10), - controller: stash, + amount: bondAmount, payee: stash, autoStake: true, }, @@ -69,30 +75,32 @@ describe('staking', () => { expect(currentLedger?.stash.address).toEqual(stash.address); }); - it('should allow for a controller to be reassigned', async () => { - if (!isChainV7(sdk)) { - return; - } + it('should report bonded POLYX as reserved on the stash balance', async () => { + const { free, locked, total, reserved, frozen } = await stash.getBalance(); - const setControllerTx = await sdk.staking.setController( - { controller }, - { signingAccount: stash } - ); - - await expect(setControllerTx.run()).resolves.not.toThrow(); + expect(reserved.gte(bondAmount)).toBe(true); + expect(total).toEqual(free.plus(locked)); + expect(locked).toEqual(total.minus(free)); + expect(frozen.gte(0)).toBe(true); + }); + it('should reject pairing a stash that is already its own controller', async () => { + /* + As of chain v8 a stash is bonded as its own controller, so `setController` only has an + effect for legacy stashes that still have a separate controller + */ const currentController = await stash.staking.getController(); - expect(currentController?.address).toEqual(controller.address); + expect(currentController?.address).toEqual(stash.address); + + const setControllerTx = await sdk.staking.setController({ signingAccount: stash }); + + await expect(setControllerTx.run()).rejects.toThrow(/AlreadyPaired/); }); it('should allow for a payee to be reassigned', async () => { - if (!isChainV7(sdk)) { - return; - } - const setPayeeTx = await sdk.staking.setPayee( { payee, autoStake: false }, - { signingAccount: controller } + { signingAccount: stash } ); await expect(setPayeeTx.run()).resolves.not.toThrow(); @@ -103,11 +111,7 @@ describe('staking', () => { }); it('should allow for the stash to bond extra', async () => { - if (!isChainV7(sdk)) { - return; - } - - const ledgerBefore = await controller.staking.getLedger(); + const ledgerBefore = await stash.staking.getLedger(); const bondMoreTx = await sdk.staking.bondExtra( { amount: new BigNumber(5) }, @@ -116,30 +120,19 @@ describe('staking', () => { await expect(bondMoreTx.run()).resolves.not.toThrow(); - const ledgerAfter = await controller.staking.getLedger(); + const ledgerAfter = await stash.staking.getLedger(); expect(ledgerBefore?.total.plus(5)).toEqual(ledgerAfter?.total); }); it('should allow for a controller to unbond', async () => { - if (!isChainV7(sdk)) { - return; - } - - const unbondTx = await sdk.staking.unbond( - { amount: new BigNumber(10) }, - { signingAccount: controller } - ); + const unbondTx = await sdk.staking.unbond({ amount: bondAmount }, { signingAccount: stash }); await expect(unbondTx.run()).resolves.not.toThrow(); }); it('should allow for the controller to call withdraw', async () => { - if (!isChainV7(sdk)) { - return; - } - - const withdraw = await sdk.staking.withdraw({ signingAccount: controller }); + const withdraw = await sdk.staking.withdraw({ signingAccount: stash }); await expect(withdraw.run()).resolves.not.toThrow(); }); diff --git a/tests/src/__tests__/sdk/assets/fundingRound.ts b/tests/src/__tests__/sdk/assets/fundingRound.ts new file mode 100644 index 0000000..41c26a9 --- /dev/null +++ b/tests/src/__tests__/sdk/assets/fundingRound.ts @@ -0,0 +1,79 @@ +import { BigNumber, Polymesh } from '@polymeshassociation/polymesh-sdk'; +import { FungibleAsset } from '@polymeshassociation/polymesh-sdk/types'; + +import { TestFactory } from '~/helpers'; +import { createAsset } from '~/sdk/assets/createAsset'; +import { issueTokens } from '~/sdk/assets/issueTokens'; + +let factory: TestFactory; + +describe('getIssuedInFundingRound', () => { + let sdk: Polymesh; + let asset: FungibleAsset; + + const firstRound = 'Series A'; + const secondRound = 'Series B'; + const initialSupply = new BigNumber(100); + + beforeAll(async () => { + factory = await TestFactory.create({}); + sdk = factory.polymeshSdk; + + asset = await createAsset(sdk, { + ticker: factory.nextTicker(), + name: 'Funding round test', + isDivisible: true, + initialSupply, + fundingRound: firstRound, + }); + }); + + afterAll(async () => { + await factory.close(); + }); + + it('should report the initial supply against the initial funding round', async () => { + const [currentRound, issued] = await Promise.all([ + asset.currentFundingRound(), + asset.getIssuedInFundingRound(firstRound), + ]); + + expect(currentRound).toEqual(firstRound); + expect(issued).toEqual(initialSupply); + }); + + it('should report zero for a funding round the Asset never had', async () => { + const issued = await asset.getIssuedInFundingRound('Never Happened'); + + expect(issued).toEqual(new BigNumber(0)); + }); + + it('should accumulate further issuance into the current funding round', async () => { + const extra = new BigNumber(25); + + await issueTokens(asset, extra); + + const issued = await asset.getIssuedInFundingRound(firstRound); + + expect(issued).toEqual(initialSupply.plus(extra)); + }); + + it('should track each funding round separately', async () => { + const modifyTx = await asset.modify({ fundingRound: secondRound }); + await modifyTx.run(); + + expect(modifyTx.isSuccess).toBe(true); + await expect(asset.currentFundingRound()).resolves.toEqual(secondRound); + + const secondRoundAmount = new BigNumber(40); + await issueTokens(asset, secondRoundAmount); + + const [firstRoundIssued, secondRoundIssued] = await Promise.all([ + asset.getIssuedInFundingRound(firstRound), + asset.getIssuedInFundingRound(secondRound), + ]); + + expect(firstRoundIssued).toEqual(initialSupply.plus(25)); + expect(secondRoundIssued).toEqual(secondRoundAmount); + }); +}); diff --git a/tests/src/__tests__/sdk/assets/manageCheckpoints.ts b/tests/src/__tests__/sdk/assets/manageCheckpoints.ts index 2a6f48b..f273559 100644 --- a/tests/src/__tests__/sdk/assets/manageCheckpoints.ts +++ b/tests/src/__tests__/sdk/assets/manageCheckpoints.ts @@ -22,7 +22,18 @@ describe('manageCheckpoints', () => { await factory.close(); }); + it('should report a null next Checkpoint for an Asset without Schedules', async () => { + await expect(asset.checkpoints.schedules.getNextCheckpoint()).resolves.toBeNull(); + }); + it('should execute mangeCheckpoints without errors', async () => { await expect(manageCheckpoints(sdk, asset)).resolves.not.toThrow(); }); + + it('should report a null next Checkpoint once every Schedule has been removed', async () => { + const schedules = await asset.checkpoints.schedules.get(); + expect(schedules).toHaveLength(0); + + await expect(asset.checkpoints.schedules.getNextCheckpoint()).resolves.toBeNull(); + }); }); diff --git a/tests/src/__tests__/sdk/assets/tickerRegistrationConfig.ts b/tests/src/__tests__/sdk/assets/tickerRegistrationConfig.ts new file mode 100644 index 0000000..dcfaa5a --- /dev/null +++ b/tests/src/__tests__/sdk/assets/tickerRegistrationConfig.ts @@ -0,0 +1,71 @@ +import { BigNumber, Polymesh } from '@polymeshassociation/polymesh-sdk'; +import { KnownAssetType } from '@polymeshassociation/polymesh-sdk/types'; + +import { TestFactory } from '~/helpers'; + +let factory: TestFactory; + +describe('tickerRegistrationConfig', () => { + let sdk: Polymesh; + let maxTickerLength: BigNumber; + + beforeAll(async () => { + factory = await TestFactory.create({}); + sdk = factory.polymeshSdk; + + ({ maxTickerLength } = await sdk.assets.getTickerRegistrationConfig()); + }); + + afterAll(async () => { + await factory.close(); + }); + + it('should fetch the chain wide ticker registration config', async () => { + const config = await sdk.assets.getTickerRegistrationConfig(); + + expect(config).toEqual({ + maxTickerLength: expect.any(BigNumber), + registrationLength: expect.anything(), + }); + + expect(config.maxTickerLength.gt(0)).toBe(true); + + // a null `registrationLength` means reservations never expire + if (config.registrationLength !== null) { + expect(config.registrationLength.gt(0)).toBe(true); + } + }); + + it('should reject reserving a ticker longer than the chain allows', async () => { + const tooLong = 'A'.repeat(maxTickerLength.toNumber() + 1); + + await expect(sdk.assets.reserveTicker({ ticker: tooLong })).rejects.toThrow(); + }); + + it('should reject creating an Asset with a ticker longer than the chain allows', async () => { + const tooLong = 'A'.repeat(maxTickerLength.toNumber() + 1); + + await expect( + sdk.assets.createAsset({ + ticker: tooLong, + name: 'Too long ticker', + isDivisible: true, + assetType: KnownAssetType.EquityCommon, + }) + ).rejects.toThrow(); + }); + + it('should accept a ticker exactly at the maximum allowed length', async () => { + const max = maxTickerLength.toNumber(); + const base = factory.nextTicker(); + const ticker = base.length >= max ? base.slice(0, max) : base.padEnd(max, 'Z'); + + expect(ticker.length).toEqual(max); + + const reserveTx = await sdk.assets.reserveTicker({ ticker }); + const reservation = await reserveTx.run(); + + expect(reserveTx.isSuccess).toBe(true); + expect(reservation.ticker).toEqual(ticker); + }); +}); diff --git a/tests/src/__tests__/sdk/assets/transferFunds.ts b/tests/src/__tests__/sdk/assets/transferFunds.ts new file mode 100644 index 0000000..bafc22b --- /dev/null +++ b/tests/src/__tests__/sdk/assets/transferFunds.ts @@ -0,0 +1,213 @@ +import { LocalSigningManager } from '@polymeshassociation/local-signing-manager'; +import { BigNumber, Polymesh } from '@polymeshassociation/polymesh-sdk'; +import { + FungibleAsset, + Identity, + KnownNftType, + MetadataType, + NftCollection, + NumberedPortfolio, + ReceiverAffirmationRequirement, +} from '@polymeshassociation/polymesh-sdk/types'; + +import { TestFactory } from '~/helpers'; +import { createAsset } from '~/sdk/assets/createAsset'; +import { createNftCollection } from '~/sdk/assets/createNftCollection'; +import { createPortfolio } from '~/sdk/identities/portfolios'; +import { randomNonce } from '~/util'; + +let factory: TestFactory; + +describe('transferFunds', () => { + let sdk: Polymesh; + let asset: FungibleAsset; + let collection: NftCollection; + let sender: Identity; + let senderPortfolio: NumberedPortfolio; + let receiver: Identity; + let receiverAddress: string; + + const amount = new BigNumber(10); + + beforeAll(async () => { + factory = await TestFactory.create({}); + sdk = factory.polymeshSdk; + + const signingIdentity = await sdk.getSigningIdentity(); + if (!signingIdentity) { + throw new Error('the SDK should have a signing Identity'); + } + sender = signingIdentity; + + receiverAddress = factory.signingManager.addAccount({ + mnemonic: LocalSigningManager.generateAccount(), + }); + + const { + results: [{ did: receiverDid }], + } = await factory.createIdentityForAddresses([receiverAddress]); + + receiver = await sdk.identities.getIdentity({ did: receiverDid }); + + asset = await createAsset(sdk, { + initialSupply: new BigNumber(1000), + isDivisible: true, + }); + + collection = await createNftCollection(sdk, { + ticker: factory.nextTicker(), + nftType: KnownNftType.Derivative, + collectionKeys: [ + { + type: MetadataType.Local, + name: 'img', + spec: { url: 'https://example.com/nft/{id}' }, + }, + ], + }); + + const [pauseAssetCompliance, pauseCollectionCompliance] = await Promise.all([ + asset.compliance.requirements.pause(), + collection.compliance.requirements.pause(), + ]); + + await pauseAssetCompliance.run(); + await pauseCollectionCompliance.run(); + + const issueNftTx = await collection.issue({ + metadata: [ + { + type: MetadataType.Local, + id: new BigNumber(1), + value: 'https://example.com/nft/1', + }, + ], + }); + await issueNftTx.run(); + + senderPortfolio = await createPortfolio(sdk, randomNonce(12)); + }); + + afterAll(async () => { + await factory.close(); + }); + + it('should settle immediately and resolve to undefined within the same Identity', async () => { + const transferTx = await sdk.assets.transferFunds({ + asset, + amount, + from: await sender.portfolios.getPortfolio(), + to: senderPortfolio, + }); + + const result = await transferTx.run(); + + expect(transferTx.isSuccess).toBe(true); + expect(result).toBeUndefined(); + + const [{ total }] = await senderPortfolio.getAssetBalances({ assets: [asset] }); + expect(total).toEqual(amount); + }); + + it('should settle immediately when the receiver auto-affirms', async () => { + await expect(receiver.isMandatoryReceiverAffirmationEnabled()).resolves.toBe(false); + + const transferTx = await sdk.assets.transferFunds({ + asset, + amount, + from: await sender.portfolios.getPortfolio(), + to: await receiver.portfolios.getPortfolio(), + }); + + const result = await transferTx.run(); + + expect(transferTx.isSuccess).toBe(true); + expect(result).toBeUndefined(); + + const [{ total }] = await ( + await receiver.portfolios.getPortfolio() + ).getAssetBalances({ assets: [asset] }); + expect(total).toEqual(amount); + }); + + it('should resolve to a pending Instruction when the receiver must affirm', async () => { + const requireAffirmationTx = await receiver.setMandatoryReceiverAffirmation( + { requirement: ReceiverAffirmationRequirement.Required }, + { signingAccount: receiverAddress } + ); + await requireAffirmationTx.run(); + + expect(requireAffirmationTx.isSuccess).toBe(true); + await expect(receiver.isMandatoryReceiverAffirmationEnabled()).resolves.toBe(true); + + const transferTx = await sdk.assets.transferFunds({ + asset, + amount, + from: await sender.portfolios.getPortfolio(), + to: await receiver.portfolios.getPortfolio(), + memo: 'awaiting affirmation', + }); + + const instruction = await transferTx.run(); + + expect(transferTx.isSuccess).toBe(true); + expect(instruction).toBeDefined(); + + if (!instruction) { + throw new Error( + 'a cross-Identity transfer awaiting affirmation should return an Instruction' + ); + } + + await expect(instruction.isPending()).resolves.toBe(true); + + const affirmTx = await instruction.affirm({}, { signingAccount: receiverAddress }); + await affirmTx.run(); + + expect(affirmTx.isSuccess).toBe(true); + }); + + it('should settle immediately again once the receiver pre-approves the asset', async () => { + const preApproveTx = await asset.settlements.preApprove({ signingAccount: receiverAddress }); + await preApproveTx.run(); + + expect(preApproveTx.isSuccess).toBe(true); + await expect(receiver.isAssetPreApproved(asset)).resolves.toBe(true); + + const transferTx = await sdk.assets.transferFunds({ + asset, + amount, + from: await sender.portfolios.getPortfolio(), + to: await receiver.portfolios.getPortfolio(), + }); + + const result = await transferTx.run(); + + expect(transferTx.isSuccess).toBe(true); + expect(result).toBeUndefined(); + }); + + it('should transfer an NFT between Identities', async () => { + const preApproveTx = await collection.settlements.preApprove({ + signingAccount: receiverAddress, + }); + await preApproveTx.run(); + + const transferTx = await sdk.assets.transferFunds({ + asset: collection, + nfts: [new BigNumber(1)], + from: await sender.portfolios.getPortfolio(), + to: await receiver.portfolios.getPortfolio(), + }); + + const result = await transferTx.run(); + + expect(transferTx.isSuccess).toBe(true); + expect(result).toBeUndefined(); + + const receiverPortfolio = await receiver.portfolios.getPortfolio(); + const holdings = await receiverPortfolio.getCollections({ collections: [collection] }); + + expect(holdings[0].free.map(({ id }) => id.toNumber())).toContain(1); + }); +}); diff --git a/tests/src/__tests__/sdk/identities/childIdentities.ts b/tests/src/__tests__/sdk/identities/childIdentities.ts deleted file mode 100644 index 2459e2e..0000000 --- a/tests/src/__tests__/sdk/identities/childIdentities.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { LocalSigningManager } from '@polymeshassociation/local-signing-manager'; -import { Polymesh } from '@polymeshassociation/polymesh-sdk'; - -import { TestFactory } from '~/helpers'; -import { - assertChildIdentity, - createChildIdentity, - removeChildIdentity, -} from '~/sdk/identities/childIdentities'; -import { manageSecondaryKeys } from '~/sdk/identities/manageSecondaryKeys'; - -let factory: TestFactory; - -describe.skip('manageChildIdentities', () => { - let sdk: Polymesh; - let childAddress: string; - - beforeAll(async () => { - factory = await TestFactory.create({}); - - const mnemonic = LocalSigningManager.generateAccount(); - childAddress = factory.signingManager.addAccount({ mnemonic }); - - sdk = factory.polymeshSdk; - }); - - afterAll(async () => { - await factory.close(); - }); - - it('should execute without errors', async () => { - await expect(manageSecondaryKeys(sdk, childAddress)).resolves.not.toThrow(); - - const childIdentity = await createChildIdentity(sdk, childAddress); - - await expect(assertChildIdentity(sdk, childIdentity.did)).resolves.not.toThrow(); - - await expect(removeChildIdentity(sdk, childIdentity.did)).resolves.not.toThrow(); - }); -}); diff --git a/tests/src/__tests__/sdk/identities/portfolioPreApproval.ts b/tests/src/__tests__/sdk/identities/portfolioPreApproval.ts new file mode 100644 index 0000000..9992385 --- /dev/null +++ b/tests/src/__tests__/sdk/identities/portfolioPreApproval.ts @@ -0,0 +1,110 @@ +import { BigNumber, Polymesh } from '@polymeshassociation/polymesh-sdk'; +import { + FungibleAsset, + Identity, + NumberedPortfolio, +} from '@polymeshassociation/polymesh-sdk/types'; + +import { TestFactory } from '~/helpers'; +import { createAsset } from '~/sdk/assets/createAsset'; +import { createPortfolio } from '~/sdk/identities/portfolios'; +import { randomNonce } from '~/util'; + +let factory: TestFactory; + +describe('portfolioPreApproval', () => { + let sdk: Polymesh; + let asset: FungibleAsset; + let identity: Identity; + let portfolio: NumberedPortfolio; + + beforeAll(async () => { + factory = await TestFactory.create({}); + sdk = factory.polymeshSdk; + + const signingIdentity = await sdk.getSigningIdentity(); + if (!signingIdentity) { + throw new Error('the SDK should have a signing Identity'); + } + identity = signingIdentity; + + asset = await createAsset(sdk, { initialSupply: new BigNumber(100), isDivisible: true }); + portfolio = await createPortfolio(sdk, randomNonce(12)); + }); + + afterAll(async () => { + await factory.close(); + }); + + it('should report the asset as not pre-approved initially', async () => { + const [portfolioApproved, identityApproved] = await Promise.all([ + portfolio.isAssetPreApproved(asset), + identity.isAssetPreApproved(asset), + ]); + + expect(portfolioApproved).toBe(false); + expect(identityApproved).toBe(false); + }); + + it('should pre-approve an asset for a single Portfolio', async () => { + const preApproveTx = await portfolio.preApproveAsset({ asset }); + await preApproveTx.run(); + + expect(preApproveTx.isSuccess).toBe(true); + await expect(portfolio.isAssetPreApproved(asset)).resolves.toBe(true); + }); + + it('should list the asset among the Portfolio pre-approved assets', async () => { + const { data } = await portfolio.preApprovedAssets(); + + expect(data.map(({ id }) => id)).toContain(asset.id); + }); + + it('should not pre-approve the asset at the Identity level', async () => { + const [identityApproved, { data }] = await Promise.all([ + identity.isAssetPreApproved(asset), + identity.preApprovedAssets(), + ]); + + expect(identityApproved).toBe(false); + expect(data.map(({ id }) => id)).not.toContain(asset.id); + }); + + it('should keep Portfolio and Identity pre-approvals independent', async () => { + const preApproveTx = await asset.settlements.preApprove(); + await preApproveTx.run(); + + expect(preApproveTx.isSuccess).toBe(true); + + const [identityApproved, portfolioApproved] = await Promise.all([ + identity.isAssetPreApproved(asset), + portfolio.isAssetPreApproved(asset), + ]); + + expect(identityApproved).toBe(true); + expect(portfolioApproved).toBe(true); + + const removeIdentityApprovalTx = await asset.settlements.removePreApproval(); + await removeIdentityApprovalTx.run(); + + expect(removeIdentityApprovalTx.isSuccess).toBe(true); + + await expect(identity.isAssetPreApproved(asset)).resolves.toBe(false); + await expect(portfolio.isAssetPreApproved(asset)).resolves.toBe(true); + }); + + it('should remove the Portfolio pre-approval', async () => { + const removeTx = await portfolio.removeAssetPreApproval({ asset }); + await removeTx.run(); + + expect(removeTx.isSuccess).toBe(true); + + const [approved, { data }] = await Promise.all([ + portfolio.isAssetPreApproved(asset), + portfolio.preApprovedAssets(), + ]); + + expect(approved).toBe(false); + expect(data.map(({ id }) => id)).not.toContain(asset.id); + }); +}); diff --git a/tests/src/__tests__/sdk/identities/registerDid.ts b/tests/src/__tests__/sdk/identities/registerDid.ts new file mode 100644 index 0000000..2f18250 --- /dev/null +++ b/tests/src/__tests__/sdk/identities/registerDid.ts @@ -0,0 +1,101 @@ +import { LocalSigningManager } from '@polymeshassociation/local-signing-manager'; +import { Polymesh } from '@polymeshassociation/polymesh-sdk'; +import { ClaimType, Identity } from '@polymeshassociation/polymesh-sdk/types'; + +import { wellKnown } from '~/consts'; +import { TestFactory } from '~/helpers'; + +let factory: TestFactory; + +describe('registerDid', () => { + let sdk: Polymesh; + let registrar: Identity; + let registrarAddress: string; + let signingIdentity: Identity; + + beforeAll(async () => { + factory = await TestFactory.create({}); + sdk = factory.polymeshSdk; + + const identity = await sdk.getSigningIdentity(); + if (!identity) { + throw new Error('the SDK should have a signing Identity'); + } + signingIdentity = identity; + + registrarAddress = factory.signingManager.addAccount({ mnemonic: wellKnown.alice.mnemonic }); + registrar = await sdk.identities.getIdentity({ did: wellKnown.alice.did }); + }); + + afterAll(async () => { + await factory.close(); + }); + + it('should identify a DID Registrar', async () => { + await expect(registrar.isDidRegistrar()).resolves.toBe(true); + }); + + it('should not identify a regular Identity as a DID Registrar', async () => { + await expect(signingIdentity.isDidRegistrar()).resolves.toBe(false); + }); + + it('should register a DID for a target Account as a registrar', async () => { + const targetAccount = factory.signingManager.addAccount({ + mnemonic: LocalSigningManager.generateAccount(), + }); + + const registerTx = await sdk.identities.registerDid( + { targetAccount }, + { signingAccount: registrarAddress } + ); + + const newIdentity = await registerTx.run(); + + expect(registerTx.isSuccess).toBe(true); + expect(newIdentity.did).toEqual(expect.any(String)); + + const account = await sdk.accountManagement.getAccount({ address: targetAccount }); + const linkedIdentity = await account.getIdentity(); + + expect(linkedIdentity?.did).toEqual(newIdentity.did); + }); + + it('should reject registering a DID for an Account that already has one', async () => { + const { account } = await signingIdentity.getPrimaryAccount(); + + await expect( + sdk.identities.registerDid( + { targetAccount: account.address }, + { signingAccount: registrarAddress } + ) + ).rejects.toThrow(); + }); + + it('should reject registering a DID when the signer is not a DID Registrar', async () => { + const targetAccount = factory.signingManager.addAccount({ + mnemonic: LocalSigningManager.generateAccount(), + }); + + await expect(sdk.identities.registerDid({ targetAccount })).rejects.toThrow(); + }); + + it('should require the DID Registrar role to issue a CDD claim', async () => { + const claims = [ + { + target: signingIdentity, + claim: { type: ClaimType.CustomerDueDiligence, id: '0x01'.padEnd(66, '0') } as const, + }, + ]; + + // the role check rejects a non registrar before the transaction is prepared + await expect(sdk.claims.addClaims({ claims })).rejects.toThrow(/required roles/); + + const addClaimsTx = await sdk.claims.addClaims( + { claims }, + { signingAccount: registrarAddress } + ); + await addClaimsTx.run(); + + expect(addClaimsTx.isSuccess).toBe(true); + }); +}); diff --git a/tests/src/__tests__/sdk/identities/txGroups.ts b/tests/src/__tests__/sdk/identities/txGroups.ts new file mode 100644 index 0000000..1862b81 --- /dev/null +++ b/tests/src/__tests__/sdk/identities/txGroups.ts @@ -0,0 +1,115 @@ +import { LocalSigningManager } from '@polymeshassociation/local-signing-manager'; +import { Polymesh } from '@polymeshassociation/polymesh-sdk'; +import { + AGENT_TX_GROUP_VALUES, + DID_REGISTRATION_TX_TAGS, + INSTRUCTION_MEDIATION_TX_TAGS, + ISSUANCE_TX_TAGS, + MULTISIG_MANAGEMENT_TX_TAGS, + TX_GROUP_TO_TAGS_MAP, + TxGroup, + TxTags, +} from '@polymeshassociation/polymesh-sdk/types'; + +import { TestFactory } from '~/helpers'; + +let factory: TestFactory; + +/* + Chain v8 rebuilt the transaction groups from the chain's own permission model. These tests + pin the resulting mapping so a regression in the constants is caught without a chain round trip, + and then confirm the newly grantable groups can actually be granted on chain. +*/ +describe('txGroups', () => { + let sdk: Polymesh; + + beforeAll(async () => { + factory = await TestFactory.create({}); + sdk = factory.polymeshSdk; + }); + + afterAll(async () => { + await factory.close(); + }); + + it('should no longer expose a RelayerManagement group', () => { + expect(Object.values(TxGroup)).not.toContain('RelayerManagement'); + expect(TX_GROUP_TO_TAGS_MAP).not.toHaveProperty('RelayerManagement'); + }); + + it('should expose the DidRegistration group', () => { + expect(Object.values(TxGroup)).toContain(TxGroup.DidRegistration); + expect(DID_REGISTRATION_TX_TAGS).toEqual([TxTags.identity.RegisterDid]); + expect(TX_GROUP_TO_TAGS_MAP[TxGroup.DidRegistration]).toEqual([TxTags.identity.RegisterDid]); + }); + + it('should expose the InstructionMediation group with the mediator transactions', () => { + expect(Object.values(TxGroup)).toContain(TxGroup.InstructionMediation); + expect(INSTRUCTION_MEDIATION_TX_TAGS).toEqual( + expect.arrayContaining([ + TxTags.settlement.AffirmInstructionAsMediator, + TxTags.settlement.RejectInstructionAsMediator, + TxTags.settlement.LockInstruction, + TxTags.settlement.UnlockInstruction, + ]) + ); + expect(TX_GROUP_TO_TAGS_MAP[TxGroup.InstructionMediation]).toEqual([ + ...INSTRUCTION_MEDIATION_TX_TAGS, + ]); + }); + + it('should reduce MultiSigManagement to the only permission checked call', () => { + expect(MULTISIG_MANAGEMENT_TX_TAGS).toEqual([TxTags.multiSig.CreateMultisig]); + }); + + it('should make nft.createNftCollection grantable to External Agents', () => { + expect(ISSUANCE_TX_TAGS).toContain(TxTags.nft.CreateNftCollection); + expect(AGENT_TX_GROUP_VALUES).toContain(TxGroup.Issuance); + }); + + it('should not offer the mediation or registration groups to External Agents', () => { + expect(AGENT_TX_GROUP_VALUES).not.toContain(TxGroup.InstructionMediation); + expect(AGENT_TX_GROUP_VALUES).not.toContain(TxGroup.DidRegistration); + }); + + it('should not map any tag for an extrinsic removed from the chain', () => { + const allTags = Object.values(TX_GROUP_TO_TAGS_MAP).flat(); + + ['settlement.addInstructionWithMemo', 'settlement.addAndAffirmInstructionWithMemo'].forEach( + (tag) => { + expect(allTags).not.toContain(tag); + } + ); + }); + + it('should grant the new groups to a secondary key', async () => { + const secondaryKey = factory.signingManager.addAccount({ + mnemonic: LocalSigningManager.generateAccount(), + }); + + const inviteTx = await sdk.accountManagement.inviteAccount({ + targetAccount: secondaryKey, + permissions: { + assets: null, + portfolios: null, + transactionGroups: [TxGroup.InstructionMediation, TxGroup.DidRegistration], + }, + }); + + const authorization = await inviteTx.run(); + + expect(inviteTx.isSuccess).toBe(true); + + const acceptTx = await authorization.accept({ signingAccount: secondaryKey }); + await acceptTx.run(); + + expect(acceptTx.isSuccess).toBe(true); + + const account = await sdk.accountManagement.getAccount({ address: secondaryKey }); + const { transactions } = await account.getPermissions(); + + expect(transactions?.values).toEqual( + expect.arrayContaining([...INSTRUCTION_MEDIATION_TX_TAGS, ...DID_REGISTRATION_TX_TAGS]) + ); + }); +}); diff --git a/tests/src/__tests__/sdk/network/protocolFees.ts b/tests/src/__tests__/sdk/network/protocolFees.ts new file mode 100644 index 0000000..37f6329 --- /dev/null +++ b/tests/src/__tests__/sdk/network/protocolFees.ts @@ -0,0 +1,65 @@ +import { BigNumber, Polymesh } from '@polymeshassociation/polymesh-sdk'; +import { TxTags } from '@polymeshassociation/polymesh-sdk/types'; + +import { TestFactory } from '~/helpers'; + +let factory: TestFactory; + +/* + The protocol fee mapping was corrected against the v8 runtime. Fees for priced operations are + no longer silently reported as zero. +*/ +describe('protocolFees', () => { + let sdk: Polymesh; + + const pricedTags = [ + TxTags.asset.CreateAsset, + TxTags.asset.RegisterUniqueTicker, + TxTags.identity.RegisterDid, + ]; + + beforeAll(async () => { + factory = await TestFactory.create({}); + sdk = factory.polymeshSdk; + }); + + afterAll(async () => { + await factory.close(); + }); + + it('should return a fee for each requested tag', async () => { + const fees = await sdk.network.getProtocolFees({ tags: pricedTags }); + + expect(fees).toHaveLength(pricedTags.length); + + fees.forEach(({ tag, fees: fee }) => { + expect(pricedTags).toContain(tag); + expect(fee).toEqual(expect.any(BigNumber)); + expect(fee.gte(0)).toBe(true); + }); + }); + + it('should not report a zero fee for priced operations', async () => { + const fees = await sdk.network.getProtocolFees({ + tags: [TxTags.asset.CreateAsset, TxTags.asset.RegisterUniqueTicker], + }); + + fees.forEach(({ tag, fees: fee }) => { + expect(fee.gt(0)).toBe(true); + expect(tag).toBeDefined(); + }); + }); + + it('should agree with the fee quoted by a prepared transaction', async () => { + const [{ fees: quotedFee }] = await sdk.network.getProtocolFees({ + tags: [TxTags.asset.RegisterUniqueTicker], + }); + + const reserveTx = await sdk.assets.reserveTicker({ ticker: factory.nextTicker() }); + const { + fees: { protocol }, + } = await reserveTx.getTotalFees(); + + expect(protocol).toEqual(quotedFee); + }); +}); diff --git a/tests/src/__tests__/sdk/settlements/createSto.ts b/tests/src/__tests__/sdk/settlements/createSto.ts index 2080f2d..1269dee 100644 --- a/tests/src/__tests__/sdk/settlements/createSto.ts +++ b/tests/src/__tests__/sdk/settlements/createSto.ts @@ -5,7 +5,6 @@ import { FungibleAsset } from '@polymeshassociation/polymesh-sdk/types'; import { TestFactory } from '~/helpers'; import { createAsset } from '~/sdk/assets/createAsset'; import { createSto } from '~/sdk/settlements/createSto'; -import { isChainV7 } from '~/util'; let factory: TestFactory; @@ -40,10 +39,6 @@ describe('createSto', () => { }); it('should execute without errors', async () => { - if (!isChainV7(sdk)) { - return; - } - await createSto(sdk, investorDid, offeringAsset, raisingAsset); }); }); diff --git a/tests/src/__tests__/sdk/settlements/mediatorLock.ts b/tests/src/__tests__/sdk/settlements/mediatorLock.ts new file mode 100644 index 0000000..3a9a0ec --- /dev/null +++ b/tests/src/__tests__/sdk/settlements/mediatorLock.ts @@ -0,0 +1,90 @@ +import { LocalSigningManager } from '@polymeshassociation/local-signing-manager'; +import { BigNumber, Polymesh } from '@polymeshassociation/polymesh-sdk'; +import { FungibleAsset, Instruction } from '@polymeshassociation/polymesh-sdk/types'; + +import { TestFactory } from '~/helpers'; +import { createAsset } from '~/sdk/assets/createAsset'; +import { + affirmForLock, + assertLegStatuses, + createLockableInstruction, + lockInstruction, + unlockInstruction, +} from '~/sdk/settlements/mediatorLock'; + +let factory: TestFactory; + +describe('mediatorLock', () => { + let sdk: Polymesh; + let asset: FungibleAsset; + let instruction: Instruction; + + let counterPartyDid: string; + let counterPartyAddress: string; + let mediatorDid: string; + let mediatorAddress: string; + + const amount = new BigNumber(10); + + beforeAll(async () => { + factory = await TestFactory.create({}); + sdk = factory.polymeshSdk; + + counterPartyAddress = factory.signingManager.addAccount({ + mnemonic: LocalSigningManager.generateAccount(), + }); + mediatorAddress = factory.signingManager.addAccount({ + mnemonic: LocalSigningManager.generateAccount(), + }); + + ({ + results: [{ did: counterPartyDid }, { did: mediatorDid }], + } = await factory.createIdentityForAddresses([counterPartyAddress, mediatorAddress])); + + asset = await createAsset(sdk, { initialSupply: new BigNumber(100), isDivisible: true }); + }); + + afterAll(async () => { + await factory.close(); + }); + + it('should create a SettleAfterLock instruction with a mediator', async () => { + instruction = await createLockableInstruction(sdk, asset, counterPartyDid, mediatorDid, amount); + + const mediators = await instruction.getMediators(); + + expect(mediators.map(({ identity: { did } }) => did)).toContain(mediatorDid); + }); + + it('should be affirmed by every party including the mediator', async () => { + await expect( + affirmForLock(instruction, counterPartyDid, counterPartyAddress, mediatorAddress) + ).resolves.not.toThrow(); + }); + + it('should be locked for execution by the mediator', async () => { + await expect(lockInstruction(instruction, mediatorAddress)).resolves.not.toThrow(); + }); + + it('should report a status for each leg', async () => { + await expect(assertLegStatuses(instruction)).resolves.not.toThrow(); + }); + + it('should be unlocked by the mediator, starting the relock cooldown', async () => { + await expect(unlockInstruction(instruction, mediatorAddress)).resolves.not.toThrow(); + }); + + it('should not allow an immediate relock while the cooldown is active', async () => { + const { cooldownEndsAt } = await instruction.getRelockStatus(); + + if (!cooldownEndsAt || cooldownEndsAt <= new Date()) { + // the cooldown has already elapsed, a relock is legitimately allowed + return; + } + + // the cooldown is checked while the transaction is prepared, before it is ever submitted + await expect(instruction.lockForExecution({ signingAccount: mediatorAddress })).rejects.toThrow( + /cannot be locked for execution/ + ); + }); +}); diff --git a/tests/src/__tests__/sdk/settlements/tradeAssets.ts b/tests/src/__tests__/sdk/settlements/tradeAssets.ts index 4cfff53..70a339a 100644 --- a/tests/src/__tests__/sdk/settlements/tradeAssets.ts +++ b/tests/src/__tests__/sdk/settlements/tradeAssets.ts @@ -6,7 +6,6 @@ import { TestFactory } from '~/helpers'; import { createAsset } from '~/sdk/assets/createAsset'; import { tradeAssets } from '~/sdk/settlements/tradeAssets'; import { tradeOffChainAssets } from '~/sdk/settlements/tradeOffChainAssets'; -import { isChainV7 } from '~/util'; let factory: TestFactory; let counterPartyDid: string; @@ -69,10 +68,6 @@ describe('tradeAssets', () => { }); it('should transfer off chain assets', async () => { - if (!isChainV7(sdk)) { - return; - } - const bid = { ticker: bidOffChainTicker, offChainAmount: new BigNumber(10), diff --git a/tests/src/__tests__/sdk/settlements/transferErrors.ts b/tests/src/__tests__/sdk/settlements/transferErrors.ts new file mode 100644 index 0000000..048231f --- /dev/null +++ b/tests/src/__tests__/sdk/settlements/transferErrors.ts @@ -0,0 +1,149 @@ +import { LocalSigningManager } from '@polymeshassociation/local-signing-manager'; +import { BigNumber, Polymesh } from '@polymeshassociation/polymesh-sdk'; +import * as sdkTypes from '@polymeshassociation/polymesh-sdk/types'; +import { FungibleAsset, Identity, TransferError } from '@polymeshassociation/polymesh-sdk/types'; + +import { TestFactory } from '~/helpers'; +import { createAsset } from '~/sdk/assets/createAsset'; + +let factory: TestFactory; + +/* + Chain v8 aligned `TransferError` with the errors the chain actually reports. `canTransfer` + now reports those failures in its breakdown instead of throwing. +*/ +describe('transferErrors', () => { + let sdk: Polymesh; + let asset: FungibleAsset; + let indivisibleAsset: FungibleAsset; + let sender: Identity; + let receiver: Identity; + + beforeAll(async () => { + factory = await TestFactory.create({}); + sdk = factory.polymeshSdk; + + const signingIdentity = await sdk.getSigningIdentity(); + if (!signingIdentity) { + throw new Error('the SDK should have a signing Identity'); + } + sender = signingIdentity; + + const receiverAddress = factory.signingManager.addAccount({ + mnemonic: LocalSigningManager.generateAccount(), + }); + + const { + results: [{ did: receiverDid }], + } = await factory.createIdentityForAddresses([receiverAddress]); + + receiver = await sdk.identities.getIdentity({ did: receiverDid }); + + // both assets are created by the same signing Account, so they cannot be submitted in parallel + asset = await createAsset(sdk, { initialSupply: new BigNumber(100), isDivisible: true }); + indivisibleAsset = await createAsset(sdk, { + initialSupply: new BigNumber(100), + isDivisible: false, + }); + + const pauseTx = await asset.compliance.requirements.pause(); + await pauseTx.run(); + + const pauseIndivisibleTx = await indivisibleAsset.compliance.requirements.pause(); + await pauseIndivisibleTx.run(); + }); + + afterAll(async () => { + await factory.close(); + }); + + it('should no longer export the TransferStatus enum', () => { + expect(sdkTypes).not.toHaveProperty('TransferStatus'); + }); + + it('should drop the transfer errors that cannot occur on chain v8', () => { + const members = Object.values(TransferError); + + expect(members).not.toContain('InvalidReceiverCdd'); + expect(members).not.toContain('InvalidSenderCdd'); + expect(members).not.toContain('ScopeClaimMissing'); + expect(members).not.toContain('InvalidReceiverPortfolio'); + }); + + it('should add an error for an inactive receiving Identity', () => { + expect(Object.values(TransferError)).toContain(TransferError.InvalidReceiverIdentity); + }); + + it('should report a valid transfer as possible', async () => { + const breakdown = await asset.settlements.canTransfer({ + from: sender, + to: receiver, + amount: new BigNumber(10), + }); + + expect(breakdown.general).toEqual([]); + expect(breakdown.result).toBe(true); + }); + + it('should allow a transfer within the same Identity', async () => { + // as of chain v8 an Identity may move funds between its own holders, so this is not a SelfTransfer + const breakdown = await asset.settlements.canTransfer({ + from: sender, + to: sender, + amount: new BigNumber(10), + }); + + expect(breakdown.general).toEqual([]); + expect(breakdown.result).toBe(true); + }); + + it('should report an insufficient balance instead of throwing', async () => { + const breakdown = await asset.settlements.canTransfer({ + from: sender, + to: receiver, + amount: new BigNumber(1000000), + }); + + expect(breakdown.general).toEqual(expect.arrayContaining([expect.stringContaining('Balance')])); + expect(breakdown.result).toBe(false); + }); + + it('should reject a decimal amount for a non divisible asset before reaching the chain', async () => { + /* + `InvalidGranularity` remains part of the enum for breakdowns produced by the chain, but the + SDK rejects a decimal amount for an indivisible Asset while encoding the call + */ + await expect( + indivisibleAsset.settlements.canTransfer({ + from: sender, + to: receiver, + amount: new BigNumber(1.5), + }) + ).rejects.toThrow(/indivisible/); + + const breakdown = await indivisibleAsset.settlements.canTransfer({ + from: sender, + to: receiver, + amount: new BigNumber(1), + }); + + expect(breakdown.general).not.toContain(TransferError.InvalidGranularity); + }); + + it('should report frozen transfers instead of throwing', async () => { + const freezeTx = await asset.freeze(); + await freezeTx.run(); + + const breakdown = await asset.settlements.canTransfer({ + from: sender, + to: receiver, + amount: new BigNumber(10), + }); + + expect(breakdown.general).toContain(TransferError.TransfersFrozen); + expect(breakdown.result).toBe(false); + + const unfreezeTx = await asset.unfreeze(); + await unfreezeTx.run(); + }); +}); diff --git a/tests/src/helpers/factory.ts b/tests/src/helpers/factory.ts index fb48bd4..844092d 100644 --- a/tests/src/helpers/factory.ts +++ b/tests/src/helpers/factory.ts @@ -7,7 +7,7 @@ import { RestClient } from '~/rest'; import { ProcessMode } from '~/rest/common'; import { Identity } from '~/rest/identities'; import { RestErrorResult, ResultSet } from '~/rest/interfaces'; -import { alphabet, isChainV7, randomNonce } from '~/util'; +import { alphabet, randomNonce } from '~/util'; import { VaultClient } from '~/vault'; const nonceLength = 9; @@ -109,29 +109,25 @@ export class TestFactory { } public async createIdentityForAddresses(addresses: string[]): Promise> { - if (!isChainV7(this.polymeshSdk)) { - await this.prefundAddresses(addresses); - await this.selfRegisterAddresses(addresses); - await this.fundTestAccountsFromAdmin(addresses); - - const results = await Promise.all( - addresses.map(async (address) => { - const { identity } = await this.restClient.get<{ identity: Identity }>( - `/accounts/${address}` - ); - - if (!identity) { - throw new Error(`Identity was not found for ${address} after registration`); - } - - return identity; - }) - ); + await this.prefundAddresses(addresses); + await this.selfRegisterAddresses(addresses); + await this.fundTestAccountsFromAdmin(addresses); + + const results = await Promise.all( + addresses.map(async (address) => { + const { identity } = await this.restClient.get<{ identity: Identity }>( + `/accounts/${address}` + ); + + if (!identity) { + throw new Error(`Identity was not found for ${address} after registration`); + } - return { results, total: String(results.length) }; - } + return identity; + }) + ); - return this.fundTestAccounts(addresses); + return { results, total: String(results.length) }; } private async prefundAddresses(addresses: string[]): Promise { @@ -243,11 +239,6 @@ export class TestFactory { const addresses = await this.signingManager.getAccounts(); - if (isChainV7(this.polymeshSdk)) { - await this.fundTestAccounts(addresses); - return; - } - const [address] = addresses; await this.restClient.post('/developer-testing/prefund-accounts', { diff --git a/tests/src/sdk/assets/manageCheckpoints.ts b/tests/src/sdk/assets/manageCheckpoints.ts index 8140190..27e6dd7 100644 --- a/tests/src/sdk/assets/manageCheckpoints.ts +++ b/tests/src/sdk/assets/manageCheckpoints.ts @@ -13,6 +13,7 @@ import assert from 'node:assert'; - Fetches Schedule details - Fetches Checkpoints originated by a Schedule - Fetches a single Schedule for an asset + - Fetches the next Checkpoint across all of the asset's Schedules - Deletes a Schedule */ export const manageCheckpoints = async (sdk: Polymesh, asset: FungibleAsset): Promise => { @@ -91,6 +92,23 @@ export const manageCheckpoints = async (sdk: Polymesh, asset: FungibleAsset): Pr 'schedule should be the same as the one fetched' ); + // the next Checkpoint is aggregated across every active Schedule of the asset + const nextCheckpoint = await asset.checkpoints.schedules.getNextCheckpoint(); + assert(nextCheckpoint, `${asset.id} should report a next Checkpoint while a Schedule is active`); + assert(nextCheckpoint.nextAt instanceof Date, 'the next Checkpoint should report a date'); + assert( + nextCheckpoint.totalPending.eq(remainingCheckpoints), + `the pending Checkpoint total (${nextCheckpoint.totalPending.toString()}) should match the Schedule's remaining Checkpoints (${remainingCheckpoints.toString()})` + ); + assert( + nextCheckpoint.schedules.some(({ id }) => id.eq(newSchedule.id)), + 'the next Checkpoint should be attributed to the created Schedule' + ); + assert( + nextCheckpoint.nextAt.getTime() === nextCheckpointDate.getTime(), + "the aggregated next Checkpoint date should match the only Schedule's next date" + ); + // A schedule can be removed if its no longer needed const removeScheduleTx = await asset.checkpoints.schedules.remove({ schedule: newSchedule, diff --git a/tests/src/sdk/assets/manageDistributions.ts b/tests/src/sdk/assets/manageDistributions.ts index fa52c86..c58dcb7 100644 --- a/tests/src/sdk/assets/manageDistributions.ts +++ b/tests/src/sdk/assets/manageDistributions.ts @@ -8,6 +8,7 @@ import { wellKnown } from '~/consts'; This script showcases Dividend Distribution related functionality. It: - Creates a Dividend Distribution - Modifies its Checkpoint + - Links documents to the Corporate Action and fetches them back - Fetches the Distribution details - Fetches all the Distribution participants - Pushes dividend payments @@ -83,6 +84,46 @@ export const manageDistributions = async ( await modifyCheckpointTx.run(); assert(modifyCheckpointTx.isSuccess); + // a Corporate Action starts with no documents linked to it + const documentsBefore = await distribution.getDocuments(); + assert( + documentsBefore.length === 0, + `a new Corporate Action should have no linked documents, got ${documentsBefore.length}` + ); + + /* + Only documents already registered against the Asset can be linked to a Corporate Action, + so they are added to the Asset first + */ + const caDocument = { + name: 'Distribution Terms', + uri: 'https://example.com/distribution-terms.pdf', + contentHash: '0x01'.padEnd(66, '0'), + type: 'Terms', + }; + + const addDocumentsTx = await asset.documents.add({ documents: [caDocument] }); + await addDocumentsTx.run(); + assert(addDocumentsTx.isSuccess); + + const linkDocumentsTx = await distribution.linkDocuments({ documents: [caDocument] }); + await linkDocumentsTx.run(); + assert(linkDocumentsTx.isSuccess); + + const linkedDocuments = await distribution.getDocuments(); + assert( + linkedDocuments.length === 1, + `the Corporate Action should have one linked document, got ${linkedDocuments.length}` + ); + assert( + linkedDocuments[0].name === caDocument.name && linkedDocuments[0].uri === caDocument.uri, + 'the linked document should be the one added to the Asset' + ); + assert( + linkedDocuments[0].id instanceof BigNumber, + 'a linked document should carry its on-chain ID' + ); + // fetch distribution details (whether funds have been reclaimed and the amount of remaining funds) const { remainingFunds, fundsReclaimed } = await distribution.details(); assert(remainingFunds.gt(0), 'There should be remaining funds'); diff --git a/tests/src/sdk/identities/childIdentities.ts b/tests/src/sdk/identities/childIdentities.ts deleted file mode 100644 index fc1fd6e..0000000 --- a/tests/src/sdk/identities/childIdentities.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Polymesh } from '@polymeshassociation/polymesh-sdk'; -import { ChildIdentity } from '@polymeshassociation/polymesh-sdk/internal'; -import assert from 'node:assert'; - -/** - * Function to add a child identity - * @note the childAddress should already be a secondary key of the signing identity - */ -export const createChildIdentity = async ( - sdk: Polymesh, - childAddress: string -): Promise => { - const identity = await sdk.getSigningIdentity(); - assert(identity); - - const secondaryAccounts = await identity.getSecondaryAccounts(); - assert(secondaryAccounts?.data.some(({ account: { address } }) => childAddress === address)); - - const createChildTx = await sdk.identities.createChild({ - secondaryKey: childAddress, - }); - - const childIdentity = await createChildTx.run(); - - assert(createChildTx.isSuccess); - assert(childIdentity.did); - - return childIdentity; -}; - -/** - * Function to assert that a given DID is child of the signing identity - */ -export const assertChildIdentity = async (sdk: Polymesh, childDid: string): Promise => { - const identity = await sdk.getSigningIdentity(); - assert(identity); - - const childIdentity = await sdk.identities.getChildIdentity({ did: childDid }); - - const exists = await childIdentity.exists(); - - assert(exists); - - const parent = await childIdentity.getParentDid(); - - assert(parent?.did === identity.did); - - const children = await identity.getChildIdentities(); - - assert( - children.some(({ did }) => did === childDid), - 'childDid is not a child of the signing Identity' - ); -}; - -/** - * Function to remove a child identity - */ -export const removeChildIdentity = async (sdk: Polymesh, childDid: string): Promise => { - const identity = await sdk.getSigningIdentity(); - assert(identity); - - const unlinkChildTx = await identity.unlinkChild({ - child: childDid, - }); - - await unlinkChildTx.run(); - - assert(unlinkChildTx.isSuccess); -}; diff --git a/tests/src/sdk/settlements/createSto.ts b/tests/src/sdk/settlements/createSto.ts index 2f72eb0..5215c04 100644 --- a/tests/src/sdk/settlements/createSto.ts +++ b/tests/src/sdk/settlements/createSto.ts @@ -1,10 +1,15 @@ import { BigNumber, Polymesh } from '@polymeshassociation/polymesh-sdk'; import { + Account, + DefaultPortfolio, FungibleAsset, + Identity, + NumberedPortfolio, + Offering, OfferingBalanceStatus, OfferingSaleStatus, OfferingTimingStatus, - VenueType + VenueType, } from '@polymeshassociation/polymesh-sdk/types'; import assert from 'node:assert'; @@ -125,35 +130,75 @@ export const createSto = async ( await investTx.run(); assert(investTx.isSuccess); - let offChainFundingDetails = await investableOffering.offChainFundingDetails(); - assert(offChainFundingDetails.enabled === false, 'off chain funding should be disabled'); + await enableOffChainFunding(investableOffering); + await investWithOffChainFunding(investableOffering, investor, investorPortfolio, investorAccount); - const enableOffChainFundingTx = await investableOffering.enableOffChainFunding({ - offChainTicker: 'OFFCHAIN1234', + // Freeze the offering + const freezeTx = await offering.freeze(); + await freezeTx.run(); + assert(freezeTx.isSuccess); + + // Unfreeze + const unfreezeTx = await offering.unfreeze(); + await unfreezeTx.run(); + assert(unfreezeTx.isSuccess); + + // Close + const closeTx = await offering.close(); + await closeTx.run(); + assert(closeTx.isSuccess); + + // Fetch investments from the offering + const { data: investments } = await offering.getInvestments(); + assert(investments.length > 0, 'the asset should have investments'); +}; + +export const offChainFundingTicker = 'OFFCHAIN1234'; + +/** + * Turns on off-chain funding for an Offering so investments can be settled with a signed receipt + */ +export const enableOffChainFunding = async (offering: Offering): Promise => { + const detailsBefore = await offering.offChainFundingDetails(); + assert(detailsBefore.enabled === false, 'off chain funding should be disabled'); + + const enableOffChainFundingTx = await offering.enableOffChainFunding({ + offChainTicker: offChainFundingTicker, }); await enableOffChainFundingTx.run(); assert(enableOffChainFundingTx.isSuccess); - offChainFundingDetails = await investableOffering.offChainFundingDetails(); - assert(offChainFundingDetails.enabled === true, 'off chain funding should be enabled'); + const detailsAfter = await offering.offChainFundingDetails(); + assert(detailsAfter.enabled === true, 'off chain funding should be enabled'); assert( - offChainFundingDetails.offChainTicker === 'OFFCHAIN1234', - 'off chain funding should be enabled' + detailsAfter.offChainTicker === offChainFundingTicker, + 'off chain funding should be enabled for the given ticker' ); +}; - const authChainFundingReceipt = await investableOffering.generateOffChainFundingReceipt({ +/** + * Invests in an Offering using an off-chain funding receipt + */ +export const investWithOffChainFunding = async ( + offering: Offering, + investor: Identity, + investorPortfolio: DefaultPortfolio | NumberedPortfolio, + investorAccount: Account +): Promise => { + const fundingReceipt = await offering.generateOffChainFundingReceipt({ uid: new BigNumber(1), - offChainTicker: 'OFFCHAIN1234', + offChainTicker: offChainFundingTicker, amount: new BigNumber(100), sender: investor, metadata: 'Off chain metadata', signer: investorAccount, + expiresAt: new Date('2055/01/01'), }); - const offChainInvestTx = await investableOffering.invest( + const offChainInvestTx = await offering.invest( { - offChainTicker: 'OFFCHAIN1234', - offChainFundingReceipt: authChainFundingReceipt, + offChainTicker: offChainFundingTicker, + offChainFundingReceipt: fundingReceipt, purchasePortfolio: investorPortfolio, purchaseAmount: new BigNumber(10), maxPrice: new BigNumber(11), @@ -163,23 +208,4 @@ export const createSto = async ( await offChainInvestTx.run(); assert(offChainInvestTx.isSuccess); - - // Freeze the offering - const freezeTx = await offering.freeze(); - await freezeTx.run(); - assert(freezeTx.isSuccess); - - // Unfreeze - const unfreezeTx = await offering.unfreeze(); - await unfreezeTx.run(); - assert(unfreezeTx.isSuccess); - - // Close - const closeTx = await offering.close(); - await closeTx.run(); - assert(closeTx.isSuccess); - - // Fetch investments from the offering - const { data: investments } = await offering.getInvestments(); - assert(investments.length > 0, 'the asset should have investments'); }; diff --git a/tests/src/sdk/settlements/createVenue.ts b/tests/src/sdk/settlements/createVenue.ts index 5909d6c..f752230 100644 --- a/tests/src/sdk/settlements/createVenue.ts +++ b/tests/src/sdk/settlements/createVenue.ts @@ -23,12 +23,22 @@ export const createVenue = async ( assert(venueTx.isSuccess); if (createVenueParams.signers?.length) { - const allowedSigners = await venue.getAllowedSigners(); + const [allowedSigners, signerCount] = await Promise.all([ + venue.getAllowedSigners(), + venue.getSignerCount(), + ]); assert( allowedSigners.map(({ address }) => createVenueParams.signers?.includes(address)), 'signers are added to the Venue' ); + + assert( + signerCount.eq(allowedSigners.length), + `the Venue signer count (${signerCount.toString()}) should match the allowed signers (${ + allowedSigners.length + })` + ); } return venue; }; diff --git a/tests/src/sdk/settlements/mediatorLock.ts b/tests/src/sdk/settlements/mediatorLock.ts new file mode 100644 index 0000000..953a0bd --- /dev/null +++ b/tests/src/sdk/settlements/mediatorLock.ts @@ -0,0 +1,189 @@ +import { BigNumber, Polymesh } from '@polymeshassociation/polymesh-sdk'; +import { + AffirmationStatus, + FungibleAsset, + Instruction, + InstructionType, + LegStatusType, + VenueType, +} from '@polymeshassociation/polymesh-sdk/types'; +import assert from 'node:assert'; + +import { createVenue } from '~/sdk/settlements/createVenue'; +import { addIsNotBlocked } from '~/sdk/settlements/util'; + +/* + An Instruction of type `SettleAfterLock` is held by a mediator until they lock it for + execution. This script showcases the full lock/relock cycle. It: + - Creates a `SettleAfterLock` Instruction with a mediator + - Affirms it as every involved party, including the mediator + - Locks it for execution and inspects the lock info + - Unlocks it, returning it to `Pending` + - Inspects the relock cooldown that unlocking starts +*/ +export const createLockableInstruction = async ( + sdk: Polymesh, + asset: FungibleAsset, + counterPartyDid: string, + mediatorDid: string, + amount: BigNumber +): Promise => { + const [identity, counterParty] = await Promise.all([ + sdk.getSigningIdentity(), + sdk.identities.getIdentity({ did: counterPartyDid }), + ]); + assert(identity); + + await addIsNotBlocked(asset); + + const venue = await createVenue(sdk, { + description: 'Mediated settlement venue', + type: VenueType.Exchange, + }); + + const destinationPortfolio = await counterParty.portfolios.getPortfolio(); + + const addInstructionTx = await venue.addInstruction({ + legs: [ + { + from: identity, + to: destinationPortfolio, + amount, + asset: asset.id, + }, + ], + mediators: [mediatorDid], + endAfterLock: true, + memo: 'Locked settlement', + }); + + const instruction = await addInstructionTx.run(); + assert(addInstructionTx.isSuccess, 'add instruction should succeed'); + + const details = await instruction.detailsFromChain(); + assert( + details.type === InstructionType.SettleAfterLock, + `the instruction should be of type SettleAfterLock, got ${details.type}` + ); + + return instruction; +}; + +/** + * Affirms the Instruction as the receiver and as the mediator, leaving it ready to be locked + * + * @note a receiver that has not opted in to mandatory affirmation is affirmed on creation, + * so the receiving side only needs to affirm when it is still pending + */ +export const affirmForLock = async ( + instruction: Instruction, + counterPartyDid: string, + counterPartyAddress: string, + mediatorAddress: string +): Promise => { + const { data: affirmations } = await instruction.getAffirmations(); + + const counterPartyAffirmed = affirmations.some( + ({ party, status }) => + 'did' in party && party.did === counterPartyDid && status === AffirmationStatus.Affirmed + ); + + if (!counterPartyAffirmed) { + const affirmTx = await instruction.affirm({}, { signingAccount: counterPartyAddress }); + await affirmTx.run(); + assert(affirmTx.isSuccess, 'the receiver should be able to affirm'); + } + + const affirmAsMediatorTx = await instruction.affirmAsMediator( + {}, + { signingAccount: mediatorAddress } + ); + await affirmAsMediatorTx.run(); + assert(affirmAsMediatorTx.isSuccess, 'the mediator should be able to affirm'); + + const pendingAffirmations = await instruction.getPendingAffirmationCount(); + assert( + pendingAffirmations.isZero(), + `the instruction should have no pending affirmations, got ${pendingAffirmations.toString()}` + ); +}; + +/** + * Locks the Instruction for execution as its mediator and asserts the reported lock info + */ +export const lockInstruction = async ( + instruction: Instruction, + mediatorAddress: string +): Promise => { + const lockedBefore = await instruction.getLockedInfo(); + assert(!lockedBefore.isLocked, 'the instruction should not be locked before locking it'); + + const lockTx = await instruction.lockForExecution({ signingAccount: mediatorAddress }); + await lockTx.run(); + assert(lockTx.isSuccess, 'the mediator should be able to lock the instruction'); + + const lockedAfter = await instruction.getLockedInfo(); + assert(lockedAfter.isLocked, 'the instruction should be locked after locking it'); + assert(lockedAfter.lockedAt instanceof Date, 'a locked instruction should report `lockedAt`'); + assert(lockedAfter.unlocksAt instanceof Date, 'a locked instruction should report `unlocksAt`'); + assert( + lockedAfter.expiry instanceof BigNumber, + 'a locked instruction should report its lock `expiry`' + ); +}; + +/** + * Asserts the leg statuses reported for a locked Instruction + */ +export const assertLegStatuses = async (instruction: Instruction): Promise => { + const { data: legs } = await instruction.getLegsFromChain(); + assert(legs.length > 0, 'the instruction should have at least one leg'); + + const statuses = await Promise.all( + legs.map((_, index) => instruction.getLegStatus({ legId: new BigNumber(index) })) + ); + + const knownTypes = Object.values(LegStatusType); + statuses.forEach(({ type }, index) => { + assert( + knownTypes.includes(type), + `leg ${index} reported an unknown status type: ${String(type)}` + ); + }); +}; + +/** + * Unlocks a locked Instruction as its mediator and asserts the relock cooldown it starts + */ +export const unlockInstruction = async ( + instruction: Instruction, + mediatorAddress: string +): Promise => { + const relockBefore = await instruction.getRelockStatus(); + assert(relockBefore.unlockedAt === null, 'the instruction should not have been unlocked yet'); + assert(relockBefore.relockCount.isZero(), 'the relock count should start at zero'); + + const unlockTx = await instruction.unlockForExecution({ signingAccount: mediatorAddress }); + await unlockTx.run(); + assert(unlockTx.isSuccess, 'the mediator should be able to unlock the instruction'); + + const lockedInfo = await instruction.getLockedInfo(); + assert(!lockedInfo.isLocked, 'the instruction should no longer be locked'); + + const relockAfter = await instruction.getRelockStatus(); + assert( + relockAfter.unlockedAt instanceof Date, + 'an unlocked instruction should report `unlockedAt`' + ); + assert( + relockAfter.cooldownEndsAt instanceof Date, + 'an unlocked instruction should report `cooldownEndsAt`' + ); + assert( + relockAfter.maxRelockCount.gte(relockAfter.relockCount), + 'the relock count should not exceed the maximum allowed' + ); + + const isPending = await instruction.isPending(); + assert(isPending, 'the instruction should return to pending after being unlocked'); +}; diff --git a/tests/src/util.ts b/tests/src/util.ts index fc94bbc..3a4ff73 100644 --- a/tests/src/util.ts +++ b/tests/src/util.ts @@ -143,12 +143,6 @@ export const awaitMiddlewareSyncedForRestApi = async ( export const getDayInFuture = (days: number): Date => new Date(Date.now() + 1000 * 60 * 60 * 24 * days); -type PolymeshWithContext = Polymesh & { - context: { isV7: boolean }; -}; - -export const isChainV7 = (sdk: Polymesh): boolean => (sdk as PolymeshWithContext).context.isV7; - export const isRestError = ( result: unknown ): result is { statusCode: number; message?: string | string[] } => @@ -231,7 +225,7 @@ export const createDirectInstruction = async ( }; /** - * On chain v8, venue instructions without an end block can auto-execute and be purged + * Venue instructions without an end block can auto-execute and be purged * before the API returns. Tests that need a pending instruction should pass this. */ export const withPendingInstructionBlock = async >( @@ -240,31 +234,23 @@ export const withPendingInstructionBlock = async => { - if (isChainV7(polymeshSdk)) { - return params; - } - - const latestBlock = await restClient.network.getLatestBlock(); + const endAfterBlock = await getPendingInstructionEndBlock(polymeshSdk, restClient, blocksAhead); return { ...params, - endAfterBlock: (Number(latestBlock.id) + blocksAhead).toString(), + endAfterBlock: endAfterBlock.toString(), }; }; /** - * On v8, instructions without an end block can auto-settle immediately. + * Instructions without an end block can auto-settle immediately. * SDK settlement helpers should pass this as `endBlock` / `endAfterBlock`. */ export const getPendingInstructionEndBlock = async ( polymeshSdk: Polymesh, restClient?: RestClient, blocksAhead = 50 -): Promise => { - if (isChainV7(polymeshSdk)) { - return undefined; - } - +): Promise => { if (restClient) { const latestBlock = await restClient.network.getLatestBlock(); return new BigNumber(Number(latestBlock.id) + blocksAhead); diff --git a/tests/yarn.lock b/tests/yarn.lock index 977aa71..22a056f 100644 --- a/tests/yarn.lock +++ b/tests/yarn.lock @@ -1380,9 +1380,9 @@ __metadata: languageName: node linkType: hard -"@polymeshassociation/polymesh-sdk@npm:30.1.0": - version: 30.1.0 - resolution: "@polymeshassociation/polymesh-sdk@npm:30.1.0" +"@polymeshassociation/polymesh-sdk@npm:31.0.0": + version: 31.0.0 + resolution: "@polymeshassociation/polymesh-sdk@npm:31.0.0" dependencies: "@apollo/client": "npm:^3.8.1" "@polkadot/api": "npm:16.5.2" @@ -1400,7 +1400,7 @@ __metadata: semver: "npm:^7.5.4" ts-morph: "npm:^25.0.1" ws: "npm:^8.18.3" - checksum: 10c0/29c4634d6717d3e8b444858ba763b77a021c7ac0c9895ae0535130d6dc3e6ddb549d42d57a24a719960de17f75aedd2013fdfe6f2f6c3397b366ac4935119534 + checksum: 10c0/9c74bb0a6be7e0c528edb4d4d35f8841d3e754e1fdae694749ee461d9b833e200c62c2563baf3c549cb0434588f91079afc3a53c8ea60e6c009b18b265b91815 languageName: node linkType: hard @@ -6401,7 +6401,7 @@ __metadata: resolution: "polymesh-dev-env@workspace:." dependencies: "@polymeshassociation/local-signing-manager": "npm:^4.1.1" - "@polymeshassociation/polymesh-sdk": "npm:30.1.0" + "@polymeshassociation/polymesh-sdk": "npm:31.0.0" "@types/jest": "npm:^29.5.14" "@types/node": "npm:^22.15.17" "@typescript-eslint/eslint-plugin": "npm:4.29.0"