diff --git a/docs/.gitbook/assets/swagger-indexer.yaml b/docs/.gitbook/assets/swagger-indexer.yaml index 583047f285..ec0aa7f4c9 100644 --- a/docs/.gitbook/assets/swagger-indexer.yaml +++ b/docs/.gitbook/assets/swagger-indexer.yaml @@ -2335,6 +2335,146 @@ paths: schema: $ref: '#/components/schemas/InternalServerErrorDTO' tags: *ref_0 + /entities/token-mints: + get: + operationId: EntityApi_searchTokenMints + summary: Search token mints (carbon credits) + description: >- + Search for issued tokens (Mint Token VPs) with filtering by amount, + time period, geography, policy, methodology, and other criteria. + Returns enriched results with token info, policy details, and + project attributes. + parameters: + - name: pageIndex + required: false + in: query + description: Page index (zero-based) + example: 0 + schema: + type: number + - name: pageSize + required: false + in: query + description: Page size + example: 20 + schema: + type: number + - name: orderField + required: false + in: query + description: Field to order by + example: analytics.tokenAmount + schema: + type: string + - name: orderDir + required: false + in: query + description: Order direction + example: DESC + schema: + type: string + - name: keywords + required: false + in: query + description: Keywords for text search (JSON array) + example: '["carbon","solar"]' + schema: + type: string + - name: minAmount + required: false + in: query + description: Minimum token amount + example: '100' + schema: + type: string + - name: maxAmount + required: false + in: query + description: Maximum token amount + example: '10000' + schema: + type: string + - name: startDate + required: false + in: query + description: Start date for filtering (ISO 8601) + example: '2024-01-01T00:00:00Z' + schema: + type: string + - name: endDate + required: false + in: query + description: End date for filtering (ISO 8601) + example: '2024-12-31T23:59:59Z' + schema: + type: string + - name: policyId + required: false + in: query + description: Filter by policy topic ID + example: 0.0.12345 + schema: + type: string + - name: tokenId + required: false + in: query + description: Filter by Hedera token ID + example: 0.0.67890 + schema: + type: string + - name: geography + required: false + in: query + description: Filter by geography / location keyword + example: India + schema: + type: string + - name: schemaName + required: false + in: query + description: Filter by schema / methodology name + example: iRec + schema: + type: string + - name: issuer + required: false + in: query + description: Filter by issuer DID + schema: + type: string + responses: + '200': + description: Token mint search results + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/TokenMintResultDTO' + pageIndex: + type: number + pageSize: + type: number + total: + type: number + totalAmount: + type: number + description: >- + Sum of all matching token amounts across the + entire result set + example: 500000 + order: + type: object + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/InternalServerErrorDTO' + tags: *ref_0 /landing/analytics: get: operationId: LandingApi_getOnboardingStat @@ -7652,3 +7792,72 @@ components: - tokensCount - rate - tags + TokenMintResultDTO: + type: object + description: Token mint (carbon credit) search result + properties: + consensusTimestamp: + type: string + description: Consensus timestamp + example: '1706823227.586179534' + topicId: + type: string + description: Topic identifier + example: 0.0.4481265 + tokenId: + type: string + description: Hedera token identifier + example: 0.0.67890 + tokenName: + type: string + description: Token name from Hedera + example: Carbon Credit Token + tokenSymbol: + type: string + description: Token symbol + example: CCT + tokenAmount: + type: string + description: Minted token amount (string) + example: '1000' + tokenAmountNumeric: + type: number + description: Minted token amount (numeric) + example: 1000 + policyId: + type: string + description: Policy topic identifier + example: 0.0.12345 + policyDescription: + type: string + description: Policy description + example: Solar energy carbon credit policy + schemaNames: + description: Schema/methodology names + type: array + items: + type: string + example: + - iRec + issuer: + type: string + description: Issuer DID + example: >- + did:hedera:testnet:8Go53QCUXZ4nzSQMyoWovWCxseogGTMLDiHg14Fkz4VN_0.0.4481265 + owner: + type: string + description: Owner + mintDate: + type: string + description: Mint date (ISO 8601) + example: '2024-06-15T12:00:00.000Z' + geography: + type: string + description: Geography / project location + example: India + required: + - consensusTimestamp + - topicId + - tokenId + - tokenAmount + - tokenAmountNumeric \ No newline at end of file diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 3bd54cee34..af47e69a2c 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -71,6 +71,7 @@ * [Returns Schema Tree](guardian/global-indexer/indexer-apis/returns-schema-tree.md) * [Returns Tokens](guardian/global-indexer/indexer-apis/returns-tokens.md) * [Returns Token as per TokenID](guardian/global-indexer/indexer-apis/returns-token-as-per-tokenid.md) + * [Returns Token Mints](guardian/global-indexer/indexer-apis/returns-token-mints.md) * [Returns Roles](guardian/global-indexer/indexer-apis/returns-roles.md) * [Returns Role as per MessageID](guardian/global-indexer/indexer-apis/returns-role-as-per-messageid.md) * [Returns DIDs](guardian/global-indexer/indexer-apis/returns-dids.md) diff --git a/docs/guardian/global-indexer/indexer-apis/returns-token-mints.md b/docs/guardian/global-indexer/indexer-apis/returns-token-mints.md new file mode 100644 index 0000000000..0c9ed679ae --- /dev/null +++ b/docs/guardian/global-indexer/indexer-apis/returns-token-mints.md @@ -0,0 +1,30 @@ +# Returns Token Mints (Carbon Credits) + +Search and filter tokenized carbon credit mints with support for filtering by token, policy, amount range, geography, date range, and more. Returns paginated results along with the aggregate `totalAmount` of all matching mints. + +## Query Parameters + +| Parameter | Type | Description | +| ------------ | ------ | ------------------------------------------------ | +| tokenId | string | Filter by Hedera token ID | +| policyId | string | Filter by policy consensus timestamp | +| minAmount | number | Minimum token amount | +| maxAmount | number | Maximum token amount | +| geography | string | Filter by geography / project location | +| schemaName | string | Filter by schema name of the underlying VC | +| issuer | string | Filter by issuer DID | +| startDate | string | Start of date range (ISO 8601) | +| endDate | string | End of date range (ISO 8601) | +| keywords | string | Comma-separated keywords for full-text search | +| orderField | string | Field to sort by (e.g. `consensusTimestamp`) | +| orderDir | string | Sort direction: `asc` or `desc` | +| pageIndex | number | Page index (0-based) | +| pageSize | number | Number of items per page | + +## Response + +The response includes the standard paginated fields (`items`, `pageIndex`, `pageSize`, `total`) plus an additional `totalAmount` field representing the sum of all matching mint amounts across all pages. + +{% swagger src="../../../.gitbook/assets/swagger-indexer.yaml" path="/entities/token-mints" method="get" %} +[swagger-indexer.yaml](../../../.gitbook/assets/swagger-indexer.yaml) +{% endswagger %} diff --git a/e2e-tests/cypress/e2e/api-tests/017_indexer/getTokenMints.cy.js b/e2e-tests/cypress/e2e/api-tests/017_indexer/getTokenMints.cy.js new file mode 100644 index 0000000000..93926b0e93 --- /dev/null +++ b/e2e-tests/cypress/e2e/api-tests/017_indexer/getTokenMints.cy.js @@ -0,0 +1,220 @@ +import { METHOD, STATUS_CODE } from "../../../support/api/api-const"; +import API from "../../../support/ApiUrls"; + +context("Indexer. Search token mints (carbon credits)", { tags: ['indexer', 'firstPool'] }, () => { + + it("Search token mints - default (no filters)", () => { + cy.request({ + method: METHOD.GET, + url: API.ApiIndexer + API.IndexerTokenMints, + }).then((response) => { + expect(response.status).eql(STATUS_CODE.OK); + expect(response.body).to.have.property("total"); + expect(response.body).to.have.property("totalAmount"); + expect(response.body).to.have.property("pageIndex"); + expect(response.body).to.have.property("pageSize"); + expect(response.body).to.have.property("items"); + expect(response.body.items).to.be.an("array"); + if (response.body.items.length > 0) { + const item = response.body.items[0]; + expect(item).to.have.property("consensusTimestamp"); + expect(item).to.have.property("topicId"); + expect(item).to.have.property("tokenId"); + expect(item).to.have.property("tokenAmount"); + expect(item).to.have.property("tokenAmountNumeric"); + expect(item.tokenAmountNumeric).to.be.a("number"); + } + }); + }); + + it("Search token mints - with pagination", () => { + cy.request({ + method: METHOD.GET, + url: API.ApiIndexer + API.IndexerTokenMints, + qs: { + pageIndex: 0, + pageSize: 5, + }, + }).then((response) => { + expect(response.status).eql(STATUS_CODE.OK); + expect(response.body.pageSize).to.be.at.most(5); + expect(response.body.pageIndex).to.eq(0); + expect(response.body.items.length).to.be.at.most(5); + }); + }); + + it("Search token mints - with ordering by tokenAmount", () => { + cy.request({ + method: METHOD.GET, + url: API.ApiIndexer + API.IndexerTokenMints, + qs: { + orderField: "analytics.tokenAmount", + orderDir: "DESC", + pageSize: 10, + }, + }).then((response) => { + expect(response.status).eql(STATUS_CODE.OK); + expect(response.body.items).to.be.an("array"); + }); + }); + + it("Search token mints - filter by minAmount", () => { + cy.request({ + method: METHOD.GET, + url: API.ApiIndexer + API.IndexerTokenMints, + qs: { + minAmount: "1", + }, + }).then((response) => { + expect(response.status).eql(STATUS_CODE.OK); + expect(response.body.items).to.be.an("array"); + for (const item of response.body.items) { + expect(item.tokenAmountNumeric).to.be.at.least(1); + } + }); + }); + + it("Search token mints - filter by maxAmount", () => { + cy.request({ + method: METHOD.GET, + url: API.ApiIndexer + API.IndexerTokenMints, + qs: { + maxAmount: "999999999", + }, + }).then((response) => { + expect(response.status).eql(STATUS_CODE.OK); + expect(response.body.items).to.be.an("array"); + for (const item of response.body.items) { + expect(item.tokenAmountNumeric).to.be.at.most(999999999); + } + }); + }); + + it("Search token mints - filter by amount range", () => { + cy.request({ + method: METHOD.GET, + url: API.ApiIndexer + API.IndexerTokenMints, + qs: { + minAmount: "1", + maxAmount: "100000", + }, + }).then((response) => { + expect(response.status).eql(STATUS_CODE.OK); + expect(response.body.items).to.be.an("array"); + for (const item of response.body.items) { + expect(item.tokenAmountNumeric).to.be.at.least(1); + expect(item.tokenAmountNumeric).to.be.at.most(100000); + } + }); + }); + + it("Search token mints - filter by date range", () => { + cy.request({ + method: METHOD.GET, + url: API.ApiIndexer + API.IndexerTokenMints, + qs: { + startDate: "2020-01-01T00:00:00Z", + endDate: "2030-12-31T23:59:59Z", + }, + }).then((response) => { + expect(response.status).eql(STATUS_CODE.OK); + expect(response.body.items).to.be.an("array"); + }); + }); + + it("Search token mints - filter by policyId", () => { + // First get any token mint to extract policyId + cy.request({ + method: METHOD.GET, + url: API.ApiIndexer + API.IndexerTokenMints, + qs: { pageSize: 1 }, + }).then((response) => { + if (response.body.items.length > 0 && response.body.items[0].policyId) { + const policyId = response.body.items[0].policyId; + cy.request({ + method: METHOD.GET, + url: API.ApiIndexer + API.IndexerTokenMints, + qs: { policyId }, + }).then((filteredResponse) => { + expect(filteredResponse.status).eql(STATUS_CODE.OK); + for (const item of filteredResponse.body.items) { + expect(item.policyId).to.eq(policyId); + } + }); + } + }); + }); + + it("Search token mints - filter by tokenId", () => { + // First get any token mint to extract tokenId + cy.request({ + method: METHOD.GET, + url: API.ApiIndexer + API.IndexerTokenMints, + qs: { pageSize: 1 }, + }).then((response) => { + if (response.body.items.length > 0) { + const tokenId = response.body.items[0].tokenId; + cy.request({ + method: METHOD.GET, + url: API.ApiIndexer + API.IndexerTokenMints, + qs: { tokenId }, + }).then((filteredResponse) => { + expect(filteredResponse.status).eql(STATUS_CODE.OK); + for (const item of filteredResponse.body.items) { + expect(item.tokenId).to.eq(tokenId); + } + }); + } + }); + }); + + it("Search token mints - enriched data includes token info", () => { + cy.request({ + method: METHOD.GET, + url: API.ApiIndexer + API.IndexerTokenMints, + qs: { pageSize: 10 }, + }).then((response) => { + expect(response.status).eql(STATUS_CODE.OK); + expect(response.body.items).to.be.an("array"); + // Verify structure of enriched items + for (const item of response.body.items) { + expect(item).to.have.property("consensusTimestamp"); + expect(item).to.have.property("topicId"); + expect(item).to.have.property("tokenId"); + expect(item).to.have.property("tokenAmount"); + expect(item).to.have.property("tokenAmountNumeric"); + // Optional enriched fields should be present if available + if (item.tokenName !== undefined) { + expect(item.tokenName).to.be.a("string"); + } + if (item.policyDescription !== undefined) { + expect(item.policyDescription).to.be.a("string"); + } + if (item.mintDate !== undefined) { + expect(item.mintDate).to.be.a("string"); + } + } + }); + }); + + it("Search token mints - combined filters (amount + date)", () => { + cy.request({ + method: METHOD.GET, + url: API.ApiIndexer + API.IndexerTokenMints, + qs: { + minAmount: "1", + startDate: "2020-01-01T00:00:00Z", + endDate: "2030-12-31T23:59:59Z", + pageSize: 10, + orderField: "analytics.tokenAmount", + orderDir: "DESC", + }, + }).then((response) => { + expect(response.status).eql(STATUS_CODE.OK); + expect(response.body.items).to.be.an("array"); + for (const item of response.body.items) { + expect(item.tokenAmountNumeric).to.be.at.least(1); + } + }); + }); +}); diff --git a/e2e-tests/cypress/support/ApiUrls.js b/e2e-tests/cypress/support/ApiUrls.js index b75ebc1810..44105a09bb 100644 --- a/e2e-tests/cypress/support/ApiUrls.js +++ b/e2e-tests/cypress/support/ApiUrls.js @@ -237,6 +237,7 @@ const API = { IndexerNFTs: "entities/nfts/", IndexerTopics: "entities/topics/", IndexerContracts: "entities/contracts/", + IndexerTokenMints: "entities/token-mints/", //Worker tasks WorkerTasks: "worker-tasks/", @@ -283,5 +284,6 @@ const API = { TenantsInvite: "tenants/invite", TermsAgree: "accounts/terms/agree" + }; export default API; diff --git a/indexer-api-gateway/src/api/services/entities.ts b/indexer-api-gateway/src/api/services/entities.ts index 3322fc6abe..a55ebfc588 100644 --- a/indexer-api-gateway/src/api/services/entities.ts +++ b/indexer-api-gateway/src/api/services/entities.ts @@ -62,7 +62,9 @@ import { FormulaDetailsDTO, FormulaDTO, FormulaRelationshipsDTO, - SchemasPackageDetailsDTO + SchemasPackageDetailsDTO, + TokenMintResultDTO, + TokenMintPageDTO } from '#dto'; @Controller('entities') @@ -2117,5 +2119,126 @@ export class EntityApi extends ApiClient { ); } //#endregion + + //#region TOKEN MINTS + @ApiOperation({ + summary: 'Search token mints', + description: + 'Search for issued token mint events (VP documents representing token minting). ' + + 'Supports filtering by amount range, time period, policy (methodology), token, ' + + 'geography, schema name (standard), and issuer. Results include enriched token ' + + 'and policy information. Useful for finding suitable carbon credits.', + }) + @ApiPaginatedRequest + @ApiOkResponse({ + description: 'Token Mints', + type: TokenMintPageDTO, + }) + @ApiInternalServerErrorResponse({ + description: 'Internal server error', + type: InternalServerErrorDTO, + }) + @Get('/token-mints') + @ApiQuery({ + name: 'minAmount', + description: 'Minimum token amount (inclusive)', + example: '1000', + required: false, + }) + @ApiQuery({ + name: 'maxAmount', + description: 'Maximum token amount (inclusive)', + example: '100000', + required: false, + }) + @ApiQuery({ + name: 'startDate', + description: 'Start date for minting time filter (ISO 8601 or epoch seconds, e.g. 2024-01-01T00:00:00Z)', + example: '2024-01-01T00:00:00Z', + required: false, + }) + @ApiQuery({ + name: 'endDate', + description: 'End date for minting time filter (ISO 8601 or epoch seconds, e.g. 2024-12-31T23:59:59Z)', + example: '2024-12-31T23:59:59Z', + required: false, + }) + @ApiQuery({ + name: 'policyId', + description: 'Policy message identifier (methodology)', + example: '1706823227.586179534', + required: false, + }) + @ApiQuery({ + name: 'tokenId', + description: 'Token identifier', + example: '0.0.1621155', + required: false, + }) + @ApiQuery({ + name: 'geography', + description: 'Geography / region keyword to search', + example: 'United States', + required: false, + }) + @ApiQuery({ + name: 'schemaName', + description: 'Schema name / standard type', + example: 'Monitoring Report', + required: false, + }) + @ApiQuery({ + name: 'issuer', + description: 'Issuer DID', + example: + 'did:hedera:testnet:8Go53QCUXZ4nzSQMyoWovWCxseogGTMLDiHg14Fkz4VN_0.0.4481265', + required: false, + }) + @ApiQuery({ + name: 'keywords', + description: 'Keywords to search (JSON array)', + examples: { + 'carbon': { + description: 'Search for carbon-related mint events', + value: '["carbon"]', + }, + }, + required: false, + }) + @HttpCode(HttpStatus.OK) + async searchTokenMints( + @Query('pageIndex') pageIndex?: number, + @Query('pageSize') pageSize?: number, + @Query('orderField') orderField?: string, + @Query('orderDir') orderDir?: string, + @Query('keywords') keywords?: string, + @Query('minAmount') minAmount?: string, + @Query('maxAmount') maxAmount?: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + @Query('policyId') policyId?: string, + @Query('tokenId') tokenId?: string, + @Query('geography') geography?: string, + @Query('schemaName') schemaName?: string, + @Query('issuer') issuer?: string + ) { + return await this.send(IndexerMessageAPI.SEARCH_TOKEN_MINTS, { + pageIndex, + pageSize, + orderField, + orderDir, + keywords, + minAmount, + maxAmount, + startDate, + endDate, + policyId, + tokenId, + geography, + schemaName, + issuer, + }); + } + //#endregion //#endregion } diff --git a/indexer-api-gateway/src/dto/details/index.ts b/indexer-api-gateway/src/dto/details/index.ts index 7b09e261fa..73c7617005 100644 --- a/indexer-api-gateway/src/dto/details/index.ts +++ b/indexer-api-gateway/src/dto/details/index.ts @@ -16,4 +16,5 @@ export * from './token.details.js'; export * from './update-file.js'; export * from './label.details.js'; export * from './statistic.details.js'; -export * from './formula.details.js'; \ No newline at end of file +export * from './formula.details.js'; +export * from './token-mint.details.js'; \ No newline at end of file diff --git a/indexer-api-gateway/src/dto/details/token-mint.details.ts b/indexer-api-gateway/src/dto/details/token-mint.details.ts new file mode 100644 index 0000000000..e7187eb6b2 --- /dev/null +++ b/indexer-api-gateway/src/dto/details/token-mint.details.ts @@ -0,0 +1,123 @@ +import { + TokenMintResult, +} from '@indexer/interfaces'; +import { ApiProperty } from '@nestjs/swagger'; + +export class TokenMintResultDTO implements TokenMintResult { + @ApiProperty({ + description: 'Consensus timestamp (unique message identifier)', + example: '1706823227.586179534', + }) + consensusTimestamp: string; + + @ApiProperty({ + description: 'Topic identifier', + example: '0.0.1960', + }) + topicId: string; + + @ApiProperty({ + description: 'Token identifier', + example: '0.0.1621155', + }) + tokenId: string; + + @ApiProperty({ + description: 'Token name', + example: 'Carbon Credit Token', + }) + tokenName?: string; + + @ApiProperty({ + description: 'Token symbol', + example: 'CCT', + }) + tokenSymbol?: string; + + @ApiProperty({ + description: 'Minted token amount', + example: '5000', + }) + tokenAmount: string; + + @ApiProperty({ + description: 'Parsed numeric amount for sorting', + example: 5000, + }) + tokenAmountNumeric?: number; + + @ApiProperty({ + description: 'Policy message identifier (methodology)', + example: '1706823227.586179534', + }) + policyId?: string; + + @ApiProperty({ + description: 'Policy description / name', + example: 'iREC Policy', + }) + policyDescription?: string; + + @ApiProperty({ + description: 'Schema names (standard type)', + example: ['MintToken', 'Monitoring Report'], + }) + schemaNames?: string[]; + + @ApiProperty({ + description: 'Issuer DID', + example: + 'did:hedera:testnet:8Go53QCUXZ4nzSQMyoWovWCxseogGTMLDiHg14Fkz4VN_0.0.4481265', + }) + issuer?: string; + + @ApiProperty({ + description: 'Owner', + example: '0.0.1234', + }) + owner?: string; + + @ApiProperty({ + description: 'Mint date (derived from consensus timestamp)', + example: '2024-02-06T05:40:45.000Z', + }) + mintDate?: string; + + @ApiProperty({ + description: 'Geography (if available)', + example: 'United States', + }) + geography?: string; +} + +export class TokenMintPageDTO { + @ApiProperty({ + description: 'Items on the current page', + type: [TokenMintResultDTO], + }) + items: TokenMintResultDTO[]; + + @ApiProperty({ + description: 'Page index (zero-based)', + example: 0, + }) + pageIndex: number; + + @ApiProperty({ + description: 'Items per page', + example: 20, + }) + pageSize: number; + + @ApiProperty({ + description: 'Total number of matching token mint events', + example: 150, + }) + total: number; + + @ApiProperty({ + description: 'Sum of all matching token amounts across the entire result set (not just the current page)', + example: 500000, + }) + totalAmount: number; +} diff --git a/indexer-api-gateway/tsconfig.json b/indexer-api-gateway/tsconfig.json index 7ef75633a1..c6f07ec4bd 100644 --- a/indexer-api-gateway/tsconfig.json +++ b/indexer-api-gateway/tsconfig.json @@ -8,6 +8,9 @@ "resolveJsonModule": true, "experimentalDecorators": true, "inlineSourceMap": true, + "types": [ + "node" + ], "skipLibCheck": true, "lib": [ "esnext" diff --git a/indexer-common/src/messages/message-api.ts b/indexer-common/src/messages/message-api.ts index 26e22ac282..251cc24952 100644 --- a/indexer-common/src/messages/message-api.ts +++ b/indexer-common/src/messages/message-api.ts @@ -112,8 +112,12 @@ export enum IndexerMessageAPI { GET_ARTIFACT_FILE_META = 'GET_ARTIFACT_FILE_META', GET_ARTIFACT_FILE_CHUNK = 'GET_ARTIFACT_FILE_CHUNK', - GET_COMPARE_ORIGINAL_POLICY = "INDEXER_API_GET_COMPARE_ORIGINAL_POLICY", - GET_DERIVATIONS = "INDEXER_API_GET_DERIVATIONS", + GET_COMPARE_ORIGINAL_POLICY = 'INDEXER_API_GET_COMPARE_ORIGINAL_POLICY', + GET_DERIVATIONS = 'INDEXER_API_GET_DERIVATIONS', + + // #region TOKEN MINTS + SEARCH_TOKEN_MINTS = 'INDEXER_API_SEARCH_TOKEN_MINTS', + // #endregion } diff --git a/indexer-common/tsconfig.json b/indexer-common/tsconfig.json index 76bc55ac87..a3d55af4bb 100644 --- a/indexer-common/tsconfig.json +++ b/indexer-common/tsconfig.json @@ -12,6 +12,9 @@ "experimentalDecorators": true, "esModuleInterop": true, "declaration": true, + "types": [ + "node" + ], "skipLibCheck": true }, "include": [ diff --git a/indexer-frontend/package-lock.json b/indexer-frontend/package-lock.json index 11f0578761..4faab4a652 100644 --- a/indexer-frontend/package-lock.json +++ b/indexer-frontend/package-lock.json @@ -20,6 +20,7 @@ "@angular/router": "^17.3.0", "@indexer/interfaces": "file:../indexer-interfaces", "@jsverse/transloco": "^7.2.1", + "@meeco/cryppo": "^2.0.2", "ag-grid-angular": "34.2.0", "ag-grid-community": "34.2.0", "chart.js": "^4.4.3", @@ -40,6 +41,7 @@ "@angular-devkit/build-angular": "^17.3.2", "@angular/cli": "^17.3.2", "@angular/compiler-cli": "^17.3.0", + "@types/glob": "^8.1.0", "@types/jasmine": "~5.1.0", "@types/mapbox-gl": "^3.1.0", "@types/papaparse": "5.3.16", @@ -57,6 +59,7 @@ "version": "3.5.0-rc", "license": "Apache-2.0", "devDependencies": { + "@types/glob": "^8.1.0", "@types/node": "^22.15.19", "tslint": "^6.1.3", "typescript": "^5.8.3" @@ -3854,6 +3857,30 @@ "tslib": "^2.1.0" } }, + "node_modules/@meeco/cryppo": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@meeco/cryppo/-/cryppo-2.0.2.tgz", + "integrity": "sha512-L8K1eGrH5/GXcUQu9IxrWxXU38mpuUtqtx8chEU14VNsJWC2wb1GLzywNHoyKNqYkc0qhur8vTGN8bOtzJSBvA==", + "license": "MIT", + "dependencies": { + "bson": "^4.0.4", + "buffer": "^5.1.0", + "node-forge": "0.10.0", + "yaml": "^1.6.0" + }, + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@meeco/cryppo/node_modules/node-forge": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", + "integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/@multiformats/base-x": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@multiformats/base-x/-/base-x-4.0.1.tgz", @@ -4741,6 +4768,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "^5.1.2", + "@types/node": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -4789,6 +4827,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.6.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.2.tgz", @@ -5710,6 +5755,18 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bson": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/bson/-/bson-4.7.2.tgz", + "integrity": "sha512-Ry9wCtIZ5kGqkJoi6aD8KjxFZEx78guTQDnpXWiNthsxzrxAK/i8E6pCHAIZTbaEFWcOCvbecMukfK7XUvyLpQ==", + "license": "Apache-2.0", + "dependencies": { + "buffer": "^5.6.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -13307,7 +13364,7 @@ "version": "5.4.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -14542,6 +14599,15 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/indexer-frontend/src/app/app.routes.ts b/indexer-frontend/src/app/app.routes.ts index caafec9460..f2283b7ae1 100644 --- a/indexer-frontend/src/app/app.routes.ts +++ b/indexer-frontend/src/app/app.routes.ts @@ -52,6 +52,7 @@ import { FormulaDetailsComponent } from '@views/details/formula-details/formula- import { PriorityQueueComponent } from '@views/priority-queue/priority-queue.component'; import { SchemasPackagesComponent } from '@views/collections/schemas-packages/schemas-packages.component'; import { SchemasPackageDetailsComponent } from '@views/details/schemas-packages-details/schemas-packages-details.component'; +import { TokenMintsComponent } from '@views/collections/token-mints/token-mints.component'; export const routes: Routes = [ // _DEV @@ -87,6 +88,7 @@ export const routes: Routes = [ { path: 'statistic-documents', component: StatisticDocumentsComponent }, { path: 'formulas', component: FormulasComponent }, { path: 'schemas-packages', component: SchemasPackagesComponent }, + { path: 'token-mints', component: TokenMintsComponent }, //Details { path: 'registries/:id', component: RegistryDetailsComponent }, diff --git a/indexer-frontend/src/app/components/header/header.component.ts b/indexer-frontend/src/app/components/header/header.component.ts index f0b85f6e8e..331689e17c 100644 --- a/indexer-frontend/src/app/components/header/header.component.ts +++ b/indexer-frontend/src/app/components/header/header.component.ts @@ -120,6 +120,10 @@ export class HeaderComponent { label: 'header.label_documents', routerLink: '/label-documents', }, + { + label: 'header.token_mints', + routerLink: '/token-mints', + }, ]; public othersMenu: MenuItem[] = [ diff --git a/indexer-frontend/src/app/services/entities.service.ts b/indexer-frontend/src/app/services/entities.service.ts index 2007db3f6c..3fc8d9923f 100644 --- a/indexer-frontend/src/app/services/entities.service.ts +++ b/indexer-frontend/src/app/services/entities.service.ts @@ -472,4 +472,15 @@ export class EntitiesService { messageId }) as any; } + + //#region TOKEN MINTS + public getTokenMints(filters: PageFilters): Observable { + const entity = 'token-mints'; + const options = ApiUtils.getOptions(filters); + return this.http.get( + `${this.url}/${entity}`, + options + ) as any; + } + //#endregion } diff --git a/indexer-frontend/src/app/views/collections/token-mints/token-mints.component.html b/indexer-frontend/src/app/views/collections/token-mints/token-mints.component.html new file mode 100644 index 0000000000..da22bf9a0a --- /dev/null +++ b/indexer-frontend/src/app/views/collections/token-mints/token-mints.component.html @@ -0,0 +1,37 @@ +
+
+

