feat: index corporate actions, checkpoints, ballots and relayer subsidies; merge external-agent entities and retire TransferManager - #356
Conversation
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.
…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.
|
| """ | ||
| type TickerExternalAgent @entity { | ||
| id: ID! # assetId/callerId | ||
| type AssetAgent @entity @compositeIndexes(fields: [["asset", "identity"]]) { |
There was a problem hiding this comment.
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").
| 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"]]) { |
There was a problem hiding this comment.
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.)
| """ | ||
| One identity's vote in a `CorporateBallot`, from `corporateBallot.VoteCast` | ||
| """ | ||
| type CorporateBallotVote @entity { |
There was a problem hiding this comment.
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.
| "null = applies to every holder, subject to `targetTreatment`" | ||
| targetIdentities: [String!] | ||
| targetTreatment: TargetTreatment | ||
| defaultWithholdingTax: BigInt |
There was a problem hiding this comment.
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.
| "current POLYX limit (remaining)" | ||
| allowance: BigInt! | ||
| "cumulative `SubsidyDebited` (v8+ only; 0 for pre-v8 history)" | ||
| totalDebited: BigInt! |
There was a problem hiding this comment.
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.
| totalSupply: BigInt! | ||
| datetime: Date! | ||
| "Set when created by a `CheckpointSchedule` rather than manually" | ||
| schedule: CheckpointSchedule |
There was a problem hiding this comment.
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
CheckpointIdfrom the sequence; the chain never merges across schedules. If schedules A and B both fall due atT, you get twoCheckpointCreatedevents with the same timestamp and different ids, andSchedulePointsrecords 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 aBTreeSetin ascending order, and checkpoint ids increase strictly across the batch.
So in handleCheckpointCreated:
CheckpointCreated's first arg isOption<IdentityId>—Noneonly when a schedule triggered it. Skip the lookup entirely for manual checkpoints.- 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
Checkpointlinked. Ordered by(scheduleId, moment), that sequence lines up one-to-one with theNone-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({ |
There was a problem hiding this comment.
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
nftpallet.create_nft_collection,issue_nftandredeem_nftare the counterparts ofasset.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. (NFTHoldingsUpdatedshould stay out for the same reasonasset.Transferis excluded — it fires on every transfer.) statistics.SetAssetTransferCompliance— the only event in that pallet not covered; the other nine are.capitaldistribution.Reclaimed—reclaimis agent-only. MeanwhileBenefitClaimedis covered although it's emitted by bothpush_benefit(agent) andclaim(any holder), so it's over-inclusive in exactly the wayasset.Transferwas 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) { |
There was a problem hiding this comment.
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, { |
There was a problem hiding this comment.
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.
| declarationDate: Date! | ||
| "Set once `checkpoint.RecordDateChanged` resolves the record date to an existing checkpoint" | ||
| recordDate: Date | ||
| checkpoint: Checkpoint |
There was a problem hiding this comment.
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.



What's in each commit
feat: index corporate actions—CorporateAction+CorporateActionDefaultConfigentities.
CAInitiated,CARemoved,RecordDateChanged,CALinkedToDoc,DefaultTargetIdentitiesChanged,DefaultWithholdingTaxChanged,DidWithholdingTaxChanged.feat: index checkpoints and schedules—Checkpoint+CheckpointScheduleentities.CheckpointCreated,ScheduleCreated,ScheduleRemoved. The one real version change in thiswhole domain:
ScheduleCreated/ScheduleRemovedwent 3→4 args at chain v6.0.0 (ScheduleIdinserted, payload type changed) — handled as a two-entry decode shape, with a regression test
asserting both arities decode and a mismatch throws.
feat: index corporate ballots—CorporateBallot+CorporateBallotVoteentities.Created,MetaChanged,RangeChanged,RCVChanged,Removed,VoteCast— shape-identicalacross every checked spec version, no version work.
feat: index the Subsidy/Relayer pallet— newSubsidyentity. Therelayerpallet hadzero 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.
feat!: merge the TickerExternalAgent entities(breaking) —TickerExternalAgent→AssetAgent,TickerExternalAgentHistory→AssetAgentHistory(these two duplicated theirwrite 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).
AgentGroupgains theassetrelation it was missingentirely;
AgentGroupMembership.memberis now anIdentityrelation instead of a bare string.feat!: retire the TransferManager dual model(breaking) — removes theTransferManagerentity, its
Asset.transferManagersderived field, and its writer. It was documented deprecatedin 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:
tickerExternalAgentsassetAgentscallerIdfilters/selections →identityIdtickerExternalAgentHistoriesassetAgentHistoriestypeis now theAgentHistoryTypeenum (was a plain string)tickerExternalAgentActionsassetAgentActionspermissionsonAssetAgent/AssetAgentHistorystays a JSON-encoded string, not a typedjsonField — see the docstring on
AssetAgentinschema.graphqlfor why (the existingPermissionsJsonshape was built for a different on-chain permission model and doesn't fit anAgentGroup'sExtrinsicPermissionswithout a lossy flattening).Commit 6: no consumer query changes. Confirmed neither the SDK nor the portal queries
transferManagerstoday. It's a schema removal, hence theBREAKING CHANGEfooter, but nothingdownstream needs to change.
Verification
yarn codegen && yarn typecheck && yarn lint && yarn test:unitall pass (571 unit tests, 53suites), plus
yarn build(validates everyproject.tshandler name resolves) and the10-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.