Skip to content

feat: index corporate actions, checkpoints, ballots and relayer subsidies; merge external-agent entities and retire TransferManager - #356

Open
prashantasdeveloper wants to merge 7 commits into
fix/redesign-post-resync-defectsfrom
redesign/08-coverage
Open

prashantasdeveloper wants to merge 7 commits into
fix/redesign-post-resync-defectsfrom
redesign/08-coverage

Conversation

@prashantasdeveloper

Copy link
Copy Markdown
Contributor

What's in each commit

  1. feat: index corporate actions — CorporateAction + CorporateActionDefaultConfig
    entities. CAInitiated, CARemoved, RecordDateChanged, CALinkedToDoc,
    DefaultTargetIdentitiesChanged, DefaultWithholdingTaxChanged, DidWithholdingTaxChanged.
  2. feat: index checkpoints and schedules — Checkpoint + CheckpointSchedule entities.
    CheckpointCreated, ScheduleCreated, ScheduleRemoved. The one real version change in this
    whole domain: ScheduleCreated/ScheduleRemoved went 3→4 args at chain v6.0.0 (ScheduleId
    inserted, payload type changed) — handled as a two-entry decode shape, with a regression test
    asserting both arities decode and a mismatch throws.
  3. feat: index corporate ballots — CorporateBallot + CorporateBallotVote entities.
    Created, MetaChanged, RangeChanged, RCVChanged, Removed, VoteCast — shape-identical
    across every checked spec version, no version work.
  4. feat: index the Subsidy/Relayer pallet — new Subsidy entity. The relayer pallet had
    zero indexer coverage despite full SDK support. Renamed paying-key → subsidy at chain v8.0.0;
    both eras are handled, since pre-v8 is the bulk of relayer history on any live chain.
  5. feat!: merge the TickerExternalAgent entities (breaking) — TickerExternalAgent →
    AssetAgent, TickerExternalAgentHistory → AssetAgentHistory (these two duplicated their
    write path, which is what actually merges); TickerExternalAgentAction → AssetAgentAction
    (straight rename, unchanged shape — it answers a different question and is driven from an
    unrelated 20-pallet event table). AgentGroup gains the asset relation it was missing
    entirely; AgentGroupMembership.member is now an Identity relation instead of a bare string.
  6. feat!: retire the TransferManager dual model (breaking) — removes the TransferManager
    entity, its Asset.transferManagers derived field, and its writer. It was documented deprecated
    in its own schema comment yet still written unconditionally; neither consumer queries it.

Commits 1–4 are purely additive — new entities and events that didn't exist before, no existing
query changes. Commits 5–6 are breaking schema changes.

Consumer impact

Commits 1–4: none. New entities, new coverage, nothing existing changes shape.

Commit 5 needs a coordinated SDK/portal release. Both consumers query all three renamed
entities:

Old query New query Other changes
tickerExternalAgents assetAgents callerId filters/selections → identityId
tickerExternalAgentHistories assetAgentHistories type is now the AgentHistoryType enum (was a plain string)
tickerExternalAgentActions assetAgentActions no field changes

