From c4a68d2a39f9838cecfd0a23f51dddd2c72d6568 Mon Sep 17 00:00:00 2001 From: Prashant Bajpai <34747455+prashantasdeveloper@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:59:44 +0530 Subject: [PATCH 01/16] chore: bump the SDK to 31.0.0-beta.7 and drop chain v7 support The SDK narrowed SUPPORTED_SPEC_VERSION_RANGE to 8.x in 31.0.0-beta.1 and throws when connecting to a v7 node, so the suite is now v8 only and every v7 branch in it is unreachable. These changes have to land together: the bump does not compile until the removed APIs are gone, and the v7 gates cannot be dropped before the bump makes them dead. context.isV7 no longer exists, so isChainV7 silently returned undefined and quietly disabled the code it guarded. Remove it along with the branches it fed. withPendingInstructionBlock now shares getPendingInstructionEndBlock rather than keeping a v7 escape hatch, and that helper always resolves a block, so its return type loses the undefined. The ChildIdentity entity and every child identity API were removed from the SDK, so their example and (already skipped) suite go with them. Two suites were gated to v7 only and now run on v8: - The off chain settlement leg of tradeAssets. 30.1.1-beta.3 corrected the v8 receipt expiry and encoding, which is what kept it on v7. - createSto. The on chain funded parts pass on v8. Its off chain funding section is extracted into enableOffChainFunding and investWithOffChainFunding, and the invest is pinned as a known SDK defect: the chain's FundraiserReceiptDetails carries an expiresAt that offChainFundingReceiptDetailsToMeshReceiptDetails never sets, so it encodes as 0 and every off chain funded investment is rejected with sto.ReceiptExpired. generateOffChainFundingReceipt has no parameter to supply one either. The equivalent fix landed for settlement receipts in 30.1.1-beta.3 but not for STO funding receipts. relayer.removePayingKey was the v7 spelling of relayer.removeSubsidy, so the subsidy suite asserts the v8 tag directly. --- tests/README.md | 2 + tests/package.json | 2 +- tests/src/__tests__/rest/subsidy.ts | 7 +- .../sdk/identities/childIdentities.ts | 40 ------- .../__tests__/sdk/settlements/createSto.ts | 5 - .../__tests__/sdk/settlements/tradeAssets.ts | 5 - tests/src/helpers/factory.ts | 45 +++----- tests/src/sdk/identities/childIdentities.ts | 70 ------------ tests/src/sdk/settlements/createSto.ts | 106 ++++++++++++------ tests/src/util.ts | 24 +--- tests/yarn.lock | 10 +- 11 files changed, 105 insertions(+), 211 deletions(-) delete mode 100644 tests/src/__tests__/sdk/identities/childIdentities.ts delete mode 100644 tests/src/sdk/identities/childIdentities.ts 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..302fa79 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-beta.7", "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/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/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/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/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/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..742e655 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,89 @@ 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); + + /* + Known SDK defect (as of 31.0.0-beta.7). The chain's `FundraiserReceiptDetails` carries an + `expiresAt` field which `offChainFundingReceiptDetailsToMeshReceiptDetails` never sets, so it + is encoded as 0 and the chain rejects every off-chain funded investment as `sto.ReceiptExpired`. + `generateOffChainFundingReceipt` has no `expiresAt` parameter to supply one either. The + equivalent fix landed for settlement receipts in 30.1.1-beta.3 but not for STO funding receipts. + + When the SDK is fixed this assertion will start failing: swap it for a success assertion. + */ + await assert.rejects( + () => + investWithOffChainFunding(investableOffering, investor, investorPortfolio, investorAccount), + /ReceiptExpired/, + 'off chain funded investment should fail until the receipt expiry is encoded' + ); + + // 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'; - const enableOffChainFundingTx = await investableOffering.enableOffChainFunding({ - offChainTicker: '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, }); - 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 +222,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/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..04b2d6e 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-beta.7": + version: 31.0.0-beta.7 + resolution: "@polymeshassociation/polymesh-sdk@npm:31.0.0-beta.7" 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/15239455c18e2af83ee4e5b01ece76a36905d705713cfad0d8674fb8ba56163144d274f696538c2be9a1ad8d58f7cd2bfe1b92facd2cfcce3f00ede0a32d2e4b 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-beta.7" "@types/jest": "npm:^29.5.14" "@types/node": "npm:^22.15.17" "@typescript-eslint/eslint-plugin": "npm:4.29.0" From f863b7655bbf20f9f3d8fe76eba009c25dc5882b Mon Sep 17 00:00:00 2001 From: Prashant Bajpai <34747455+prashantasdeveloper@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:59:58 +0530 Subject: [PATCH 02/16] fix: align the staking tests with the v8 bonding model BondPolyxParams.controller and SetStakingControllerParams were removed in 31.0.0-beta.1, and Staking.setController is now a no args procedure: a stash can only make itself its own controller. A stash is already bonded as its own controller on v8, so setController rejects with staking.AlreadyPaired. Assert that rather than a success, since the call only has an effect for legacy stashes that still have a separate controller. The separate controller account the suite used to bond through is gone; the stash signs bondExtra, unbond and withdraw itself. The remaining tests were gated to v7 and never ran on v8. They pass once they sign as the stash, so drop the gates. Also cover two behaviours the bump changed: - staking.getPayee returns null for an Account that has never bonded rather than throwing (31.0.0-beta.1). - Bonded POLYX shows up in the new reserved field on the Account balance (30.1.1-beta.2). --- tests/src/__tests__/sdk/accounts/staking.ts | 87 ++++++++++----------- 1 file changed, 40 insertions(+), 47 deletions(-) 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(); }); From facc891b95965782f8dfc0e30a2d9fdfb886e52d Mon Sep 17 00:00:00 2001 From: Prashant Bajpai <34747455+prashantasdeveloper@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:00:12 +0530 Subject: [PATCH 03/16] test: add coverage for the ticker registration config getter Covers Assets.getTickerRegistrationConfig and the ticker length validation it backs, both added in 30.2.0-beta.1. A ticker over the chain's maxTickerLength is now rejected by reserveTicker and createAsset before submission, and one exactly at the limit is accepted. --- .../sdk/assets/tickerRegistrationConfig.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/src/__tests__/sdk/assets/tickerRegistrationConfig.ts 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); + }); +}); From e0c412d837ddf1f6f154bb7d6c411dde63fe3bbb Mon Sep 17 00:00:00 2001 From: Prashant Bajpai <34747455+prashantasdeveloper@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:00:17 +0530 Subject: [PATCH 04/16] test: add coverage for funding round issuance Covers BaseAsset.getIssuedInFundingRound, added in 30.2.0-beta.1. Asserts the initial supply lands against the round the Asset was created with, that further issuance accumulates into the current round, that each round is tracked separately across a modify, and that an unknown round reports zero. --- .../src/__tests__/sdk/assets/fundingRound.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/src/__tests__/sdk/assets/fundingRound.ts 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); + }); +}); From 51c8b7a16d618777d8f2c51489959ae2f802fe92 Mon Sep 17 00:00:00 2001 From: Prashant Bajpai <34747455+prashantasdeveloper@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:00:26 +0530 Subject: [PATCH 05/16] test: add coverage for the v8 POLYX balance breakdown 30.1.1-beta.2 corrected how a POLYX balance is derived and exposed the raw chain values. free is now what the Account can actually spend, and reserved and frozen are surfaced alongside it. Pins the shape and the invariants that relate the components, and checks accountManagement.getAccountBalance agrees with Account.getBalance. --- tests/src/__tests__/sdk/accounts/balance.ts | 65 +++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/src/__tests__/sdk/accounts/balance.ts 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); + }); +}); From a09dc2faf04d39016e74019568acaebb1ee41213 Mon Sep 17 00:00:00 2001 From: Prashant Bajpai <34747455+prashantasdeveloper@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:00:34 +0530 Subject: [PATCH 06/16] test: add coverage for v8 protocol fees 31.0.0-beta.1 corrected the protocol fee mapping against the v8 runtime, where fees for priced operations were silently reported as zero. Asserts a fee comes back for every requested tag, that priced operations report a non zero fee, and that the quoted fee matches what a prepared transaction reports through getTotalFees. --- .../src/__tests__/sdk/network/protocolFees.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/src/__tests__/sdk/network/protocolFees.ts 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); + }); +}); From 13d0f974c2531b30bdb4b253350a7f5a4dc29586 Mon Sep 17 00:00:00 2001 From: Prashant Bajpai <34747455+prashantasdeveloper@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:00:46 +0530 Subject: [PATCH 07/16] test: add coverage for the rebuilt transaction groups 31.0.0-beta.1 and beta.2 rebuilt the transaction groups from the chain's own permission model, which changes what a permission UI built from TX_GROUP_TO_TAGS_MAP produces. Pins the mapping so a regression in the constants is caught without a chain round trip: RelayerManagement is gone, DidRegistration and InstructionMediation are new, MultiSigManagement is down to the one permission checked call, tags for extrinsics no longer on chain are absent, and nft.CreateNftCollection is reachable through an agent grantable group. Then grants the two new groups to a secondary key end to end, since they are newly grantable rather than relocated and no existing grant covers them. --- .../src/__tests__/sdk/identities/txGroups.ts | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/src/__tests__/sdk/identities/txGroups.ts 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]) + ); + }); +}); From e6a83481a4059f69f618ae717f58968611b67388 Mon Sep 17 00:00:00 2001 From: Prashant Bajpai <34747455+prashantasdeveloper@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:00:48 +0530 Subject: [PATCH 08/16] test: add coverage for portfolio level asset pre-approval 30.2.0-beta.4 added pre-approval at the Portfolio level alongside the existing Identity level one, alongside Portfolio.preApprovedAssets. Covers preApproveAsset, isAssetPreApproved, preApprovedAssets and removeAssetPreApproval, and asserts the two levels stay independent: removing the Identity approval leaves the Portfolio one in place. --- .../sdk/identities/portfolioPreApproval.ts | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/src/__tests__/sdk/identities/portfolioPreApproval.ts 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); + }); +}); From 28a2e404eb711289e5eb601df8ce80868ba47a01 Mon Sep 17 00:00:00 2001 From: Prashant Bajpai <34747455+prashantasdeveloper@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:01:04 +0530 Subject: [PATCH 09/16] test: add coverage for registrar gated DID registration 30.2.0-beta.5 added the registerDid procedure and 31.0.0-beta.1 renamed Identity.isCddProvider to isDidRegistrar, deprecated registerIdentity and reduced issuing a CDD claim to the DID Registrar role. Covers the happy path signed by a registrar, the rejections for an Account that already has an Identity and for a signer without the role, and both sides of the CDD claim role check. --- .../__tests__/sdk/identities/registerDid.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 tests/src/__tests__/sdk/identities/registerDid.ts 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); + }); +}); From 0f48658cda4a2ab62ff60ba98bbd5bae12f8bfa2 Mon Sep 17 00:00:00 2001 From: Prashant Bajpai <34747455+prashantasdeveloper@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:01:13 +0530 Subject: [PATCH 10/16] test: add coverage for the realigned transfer errors 31.0.0-beta.1 aligned TransferError with the errors the chain actually reports, removed the TransferStatus enum, dropped the CDD and investor uniqueness members, and added InvalidReceiverIdentity. canTransfer reports failures in its breakdown rather than throwing. Pins the enum membership and covers the breakdown for a valid transfer, an insufficient balance and frozen transfers. Two cases are asserted as they actually behave rather than as the names suggest. A transfer within one Identity is not a SelfTransfer on v8, since an Identity may move funds between its own holders. And a decimal amount for an indivisible Asset never reaches the chain to produce InvalidGranularity, because the SDK rejects it while encoding the call. --- .../sdk/settlements/transferErrors.ts | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 tests/src/__tests__/sdk/settlements/transferErrors.ts 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(); + }); +}); From 6bbd894b7ff3b516b05183a64fbcc650435f0843 Mon Sep 17 00:00:00 2001 From: Prashant Bajpai <34747455+prashantasdeveloper@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:01:31 +0530 Subject: [PATCH 11/16] test: add coverage for the transferFunds instruction result 31.0.0-beta.5 changed Assets.transferFunds from void to Instruction | undefined and allowed transfers between asset holders on different Identities. Covers all three outcomes the return type encodes: undefined when the transfer settles immediately within one Identity, undefined when it settles immediately because the receiver auto affirms or has pre-approved the asset, and a resolved pending Instruction when the receiver has opted in to mandatory affirmation and has yet to affirm. Also covers the NFT leg, reworked in beta.5 and beta.7. --- .../src/__tests__/sdk/assets/transferFunds.ts | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 tests/src/__tests__/sdk/assets/transferFunds.ts 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); + }); +}); From 81b80f9bb59e6fb2f5caccc36915efaa4cf798c7 Mon Sep 17 00:00:00 2001 From: Prashant Bajpai <34747455+prashantasdeveloper@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:01:33 +0530 Subject: [PATCH 12/16] test: add coverage for the mediator lock and unlock cycle 30.2.0-beta.2 added unlockInstruction, completing the lock/relock cycle for a SettleAfterLock instruction, and 30.2.0-beta.3 added getters for leg status and venue signer count. Walks the cycle as a mediator: lock for execution, read the lock info, read each leg's status, unlock back to pending, and read the relock cooldown the unlock starts. Then asserts a relock inside that cooldown is refused, which happens while the transaction is prepared rather than on submission. A receiver that has not opted in to mandatory affirmation is affirmed when the instruction is created, so the helper only affirms the receiving side when it is still pending. Venue.getSignerCount is asserted against getAllowedSigners in createVenue, where the off chain suites already exercise a venue with signers. --- .../__tests__/sdk/settlements/mediatorLock.ts | 90 +++++++++ tests/src/sdk/settlements/createVenue.ts | 12 +- tests/src/sdk/settlements/mediatorLock.ts | 189 ++++++++++++++++++ 3 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 tests/src/__tests__/sdk/settlements/mediatorLock.ts create mode 100644 tests/src/sdk/settlements/mediatorLock.ts 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/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'); +}; From 71be7c245def495fa05fe9a365999a63bbf14300 Mon Sep 17 00:00:00 2001 From: Prashant Bajpai <34747455+prashantasdeveloper@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:03:49 +0530 Subject: [PATCH 13/16] test: add coverage for the aggregated next checkpoint getter Covers Checkpoints.Schedules.getNextCheckpoint, added in 31.0.0-beta.3. With one active Schedule the aggregate should agree with that Schedule's own details, so the example asserts the date, the pending total and the Schedule attribution against it. The documented null case is split in two because only one half holds. An Asset that never had a Schedule reports null. An Asset whose Schedules have all been removed throws, and is pinned as a known SDK defect: the chain keeps cachedNextCheckpoints populated with a nextAt sentinel of u64::MAX, which momentToDate cannot convert. --- .../__tests__/sdk/assets/manageCheckpoints.ts | 21 +++++++++++++++++++ tests/src/sdk/assets/manageCheckpoints.ts | 18 ++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/tests/src/__tests__/sdk/assets/manageCheckpoints.ts b/tests/src/__tests__/sdk/assets/manageCheckpoints.ts index 2a6f48b..f62a740 100644 --- a/tests/src/__tests__/sdk/assets/manageCheckpoints.ts +++ b/tests/src/__tests__/sdk/assets/manageCheckpoints.ts @@ -22,7 +22,28 @@ 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(); }); + + /* + Known SDK defect (as of 31.0.0-beta.7). `getNextCheckpoint` is documented to resolve to + `null` when an Asset has no active Schedules, and it does while `cachedNextCheckpoints` is + unset. Once every Schedule has been removed the chain keeps the entry with a + `nextAt` sentinel of `u64::MAX`, which `momentToDate` cannot convert. + + When the SDK is fixed this test will start failing: replace it with the `null` assertion above. + */ + it('should throw instead of returning null once every Schedule has been removed', async () => { + const schedules = await asset.checkpoints.schedules.get(); + expect(schedules).toHaveLength(0); + + await expect(asset.checkpoints.schedules.getNextCheckpoint()).rejects.toThrow( + 'Number can only safely store up to 53 bits' + ); + }); }); 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, From d4c5e95b0d97df2a45d278222774178bef68f33d Mon Sep 17 00:00:00 2001 From: Prashant Bajpai <34747455+prashantasdeveloper@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:04:19 +0530 Subject: [PATCH 14/16] test: add coverage for corporate action documents Covers CorporateActionBase.getDocuments, added in 31.0.0-beta.4. Asserts a new Corporate Action starts with no linked documents, then links one through the Asset it was registered against and reads it back with its on-chain ID. --- tests/src/sdk/assets/manageDistributions.ts | 41 +++++++++++++++++++++ 1 file changed, 41 insertions(+) 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'); From 950d77a92e19cc8d1f7c95d072e0828fbdac5474 Mon Sep 17 00:00:00 2001 From: Prashant Bajpai <34747455+prashantasdeveloper@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:09:59 +0530 Subject: [PATCH 15/16] fix: bump the SDK to 31.0.0-beta.8 and flip the two pinned defects 31.0.0-beta.8 fixes both SDK defects that were pinned as known-broken while the suite was on beta.7: - getNextCheckpoint now returns null once every Schedule has been removed, instead of throwing on the chain's u64::MAX nextAt sentinel. The checkpoint test asserts the documented null behaviour directly. - offChainFundingReceiptDetailsToMeshReceiptDetails now encodes expiresAt, so an off chain funded STO investment settles instead of being rejected as sto.ReceiptExpired. createSto asserts the investment succeeds instead of asserting it fails. This release also makes expiresAt required (previously optional) on generateOffChainAffirmationReceipt/generateOffChainFundingReceipt and their receipt types, part of the same fix. Both call sites in this suite already passed it, so no other change was needed. --- tests/package.json | 2 +- .../__tests__/sdk/assets/manageCheckpoints.ts | 14 ++------------ tests/src/sdk/settlements/createSto.ts | 18 ++---------------- tests/yarn.lock | 10 +++++----- 4 files changed, 10 insertions(+), 34 deletions(-) diff --git a/tests/package.json b/tests/package.json index 302fa79..9750633 100644 --- a/tests/package.json +++ b/tests/package.json @@ -49,7 +49,7 @@ }, "dependencies": { "@polymeshassociation/local-signing-manager": "^4.1.1", - "@polymeshassociation/polymesh-sdk": "31.0.0-beta.7", + "@polymeshassociation/polymesh-sdk": "31.0.0-beta.8", "cross-fetch": "^4.1.0", "dotenv": "^16.5.0" }, diff --git a/tests/src/__tests__/sdk/assets/manageCheckpoints.ts b/tests/src/__tests__/sdk/assets/manageCheckpoints.ts index f62a740..f273559 100644 --- a/tests/src/__tests__/sdk/assets/manageCheckpoints.ts +++ b/tests/src/__tests__/sdk/assets/manageCheckpoints.ts @@ -30,20 +30,10 @@ describe('manageCheckpoints', () => { await expect(manageCheckpoints(sdk, asset)).resolves.not.toThrow(); }); - /* - Known SDK defect (as of 31.0.0-beta.7). `getNextCheckpoint` is documented to resolve to - `null` when an Asset has no active Schedules, and it does while `cachedNextCheckpoints` is - unset. Once every Schedule has been removed the chain keeps the entry with a - `nextAt` sentinel of `u64::MAX`, which `momentToDate` cannot convert. - - When the SDK is fixed this test will start failing: replace it with the `null` assertion above. - */ - it('should throw instead of returning null once every Schedule has been removed', async () => { + 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()).rejects.toThrow( - 'Number can only safely store up to 53 bits' - ); + await expect(asset.checkpoints.schedules.getNextCheckpoint()).resolves.toBeNull(); }); }); diff --git a/tests/src/sdk/settlements/createSto.ts b/tests/src/sdk/settlements/createSto.ts index 742e655..5215c04 100644 --- a/tests/src/sdk/settlements/createSto.ts +++ b/tests/src/sdk/settlements/createSto.ts @@ -131,22 +131,7 @@ export const createSto = async ( assert(investTx.isSuccess); await enableOffChainFunding(investableOffering); - - /* - Known SDK defect (as of 31.0.0-beta.7). The chain's `FundraiserReceiptDetails` carries an - `expiresAt` field which `offChainFundingReceiptDetailsToMeshReceiptDetails` never sets, so it - is encoded as 0 and the chain rejects every off-chain funded investment as `sto.ReceiptExpired`. - `generateOffChainFundingReceipt` has no `expiresAt` parameter to supply one either. The - equivalent fix landed for settlement receipts in 30.1.1-beta.3 but not for STO funding receipts. - - When the SDK is fixed this assertion will start failing: swap it for a success assertion. - */ - await assert.rejects( - () => - investWithOffChainFunding(investableOffering, investor, investorPortfolio, investorAccount), - /ReceiptExpired/, - 'off chain funded investment should fail until the receipt expiry is encoded' - ); + await investWithOffChainFunding(investableOffering, investor, investorPortfolio, investorAccount); // Freeze the offering const freezeTx = await offering.freeze(); @@ -207,6 +192,7 @@ export const investWithOffChainFunding = async ( sender: investor, metadata: 'Off chain metadata', signer: investorAccount, + expiresAt: new Date('2055/01/01'), }); const offChainInvestTx = await offering.invest( diff --git a/tests/yarn.lock b/tests/yarn.lock index 04b2d6e..f32fc23 100644 --- a/tests/yarn.lock +++ b/tests/yarn.lock @@ -1380,9 +1380,9 @@ __metadata: languageName: node linkType: hard -"@polymeshassociation/polymesh-sdk@npm:31.0.0-beta.7": - version: 31.0.0-beta.7 - resolution: "@polymeshassociation/polymesh-sdk@npm:31.0.0-beta.7" +"@polymeshassociation/polymesh-sdk@npm:31.0.0-beta.8": + version: 31.0.0-beta.8 + resolution: "@polymeshassociation/polymesh-sdk@npm:31.0.0-beta.8" 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/15239455c18e2af83ee4e5b01ece76a36905d705713cfad0d8674fb8ba56163144d274f696538c2be9a1ad8d58f7cd2bfe1b92facd2cfcce3f00ede0a32d2e4b + checksum: 10c0/c3f2d3661f6197c447f76e2dd981a8bd2ce45ad093d993aaffb5156819720e7f188e0b08bfb3a69f6613e8d6bf8d0cfe950b9dbbd19674f38fa515afb54906fa 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:31.0.0-beta.7" + "@polymeshassociation/polymesh-sdk": "npm:31.0.0-beta.8" "@types/jest": "npm:^29.5.14" "@types/node": "npm:^22.15.17" "@typescript-eslint/eslint-plugin": "npm:4.29.0" From 6326a7c78c53d308a4b57baa930dafce7c3855b3 Mon Sep 17 00:00:00 2001 From: Prashant Bajpai <34747455+prashantasdeveloper@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:51:41 +0530 Subject: [PATCH 16/16] chore: move off the beta tag to the stable SDK 31.0.0 release 31.0.0 is now published to the latest dist-tag with the same content as 31.0.0-beta.8 (its changelog is the squashed history of every 31.0.0-beta.* commit plus the beta.8 fix), so no other change is needed. --- tests/package.json | 2 +- tests/yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/package.json b/tests/package.json index 9750633..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": "31.0.0-beta.8", + "@polymeshassociation/polymesh-sdk": "31.0.0", "cross-fetch": "^4.1.0", "dotenv": "^16.5.0" }, diff --git a/tests/yarn.lock b/tests/yarn.lock index f32fc23..22a056f 100644 --- a/tests/yarn.lock +++ b/tests/yarn.lock @@ -1380,9 +1380,9 @@ __metadata: languageName: node linkType: hard -"@polymeshassociation/polymesh-sdk@npm:31.0.0-beta.8": - version: 31.0.0-beta.8 - resolution: "@polymeshassociation/polymesh-sdk@npm:31.0.0-beta.8" +"@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/c3f2d3661f6197c447f76e2dd981a8bd2ce45ad093d993aaffb5156819720e7f188e0b08bfb3a69f6613e8d6bf8d0cfe950b9dbbd19674f38fa515afb54906fa + 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:31.0.0-beta.8" + "@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"