Skip to main content
Version: 31.0.x

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.x spec 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 versionChain v7Chain 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.

  1. Point the SDK at a chain v8 node (see Chain v7 is no longer supported)
  2. Remove usage of child-identity APIs, CDD claim lookups, AccountManagement.subsidizeAccount, Instruction.withdraw/withdrawAsMediator, destinationPortfolio, and cddAuth (see Removed v7-only entities and features)
  3. Stop passing controller to bondPolyx and Staking.setController (see Removed v7-only entities and features)
  4. Rename Identity.isCddProvider() to Identity.isDidRegistrar() (see Identity.isCddProvider renamed)
  5. Remove usage of TransferError.InvalidReceiverCdd, InvalidSenderCdd, ScopeClaimMissing and InvalidReceiverPortfolio (see Removed unproducible TransferError members)
  6. Remove usage of the TransferStatus enum (see Removed the TransferStatus enum)
  7. Remove usage of TxGroup.RelayerManagement, and re-check any permission UI built from the TxGroup constants (see Transaction groups rebuilt)
  8. Update any code matching on the changed error message strings (see Error message changes)
  9. Migrate from TxGroup.CddRegistration to TxGroup.DidRegistration (see Deprecated APIs)
  10. Remove usage of ScopeClaimProof, AddInvestorUniquenessClaimParams, ModifyPrimaryIssuanceAgentParams and ModifyCorporateActionsAgentParams (see Removed types describing extrinsics that no longer exist)
  11. Update any code that types Assets.transferFunds's result as void (see Assets.transferFunds return type changed)
  12. Remove usage of MiddlewareMetadata.paddedIds (see MiddlewareMetadata.paddedIds removed)
  13. Pass expiresAt to Instruction.generateOffChainAffirmationReceipt and Offering.generateOffChainFundingReceipt (see expiresAt is 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.

RemovedReplacement
ChildIdentity entity and all child-identity APIsnone — child identities do not exist on v8
Claims.getCddClaims, Identity.hasValidCddnone — the backing runtime APIs were removed
RoleType.CddProvider, CddProviderRole, isCddProviderRoleRoleType.DidRegistrar
AccountManagement.subsidizeAccountAccountManagement.approveSubsidy + acceptSubsidy
AuthorizationType.AddRelayerPayingKey and its consume flowAccountManagement.approveSubsidy
InstructionAffirmationOperation.Withdraw / WithdrawAsMediatorreject / rejectAsMediator
Nft.controllerTransfer's destinationPortfolio paramdestination
AcceptPrimaryKeyRotationParams.cddAuthnone — CDD attestation is no longer part of key rotation
SetStakingControllerParams, BondPolyxParams.controllernone — 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:

GroupReason
TxGroup.RelayerManagement / RELAYER_MANAGEMENT_TX_TAGSevery extrinsic in the relayer pallet is ensure_signed only, so the group never gated anything. No replacement grant is needed

New groups:

GroupContents
TxGroup.InstructionMediationAffirmInstructionAsMediator, RejectInstructionAsMediator, LockInstruction, UnlockInstructionnewly grantable; no group previously covered these, so existing SettlementManagement grantees do not already have them and must be granted the new group
TxGroup.DidRegistrationidentity.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 permissions argument of createMultisig — not by its signers' permissions.

Removed tags — extrinsics no longer on chain:

  • SETTLEMENT_MANAGEMENT_TX_TAGS: settlement.AddInstructionWithMemo, settlement.AddAndAffirmInstructionWithMemo (folded into AddInstruction / AddAndAffirmInstruction)
  • PORTFOLIO_MANAGEMENT_TX_TAGS: portfolio.MovePortfolioFundsV2 (folded into MovePortfolioFunds)

Added tags — permission-checked v8 extrinsics no group previously covered:

  • TxGroup.SettlementManagement: TransferFunds, SetMandatoryReceiverAffirmation, AddInstructionWithMediators, AddAndAffirmWithMediators, the *WithCount affirm/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, RevokeCreatePortfoliosPermission
  • TxGroup.StoManagement: EnableOffchainFunding
  • TxGroup.CorporateActionsManagement: InitiateCorporateActionAndBallot
  • TxGroup.Issuance: nft.CreateNftCollection
  • TxGroup.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_VALUES or the individual *_TX_TAGS constants 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:

BeforeAfter
'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_subsidyaccept_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.

RemovedDescribed
ScopeClaimProofthe ZK proof payload of identity.add_investor_uniqueness_claim, an extrinsic that does not exist on v8
AddInvestorUniquenessClaimParamsthe params of that same extrinsic
ModifyPrimaryIssuanceAgentParamsthe Primary Issuance Agent concept, superseded by External Agents
ModifyCorporateActionsAgentParamsthe 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 still Pending the 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.generateOffChainAffirmationReceipt
  • Offering.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.getAccountBalance uses only the v8 FrameSystemAccountInfo shape — the legacy AccountInfo assembly path is removed.
  • transferPolyx always uses balances.transferWithMemo with an optional memo — v7's memo-less balances.transfer path is removed.
  • Instruction.generateOffChainAffirmationReceipt and Instruction.generateOffChainAffirmationTransactionRaw always require expiresAt, and the payload always uses the v8 receipt format.
  • Identity.checkRoleForDid and Identity.isDidRegistrar always query didRegistrars.activeMembers — the v7 cddServiceProviders.activeMembers fallback is gone. (Identity.isGcMember is unrelated and unchanged; it queries committeeMembership.activeMembers.)
  • registerDid and selfRegisterDid no 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.

ProcedureSubmitsDeclared (removed)Now declares
quitSubsidyrelayer.removeSubsidyrelayer.RemovePayingKeyrelayer.RemoveSubsidy
toggleAssetPreApprovalasset.preApproveAssetasset.PreApproveTickerasset.PreApproveAsset
toggleAssetPreApprovalasset.removeAssetPreApprovalasset.RemoveTickerPreApprovalasset.RemoveAssetPreApproval
modifyMultiSigmultiSig.addMultisigSignersViaAdminmultiSig.AddMultisigSignersViaCreatormultiSig.AddMultisigSignersViaAdmin
modifyMultiSigmultiSig.removeMultisigSignersViaAdminmultiSig.RemoveMultisigSignersViaCreatormultiSig.RemoveMultisigSignersViaAdmin
modifyMultiSigmultiSig.changeSigsRequiredViaAdminmultiSig.ChangeSigsRequiredViaCreatormultiSig.ChangeSigsRequiredViaAdmin
consumeAddMultiSigSignerAuthorizationmultiSig.acceptMultisigSignermultiSig.AcceptMultisigSignerAsIdentitymultiSig.AcceptMultisigSigner

Two related fixes:

  • PolymeshTransaction.ignoresSubsidy() compared against relayer.RemovePayingKey and so was permanently false, meaning relayer.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.AcceptMultisigSignerAsKey instead of multiSig.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 errorTransferError
InvalidNFTTransferSenderDidMatchesReceiverDidSelfTransfer
InvalidNFTTransferCollectionNotFoundAssetDoesNotExists
InvalidNFTTransferCountOverflowBalanceOverflow
InvalidNFTTransferInvalidReceiverDIDInvalidReceiverIdentity (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.

ProcedureSubmitsDeclaredNow declares
configureDividendDistributioncorporateAction.initiateCorporateActionAndDistributecapitalDistribution.Distribute (every call)corporateAction.InitiateCorporateActionAndDistribute
createAssetasset.createAssetWithCustomType for an unregistered custom typeasset.CreateAssetasset.CreateAssetWithCustomType on that branch
createAssetasset.issue when initialSupply is setnothingasset.Issue
inviteExternalAgentexternalAgents.createGroupAndAddAuth when no group matchesidentity.AddAuthorizationexternalAgents.CreateGroupAndAddAuth on that branch
setPermissionGroupexternalAgents.createAndChangeCustomGroup when no group matchesexternalAgents.ChangeGroupexternalAgents.CreateAndChangeCustomGroup on that branch
registerIdentityidentity.cddRegisterDidWithCdd when createCdd is setidentity.CddRegisterDididentity.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:

ProcedureDroppedChain gate
modifyMultiSigall six multiSig.* add/remove-signer and change-signatures tagsensure_ms_adminensure_primary_key, or ensure_signed with the MultiSig as origin
setMultiSigAdminmultiSig.AddAdmin, multiSig.RemoveAdminViaAdminensure_signed / ensure_ms_admin
removeMultiSigPayermultiSig.RemovePayerViaPayerensure_ms_payer
modifyAllowancerelayer.IncreasePolyxLimit, DecreasePolyxLimit, UpdatePolyxLimitensure_signed (every relayer extrinsic)
addSecondaryAccountsWithAuthidentity.AddSecondaryKeysWithAuthorizationensure_primary_key
removeSecondaryAccountsidentity.RemoveSecondaryKeysensure_primary_key
toggleFreezeSecondaryAccountsidentity.FreezeSecondaryKeys, UnfreezeSecondaryKeysensure_primary_key
modifySignerPermissionsidentity.SetSecondaryKeyPermissionsensure_primary_key
acceptSubsidy, revokeSubsidy, quitSubsidyrelayer.AcceptSubsidy, relayer.RevokeSubsidy, relayer.RemoveSubsidyensure_signed
createTransactionBatchutility.BatchAllnot 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:

ProcedureSigning key needsSigning Identity needs, as an Agent of the Asset
Asset.transferOwnershipidentity.AddAuthorizationasset.AcceptAssetOwnershipTransfer
Permissions.inviteAgentidentity.AddAuthorizationexternalAgents.AcceptBecomeAgent
Permissions.inviteAgent, when no existing Permission Group matchesexternalAgents.CreateGroupAndAddAuth, scoped to the AssetexternalAgents.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:

TagDerived (invalid)Actual chain variant
asset.registerUniqueTickerAssetRegisterUniqueTickerAssetRegisterTicker
identity.cddRegisterDidIdentityCddRegisterDidIdentityRegisterDid

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:

TagBills asWhy
identity.cddRegisterDidWithCddIdentityRegisterDidall DID registration variants share base_register_did
identity.selfRegisterDidIdentityRegisterDidcharges the op explicitly
corporateAction.initiateCorporateActionAndDistributeCapitalDistributionDistributedelegates to unverified_distribute
corporateAction.initiateCorporateActionAndBallotCorporateBallotAttachBallotdelegates to unverified_create_ballot
asset.createAssetWithCustomTypeAssetCreateAssetregisters 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.createNftCollection and nft.issueNft are unaffected. Both map to ProtocolOp variants (NFTCreateCollection, NFTMint) that exist on chain but that the runtime has no charge_fee call site for, so both quoted 0 before this change and quote 0 after. The mappings are still correct: nft.createNftCollection only reaches a fee on the branch where it creates the asset itself, which the SDK never takes — it always passes an assetId and submits asset.createAsset separately 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 versionChain v7Chain v8.xMiddleware V2Polkadot.js
v30.2.0≥ v19.6.0-alpha.216.5.2
v31.0.0≥ v19.6.0-alpha.216.5.2