diff --git a/.changeset/itchy-peaches-juggle.md b/.changeset/itchy-peaches-juggle.md new file mode 100644 index 0000000..7976e1b --- /dev/null +++ b/.changeset/itchy-peaches-juggle.md @@ -0,0 +1,11 @@ +--- +"@near-lake/primitives": minor +--- + +Added: +- `Block.functionCalls` to get an array of FunctionCallView in this block, with different filters +- `Block.functionCallsToReceiver` to get an array of FunctionCallView to a specific receiver, optionally specifying method name +- `FunctionCallView` type that provides complete information about the function call, including parsed arguments and events + +Changed: +- `Event` class has been changed to inline RawEvent fields. diff --git a/package.json b/package.json index 6cf55b7..414e3ec 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "type": "git", "url": "git+https://github.com/near/near-lake-framework-js.git" }, - "homepage": "https://near-indexers.io", + "homepage": "https://docs.near.org/concepts/advanced/near-lake-framework", "scripts": { "build": "turbo run build", "clean": "turbo run clean", diff --git a/packages/near-lake-primitives/package.json b/packages/near-lake-primitives/package.json index 87f9d7a..cddce5b 100644 --- a/packages/near-lake-primitives/package.json +++ b/packages/near-lake-primitives/package.json @@ -5,8 +5,8 @@ "lake-primitives", "near-indexer" ], - "author": "NEAR Inc ", - "description": "Near Protocol primitive datatypes utilized by near-lake-framework", + "author": "Data Platform ", + "description": "Near Protocol primitive datatypes utilized by near-lake-framework and QueryAPI", "main": "dist/src/index.js", "files": [ "/dist" diff --git a/packages/near-lake-primitives/src/helpers.ts b/packages/near-lake-primitives/src/helpers.ts new file mode 100644 index 0000000..d792b03 --- /dev/null +++ b/packages/near-lake-primitives/src/helpers.ts @@ -0,0 +1,43 @@ +import { ExecutionStatus, ReceiptStatusFilter } from "./types/core/types"; + +export function isMatchingReceiverSingle( + receiverId: string, + wildcardFilter: string +) { + if (wildcardFilter === "*") { + return true; + } + const regExp = new RegExp( + wildcardFilter.replace(/\*/g, "[\\w\\d]+").replace(/\./g, "\\.") + ); + return regExp.test(receiverId); +} + +export function isMatchingReceiver( + receiverId: string, + contractFilter: string +): boolean { + const filters = contractFilter.split(",").map((f) => f.trim()); + return filters.some((f) => isMatchingReceiverSingle(receiverId, f)); +} + +export function isSuccessfulReceipt(receiptStatus: ExecutionStatus): boolean { + return ( + receiptStatus.hasOwnProperty("SuccessValue") || + receiptStatus.hasOwnProperty("SuccessReceiptId") + ); +} + +export function isMatchingReceiptStatus( + receiptStatus: ExecutionStatus, + statusFilter: ReceiptStatusFilter +): boolean { + switch (statusFilter) { + case "all": + return true; + case "onlySuccessful": + return isSuccessfulReceipt(receiptStatus); + case "onlyFailed": + return receiptStatus.hasOwnProperty("Failure"); + } +} diff --git a/packages/near-lake-primitives/src/types/block.ts b/packages/near-lake-primitives/src/types/block.ts index cf6a873..6d3a244 100644 --- a/packages/near-lake-primitives/src/types/block.ts +++ b/packages/near-lake-primitives/src/types/block.ts @@ -1,8 +1,14 @@ -import { Action, Receipt } from './receipts'; -import { StreamerMessage, ValidatorStakeView } from './core/types'; -import { Transaction } from './transactions'; -import { Event, RawEvent, Log } from './events'; -import { StateChange } from './stateChanges'; +import { Action, Receipt } from "./receipts"; +import { + ReceiptStatusFilter, + StreamerMessage, + ValidatorStakeView, +} from "./core/types"; +import { Transaction } from "./transactions"; +import { Event, Log, RawEvent } from "./events"; +import { StateChange } from "./stateChanges"; +import { isMatchingReceiptStatus, isMatchingReceiver } from "../helpers"; +import { FunctionCallView } from "./functionCallView"; /** * The `Block` type is used to represent a block in the NEAR Lake Framework. @@ -12,214 +18,279 @@ import { StateChange } from './stateChanges'; * - `Block` is not the fairest name for this structure either. NEAR Protocol is a sharded blockchain, so its block is actually an ephemeral structure that represents a collection of real blocks called chunks in NEAR Protocol. */ export class Block { - constructor( - /** - * Low-level structure for backward compatibility. - * As implemented in previous versions of [`near-lake-framework`](https://www.npmjs.com/package/near-lake-framework). - */ - readonly streamerMessage: StreamerMessage, - private executedReceipts: Receipt[], - /** - * Receipts included on the chain but not executed yet marked as “postponed”: they are represented by the same structure `Receipt` (see the corresponding section in this doc for more details). - */ - readonly postponedReceipts: Receipt[], - /** - * List of included `Transactions`, converted into `Receipts`. - * - * **_NOTE_:** Heads up! You might want to know about `Transactions` to know where the action chain has begun. Unlike Ethereum, where a Transaction contains everything you may want to know about a particular interaction on the Ethereum blockchain, Near Protocol because of its asynchronous nature converts a `Transaction` into a `Receipt` before executing it. Thus, On NEAR, `Receipts` are more important for figuring out what happened on-chain as a result of a Transaction signed by a user. Read more about [Transactions on Near](https://nomicon.io/RuntimeSpec/Transactions) here. - * - */ - readonly transactions: Transaction[], - private _actions: Map, - private _events: Map, - private _stateChanges: StateChange[]) { } - + constructor( /** - * Returns the block hash. A shortcut to get the data from the block header. + * Low-level structure for backward compatibility. + * As implemented in previous versions of [`near-lake-framework`](https://www.npmjs.com/package/near-lake-framework). */ - get blockHash(): string { - return this.header().hash; - } - + readonly streamerMessage: StreamerMessage, + private executedReceipts: Receipt[], /** - * Returns the previous block hash. A shortcut to get the data from the block header. + * Receipts included on the chain but not executed yet marked as “postponed”: they are represented by the same structure `Receipt` (see the corresponding section in this doc for more details). */ - get prevBlockHash(): string { - return this.header().prevHash; - } - + readonly postponedReceipts: Receipt[], /** - * Returns the block height. A shortcut to get the data from the block header. + * List of included `Transactions`, converted into `Receipts`. + * + * **_NOTE_:** Heads up! You might want to know about `Transactions` to know where the action chain has begun. Unlike Ethereum, where a Transaction contains everything you may want to know about a particular interaction on the Ethereum blockchain, Near Protocol because of its asynchronous nature converts a `Transaction` into a `Receipt` before executing it. Thus, On NEAR, `Receipts` are more important for figuring out what happened on-chain as a result of a Transaction signed by a user. Read more about [Transactions on Near](https://nomicon.io/RuntimeSpec/Transactions) here. + * */ - get blockHeight(): number { - return this.header().height; - } + readonly transactions: Transaction[], + private _actions: Map, + private _events: Map, + private _stateChanges: StateChange[] + ) {} - /** - * Returns a `BlockHeader` structure of the block - * See `BlockHeader` structure sections for details. - */ - header(): BlockHeader { - return BlockHeader.fromStreamerMessage(this.streamerMessage); - } + /** + * Returns the block hash. A shortcut to get the data from the block header. + */ + get blockHash(): string { + return this.header().hash; + } - /** - * Returns a slice of `Receipts` executed in the block. - * Basically is a getter for the `executedReceipts` field. - */ - receipts(): Receipt[] { - if (this.executedReceipts.length == 0) { - this.executedReceipts = this.streamerMessage.shards - .flatMap((shard) => shard.receiptExecutionOutcomes) - .map((executionReceipt) => Receipt.fromOutcomeWithReceipt(executionReceipt)) - } - return this.executedReceipts; - } + /** + * Returns the previous block hash. A shortcut to get the data from the block header. + */ + get prevBlockHash(): string { + return this.header().prevHash; + } - /** - * Returns an Array of `Actions` executed in the block. - */ - actions(): Action[] { - const actions: Action[] = this.streamerMessage.shards - .flatMap((shard) => shard.receiptExecutionOutcomes) - .filter((exeuctionOutcomeWithReceipt) => Action.isActionReceipt(exeuctionOutcomeWithReceipt.receipt)) - .map((exeuctionOutcomeWithReceipt) => Action.fromReceiptView(exeuctionOutcomeWithReceipt.receipt)) - .filter((action): action is Action => action !== null) - .map(action => action) - return actions - } + /** + * Returns the block height. A shortcut to get the data from the block header. + */ + get blockHeight(): number { + return this.header().height; + } - /** - * Returns `Events` emitted in the block. - */ - events(): Event[] { - const events = this.receipts().flatMap((executedReceipt) => executedReceipt.logs.filter(RawEvent.isEvent).map(RawEvent.fromLog).map((rawEvent) => { - let event: Event = { relatedReceiptId: executedReceipt.receiptId, rawEvent: rawEvent } - return event - })) - return events - } + /** + * Returns the block date in ISO format, e.g. 2022-01-01. + */ + get blockDate(): string { + return new Date(this.streamerMessage.block.header.timestamp / 1000000) + .toISOString() + .substring(0, 10); + } - /** - * Returns raw logs regardless of the fact that they are standard events or not. - */ - logs(): Log[] { - const logs: Log[] = this.receipts().flatMap((executedReceipt) => executedReceipt.logs.map((rawLog) => { - let log: Log = { relatedReceiptId: executedReceipt.receiptId, log: rawLog } - return log - })) - return logs - } + /** + * Returns a `BlockHeader` structure of the block + * See `BlockHeader` structure sections for details. + */ + header(): BlockHeader { + return BlockHeader.fromStreamerMessage(this.streamerMessage); + } - /** - * Returns an Array of `StateChange` occurred in the block. - */ - stateChanges(): StateChange[] { - if (this._stateChanges.length == 0) { - this._stateChanges = this.streamerMessage.shards - .flatMap((shard) => shard.stateChanges) - .map(StateChange.fromStateChangeView) - } - return this._stateChanges + /** + * Returns a slice of `Receipts` executed in the block. + * Basically is a getter for the `executedReceipts` field. + */ + receipts(): Receipt[] { + if (this.executedReceipts.length == 0) { + this.executedReceipts = this.streamerMessage.shards + .flatMap((shard) => shard.receiptExecutionOutcomes) + .map((executionReceipt) => + Receipt.fromOutcomeWithReceipt(executionReceipt) + ); } + return this.executedReceipts; + } - /** - * Returns `Action` of the provided `receipt_id` from the block if any. Returns `undefined` if there is no corresponding `Action`. - * - * This method uses the internal `Block` `action` field which is empty by default and will be filled with the block’s actions on the first call to optimize memory usage. - * - * The result is either `Action | undefined` since there might be a request for an `Action` by `receipt_id` from another block, in which case this method will be unable to find the `Action` in the current block. In the other case, the request might be for an `Action` for a `receipt_id` that belongs to a `DataReceipt` where an action does not exist. - */ - actionByReceiptId(receipt_id: string): Action | undefined { - if (this._actions.size == 0) { - this.buildActionsHashmap() - } - return this._actions.get(receipt_id); - } + /** + * Returns an Array of `Actions` executed in the block. + */ + actions(): Action[] { + return this.streamerMessage.shards + .flatMap((shard) => shard.receiptExecutionOutcomes) + .filter((executionOutcomeWithReceipt) => + Action.isActionReceipt(executionOutcomeWithReceipt.receipt) + ) + .map((executionOutcomeWithReceipt) => + Action.fromOutcomeWithReceipt(executionOutcomeWithReceipt) + ) + .filter((action): action is Action => action !== null); + } - /** - * Returns an Array of Events emitted by `ExecutionOutcome` for the given `receipt_id`. There might be more than one `Event` for the `Receipt` or there might be none of them. In the latter case, this method returns an empty Array. - */ - eventsByReceiptId(receipt_id: string): Event[] { - if (this._events.size == 0) { - this.buildEventsHashmap() - } - return this._events.get(receipt_id) || []; - } + /** + * Returns an Array of function calls executed in the block matching provided filters. + * @param receiverFilter - filter by contract name (e.g. `*.pool.near,*.poolv1.near`). Default is `*` (all contracts). + * @param statusFilter - filter by receipt status (all|onlySuccessful|onlyFailed). Default is `onlySuccessful`. + */ + functionCalls( + receiverFilter = "*", + statusFilter: ReceiptStatusFilter = "onlySuccessful" + ): FunctionCallView[] { + return this.actions() + .filter( + (action) => + isMatchingReceiver(action.receiverId, receiverFilter) && + isMatchingReceiptStatus(action.receiptStatus, statusFilter) + ) + .flatMap((a) => + a.operations + .filter((op) => op.hasOwnProperty("FunctionCall")) + .map((op) => + FunctionCallView.fromFunctionCall(Object(op).FunctionCall, a) + ) + ); + } - /** - * Returns an Array of Events emitted by `ExecutionOutcome` for the given `account_id`. There might be more than one `Event` for the `Receipt` or there might be none of them. In the latter case, this method returns an empty Array. - */ - eventsByAccountId(account_id: string): Event[] { - return this.events().filter((event) => { - const action = this.actionByReceiptId(event.relatedReceiptId) - return action?.receiverId == account_id || action?.signerId == account_id - }); + /** + * Returns an Array of function calls to receivers matching receiverFilter. + * @param receiverFilter - filter by contract name (e.g. `*.pool.near,*.poolv1.near`). Default is `*` (all contracts). + * @param method - name of the method to filter by. Returns all function calls to receiverFilter if not provided. + */ + functionCallsToReceiver( + receiverFilter = "*", + method?: string + ): FunctionCallView[] { + return this.actions() + .filter((action) => isMatchingReceiver(action.receiverId, receiverFilter)) + .flatMap((a) => + a.operations + .filter((op) => op.hasOwnProperty("FunctionCall")) + .filter((op) => + method ? Object(op).FunctionCall.methodName === method : true + ) + .map((op) => + FunctionCallView.fromFunctionCall(Object(op).FunctionCall, a) + ) + ); + } + + /** + * Returns `Events` emitted in the block. + */ + events(): Event[] { + const events = this.receipts().flatMap((executedReceipt) => + executedReceipt.logs + .filter(RawEvent.isEvent) + .map(RawEvent.fromLog) + .map((rawEvent) => { + return new Event(executedReceipt.receiptId, rawEvent); + }) + ); + return events; + } + + /** + * Returns raw logs regardless of the fact that they are standard events or not. + */ + logs(): Log[] { + const logs: Log[] = this.receipts().flatMap((executedReceipt) => + executedReceipt.logs.map((rawLog) => { + let log: Log = { + relatedReceiptId: executedReceipt.receiptId, + log: rawLog, + }; + return log; + }) + ); + return logs; + } + + /** + * Returns an Array of `StateChange` occurred in the block. + */ + stateChanges(): StateChange[] { + if (this._stateChanges.length == 0) { + this._stateChanges = this.streamerMessage.shards + .flatMap((shard) => shard.stateChanges) + .map(StateChange.fromStateChangeView); } + return this._stateChanges; + } - private buildActionsHashmap() { - const actions = new Map(); - this.actions().forEach(action => { - actions.set(action.receiptId, action) - } - ); - this._actions = actions + /** + * Returns `Action` of the provided `receipt_id` from the block if any. Returns `undefined` if there is no corresponding `Action`. + * + * This method uses the internal `Block` `action` field which is empty by default and will be filled with the block’s actions on the first call to optimize memory usage. + * + * The result is either `Action | undefined` since there might be a request for an `Action` by `receipt_id` from another block, in which case this method will be unable to find the `Action` in the current block. In the other case, the request might be for an `Action` for a `receipt_id` that belongs to a `DataReceipt` where an action does not exist. + */ + actionByReceiptId(receipt_id: string): Action | undefined { + if (this._actions.size == 0) { + this.buildActionsHashmap(); } + return this._actions.get(receipt_id); + } - private buildEventsHashmap(): Map { - const events = new Map(); - for (const receipt of this.executedReceipts) { - events.set(receipt.receiptId, receipt.events); - } - return events; + /** + * Returns an Array of Events emitted by `ExecutionOutcome` for the given `receipt_id`. There might be more than one `Event` for the `Receipt` or there might be none of them. In the latter case, this method returns an empty Array. + */ + eventsByReceiptId(receipt_id: string): Event[] { + if (this._events.size == 0) { + this.buildEventsHashmap(); } + return this._events.get(receipt_id) || []; + } + + /** + * Returns an Array of Events emitted by `ExecutionOutcome` for the given `account_id`. There might be more than one `Event` for the `Receipt` or there might be none of them. In the latter case, this method returns an empty Array. + */ + eventsByAccountId(account_id: string): Event[] { + return this.events().filter((event) => { + const action = this.actionByReceiptId(event.receiptId); + return action?.receiverId == account_id || action?.signerId == account_id; + }); + } + private buildActionsHashmap() { + const actions = new Map(); + this.actions().forEach((action) => { + actions.set(action.receiptId, action); + }); + this._actions = actions; + } - static fromStreamerMessage(streamerMessage: StreamerMessage): Block { - const block: Block = new Block(streamerMessage, [], [], [], new Map(), new Map(), []); - return block; + private buildEventsHashmap(): Map { + const events = new Map(); + for (const receipt of this.executedReceipts) { + events.set(receipt.receiptId, receipt.events); } + return events; + } + static fromStreamerMessage(streamerMessage: StreamerMessage): Block { + return new Block(streamerMessage, [], [], [], new Map(), new Map(), []); + } } - /** - * Replacement for `BlockHeaderView` from [near-primitives](https://github.com/near/nearcore/tree/master/core/primitives). Shrunken and simplified. + * Replacement for `BlockHeaderView` from [near-primitives](https://github.com/near/nearcore/tree/master/core/primitives). Shrunken and simplified. * * **Note:** the original `BlockHeaderView` is still accessible via the `.streamerMessage` attribute. */ export class BlockHeader { + constructor( + readonly height: number, + readonly hash: string, + readonly prevHash: string, + readonly author: string, + readonly timestampNanosec: string, + readonly epochId: string, + readonly nextEpochId: string, + readonly gasPrice: string, + readonly totalSupply: string, + readonly latestProtocolVersion: number, + readonly randomValue: string, + readonly chunksIncluded: number, + readonly validatorProposals: ValidatorStakeView[] + ) {} - constructor( - readonly height: number, - readonly hash: string, - readonly prevHash: string, - readonly author: string, - readonly timestampNanosec: string, - readonly epochId: string, - readonly nextEpochId: string, - readonly gasPrice: string, - readonly totalSupply: string, - readonly latestProtocolVersion: number, - readonly randomValue: string, - readonly chunksIncluded: number, - readonly validatorProposals: ValidatorStakeView[]) { } - - static fromStreamerMessage(streamerMessage: StreamerMessage): BlockHeader { - return new BlockHeader( - streamerMessage.block.header.height, - streamerMessage.block.header.hash, - streamerMessage.block.header.prevHash, - streamerMessage.block.author, - streamerMessage.block.header.timestampNanosec, - streamerMessage.block.header.epochId, - streamerMessage.block.header.nextEpochId, - streamerMessage.block.header.gasPrice, - streamerMessage.block.header.totalSupply, - streamerMessage.block.header.latestProtocolVersion, - streamerMessage.block.header.randomValue, - streamerMessage.block.header.chunksIncluded, - streamerMessage.block.header.validatorProposals, - ); - } + static fromStreamerMessage(streamerMessage: StreamerMessage): BlockHeader { + return new BlockHeader( + streamerMessage.block.header.height, + streamerMessage.block.header.hash, + streamerMessage.block.header.prevHash, + streamerMessage.block.author, + streamerMessage.block.header.timestampNanosec, + streamerMessage.block.header.epochId, + streamerMessage.block.header.nextEpochId, + streamerMessage.block.header.gasPrice, + streamerMessage.block.header.totalSupply, + streamerMessage.block.header.latestProtocolVersion, + streamerMessage.block.header.randomValue, + streamerMessage.block.header.chunksIncluded, + streamerMessage.block.header.validatorProposals + ); + } } diff --git a/packages/near-lake-primitives/src/types/core/types.ts b/packages/near-lake-primitives/src/types/core/types.ts index 8a62af1..3bfc856 100644 --- a/packages/near-lake-primitives/src/types/core/types.ts +++ b/packages/near-lake-primitives/src/types/core/types.ts @@ -10,6 +10,7 @@ export interface BlockView { header: BlockHeaderView; chunks: ChunkHeader[]; } + export interface BlockHeaderView { author: any; approvals: (string | null)[]; @@ -138,23 +139,23 @@ export type ReceiptView = { */ export type ExecutionStatus = | { - /** - * Execution succeeded with a value, value is represented by `Uint8Array` and can be anything. - */ - SuccessValue: Uint8Array; - } + /** + * Execution succeeded with a value, value is represented by `Uint8Array` and can be anything. + */ + SuccessValue: Uint8Array; +} | { - /** - * Execution succeeded and a result of the execution is a new `Receipt` with the id. - */ - SuccessReceiptId: string; - } + /** + * Execution succeeded and a result of the execution is a new `Receipt` with the id. + */ + SuccessReceiptId: string; +} | { - /** - * Execution failed with an error represented by a `String`. - */ - Failure: string; - } + /** + * Execution failed with an error represented by a `String`. + */ + Failure: string; +} | "Postponed"; type ExecutionProof = { @@ -162,22 +163,24 @@ type ExecutionProof = { hash: string; }; +export type ReceiptExecutionOutcome = { + executorId: string; + gasBurnt: number; + logs: string[]; + metadata: { + gasProfile: string | null; + version: number; + }; + receiptIds: string[]; + status: ExecutionStatus; + tokensBurnt: string; +} + export type ExecutionOutcomeWithReceipt = { executionOutcome: { blockHash: string; id: string; - outcome: { - executorId: string; - gasBurnt: number; - logs: string[]; - metadata: { - gasProfile: string | null; - version: number; - }; - receiptIds: string[]; - status: ExecutionStatus; - tokensBurnt: string; - }; + outcome: ReceiptExecutionOutcome; proof: ExecutionProof[]; }; receipt: ReceiptView; @@ -311,3 +314,6 @@ export type StateChangeWithCauseView = { }; type: string; }; + +export type ReceiptStatusFilter = "all" | "onlySuccessful" | "onlyFailed"; + diff --git a/packages/near-lake-primitives/src/types/events.ts b/packages/near-lake-primitives/src/types/events.ts index 7e2649c..584d4bc 100644 --- a/packages/near-lake-primitives/src/types/events.ts +++ b/packages/near-lake-primitives/src/types/events.ts @@ -1,7 +1,6 @@ - export type Log = { - log: String; - relatedReceiptId: String; + log: String; + relatedReceiptId: String; } /** @@ -16,31 +15,54 @@ export type Log = { * - `data`: associate event data. Strictly typed for each set {standard, version, event} inside corresponding NEP */ export class Event { - constructor(readonly relatedReceiptId: string, readonly rawEvent: RawEvent) { } + constructor(readonly receiptId: string, private readonly rawEvent: RawEvent) { + } + + get event(): string { + return this.rawEvent.event; + } + + get standard(): string { + return this.rawEvent.standard; + } + + get version(): string { + return this.rawEvent.version; + } + + get data(): JSON | undefined { + return this.rawEvent.data; + } + + static fromLog = (log: string, receiptId = ""): Event => { + const rawEvent = RawEvent.fromLog(log); + return new Event(receiptId, rawEvent); + }; - static fromLog = (log: string): Event => { - const rawEvent = RawEvent.fromLog(log); - return new Event('', rawEvent); - } + get relatedReceiptId(): string { + console.warn("relatedReceiptId is deprecated, use receiptId instead"); + return this.receiptId; + } } /** * This structure is a copy of the [JSON Events](https://github.com/near/NEPs/blob/master/neps/nep-0297.md) structure representation. */ export class RawEvent { - constructor(readonly event: string, readonly standard: string, readonly version: string, readonly data: JSON | undefined) { } + constructor(readonly event: string, readonly standard: string, readonly version: string, readonly data: JSON | undefined) { + } - static isEvent = (log: string): boolean => { - return log.startsWith('EVENT_JSON:'); - }; + static isEvent = (log: string): boolean => { + return log.startsWith("EVENT_JSON:"); + }; - static fromLog = (log: string): RawEvent => { - const { event, standard, version, data } = JSON.parse(log.replace('EVENT_JSON:', '')); + static fromLog = (log: string): RawEvent => { + const { event, standard, version, data } = JSON.parse(log.replace("EVENT_JSON:", "")); - return new RawEvent(event, standard, version, data); - }; + return new RawEvent(event, standard, version, data); + }; }; export type Events = { - events: Event[]; + events: Event[]; } diff --git a/packages/near-lake-primitives/src/types/functionCallView.ts b/packages/near-lake-primitives/src/types/functionCallView.ts new file mode 100644 index 0000000..d31a60a --- /dev/null +++ b/packages/near-lake-primitives/src/types/functionCallView.ts @@ -0,0 +1,118 @@ +import { Action, FunctionCall } from "./receipts"; +import { Event } from "./events"; +import { isSuccessfulReceipt } from "../helpers"; +import { ExecutionStatus } from "./core/types"; + +/** + * Represents a Function Call to a smart cotract + * + */ +export class FunctionCallView { + constructor( + /** + * The account ID of the contract that is called. + */ + readonly receiverId: string, + + /** + * The method name of the contract that was invoked. + */ + readonly methodName: string, + + /** + * Base64 encoded arguments to the method. + */ + readonly args: string, + + /** + * gas amount. + */ + readonly gas: number, + + /** + * deposit amount in yoctoNEAR. + */ + readonly deposit: string, + + private readonly action: Action + ) {} + + /** + * receiptId in which this call was executed. + */ + get receiptId(): string { + return this.action.receiptId; + } + + /** + * whether the call was successful. + */ + get isSuccessful(): boolean { + return isSuccessfulReceipt(this.action.receiptStatus); + } + + /** + * Execution status object of the corresponding receipt. + */ + get receiptStatus(): ExecutionStatus { + return this.action.receiptStatus; + } + + /** + * predecessorId: the contract that invoked this call. + */ + get predecessorId(): string { + return this.action.predecessorId; + } + + /** + * original signer of the transaction. + */ + get signerId(): string { + return this.action.signerId; + } + + /** + * array of parsed events complying with NEP-297 emitted in this call. + */ + get events(): Event[] { + return this.action.events; + } + + /** + * array of logs produced in this call. + */ + get logs(): string[] { + return this.action.logs; + } + + /** + * arguments, decoded from base64 and parsed to JSON + * @param encoding encoding of the args, default is utf-8 + * @returns JSON object of the arguments + * @throws Error if failed to parse the args as JSON + */ + argsAsJSON(encoding: BufferEncoding = "utf-8"): JSON { + try { + return JSON.parse(Buffer.from(this.args, "base64").toString(encoding)); + } catch (e: any) { + throw new Error( + `Failed to parse args '${this.args}' on method '${this.methodName}' as JSON: ${e.message}` + ); + } + } + + static fromFunctionCall( + functionCall: FunctionCall, + action: Action + ): FunctionCallView { + return new FunctionCallView( + action.receiverId, + functionCall.methodName, + functionCall.args, + functionCall.gas, + functionCall.deposit, + action + ); + } +} diff --git a/packages/near-lake-primitives/src/types/receipts.ts b/packages/near-lake-primitives/src/types/receipts.ts index ecee770..3b99602 100644 --- a/packages/near-lake-primitives/src/types/receipts.ts +++ b/packages/near-lake-primitives/src/types/receipts.ts @@ -1,6 +1,10 @@ - -import { ExecutionOutcomeWithReceipt, ExecutionStatus, ReceiptView, ActionReceipt } from './core/types'; -import { Events, Event } from './events'; +import { + ExecutionOutcomeWithReceipt, + ExecutionStatus, + ReceiptView, + ActionReceipt, +} from "./core/types"; +import { Events, Event, RawEvent } from "./events"; /** * This field is a simplified representation of the `ReceiptView` structure from [near-primitives](https://github.com/near/nearcore/tree/master/core/primitives). @@ -10,49 +14,57 @@ export class Receipt implements Events { /** * Defined the type of the `Receipt`: `Action` or `Data` representing the `ActionReceipt` and `DataReceipt`. */ - readonly receiptKind: ReceiptKind, + readonly receiptKind: ReceiptKind, /** * The ID of the `Receipt` of the `CryptoHash` type. */ - readonly receiptId: string, + readonly receiptId: string, /** * The receiver account id of the `Receipt`. */ - readonly receiverId: string, + readonly receiverId: string, /** * The predecessor account id of the `Receipt`. */ - readonly predecessorId: string, + readonly predecessorId: string, /** * Represents the status of `ExecutionOutcome` of the `Receipt`. */ - readonly status: ExecutionStatus, + readonly status: ExecutionStatus, /** * The id of the `ExecutionOutcome` for the `Receipt`. Returns `null` if the `Receipt` isn’t executed yet and has a postponed status. */ - readonly executionOutcomeId?: string | undefined, + readonly executionOutcomeId?: string | undefined, /** * The original logs of the corresponding `ExecutionOutcome` of the `Receipt`. * - * **Note:** not all of the logs might be parsed as JSON Events (`Events`). + * **Note:** not all the logs might be parsed as JSON Events (`Events`). */ - readonly logs: string[] = []) { } + readonly logs: string[] = [] + ) {} /** * Returns an Array of `Events` for the `Receipt`, if any. This might be empty if the `logs` field is empty or doesn’t contain JSON Events compatible log records. */ get events(): Event[] { - return this.logs.map(Event.fromLog).filter((e): e is Event => e !== undefined); + return this.logs + .filter((log) => RawEvent.isEvent(log)) + .map((log) => Event.fromLog(log, this.receiptId)); } - static fromOutcomeWithReceipt = (outcomeWithReceipt: ExecutionOutcomeWithReceipt): Receipt => { - const kind = 'Action' in outcomeWithReceipt.receipt ? ReceiptKind.Action : ReceiptKind.Data + static fromOutcomeWithReceipt = ( + outcomeWithReceipt: ExecutionOutcomeWithReceipt + ): Receipt => { + const kind = + "Action" in outcomeWithReceipt.receipt + ? ReceiptKind.Action + : ReceiptKind.Data; return new Receipt( kind, outcomeWithReceipt.receipt.receiptId, @@ -60,18 +72,17 @@ export class Receipt implements Events { outcomeWithReceipt.receipt?.predecessorId, outcomeWithReceipt.executionOutcome.outcome.status, outcomeWithReceipt.executionOutcome.id, - outcomeWithReceipt.executionOutcome.outcome.logs, + outcomeWithReceipt.executionOutcome.outcome.logs ); }; - } /** * `ReceiptKind` a simple `enum` to represent the `Receipt` type: either `Action` or `Data`. */ export enum ReceiptKind { - Action = 'Action', - Data = 'Data', + Action = "Action", + Data = "Data", } /** @@ -80,92 +91,123 @@ export enum ReceiptKind { * Basically, `Action` is the structure that indexer developers will be encouraged to work the most in their action-oriented indexers. */ export class Action { - constructor( - /** - * The id of the corresponding `Receipt` - */ - readonly receiptId: string, + /** + * The id of the corresponding `Receipt` + */ + readonly receiptId: string, - /** - * The predecessor account id of the corresponding `Receipt`. - * This field is a piece of denormalization of the structures (`Receipt` and `Action`). - */ - readonly predecessorId: string, + /** + * The status of the corresponding `Receipt` + */ + readonly receiptStatus: ExecutionStatus, - /** - * The receiver account id of the corresponding `Receipt`. - * This field is a piece of denormalization of the structures (`Receipt` and `Action`). - */ - readonly receiverId: string, + /** + * The predecessor account id of the corresponding `Receipt`. + * This field is a piece of denormalization of the structures (`Receipt` and `Action`). + */ + readonly predecessorId: string, - /** - * The signer account id of the corresponding `Receipt` - */ - readonly signerId: string, + /** + * The receiver account id of the corresponding `Receipt`. + * This field is a piece of denormalization of the structures (`Receipt` and `Action`). + */ + readonly receiverId: string, - /** - * The signer’s PublicKey for the corresponding `Receipt` - */ - readonly signerPublicKey: string, + /** + * The signer account id of the corresponding `Receipt` + */ + readonly signerId: string, + + /** + * The signer’s PublicKey for the corresponding `Receipt` + */ + readonly signerPublicKey: string, + + /** + * An array of `Operation` for this `ActionReceipt` + */ + readonly operations: Operation[], + + /** + * An array of log messages for this `ActionReceipt` + */ + readonly logs: string[] = [] + ) {} /** - * An array of `Operation` for this `ActionReceipt` + * An array of events complying to NEP-0297 standard for this `ActionReceipt` */ - readonly operations: Operation[]) { } + get events(): Event[] { + return this.logs + .filter(RawEvent.isEvent) + .map((log) => Event.fromLog(log, this.receiptId)); + } static isActionReceipt = (receipt: ReceiptView) => { - if (typeof receipt.receipt === "object" && receipt.receipt.constructor.name === "ActionReceipt") return true - return true - } + return ( + typeof receipt.receipt === "object" && + Object(receipt.receipt).hasOwnProperty("Action") + ); + }; - static fromReceiptView = (receipt: ReceiptView): Action | null => { - if (!this.isActionReceipt(receipt)) return null - const { Action } = receipt.receipt as ActionReceipt; - return { - receiptId: receipt.receiptId, - predecessorId: receipt.predecessorId, - receiverId: receipt.receiverId, - signerId: Action.signerId, - signerPublicKey: Action.signerPublicKey, - operations: Action.actions.map((a) => a) as Operation[], - }; - } -}; + static fromOutcomeWithReceipt = ( + outcomeWithReceipt: ExecutionOutcomeWithReceipt + ): Action | null => { + if (!this.isActionReceipt(outcomeWithReceipt.receipt)) return null; + const receiptView = outcomeWithReceipt.receipt; + const { Action: action } = receiptView.receipt as ActionReceipt; + return new Action( + receiptView.receiptId, + outcomeWithReceipt.executionOutcome.outcome.status, + receiptView.predecessorId, + receiptView.receiverId, + action.signerId, + action.signerPublicKey, + action.actions.map((a) => a) as Operation[], + outcomeWithReceipt.executionOutcome.outcome.logs + ); + }; +} -class DeployContract { - constructor(readonly code: Uint8Array) { } -}; +export class DeployContract { + constructor(readonly code: Uint8Array) {} +} -class FunctionCall { - constructor(readonly methodName: string, readonly args: Uint8Array, readonly gas: number, readonly deposit: string) { } -}; +export class FunctionCall { + constructor( + readonly methodName: string, + readonly args: string, + readonly gas: number, + readonly deposit: string + ) {} +} -class Transfer { - constructor(readonly deposit: string) { } -}; +export class Transfer { + constructor(readonly deposit: string) {} +} -class Stake { - constructor(readonly stake: number, readonly publicKey: string) { } -}; +export class Stake { + constructor(readonly stake: number, readonly publicKey: string) {} +} -class AddKey { - constructor(readonly publicKey: string, readonly accessKey: AccessKey) { } -}; +export class AddKey { + constructor(readonly publicKey: string, readonly accessKey: AccessKey) {} +} -class DeleteKey { - constructor(readonly publicKey: string) { } -}; +export class DeleteKey { + constructor(readonly publicKey: string) {} +} -class DeleteAccount { - constructor(readonly beneficiaryId: string) { } -}; +export class DeleteAccount { + constructor(readonly beneficiaryId: string) {} +} /** * A representation of the original `ActionView` from [near-primitives](https://github.com/near/nearcore/tree/master/core/primitives). */ export type Operation = - | 'CreateAccount' + | "CreateAccount" | DeployContract | FunctionCall | Transfer @@ -175,10 +217,16 @@ export type Operation = | DeleteAccount; export class AccessKey { - constructor(readonly nonce: number, readonly permission: string | AccessKeyFunctionCallPermission) { } + constructor( + readonly nonce: number, + readonly permission: string | AccessKeyFunctionCallPermission + ) {} } class AccessKeyFunctionCallPermission { - constructor(readonly allowance: string, readonly receiverId: string, readonly methodNames: string[]) { } + constructor( + readonly allowance: string, + readonly receiverId: string, + readonly methodNames: string[] + ) {} } - diff --git a/packages/near-lake-primitives/test/__snapshots__/block.test.ts.snap b/packages/near-lake-primitives/test/__snapshots__/block.test.ts.snap index 9107c61..ba5171e 100644 --- a/packages/near-lake-primitives/test/__snapshots__/block.test.ts.snap +++ b/packages/near-lake-primitives/test/__snapshots__/block.test.ts.snap @@ -8,7 +8,7 @@ exports[`Block deserializes using borsch 1`] = ` exports[`Block parses event logs 1`] = ` [ - { + Event { "rawEvent": RawEvent { "data": [ { @@ -22,7 +22,7 @@ exports[`Block parses event logs 1`] = ` "standard": "nep171", "version": "1.0.0", }, - "relatedReceiptId": "AVeR4o6MWKYMhjJV8x6ZNk9U9kQYm1qbiN8bBaQQ4rWG", + "receiptId": "AVeR4o6MWKYMhjJV8x6ZNk9U9kQYm1qbiN8bBaQQ4rWG", }, ] `; diff --git a/packages/near-lake-primitives/test/block.test.ts b/packages/near-lake-primitives/test/block.test.ts index d300d3a..d5e4e6b 100644 --- a/packages/near-lake-primitives/test/block.test.ts +++ b/packages/near-lake-primitives/test/block.test.ts @@ -1,14 +1,9 @@ -import { readFile } from "fs/promises"; - -import { Block, borsh } from "../src"; +import { borsh } from "../src"; +import { getBlock } from "./helpers"; describe("Block", () => { it("serializes meta transactions", async () => { - let streamerMessageBuffer = await readFile( - `${__dirname}/../../../blocks/105793821.json` - ); - let streamerMessage = JSON.parse(streamerMessageBuffer.toString()); - let block = Block.fromStreamerMessage(streamerMessage); + const block = await getBlock(105793821); const actions = block.actionByReceiptId( "Dpego7SpsK36PyXjUMrFoSze8ZpNsB9xhb3XJJYtXSix" @@ -17,11 +12,7 @@ describe("Block", () => { }); it("parses event logs", async () => { - let streamerMessageBuffer = await readFile( - `${__dirname}/../../../blocks/61321189.json` - ); - let streamerMessage = JSON.parse(streamerMessageBuffer.toString()); - let block = Block.fromStreamerMessage(streamerMessage); + const block = await getBlock(61321189); expect(block.events()).toMatchSnapshot(); }); @@ -32,11 +23,7 @@ describe("Block", () => { } it("deserializes using borsch", async () => { - let streamerMessageBuffer = await readFile( - `${__dirname}/../../../blocks/114158749.json` - ); - let streamerMessage = JSON.parse(streamerMessageBuffer.toString()); - let block = Block.fromStreamerMessage(streamerMessage); + const block = await getBlock(114158749); const stateChanges = block.streamerMessage.shards .flatMap((e) => e.stateChanges) diff --git a/packages/near-lake-primitives/test/functionCalls.test.ts b/packages/near-lake-primitives/test/functionCalls.test.ts new file mode 100644 index 0000000..d389845 --- /dev/null +++ b/packages/near-lake-primitives/test/functionCalls.test.ts @@ -0,0 +1,41 @@ +import { getBlock } from "./helpers"; + +describe("Block", () => { + describe("functionCalls", () => { + it("gets single failed function call in 114158749", async () => { + let block = await getBlock(114158749); + const functionCalls = block.functionCalls("*", "onlyFailed"); + expect(functionCalls.length).toEqual(1); + }); + + it("gets args for devgovgigs.near add_post to in 114158749", async () => { + let block = await getBlock(114158749); + const functionCalls = block.functionCalls("devgovgigs.near", "all"); + expect(functionCalls.length).toEqual(1); + const call = functionCalls[0]; + expect(call.methodName).toEqual("add_post"); + const jsonArgs = call.argsAsJSON(); + expect(jsonArgs).toHaveProperty("parent_id"); + expect(jsonArgs).toHaveProperty("labels"); + expect(jsonArgs).toHaveProperty("body"); + + expect(call).toEqual( + block.functionCallsToReceiver("devgovgigs.near", "add_post")[0] + ); + }); + + it("allows to extract ft_transfer events from function calls to token.sweat", async () => { + const block = await getBlock(114158749); + const functionCalls = block.functionCallsToReceiver( + "token.sweat", + "ft_transfer" + ); + expect(functionCalls.length).toEqual(17); + expect( + functionCalls + .flatMap((fc) => fc.events) + .every((event) => event.standard === "nep141") + ).toBeTruthy(); + }); + }); +}); diff --git a/packages/near-lake-primitives/test/helpers.test.ts b/packages/near-lake-primitives/test/helpers.test.ts new file mode 100644 index 0000000..7b9fde5 --- /dev/null +++ b/packages/near-lake-primitives/test/helpers.test.ts @@ -0,0 +1,82 @@ +import { + isMatchingReceiptStatus, + isMatchingReceiver, + isMatchingReceiverSingle, +} from "../src/helpers"; +import { ExecutionStatus, ReceiptStatusFilter } from "../src/types/core/types"; +import { describe } from "node:test"; + +describe("Helpers", () => { + describe("isMatchingReceiverSingle", () => { + it("should match wildcard", () => { + expect(isMatchingReceiverSingle("test", "*")).toBeTruthy(); + }); + + it("should not match different contract", () => { + expect(isMatchingReceiverSingle("test", "acc.near")).toEqual(false); + }); + + it("should match sub-sub-account", () => { + expect(isMatchingReceiverSingle("a.acc.near", "*.acc.near")).toEqual( + true + ); + }); + + it("should match sub-sub-account", () => { + expect(isMatchingReceiverSingle("a.b.acc.near", "*.acc.near")).toEqual( + true + ); + }); + }); + + describe("isMatchingReceiver", () => { + it("should match wildcard", () => { + expect(isMatchingReceiver("test", "*")).toEqual(true); + }); + it("should not match different contract", () => { + expect(isMatchingReceiverSingle("test", "test1")).toEqual(false); + }); + }); + + describe("isMatchingReceiptStatus", () => { + const table: { + receiptStatus: ExecutionStatus; + statusFilter: ReceiptStatusFilter; + expected: boolean; + }[] = [ + { + receiptStatus: { SuccessValue: new Uint8Array() }, + statusFilter: "all", + expected: true, + }, + { + receiptStatus: { SuccessValue: new Uint8Array() }, + statusFilter: "onlyFailed", + expected: false, + }, + { + receiptStatus: { SuccessValue: new Uint8Array() }, + statusFilter: "onlySuccessful", + expected: true, + }, + { + receiptStatus: { Failure: "" }, + statusFilter: "onlySuccessful", + expected: false, + }, + { + receiptStatus: { Failure: "" }, + statusFilter: "onlyFailed", + expected: true, + }, + ]; + it.each(table)( + "should return `$expected` for `$receiptStatus` with filter `$statusFilter`", + ({ receiptStatus, statusFilter, expected }) => { + expect(isMatchingReceiptStatus(receiptStatus, statusFilter)).toEqual( + expected + ); + } + ); + }); +}); diff --git a/packages/near-lake-primitives/test/helpers.ts b/packages/near-lake-primitives/test/helpers.ts new file mode 100644 index 0000000..0468850 --- /dev/null +++ b/packages/near-lake-primitives/test/helpers.ts @@ -0,0 +1,10 @@ +import { readFile } from "fs/promises"; +import { Block } from "../src"; + +export async function getBlock(blockHeight: number) { + const streamerMessageBuffer = await readFile( + `${__dirname}/../../../blocks/${blockHeight}.json` + ); + const streamerMessage = JSON.parse(streamerMessageBuffer.toString()); + return Block.fromStreamerMessage(streamerMessage); +} \ No newline at end of file