SDK Changelog - v30.2.0 to v31.0.0
This guide describes changes between v30.2.0 and v31.0.0 of @polymeshassociation/polymesh-sdk, including breaking changes, deprecations, new features, bug fixes, and migration steps. If you're upgrading, start with the Quick Migration Checklist below.
Overview
v31.0.0 is a major release. It drops all chain v7 support and backward-compatibility code, now that the SDK only targets chain v8. Context.isV7 and every v7/v8 branch are gone, along with every feature that only existed for v7 chains.
Key themes in this release:
- Chain v8 only — connecting to a v7 chain now throws on initialization. Any chain
8.xspec is accepted. - v7-only APIs removed — child identities, CDD claim lookups, the legacy subsidy authorization flow, instruction affirmation withdrawal, and the staking controller argument.
- Transaction groups rebuilt from the chain's actual permission model, audited against the v8.0.2 runtime.
- Bug fixes for permission tags and protocol fees that named the wrong extrinsic — either one removed in v8, or one other than the extrinsic actually submitted.
In this guide
Upgrading? Start with Prerequisites and Quick Migration Checklist.
Need to know what broke? See Breaking Changes.
Curious about removed or deprecated APIs? Visit Deprecated APIs.
Want to understand behavior changes? See Modified Behavior.
Looking for what's new? See New Features.
Looking for fixes? Check Bug Fixes.
Prerequisites
Supported chain versions
SUPPORTED_SPEC_VERSION_RANGE is narrowed from '7.0 || 7.1 || 7.2 || 7.3 || 8.0' to '8.0 || 8.1'. The SDK must be pointed at a chain v8 node; connecting to a v7 chain throws on initialization.
| SDK version | Chain v7 | Chain v8.x |
|---|---|---|
| v30.2.0 | ✅ | ✅ |
| v31.0.0 | ❌ | ✅ |
No dependency, middleware or Node.js version changes accompany this release.
Quick migration checklist
Use this checklist when upgrading from v30.2.0.
- Point the SDK at a chain v8 node (see Chain v7 is no longer supported)
- Remove usage of child-identity APIs, CDD claim lookups,
AccountManagement.subsidizeAccount,Instruction.withdraw/withdrawAsMediator,destinationPortfolio, andcddAuth(see Removed v7-only entities and features) - Stop passing
controllertobondPolyxandStaking.setController(see Removed v7-only entities and features) - Rename
Identity.isCddProvider()toIdentity.isDidRegistrar()(seeIdentity.isCddProviderrenamed) - Remove usage of
TransferError.InvalidReceiverCdd,InvalidSenderCdd,ScopeClaimMissingandInvalidReceiverPortfolio(see Removed unproducibleTransferErrormembers) - Remove usage of the
TransferStatusenum (see Removed theTransferStatusenum) - Remove usage of
TxGroup.RelayerManagement, and re-check any permission UI built from theTxGroupconstants (see Transaction groups rebuilt) - Update any code matching on the changed error message strings (see Error message changes)
- Migrate from
TxGroup.CddRegistrationtoTxGroup.DidRegistration(see Deprecated APIs) - Remove usage of
ScopeClaimProof,AddInvestorUniquenessClaimParams,ModifyPrimaryIssuanceAgentParamsandModifyCorporateActionsAgentParams(see Removed types describing extrinsics that no longer exist) - Update any code that types
Assets.transferFunds's result asvoid(seeAssets.transferFundsreturn type changed) - Remove usage of
MiddlewareMetadata.paddedIds(seeMiddlewareMetadata.paddedIdsremoved) - Pass
expiresAttoInstruction.generateOffChainAffirmationReceiptandOffering.generateOffChainFundingReceipt(seeexpiresAtis required on off-chain receipts)
Breaking changes
1. Chain v7 is no longer supported
SUPPORTED_SPEC_VERSION_RANGE is narrowed to '8.0 || 8.1'. Connecting the SDK to a v7 chain now throws on initialization. Context.isV7 and the internal isV7Spec util are removed.
2. Removed v7-only entities and features
These have no v8 equivalent. All of them raise type errors at compile time once upgraded, since the corresponding types are removed.
| Removed | Replacement |
|---|---|
ChildIdentity entity and all child-identity APIs | none — child identities do not exist on v8 |
Claims.getCddClaims, Identity.hasValidCdd | none — the backing runtime APIs were removed |
RoleType.CddProvider, CddProviderRole, isCddProviderRole | RoleType.DidRegistrar |
AccountManagement.subsidizeAccount | AccountManagement.approveSubsidy + acceptSubsidy |
AuthorizationType.AddRelayerPayingKey and its consume flow | AccountManagement.approveSubsidy |
InstructionAffirmationOperation.Withdraw / WithdrawAsMediator | reject / rejectAsMediator |
Nft.controllerTransfer's destinationPortfolio param | destination |
AcceptPrimaryKeyRotationParams.cddAuth | none — CDD attestation is no longer part of key rotation |
SetStakingControllerParams, BondPolyxParams.controller | none — the stash is always its own controller on v8 |
Child identities. Identity.getChildIdentities, Identity.unlinkChild, Identity.isChild, Identities.createChild, Identities.createChildren, Identities.getChildIdentity, the createChildIdentity/createChildIdentities/unlinkChildIdentity procedures, and Context.getChildIdentity. Removed types: CreateChildIdentityParams, CreateChildIdentitiesParams, ChildKeyWithAuth, UnlinkChildParams.
Legacy relayer-paying-key flow. The consumeAddRelayerPayingKeyAuthorization procedure, AuthorizationType.AddRelayerPayingKey, and the AddRelayerPayingKeyAuthorizationData / ConsumeAddRelayerPayingKeyAuthorizationParams types are removed. AuthorizationType.OldAddRelayerPayingKey remains so an authorization stored before v8 can still be read and removed, but it can no longer be accepted — doing so throws NotSupported, pointing at AccountManagement.approveSubsidy. No v8 extrinsic creates one.
Instruction affirmation withdrawal. Instruction.withdraw, Instruction.withdrawAsMediator and the WithdrawInstructionParams type are removed.
Staking controller. Staking.setController is now a no-arg procedure. bondPolyx no longer accepts, validates or sends a controller argument — on v8 the stash is always its own controller, so the RewardDestination::Controller case can no longer be produced. Supplying a payee equal to the stash now resolves via the existing stash case, and the internal stakingRewardDestinationToRaw no longer accepts { controller: true }. The chain enum still carries the variant, so rewardDestinationToPayee continues to decode a stored Controller payee.
3. Identity.isCddProvider renamed to Identity.isDidRegistrar
The method already queried the v8 didRegistrars pallet, so it is renamed to match what it checks:
// Before
await identity.isCddProvider();
// After
await identity.isDidRegistrar();
4. Removed unproducible TransferError members
TransferError.InvalidReceiverCdd, TransferError.InvalidSenderCdd, TransferError.ScopeClaimMissing and TransferError.InvalidReceiverPortfolio are removed. None could be produced on v8 — the first three because transfers are no longer CDD gated, the fourth because its producer was deleted with v7 support.
InvalidReceiverPortfolio's sole producer was the removed granularCanTransferResultToTransferBreakdown cascade, and it cannot be re-mapped: the portfolio pallet has a single PortfolioDoesNotExist variant, already mapped to InvalidSenderPortfolio.
Verified against Polymesh Mainnet (spec 8000020): the chain defines no CDD-related error in the asset, portfolio, statistics, nft or complianceManager pallets. Transfers are not gated on CDD claims as of v8, so neither CDD value could ever be produced. ScopeClaimMissing relates to Investor Uniqueness, removed from the chain some time ago.
The internal granularCanTransferResultToTransferBreakdown helper that produced these values is removed, along with the now-orphaned assetComplianceResultToCompliance and complianceRequirementResultToRequirementCompliance helpers. The live path, transferReportToTransferBreakdown, is unaffected.
5. Removed the TransferStatus enum
TransferStatus and its sole producer u8ToTransferStatus are removed, along with the transfersCanTransfer mock option in testUtils.
The enum is an ERC1400-style status code set decoded from the v6 asset_canTransfer RPC, which no longer exists — the SDK's only remaining rpc.* references are rpc.chain and rpc.system. Nothing in the SDK had called u8ToTransferStatus for several releases, and several members named concepts long gone from the chain (SmartExtensionFailure, ScopeClaimMissing).
TransferError — the enum that is actually populated, by Settlements.canTransfer — keeps its role. It loses the members listed in section 4, gains InvalidReceiverIdentity (see Bug fixes), and its members' doc comments no longer refer to TransferStatus equivalents.
6. Transaction groups rebuilt from the chain's permission model
Every group was audited against Polymesh Mainnet (spec 8000020) and the v8.0.2 runtime source. A transaction now belongs to a group only if the chain resolves its origin through a call-permission check — the only path that consults a signer's ExtrinsicPermissions. Transactions gated by ensure_signed, ensure_root, ensure_did or ensure_primary_key ignore permissions entirely, so granting them never had any effect.
Groups now describe the chain's permission-checked surface rather than the SDK's current procedure coverage, so a transaction is grouped whether or not a procedure exists for it yet.
Removed groups:
| Group | Reason |
|---|---|
TxGroup.RelayerManagement / RELAYER_MANAGEMENT_TX_TAGS | every extrinsic in the relayer pallet is ensure_signed only, so the group never gated anything. No replacement grant is needed |
New groups:
| Group | Contents |
|---|---|
TxGroup.InstructionMediation | AffirmInstructionAsMediator, RejectInstructionAsMediator, LockInstruction, UnlockInstruction — newly grantable; no group previously covered these, so existing SettlementManagement grantees do not already have them and must be granted the new group |
TxGroup.DidRegistration | identity.RegisterDid |
TxGroup.MultiSigManagement is reduced to multiSig.CreateMultisig — the only extrinsic in the pallet that runs a permission check. The others are called by the MultiSig account itself via an executed proposal, are ensure_signed only, or are primary-key only.
A MultiSig executes proposals under its own origin, so what a MultiSig may do via proposal is constrained by the permissions held by the MultiSig Account itself — set with the optional
permissionsargument ofcreateMultisig— not by its signers' permissions.
Removed tags — extrinsics no longer on chain:
SETTLEMENT_MANAGEMENT_TX_TAGS:settlement.AddInstructionWithMemo,settlement.AddAndAffirmInstructionWithMemo(folded intoAddInstruction/AddAndAffirmInstruction)PORTFOLIO_MANAGEMENT_TX_TAGS:portfolio.MovePortfolioFundsV2(folded intoMovePortfolioFunds)
Added tags — permission-checked v8 extrinsics no group previously covered:
TxGroup.SettlementManagement:TransferFunds,SetMandatoryReceiverAffirmation,AddInstructionWithMediators,AddAndAffirmWithMediators, the*WithCountaffirm/reject variants the SDK now submits by default,asset.Approve, the portfolio pre-approval pair, and the v8 direct-transfer flow (asset.TransferAsset,asset.ReceiverAffirmAssetTransfer,asset.RejectAssetTransfer,nft.TransferNft)TxGroup.PortfolioManagement:AllowIdentityToCreatePortfolios,RevokeCreatePortfoliosPermissionTxGroup.StoManagement:EnableOffchainFundingTxGroup.CorporateActionsManagement:InitiateCorporateActionAndBallotTxGroup.Issuance:nft.CreateNftCollectionTxGroup.ExternalAgentManagement:externalAgents.AcceptBecomeAgent
nft.createNftCollection now appears in two groups, because the extrinsic takes two paths on chain. Creating a collection for a new Asset is a Secondary Key operation (ensure_origin_call_permissions) and stays in TxGroup.AssetRegistration; creating one under an existing Asset is Agent checked (ExternalAgents::ensure_agent_asset_perms). TxGroup.AssetRegistration is not agent grantable, so before this change no External Agent group could ever be granted the second path. TxGroup.Issuance is agent grantable and now covers it.
externalAgents.acceptBecomeAgent likewise appears in two groups. TxGroup.ExternalAgentParticipation grants a Secondary Key the ability to accept an invitation for its own Identity; TxGroup.ExternalAgentManagement — which is agent grantable — now grants an Agent the ability to invite others, because the chain checks the invitation's creator against this transaction when the invitee accepts. Previously only PermissionGroupType.Full could invite an Agent.
Two exclusions are worth calling out: identity.selfRegisterDid is callable only by a key not linked to any Identity, and asset.updateGlobalMetadataSpec requires a root origin as global metadata is governance-controlled. They are not the only uncovered extrinsics — around 36 across the audited pallets are not in any group, the rest being root, governance or committee gated, or otherwise never permission-checked.
Consumers building permission UIs from
TX_GROUP_TO_TAGS_MAP,AGENT_TX_GROUP_VALUESor the individual*_TX_TAGSconstants should re-check their output — options that never gated anything disappear, and several shipping v8 features become grantable for the first time.
7. Error message changes
Some validations carried CDD-era or v7-era wording that no longer describes what they check. If your code matches on these strings, update it:
| Before | After |
|---|---|
'Issuing Identity does not have a valid CDD claim' | 'Issuing Identity does not exist' |
'Issuer must be a CDD provider' | 'Issuer must be a DID Registrar' |
'MultiSig signers must be accounts as of v7' | 'MultiSig signers must be accounts' |
'`expiresAt` is mandatory from chain 8.x' | '`expiresAt` is required' |
These no longer occur at all, since the validation raising them is removed (see below):
'Beneficiary Account does not have a valid CDD Claim''Subsidizer Account does not have a valid CDD Claim'
Validation of OldAddRelayerPayingKey authorizations is removed. The authorization is marked deprecated in the runtime and appears nowhere outside the AuthorizationData enum — no v8 extrinsic creates, accepts or consumes one, since the subsidy flow (approve_subsidy → accept_subsidy) is direct rather than authorization-based. A legacy authorization stored before v8 can still be read and removed, but never accepted, so assertAuthorizationRequestValid had no work to do for it and the four chain queries it made could never run.
8. Removed types describing extrinsics that no longer exist
Four exported types described chain features removed before v8. None was referenced anywhere in the SDK, and no procedure accepted or returned them — there is no replacement because there is no longer an operation to describe.
| Removed | Described |
|---|---|
ScopeClaimProof | the ZK proof payload of identity.add_investor_uniqueness_claim, an extrinsic that does not exist on v8 |
AddInvestorUniquenessClaimParams | the params of that same extrinsic |
ModifyPrimaryIssuanceAgentParams | the Primary Issuance Agent concept, superseded by External Agents |
ModifyCorporateActionsAgentParams | the params of the modifyCorporateActionsAgent procedure, itself removed below |
Investor Uniqueness was removed from the chain some time ago; TransferError.ScopeClaimMissing, which belonged to the same feature, is removed in section 4.
The modifyCorporateActionsAgent procedure is removed alongside them. Its only caller, CorporateActions.setAgent, was removed in v18.0.0 in favour of Permissions.inviteAgent, but the procedure itself was left behind.
In testUtils, the three Asset entity mocks no longer default a primaryIssuanceAgents: [] field. AssetDetails has no such field, so the default was never read — but a test asserting on a whole mock object with toEqual will need it dropped from the expectation.
9. Assets.transferFunds return type changed
Assets.transferFunds now resolves to Instruction | undefined instead of void, reflecting that the underlying settlement.transferFunds extrinsic can leave a pending settlement instruction rather than always settling immediately (see Cross-identity Assets.transferFunds below).
undefined— the transfer settled immediately, exactly as before (same-DID transfer, or the receiving Identity has automatic affirmation and settled in the same transaction).- an
Instruction— the transfer created a settlement instruction that is stillPendingthe receiving Identity's affirmation.
Code that calls transferFunds and ignores the result is unaffected. Code that types the result as void (e.g. an explicitly annotated intermediate variable) needs updating.
10. MiddlewareMetadata.paddedIds removed
MiddlewareMetadata no longer carries a paddedIds flag, and Network.getMiddlewareMetadata() no longer returns one.
The flag reported whether the connected SubQuery instance pads entity IDs for correct lexical ordering — a behavior introduced in SubQuery v19. Every middleware version this SDK supports pads them (MINIMUM_SQ_VERSION is v19.6.0-alpha.2), so the flag was always true in a supported setup. The internal MINIMUM_SQ_PADDED_ID_VERSION constant and every orderBy branch it selected are removed; middleware queries now always order by the padded block/event ID columns.
Code that reads paddedIds off the metadata should drop it. No query results change against a supported middleware.
11. expiresAt is required on off-chain receipts
Chain v8 signs off-chain receipts as a ChainScopedMessage, which always includes an expiry. expiresAt is therefore a required argument of:
Instruction.generateOffChainAffirmationReceiptOffering.generateOffChainFundingReceipt
and a required field on OffChainAffirmationReceipt and OffChainFundingReceipt.
await instruction.generateOffChainAffirmationReceipt({
legId,
uid,
expiresAt: new Date('2030-01-01'),
});
await offering.generateOffChainFundingReceipt({
uid,
offChainTicker,
amount,
sender,
expiresAt: new Date('2030-01-01'),
});
Deprecated APIs
registerIdentity's createCdd / expiry params
As of chain v8 the underlying cdd_register_did / cdd_register_did_with_cdd extrinsics are deprecated and no longer attach a CustomerDueDiligence claim — they behave identically to identity.registerDid. Identities.registerIdentity is marked @deprecated and the createCdd / expiry params no longer have any on-chain effect.
Use Identities.registerDid (registrar-gated, added in v30.2.0) or Identities.selfRegisterDid (permissionless) instead.
TxGroup.CddRegistration
Deprecated but retained — identity.cddRegisterDid and identity.cddRegisterDidWithCdd still exist on chain, so the group remains functional. Use TxGroup.DidRegistration instead.
Retained CDD surface
For clarity, the following are intentionally retained: CddClaim, isCddClaim, ClaimType.CustomerDueDiligence, DEFAULT_CDD_ID, and the modifyClaims CDD-ID validation. CDD claims are independent of DID registration on v8 and can still be issued via identity.addClaim — but only by a DID Registrar: the runtime routes a CustomerDueDiligence claim through base_add_cdd_claim, which calls ensure_authorized_did_registrar. claims.addClaims and claims.editClaims therefore require RoleType.DidRegistrar when any CDD claim is involved; claims.revokeClaims does not (see Bug fixes). Only the runtime-backed CDD validity lookups were removed.
Modified behavior
These paths previously branched on context.isV7 and are now unconditional:
Context.getAccountBalanceuses only the v8FrameSystemAccountInfoshape — the legacyAccountInfoassembly path is removed.transferPolyxalways usesbalances.transferWithMemowith an optional memo — v7's memo-lessbalances.transferpath is removed.Instruction.generateOffChainAffirmationReceiptandInstruction.generateOffChainAffirmationTransactionRawalways requireexpiresAt, and the payload always uses the v8 receipt format.Identity.checkRoleForDidandIdentity.isDidRegistraralways querydidRegistrars.activeMembers— the v7cddServiceProviders.activeMembersfallback is gone. (Identity.isGcMemberis unrelated and unchanged; it queriescommitteeMembership.activeMembers.)registerDidandselfRegisterDidno longer guard against v7 chains.
New features
Cross-identity Assets.transferFunds
Assets.transferFunds no longer requires from and to to belong to the same Identity. The underlying settlement.transferFunds extrinsic already supports this on chain: it authorizes the source (allowance for an Account, custody for a Portfolio), creates a settlement instruction, and auto-affirms it on behalf of the sender. If the receiving Identity has automatic affirmation (the default), the instruction settles immediately in the same transaction. Otherwise it's left Pending, awaiting the receiver's separate affirmation via the normal Instruction.affirm() flow.
const result = await sdk.assets.transferFunds({
from: aliceDefaultPortfolio, // or an Account
to: bobDefaultPortfolio, // a different Identity's Portfolio or Account
asset,
amount: new BigNumber(100),
});
if (result) {
// a pending Instruction — bob must affirm before the transfer settles
await result.details(); // status: 'Pending'
}
See Assets.transferFunds return type changed for the accompanying return-type change.
Asset.checkpoints.schedules.getNextCheckpoint getter
Returns the closest upcoming Checkpoint across all of an Asset's active Schedules, read from the chain's cached checkpoint.cachedNextCheckpoints storage instead of resolving each Schedule individually:
const next = await asset.checkpoints.schedules.getNextCheckpoint();
// { nextAt: Date, totalPending: BigNumber, schedules: [{ id: BigNumber, nextAt: Date }, ...] }
// or null if the Asset has no active Schedules (including after every Schedule has been removed)
This complements the existing CheckpointSchedule.details(), which resolves the next date for one specific Schedule — getNextCheckpoint gives an asset-wide summary in a single query.
CorporateActionBase.getDocuments getter
Returns the documents linked to a Corporate Action, read from the chain's corporateAction.caDocLink storage and resolved against the Asset's document list:
const documents = await corporateAction.getDocuments();
// AssetDocumentWithId[]
This is the missing read-side counterpart to the existing linkDocuments method, which writes to the same storage via corporateAction.linkCaDoc. Any linked document ID that no longer exists on the Asset (since removed) is omitted from the result.
Bug fixes
Transaction tags corrected against chain v8 metadata
Several getAuthorization permission declarations named extrinsics removed in chain v8 while submitting a different, live extrinsic. The SDK's pre-flight checkPermissions / checkRoles therefore evaluated the signing key against a tag the chain never checks, and could disagree with the chain's own verdict in either direction.
| Procedure | Submits | Declared (removed) | Now declares |
|---|---|---|---|
quitSubsidy | relayer.removeSubsidy | relayer.RemovePayingKey | relayer.RemoveSubsidy |
toggleAssetPreApproval | asset.preApproveAsset | asset.PreApproveTicker | asset.PreApproveAsset |
toggleAssetPreApproval | asset.removeAssetPreApproval | asset.RemoveTickerPreApproval | asset.RemoveAssetPreApproval |
modifyMultiSig | multiSig.addMultisigSignersViaAdmin | multiSig.AddMultisigSignersViaCreator | multiSig.AddMultisigSignersViaAdmin |
modifyMultiSig | multiSig.removeMultisigSignersViaAdmin | multiSig.RemoveMultisigSignersViaCreator | multiSig.RemoveMultisigSignersViaAdmin |
modifyMultiSig | multiSig.changeSigsRequiredViaAdmin | multiSig.ChangeSigsRequiredViaCreator | multiSig.ChangeSigsRequiredViaAdmin |
consumeAddMultiSigSignerAuthorization | multiSig.acceptMultisigSigner | multiSig.AcceptMultisigSignerAsIdentity | multiSig.AcceptMultisigSigner |
Two related fixes:
PolymeshTransaction.ignoresSubsidy()compared againstrelayer.RemovePayingKeyand so was permanentlyfalse, meaningrelayer.removeSubsidy— which must always be paid by the caller — was treated as subsidisable.- Accounts could not accept a MultiSig signer invitation without explicit permission. The "allowed to any Account regardless of permissions" list named the removed
multiSig.AcceptMultisigSignerAsKeyinstead ofmultiSig.AcceptMultisigSigner.
Revoking a CDD claim no longer requires the DID Registrar role
modifyClaims applied RoleType.DidRegistrar whenever any claim in the batch was a CustomerDueDiligence claim, regardless of operation. Only issuing one is registrar gated on chain: identity.add_claim branches on Claim::CustomerDueDiligence into base_add_cdd_claim, which calls ensure_authorized_did_registrar. identity.revoke_claim has no such check — it resolves the claim by (target, claimType, issuer, scope), so the only requirement is being the Identity that issued it.
The role is now required for claims.addClaims and claims.editClaims only. claims.revokeClaims declares just the identity.RevokeClaim permission, matching the chain. This unblocks an Identity that issued CDD claims and was later removed from the registrar group, which the chain allows but SDK pre-flight rejected.
staking.getPayee() returns null for an Account with no payee
staking.payee is an Option, and the non-subscription path called .unwrap() on it, so getPayee() threw a raw polkadot error for any Account that is not a stash — contradicting its documented null return and diverging from the subscription path, which already used unwrapOr(null). Both paths now agree.
canTransfer reports transfer failures instead of throwing
nftDispatchErrorToTransferError mapped 8 of the nft pallet's error variants and threw UnexpectedError on anything else — unlike its fungible sibling, which falls back to the chain's own error name. Four errors the NFT transfer-validation path emits were unmapped, so NonFungibleSettlements.canTransfer — a predicate whose whole contract is to explain why a transfer would fail — threw instead of returning a breakdown. They now map onto TransferError:
| Chain error | TransferError |
|---|---|
InvalidNFTTransferSenderDidMatchesReceiverDid | SelfTransfer |
InvalidNFTTransferCollectionNotFound | AssetDoesNotExists |
InvalidNFTTransferCountOverflow | BalanceOverflow |
InvalidNFTTransferInvalidReceiverDID | InvalidReceiverIdentity (new) |
New member: TransferError.InvalidReceiverIdentity — the receiving Identity is not active. Both transfer-validation paths report this condition (nft.InvalidNFTTransferInvalidReceiverDID and asset.InvalidTransferInvalidReceiverDID) and neither was mapped, so it previously surfaced as a raw chain error name — two different spellings for the same condition. assetDispatchErrorToTransferError now maps it too.
Anything still unmapped — MaxNumberOfNFTsPerLegExceeded, ZeroCount, UnexpectedNonFungibleToken — falls back to the chain's own name rather than throwing. These are structural or caller errors rather than conditions a user can act on, so they get no TransferError member. nftDispatchErrorToTransferError returns TransferError | string accordingly; TransferBreakdown.general was already (TransferError | string)[], so consumers reading it are unaffected.
Procedures now declare the transaction they actually submit
The fixes above covered procedures declaring a tag for an extrinsic removed in v8. A second group declared a tag for an extrinsic that exists but is not the one submitted — sometimes on every call, sometimes only on one branch. checkPermissions therefore evaluated the signing key against a tag the chain never checks for that call, and could disagree with the chain in either direction: a correctly-permissioned key blocked client-side, or an under-permissioned one passing pre-flight and failing on chain.
| Procedure | Submits | Declared | Now declares |
|---|---|---|---|
configureDividendDistribution | corporateAction.initiateCorporateActionAndDistribute | capitalDistribution.Distribute (every call) | corporateAction.InitiateCorporateActionAndDistribute |
createAsset | asset.createAssetWithCustomType for an unregistered custom type | asset.CreateAsset | asset.CreateAssetWithCustomType on that branch |
createAsset | asset.issue when initialSupply is set | nothing | asset.Issue |
inviteExternalAgent | externalAgents.createGroupAndAddAuth when no group matches | identity.AddAuthorization | externalAgents.CreateGroupAndAddAuth on that branch |
setPermissionGroup | externalAgents.createAndChangeCustomGroup when no group matches | externalAgents.ChangeGroup | externalAgents.CreateAndChangeCustomGroup on that branch |
registerIdentity | identity.cddRegisterDidWithCdd when createCdd is set | identity.CddRegisterDid | identity.CddRegisterDidWithCdd on that branch |
createAsset also stopped declaring asset.RegisterCustomAssetType, which it never submits — an unregistered custom type is created by createAssetWithCustomType itself. The guard that pushed it (customTypeData?.rawId.isEmpty) could never be true, since a custom type ID is always either an existing registration or the next sequence value, both ≥ 1.
inviteExternalAgent and setPermissionGroup resolve the matching Permission Group in prepareStorage rather than mid-prepare, so getAuthorization can see which branch will run. No extra chain queries — the lookup moved rather than being duplicated.
Procedures no longer declare permissions the chain never checks
The two fixes above corrected procedures that declared the wrong tag. A third group declared a tag the chain never consults at all. Because these extrinsics are gated by ensure_signed or ensure_primary_key, they never read a signer's ExtrinsicPermissions — so no permission grant could satisfy the declaration, and SDK pre-flight rejected keys the chain would have accepted. Correspondingly, none of these tags belongs to any TxGroup, so there was never a grant to hold.
The declarations are dropped from:
| Procedure | Dropped | Chain gate |
|---|---|---|
modifyMultiSig | all six multiSig.* add/remove-signer and change-signatures tags | ensure_ms_admin → ensure_primary_key, or ensure_signed with the MultiSig as origin |
setMultiSigAdmin | multiSig.AddAdmin, multiSig.RemoveAdminViaAdmin | ensure_signed / ensure_ms_admin |
removeMultiSigPayer | multiSig.RemovePayerViaPayer | ensure_ms_payer |
modifyAllowance | relayer.IncreasePolyxLimit, DecreasePolyxLimit, UpdatePolyxLimit | ensure_signed (every relayer extrinsic) |
addSecondaryAccountsWithAuth | identity.AddSecondaryKeysWithAuthorization | ensure_primary_key |
removeSecondaryAccounts | identity.RemoveSecondaryKeys | ensure_primary_key |
toggleFreezeSecondaryAccounts | identity.FreezeSecondaryKeys, UnfreezeSecondaryKeys | ensure_primary_key |
modifySignerPermissions | identity.SetSecondaryKeyPermissions | ensure_primary_key |
acceptSubsidy, revokeSubsidy, quitSubsidy | relayer.AcceptSubsidy, relayer.RevokeSubsidy, relayer.RemoveSubsidy | ensure_signed |
createTransactionBatch | utility.BatchAll | not checked as the outer call — each batched call is dispatched under its own metadata and checked individually |
The real requirements are unchanged and still enforced: modifyAllowance still checks the caller is the subsidizer via roles, and the four identity procedures still check the caller is the primary key via signerPermissions.
In the opposite direction, revokeIdentityToCreatePortfolios declared no transaction while submitting portfolio.revokeCreatePortfoliosPermission, which is permission-checked. It now declares that tag, matching its allowIdentityToCreatePortfolios sibling and consuming the PORTFOLIO_MANAGEMENT_TX_TAGS grant added in section 6.
Ballot voting no longer requires External Agent status
castBallotVote scoped its permission to the Asset, which asserts the signing Identity is an External Agent of it — so every ordinary holder's vote failed pre-flight with The Identity is not an Agent for the Asset. Voting is a holder action, so the Asset permission is dropped, matching claimDividends and the other holder-side procedures.
Authorization procedures now check the permission the chain will actually enforce
Asset.transferOwnership and Permissions.inviteAgent both submit identity.addAuthorization, a generic call that the chain lets any Identity raise. The real Agent check happens when the target accepts: ensure_agent_permissioned runs against the Identity that created the authorization, and because it reads CurrentDispatchableName, the permission it demands is the accept transaction.
Pre-flight previously asserted bare Agent status against identity.addAuthorization — a tag the chain never checks for an Agent, and one no group grants. It now checks what acceptance will require:
| Procedure | Signing key needs | Signing Identity needs, as an Agent of the Asset |
|---|---|---|
Asset.transferOwnership | identity.AddAuthorization | asset.AcceptAssetOwnershipTransfer |
Permissions.inviteAgent | identity.AddAuthorization | externalAgents.AcceptBecomeAgent |
Permissions.inviteAgent, when no existing Permission Group matches | externalAgents.CreateGroupAndAddAuth, scoped to the Asset | externalAgents.CreateGroupAndAddAuth and externalAgents.AcceptBecomeAgent |
This is deliberately stricter than the chain at the moment of submission, and exactly as strict as the two-step flow as a whole — an invitation created without the accept-side permission can never be accepted. See section 6 for the group changes that make these grantable.
Protocol fees no longer silently reported as zero
txTagToProtocolOp derived the ProtocolOp variant name mechanically from the tag. A tag cannot be translated that way, for two reasons — and Context.getProtocolFees swallows the resulting error and returns a fee of 0, so every failure was silent.
Variant names that survived an extrinsic rename. Two live extrinsics quoted 0 because the derived name is not one the runtime knows:
| Tag | Derived (invalid) | Actual chain variant |
|---|---|---|
asset.registerUniqueTicker | AssetRegisterUniqueTicker | AssetRegisterTicker |
identity.cddRegisterDid | IdentityCddRegisterDid | IdentityRegisterDid |
Extrinsics that bill under a variant named after a different extrinsic, because they delegate to it. Five more quoted 0, verified against the runtime's charge_fee call sites:
| Tag | Bills as | Why |
|---|---|---|
identity.cddRegisterDidWithCdd | IdentityRegisterDid | all DID registration variants share base_register_did |
identity.selfRegisterDid | IdentityRegisterDid | charges the op explicitly |
corporateAction.initiateCorporateActionAndDistribute | CapitalDistributionDistribute | delegates to unverified_distribute |
corporateAction.initiateCorporateActionAndBallot | CorporateBallotAttachBallot | delegates to unverified_create_ballot |
asset.createAssetWithCustomType | AssetCreateAsset | registers the custom type inline, billing one asset creation fee |
The mapping is now explicit and mirrors the runtime. identity.registerDid is added and the dead asset.registerTicker entry removed. This affects Network.getProtocolFees and transaction.getTotalFees() for ticker reservation, identity registration, dividend distribution, ballot creation and custom-type asset creation.
createAsset also stopped adding a manual fee for asset.registerCustomAssetType. There is no ProtocolOp variant for it — the runtime charges nothing for the registration — so the addition was always exactly 0.
nft.createNftCollectionandnft.issueNftare unaffected. Both map toProtocolOpvariants (NFTCreateCollection,NFTMint) that exist on chain but that the runtime has nocharge_feecall site for, so both quoted 0 before this change and quote 0 after. The mappings are still correct:nft.createNftCollectiononly reaches a fee on the branch where it creates the asset itself, which the SDK never takes — it always passes anassetIdand submitsasset.createAssetseparately when a new asset is needed.
getNextCheckpoint returns null after every Schedule has been removed
Asset.checkpoints.schedules.getNextCheckpoint is documented to return null when the Asset has no active Schedules. That held while checkpoint.cachedNextCheckpoints was unset, but removing a Schedule does not clear the storage entry — base_remove_schedule only drops the Schedule from the map and recalculates nextAt. The entry is removed later, when advance_schedules observes an empty map.
Until then the chain keeps a sentinel { nextAt: u64::MAX, totalPending: 0, schedules: {} }. momentToDate called u64.toNumber() on that nextAt and threw Number can only safely store up to 53 bits.
The getter now treats an empty schedules map as "no active Schedules" and returns null, matching NextCheckpoints::is_empty on chain.
STO off-chain funding receipts now encode expiresAt
PolymeshPrimitivesStoFundraiserReceiptDetails includes expiresAt. offChainFundingReceiptDetailsToMeshReceiptDetails never set it, so it encoded as 0 and the chain rejected every off-chain funded investment with sto.ReceiptExpired. Offering.generateOffChainFundingReceipt also had no way to supply an expiry, and did not include genesis hash, receipt label or expiry in the signed payload — unlike settlement receipts, which were fixed in 30.1.1-beta.3.
Pass expiresAt when generating a funding receipt (same contract as Instruction.generateOffChainAffirmationReceipt):
const receipt = await offering.generateOffChainFundingReceipt({
uid,
offChainTicker,
amount,
sender,
expiresAt: new Date('2030-01-01'),
});
The signed payload now matches the chain's ChainScopedMessage: <Bytes> + genesis hash + uid + SCALE-encoded "Polymesh STO Fundraiser Receipt" label + expiry + fundraiser receipt + </Bytes>.
transferFunds rejected NFTs owned by a MultiSig's own Account
transferFunds's NFT-ownership check compared the Account or Portfolio returned by Nft.getOwner() against the source holder using Entity.isEqual, whose uuid is keyed by the entity's constructor name. getOwner() always resolves an Account-type holder to a plain Account, so when the source was a MultiSig's own Account — a MultiSig subclass instance — the comparison failed even though the address matched, and the transfer was rejected with Some of the NFTs are not owned by the sender, are locked, or do not exist. The check now compares by address whenever both sides are Account-like, falling back to isEqual for Portfolio holders.
Version compatibility matrix
| SDK version | Chain v7 | Chain v8.x | Middleware V2 | Polkadot.js |
|---|---|---|---|---|
| v30.2.0 | ✅ | ✅ | ≥ v19.6.0-alpha.2 | 16.5.2 |
| v31.0.0 | ❌ | ✅ | ≥ v19.6.0-alpha.2 | 16.5.2 |