permissions on AssetAgent/AssetAgentHistory stays a JSON-encoded string, not a typed
jsonField — see the docstring on AssetAgent in schema.graphql for why (the existing
PermissionsJson shape was built for a different on-chain permission model and doesn't fit an
AgentGroup's ExtrinsicPermissions without a lossy flattening).

Commit 6: no consumer query changes. Confirmed neither the SDK nor the portal queries
transferManagers today. It's a schema removal, hence the BREAKING CHANGE footer, but nothing
downstream needs to change.

Verification

yarn codegen && yarn typecheck && yarn lint && yarn test:unit all pass (571 unit tests, 53
suites), plus yarn build (validates every project.ts handler name resolves) and the
10-index-per-entity cap check across the whole schema. A fixture test exists for every new
handler. Full genesis resync was not run for this PR (out of scope for the bar here, matching how
earlier phases were verified) — worth doing before merge given how central corporate actions and
checkpoints are to the securities domain.

Handles CAInitiated, CARemoved, RecordDateChanged, CALinkedToDoc,
DefaultTargetIdentitiesChanged, DefaultWithholdingTaxChanged and
DidWithholdingTaxChanged. New CorporateAction and
CorporateActionDefaultConfig entities. CAInitiated and the
CorporateAction struct are shape-identical v4.1.3-v8.0.0
(docs/reference/event-shape-verification.md); only Ticker->AssetId at
7.x, already handled by getAssetId/getCaIdValue.

MaxDetailsLengthChanged stays unregistered (chain config, no entity),
as does the removed CAATransferred event (superseded by external
agents pre-6.0, per docs/implementation/06-corporate-actions.md).

src/decode/shapes/corporateActions.ts also registers the checkpoint
and corporateBallot shapes landing in the next two commits — one
cohesive shape-registration file for the domain rather than an
artificial split.
New Checkpoint and CheckpointSchedule entities. Handles
CheckpointCreated, ScheduleCreated and ScheduleRemoved.

ScheduleCreated/ScheduleRemoved are the one real version change in
this domain: arity 3->4 at v6.0.0 (ScheduleId inserted at index 2,
payload StoredSchedule -> ScheduleCheckpoints). Registered as a
two-entry shape in src/decode/shapes/corporateActions.ts; parseSchedule
decodes either era, with a regression test asserting both arities
decode correctly and a mismatched arity throws.

MaximumSchedulesComplexityChanged stays unregistered (chain config, no
entity).
New CorporateBallot and CorporateBallotVote entities. Handles Created,
MetaChanged, RangeChanged, RCVChanged, Removed and VoteCast — all six
events are shape-identical across every checked spec version
(docs/reference/event-shape-verification.md), so no version branching
here.
New Subsidy entity. The relayer pallet was renamed paying-key ->
subsidy at a clean v8.0.0 boundary (verified against
pallets/relayer/src/lib.rs at v6.3.0/v7.0.0/v7.4.0/v8.0.0); pre-v8
events carry a leading EventDid that v8 events drop. Both eras are
handled — pre-v8 is the bulk of relayer history on mainnet — via
AuthorizedPayingKey/AcceptedPayingKey/RemovedPayingKey/UpdatedPolyxLimit
and ApprovedSubsidy/AcceptedSubsidy/RemovedSubsidy/RemovedPendingSubsidy/
SubsidyDebited/UpdatedPolyxLimit, each pair sharing one handler.
UpdatedPolyxLimit is the one arity change (5 args pre-v8, 4 at v8+),
covered by a decode regression test.
TickerExternalAgent -> AssetAgent (caller -> identity), and
TickerExternalAgentHistory -> AssetAgentHistory (type: String! ->
AgentHistoryType! enum) — these two duplicated their write path (both
fire on the same externalagents events), which is what actually
merges. TickerExternalAgentAction -> AssetAgentAction is a straight
rename with an unchanged shape: it is driven from a 20-pallet lookup
table on every chain event, not from externalagents, and answers a
different question (what an agent did, not who is an agent), so it
stays its own entity — caller stays caller there, deliberately
asymmetric with AssetAgent.identity.

AgentGroup gains the asset relation it was missing entirely (defect
G). AgentGroupMembership.member is now an Identity relation instead of
a bare String.

permissions stays a JSON-in-a-string String, not the PermissionsJson
jsonField this was originally going to become: that type's shape was
built for the secondary-key permission model, and an AgentGroup's
ExtrinsicPermissions is a different, richer on-chain structure that
would need a lossy flattening to fit it. See the schema docstrings.

AssetAgent.group/.permissions are populated by AgentAdded but not kept
current by GroupChanged yet (only AssetAgentHistory is) — noted in
mapExternalAgent.ts.

BREAKING CHANGE: TickerExternalAgent renamed to AssetAgent (caller
field renamed to identity); TickerExternalAgentHistory renamed to
AssetAgentHistory (type field is now the AgentHistoryType enum, was
String); TickerExternalAgentAction renamed to AssetAgentAction (no
field changes); AgentGroupMembership.member is now an Identity
relation instead of a String. SDK queries tickerExternalAgents,
tickerExternalAgentHistories and tickerExternalAgentActions all need
updating to the renamed root fields, and callerId filters/selections
on the first need to become identityId. Needs a coordinated SDK/portal
release — see the PR description.
TransferManager was documented deprecated in its own schema comment in
favour of TransferCompliance, yet both were written unconditionally
for the same pre-v5 statistics events. Neither is queried by either
consumer (the SDK reads transfer restrictions from chain) — confirmed
against docs/reference/consumer-queries.md.

Removes the TransferManager entity, its Asset.transferManagers derived
field, and mapTransferManager.ts. TransferRestrictionTypeEnum is kept:
handleStatisticTransferManagerAdded and the surviving exemption
handlers in mapStatistics.ts still use it for the pre-v5
percentage/count StatType and TransferComplianceExemption model.

BREAKING CHANGE: the TransferManager entity and the transferManagers
query (including Asset.transferManagers) are removed. Confirmed
unused by both the SDK and the portal, so no consumer query changes,
but it is a schema removal.
@prashantasdeveloper
prashantasdeveloper requested a review from a team as a code owner September 14, 2026 10:49
…d assetAgentActions.test.ts

registerShape('relayer', X, [{ from: V8, fields: [...] }]) repeated the same
two field lists across five event names; adds introducedAt/registerShapes to
registry.ts (siblings of stable/discontinuedAt) and uses them instead.
@sonarqubecloud

Copy link
Copy Markdown

@prashantasdeveloper
prashantasdeveloper added this pull request to stack #358 September 15, 2026 08:23
@prashantasdeveloper
prashantasdeveloper marked this pull request as draft September 15, 2026 10:29
@prashantasdeveloper
prashantasdeveloper marked this pull request as ready for review September 21, 2026 12:32
Comment thread schema.graphql
"""
type TickerExternalAgent @entity {
id: ID! # assetId/callerId
type AssetAgent @entity @compositeIndexes(fields: [["asset", "identity"]]) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AssetAgent is indexed on both sides, but neither Asset nor Identity can traverse to it — so "who are this asset's agents" and "which assets is this identity an agent of" both need a separate top-level assetAgents(filter: …) query rather than composing into the entity selection.

@derivedFrom is virtual: no column, no storage, no write-path cost, and it resolves through the FK indexes already declared right here, so neither entity's 10-index budget is touched. Asset already derives seven relations of exactly this shape (documents, holders, holdings, nfts, compliance, trustedClaimIssuers, mandatoryMediators), which makes agents the one central relation that can't be reached.

Suggest adding both directions:

# Asset
"The identities currently acting as agents for this asset. Past memberships are `AssetAgentHistory`"
agents: [AssetAgent!]! @derivedFrom(field: "asset")

# Identity
"The assets this identity is currently an agent for. Past memberships are `AssetAgentHistory`"
agentOf: [AssetAgent!]! @derivedFrom(field: "identity")

The docstrings are worth including: AssetAgent holds current membership only, so without them a consumer reasonably reads asset { agents } as complete history — the inverse of what Account.keyAssignments states explicitly ("current and historical").

Comment thread schema.graphql
A corporate action on an Asset — a dividend, benefit, or issuer notice. Defines the record date,
target holders and withholding tax that a linked `Distribution` or `CorporateBallot` is subject to
"""
type CorporateAction @entity @compositeIndexes(fields: [["asset", "kind"]]) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CorporateAction, Distribution and CorporateBallot all key on assetId/localId — on chain they're the same CAId, since a distribution and a ballot are each a CA plus extra data. But only CorporateBallot carries corporateAction: CorporateAction!. Distribution has no equivalent, and CorporateAction has no link back to either.

So you can reach a ballot's CA, but not a distribution's, and you can't go from a CA to the benefit it declares — which is the question most consumers of this data will ask first.

Since the ids are already identical, both directions are close to free:

# Distribution
corporateAction: CorporateAction!

# CorporateAction
distribution: Distribution @derivedFrom(field: "corporateAction")
ballot: CorporateBallot @derivedFrom(field: "corporateAction")

(Distribution itself is outside this PR's diff, hence the comment here.)

Comment thread schema.graphql
"""
One identity's vote in a `CorporateBallot`, from `corporateBallot.VoteCast`
"""
type CorporateBallotVote @entity {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an append-only log of a value the chain treats as replaceable. handleBallotVoteCast creates a row per VoteCast keyed on blockEventId, but on chain Votes is (CAId, IdentityId) -> Vec<BallotVote> and voting again while the ballot is open replaces the previous vote.

So an identity that changes its mind gets two rows, ballot.votes returns both, and nothing marks which is current — a consumer tallying results double-counts them.

This PR already introduces AssetAgent / AssetAgentHistory for exactly this current-vs-historical split, and ballot votes have the same shape. Either apply that pattern, or key the row on ballotId/voterId so it upserts and keep the log separately. At minimum the docstring should say the list is append-only and that the current vote is the most recent row per voter.

Comment thread schema.graphql
"null = applies to every holder, subject to `targetTreatment`"
targetIdentities: [String!]
targetTreatment: TargetTreatment
defaultWithholdingTax: BigInt

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

defaultWithholdingTax is a Permill on chain — parts per million — and it's stored here as a bare BigInt with no docstring. Same for DidTax.tax and the matching field on CorporateActionDefaultConfig.

A consumer reading 10000 has no way to know that means 1%. Please add the unit to all three docstrings; it's the kind of thing that only surfaces once someone has rendered a wrong percentage to an investor.

Comment thread schema.graphql
"current POLYX limit (remaining)"
allowance: BigInt!
"cumulative `SubsidyDebited` (v8+ only; 0 for pre-v8 history)"
totalDebited: BigInt!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring says "v8+ only; 0 for pre-v8 history", which makes a genuine zero and an unindexable past the same stored value — a consumer can't tell "nothing was ever debited" from "we couldn't know".

Making it nullable, with null for the pre-v8 range, keeps the distinction and matches the "unknown is recorded, never guessed" line the review docs take elsewhere.

Comment thread schema.graphql
totalSupply: BigInt!
datetime: Date!
"Set when created by a `CheckpointSchedule` rather than manually"
schedule: CheckpointSchedule

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommendation: wire this by index lookup, no chain read. It's the prerequisite for the pendingCheckpoints comment below.

It's never set today — the only Checkpoint.create (mapCheckpoint.ts:73) doesn't pass scheduleId, and nothing writes it afterwards. So schedule is null on every checkpoint in this release, and the handler comment treats linking as impractical ("scanning every schedule for this asset").

It's both cheaper and more exact than that. Confirmed against advance_schedules (pallets/asset/src/checkpoint/mod.rs:457-523):

  • A checkpoint belongs to at most one schedule. Each schedule gets its own CheckpointId from the sequence; the chain never merges across schedules. If schedules A and B both fall due at T, you get two CheckpointCreated events with the same timestamp and different ids, and SchedulePoints records each id under exactly one (asset, schedule_id). So the single-valued FK here is the right shape — no join entity needed.
  • The emission order is deterministic. Schedules are processed in ascending ScheduleId, timestamps within a schedule come out of a BTreeSet in ascending order, and checkpoint ids increase strictly across the batch.

So in handleCheckpointCreated:

  1. CheckpointCreated's first arg is Option<IdentityId> — None only when a schedule triggered it. Skip the lookup entirely for manual checkpoints.
  2. For the scheduled ones, take the asset's schedules from the index, and for each the declared moments that are due and don't already have a Checkpoint linked. Ordered by (scheduleId, moment), that sequence lines up one-to-one with the None-caller events in the extrinsic, so the assignment is exact rather than a timestamp guess.

Timestamp alone is ambiguous between two schedules due at the same moment — the ordering is what removes the ambiguity. If the pairing ever doesn't line up, record an IndexerAnomaly and leave schedule null rather than pick one.

Don't match on block time. Schedules only advance when the asset's balances change, so a checkpoint's moment can predate the block that created it by some margin — which is also why the block.timestamp fallback on datetime is worth removing (separate comment on mapCheckpoint.ts).

SchedulePoints: (AssetId, ScheduleId) -> Vec<CheckpointId> answers this directly if you ever want to verify a backfill, but it's a chain read per lookup so I wouldn't put it on the indexing path.

If none of this is wanted in this PR, drop the field until it is — a declared relation that's permanently null is worse than an absent one in a release consumers have to migrate to.

);
if (assetId) {
await TickerExternalAgentAction.create({
await AssetAgentAction.create({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commenting here because it's the only part of this file in the diff, but this is about the ExternalAgentEventsManager table below — and, ahead of it, EventIdEnum.

Both are behind the current runtime. Checked against testnet 8001020 (8.1.2), which is where we should be targeting — mainnet is still 8000020, so it doesn't show these.

Seven events across the whole runtime are missing from EventIdEnum:

Pallet Missing
asset FrozenBalanceSet, SetAccountFreeze, ControllerTransferTo
nft NFTApproval, NFTApprovalForAll, NFTApprovalSpent
balances Restored

The first two are the 8.1.x per-account asset freezing, and they're agent actions by definition. ControllerTransferTo matters too — its sibling ControllerTransfer is already in the agent table, so the pair is now split. Until they're in the enum they can't be registered in project.ts or added here at all; they'd decode to EventIdEnum.Unknown and raise UnknownEnumValue anomalies for every occurrence.

Then the agent table itself. Its docstring says it covers "statistics, sto, complianceManager, nft and the rest", but there are no nft entries at all:

  • The whole nft pallet. create_nft_collection, issue_nft and redeem_nft are the counterparts of asset.create/issue/redeem, and the asset ones are in the table — so an agent minting a fungible token is recorded and an agent minting an NFT isn't. (NFTHoldingsUpdated should stay out for the same reason asset.Transfer is excluded — it fires on every transfer.)
  • statistics.SetAssetTransferCompliance — the only event in that pallet not covered; the other nine are.
  • capitaldistribution.Reclaimed — reclaim is agent-only. Meanwhile BenefitClaimed is covered although it's emitted by both push_benefit (agent) and claim (any holder), so it's over-inclusive in exactly the way asset.Transfer was excluded for. The agent-only event is out and the mixed one is in.

Two that may not belong: asset.PreApprovedAsset / RemovePreApprovedAsset are receiver-side declarations rather than agent-permissioned calls, as is AssetAffirmationExemption. Worth confirming they call ensure_agent_permissioned, since the class docstring makes that the definition.

This needs to cover every spec version replayed from genesis, not just the newest. The index replays from block 1, so deprecated and renamed events count: IssuedNFT and RedeemedNFT were deprecated at 6.0, and NFTPortfolioUpdated became NFTHoldingsUpdated at 8.0. A table built only against current metadata loses the historical half.

Given that, a mechanised check would be worth more than another manual sweep — diffing both EventIdEnum and this table against each runtime's events, in the spirit of what CAPTURED_MODULES does for arity. The seven missing enum entries took one metadata query to find, which is the argument for automating it.


const corporateAction = await CorporateAction.get(caId(assetId, localId));

if (!corporateAction) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This swallows a "shouldn't happen" case. A CARemoved for a CA that was never indexed means either the CAInitiated was missed or the id derivation disagrees between the two handlers — both worth knowing about, and neither leaves a trace today.

Same pattern at :95 and :113 here, all four ballot handlers in mapCorporateBallot.ts (:61, :78, :98, :115), and handleScheduleRemoved in mapCheckpoint.ts:118. Eight silent returns in total.

mapSubsidy.ts in this same PR already does it right — getSubsidyOrAnomaly records AnomalyKind.MissingReferencedEntity with the id it looked for, then returns undefined. Worth lifting that shape into a shared helper and using it for all eight.

recordDate: ca.recordDate ? new Date(ca.recordDate.date) : undefined,
details: bytesToString(rawDetails),
targetIdentities: ca.targets.identities,
targetTreatment: toEnum(TargetTreatment, ca.targets.treatment, TargetTreatment.Exclude, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback here is Exclude, which is a meaningful value rather than a catch-all — and targetTreatment is nullable in the schema, so undefined is available.

Include and Exclude are opposites: one targets the listed identities, the other targets everyone but them. If a future runtime adds a variant, guessing Exclude silently inverts who a corporate action applies to. toEnum records an UnknownEnumValue anomaly, so it wouldn't be invisible, but the stored row would still read as a confident answer.

Contrast the CorporateActionKind call just above, which falls back to Other — a genuine catch-all member, so nothing is misrepresented. Suggest leaving targetTreatment undefined on an unrecognised value instead of defaulting to one of the two real ones.

Comment thread schema.graphql
declarationDate: Date!
"Set once `checkpoint.RecordDateChanged` resolves the record date to an existing checkpoint"
recordDate: Date
checkpoint: Checkpoint

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never set — nothing in mapCorporateAction.ts assigns checkpointId, on any path including handleRecordDateChanged.

That makes three always-null relations added by this PR: this one, AssetAgent.group / .permissions, and Checkpoint.schedule. Each has a sound reason individually, but together they're a pattern worth deciding on deliberately rather than per-field, because consumers have to migrate to this release and can't tell a null that means "not applicable" from one that means "not implemented yet".

The chain's RecordDate is { date, checkpoint: CACheckpoint } where CACheckpoint is Scheduled(ScheduleId, u64) or Existing(CheckpointId). The Existing case carries the checkpoint id directly, so at least that half is resolvable from the event already being decoded here — the Scheduled case is the one that needs the checkpoint to be created first.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants