-
Notifications
You must be signed in to change notification settings - Fork 0
[parsers] Add CIP-56 Token Standard V1 result parsers #410
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
cffa3da
d110ea8
2993621
2d8fa60
88c0ea0
31cae46
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import { CantonError, type ErrorContext } from '../../core/errors'; | ||
| import { isRecord, isString } from '../../core/utils'; | ||
|
|
||
| export interface ParsedTemplateId { | ||
|
|
@@ -131,6 +132,33 @@ export function hasTemplateName(templateId: string, expectedTemplateName: string | |
| } | ||
| } | ||
|
|
||
| /** The `Module:Template` part of a template id, dropping the leading package id or `#package-name`. */ | ||
| export function qualifiedTemplateName(templateId: string): string { | ||
| const firstColon = templateId.indexOf(':'); | ||
| return firstColon === -1 ? templateId : templateId.slice(firstColon + 1); | ||
| } | ||
|
|
||
| /** | ||
| * Match a template id from a ledger event against a filter, ignoring the package component. | ||
| * | ||
| * A create event always names the package _id_ that produced it, which a caller cannot know in advance; a filter is | ||
| * usually written with a package _name_ (`#MyPackage:Module:Template`) or without a package at all (`Module:Template`, | ||
| * or just `Template`). All three forms match here. | ||
| */ | ||
| export function matchesTemplateId(templateId: string, filter: string): boolean { | ||
| if (templateId === filter) return true; | ||
|
|
||
| const idSuffix = templateId.split(':').slice(1); | ||
| if (idSuffix.length === 0) return false; | ||
|
|
||
| const filterParts = filter.split(':'); | ||
| const filterSuffix = filterParts.length > idSuffix.length ? filterParts.slice(1) : filterParts; | ||
| if (filterSuffix.length === 0 || filterSuffix.length > idSuffix.length) return false; | ||
|
|
||
| const offset = idSuffix.length - filterSuffix.length; | ||
| return filterSuffix.every((part, index) => part === idSuffix[offset + index]); | ||
| } | ||
|
|
||
| export function parseCreatedEvent(event: unknown): ParsedCreatedEvent | null { | ||
| const value = unwrapVariant(event, 'created'); | ||
| if (!value) return null; | ||
|
|
@@ -275,13 +303,15 @@ function getEventsById(transaction: unknown): Readonly<Record<string, unknown>> | |
|
|
||
| for (const path of paths) { | ||
| const eventsById = getNestedRecord(transaction, path); | ||
| if (eventsById) return eventsById; | ||
| if (eventsById && Object.keys(eventsById).length > 0) return eventsById; | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| function getEventArray(transaction: unknown): readonly unknown[] | null { | ||
| if (Array.isArray(transaction)) return transaction; | ||
|
|
||
| const paths: ReadonlyArray<readonly string[]> = [ | ||
| ['events'], | ||
| ['transactionTree', 'events'], | ||
|
|
@@ -296,11 +326,30 @@ function getEventArray(transaction: unknown): readonly unknown[] | null { | |
| return null; | ||
| } | ||
|
|
||
| export function extractEventsFromTransaction(transaction: unknown): ParsedTransactionEvents { | ||
| /** Node ids are numeric strings, but a map preserves whatever order it was built with. */ | ||
| function compareNodeIds(left: string, right: string): number { | ||
| const leftNumber = Number(left); | ||
| const rightNumber = Number(right); | ||
| if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) return leftNumber - rightNumber; | ||
| return left < right ? -1 : left > right ? 1 : 0; | ||
| } | ||
|
|
||
| /** | ||
| * The raw events of a transaction in node-id order, from whichever shape the response used: a tree keyed by node id, or | ||
| * a flat event array, wrapped in `transactionTree`, in `transaction`, or on its own. | ||
| */ | ||
| export function getTransactionEvents(transaction: unknown): readonly unknown[] { | ||
| const eventsById = getEventsById(transaction); | ||
| const events = eventsById ? Object.values(eventsById) : (getEventArray(transaction) ?? []); | ||
| if (eventsById) { | ||
| return Object.keys(eventsById) | ||
| .sort(compareNodeIds) | ||
| .map((key) => eventsById[key]); | ||
| } | ||
| return getEventArray(transaction) ?? []; | ||
| } | ||
|
|
||
| return events.reduce<ParsedTransactionEvents>( | ||
| export function extractEventsFromTransaction(transaction: unknown): ParsedTransactionEvents { | ||
| return getTransactionEvents(transaction).reduce<ParsedTransactionEvents>( | ||
| (acc, event) => { | ||
| const created = parseCreatedEvent(event); | ||
| if (created) acc.created.push(created); | ||
|
|
@@ -316,3 +365,101 @@ export function extractEventsFromTransaction(transaction: unknown): ParsedTransa | |
| { created: [], archived: [], exercised: [] } | ||
| ); | ||
| } | ||
|
|
||
| export const TransactionParseErrorCode = { | ||
| EXERCISE_RESULT_NOT_FOUND: 'TRANSACTION_EXERCISE_RESULT_NOT_FOUND', | ||
| UPDATE_ID_NOT_FOUND: 'TRANSACTION_UPDATE_ID_NOT_FOUND', | ||
| } as const; | ||
|
|
||
| export type TransactionParseErrorCode = (typeof TransactionParseErrorCode)[keyof typeof TransactionParseErrorCode]; | ||
|
|
||
| /** Thrown when a transaction response does not contain something the caller asserted it would. */ | ||
| export class TransactionParseError extends CantonError { | ||
| public override readonly name: string; | ||
|
|
||
| public constructor(code: TransactionParseErrorCode, message: string, context?: ErrorContext) { | ||
| super(message, code, context); | ||
| this.name = 'TransactionParseError'; | ||
| } | ||
| } | ||
|
|
||
| /** The update id of a transaction response, or `undefined` when it names none. */ | ||
| export function getTransactionUpdateId(transaction: unknown): string | undefined { | ||
| const paths: ReadonlyArray<readonly string[]> = [ | ||
| ['updateId'], | ||
| ['transactionTree', 'updateId'], | ||
| ['transaction', 'updateId'], | ||
| ]; | ||
|
|
||
| for (const path of paths) { | ||
| let current: unknown = transaction; | ||
| for (const segment of path) { | ||
| if (!isRecord(current)) { | ||
| current = undefined; | ||
| break; | ||
| } | ||
| current = current[segment]; | ||
| } | ||
| if (isString(current) && current.length > 0) return current; | ||
| } | ||
|
|
||
| return undefined; | ||
| } | ||
|
|
||
| /** The update id, for a caller that needs to correlate a submission with what the ledger did. */ | ||
| export function requireTransactionUpdateId(transaction: unknown): string { | ||
| const updateId = getTransactionUpdateId(transaction); | ||
| if (updateId === undefined) { | ||
| throw new TransactionParseError( | ||
| TransactionParseErrorCode.UPDATE_ID_NOT_FOUND, | ||
| 'The transaction names no update id.' | ||
| ); | ||
| } | ||
| return updateId; | ||
| } | ||
|
|
||
| /** Every exercise of `choice` in the transaction, in node-id order. */ | ||
| export function findExercisedEvents(transaction: unknown, choice: string): ParsedExercisedEvent[] { | ||
| return extractEventsFromTransaction(transaction).exercised.filter((event) => event.choice === choice); | ||
| } | ||
|
|
||
| /** The first exercise of any of `choices`, in node-id order — the order the transaction produced them. */ | ||
| export function findExercisedEvent( | ||
| transaction: unknown, | ||
| choices: string | readonly string[] | ||
| ): ParsedExercisedEvent | undefined { | ||
| const wanted = typeof choices === 'string' ? [choices] : choices; | ||
| return extractEventsFromTransaction(transaction).exercised.find((event) => wanted.includes(event.choice)); | ||
| } | ||
|
|
||
| /** The return value of the first exercise of `choice`, or `undefined` when the transaction contains none. */ | ||
| export function findExerciseResult(transaction: unknown, choice: string | readonly string[]): unknown { | ||
| return findExercisedEvent(transaction, choice)?.exerciseResult; | ||
| } | ||
|
|
||
| /** | ||
| * The return value of the named choice. A caller asking for it is asserting the transaction contains it, so a | ||
| * transaction without that exercise is reported rather than returned as `undefined`. | ||
| */ | ||
| export function requireExerciseResult(transaction: unknown, choice: string | readonly string[]): unknown { | ||
| const exercised = findExercisedEvent(transaction, choice); | ||
| if (!exercised) { | ||
| const wanted = typeof choice === 'string' ? choice : choice.join(', '); | ||
| throw new TransactionParseError( | ||
| TransactionParseErrorCode.EXERCISE_RESULT_NOT_FOUND, | ||
| `The transaction contains no ${wanted} exercise.`, | ||
| { choice: wanted, updateId: getTransactionUpdateId(transaction) } | ||
| ); | ||
| } | ||
| return exercised.exerciseResult; | ||
| } | ||
|
|
||
| /** | ||
| * Contract ids created by the transaction, in node-id order, optionally narrowed to one template. The filter is matched | ||
| * package-agnostically by {@link matchesTemplateId}. | ||
| */ | ||
| export function findCreatedContractIds(transaction: unknown, templateFilter?: string): string[] { | ||
| return extractEventsFromTransaction(transaction) | ||
| .created.filter((created) => templateFilter === undefined || matchesTemplateId(created.templateId, templateFilter)) | ||
| .map((created) => created.contractId); | ||
| } | ||
|
Comment on lines
+421
to
+465
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win Parse each transaction once per parser call. The V1 lookup helpers repeatedly walk and parse the same transaction when chained. Reuse a parsed 📍 Affects 3 files
🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| export * from './v1'; | ||
| export * from './v2'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| /** | ||
| * Reserving units under an allocation, and the three ways an allocation ends. | ||
| * | ||
| * `AllocationFactory_Allocate` returns an `AllocationInstructionResult`, which names the allocation when the registry | ||
| * allocates in one step. `Allocation_ExecuteTransfer`, `_Cancel` and `_Withdraw` all return holdings; the difference | ||
| * between them is who gets them. | ||
| */ | ||
|
|
||
| import { findExercisedEvent, requireTransactionUpdateId } from '../../parsers/event-parser'; | ||
| import { | ||
| TOKEN_STANDARD_V1_ALLOCATION_EXIT_CHOICES, | ||
| TOKEN_STANDARD_V1_ALLOCATION_RESULT_TAGS, | ||
| TokenStandardV1AllocationResultTag, | ||
| TokenStandardV1Choice, | ||
| } from './constants'; | ||
| import { | ||
| readContractIds, | ||
| requireContractIds, | ||
| requireKnownVariant, | ||
| requireNonEmptyString, | ||
| requireResultRecord, | ||
| requireResultRecordOfAny, | ||
| } from './result'; | ||
| import type { TokenStandardV1TransferStatus } from './transfer'; | ||
|
|
||
| export interface TokenStandardV1AllocationResult { | ||
| readonly updateId: string; | ||
| /** A registry that allocates in one step always reports `completed`. */ | ||
| readonly status: TokenStandardV1TransferStatus; | ||
| readonly senderChangeCids: string[]; | ||
| /** The allocation the units are now reserved under. */ | ||
| readonly allocationCid: string | undefined; | ||
| /** The pending instruction, when the allocation did not complete in this transaction. */ | ||
| readonly allocationInstructionCid: string | undefined; | ||
| } | ||
|
|
||
| /** | ||
| * The `AllocationInstructionResult` of `AllocationFactory_Allocate`. | ||
| * | ||
| * As with a transfer, `failed` is reported only for the standard's `_Failed` output; an undefined output tag, or a | ||
| * result naming no output, is reported rather than read as a rejected allocation. | ||
| */ | ||
| export function parseAllocationResult(transaction: unknown): TokenStandardV1AllocationResult { | ||
| const result = requireResultRecord(transaction, TokenStandardV1Choice.allocate); | ||
| const updateId = requireTransactionUpdateId(transaction); | ||
| const senderChangeCids = requireContractIds(result['senderChangeCids'], 'senderChangeCids'); | ||
| const output = requireKnownVariant(result, 'output', TOKEN_STANDARD_V1_ALLOCATION_RESULT_TAGS, 'allocation result'); | ||
|
|
||
| if (output.tag === TokenStandardV1AllocationResultTag.completed) { | ||
| return { | ||
| updateId, | ||
| status: 'completed', | ||
| senderChangeCids, | ||
| allocationCid: requireNonEmptyString(output.value['allocationCid'], 'allocationCid'), | ||
| allocationInstructionCid: undefined, | ||
| }; | ||
| } | ||
|
|
||
| if (output.tag === TokenStandardV1AllocationResultTag.pending) { | ||
| return { | ||
| updateId, | ||
| status: 'pending', | ||
| senderChangeCids, | ||
| allocationCid: undefined, | ||
| allocationInstructionCid: requireNonEmptyString( | ||
| output.value['allocationInstructionCid'], | ||
| 'allocationInstructionCid' | ||
| ), | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| updateId, | ||
| status: 'failed', | ||
| senderChangeCids, | ||
| allocationCid: undefined, | ||
| allocationInstructionCid: undefined, | ||
| }; | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| export interface TokenStandardV1AllocationTransferResult { | ||
| readonly updateId: string; | ||
| /** Units returned to the sender: everything on a cancel or a withdraw, nothing on a delivery. */ | ||
| readonly senderHoldingCids: string[]; | ||
| readonly receiverHoldingCids: string[]; | ||
| } | ||
|
|
||
| /** The result of the allocation exit in this transaction — `Allocation_ExecuteTransfer`, `_Cancel` or `_Withdraw`. */ | ||
| export function parseAllocationTransferResult(transaction: unknown): TokenStandardV1AllocationTransferResult { | ||
| const result = requireResultRecordOfAny(transaction, TOKEN_STANDARD_V1_ALLOCATION_EXIT_CHOICES); | ||
| const exercised = findExercisedEvent(transaction, TOKEN_STANDARD_V1_ALLOCATION_EXIT_CHOICES); | ||
| const executedTransfer = exercised?.choice === TokenStandardV1Choice.allocationExecuteTransfer; | ||
| return { | ||
| updateId: requireTransactionUpdateId(transaction), | ||
| senderHoldingCids: executedTransfer | ||
| ? readContractIds(result['senderHoldingCids'], 'senderHoldingCids') | ||
| : requireContractIds(result['senderHoldingCids'], 'senderHoldingCids'), | ||
| receiverHoldingCids: executedTransfer | ||
| ? requireContractIds(result['receiverHoldingCids'], 'receiverHoldingCids') | ||
| : readContractIds(result['receiverHoldingCids'], 'receiverHoldingCids'), | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| /** | ||
| * Reading a mint or a burn back out of the transaction that performed it. | ||
| * | ||
| * `BurnMintFactory_BurnMint` returns a record rather than contract ids, and the interesting half of a mint is in that | ||
| * record: `outputCids` names the holdings it created. The create events alone cannot say it — a mint of two holdings | ||
| * creates two contracts with nothing to distinguish which output each was — so the exercise result is read instead, and | ||
| * a registry that mints outside the factory is read from the creates only when the caller asks for it. | ||
| */ | ||
|
|
||
| import { | ||
| findExercisedEvent, | ||
| findCreatedContractIds, | ||
| getTransactionUpdateId, | ||
| requireTransactionUpdateId, | ||
| } from '../../parsers/event-parser'; | ||
| import { TokenStandardV1Choice } from './constants'; | ||
| import { TokenStandardV1ResultError, TokenStandardV1ResultErrorCode } from './errors'; | ||
| import { requireContractIds, requireResultRecord } from './result'; | ||
|
|
||
| export interface TokenStandardV1BurnMintResult { | ||
| readonly updateId: string; | ||
| /** `Holding` interface contract ids, one per output, in the order the outputs were given. Empty for a pure burn. */ | ||
| readonly outputHoldingCids: string[]; | ||
| } | ||
|
|
||
| /** | ||
| * The result of the burn-mint in this transaction: a mint's new holdings, or an empty list for a burn. Also finds a | ||
| * burn-mint that was exercised as a child of another choice, which is how a redeem's burn is confirmed. | ||
| */ | ||
| export function parseBurnMintResult(transaction: unknown): TokenStandardV1BurnMintResult { | ||
| const result = requireResultRecord(transaction, TokenStandardV1Choice.burnMint); | ||
| return { | ||
| updateId: requireTransactionUpdateId(transaction), | ||
| outputHoldingCids: requireContractIds(result['outputCids'], 'outputCids'), | ||
| }; | ||
| } | ||
|
|
||
| export interface ParseMintedHoldingsOptions { | ||
| /** | ||
| * Registry-specific choices that mint without going through the burn-mint factory, such as an admin recovery's | ||
| * `BurnHoldingEnforcement`, which returns contract ids of the concrete template rather than a burn-mint result. | ||
| * Required together with `holdingTemplate` to enable the create-event fallback, which then runs only when the | ||
| * transaction exercised one of these. | ||
| */ | ||
| readonly mintingChoices?: readonly string[]; | ||
| /** | ||
| * Template of the concrete holding contract, narrowing the fallback's create events to it. Matched | ||
| * package-agnostically, so `Module:Template`, `Template`, or a fully qualified id all work. Required together with | ||
| * `mintingChoices` to enable the create-event fallback. | ||
| */ | ||
| readonly holdingTemplate?: string; | ||
| } | ||
|
|
||
| /** | ||
| * The holdings a mint created, from `BurnMintFactory_BurnMint`'s `outputCids`. | ||
| * | ||
| * Create events are never read on their own: every contract a transaction happened to create is not the same thing as | ||
| * the outputs of a mint, and answering with the first would name a sender's change as minted supply. A caller whose | ||
| * registry mints outside the factory opts into the fallback by naming both `mintingChoices` and `holdingTemplate`; | ||
| * without both, a transaction carrying no burn-mint result is reported. | ||
| */ | ||
| export function parseMintedHoldings(transaction: unknown, options: ParseMintedHoldingsOptions = {}): string[] { | ||
| if (findExercisedEvent(transaction, TokenStandardV1Choice.burnMint)) { | ||
| const result = requireResultRecord(transaction, TokenStandardV1Choice.burnMint); | ||
| return requireContractIds(result['outputCids'], 'outputCids'); | ||
| } | ||
|
|
||
| const { mintingChoices, holdingTemplate } = options; | ||
| const canFallback = | ||
| Array.isArray(mintingChoices) && | ||
| mintingChoices.length > 0 && | ||
| typeof holdingTemplate === 'string' && | ||
| holdingTemplate.length > 0; | ||
| if (!canFallback) { | ||
| throw new TokenStandardV1ResultError( | ||
| TokenStandardV1ResultErrorCode.RESULT_NOT_FOUND, | ||
| `The transaction contains no ${TokenStandardV1Choice.burnMint} exercise, so it minted nothing. Name mintingChoices and holdingTemplate to read a registry-specific mint from the create events instead.`, | ||
| { choice: TokenStandardV1Choice.burnMint, updateId: getTransactionUpdateId(transaction) } | ||
| ); | ||
| } | ||
| if (!findExercisedEvent(transaction, mintingChoices)) { | ||
| throw new TokenStandardV1ResultError( | ||
| TokenStandardV1ResultErrorCode.RESULT_NOT_FOUND, | ||
| `The transaction contains neither a ${TokenStandardV1Choice.burnMint} exercise nor any of these minting choices: ${mintingChoices.join(', ')}.`, | ||
| { choices: [TokenStandardV1Choice.burnMint, ...mintingChoices], updateId: getTransactionUpdateId(transaction) } | ||
| ); | ||
| } | ||
|
|
||
| return findCreatedContractIds(transaction, holdingTemplate); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Detect a
#package-nameprefix explicitly inmatchesTemplateId.The package component is inferred only from part counts. When a filter names a package name and fewer module parts than the event template id, the package name is compared against a module segment and the match fails.
Example:
matchesTemplateId('pkg:Module:Nested:Template', '#name:Nested:Template')returnsfalse.A filter starting with
#always names a package, so drop that part regardless of length.🐛 Proposed fix
const filterParts = filter.split(':'); - const filterSuffix = filterParts.length > idSuffix.length ? filterParts.slice(1) : filterParts; + const namesPackage = filterParts[0]?.startsWith('#') === true || filterParts.length > idSuffix.length; + const filterSuffix = namesPackage ? filterParts.slice(1) : filterParts; if (filterSuffix.length === 0 || filterSuffix.length > idSuffix.length) return false;📝 Committable suggestion
🤖 Prompt for AI Agents