{{ 'header.token_mints' | transloco }}

+
+ {{ 'grid.total_amount' | transloco }}: + {{ totalAmount | number }} +
+
+ +
+ +
+ @for (filter of filters; track $index) { + @if (filter.type === 'input') { + + {{ filter.label | transloco }} + + + } + } +
+
+
+
+
+
diff --git a/indexer-frontend/src/app/views/collections/token-mints/token-mints.component.scss b/indexer-frontend/src/app/views/collections/token-mints/token-mints.component.scss new file mode 100644 index 0000000000..a6edcdbb3c --- /dev/null +++ b/indexer-frontend/src/app/views/collections/token-mints/token-mints.component.scss @@ -0,0 +1,18 @@ +.total-amount { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + font-size: 16px; + + &__label { + font-weight: 500; + color: var(--text-color-secondary, #6c757d); + } + + &__value { + font-weight: 700; + font-size: 20px; + color: var(--primary-color, #3b82f6); + } +} diff --git a/indexer-frontend/src/app/views/collections/token-mints/token-mints.component.ts b/indexer-frontend/src/app/views/collections/token-mints/token-mints.component.ts new file mode 100644 index 0000000000..128571d88f --- /dev/null +++ b/indexer-frontend/src/app/views/collections/token-mints/token-mints.component.ts @@ -0,0 +1,238 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatSortModule } from '@angular/material/sort'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { MatTableModule } from '@angular/material/table'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { BaseGridComponent, Filter } from '../base-grid/base-grid.component'; +import { TranslocoModule } from '@jsverse/transloco'; +import { EntitiesService } from '@services/entities.service'; +import { ColumnType, TableComponent } from '@components/table/table.component'; +import { InputGroupModule } from 'primeng/inputgroup'; +import { InputGroupAddonModule } from 'primeng/inputgroupaddon'; +import { InputTextModule } from 'primeng/inputtext'; +import { ChipsModule } from 'primeng/chips'; +import { HederaType } from '@components/hedera-explorer/hedera-explorer.component'; + +@Component({ + selector: 'token-mints', + templateUrl: './token-mints.component.html', + styleUrls: [ + '../base-grid/base-grid.component.scss', + './token-mints.component.scss', + ], + standalone: true, + imports: [ + CommonModule, + MatPaginatorModule, + MatTableModule, + MatSortModule, + MatFormFieldModule, + MatSelectModule, + MatInputModule, + FormsModule, + MatButtonModule, + TranslocoModule, + ReactiveFormsModule, + TableComponent, + InputGroupModule, + InputGroupAddonModule, + InputTextModule, + ChipsModule, + ], +}) +export class TokenMintsComponent extends BaseGridComponent { + public totalAmount: number = 0; + + columns: any[] = [ + { + type: ColumnType.BUTTON, + title: 'grid.open', + btn_label: 'grid.open', + width: '100px', + callback: this.onOpen.bind(this), + }, + { + type: ColumnType.TEXT, + field: 'tokenName', + title: 'grid.name', + width: '200px', + }, + { + type: ColumnType.TEXT, + field: 'tokenSymbol', + title: 'grid.symbol', + width: '100px', + }, + { + type: ColumnType.TEXT, + field: 'tokenAmount', + title: 'grid.token_amount', + width: '150px', + sort: true, + formatValue: (value: any) => { + const num = parseFloat(value); + if (!isNaN(num)) { + return num.toLocaleString(); + } + return value; + }, + }, + { + type: ColumnType.HEDERA, + field: 'tokenId', + title: 'grid.token_id', + width: '200px', + hederaType: HederaType.TOKEN, + }, + { + type: ColumnType.TEXT, + field: 'policyDescription', + title: 'grid.policy', + width: '250px', + }, + { + type: ColumnType.TEXT, + field: 'issuer', + title: 'grid.issuer', + width: '300px', + }, + { + type: ColumnType.TEXT, + field: 'geography', + title: 'grid.coordinates', + width: '200px', + formatValue: (value: any) => { + if (typeof value === 'string') { + const parts = value.split(','); + if (parts.length === 2) { + return `Lat: ${parts[0]}, Lng: ${parts[1]}`; + } + } + return value; + }, + }, + { + type: ColumnType.BUTTON, + title: 'grid.map', + btn_label: 'grid.view_map', + width: '100px', + callback: this.onMap.bind(this), + }, + { + type: ColumnType.TEXT, + field: 'mintDate', + title: 'grid.date', + width: '200px', + sort: true, + formatValue: (value: any) => { + if (value) { + const date = new Date(value); + return date.toLocaleString(); + } + return ''; + }, + }, + ]; + + constructor( + private entitiesService: EntitiesService, + route: ActivatedRoute, + router: Router + ) { + super(route, router); + + this.orderField = 'consensusTimestamp'; + this.orderDir = 'desc'; + + this.filters.push( + new Filter({ + type: 'input', + field: 'tokenId', + label: 'grid.filter.token_id', + }), + new Filter({ + type: 'input', + field: 'policyId', + label: 'grid.filter.policy_id', + }), + new Filter({ + type: 'input', + field: 'minAmount', + label: 'grid.filter.min_amount', + }), + new Filter({ + type: 'input', + field: 'maxAmount', + label: 'grid.filter.max_amount', + }), + new Filter({ + type: 'input', + field: 'geography', + label: 'grid.filter.geography', + }), + new Filter({ + type: 'input', + field: 'schemaName', + label: 'grid.filter.schema', + }), + new Filter({ + type: 'input', + field: 'startDate', + label: 'grid.filter.start_date', + }), + new Filter({ + type: 'input', + field: 'endDate', + label: 'grid.filter.end_date', + }), + ); + } + + protected override getFilters(): any { + const filters = super.getFilters(); + // When sorting by token amount, use the analytics field + if (filters.orderField === 'tokenAmount') { + filters.orderField = 'analytics.tokenAmount'; + } + return filters; + } + + protected loadData(): void { + const filters = this.getFilters(); + this.loadingData = true; + this.entitiesService.getTokenMints(filters).subscribe({ + next: (result) => { + this.setResult(result); + this.totalAmount = result?.totalAmount || 0; + setTimeout(() => { + this.loadingData = false; + }, 500); + }, + error: ({ message }) => { + this.loadingData = false; + console.error(message); + }, + }); + } + + protected loadFilters(): void { + this.loadingFilters = false; + } + + public override onOpen(element: any) { + this.router.navigate([`/vp-documents/${element.consensusTimestamp}`]); + } + + public onMap(element: any) { + if (element && element.geography) { + const url = `https://www.google.com/maps/search/?api=1&query=${element.geography}`; + window.open(url, '_blank'); + } + } +} diff --git a/indexer-frontend/src/assets/i18n/en.json b/indexer-frontend/src/assets/i18n/en.json index bdb7a2a5b2..d5e2910019 100644 --- a/indexer-frontend/src/assets/i18n/en.json +++ b/indexer-frontend/src/assets/i18n/en.json @@ -47,6 +47,7 @@ "statistics": "Statistics", "labels": "Labels", "formulas": "Formulas", + "token_mints": "Carbon Credit Mints", "loading_progress": "Loading data...", "left": "left" }, @@ -105,6 +106,11 @@ "createdTimestamp": "Created Date", "modifiedTimestamp": "Modified Date", "coordinates": "Coordinates", + "token_amount": "Amount", + "geography": "Geography", + "total_amount": "Total Carbon Credits", + "map": "Map", + "view_map": "View Map", "prioritize": "Prioritize", "send_to_priority": "Send to Priority Update Queue", "lastUpdate": "Last Update", @@ -122,7 +128,14 @@ "schema_id": "Schema Id", "relationship": "Relationship", "target": "Target", - "token_id": "Token Id" + "token_id": "Token Id", + "policy_id": "Policy Id", + "min_amount": "Min Amount", + "max_amount": "Max Amount", + "geography": "Geography", + "schema": "Schema", + "start_date": "Start Date", + "end_date": "End Date" }, "paginator": { "items_per_page": "Items per page", diff --git a/indexer-interfaces/src/interfaces/details/index.ts b/indexer-interfaces/src/interfaces/details/index.ts index afedff5113..ea654cdf19 100644 --- a/indexer-interfaces/src/interfaces/details/index.ts +++ b/indexer-interfaces/src/interfaces/details/index.ts @@ -15,4 +15,5 @@ export * from './token.details.js'; export * from './nft.details.js'; export * from './statistic.details.js'; export * from './label.details.js'; -export * from './formula.details.js'; \ No newline at end of file +export * from './formula.details.js'; +export * from './token-mint.details.js'; \ No newline at end of file diff --git a/indexer-interfaces/src/interfaces/details/token-mint.details.ts b/indexer-interfaces/src/interfaces/details/token-mint.details.ts new file mode 100644 index 0000000000..aa573ede5b --- /dev/null +++ b/indexer-interfaces/src/interfaces/details/token-mint.details.ts @@ -0,0 +1,190 @@ +import { Message } from '../message.interface.js'; + +/** + * Token mint search filters + */ +export interface TokenMintFilters { + /** + * Page index + */ + pageIndex?: number | string; + /** + * Page size + */ + pageSize?: number | string; + /** + * Order direction + */ + orderDir?: string; + /** + * Order field + */ + orderField?: string; + /** + * Keywords + */ + keywords?: string; + /** + * Minimum token amount + */ + minAmount?: string; + /** + * Maximum token amount + */ + maxAmount?: string; + /** + * Start date (ISO 8601) for filtering by minting time + */ + startDate?: string; + /** + * End date (ISO 8601) for filtering by minting time + */ + endDate?: string; + /** + * Policy message identifier (methodology) + */ + policyId?: string; + /** + * Token identifier + */ + tokenId?: string; + /** + * Geography / region keyword + */ + geography?: string; + /** + * Schema name (standard type) + */ + schemaName?: string; + /** + * Issuer DID + */ + issuer?: string; +} + +/** + * Token mint analytics + */ +export interface TokenMintAnalytics { + /** + * Text search + */ + textSearch?: string; + /** + * Policy message identifier + */ + policyId?: string; + /** + * Schema message identifiers + */ + schemaIds?: string[]; + /** + * Schema names + */ + schemaNames?: string[]; + /** + * Issuer + */ + issuer?: string; + /** + * Token ID + */ + tokenId?: string; + /** + * Token Amount + */ + tokenAmount?: string; +} + +/** + * Token mint options + */ +export interface TokenMintOptions { + /** + * Issuer + */ + issuer: string; + /** + * Relationships + */ + relationships: string[]; +} + +/** + * Token mint item - a VP document representing a token mint event + */ +export type TokenMint = Message; + +/** + * Enriched token mint result with joined token and policy info + */ +export interface TokenMintResult { + /** + * Consensus timestamp (unique message ID) + */ + consensusTimestamp: string; + /** + * Topic ID + */ + topicId: string; + /** + * Token ID + */ + tokenId: string; + /** + * Token name (from TokenCache) + */ + tokenName?: string; + /** + * Token symbol (from TokenCache) + */ + tokenSymbol?: string; + /** + * Minted amount + */ + tokenAmount: string; + /** + * Parsed numeric amount for sorting + */ + tokenAmountNumeric?: number; + /** + * Policy message identifier (methodology) + */ + policyId?: string; + /** + * Policy description / name + */ + policyDescription?: string; + /** + * Schema names (standard type) + */ + schemaNames?: string[]; + /** + * Issuer DID + */ + issuer?: string; + /** + * Owner + */ + owner?: string; + /** + * Consensus timestamp as a date + */ + mintDate?: string; + /** + * Geography (if available from project coordinates) + */ + geography?: string; +} + +/** + * Token mint search page result with aggregated totalAmount + */ +export interface TokenMintPage { + items: TokenMintResult[]; + pageIndex: number; + pageSize: number; + total: number; + totalAmount: number; + order?: any; +} diff --git a/indexer-interfaces/tsconfig.json b/indexer-interfaces/tsconfig.json index 76bc55ac87..ca881494c7 100644 --- a/indexer-interfaces/tsconfig.json +++ b/indexer-interfaces/tsconfig.json @@ -12,7 +12,8 @@ "experimentalDecorators": true, "esModuleInterop": true, "declaration": true, - "skipLibCheck": true + "skipLibCheck": true, + "types": ["node"] }, "include": [ "src/**/*" diff --git a/indexer-service/src/api/entities.service.ts b/indexer-service/src/api/entities.service.ts index a8739fe44d..4882263160 100644 --- a/indexer-service/src/api/entities.service.ts +++ b/indexer-service/src/api/entities.service.ts @@ -11,6 +11,7 @@ import { TopicCache, TokenCache, NftCache, + ProjectCoordinates, } from '@indexer/common'; import escapeStringRegexp from 'escape-string-regexp'; import { Relationships } from '../utils/relationships.js'; @@ -60,7 +61,9 @@ import { FormulaRelationships, PolicyActivity, SchemasPackageDetails, - TagType + TagType, + TokenMintFilters, + TokenMintResult } from '@indexer/interfaces'; import { parsePageParams } from '../utils/parse-page-params.js'; import axios from 'axios'; @@ -2614,6 +2617,323 @@ export class EntityService { } } //#endregion + + //#region TOKEN MINTS + @MessagePattern(IndexerMessageAPI.SEARCH_TOKEN_MINTS) + async searchTokenMints( + @Payload() msg: TokenMintFilters + ): Promise>> { + try { + const options = parsePageParams(msg as any); + const em = DataBaseHelper.getEntityManager(); + + // Build base filter: VP documents that have a tokenId in analytics (i.e. mint events) + const filters: any = { + type: MessageType.VP_DOCUMENT, + 'analytics.tokenId': { $exists: true, $ne: null }, + }; + + // Filter by tokenId + if (msg.tokenId) { + filters['analytics.tokenId'] = msg.tokenId; + } + + // Filter by policyId (methodology) + if (msg.policyId) { + filters['analytics.policyId'] = msg.policyId; + } + + // Filter by issuer + if (msg.issuer) { + filters['analytics.issuer'] = { + $regex: `.*${escapeStringRegexp(msg.issuer).trim()}.*`, + $options: 'si', + }; + } + + // Filter by schemaName (standard type) + if (msg.schemaName) { + filters['analytics.schemaNames'] = { + $regex: `.*${escapeStringRegexp(msg.schemaName).trim()}.*`, + $options: 'si', + }; + } + + // Filter by geography (text search in analytics.textSearch) + if (msg.geography) { + filters['analytics.textSearch'] = { + $regex: `.*${escapeStringRegexp(msg.geography).trim()}.*`, + $options: 'si', + }; + } + + // Filter by keywords + if (msg.keywords) { + let keywords: string[]; + try { + keywords = JSON.parse(msg.keywords); + } catch { + keywords = []; + } + if (keywords.length > 0) { + if (!filters.$and) { + filters.$and = []; + } + for (const keyword of keywords) { + filters.$and.push({ + 'analytics.textSearch': { + $regex: `.*${escapeStringRegexp(keyword).trim()}.*`, + $options: 'si', + }, + }); + } + } + } + + // Filter by consensus timestamp range (time period) + if (msg.startDate || msg.endDate) { + const timestampFilter: any = {}; + if (msg.startDate) { + const startEpoch = new Date(msg.startDate).getTime() / 1000; + if (!isNaN(startEpoch)) { + timestampFilter.$gte = String(startEpoch); + } + } + if (msg.endDate) { + const endEpoch = new Date(msg.endDate).getTime() / 1000; + if (!isNaN(endEpoch)) { + timestampFilter.$lte = String(endEpoch); + } + } + if (Object.keys(timestampFilter).length > 0) { + filters.consensusTimestamp = timestampFilter; + } + } + + // For amount filtering, we need to use aggregation pipeline since + // tokenAmount is stored as a string in analytics + const hasAmountFilter = msg.minAmount || msg.maxAmount; + + if (hasAmountFilter) { + // Use MongoDB aggregation for numeric comparison on string amounts + const pipeline: any[] = [ + { $match: filters }, + { + $addFields: { + _tokenAmountNum: { + $convert: { input: '$analytics.tokenAmount', to: 'double', onError: 0, onNull: 0 }, + }, + }, + }, + ]; + + const amountMatch: any = {}; + if (msg.minAmount) { + amountMatch.$gte = parseFloat(msg.minAmount); + } + if (msg.maxAmount) { + amountMatch.$lte = parseFloat(msg.maxAmount); + } + pipeline.push({ $match: { _tokenAmountNum: amountMatch } }); + + // Count total and compute totalAmount BEFORE pagination + const countPipeline = [...pipeline, { $count: 'total' }]; + const countResult = await em.aggregate(Message, countPipeline); + const total = countResult.length > 0 ? countResult[0].total : 0; + + const totalAmountPipeline = [...pipeline, { + $group: { _id: null, totalAmount: { $sum: '$_tokenAmountNum' } } + }]; + const totalAmountResult = await em.aggregate(Message, totalAmountPipeline); + const totalAmount = totalAmountResult.length > 0 ? totalAmountResult[0].totalAmount : 0; + + // Sort + if (options.orderBy) { + const sortField = Object.keys(options.orderBy)[0]; + const sortDir = options.orderBy[sortField] === 'ASC' ? 1 : -1; + if (sortField === 'analytics.tokenAmount') { + pipeline.push({ $sort: { _tokenAmountNum: sortDir } }); + } else { + pipeline.push({ $sort: { [sortField]: sortDir } }); + } + } else { + pipeline.push({ $sort: { _tokenAmountNum: -1 } }); + } + + // Paginate + pipeline.push({ $skip: options.offset }); + pipeline.push({ $limit: options.limit }); + + const rows = await em.aggregate(Message, pipeline); + + // Enrich results with token and policy info + const enriched = await this.enrichTokenMints(em, rows); + + return new MessageResponse({ + items: enriched, + pageIndex: options.offset / options.limit, + pageSize: options.limit, + total, + totalAmount, + order: options.orderBy, + }); + } else { + // No amount filter - use standard findAndCount + // Default sort by consensusTimestamp descending if no order specified + if (!options.orderBy) { + options.orderBy = { consensusTimestamp: 'DESC' }; + } + + const [rows, total] = await em.findAndCount( + Message, + filters, + options + ); + + // Compute totalAmount from all matching documents + const totalAmountAgg = await em.aggregate(Message, [ + { $match: filters }, + { + $addFields: { + _tokenAmountNum: { + $convert: { input: '$analytics.tokenAmount', to: 'double', onError: 0, onNull: 0 }, + }, + }, + }, + { $group: { _id: null, totalAmount: { $sum: '$_tokenAmountNum' } } }, + ]); + const totalAmount = totalAmountAgg.length > 0 ? totalAmountAgg[0].totalAmount : 0; + + // Enrich results with token and policy info + const enriched = await this.enrichTokenMints(em, rows); + + return new MessageResponse({ + items: enriched, + pageIndex: options.offset / options.limit, + pageSize: options.limit, + total, + totalAmount, + order: options.orderBy, + }); + } + } catch (error) { + return new MessageError(error, getErrorCode(error.code)); + } + } + + /** + * Enrich VP mint documents with token cache and policy information + */ + private async enrichTokenMints( + em: any, + rows: any[] + ): Promise { + // Collect unique token IDs, policy IDs, and related VC IDs for geography + const tokenIds = new Set(); + const policyIds = new Set(); + const relatedVcIds = new Set(); + for (const row of rows) { + if (row.analytics?.tokenId) { + tokenIds.add(row.analytics.tokenId); + } + if (row.analytics?.policyId) { + policyIds.add(row.analytics.policyId); + } + // Collect related VC IDs for geography lookup + if (Array.isArray(row.options?.relationships)) { + for (const rel of row.options.relationships) { + if (rel) { + relatedVcIds.add(rel); + } + } + } + } + + // Batch load token info + const tokenMap = new Map(); + if (tokenIds.size > 0) { + const tokens = await em.find(TokenCache, { + tokenId: { $in: [...tokenIds] }, + }); + for (const token of tokens) { + tokenMap.set(token.tokenId, token); + } + } + + // Batch load policy info + const policyMap = new Map(); + if (policyIds.size > 0) { + const policies = await em.find(Message, { + type: MessageType.INSTANCE_POLICY, + consensusTimestamp: { $in: [...policyIds] }, + } as any); + for (const policy of policies) { + policyMap.set(policy.consensusTimestamp, policy); + } + } + + // Batch load project coordinates for geography + // ProjectCoordinates.projectId = VC consensusTimestamp + const coordMap = new Map(); + if (relatedVcIds.size > 0) { + const coords = await em.find(ProjectCoordinates, { + projectId: { $in: [...relatedVcIds] }, + }); + for (const coord of coords) { + coordMap.set(coord.projectId, coord.coordinates); + } + } + + // Map to enriched results + return rows.map((row) => { + const tokenInfo = tokenMap.get(row.analytics?.tokenId); + const policyInfo = policyMap.get(row.analytics?.policyId); + + // Parse consensus timestamp to date + let mintDate: string | undefined; + if (row.consensusTimestamp) { + try { + const epochSeconds = parseFloat(row.consensusTimestamp); + mintDate = new Date(epochSeconds * 1000).toISOString(); + } catch { + // ignore parse errors + } + } + + // Resolve geography from project coordinates of related VCs + let geography: string | undefined; + if (Array.isArray(row.options?.relationships)) { + for (const rel of row.options.relationships) { + const coordStr = coordMap.get(rel); + if (coordStr) { + geography = coordStr; + break; + } + } + } + + const amountStr = row.analytics?.tokenAmount || '0'; + const amountNum = parseFloat(amountStr); + + return { + consensusTimestamp: row.consensusTimestamp, + topicId: row.topicId, + tokenId: row.analytics?.tokenId || '', + tokenName: tokenInfo?.name || undefined, + tokenSymbol: tokenInfo?.symbol || undefined, + tokenAmount: amountStr, + tokenAmountNumeric: isNaN(amountNum) ? 0 : amountNum, + policyId: row.analytics?.policyId || undefined, + policyDescription: policyInfo?.options?.description || policyInfo?.options?.name || undefined, + schemaNames: row.analytics?.schemaNames || undefined, + issuer: row.analytics?.issuer || row.options?.issuer || undefined, + owner: row.owner || undefined, + mintDate, + geography, + } as TokenMintResult; + }); + } + //#endregion //#region FILES @MessagePattern(IndexerMessageAPI.UPDATE_FILES) async updateFiles( diff --git a/indexer-service/tsconfig.json b/indexer-service/tsconfig.json index 6f26a8f994..fde9ab20a3 100644 --- a/indexer-service/tsconfig.json +++ b/indexer-service/tsconfig.json @@ -11,6 +11,9 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "esModuleInterop": true, + "types": [ + "node" + ], "skipLibCheck": true, "baseUrl": "./src", "paths": {} diff --git a/indexer-worker-service/tsconfig.json b/indexer-worker-service/tsconfig.json index 6f26a8f994..fde9ab20a3 100644 --- a/indexer-worker-service/tsconfig.json +++ b/indexer-worker-service/tsconfig.json @@ -11,6 +11,9 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "esModuleInterop": true, + "types": [ + "node" + ], "skipLibCheck": true, "baseUrl": "./src", "paths": {} diff --git a/swagger-indexer.yaml b/swagger-indexer.yaml index 055de7d965..0aacc498d9 100644 --- a/swagger-indexer.yaml +++ b/swagger-indexer.yaml @@ -2751,6 +2751,153 @@ paths: summary: Try to unpack schemas tags: - entities + /entities/token-mints: + get: + description: >- + Search for issued token mint events (VP documents representing token + minting). Supports filtering by amount range, time period, policy + (methodology), token, geography, schema name (standard), and issuer. + Results include enriched token and policy information. Useful for finding + suitable carbon credits. + operationId: EntityApi_searchTokenMints + parameters: + - name: pageIndex + required: false + in: query + description: Page index + schema: + example: 0 + type: number + - name: pageSize + required: false + in: query + description: Page size + schema: + type: number + maximum: 100 + - name: orderField + required: false + in: query + description: Order field + schema: + example: analytics.tokenAmount + type: string + - name: orderDir + required: false + in: query + description: Order direction + examples: + ASC: + value: ASC + description: Ascending ordering + DESC: + value: DESC + description: Descending ordering + schema: + type: string + - name: minAmount + required: false + in: query + description: Minimum token amount (inclusive) + schema: + example: '1000' + type: string + - name: maxAmount + required: false + in: query + description: Maximum token amount (inclusive) + schema: + example: '100000' + type: string + - name: startDate + required: false + in: query + description: >- + Start date for minting time filter (ISO 8601, e.g. + 2024-01-01T00:00:00Z) + schema: + example: '2024-01-01T00:00:00Z' + type: string + - name: endDate + required: false + in: query + description: >- + End date for minting time filter (ISO 8601, e.g. + 2024-12-31T23:59:59Z) + schema: + example: '2024-12-31T23:59:59Z' + type: string + - name: policyId + required: false + in: query + description: Policy message identifier (methodology) + schema: + example: '1706823227.586179534' + type: string + - name: tokenId + required: false + in: query + description: Token identifier + schema: + example: 0.0.1621155 + type: string + - name: geography + required: false + in: query + description: Geography / region keyword to search + schema: + example: United States + type: string + - name: schemaName + required: false + in: query + description: Schema name / standard type + schema: + example: Monitoring Report + type: string + - name: issuer + required: false + in: query + description: Issuer DID + schema: + example: >- + did:hedera:testnet:8Go53QCUXZ4nzSQMyoWovWCxseogGTMLDiHg14Fkz4VN_0.0.4481265 + type: string + - name: keywords + required: false + in: query + description: Keywords to search (JSON array) + schema: + example: '["carbon"]' + type: string + responses: + '200': + description: Token Mints + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/PageDTO' + - properties: + items: + type: array + items: + $ref: '#/components/schemas/TokenMintResultDTO' + totalAmount: + type: number + description: >- + Sum of all matching token amounts across the + entire result set + example: 500000 + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/InternalServerErrorDTO' + summary: Search token mints + tags: + - entities /landing/analytics: get: description: >- @@ -3332,6 +3479,75 @@ components: required: - code - message + TokenMintResultDTO: + type: object + properties: + consensusTimestamp: + type: string + description: Consensus timestamp (unique message identifier) + example: '1706823227.586179534' + topicId: + type: string + description: Topic identifier + example: '0.0.1960' + tokenId: + type: string + description: Token identifier + example: '0.0.1621155' + tokenName: + type: string + description: Token name + example: Carbon Credit Token + tokenSymbol: + type: string + description: Token symbol + example: CCT + tokenAmount: + type: string + description: Minted token amount + example: '5000' + tokenAmountNumeric: + type: number + description: Parsed numeric amount for sorting + example: 5000 + policyId: + type: string + description: Policy message identifier (methodology) + example: '1706823227.586179534' + policyDescription: + type: string + description: Policy description / name + example: iREC Policy + schemaNames: + type: array + description: Schema names (standard type) + items: + type: string + example: + - MintToken + - Monitoring Report + issuer: + type: string + description: Issuer DID + example: >- + did:hedera:testnet:8Go53QCUXZ4nzSQMyoWovWCxseogGTMLDiHg14Fkz4VN_0.0.4481265 + owner: + type: string + description: Owner + example: '0.0.1234' + mintDate: + type: string + description: Mint date (derived from consensus timestamp) + example: '2024-02-06T05:40:45.000Z' + geography: + type: string + description: Geography (if available) + example: United States + required: + - consensusTimestamp + - topicId + - tokenId + - tokenAmount RegistryOptionsDTO: type: object properties: