diff --git a/docs/implementation/08-external-agents.md b/docs/implementation/08-external-agents.md index c3a4d14d..b25e8e56 100644 --- a/docs/implementation/08-external-agents.md +++ b/docs/implementation/08-external-agents.md @@ -1,8 +1,13 @@ # 08 — External agents, compliance, and remaining cleanups -Consolidates the three `TickerExternalAgent*` entities, resolves the dual transfer-restriction model, and covers the small entities not addressed elsewhere. +Renames the three `TickerExternalAgent*` entities to `Asset`-prefixed names, resolves the dual transfer-restriction model, and covers the small entities not addressed elsewhere. -**Entities:** `AssetAgent`/`AssetAgentHistory` (replacing three), `AgentGroup` (relation fix), `TransferManager` (removed), plus minor fixes. +**Entities:** `AssetAgent` (renamed from `TickerExternalAgent`) and `AssetAgentHistory` (renamed +from `TickerExternalAgentHistory`) — what actually *merges* is the two handler modules that write +them, both firing on the same `externalagents` events. `AssetAgentAction` (renamed from +`TickerExternalAgentAction`) is a straight rename with an unchanged shape — it answers "what did +this agent do", a different question from membership, so it stays a separate entity. Plus +`AgentGroup` (relation fix), `TransferManager` (removed), plus minor fixes. --- @@ -10,38 +15,45 @@ Consolidates the three `TickerExternalAgent*` entities, resolves the dual transf ### Problem -- Three entities for one concept: `TickerExternalAgent` (current), `TickerExternalAgentAction` (action log), `TickerExternalAgentHistory` (membership history). +- Three entities, stale `Ticker`-prefixed naming post-7.x, and two of the three duplicate their + write path: `TickerExternalAgent` (current membership) and `TickerExternalAgentHistory` + (membership history) both write from handlers that fire on the same `externalagents` events + (`AgentAdded`/`AgentRemoved`/`GroupChanged`) — that duplication is what's worth consolidating. + `TickerExternalAgentAction` (action log) is a different concern entirely: it is driven from + *every* chain event via a 20-pallet lookup table, not from `externalagents`, so it is renamed but + kept as its own entity. - **G — `AgentGroup` has no `asset` relation.** Its id is `assetId/group_id`, but there is no `asset` field, so "all groups for asset X" requires parsing the id string. -- `TickerExternalAgentHistory.type: String!` is **untyped** where an enum belongs; `permissions: String` is **JSON-in-a-string** where the existing `PermissionsJson` jsonField belongs. +- `TickerExternalAgentHistory.type: String!` is **untyped** where an enum belongs. - `AgentGroupMembership.member: String!` is not an `Identity` relation. -- `Ticker`-prefixed naming is stale post-7.x. -### Target schema +### Target schema (as implemented) + +Field provenance is `createdEvent`/`updatedEvent` (Event-grain, per the D13 rework on +`redesign/07-schema-invariants`) rather than the `createdBlock`/`updatedBlock` this plan originally +sketched — every other entity in the schema made that same move, so these follow it too. `eventIdx` +/ `datetime` are not stored as separate copies; they are reachable through `createdEvent`. ```graphql "Current agent membership for an asset." -type AssetAgent @entity @compositeIndexes(fields: [["assetId", "identityId"]]) { +type AssetAgent @entity @compositeIndexes(fields: [["asset", "identity"]]) { id: ID! # assetId/did asset: Asset! @index - identity: Identity! @index + identity: Identity! @index # was `caller` on TickerExternalAgent group: AgentGroup - permissions: PermissionsJson - createdBlock: Block! - updatedBlock: Block! + permissions: String # kept String — see note below createdEvent: Event! + updatedEvent: Event! } "Append-only membership and permission history." type AssetAgentHistory @entity { - id: ID! # padId(block)/padId(eventIdx)/did — D4 + id: ID! # blockId/eventIdx/did asset: Asset! @index identity: Identity! @index type: AgentHistoryType! # was an untyped String - permissions: PermissionsJson # was JSON-in-a-string - eventIdx: Int! - datetime: Date! - createdBlock: Block! + permissions: String # kept String — see note below createdEvent: Event! + updatedEvent: Event! } enum AgentHistoryType { AgentAdded, AgentRemoved, AgentPermissionsChanged, GroupChanged } @@ -49,39 +61,57 @@ enum AgentHistoryType { AgentAdded, AgentRemoved, AgentPermissionsChanged, Group type AgentGroup @entity { id: ID! # assetId/groupId asset: Asset! @index # ← was missing entirely - groupId: Int! - permissions: PermissionsJson # was String + permissions: String # kept String — see note below members: [AgentGroupMembership!]! @derivedFrom(field: "group") - createdBlock: Block! - updatedBlock: Block! + createdEvent: Event! + updatedEvent: Event! } type AgentGroupMembership @entity { id: ID! # assetId/groupId/did member: Identity! @index # was String group: AgentGroup! @index - createdBlock: Block! - updatedBlock: Block! + createdEvent: Event! + updatedEvent: Event! } ``` -`TickerExternalAgentAction` is **kept** but renamed `AssetAgentAction` — it records *what an agent did*, which is a different question from membership, and the SDK queries it **[V]**. +**`permissions` stays `String` (JSON-in-a-string), not the `PermissionsJson` jsonField this plan +originally proposed.** `PermissionsJson`'s shape (`assets`/`portfolios`/`transactions`/ +`transactionGroups`, each `{type, values}`) was built for the secondary-key permission model. An +`AgentGroup`'s on-chain permission set is `ExtrinsicPermissions` (`Whole | These +| Except`, nesting per-pallet dispatchable names) — a different, richer +structure that would need a lossy flattening to fit `PermissionsJson`. Typing it correctly (a +dedicated jsonField shaped for `ExtrinsicPermissions`, or a flattening scheme) is a separate +modeling exercise, deferred rather than forced into the wrong shape here. + +`AssetAgent.group` / `.permissions` are populated by `AgentAdded` but not kept current by +`GroupChanged` (only `AssetAgentHistory` is updated there) — see the code comment in +`mapExternalAgent.ts::handleExternalAgentAdded`. Wiring both paths together is a follow-up. + +`TickerExternalAgentAction` is **kept** but renamed `AssetAgentAction`, shape unchanged — it +records *what an agent did*, which is a different question from membership, and the SDK queries it +**[V]**. `caller` stays `caller` there (not renamed to `identity`) — deliberately asymmetric, see +the entity's schema docstring. ### Handler changes `src/mappings/entities/externalAgents/` — `mapExternalAgent.ts`, `mapExternalAgentAction.ts`, `mapExternalAgentHistory.ts`. Entity targets and field types change; the event handling is already correct. All asset lookups already route through `getAssetId` **[V]**. -`mapExternalAgentAction.ts` has partial `is7Dot3Chain` coverage for the `sto` module's asset-id position **[V]** — move that into the legacy decoder table ([09](./09-infrastructure.md)) rather than leaving it inline. +`mapExternalAgentAction.ts` has partial `is7Dot3Chain` coverage for the `sto` module's asset-id +position **[V]** — plan 09 moves that into the legacy decoder table; left inline here, unrelated to +this commit's scope. ### Consumer impact — breaking | Consumer | Query | Change | |---|---|---| -| SDK | `tickerExternalAgents` | Rename → `assetAgents`. | +| SDK | `tickerExternalAgents` | Rename → `assetAgents`; `callerId` filters/selections → `identityId`. | | SDK | `tickerExternalAgentActions` | Rename → `assetAgentActions`. | -| SDK | `tickerExternalAgentHistories` | Rename → `assetAgentHistories`; `type` becomes an enum, `permissions` becomes a jsonField. | +| SDK | `tickerExternalAgentHistories` | Rename → `assetAgentHistories`; `type` becomes an enum (`permissions` stays a String — see the modeling note above). | -Three renames plus two type changes. Mechanical, but the SDK queries all three **[V]** so it needs a coordinated release. +Three renames plus one type change (`type` → enum) plus the `caller`→`identity` field rename. +Mechanical, but the SDK queries all three **[V]** so it needs a coordinated release. **[I]** If the rename churn is judged not worth it, keeping the `TickerExternalAgent*` names while fixing the `AgentGroup.asset` relation and the untyped fields captures most of the value. Worth asking the SDK team which they prefer. diff --git a/docs/implementation/README.md b/docs/implementation/README.md index 7333ad92..a97405b1 100644 --- a/docs/implementation/README.md +++ b/docs/implementation/README.md @@ -18,7 +18,7 @@ Event shapes for every domain below are verified in [`../reference/event-shape-v | [05](./05-movement-ledger.md) | Movement ledger | `PortfolioMovement` → `AssetTransaction` | 03 | | [06](./06-corporate-actions.md) | Corporate actions | `CorporateAction`, `Checkpoint`, `CorporateBallot` | — *(low priority, D6)* | | [07](./07-staking.md) | Staking | `StakingPosition`, `Nomination`, era tracking | 02 | -| [08](./08-external-agents.md) | External agents | Merge the three `TickerExternalAgent*` entities | — | +| [08](./08-external-agents.md) | External agents | Rename `TickerExternalAgent*` → `AssetAgent*`, merging the membership + history write path | — | | [09](./09-infrastructure.md) | Infrastructure | `IndexerAnomaly`, `ChainUpgrade`, decode layer, index consolidation | — | | [10](./10-partial-index.md) | Partial index | `IndexOrigin`, chain-state seeding at an arbitrary start block | 09, and reuses seeders from 02/03 | | [11](./11-throughput.md) | Throughput | Slow blocks, chain-read minimisation, non-total internal paging | 09 | diff --git a/project.ts b/project.ts index 19c9b418..efb113f7 100644 --- a/project.ts +++ b/project.ts @@ -117,10 +117,10 @@ const filters: Record> = { Removed: ['handleDistributionRemoved'], }, checkpoint: { - CheckpointCreated: [], - MaximumSchedulesComplexityChanged: [], - ScheduleCreated: [], - ScheduleRemoved: [], + CheckpointCreated: ['handleCheckpointCreated'], + MaximumSchedulesComplexityChanged: [], // chain config, no entity + ScheduleCreated: ['handleScheduleCreated'], + ScheduleRemoved: ['handleScheduleRemoved'], }, complianceManager: { AssetCompliancePaused: ['handleAssetCompliancePaused'], @@ -137,22 +137,24 @@ const filters: Record> = { // never deployed to a production chain. Events are only indexed into the generic events table confidentialAsset: {}, corporateAction: { - CAInitiated: [], - CALinkedToDoc: [], - CARemoved: [], - DefaultTargetIdentitiesChanged: [], - DefaultWithholdingTaxChanged: [], - DidWithholdingTaxChanged: [], - MaxDetailsLengthChanged: [], - RecordDateChanged: [], + CAInitiated: ['handleCaInitiated'], + CALinkedToDoc: ['handleCaLinkedToDoc'], + CARemoved: ['handleCaRemoved'], + DefaultTargetIdentitiesChanged: ['handleDefaultTargetIdentitiesChanged'], + DefaultWithholdingTaxChanged: ['handleDefaultWithholdingTaxChanged'], + DidWithholdingTaxChanged: ['handleDidWithholdingTaxChanged'], + MaxDetailsLengthChanged: [], // chain config, no entity + RecordDateChanged: ['handleRecordDateChanged'], + // pre-6.0 CAA transfers — superseded by external agents, not indexed + // CAATransferred: [], }, corporateBallot: { - Created: [], - MetaChanged: [], - RangeChanged: [], - RCVChanged: [], - Removed: [], - VoteCast: [], + Created: ['handleBallotCreated'], + MetaChanged: ['handleBallotMetaChanged'], + RangeChanged: ['handleBallotRangeChanged'], + RCVChanged: ['handleBallotRcvChanged'], + Removed: ['handleBallotRemoved'], + VoteCast: ['handleBallotVoteCast'], }, externalAgents: { AgentAdded: ['handleExternalAgentAdded', 'handleAgentAdded'], @@ -255,6 +257,18 @@ const filters: Record> = { protocolFee: { FeeCharged: ['handleTransactionFeeCharged'], }, + relayer: { + // deprecated from 8.0.0 chain version — superseded by the Subsidy events below + AuthorizedPayingKey: ['handleSubsidyApproved'], + AcceptedPayingKey: ['handleSubsidyAccepted'], + RemovedPayingKey: ['handleSubsidyRemoved'], + UpdatedPolyxLimit: ['handlePolyxLimitUpdated'], + ApprovedSubsidy: ['handleSubsidyApproved'], + AcceptedSubsidy: ['handleSubsidyAccepted'], + RemovedPendingSubsidy: ['handleSubsidyRemoved'], + RemovedSubsidy: ['handleSubsidyRemoved'], + SubsidyDebited: ['handleSubsidyDebited'], + }, settlement: { AffirmationWithdrawn: ['handleAffirmationWithdrawn'], FailedToExecuteInstruction: ['handleFailedToExecuteInstruction'], @@ -326,10 +340,12 @@ const filters: Record> = { SetAssetTransferCompliance: ['handleSetTransferCompliance'], StatTypesAdded: ['handleStatTypeAdded'], StatTypesRemoved: ['handleStatTypeRemoved'], - TransferManagerAdded: ['handleTransferManagerAdded', 'handleStatisticTransferManagerAdded'], - TransferManagerRemoved: ['handleTransferManagerRemoved'], - ExemptionsAdded: ['handleExemptionsAdded', 'handleTransferManagerExemptionsAdded'], - ExemptionsRemoved: ['handleExemptionsRemoved', 'handleTransferManagerExemptionsRemoved'], + // TransferManager (deprecated, retired) is gone; these still feed StatType / + // TransferComplianceExemption for the pre-v5 percentage/count restriction model + TransferManagerAdded: ['handleStatisticTransferManagerAdded'], + TransferManagerRemoved: [], + ExemptionsAdded: ['handleTransferManagerExemptionsAdded'], + ExemptionsRemoved: ['handleTransferManagerExemptionsRemoved'], TransferConditionExemptionsAdded: ['handleStatisticExemptionsAdded'], TransferConditionExemptionsRemoved: ['handleStatisticExemptionsRemoved'], }, diff --git a/schema.graphql b/schema.graphql index eb3c6e60..a0589ef1 100644 --- a/schema.graphql +++ b/schema.graphql @@ -2179,8 +2179,12 @@ type StakingEvent @entity { """ Represents an administrative action on an Asset, e.g. tokens issued, compliance rules updated, document added, etc. + +`caller` (who invoked the action), not `identity` — deliberately asymmetric with `AssetAgent.identity` +(who is an agent). This entity answers "what did this agent do", a different question from +membership, which `AssetAgent` / `AssetAgentHistory` answer """ -type TickerExternalAgentAction @entity { +type AssetAgentAction @entity { id: ID! asset: Asset! @index(unique: false) caller: Identity @index(unique: false) @@ -2313,26 +2317,46 @@ type Investment @entity { } """ -Represents an Identity authorized to perform actions on behalf of a company +Current agent membership for an Asset — an Identity authorized to perform actions on its behalf + +`permissions` stays JSON-in-a-string rather than the `PermissionsJson` jsonField: that type's +shape (`assets`/`portfolios`/`transactions`/`transactionGroups`, each a flat `{type, values}`) was +built for the secondary-key permission model. An `AgentGroup`'s `ExtrinsicPermissions` is a +different, richer on-chain structure (`Whole | These | Except`, +nesting per-pallet dispatchable names) that does not fit it without a lossy flattening — typing it +correctly is a separate modeling exercise, deferred rather than forced into the wrong shape here """ -type TickerExternalAgent @entity { - id: ID! # assetId/callerId +type AssetAgent @entity @compositeIndexes(fields: [["asset", "identity"]]) { + id: ID! # assetId/did asset: Asset! @index(unique: false) - caller: Identity! @index(unique: false) + identity: Identity! @index(unique: false) + group: AgentGroup + permissions: String updatedEvent: Event! createdEvent: Event! } """ -Represents a change in the status of an external agent. +Represents all possible kinds of `AssetAgentHistory` row + +"AgentAdded" and "AgentPermissionsChanged" carry `permissions`; `AgentRemoved` and `GroupChanged` may not +""" +enum AgentHistoryType { + AgentAdded + AgentRemoved + AgentPermissionsChanged + GroupChanged +} -"AgentAdded" and "AgentPermissionsChanged" will have `permissions` field. `AgentRemoved` will have an empty `permissions` value """ -type TickerExternalAgentHistory @entity { +Append-only membership and permission history for an Asset's agents +""" +type AssetAgentHistory @entity { id: ID! #block_id/event_idx/did asset: Asset! @index(unique: false) identity: Identity! @index(unique: false) - type: String! + type: AgentHistoryType! + "JSON-in-a-string, not the PermissionsJson jsonField — see the docstring on AssetAgent.permissions" permissions: String updatedEvent: Event! createdEvent: Event! @@ -2343,6 +2367,7 @@ Represents a set of agents authorized to perform actions for a given Asset """ type AgentGroup @entity { id: ID! #assetId/group_id + asset: Asset! @index permissions: String! members: [AgentGroupMembership!]! @derivedFrom(field: "group") createdEvent: Event! @@ -2354,7 +2379,7 @@ Represent membership in a group. Tracks which members belong to which groups """ type AgentGroupMembership @entity { id: ID! #assetId/group_id/member - member: String! @index(unique: false) + member: Identity! @index(unique: false) group: AgentGroup! @index(unique: false) createdEvent: Event! updatedEvent: Event! @@ -2641,6 +2666,170 @@ type DistributionPayment @entity { createdEvent: Event! } +""" +The kind of benefit or notice a `CorporateAction` represents +""" +enum CorporateActionKind { + PredictableBenefit + UnpredictableBenefit + IssuerNotice + Reorganization + Other +} + +""" +Whether `CorporateAction.targetIdentities` names the holders it applies to, or the holders it excludes +""" +enum TargetTreatment { + Include + Exclude +} + +""" +A per-DID withholding tax override, as set by `corporateAction.DidWithholdingTaxChanged` +""" +type DidTax @jsonField { + did: String! + tax: BigInt! +} + +""" +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"]]) { + id: ID! # assetId/localId + asset: Asset! @index + localId: Int! + kind: CorporateActionKind! + declarationDate: Date! + "Set once `checkpoint.RecordDateChanged` resolves the record date to an existing checkpoint" + recordDate: Date + checkpoint: Checkpoint + details: String + "null = applies to every holder, subject to `targetTreatment`" + targetIdentities: [String!] + targetTreatment: TargetTreatment + defaultWithholdingTax: BigInt + didWithholdingTax: [DidTax] + documents: [String!] + isRemoved: Boolean! + createdEvent: Event! + updatedEvent: Event! +} + +""" +Asset-level corporate action defaults — `corporateAction.DefaultTargetIdentitiesChanged`, +`DefaultWithholdingTaxChanged` and `DidWithholdingTaxChanged` set these ahead of any specific CA +""" +type CorporateActionDefaultConfig @entity { + id: ID! # assetId + asset: Asset! @index + targetIdentities: [String!] + targetTreatment: TargetTreatment + defaultWithholdingTax: BigInt + didWithholdingTax: [DidTax] + createdEvent: Event! + updatedEvent: Event! +} + +""" +A balance-at-record-date snapshot for an Asset, from `checkpoint.CheckpointCreated`. Makes a +linked `CorporateAction`'s distribution amounts verifiable +""" +type Checkpoint @entity @compositeIndexes(fields: [["asset", "checkpointId"]]) { + id: ID! # assetId/checkpointId + asset: Asset! @index + checkpointId: Int! + totalSupply: BigInt! + datetime: Date! + "Set when created by a `CheckpointSchedule` rather than manually" + schedule: CheckpointSchedule + createdEvent: Event! +} + +""" +A recurring checkpoint schedule for an Asset, from `checkpoint.ScheduleCreated`. The pre-v6 +period-based model (`period`/`start`/`remaining`) and the v6+ explicit-timestamp model +(`pendingCheckpoints`) are mutually exclusive on one row, era implicit in which is null +""" +type CheckpointSchedule @entity { + id: ID! # assetId/scheduleId + asset: Asset! @index + scheduleId: Int! + "v6+ — explicit list of scheduled checkpoint timestamps (`ScheduleCheckpoints.pending`)" + pendingCheckpoints: [Date!] + "pre-v6 only — the period-based model (`CheckpointSchedule`). Null from v6 onward" + period: String + start: Date + remaining: Int + nextCheckpointAt: Date + "null while active" + removedEvent: Event + createdEvent: Event! +} + +""" +A shareholder ballot on a `CorporateAction`, from `corporateBallot.Created` +""" +type CorporateBallot @entity { + id: ID! # assetId/localId + asset: Asset! @index + corporateAction: CorporateAction! + startDate: Date! + endDate: Date! + "title, motions" + meta: String + "ranked-choice voting enabled" + rcv: Boolean! + isRemoved: Boolean! + votes: [CorporateBallotVote!]! @derivedFrom(field: "ballot") + createdEvent: Event! + updatedEvent: Event! +} + +""" +Per-motion vote weights cast in a `CorporateBallot` +""" +type BallotVoteEntry @jsonField { + power: BigInt! + fallback: Int +} + +""" +One identity's vote in a `CorporateBallot`, from `corporateBallot.VoteCast` +""" +type CorporateBallotVote @entity { + id: ID! # padId(block)/padId(eventIdx) + ballot: CorporateBallot! @index + voter: Identity! @index + "per-motion weights, in motion order" + votes: [BallotVoteEntry] + createdEvent: Event! +} + +""" +A standing fee-paying relationship from the `relayer` pallet: `payingAccount` pays transaction and +protocol fees on `beneficiaryAccount`'s behalf, up to `allowance`. Renamed from "paying key" to +"subsidy" at chain v8.0.0 — this entity covers both eras (id `userKey/payingKey`) +""" +type Subsidy @entity { + id: ID! # userKey/payingKey + "the subsidised key" + beneficiaryAccount: Account! @index + "pays the fees" + payingAccount: Account! @index + "current POLYX limit (remaining)" + allowance: BigInt! + "cumulative `SubsidyDebited` (v8+ only; 0 for pre-v8 history)" + totalDebited: BigInt! + "false = approved/authorized but not yet accepted" + isAccepted: Boolean! + isRemoved: Boolean! + createdEvent: Event! + updatedEvent: Event! +} + """ Represents a vote on a governance proposal """ @@ -2794,19 +2983,6 @@ enum TransferRestrictionTypeEnum { Percentage } -""" -deprecated in favor of `TransferCompliance` -""" -type TransferManager @entity { - id: ID! # assetId/transferManagerType/value - asset: Asset! @index(unique: false) - type: TransferRestrictionTypeEnum! - value: Int! - exemptedEntities: [String]! - createdEvent: Event! - updatedEvent: Event! -} - """ Represents all known stat types """ @@ -3033,7 +3209,6 @@ type Asset @entity { totalTransfers: BigInt! isCompliancePaused: Boolean! compliance: [Compliance]! @derivedFrom(field: "asset") - transferManagers: [TransferManager]! @derivedFrom(field: "asset") trustedClaimIssuers: [TrustedClaimIssuer]! @derivedFrom(field: "asset") mandatoryMediators: [AssetMandatoryMediator]! @derivedFrom(field: "asset") updatedEvent: Event! diff --git a/src/decode/shapes/corporateActions.ts b/src/decode/shapes/corporateActions.ts new file mode 100644 index 00000000..88a2e567 --- /dev/null +++ b/src/decode/shapes/corporateActions.ts @@ -0,0 +1,57 @@ +import { LAST_V5, V6 } from './consts'; +import { registerShape, stable } from './registry'; + +/** + * `corporateAction` / `checkpoint` / `corporateBallot` pallet parameter shapes. + * + * Walked event-by-event against the Rust source for v5.4.3, v6.3.5, v7.0.0, v7.4.0, v8.0.0 + * (docs/reference/event-shape-verification.md). `CAInitiated` and the `CorporateAction` struct are + * shape-identical across every one of those tags, as are all six `corporateBallot` events; only + * `Ticker` → `AssetId` at 7.x, already handled by `getAssetId` / `getCaIdValue`. + * + * `ScheduleCreated` / `ScheduleRemoved` are the one real change in this domain: arity 3 → 4 at + * v6.0.0, `ScheduleId` inserted at index 2 and the payload type changing from `StoredSchedule` to + * `ScheduleCheckpoints`. + */ +registerShape( + 'corporateaction', + 'CAInitiated', + stable(['did', 'caId', 'corporateAction', 'details']) +); +registerShape('corporateaction', 'CARemoved', stable(['did', 'caId'])); +registerShape('corporateaction', 'RecordDateChanged', stable(['did', 'caId', 'corporateAction'])); +registerShape('corporateaction', 'CALinkedToDoc', stable(['did', 'caId', 'docIds'])); +registerShape( + 'corporateaction', + 'DefaultTargetIdentitiesChanged', + stable(['did', 'assetId', 'targets']) +); +registerShape('corporateaction', 'DefaultWithholdingTaxChanged', stable(['did', 'assetId', 'tax'])); +registerShape( + 'corporateaction', + 'DidWithholdingTaxChanged', + stable(['did', 'assetId', 'targetDid', 'tax']) +); +registerShape('corporateaction', 'MaxDetailsLengthChanged', stable(['did', 'length'])); + +registerShape( + 'checkpoint', + 'CheckpointCreated', + stable(['did', 'assetId', 'checkpointId', 'totalSupply', 'moment']) +); +registerShape('checkpoint', 'MaximumSchedulesComplexityChanged', stable(['did', 'complexity'])); +registerShape('checkpoint', 'ScheduleCreated', [ + { from: 0, to: LAST_V5, fields: ['did', 'assetId', 'storedSchedule'] }, + { from: V6, fields: ['did', 'assetId', 'scheduleId', 'scheduleCheckpoints'] }, +]); +registerShape('checkpoint', 'ScheduleRemoved', [ + { from: 0, to: LAST_V5, fields: ['did', 'assetId', 'storedSchedule'] }, + { from: V6, fields: ['did', 'assetId', 'scheduleId', 'scheduleCheckpoints'] }, +]); + +registerShape('corporateballot', 'Created', stable(['did', 'caId', 'range', 'meta', 'rcv'])); +registerShape('corporateballot', 'MetaChanged', stable(['did', 'caId', 'meta'])); +registerShape('corporateballot', 'RangeChanged', stable(['did', 'caId', 'range'])); +registerShape('corporateballot', 'RCVChanged', stable(['did', 'caId', 'rcv'])); +registerShape('corporateballot', 'Removed', stable(['did', 'caId'])); +registerShape('corporateballot', 'VoteCast', stable(['did', 'caId', 'votes'])); diff --git a/src/decode/shapes/index.ts b/src/decode/shapes/index.ts index 14b89ff7..b1646076 100644 --- a/src/decode/shapes/index.ts +++ b/src/decode/shapes/index.ts @@ -14,9 +14,11 @@ */ import './asset'; import './balances'; +import './corporateActions'; import './externalAgents'; import './identity'; import './portfolio'; +import './relayer'; import './settlement'; import './staking'; diff --git a/src/decode/shapes/registry.ts b/src/decode/shapes/registry.ts index 8f99c2d4..99ee79ca 100644 --- a/src/decode/shapes/registry.ts +++ b/src/decode/shapes/registry.ts @@ -71,6 +71,14 @@ export const discontinuedAt = (to: number, fields: readonly string[]): EventShap { from: 0, to, fields }, ]; +/** + * A single shape from `from` onward, open-ended - the common case for an event the chain + * introduced partway through its history, with no shape change since. + */ +export const introducedAt = (from: number, fields: readonly string[]): EventShape[] => [ + { from, fields }, +]; + /** * Declares the parameters an event carries. * @@ -88,6 +96,19 @@ export const registerShape = ( shapes.set(key, [...(shapes.get(key) ?? []), ...entries]); }; +/** + * `registerShape` for several event ids that all carry the same shape - a rename or a group of + * events introduced together often share one. `registerShapes(m, [e1, e2], entries)` reads as + * "these all look like this," and keeps the shared shape written once instead of repeated per id. + */ +export const registerShapes = ( + moduleId: string, + eventIds: readonly string[], + entries: readonly EventShape[] +): void => { + eventIds.forEach(eventId => registerShape(moduleId, eventId, entries)); +}; + /** Every registered shape, keyed by `moduleId.eventId`. Read by the metadata contract test */ export const registeredShapes = (): ReadonlyMap => shapes; diff --git a/src/decode/shapes/relayer.ts b/src/decode/shapes/relayer.ts new file mode 100644 index 00000000..addd7fce --- /dev/null +++ b/src/decode/shapes/relayer.ts @@ -0,0 +1,43 @@ +import { LAST_V7, V8 } from './consts'; +import { discontinuedAt, introducedAt, registerShape, registerShapes } from './registry'; + +/** + * `relayer` pallet parameter shapes. + * + * The 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 the v8 events drop. Renamed events don't collide positionally — each name keeps + * its own shape — so only `UpdatedPolyxLimit`, whose name is unchanged, needs a two-entry shape. + * Fields are named so one handler can read either era: pre-v8 `AuthorizedPayingKey` and v8+ + * `ApprovedSubsidy` both expose `initialPolyxLimit`. + */ +registerShape( + 'relayer', + 'AuthorizedPayingKey', + discontinuedAt(LAST_V7, ['did', 'userKey', 'payingKey', 'initialPolyxLimit', 'authId']) +); +registerShape( + 'relayer', + 'AcceptedPayingKey', + discontinuedAt(LAST_V7, ['did', 'userKey', 'payingKey']) +); +registerShape( + 'relayer', + 'RemovedPayingKey', + discontinuedAt(LAST_V7, ['did', 'userKey', 'payingKey']) +); +registerShape('relayer', 'UpdatedPolyxLimit', [ + { from: 0, to: LAST_V7, fields: ['did', 'userKey', 'payingKey', 'remaining', 'oldRemaining'] }, + { from: V8, fields: ['userKey', 'payingKey', 'remaining', 'oldRemaining'] }, +]); + +registerShapes( + 'relayer', + ['ApprovedSubsidy', 'AcceptedSubsidy'], + introducedAt(V8, ['userKey', 'payingKey', 'initialPolyxLimit']) +); +registerShapes( + 'relayer', + ['RemovedSubsidy', 'RemovedPendingSubsidy', 'SubsidyDebited'], + introducedAt(V8, ['userKey', 'payingKey', 'amount']) +); diff --git a/src/mappings/entities/assets/mapCheckpoint.ts b/src/mappings/entities/assets/mapCheckpoint.ts new file mode 100644 index 00000000..cb85620c --- /dev/null +++ b/src/mappings/entities/assets/mapCheckpoint.ts @@ -0,0 +1,125 @@ +import { SubstrateEvent } from '@subql/types'; +import { DecodedEvent, decodeEvent, V6 } from '../../../decode'; +import { Checkpoint, CheckpointSchedule } from '../../../types'; +import { + getAssetId, + getBigIntValue, + getDateValue, + getNumberValue, + specVersionOf, +} from '../../../utils'; +import { extractArgs } from '../common'; + +interface ParsedSchedule { + scheduleId: number; + pendingCheckpoints?: Date[]; + period?: string; + start?: Date; + remaining?: number; + nextCheckpointAt?: Date; +} + +interface StoredScheduleJson { + schedule: { start: number; period: { unit: string; amount: number } }; + id: number; + at: number; + remaining: number; +} + +interface ScheduleCheckpointsJson { + pending: number[]; +} + +/** + * `ScheduleCreated` / `ScheduleRemoved` are the one real version change in the corporate-actions + * domain: arity 3 → 4 at v6.0.0, `ScheduleId` inserted at index 2 and the payload type changing + * from `StoredSchedule` (period-based) to `ScheduleCheckpoints` (an explicit timestamp list). The + * pre-v6 schedule id is recoverable from the nested `StoredSchedule.id`, so pre-v6 rows are not + * lossy, just decoded differently. Exported as a pure function for the arity regression test. + */ +export const parseSchedule = (decoded: DecodedEvent, specVersion: number): ParsedSchedule => { + if (specVersion < V6) { + const stored = decoded.storedSchedule.toJSON() as unknown as StoredScheduleJson; + + return { + scheduleId: stored.id, + period: JSON.stringify(stored.schedule.period), + start: new Date(stored.schedule.start), + remaining: stored.remaining, + nextCheckpointAt: new Date(stored.at), + }; + } + + const { pending } = decoded.scheduleCheckpoints.toJSON() as unknown as ScheduleCheckpointsJson; + + return { + scheduleId: getNumberValue(decoded.scheduleId), + pendingCheckpoints: pending.map(moment => new Date(moment)), + }; +}; + +export const handleCheckpointCreated = async (event: SubstrateEvent): Promise => { + const { block, blockEventId } = extractArgs(event); + const { + assetId: rawAssetId, + checkpointId: rawCheckpointId, + totalSupply: rawTotalSupply, + moment, + } = decodeEvent(event); + + const assetId = await getAssetId(rawAssetId, block); + const checkpointId = getNumberValue(rawCheckpointId); + + await Checkpoint.create({ + id: `${assetId}/${checkpointId}`, + assetId, + checkpointId, + totalSupply: getBigIntValue(rawTotalSupply), + datetime: getDateValue(moment) ?? block.timestamp, + // `CheckpointCreated`'s first arg is `Option` — `None` when a schedule triggered + // it, `Some` for a manual `checkpoint.createCheckpoint`. Neither case names *which* schedule, + // so linking `schedule` here would mean scanning every schedule for this asset for a + // matching pending timestamp — left for a future enrichment rather than guessed at. + createdEventId: blockEventId, + }).save(); +}; + +export const handleScheduleCreated = async (event: SubstrateEvent): Promise => { + const { block, blockEventId } = extractArgs(event); + const decoded = decodeEvent(event); + const { assetId: rawAssetId } = decoded; + + const assetId = await getAssetId(rawAssetId, block); + const parsed = parseSchedule(decoded, specVersionOf(block)); + + await CheckpointSchedule.create({ + id: `${assetId}/${parsed.scheduleId}`, + assetId, + scheduleId: parsed.scheduleId, + pendingCheckpoints: parsed.pendingCheckpoints, + period: parsed.period, + start: parsed.start, + remaining: parsed.remaining, + nextCheckpointAt: parsed.nextCheckpointAt, + createdEventId: blockEventId, + }).save(); +}; + +export const handleScheduleRemoved = async (event: SubstrateEvent): Promise => { + const { block, blockEventId } = extractArgs(event); + const decoded = decodeEvent(event); + const { assetId: rawAssetId } = decoded; + + const assetId = await getAssetId(rawAssetId, block); + const { scheduleId } = parseSchedule(decoded, specVersionOf(block)); + + const schedule = await CheckpointSchedule.get(`${assetId}/${scheduleId}`); + + if (!schedule) { + return; + } + + schedule.removedEventId = blockEventId; + + await schedule.save(); +}; diff --git a/src/mappings/entities/assets/mapCorporateAction.ts b/src/mappings/entities/assets/mapCorporateAction.ts new file mode 100644 index 00000000..f6770849 --- /dev/null +++ b/src/mappings/entities/assets/mapCorporateAction.ts @@ -0,0 +1,193 @@ +import { SubstrateEvent } from '@subql/types'; +import { decodeEvent } from '../../../decode'; +import { + CorporateAction, + CorporateActionDefaultConfig, + CorporateActionKind, + DidTax, + TargetTreatment, +} from '../../../types'; +import { bytesToString, getAssetId, getCaIdValue, getTextValue } from '../../../utils'; +import { extractArgs, toEnum } from '../common'; + +/** + * The `CorporateAction` struct, identical in shape v5.4.3 through v8.0.0 + * (docs/reference/event-shape-verification.md). + */ +interface RawCorporateAction { + kind: string; + declDate: number; + recordDate: { date: number } | null; + targets: { identities: string[]; treatment: string }; + defaultWithholdingTax: number; + withholdingTax: [string, number][]; +} + +const decodeCorporateAction = (raw: unknown): RawCorporateAction => raw as RawCorporateAction; + +const caId = (assetId: string, localId: number): string => `${assetId}/${localId}`; + +export const handleCaInitiated = async (event: SubstrateEvent): Promise => { + const { block, blockEventId, eventIdx } = extractArgs(event); + const { + caId: rawCaId, + corporateAction: rawCorporateAction, + details: rawDetails, + } = decodeEvent(event); + + const { localId, assetId } = await getCaIdValue(rawCaId, block); + const ca = decodeCorporateAction(rawCorporateAction.toJSON()); + + await CorporateAction.create({ + id: caId(assetId, localId), + assetId, + localId, + kind: toEnum(CorporateActionKind, ca.kind, CorporateActionKind.Other, { + enumName: 'CorporateActionKind', + block, + eventIdx, + }), + declarationDate: new Date(ca.declDate), + recordDate: ca.recordDate ? new Date(ca.recordDate.date) : undefined, + details: bytesToString(rawDetails), + targetIdentities: ca.targets.identities, + targetTreatment: toEnum(TargetTreatment, ca.targets.treatment, TargetTreatment.Exclude, { + enumName: 'TargetTreatment', + block, + eventIdx, + }), + defaultWithholdingTax: BigInt(ca.defaultWithholdingTax), + didWithholdingTax: ca.withholdingTax.map(([did, tax]): DidTax => ({ did, tax: BigInt(tax) })), + documents: [], + isRemoved: false, + createdEventId: blockEventId, + updatedEventId: blockEventId, + }).save(); +}; + +export const handleCaRemoved = async (event: SubstrateEvent): Promise => { + const { block, blockEventId } = extractArgs(event); + const { caId: rawCaId } = decodeEvent(event); + + const { localId, assetId } = await getCaIdValue(rawCaId, block); + + const corporateAction = await CorporateAction.get(caId(assetId, localId)); + + if (!corporateAction) { + return; + } + + corporateAction.isRemoved = true; + corporateAction.updatedEventId = blockEventId; + + await corporateAction.save(); +}; + +export const handleRecordDateChanged = async (event: SubstrateEvent): Promise => { + const { block, blockEventId } = extractArgs(event); + const { caId: rawCaId, corporateAction: rawCorporateAction } = decodeEvent(event); + + const { localId, assetId } = await getCaIdValue(rawCaId, block); + const ca = decodeCorporateAction(rawCorporateAction.toJSON()); + + const corporateAction = await CorporateAction.get(caId(assetId, localId)); + + if (!corporateAction) { + return; + } + + corporateAction.recordDate = ca.recordDate ? new Date(ca.recordDate.date) : undefined; + corporateAction.updatedEventId = blockEventId; + + await corporateAction.save(); +}; + +export const handleCaLinkedToDoc = async (event: SubstrateEvent): Promise => { + const { block, blockEventId } = extractArgs(event); + const { caId: rawCaId, docIds: rawDocIds } = decodeEvent(event); + + const { localId, assetId } = await getCaIdValue(rawCaId, block); + + const corporateAction = await CorporateAction.get(caId(assetId, localId)); + + if (!corporateAction) { + return; + } + + const docIds = (rawDocIds.toJSON() as number[]).map(String); + + corporateAction.documents = [...new Set([...(corporateAction.documents ?? []), ...docIds])]; + corporateAction.updatedEventId = blockEventId; + + await corporateAction.save(); +}; + +const getOrCreateDefaultConfig = async ( + assetId: string, + blockEventId: string +): Promise => { + let config = await CorporateActionDefaultConfig.get(assetId); + + if (!config) { + config = CorporateActionDefaultConfig.create({ + id: assetId, + assetId, + createdEventId: blockEventId, + updatedEventId: blockEventId, + }); + } + + return config; +}; + +export const handleDefaultTargetIdentitiesChanged = async ( + event: SubstrateEvent +): Promise => { + const { block, blockEventId } = extractArgs(event); + const { assetId: rawAssetId, targets: rawTargets } = decodeEvent(event); + + const assetId = await getAssetId(rawAssetId, block); + const targets = rawTargets.toJSON() as { identities: string[]; treatment: string }; + + const config = await getOrCreateDefaultConfig(assetId, blockEventId); + + config.targetIdentities = targets.identities; + config.targetTreatment = toEnum(TargetTreatment, targets.treatment, TargetTreatment.Exclude, { + enumName: 'TargetTreatment', + block, + }); + config.updatedEventId = blockEventId; + + await config.save(); +}; + +export const handleDefaultWithholdingTaxChanged = async (event: SubstrateEvent): Promise => { + const { block, blockEventId } = extractArgs(event); + const { assetId: rawAssetId, tax: rawTax } = decodeEvent(event); + + const assetId = await getAssetId(rawAssetId, block); + + const config = await getOrCreateDefaultConfig(assetId, blockEventId); + + config.defaultWithholdingTax = BigInt(rawTax.toJSON() as number); + config.updatedEventId = blockEventId; + + await config.save(); +}; + +export const handleDidWithholdingTaxChanged = async (event: SubstrateEvent): Promise => { + const { block, blockEventId } = extractArgs(event); + const { assetId: rawAssetId, targetDid, tax: rawTax } = decodeEvent(event); + + const assetId = await getAssetId(rawAssetId, block); + const did = getTextValue(targetDid); + const tax = rawTax.toJSON() as number | null; + + const config = await getOrCreateDefaultConfig(assetId, blockEventId); + const existing = (config.didWithholdingTax ?? []).filter(entry => entry.did !== did); + + config.didWithholdingTax = tax === null ? existing : [...existing, { did, tax: BigInt(tax) }]; + config.updatedEventId = blockEventId; + + await config.save(); +}; diff --git a/src/mappings/entities/assets/mapCorporateBallot.ts b/src/mappings/entities/assets/mapCorporateBallot.ts new file mode 100644 index 00000000..25b0bdd1 --- /dev/null +++ b/src/mappings/entities/assets/mapCorporateBallot.ts @@ -0,0 +1,146 @@ +import { Codec } from '@polkadot/types/types'; +import { SubstrateEvent } from '@subql/types'; +import { decodeEvent } from '../../../decode'; +import { BallotVoteEntry, CorporateBallot, CorporateBallotVote } from '../../../types'; +import { bytesToString, getBooleanValue, getCaIdValue } from '../../../utils'; +import { extractArgs } from '../common'; + +/** + * `corporateBallot` pallet handlers. + * + * All six events are shape-identical across every checked spec version + * (docs/reference/event-shape-verification.md) — no version branching in this domain. + */ +const ballotId = (assetId: string, localId: number): string => `${assetId}/${localId}`; + +interface RangeJson { + start: number; + end: number; +} + +interface MetaJson { + title: string; + motions: unknown; +} + +/** `BallotMeta { title: Bytes, motions: Vec }` — `title` decodes like any other `Bytes` field */ +const decodeMeta = (rawMeta: Codec): string => { + const { title, motions } = rawMeta.toJSON() as unknown as MetaJson; + + return JSON.stringify({ title: bytesToString({ toString: () => title } as Codec), motions }); +}; + +export const handleBallotCreated = async (event: SubstrateEvent): Promise => { + const { block, blockEventId } = extractArgs(event); + const { caId: rawCaId, range: rawRange, meta: rawMeta, rcv: rawRcv } = decodeEvent(event); + + const { localId, assetId } = await getCaIdValue(rawCaId, block); + const range = rawRange.toJSON() as unknown as RangeJson; + + await CorporateBallot.create({ + id: ballotId(assetId, localId), + assetId, + corporateActionId: ballotId(assetId, localId), + startDate: new Date(range.start), + endDate: new Date(range.end), + meta: decodeMeta(rawMeta), + rcv: getBooleanValue(rawRcv), + isRemoved: false, + createdEventId: blockEventId, + updatedEventId: blockEventId, + }).save(); +}; + +export const handleBallotMetaChanged = async (event: SubstrateEvent): Promise => { + const { block, blockEventId } = extractArgs(event); + const { caId: rawCaId, meta: rawMeta } = decodeEvent(event); + + const { localId, assetId } = await getCaIdValue(rawCaId, block); + const ballot = await CorporateBallot.get(ballotId(assetId, localId)); + + if (!ballot) { + return; + } + + ballot.meta = decodeMeta(rawMeta); + ballot.updatedEventId = blockEventId; + + await ballot.save(); +}; + +export const handleBallotRangeChanged = async (event: SubstrateEvent): Promise => { + const { block, blockEventId } = extractArgs(event); + const { caId: rawCaId, range: rawRange } = decodeEvent(event); + + const { localId, assetId } = await getCaIdValue(rawCaId, block); + const ballot = await CorporateBallot.get(ballotId(assetId, localId)); + + if (!ballot) { + return; + } + + const range = rawRange.toJSON() as unknown as RangeJson; + + ballot.startDate = new Date(range.start); + ballot.endDate = new Date(range.end); + ballot.updatedEventId = blockEventId; + + await ballot.save(); +}; + +export const handleBallotRcvChanged = async (event: SubstrateEvent): Promise => { + const { block, blockEventId } = extractArgs(event); + const { caId: rawCaId, rcv: rawRcv } = decodeEvent(event); + + const { localId, assetId } = await getCaIdValue(rawCaId, block); + const ballot = await CorporateBallot.get(ballotId(assetId, localId)); + + if (!ballot) { + return; + } + + ballot.rcv = getBooleanValue(rawRcv); + ballot.updatedEventId = blockEventId; + + await ballot.save(); +}; + +export const handleBallotRemoved = async (event: SubstrateEvent): Promise => { + const { block, blockEventId } = extractArgs(event); + const { caId: rawCaId } = decodeEvent(event); + + const { localId, assetId } = await getCaIdValue(rawCaId, block); + const ballot = await CorporateBallot.get(ballotId(assetId, localId)); + + if (!ballot) { + return; + } + + ballot.isRemoved = true; + ballot.updatedEventId = blockEventId; + + await ballot.save(); +}; + +export const handleBallotVoteCast = async (event: SubstrateEvent): Promise => { + const { block, blockEventId } = extractArgs(event); + const { did: rawDid, caId: rawCaId, votes: rawVotes } = decodeEvent(event); + + const { localId, assetId } = await getCaIdValue(rawCaId, block); + const voterId = rawDid.toString(); + + const votes = (rawVotes.toJSON() as unknown as { power: number; fallback: number | null }[]).map( + (vote): BallotVoteEntry => ({ + power: BigInt(vote.power), + fallback: vote.fallback ?? undefined, + }) + ); + + await CorporateBallotVote.create({ + id: blockEventId, + ballotId: ballotId(assetId, localId), + voterId, + votes, + createdEventId: blockEventId, + }).save(); +}; diff --git a/src/mappings/entities/assets/mapTransferManager.ts b/src/mappings/entities/assets/mapTransferManager.ts deleted file mode 100644 index 6dfab387..00000000 --- a/src/mappings/entities/assets/mapTransferManager.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { SubstrateEvent } from '@subql/types'; -import { TransferManager } from '../../../types'; -import { getAssetId, getExemptionsValue, getTransferManagerValue } from '../../../utils'; -import { extractArgs } from '../common'; - -const getTransferManageId = ( - assetId: string, - { type, value }: Pick -): string => `${assetId}/${type}/${value}`; - -const getTransferManager = ( - assetId: string, - restriction: Pick -): Promise => - TransferManager.get(getTransferManageId(assetId, restriction)); - -export const handleTransferManagerAdded = async (event: SubstrateEvent): Promise => { - const { params, block, blockEventId } = extractArgs(event); - const [, rawAssetId, rawManager] = params; - - const assetId = await getAssetId(rawAssetId, block); - const { type, value } = getTransferManagerValue(rawManager); - const id = `${assetId}/${type}/${value}`; - - await TransferManager.create({ - id, - assetId, - type, - value, - exemptedEntities: [], - createdEventId: blockEventId, - updatedEventId: blockEventId, - }).save(); -}; - -export const handleTransferManagerRemoved = async (event: SubstrateEvent): Promise => { - const { params, block } = extractArgs(event); - const [, rawAssetId, rawManager] = params; - - const assetId = await getAssetId(rawAssetId, block); - const { type, value } = getTransferManagerValue(rawManager); - const id = `${assetId}/${type}/${value}`; - - await TransferManager.remove(id); -}; - -export const handleExemptionsAdded = async (event: SubstrateEvent): Promise => { - const { params, block, blockEventId } = extractArgs(event); - const [, rawAssetId, rawAgentGroup, rawExemptions] = params; - - const assetId = await getAssetId(rawAssetId, block); - const transferManagerValue = getTransferManagerValue(rawAgentGroup); - const parsedExemptions = getExemptionsValue(rawExemptions); - - const transferManager = await getTransferManager(assetId, transferManagerValue); - - if (transferManager) { - transferManager.exemptedEntities = [ - ...new Set([...parsedExemptions, ...transferManager.exemptedEntities]), - ]; - transferManager.updatedEventId = blockEventId; - - await transferManager.save(); - } -}; - -export const handleExemptionsRemoved = async (event: SubstrateEvent): Promise => { - const { params, block, blockEventId } = extractArgs(event); - const [, rawAssetId, rawAgentGroup, rawExemptions] = params; - - const assetId = await getAssetId(rawAssetId, block); - const transferManagerValue = getTransferManagerValue(rawAgentGroup); - const parsedExemptions = getExemptionsValue(rawExemptions); - - const transferManager = await getTransferManager(assetId, transferManagerValue); - - if (transferManager) { - transferManager.exemptedEntities = transferManager.exemptedEntities.filter( - e => !parsedExemptions.includes(e) - ); - transferManager.updatedEventId = blockEventId; - - await transferManager.save(); - } -}; diff --git a/src/mappings/entities/externalAgents/mapExternalAgent.ts b/src/mappings/entities/externalAgents/mapExternalAgent.ts index 281df7bb..ff5da6fa 100644 --- a/src/mappings/entities/externalAgents/mapExternalAgent.ts +++ b/src/mappings/entities/externalAgents/mapExternalAgent.ts @@ -1,6 +1,6 @@ import { SubstrateEvent } from '@subql/types'; import { decodeEvent } from '../../../decode'; -import { TickerExternalAgent } from '../../../types'; +import { AssetAgent } from '../../../types'; import { getAssetId, getTextValue } from '../../../utils'; import { extractArgs } from '../common'; @@ -8,13 +8,18 @@ export const handleExternalAgentAdded = async (event: SubstrateEvent): Promise = await AgentGroupEntity.create({ id: `${assetId}/${group}`, + assetId, permissions, createdEventId: blockEventId, updatedEventId: blockEventId, @@ -42,11 +44,11 @@ export const handleGroupPermissionsUpdated = async (event: SubstrateEvent): Prom for (const member of members) { promises.push( - TickerExternalAgentHistory.create({ - id: `${blockEventId}/${member.member}`, + AssetAgentHistory.create({ + id: `${blockEventId}/${member.memberId}`, assetId, - identityId: member.member, - type: 'AgentPermissionsChanged', + identityId: member.memberId, + type: AgentHistoryType.AgentPermissionsChanged, permissions, createdEventId: blockEventId, updatedEventId: blockEventId, @@ -73,7 +75,7 @@ export const handleAgentAdded = async (event: SubstrateEvent): Promise => eventIdx, did, block, - 'AgentAdded', + AgentHistoryType.AgentAdded, blockEventId ), ]; @@ -102,7 +104,7 @@ export const handleGroupChanged = async (event: SubstrateEvent): Promise = eventIdx, did, block, - 'AgentPermissionsChanged', + AgentHistoryType.GroupChanged, blockEventId ), ]; @@ -123,11 +125,11 @@ export async function handleAgentRemoved(event: SubstrateEvent): Promise { const promises = [ removeMember(did, assetId), - TickerExternalAgentHistory.create({ + AssetAgentHistory.create({ id: `${blockEventId}/${did}`, assetId, identityId: did, - type: 'AgentRemoved', + type: AgentHistoryType.AgentRemoved, createdEventId: blockEventId, updatedEventId: blockEventId, }).save(), @@ -143,14 +145,14 @@ const addExternalAgentHistory = async ( eventIdx: number, did: string, block: SubstrateBlock, - type: 'AgentAdded' | 'AgentPermissionsChanged', + type: AgentHistoryType.AgentAdded | AgentHistoryType.GroupChanged, blockEventId: string ): Promise => { const permissions = await permissionsFromAgentGroup(assetId, group, async n => { const ag = await AgentGroupEntity.get(`${assetId}/${n}`); return ag.permissions; }); - await TickerExternalAgentHistory.create({ + await AssetAgentHistory.create({ id: `${blockEventId}/${did}`, assetId, identityId: did, @@ -169,7 +171,7 @@ const addAgentGroupMembership = ( ): Promise => { return AgentGroupMembership.create({ id: `${assetId}/${group.custom}/${did}`, - member: did, + memberId: did, groupId: `${assetId}/${group.custom}`, createdEventId: blockEventId, updatedEventId: blockEventId, @@ -178,7 +180,7 @@ const addAgentGroupMembership = ( const removeMember = async (did: string, assetId: string) => { const memberships = await getAllByFields('AgentGroupMembership', [ - ['member', '=', did], + ['memberId', '=', did], ]); const memberIds = memberships diff --git a/src/mappings/entities/index.ts b/src/mappings/entities/index.ts index 1f1de677..942495c8 100644 --- a/src/mappings/entities/index.ts +++ b/src/mappings/entities/index.ts @@ -10,7 +10,10 @@ export * from './confidentialAssets/mapConfidentialAccount'; export * from './confidentialAssets/mapConfidentialAsset'; export * from './confidentialAssets/mapConfidentialCurveTree'; export * from './confidentialAssets/mapConfidentialSettlement'; +export * from './assets/mapCheckpoint'; +export * from './assets/mapCorporateAction'; export * from './assets/mapCorporateActions'; +export * from './assets/mapCorporateBallot'; export * from './events/mapEvent'; export * from './externalAgents/mapExternalAgentAction'; export * from './block/mapExtrinsic'; @@ -30,5 +33,5 @@ export * from './stos/mapSto'; export * from './block/mapSubqueryVersion'; export * from './externalAgents/mapExternalAgent'; export * from './externalAgents/mapExternalAgentHistory'; -export * from './assets/mapTransferManager'; +export * from './relayer/mapSubsidy'; export * from './assets/mapTickers'; diff --git a/src/mappings/entities/relayer/mapSubsidy.ts b/src/mappings/entities/relayer/mapSubsidy.ts new file mode 100644 index 00000000..2fa8765a --- /dev/null +++ b/src/mappings/entities/relayer/mapSubsidy.ts @@ -0,0 +1,178 @@ +import { SubstrateEvent } from '@subql/types'; +import { decodeEvent } from '../../../decode'; +import { AnomalyKind, Subsidy } from '../../../types'; +import { getBigIntValue, getOrCreateAccount, getTextValue, recordAnomaly } from '../../../utils'; +import { extractArgs } from '../common'; + +/** + * `relayer` pallet handlers. + * + * Renamed paying-key → subsidy at chain v8.0.0 (docs/reference/event-shape-verification.md is + * silent on this domain — verified directly against `pallets/relayer/src/lib.rs`). Each handler + * below is registered for both the pre-v8 and v8+ event names, since the field names in + * `src/decode/shapes/relayer.ts` were chosen so one handler reads either era. + */ +const subsidyId = (userKey: string, payingKey: string): string => `${userKey}/${payingKey}`; + +const ensureAccounts = async ( + userKey: string, + payingKey: string, + blockId: string, + datetime: Date, + blockEventId: string +): Promise => { + await Promise.all([ + getOrCreateAccount(userKey, blockId, datetime, blockEventId), + getOrCreateAccount(payingKey, blockId, datetime, blockEventId), + ]); +}; + +export const handleSubsidyApproved = async (event: SubstrateEvent): Promise => { + const { block, blockId, blockEventId } = extractArgs(event); + const { + userKey: rawUserKey, + payingKey: rawPayingKey, + initialPolyxLimit: rawLimit, + } = decodeEvent(event); + + const userKey = getTextValue(rawUserKey); + const payingKey = getTextValue(rawPayingKey); + + await ensureAccounts(userKey, payingKey, blockId, block.timestamp, blockEventId); + + await Subsidy.create({ + id: subsidyId(userKey, payingKey), + beneficiaryAccountId: userKey, + payingAccountId: payingKey, + allowance: getBigIntValue(rawLimit), + totalDebited: BigInt(0), + isAccepted: false, + isRemoved: false, + createdEventId: blockEventId, + updatedEventId: blockEventId, + }).save(); +}; + +/** + * Loads the `Subsidy` a mutating event refers to, recording a `MissingReferencedEntity` anomaly + * instead of silently doing nothing when the approval that would have created it was not indexed. + */ +const getSubsidyOrAnomaly = async ( + userKey: string, + payingKey: string, + event: SubstrateEvent +): Promise => { + const id = subsidyId(userKey, payingKey); + const subsidy = await Subsidy.get(id); + + if (subsidy) { + return subsidy; + } + + const { block, eventIdx, moduleId, eventId } = extractArgs(event); + + await recordAnomaly({ + kind: AnomalyKind.MissingReferencedEntity, + detail: `${eventId} found no Subsidy at id "${id}"`, + block, + eventIdx, + moduleId, + eventId, + }); + + return undefined; +}; + +export const handleSubsidyAccepted = async (event: SubstrateEvent): Promise => { + const { blockEventId } = extractArgs(event); + const decoded = decodeEvent(event); + const { userKey: rawUserKey, payingKey: rawPayingKey } = decoded; + + const subsidy = await getSubsidyOrAnomaly( + getTextValue(rawUserKey), + getTextValue(rawPayingKey), + event + ); + + if (!subsidy) { + return; + } + + subsidy.isAccepted = true; + + // v8+ `AcceptedSubsidy` repeats the limit; pre-v8 `AcceptedPayingKey` does not carry one, so + // the allowance `handleSubsidyApproved` already set stands. + if ('initialPolyxLimit' in decoded) { + subsidy.allowance = getBigIntValue(decoded.initialPolyxLimit); + } + subsidy.updatedEventId = blockEventId; + + await subsidy.save(); +}; + +export const handleSubsidyRemoved = async (event: SubstrateEvent): Promise => { + const { blockEventId } = extractArgs(event); + const { userKey: rawUserKey, payingKey: rawPayingKey } = decodeEvent(event); + + const subsidy = await getSubsidyOrAnomaly( + getTextValue(rawUserKey), + getTextValue(rawPayingKey), + event + ); + + if (!subsidy) { + return; + } + + subsidy.isRemoved = true; + subsidy.updatedEventId = blockEventId; + + await subsidy.save(); +}; + +export const handleSubsidyDebited = async (event: SubstrateEvent): Promise => { + const { blockEventId } = extractArgs(event); + const { userKey: rawUserKey, payingKey: rawPayingKey, amount: rawAmount } = decodeEvent(event); + + const subsidy = await getSubsidyOrAnomaly( + getTextValue(rawUserKey), + getTextValue(rawPayingKey), + event + ); + + if (!subsidy) { + return; + } + + const amount = getBigIntValue(rawAmount); + + subsidy.totalDebited += amount; + subsidy.allowance -= amount; + subsidy.updatedEventId = blockEventId; + + await subsidy.save(); +}; + +export const handlePolyxLimitUpdated = async (event: SubstrateEvent): Promise => { + const { blockEventId } = extractArgs(event); + const { + userKey: rawUserKey, + payingKey: rawPayingKey, + remaining: rawRemaining, + } = decodeEvent(event); + + const subsidy = await getSubsidyOrAnomaly( + getTextValue(rawUserKey), + getTextValue(rawPayingKey), + event + ); + + if (!subsidy) { + return; + } + + subsidy.allowance = getBigIntValue(rawRemaining); + subsidy.updatedEventId = blockEventId; + + await subsidy.save(); +}; diff --git a/src/utils/transferManagers.ts b/src/utils/transferManagers.ts index d5c5563c..e92e22bc 100644 --- a/src/utils/transferManagers.ts +++ b/src/utils/transferManagers.ts @@ -5,7 +5,6 @@ import { ClaimTypeEnum, Compliance, TransferComplianceExemption, - TransferManager, TransferRestrictionTypeEnum, } from '../types'; import { getAssetId } from './assets'; @@ -39,11 +38,13 @@ export const getComplianceValue = ( }; /** - * Parses AssetTransferManager + * Parses AssetTransferManager into the `{ type, value }` shape `StatType` / + * `TransferComplianceExemption` derive from — the retired `TransferManager` entity used to be the + * third consumer of this shape */ export const getTransferManagerValue = ( manager: Codec -): Pick => { +): { type: TransferRestrictionTypeEnum; value: number } => { const { countTransferManager, percentageTransferManager } = JSON.parse(JSON.stringify(manager)); if (countTransferManager) { diff --git a/tests/entities/__snapshots__/assets.test.ts.snap b/tests/entities/__snapshots__/assets.test.ts.snap deleted file mode 100644 index 3d1df5d7..00000000 --- a/tests/entities/__snapshots__/assets.test.ts.snap +++ /dev/null @@ -1,253 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`assets should return all Asset details 1`] = ` -Object { - "assets": Object { - "__typename": "AssetsConnection", - "nodes": Array [ - Object { - "__typename": "Asset", - "compliance": Object { - "__typename": "CompliancesConnection", - "nodes": Array [ - Object { - "__typename": "Compliance", - "data": "{\\"senderConditions\\":[],\\"receiverConditions\\":[]}", - "id": 1, - }, - ], - }, - "documents": Object { - "__typename": "AssetDocumentsConnection", - "nodes": Array [], - }, - "fundingRound": null, - "holders": Object { - "__typename": "AssetHoldersConnection", - "nodes": Array [ - Object { - "__typename": "AssetHolder", - "amount": "999600", - "did": "0x0500000000000000000000000000000000000000000000000000000000000000", - }, - Object { - "__typename": "AssetHolder", - "amount": "100", - "did": "0x08c278a1fda1c376118a5a018ed968b73093f43862e69fb70a43420e23dcd811", - }, - Object { - "__typename": "AssetHolder", - "amount": "100", - "did": "0x40b9ecbf803281095439a8f6a74613e2f221b0a01ebec6ad2f109ed2ea895fbb", - }, - Object { - "__typename": "AssetHolder", - "amount": "100", - "did": "0x5e356043d083040537b74f6d3516c045ca2f5635f2f1962e0a1ab0ce81806b60", - }, - Object { - "__typename": "AssetHolder", - "amount": "100", - "did": "0xea4516967d37fa2e8d1e63e08ed358798518caa6a5dad8c7b236fe9446061dc3", - }, - ], - }, - "id": "11BTICKER1", - "identifiers": Array [], - "isCompliancePaused": false, - "isDivisible": true, - "isFrozen": false, - "name": "11BTICKER1", - "ownerDid": "0x0500000000000000000000000000000000000000000000000000000000000000", - "ticker": "11BTICKER1", - "totalSupply": "1000000", - "totalTransfers": "4", - "transferManagers": Object { - "__typename": "TransferManagersConnection", - "nodes": Array [], - }, - "type": "EquityCommon", - }, - Object { - "__typename": "Asset", - "compliance": Object { - "__typename": "CompliancesConnection", - "nodes": Array [ - Object { - "__typename": "Compliance", - "data": "{\\"senderConditions\\":[],\\"receiverConditions\\":[]}", - "id": 1, - }, - ], - }, - "documents": Object { - "__typename": "AssetDocumentsConnection", - "nodes": Array [], - }, - "fundingRound": null, - "holders": Object { - "__typename": "AssetHoldersConnection", - "nodes": Array [ - Object { - "__typename": "AssetHolder", - "amount": "100000", - "did": "0x43b62e2ea8713eb6f766beed4651f41ad52b607e7c6214bd6cb444952e9c3a6f", - }, - ], - }, - "id": "15TICKER", - "identifiers": Array [], - "isCompliancePaused": false, - "isDivisible": true, - "isFrozen": false, - "name": "15TICKER", - "ownerDid": "0x43b62e2ea8713eb6f766beed4651f41ad52b607e7c6214bd6cb444952e9c3a6f", - "ticker": "15TICKER", - "totalSupply": "100000", - "totalTransfers": "0", - "transferManagers": Object { - "__typename": "TransferManagersConnection", - "nodes": Array [], - }, - "type": "EquityCommon", - }, - Object { - "__typename": "Asset", - "compliance": Object { - "__typename": "CompliancesConnection", - "nodes": Array [], - }, - "documents": Object { - "__typename": "AssetDocumentsConnection", - "nodes": Array [ - Object { - "__typename": "AssetDocument", - "contentHash": Object { - "type": "none", - "value": null, - }, - "filedAt": null, - "id": 0, - "link": "www.google.com", - "name": "google", - "type": null, - }, - ], - }, - "fundingRound": null, - "holders": Object { - "__typename": "AssetHoldersConnection", - "nodes": Array [ - Object { - "__typename": "AssetHolder", - "amount": "1000000", - "did": "0xa9755a2d863adf7981d60f816884783bb2b71788efdfae845fc2830c36500551", - }, - ], - }, - "id": "4TICKER", - "identifiers": Array [], - "isCompliancePaused": false, - "isDivisible": true, - "isFrozen": false, - "name": "4TICKER", - "ownerDid": "0xa9755a2d863adf7981d60f816884783bb2b71788efdfae845fc2830c36500551", - "ticker": "4TICKER", - "totalSupply": "1000000", - "totalTransfers": "0", - "transferManagers": Object { - "__typename": "TransferManagersConnection", - "nodes": Array [], - }, - "type": "EquityCommon", - }, - Object { - "__typename": "Asset", - "compliance": Object { - "__typename": "CompliancesConnection", - "nodes": Array [ - Object { - "__typename": "Compliance", - "data": "{\\"senderConditions\\":[{\\"conditionType\\":{\\"isPresent\\":{\\"exempted\\":{\\"ticker\\":\\"0x375449434b45520000000000\\"}}},\\"issuers\\":[{\\"issuer\\":\\"0x5c7834d42ff31e9df992016d5983301d8376494a61d464c66fd03bb4b02471e0\\",\\"trustedFor\\":{\\"any\\":null}}]}],\\"receiverConditions\\":[{\\"conditionType\\":{\\"isPresent\\":{\\"exempted\\":{\\"ticker\\":\\"0x375449434b45520000000000\\"}}},\\"issuers\\":[{\\"issuer\\":\\"0x5c7834d42ff31e9df992016d5983301d8376494a61d464c66fd03bb4b02471e0\\",\\"trustedFor\\":{\\"any\\":null}}]}]}", - "id": 1, - }, - ], - }, - "documents": Object { - "__typename": "AssetDocumentsConnection", - "nodes": Array [], - }, - "fundingRound": null, - "holders": Object { - "__typename": "AssetHoldersConnection", - "nodes": Array [ - Object { - "__typename": "AssetHolder", - "amount": "1000000", - "did": "0x5c7834d42ff31e9df992016d5983301d8376494a61d464c66fd03bb4b02471e0", - }, - ], - }, - "id": "7TICKER", - "identifiers": Array [], - "isCompliancePaused": false, - "isDivisible": true, - "isFrozen": false, - "name": "7TICKER", - "ownerDid": "0x5c7834d42ff31e9df992016d5983301d8376494a61d464c66fd03bb4b02471e0", - "ticker": "7TICKER", - "totalSupply": "1000000", - "totalTransfers": "0", - "transferManagers": Object { - "__typename": "TransferManagersConnection", - "nodes": Array [], - }, - "type": "EquityCommon", - }, - Object { - "__typename": "Asset", - "compliance": Object { - "__typename": "CompliancesConnection", - "nodes": Array [ - Object { - "__typename": "Compliance", - "data": "{\\"senderConditions\\":[],\\"receiverConditions\\":[]}", - "id": 1, - }, - ], - }, - "documents": Object { - "__typename": "AssetDocumentsConnection", - "nodes": Array [], - }, - "fundingRound": "first", - "holders": Object { - "__typename": "AssetHoldersConnection", - "nodes": Array [ - Object { - "__typename": "AssetHolder", - "amount": "1000100", - "did": "0xb92252cbcfe17dc4dfaec11ec63930a028d5d7cd106bacf54e1603d27c59df70", - }, - ], - }, - "id": "8TICKER", - "identifiers": Array [], - "isCompliancePaused": false, - "isDivisible": true, - "isFrozen": false, - "name": "8TICKER", - "ownerDid": "0xb92252cbcfe17dc4dfaec11ec63930a028d5d7cd106bacf54e1603d27c59df70", - "ticker": "8TICKER", - "totalSupply": "1000100", - "totalTransfers": "0", - "transferManagers": Object { - "__typename": "TransferManagersConnection", - "nodes": Array [], - }, - "type": "EquityCommon", - }, - ], - }, -} -`; diff --git a/tests/entities/__snapshots__/externalAgentActions.test.ts.snap b/tests/entities/__snapshots__/externalAgentActions.test.ts.snap deleted file mode 100644 index 374adf68..00000000 --- a/tests/entities/__snapshots__/externalAgentActions.test.ts.snap +++ /dev/null @@ -1,240 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`tickerExternalAgentActions should filter by caller DID 1`] = ` -Object { - "tickerExternalAgentActions": Object { - "__typename": "TickerExternalAgentActionsConnection", - "nodes": Array [ - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "IdentifiersUpdated", - "palletName": "asset", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "AgentAdded", - "palletName": "externalagents", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "Issued", - "palletName": "asset", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "GroupCreated", - "palletName": "externalagents", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "GroupPermissionsUpdated", - "palletName": "externalagents", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "ComplianceRequirementCreated", - "palletName": "compliancemanager", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "AgentRemoved", - "palletName": "externalagents", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "AgentAdded", - "palletName": "externalagents", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "AgentRemoved", - "palletName": "externalagents", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "GroupCreated", - "palletName": "externalagents", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "GroupPermissionsUpdated", - "palletName": "externalagents", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "GroupChanged", - "palletName": "externalagents", - }, - ], - }, -} -`; - -exports[`tickerExternalAgentActions should filter by event id 1`] = ` -Object { - "tickerExternalAgentActions": Object { - "__typename": "TickerExternalAgentActionsConnection", - "nodes": Array [ - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0xbd8b5c568e457823d3d3f18d2d1cc1ed79a086124760e9df41a4c66e96de9b42", - "eventId": "FundraiserFrozen", - "palletName": "sto", - }, - ], - }, -} -`; - -exports[`tickerExternalAgentActions should filter by pallet name 1`] = ` -Object { - "tickerExternalAgentActions": Object { - "__typename": "TickerExternalAgentActionsConnection", - "nodes": Array [ - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "ComplianceRequirementCreated", - "palletName": "compliancemanager", - }, - ], - }, -} -`; - -exports[`tickerExternalAgentActions should return the transactions for ticker 1`] = ` -Object { - "tickerExternalAgentActions": Object { - "__typename": "TickerExternalAgentActionsConnection", - "nodes": Array [ - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "IdentifiersUpdated", - "palletName": "asset", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "AgentAdded", - "palletName": "externalagents", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "Issued", - "palletName": "asset", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "GroupCreated", - "palletName": "externalagents", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "GroupPermissionsUpdated", - "palletName": "externalagents", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0xbe22c6f8fe0d907365c3ccbf7e955bab29844d0009e21944c66c56973c67fdd2", - "eventId": "AgentAdded", - "palletName": "externalagents", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0xbd8b5c568e457823d3d3f18d2d1cc1ed79a086124760e9df41a4c66e96de9b42", - "eventId": "AgentAdded", - "palletName": "externalagents", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "ComplianceRequirementCreated", - "palletName": "compliancemanager", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "AgentRemoved", - "palletName": "externalagents", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "AgentAdded", - "palletName": "externalagents", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "AgentRemoved", - "palletName": "externalagents", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "GroupCreated", - "palletName": "externalagents", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "GroupPermissionsUpdated", - "palletName": "externalagents", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventId": "GroupChanged", - "palletName": "externalagents", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0xbd8b5c568e457823d3d3f18d2d1cc1ed79a086124760e9df41a4c66e96de9b42", - "eventId": "FundraiserCreated", - "palletName": "sto", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0xbd8b5c568e457823d3d3f18d2d1cc1ed79a086124760e9df41a4c66e96de9b42", - "eventId": "FundraiserFrozen", - "palletName": "sto", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0xbd8b5c568e457823d3d3f18d2d1cc1ed79a086124760e9df41a4c66e96de9b42", - "eventId": "FundraiserUnfrozen", - "palletName": "sto", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0xbd8b5c568e457823d3d3f18d2d1cc1ed79a086124760e9df41a4c66e96de9b42", - "eventId": "FundraiserWindowModified", - "palletName": "sto", - }, - Object { - "__typename": "TickerExternalAgentAction", - "callerId": "0xbd8b5c568e457823d3d3f18d2d1cc1ed79a086124760e9df41a4c66e96de9b42", - "eventId": "FundraiserClosed", - "palletName": "sto", - }, - ], - "totalCount": 19, - }, -} -`; diff --git a/tests/entities/__snapshots__/tickerExternalAgent.test.ts.snap b/tests/entities/__snapshots__/tickerExternalAgent.test.ts.snap deleted file mode 100644 index 5d89bfd6..00000000 --- a/tests/entities/__snapshots__/tickerExternalAgent.test.ts.snap +++ /dev/null @@ -1,18 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`tickerExternalAgent should return the time block and event index when an agent was added to a ticker 1`] = ` -Object { - "tickerExternalAgents": Object { - "__typename": "TickerExternalAgentsConnection", - "nodes": Array [ - Object { - "__typename": "TickerExternalAgent", - "callerDid": "0x0500000000000000000000000000000000000000000000000000000000000000", - "createdBlockId": "285", - "datetime": "2022-11-08T19:02:40", - "eventIdx": 1, - }, - ], - }, -} -`; diff --git a/tests/entities/__snapshots__/tickerExternalAgentHistory.test.ts.snap b/tests/entities/__snapshots__/tickerExternalAgentHistory.test.ts.snap deleted file mode 100644 index bae93dfe..00000000 --- a/tests/entities/__snapshots__/tickerExternalAgentHistory.test.ts.snap +++ /dev/null @@ -1,81 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`tickerExternalAgentHistory should return history of each external agent in ticker 1`] = ` -Object { - "tickerExternalAgentHistories": Object { - "__typename": "TickerExternalAgentHistoriesConnection", - "nodes": Array [ - Object { - "__typename": "TickerExternalAgentHistory", - "createdBlockId": "274", - "datetime": "2022-11-08T19:02:34.5", - "did": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventIdx": 7, - "permissions": "{\\"whole\\":null}", - "ticker": "12TICKER", - "type": "AgentAdded", - }, - Object { - "__typename": "TickerExternalAgentHistory", - "createdBlockId": "279", - "datetime": "2022-11-08T19:02:37", - "did": "0xbe22c6f8fe0d907365c3ccbf7e955bab29844d0009e21944c66c56973c67fdd2", - "eventIdx": 1, - "permissions": "{\\"whole\\":null}", - "ticker": "12TICKER", - "type": "AgentAdded", - }, - Object { - "__typename": "TickerExternalAgentHistory", - "createdBlockId": "281", - "datetime": "2022-11-08T19:02:38.001", - "did": "0xbd8b5c568e457823d3d3f18d2d1cc1ed79a086124760e9df41a4c66e96de9b42", - "eventIdx": 1, - "permissions": "{\\"whole\\":null}", - "ticker": "12TICKER", - "type": "AgentAdded", - }, - Object { - "__typename": "TickerExternalAgentHistory", - "createdBlockId": "283", - "datetime": "2022-11-08T19:02:39", - "did": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventIdx": 1, - "permissions": null, - "ticker": "12TICKER", - "type": "AgentRemoved", - }, - Object { - "__typename": "TickerExternalAgentHistory", - "createdBlockId": "285", - "datetime": "2022-11-08T19:02:40", - "did": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventIdx": 1, - "permissions": "{\\"whole\\":null}", - "ticker": "12TICKER", - "type": "AgentAdded", - }, - Object { - "__typename": "TickerExternalAgentHistory", - "createdBlockId": "286", - "datetime": "2022-11-08T19:02:40.5", - "did": "0xbe22c6f8fe0d907365c3ccbf7e955bab29844d0009e21944c66c56973c67fdd2", - "eventIdx": 1, - "permissions": null, - "ticker": "12TICKER", - "type": "AgentRemoved", - }, - Object { - "__typename": "TickerExternalAgentHistory", - "createdBlockId": "289", - "datetime": "2022-11-08T19:02:42", - "did": "0x0500000000000000000000000000000000000000000000000000000000000000", - "eventIdx": 1, - "permissions": "{\\"whole\\":null}", - "ticker": "12TICKER", - "type": "AgentPermissionsChanged", - }, - ], - }, -} -`; diff --git a/tests/entities/tickerExternalAgent.test.ts b/tests/entities/assetAgent.test.ts similarity index 80% rename from tests/entities/tickerExternalAgent.test.ts rename to tests/entities/assetAgent.test.ts index ae44507e..35bdb5a0 100644 --- a/tests/entities/tickerExternalAgent.test.ts +++ b/tests/entities/assetAgent.test.ts @@ -5,22 +5,22 @@ const { query } = getApolloClient(); const ticker = '12TICKER'; -describe('tickerExternalAgent', () => { +describe('assetAgent', () => { it('should return the time block and event index when an agent was added to a ticker', async () => { const q = { variables: { ticker }, query: gql` query q($ticker: String!) { - tickerExternalAgents( + assetAgents( filter: { assetId: { equalTo: $ticker } - callerId: { + identityId: { equalTo: "${eveDid}" } } ) { nodes { - callerDid: callerId + identityDid: identityId datetime createdBlockId eventIdx @@ -40,10 +40,10 @@ describe('tickerExternalAgent', () => { variables: { ticker }, query: gql` query q($ticker: String!) { - tickerExternalAgents( + assetAgents( filter: { assetId: { equalTo: $ticker } - callerId: { + identityId: { equalTo: "${bobDid}" } }, orderBy: ID_ASC @@ -61,15 +61,15 @@ describe('tickerExternalAgent', () => { const subquery = await query(q); expect(subquery?.errors).toBeFalsy(); - expect(subquery?.data?.tickerExternalAgents.nodes).toEqual([]); + expect(subquery?.data?.assetAgents.nodes).toEqual([]); }); it('should return empty when the agent is not found', async () => { const res = await query({ variables: { ticker }, query: gql` query q($ticker: String!) { - tickerExternalAgents( - filter: { assetId: { equalTo: $ticker }, callerId: { equalTo: "bogus" } } + assetAgents( + filter: { assetId: { equalTo: $ticker }, identityId: { equalTo: "bogus" } } orderBy: ID_ASC ) { nodes { @@ -83,6 +83,6 @@ describe('tickerExternalAgent', () => { }); expect(res?.errors).toBeFalsy(); - expect(res?.data?.tickerExternalAgents.nodes).toEqual([]); + expect(res?.data?.assetAgents.nodes).toEqual([]); }); }); diff --git a/tests/entities/assetAgentActions.test.ts b/tests/entities/assetAgentActions.test.ts new file mode 100644 index 00000000..ed806be4 --- /dev/null +++ b/tests/entities/assetAgentActions.test.ts @@ -0,0 +1,70 @@ +import { gql } from '@apollo/client/core'; +import { eveDid } from '../consts'; +import { getApolloClient } from '../util'; +const { query } = getApolloClient(); + +const ticker = '12TICKER'; + +/** + * The four cases below only vary the filter clause, whether `orderBy` is set and whether + * `totalCount` is selected — this runs the query and the (identical across all four) error/shape + * assertions once, so SonarCloud stops matching the same four-way-repeated query body as + * duplication. + */ +const runActionsQuery = async ( + extraFilter: string, + { orderBy = false, totalCount = false }: { orderBy?: boolean; totalCount?: boolean } = {} +) => { + const q = { + variables: { ticker }, + query: gql` + query q($ticker: String!) { + assetAgentActions( + filter: { assetId: { equalTo: $ticker }${extraFilter} } + ${orderBy ? 'orderBy: ID_ASC' : ''} + ) { + ${totalCount ? 'totalCount' : ''} + nodes { + palletName + eventId + callerId + } + } + } + `, + }; + + const subquery = await query(q); + + expect(subquery?.errors).toBeFalsy(); + + return subquery; +}; + +describe('assetAgentActions', () => { + it('should return the transactions for ticker', async () => { + const subquery = await runActionsQuery('', { orderBy: true, totalCount: true }); + + expect(subquery?.data).toMatchSnapshot(); + }); + + it('should filter by event id', async () => { + const subquery = await runActionsQuery(', eventId: { equalTo: FundraiserFrozen }'); + + expect(subquery?.data).toMatchSnapshot(); + }); + + it('should filter by pallet name', async () => { + const subquery = await runActionsQuery(', palletName: { equalTo: "compliancemanager" }'); + + expect(subquery?.data).toMatchSnapshot(); + }); + + it('should filter by caller DID', async () => { + const subquery = await runActionsQuery(`, callerId: { equalTo: "${eveDid}" }`, { + orderBy: true, + }); + + expect(subquery?.data).toMatchSnapshot(); + }); +}); diff --git a/tests/entities/tickerExternalAgentHistory.test.ts b/tests/entities/assetAgentHistory.test.ts similarity index 82% rename from tests/entities/tickerExternalAgentHistory.test.ts rename to tests/entities/assetAgentHistory.test.ts index 2644e584..a9adaeed 100644 --- a/tests/entities/tickerExternalAgentHistory.test.ts +++ b/tests/entities/assetAgentHistory.test.ts @@ -2,13 +2,13 @@ import { gql } from '@apollo/client/core'; import { getApolloClient } from '../util'; const { query } = getApolloClient(); -describe('tickerExternalAgentHistory', () => { +describe('assetAgentHistory', () => { it('should return history of each external agent in ticker', async () => { const q = { variables: { ticker: '12TICKER' }, query: gql` query q($ticker: String!) { - tickerExternalAgentHistories(filter: { assetId: { equalTo: $ticker } }, orderBy: ID_ASC) { + assetAgentHistories(filter: { assetId: { equalTo: $ticker } }, orderBy: ID_ASC) { nodes { ticker: assetId did: identityId diff --git a/tests/entities/assets.test.ts b/tests/entities/assets.test.ts index da405ae5..f1fd106b 100644 --- a/tests/entities/assets.test.ts +++ b/tests/entities/assets.test.ts @@ -24,13 +24,6 @@ describe('assets', () => { totalSupply totalTransfers isCompliancePaused - transferManagers(orderBy: ID_ASC) { - nodes { - type - value - exemptedEntities - } - } compliance(orderBy: ID_ASC) { nodes { id: complianceId diff --git a/tests/entities/externalAgentActions.test.ts b/tests/entities/externalAgentActions.test.ts deleted file mode 100644 index 4cc274d7..00000000 --- a/tests/entities/externalAgentActions.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { gql } from '@apollo/client/core'; -import { eveDid } from '../consts'; -import { getApolloClient } from '../util'; -const { query } = getApolloClient(); - -const ticker = '12TICKER'; - -describe('tickerExternalAgentActions', () => { - it('should return the transactions for ticker', async () => { - const q = { - variables: { ticker }, - query: gql` - query q($ticker: String!) { - tickerExternalAgentActions(filter: { assetId: { equalTo: $ticker } }, orderBy: ID_ASC) { - totalCount - nodes { - palletName - eventId - callerId - } - } - } - `, - }; - const subquery = await query(q); - - expect(subquery?.errors).toBeFalsy(); - expect(subquery?.data).toMatchSnapshot(); - }); - - it('should filter by event id', async () => { - const q = { - variables: { ticker }, - query: gql` - query q($ticker: String!) { - tickerExternalAgentActions( - filter: { assetId: { equalTo: $ticker }, eventId: { equalTo: FundraiserFrozen } } - ) { - nodes { - palletName - eventId - callerId - } - } - } - `, - }; - const subquery = await query(q); - - expect(subquery?.errors).toBeFalsy(); - expect(subquery?.data).toMatchSnapshot(); - }); - - it('should filter by pallet name', async () => { - const q = { - variables: { ticker }, - query: gql` - query q($ticker: String!) { - tickerExternalAgentActions( - filter: { assetId: { equalTo: $ticker }, palletName: { equalTo: "compliancemanager" } } - ) { - nodes { - palletName - eventId - callerId - } - } - } - `, - }; - - const subquery = await query(q); - - expect(subquery?.errors).toBeFalsy(); - expect(subquery?.data).toMatchSnapshot(); - }); - - it('should filter by caller DID', async () => { - const q = { - variables: { ticker }, - query: gql` - query q($ticker: String!) { - tickerExternalAgentActions( - filter: { - assetId: { equalTo: $ticker } - callerId: { - equalTo: "${eveDid}" - } - } - orderBy: ID_ASC - ) { - nodes { - palletName - eventId - callerId - } - } - } - `, - }; - const subquery = await query(q); - - expect(subquery?.errors).toBeFalsy(); - expect(subquery?.data).toMatchSnapshot(); - }); -}); diff --git a/tests/unit/mapCheckpoint.test.ts b/tests/unit/mapCheckpoint.test.ts new file mode 100644 index 00000000..ae134256 --- /dev/null +++ b/tests/unit/mapCheckpoint.test.ts @@ -0,0 +1,177 @@ +import { ArityMismatch, decodeEvent } from '../../src/decode'; +import { + handleCheckpointCreated, + handleScheduleCreated, + handleScheduleRemoved, + parseSchedule, +} from '../../src/mappings/entities/assets/mapCheckpoint'; +import { getAssetId } from '../../src/utils'; +import { codec, mockStore, tupleEvent } from './helpers'; + +const PRE_V6_SPEC = 5_004_003; +const V6_SPEC = 6_000_001; +// Already `0x`-prefixed so `getAssetId`'s legacy-ticker path (pre-v7) and the direct passthrough +// (v7+) agree on the same value — a bare identifier would only survive the passthrough branch, +// since the legacy path hex-encodes a plain *string* but takes a Codec's `.toString()` verbatim. +const RAW_ASSET_ID = '0xabc123def456'; + +const assetIdAt = (specVersion: number) => getAssetId(RAW_ASSET_ID, { specVersion } as never); + +describe('handleCheckpointCreated', () => { + it('creates a Checkpoint from the totalSupply and moment params', async () => { + const db = mockStore(); + const assetId = await assetIdAt(8_000_000); + + await handleCheckpointCreated( + tupleEvent({ + section: 'checkpoint', + method: 'CheckpointCreated', + data: [ + codec(TEST_DID), + codec(RAW_ASSET_ID), + codec(5), + codec(1_000_000), + codec(1_700_000_000_000), + ], + }) + ); + + expect(db.Checkpoint[`${assetId}/5`]).toMatchObject({ + assetId, + checkpointId: 5, + totalSupply: BigInt(1_000_000), + datetime: new Date(1_700_000_000_000), + }); + }); +}); + +describe('parseSchedule', () => { + const preV6Event = tupleEvent({ + section: 'checkpoint', + method: 'ScheduleCreated', + data: [ + codec(TEST_DID), + codec(RAW_ASSET_ID), + codec({ + schedule: { start: 1_000, period: { unit: 'Month', amount: 1 } }, + id: 7, + at: 2_000, + remaining: 4, + }), + ], + specVersion: PRE_V6_SPEC, + }); + + const v6Event = tupleEvent({ + section: 'checkpoint', + method: 'ScheduleCreated', + data: [codec(TEST_DID), codec(RAW_ASSET_ID), codec(9), codec({ pending: [3_000, 4_000] })], + specVersion: V6_SPEC, + }); + + it('recovers the scheduleId from the nested StoredSchedule.id pre-v6 and leaves pendingCheckpoints null', () => { + const parsed = parseSchedule(decodeEvent(preV6Event), PRE_V6_SPEC); + + expect(parsed.scheduleId).toBe(7); + expect(parsed.remaining).toBe(4); + expect(parsed.nextCheckpointAt).toEqual(new Date(2_000)); + expect(parsed.pendingCheckpoints).toBeUndefined(); + }); + + it('reads scheduleId from params[2] at v6+ and leaves the period fields null', () => { + const parsed = parseSchedule(decodeEvent(v6Event), V6_SPEC); + + expect(parsed.scheduleId).toBe(9); + expect(parsed.pendingCheckpoints).toEqual([new Date(3_000), new Date(4_000)]); + expect(parsed.period).toBeUndefined(); + expect(parsed.start).toBeUndefined(); + }); + + it('throws ArityMismatch when a v6+ block reports the pre-v6 3-param arity', () => { + const threeParamAtV6 = tupleEvent({ + section: 'checkpoint', + method: 'ScheduleCreated', + data: [ + codec(TEST_DID), + codec(RAW_ASSET_ID), + codec({ schedule: {}, id: 1, at: 1, remaining: 1 }), + ], + specVersion: V6_SPEC, + }); + + expect(() => decodeEvent(threeParamAtV6)).toThrow(ArityMismatch); + }); + + it('throws ArityMismatch when a pre-v6 block reports the v6+ 4-param arity', () => { + const fourParamPreV6 = tupleEvent({ + section: 'checkpoint', + method: 'ScheduleCreated', + data: [codec(TEST_DID), codec(RAW_ASSET_ID), codec(1), codec({ pending: [] })], + specVersion: PRE_V6_SPEC, + }); + + expect(() => decodeEvent(fourParamPreV6)).toThrow(ArityMismatch); + }); +}); + +describe('handleScheduleCreated / handleScheduleRemoved', () => { + it('creates a pre-v6 schedule with period/remaining populated and pendingCheckpoints null', async () => { + const db = mockStore(); + const assetId = await assetIdAt(PRE_V6_SPEC); + + await handleScheduleCreated( + tupleEvent({ + section: 'checkpoint', + method: 'ScheduleCreated', + data: [ + codec(TEST_DID), + codec(RAW_ASSET_ID), + codec({ + schedule: { start: 1_000, period: { unit: 'Month', amount: 1 } }, + id: 7, + at: 2_000, + remaining: 4, + }), + ], + specVersion: PRE_V6_SPEC, + }) + ); + + const schedule = db.CheckpointSchedule[`${assetId}/7`]; + + expect(schedule).toMatchObject({ assetId, scheduleId: 7, remaining: 4 }); + expect(schedule.pendingCheckpoints).toBeUndefined(); + }); + + it('creates a v6+ schedule with pendingCheckpoints populated and period null, then removes it', async () => { + const db = mockStore(); + const assetId = await assetIdAt(V6_SPEC); + + await handleScheduleCreated( + tupleEvent({ + section: 'checkpoint', + method: 'ScheduleCreated', + data: [codec(TEST_DID), codec(RAW_ASSET_ID), codec(9), codec({ pending: [3_000] })], + specVersion: V6_SPEC, + }) + ); + + const schedule = db.CheckpointSchedule[`${assetId}/9`]; + + expect(schedule.pendingCheckpoints).toEqual([new Date(3_000)]); + expect(schedule.period).toBeUndefined(); + expect(schedule.removedEventId).toBeUndefined(); + + await handleScheduleRemoved( + tupleEvent({ + section: 'checkpoint', + method: 'ScheduleRemoved', + data: [codec(TEST_DID), codec(RAW_ASSET_ID), codec(9), codec({ pending: [3_000] })], + specVersion: V6_SPEC, + idx: 1, + }) + ); + + expect(db.CheckpointSchedule[`${assetId}/9`].removedEventId).toBeDefined(); + }); +}); diff --git a/tests/unit/mapCorporateAction.test.ts b/tests/unit/mapCorporateAction.test.ts new file mode 100644 index 00000000..0f604048 --- /dev/null +++ b/tests/unit/mapCorporateAction.test.ts @@ -0,0 +1,205 @@ +import { + handleCaInitiated, + handleCaLinkedToDoc, + handleCaRemoved, + handleDefaultTargetIdentitiesChanged, + handleDefaultWithholdingTaxChanged, + handleDidWithholdingTaxChanged, + handleRecordDateChanged, +} from '../../src/mappings/entities/assets/mapCorporateAction'; +import { codec, mockStore, tupleEvent } from './helpers'; + +const ASSET_ID = '0xasset000000000000000000000000'; +const DID_A = TEST_DID; +const DID_B = '0x0200000000000000000000000000000000000000000000000000000000000000'; + +const caIdCodec = (localId = 0) => codec({ assetId: ASSET_ID, local_id: localId }); + +const corporateActionCodec = (overrides: Record = {}) => + codec({ + kind: 'IssuerNotice', + declDate: 1_700_000_000_000, + recordDate: null, + targets: { identities: [], treatment: 'Exclude' }, + defaultWithholdingTax: 100_000, + withholdingTax: [], + ...overrides, + }); + +describe('handleCaInitiated', () => { + it('creates a CorporateAction with an Exclude treatment and a per-DID withholding tax override', async () => { + const db = mockStore(); + + const event = tupleEvent({ + section: 'corporateAction', + method: 'CAInitiated', + data: [ + codec(DID_A), + caIdCodec(0), + corporateActionCodec({ + targets: { identities: [DID_B], treatment: 'Exclude' }, + withholdingTax: [[DID_B, 50_000]], + }), + codec('Annual dividend'), + ], + }); + + await handleCaInitiated(event); + + const ca = db.CorporateAction[`${ASSET_ID}/0`]; + + expect(ca).toMatchObject({ + assetId: ASSET_ID, + localId: 0, + kind: 'IssuerNotice', + targetTreatment: 'Exclude', + targetIdentities: [DID_B], + defaultWithholdingTax: BigInt(100_000), + didWithholdingTax: [{ did: DID_B, tax: BigInt(50_000) }], + isRemoved: false, + details: 'Annual dividend', + }); + }); + + it('falls back to Other for an unrecognised kind and records an anomaly', async () => { + const db = mockStore(); + + const event = tupleEvent({ + section: 'corporateAction', + method: 'CAInitiated', + data: [ + codec(DID_A), + caIdCodec(1), + corporateActionCodec({ kind: 'SomeFutureKind' }), + codec(''), + ], + }); + + await handleCaInitiated(event); + + expect(db.CorporateAction[`${ASSET_ID}/1`]).toMatchObject({ kind: 'Other' }); + expect(Object.keys(db.IndexerAnomaly ?? {})).toHaveLength(1); + }); +}); + +describe('handleCaRemoved', () => { + it('sets isRemoved without deleting the row', async () => { + const db = mockStore({ + CorporateAction: { [`${ASSET_ID}/0`]: { id: `${ASSET_ID}/0`, isRemoved: false } }, + }); + + const event = tupleEvent({ + section: 'corporateAction', + method: 'CARemoved', + data: [codec(DID_A), caIdCodec(0)], + }); + + await handleCaRemoved(event); + + expect(db.CorporateAction[`${ASSET_ID}/0`].isRemoved).toBe(true); + }); +}); + +describe('handleRecordDateChanged', () => { + it('resolves the record date from the CorporateAction struct', async () => { + const db = mockStore({ + CorporateAction: { [`${ASSET_ID}/0`]: { id: `${ASSET_ID}/0`, recordDate: undefined } }, + }); + + const event = tupleEvent({ + section: 'corporateAction', + method: 'RecordDateChanged', + data: [ + codec(DID_A), + caIdCodec(0), + corporateActionCodec({ recordDate: { date: 1_700_100_000_000 } }), + ], + }); + + await handleRecordDateChanged(event); + + expect(db.CorporateAction[`${ASSET_ID}/0`].recordDate).toEqual(new Date(1_700_100_000_000)); + }); +}); + +describe('handleCaLinkedToDoc', () => { + it('appends document ids without duplicating', async () => { + const db = mockStore({ + CorporateAction: { + [`${ASSET_ID}/0`]: { id: `${ASSET_ID}/0`, documents: ['1'] }, + }, + }); + + const event = tupleEvent({ + section: 'corporateAction', + method: 'CALinkedToDoc', + data: [codec(DID_A), caIdCodec(0), codec([1, 2])], + }); + + await handleCaLinkedToDoc(event); + + expect(db.CorporateAction[`${ASSET_ID}/0`].documents).toEqual(['1', '2']); + }); +}); + +describe('asset-level defaults', () => { + it('handleDefaultTargetIdentitiesChanged creates a config row keyed by asset', async () => { + const db = mockStore(); + + const event = tupleEvent({ + section: 'corporateAction', + method: 'DefaultTargetIdentitiesChanged', + data: [codec(DID_A), codec(ASSET_ID), codec({ identities: [DID_B], treatment: 'Include' })], + }); + + await handleDefaultTargetIdentitiesChanged(event); + + expect(db.CorporateActionDefaultConfig[ASSET_ID]).toMatchObject({ + assetId: ASSET_ID, + targetIdentities: [DID_B], + targetTreatment: 'Include', + }); + }); + + it('handleDefaultWithholdingTaxChanged upserts onto the same config row', async () => { + const db = mockStore(); + + await handleDefaultWithholdingTaxChanged( + tupleEvent({ + section: 'corporateAction', + method: 'DefaultWithholdingTaxChanged', + data: [codec(DID_A), codec(ASSET_ID), codec(75_000)], + }) + ); + + expect(db.CorporateActionDefaultConfig[ASSET_ID]).toMatchObject({ + defaultWithholdingTax: BigInt(75_000), + }); + }); + + it('handleDidWithholdingTaxChanged upserts and then removes a per-DID override', async () => { + const db = mockStore(); + + await handleDidWithholdingTaxChanged( + tupleEvent({ + section: 'corporateAction', + method: 'DidWithholdingTaxChanged', + data: [codec(DID_A), codec(ASSET_ID), codec(DID_B), codec(25_000)], + }) + ); + + expect(db.CorporateActionDefaultConfig[ASSET_ID].didWithholdingTax).toEqual([ + { did: DID_B, tax: BigInt(25_000) }, + ]); + + await handleDidWithholdingTaxChanged( + tupleEvent({ + section: 'corporateAction', + method: 'DidWithholdingTaxChanged', + data: [codec(DID_A), codec(ASSET_ID), codec(DID_B), codec(null, { isEmpty: true })], + }) + ); + + expect(db.CorporateActionDefaultConfig[ASSET_ID].didWithholdingTax).toEqual([]); + }); +}); diff --git a/tests/unit/mapCorporateBallot.test.ts b/tests/unit/mapCorporateBallot.test.ts new file mode 100644 index 00000000..1be87515 --- /dev/null +++ b/tests/unit/mapCorporateBallot.test.ts @@ -0,0 +1,162 @@ +import { + handleBallotCreated, + handleBallotMetaChanged, + handleBallotRangeChanged, + handleBallotRcvChanged, + handleBallotRemoved, + handleBallotVoteCast, +} from '../../src/mappings/entities/assets/mapCorporateBallot'; +import { codec, mockStore, tupleEvent } from './helpers'; + +const ASSET_ID = '0xassetballot00000000000000000000'; +const DID_A = TEST_DID; +const caIdCodec = (localId = 0) => codec({ assetId: ASSET_ID, local_id: localId }); +const ballotId = `${ASSET_ID}/0`; + +const seedBallot = () => + mockStore({ + CorporateBallot: { + [ballotId]: { + id: ballotId, + assetId: ASSET_ID, + startDate: new Date(1_000), + endDate: new Date(2_000), + meta: JSON.stringify({ title: 'Old', motions: [] }), + rcv: false, + isRemoved: false, + }, + }, + }); + +describe('handleBallotCreated', () => { + it('creates a CorporateBallot from the range, meta and rcv params', async () => { + const db = mockStore(); + + await handleBallotCreated( + tupleEvent({ + section: 'corporateBallot', + method: 'Created', + data: [ + codec(DID_A), + caIdCodec(0), + codec({ start: 1_000, end: 2_000 }), + codec({ title: 'Annual Meeting', motions: [] }), + codec(true), + ], + }) + ); + + const ballot = db.CorporateBallot[ballotId]; + + expect(ballot).toMatchObject({ + assetId: ASSET_ID, + corporateActionId: ballotId, + startDate: new Date(1_000), + endDate: new Date(2_000), + rcv: true, + isRemoved: false, + }); + expect(JSON.parse(ballot.meta)).toMatchObject({ title: 'Annual Meeting' }); + }); +}); + +describe('handleBallotMetaChanged', () => { + it('overwrites meta', async () => { + const db = seedBallot(); + + await handleBallotMetaChanged( + tupleEvent({ + section: 'corporateBallot', + method: 'MetaChanged', + data: [codec(DID_A), caIdCodec(0), codec({ title: 'Updated', motions: [] })], + }) + ); + + expect(JSON.parse(db.CorporateBallot[ballotId].meta)).toMatchObject({ title: 'Updated' }); + }); +}); + +describe('handleBallotRangeChanged', () => { + it('overwrites startDate/endDate', async () => { + const db = seedBallot(); + + await handleBallotRangeChanged( + tupleEvent({ + section: 'corporateBallot', + method: 'RangeChanged', + data: [codec(DID_A), caIdCodec(0), codec({ start: 5_000, end: 6_000 })], + }) + ); + + expect(db.CorporateBallot[ballotId]).toMatchObject({ + startDate: new Date(5_000), + endDate: new Date(6_000), + }); + }); +}); + +describe('handleBallotRcvChanged', () => { + it('overwrites rcv', async () => { + const db = seedBallot(); + + await handleBallotRcvChanged( + tupleEvent({ + section: 'corporateBallot', + method: 'RCVChanged', + data: [codec(DID_A), caIdCodec(0), codec(true)], + }) + ); + + expect(db.CorporateBallot[ballotId].rcv).toBe(true); + }); +}); + +describe('handleBallotRemoved', () => { + it('sets isRemoved without deleting the row', async () => { + const db = seedBallot(); + + await handleBallotRemoved( + tupleEvent({ + section: 'corporateBallot', + method: 'Removed', + data: [codec(DID_A), caIdCodec(0)], + }) + ); + + expect(db.CorporateBallot[ballotId].isRemoved).toBe(true); + }); +}); + +describe('handleBallotVoteCast', () => { + it('writes per-motion vote weights in motion order', async () => { + const db = mockStore(); + + await handleBallotVoteCast( + tupleEvent({ + section: 'corporateBallot', + method: 'VoteCast', + data: [ + codec(DID_A), + caIdCodec(0), + codec([ + { power: 100, fallback: null }, + { power: 50, fallback: 2 }, + ]), + ], + blockNumber: '42', + idx: 3, + }) + ); + + const vote = Object.values(db.CorporateBallotVote)[0] as any; + + expect(vote).toMatchObject({ + ballotId, + voterId: DID_A, + votes: [ + { power: BigInt(100), fallback: undefined }, + { power: BigInt(50), fallback: 2 }, + ], + }); + }); +}); diff --git a/tests/unit/mapExternalAgent.test.ts b/tests/unit/mapExternalAgent.test.ts new file mode 100644 index 00000000..57891212 --- /dev/null +++ b/tests/unit/mapExternalAgent.test.ts @@ -0,0 +1,117 @@ +import { mapExternalAgentAction } from '../../src/mappings/entities/externalAgents/mapExternalAgentAction'; +import { + handleExternalAgentAdded, + handleExternalAgentRemoved, +} from '../../src/mappings/entities/externalAgents/mapExternalAgent'; +import { + handleAgentAdded, + handleAgentRemoved, + handleGroupChanged, + handleGroupCreated, +} from '../../src/mappings/entities/externalAgents/mapExternalAgentHistory'; +import { codec, mockStore, tupleEvent } from './helpers'; + +const ASSET_ID = '0xassetagent0000000000000000000000'; +const DID_A = TEST_DID; + +const getByFields = (): jest.Mock => (globalThis as any).store.getByFields as jest.Mock; + +describe('AgentAdded — one event, three tables', () => { + it('produces exactly one AssetAgent, one AssetAgentHistory, and one AssetAgentAction row', async () => { + const db = mockStore(); + getByFields().mockResolvedValue([]); + + const event = tupleEvent({ + section: 'externalAgents', + method: 'AgentAdded', + data: [codec(DID_A), codec(ASSET_ID), codec({ full: null })], + }); + + // `AgentAdded` is registered against all three handlers in project.ts, driven from one event. + await handleExternalAgentAdded(event); + await handleAgentAdded(event); + await mapExternalAgentAction(event); + + expect(Object.keys(db.AssetAgent ?? {})).toHaveLength(1); + expect(Object.keys(db.AssetAgentHistory ?? {})).toHaveLength(1); + expect(Object.keys(db.AssetAgentAction ?? {})).toHaveLength(1); + + const [agent] = Object.values(db.AssetAgent) as any[]; + const [history] = Object.values(db.AssetAgentHistory) as any[]; + + expect(agent).toMatchObject({ assetId: ASSET_ID, identityId: DID_A }); + expect(history).toMatchObject({ + assetId: ASSET_ID, + identityId: DID_A, + type: 'AgentAdded', + }); + }); +}); + +describe('AgentGroup', () => { + it('is queryable by assetId with no id string-parsing', async () => { + const db = mockStore(); + + await handleGroupCreated( + tupleEvent({ + section: 'externalAgents', + method: 'GroupCreated', + data: [codec(DID_A), codec(ASSET_ID), codec(1), codec({ these: [] })], + }) + ); + + const [group] = Object.values(db.AgentGroup) as any[]; + + expect(group.assetId).toBe(ASSET_ID); + }); +}); + +describe('membership lifecycle', () => { + it('added → group changed → removed produces three AssetAgentHistory rows with the right types', async () => { + const db = mockStore(); + getByFields().mockResolvedValue([]); + + await handleAgentAdded( + tupleEvent({ + section: 'externalAgents', + method: 'AgentAdded', + data: [codec(DID_A), codec(ASSET_ID), codec({ full: null })], + idx: 0, + }) + ); + + await handleGroupChanged( + tupleEvent({ + section: 'externalAgents', + method: 'GroupChanged', + data: [codec(DID_A), codec(ASSET_ID), codec(DID_A), codec({ exceptMeta: null })], + idx: 1, + }) + ); + + // project.ts registers both handlers on `AgentRemoved`: one drops the current-membership + // row, the other appends the history row. + await handleExternalAgentRemoved( + tupleEvent({ + section: 'externalAgents', + method: 'AgentRemoved', + data: [codec(DID_A), codec(ASSET_ID), codec(DID_A)], + idx: 2, + }) + ); + await handleAgentRemoved( + tupleEvent({ + section: 'externalAgents', + method: 'AgentRemoved', + data: [codec(DID_A), codec(ASSET_ID), codec(DID_A)], + idx: 2, + }) + ); + + const types = Object.values(db.AssetAgentHistory) + .map((row: any) => row.type) + .sort(); + + expect(types).toEqual(['AgentAdded', 'AgentRemoved', 'GroupChanged'].sort()); + }); +}); diff --git a/tests/unit/mapStatisticsTransferManager.test.ts b/tests/unit/mapStatisticsTransferManager.test.ts new file mode 100644 index 00000000..e3357de3 --- /dev/null +++ b/tests/unit/mapStatisticsTransferManager.test.ts @@ -0,0 +1,47 @@ +/** + * Regression test for retiring the `TransferManager` dual model (docs/implementation/08-external-agents.md + * §8.2): `TransferManager` and its writer (`mapTransferManager.ts`) are gone, but the + * `statistics.TransferManagerAdded` / `ExemptionsAdded` / `ExemptionsRemoved` events still feed + * `StatType` / `TransferComplianceExemption` for the pre-v5 percentage/count restriction model via + * `handleStatisticTransferManagerAdded` and friends — this covers that the surviving handler still + * works with `TransferManager` gone. + */ +import { handleStatisticTransferManagerAdded } from '../../src/mappings/entities/assets/mapStatistics'; +import { codec, mockStore, tupleEvent } from './helpers'; + +const ASSET_ID = '0xassetstattm00000000000000000000000'; + +describe('handleStatisticTransferManagerAdded', () => { + it('writes a Balance StatType for a Percentage restriction — no TransferManager entity exists to write', async () => { + const db = mockStore(); + + await handleStatisticTransferManagerAdded( + tupleEvent({ + section: 'statistics', + method: 'TransferManagerAdded', + data: [codec(TEST_DID), codec(ASSET_ID), codec({ percentageTransferManager: 10 })], + }) + ); + + const [statType] = Object.values(db.StatType) as any[]; + + expect(statType).toMatchObject({ assetId: ASSET_ID, opType: 'Balance' }); + // `TransferManager` was removed from the schema — a stray write would fail codegen/typecheck + // long before this test runs; this just documents the invariant. + expect(db.TransferManager).toBeUndefined(); + }); + + it('writes nothing for a Count restriction', async () => { + const db = mockStore(); + + await handleStatisticTransferManagerAdded( + tupleEvent({ + section: 'statistics', + method: 'TransferManagerAdded', + data: [codec(TEST_DID), codec(ASSET_ID), codec({ countTransferManager: 5 })], + }) + ); + + expect(db.StatType ?? {}).toEqual({}); + }); +}); diff --git a/tests/unit/mapSubsidy.test.ts b/tests/unit/mapSubsidy.test.ts new file mode 100644 index 00000000..77cd37e6 --- /dev/null +++ b/tests/unit/mapSubsidy.test.ts @@ -0,0 +1,154 @@ +import { decodeEvent } from '../../src/decode'; +import { + handlePolyxLimitUpdated, + handleSubsidyAccepted, + handleSubsidyApproved, + handleSubsidyDebited, + handleSubsidyRemoved, +} from '../../src/mappings/entities/relayer/mapSubsidy'; +import { codec, mockStore, tupleEvent } from './helpers'; + +const USER_KEY = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'; +const PAYING_KEY = '5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty'; +const SUBSIDY_ID = `${USER_KEY}/${PAYING_KEY}`; + +/** `getOrCreateAccount` short-circuits on an existing row, so seeding both keys skips the chain read */ +const seedAccounts = () => ({ + Account: { + [USER_KEY]: { id: USER_KEY }, + [PAYING_KEY]: { id: PAYING_KEY }, + }, +}); + +describe('v8 subsidy lifecycle', () => { + it('approve → accept → debit → remove produces one row through the full lifecycle', async () => { + const db = mockStore(seedAccounts()); + + await handleSubsidyApproved( + tupleEvent({ + section: 'relayer', + method: 'ApprovedSubsidy', + data: [codec(USER_KEY), codec(PAYING_KEY), codec(1_000)], + }) + ); + + expect(db.Subsidy[SUBSIDY_ID]).toMatchObject({ + beneficiaryAccountId: USER_KEY, + payingAccountId: PAYING_KEY, + allowance: BigInt(1_000), + totalDebited: BigInt(0), + isAccepted: false, + isRemoved: false, + }); + + await handleSubsidyAccepted( + tupleEvent({ + section: 'relayer', + method: 'AcceptedSubsidy', + data: [codec(USER_KEY), codec(PAYING_KEY), codec(1_000)], + idx: 1, + }) + ); + + expect(db.Subsidy[SUBSIDY_ID].isAccepted).toBe(true); + + await handleSubsidyDebited( + tupleEvent({ + section: 'relayer', + method: 'SubsidyDebited', + data: [codec(USER_KEY), codec(PAYING_KEY), codec(300)], + idx: 2, + }) + ); + + expect(db.Subsidy[SUBSIDY_ID]).toMatchObject({ + totalDebited: BigInt(300), + allowance: BigInt(700), + }); + + await handleSubsidyRemoved( + tupleEvent({ + section: 'relayer', + method: 'RemovedSubsidy', + data: [codec(USER_KEY), codec(PAYING_KEY), codec(700)], + idx: 3, + }) + ); + + expect(db.Subsidy[SUBSIDY_ID].isRemoved).toBe(true); + }); +}); + +describe('pre-v8 paying-key lifecycle', () => { + const PRE_V8_SPEC = 6_000_000; + + it('authorize → accept → remove produces the equivalent row, reading initialPolyxLimit from params[3]', async () => { + const db = mockStore(seedAccounts()); + + await handleSubsidyApproved( + tupleEvent({ + section: 'relayer', + method: 'AuthorizedPayingKey', + data: [codec(TEST_DID), codec(USER_KEY), codec(PAYING_KEY), codec(500), codec(9)], + specVersion: PRE_V8_SPEC, + }) + ); + + expect(db.Subsidy[SUBSIDY_ID]).toMatchObject({ allowance: BigInt(500), isAccepted: false }); + + await handleSubsidyAccepted( + tupleEvent({ + section: 'relayer', + method: 'AcceptedPayingKey', + data: [codec(TEST_DID), codec(USER_KEY), codec(PAYING_KEY)], + specVersion: PRE_V8_SPEC, + idx: 1, + }) + ); + + // pre-v8 `AcceptedPayingKey` carries no limit — the allowance from the approval stands + expect(db.Subsidy[SUBSIDY_ID]).toMatchObject({ isAccepted: true, allowance: BigInt(500) }); + + await handleSubsidyRemoved( + tupleEvent({ + section: 'relayer', + method: 'RemovedPayingKey', + data: [codec(TEST_DID), codec(USER_KEY), codec(PAYING_KEY)], + specVersion: PRE_V8_SPEC, + idx: 2, + }) + ); + + expect(db.Subsidy[SUBSIDY_ID].isRemoved).toBe(true); + }); +}); + +describe('UpdatedPolyxLimit', () => { + it('decodes 5 params pre-v8 and 4 params at v8+ with no arity error, setting allowance either way', async () => { + const db = mockStore(seedAccounts()); + db.Subsidy = { [SUBSIDY_ID]: { id: SUBSIDY_ID, allowance: BigInt(0) } }; + + const preV8Event = tupleEvent({ + section: 'relayer', + method: 'UpdatedPolyxLimit', + data: [codec(TEST_DID), codec(USER_KEY), codec(PAYING_KEY), codec(200), codec(500)], + specVersion: 6_000_000, + }); + + expect(() => decodeEvent(preV8Event)).not.toThrow(); + await handlePolyxLimitUpdated(preV8Event); + expect(db.Subsidy[SUBSIDY_ID].allowance).toBe(BigInt(200)); + + const v8Event = tupleEvent({ + section: 'relayer', + method: 'UpdatedPolyxLimit', + data: [codec(USER_KEY), codec(PAYING_KEY), codec(900), codec(200)], + specVersion: 8_000_000, + idx: 1, + }); + + expect(() => decodeEvent(v8Event)).not.toThrow(); + await handlePolyxLimitUpdated(v8Event); + expect(db.Subsidy[SUBSIDY_ID].allowance).toBe(BigInt(900)); + }); +});