diff --git a/.github/workflows/nova-build-temp.yaml b/.github/workflows/nova-build-temp.yaml index 62991fbe0..f5d16f647 100644 --- a/.github/workflows/nova-build-temp.yaml +++ b/.github/workflows/nova-build-temp.yaml @@ -6,7 +6,7 @@ on: TARGET_COMMIT: description: "Target Commit Hash for the SDK" required: false - default: "7d32ae0bb7e93552618923fb272a5c1cc67c2c60" + default: "133f911b18191cda9099f1b4aeaf7d0022dfe0fb" environment: type: choice description: "Select the environment to deploy to" diff --git a/api/package-lock.json b/api/package-lock.json index 60091d3ac..fde47d9c6 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -67,22 +67,21 @@ }, "../iota-sdk/bindings/nodejs": { "name": "@iota/sdk-nova", - "version": "1.1.4", + "version": "1.1.6", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@babel/traverse": "^7.23.2", - "@types/node": "^18.15.12", "class-transformer": "^0.5.1", "prebuild-install": "^7.1.1", - "reflect-metadata": "^0.1.13", - "typescript": "^4.9.4" + "reflect-metadata": "^0.1.13" }, "devDependencies": { "@napi-rs/cli": "^1.0.0", "@types/jest": "^29.4.0", + "@types/node": "^18.15.12", "@typescript-eslint/eslint-plugin": "^5.30.7", "@typescript-eslint/parser": "^5.30.7", + "cargo-cp-artifact": "^0.1.6", "dotenv": "^16.0.3", "electron-build-env": "^0.2.0", "eslint": "^8.20.0", @@ -94,6 +93,7 @@ "ts-jest": "^29.0.5", "typedoc": "^0.24.6", "typedoc-plugin-markdown": "^3.14.0", + "typescript": "^4.9.4", "webpack": "^5.88.2", "webpack-cli": "^5.1.4" } @@ -11864,12 +11864,12 @@ "@iota/sdk-nova": { "version": "file:../iota-sdk/bindings/nodejs", "requires": { - "@babel/traverse": "^7.23.2", "@napi-rs/cli": "^1.0.0", "@types/jest": "^29.4.0", "@types/node": "^18.15.12", "@typescript-eslint/eslint-plugin": "^5.30.7", "@typescript-eslint/parser": "^5.30.7", + "cargo-cp-artifact": "^0.1.6", "class-transformer": "^0.5.1", "dotenv": "^16.0.3", "electron-build-env": "^0.2.0", diff --git a/api/src/models/api/nova/IAddressDetails.ts b/api/src/models/api/nova/IAddressDetails.ts new file mode 100644 index 000000000..86034e72d --- /dev/null +++ b/api/src/models/api/nova/IAddressDetails.ts @@ -0,0 +1,12 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { AddressType } from "@iota/sdk-nova"; + +export interface IAddressDetails { + bech32: string; + hex?: string; + type?: AddressType; + label?: string; + restricted: boolean; + capabilities?: number[]; +} diff --git a/api/src/models/api/nova/IAssociationsRequest.ts b/api/src/models/api/nova/IAssociationsRequest.ts new file mode 100644 index 000000000..6c0be1433 --- /dev/null +++ b/api/src/models/api/nova/IAssociationsRequest.ts @@ -0,0 +1,11 @@ +export interface IAssociationsRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The address to get the associated outputs for. + */ + address: string; +} diff --git a/api/src/models/api/nova/IAssociationsRequestBody.ts b/api/src/models/api/nova/IAssociationsRequestBody.ts new file mode 100644 index 000000000..5873f71c1 --- /dev/null +++ b/api/src/models/api/nova/IAssociationsRequestBody.ts @@ -0,0 +1,8 @@ +import { IAddressDetails } from "./IAddressDetails"; + +export interface IAssociationsRequestBody { + /** + * The address details of the address to get the associated outputs for. + */ + addressDetails: IAddressDetails; +} diff --git a/api/src/models/api/nova/ISearchRequest.ts b/api/src/models/api/nova/ISearchRequest.ts new file mode 100644 index 000000000..b0371bef2 --- /dev/null +++ b/api/src/models/api/nova/ISearchRequest.ts @@ -0,0 +1,11 @@ +export interface ISearchRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The query to look for. + */ + query: string; +} diff --git a/api/src/models/api/nova/ISearchResponse.ts b/api/src/models/api/nova/ISearchResponse.ts new file mode 100644 index 000000000..c4b8925b5 --- /dev/null +++ b/api/src/models/api/nova/ISearchResponse.ts @@ -0,0 +1,42 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { Block, OutputResponse } from "@iota/sdk-nova"; +import { IAddressDetails } from "./IAddressDetails"; +import { IResponse } from "../IResponse"; + +export interface ISearchResponse extends IResponse { + /** + * Block if it was found. + */ + block?: Block; + + /** + * Address details. + */ + addressDetails?: IAddressDetails; + + /** + * Output if it was found (block will also be populated). + */ + output?: OutputResponse; + + /** + * Account id if it was found. + */ + accountId?: string; + + /** + * Anchor id if it was found. + */ + anchorId?: string; + + /** + * Foundry id if it was found. + */ + foundryId?: string; + + /** + * Nft id if it was found. + */ + nftId?: string; +} diff --git a/api/src/models/api/nova/chronicle/IAddressBalanceRequest.ts b/api/src/models/api/nova/chronicle/IAddressBalanceRequest.ts new file mode 100644 index 000000000..7b632de16 --- /dev/null +++ b/api/src/models/api/nova/chronicle/IAddressBalanceRequest.ts @@ -0,0 +1,11 @@ +export interface IAddressBalanceRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The bech32 address to get the balance for. + */ + address: string; +} diff --git a/api/src/models/api/nova/chronicle/IAddressBalanceResponse.ts b/api/src/models/api/nova/chronicle/IAddressBalanceResponse.ts new file mode 100644 index 000000000..32f275a9f --- /dev/null +++ b/api/src/models/api/nova/chronicle/IAddressBalanceResponse.ts @@ -0,0 +1,18 @@ +import { IResponse } from "../../IResponse"; + +export interface IAddressBalanceResponse extends IResponse { + /** + * The total balance (including Expiration, Timelock and StorageDepositReturn outputs) + */ + totalBalance?: number; + + /** + * The balance of all spendable outputs by the address at this time. + */ + availableBalance?: number; + + /** + * The ledger index at which this balance data was valid. + */ + ledgerIndex?: number; +} diff --git a/api/src/routes.ts b/api/src/routes.ts index d8b1ade11..4c4f2e0cc 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -202,6 +202,13 @@ export const routes: IRoute[] = [ func: "get", }, // Nova + { + path: "/nova/balance/chronicle/:network/:address", + method: "get", + folder: "nova/address/balance/chronicle", + func: "get", + }, + { path: "/nova/search/:network/:query", method: "get", folder: "nova", func: "search" }, { path: "/nova/output/:network/:outputId", method: "get", folder: "nova/output", func: "get" }, { path: "/nova/output/rewards/:network/:outputId", method: "get", folder: "nova/output/rewards", func: "get" }, { path: "/nova/account/:network/:accountId", method: "get", folder: "nova/account", func: "get" }, diff --git a/api/src/routes/nova/address/balance/chronicle/get.ts b/api/src/routes/nova/address/balance/chronicle/get.ts new file mode 100644 index 000000000..2e5c01252 --- /dev/null +++ b/api/src/routes/nova/address/balance/chronicle/get.ts @@ -0,0 +1,34 @@ +import { ServiceFactory } from "../../../../../factories/serviceFactory"; +import { IAddressBalanceRequest } from "../../../../../models/api/nova/chronicle/IAddressBalanceRequest"; +import { IAddressBalanceResponse } from "../../../../../models/api/nova/chronicle/IAddressBalanceResponse"; +import { IConfiguration } from "../../../../../models/configuration/IConfiguration"; +import { NOVA } from "../../../../../models/db/protocolVersion"; +import { NetworkService } from "../../../../../services/networkService"; +import { ChronicleService } from "../../../../../services/nova/chronicleService"; +import { ValidationHelper } from "../../../../../utils/validationHelper"; + +/** + * Fetch the address balance from chronicle nova. + * @param _ The configuration. + * @param request The request. + * @returns The response. + */ +export async function get(_: IConfiguration, request: IAddressBalanceRequest): Promise { + const networkService = ServiceFactory.get("network"); + const networks = networkService.networkNames(); + ValidationHelper.oneOf(request.network, networks, "network"); + + const networkConfig = networkService.get(request.network); + + if (networkConfig.protocolVersion !== NOVA) { + return {}; + } + + if (!networkConfig.permaNodeEndpoint) { + return {}; + } + + const chronicleService = ServiceFactory.get(`chronicle-${networkConfig.network}`); + + return chronicleService.addressBalance(request.address); +} diff --git a/api/src/routes/nova/output/associated/post.ts b/api/src/routes/nova/output/associated/post.ts index 0fc9952a5..a615bf58b 100644 --- a/api/src/routes/nova/output/associated/post.ts +++ b/api/src/routes/nova/output/associated/post.ts @@ -1,7 +1,7 @@ import { ServiceFactory } from "../../../../factories/serviceFactory"; +import { IAssociationsRequest } from "../../../../models/api/nova/IAssociationsRequest"; +import { IAssociationsRequestBody } from "../../../../models/api/nova/IAssociationsRequestBody"; import { IAssociation, IAssociationsResponse } from "../../../../models/api/nova/IAssociationsResponse"; -import { IAssociationsRequest } from "../../../../models/api/stardust/IAssociationsRequest"; -import { IAssociationsRequestBody } from "../../../../models/api/stardust/IAssociationsRequestBody"; import { IConfiguration } from "../../../../models/configuration/IConfiguration"; import { NOVA } from "../../../../models/db/protocolVersion"; import { NetworkService } from "../../../../services/networkService"; diff --git a/api/src/routes/nova/search.ts b/api/src/routes/nova/search.ts new file mode 100644 index 000000000..ba50dbd92 --- /dev/null +++ b/api/src/routes/nova/search.ts @@ -0,0 +1,30 @@ +import { ServiceFactory } from "../../factories/serviceFactory"; +import { ISearchRequest } from "../../models/api/nova/ISearchRequest"; +import { ISearchResponse } from "../../models/api/nova/ISearchResponse"; +import { IConfiguration } from "../../models/configuration/IConfiguration"; +import { NOVA } from "../../models/db/protocolVersion"; +import { NetworkService } from "../../services/networkService"; +import { NovaApiService } from "../../services/nova/novaApiService"; +import { ValidationHelper } from "../../utils/validationHelper"; + +/** + * Find the object from the network. + * @param _ The configuration. + * @param request The request. + * @returns The response. + */ +export async function search(_: IConfiguration, request: ISearchRequest): Promise { + const networkService = ServiceFactory.get("network"); + const networks = networkService.networkNames(); + ValidationHelper.oneOf(request.network, networks, "network"); + ValidationHelper.string(request.query, "query"); + + const networkConfig = networkService.get(request.network); + + if (networkConfig.protocolVersion !== NOVA) { + return {}; + } + + const novaApiService = ServiceFactory.get(`api-service-${networkConfig.network}`); + return novaApiService.search(request.query); +} diff --git a/api/src/services/nova/chronicleService.ts b/api/src/services/nova/chronicleService.ts new file mode 100644 index 000000000..8fc0274ff --- /dev/null +++ b/api/src/services/nova/chronicleService.ts @@ -0,0 +1,43 @@ +import logger from "../../logger"; +import { IAddressBalanceResponse } from "../../models/api/nova/chronicle/IAddressBalanceResponse"; +import { INetwork } from "../../models/db/INetwork"; +import { FetchHelper } from "../../utils/fetchHelper"; + +const CHRONICLE_ENDPOINTS = { + balance: "/api/explorer/v3/balance/", +}; + +export class ChronicleService { + /** + * The endpoint for performing communications. + */ + private readonly chronicleEndpoint: string; + + /** + * The network config in context. + */ + private readonly networkConfig: INetwork; + + constructor(config: INetwork) { + this.networkConfig = config; + this.chronicleEndpoint = config.permaNodeEndpoint; + } + + /** + * Get the current address balance info. + * @param address The address to fetch the balance for. + * @returns The address balance response. + */ + public async addressBalance(address: string): Promise { + try { + return await FetchHelper.json( + this.chronicleEndpoint, + `${CHRONICLE_ENDPOINTS.balance}${address}`, + "get", + ); + } catch (error) { + const network = this.networkConfig.network; + logger.warn(`[ChronicleService (Nova)] Failed fetching address balance for ${address} on ${network}. Cause: ${error}`); + } + } +} diff --git a/api/src/services/nova/novaApiService.ts b/api/src/services/nova/novaApiService.ts index b3388a8db..f46e24d54 100644 --- a/api/src/services/nova/novaApiService.ts +++ b/api/src/services/nova/novaApiService.ts @@ -10,19 +10,28 @@ import { IBlockResponse } from "../../models/api/nova/IBlockResponse"; import { INftDetailsResponse } from "../../models/api/nova/INftDetailsResponse"; import { IOutputDetailsResponse } from "../../models/api/nova/IOutputDetailsResponse"; import { IRewardsResponse } from "../../models/api/nova/IRewardsResponse"; +import { ISearchResponse } from "../../models/api/nova/ISearchResponse"; import { INetwork } from "../../models/db/INetwork"; import { HexHelper } from "../../utils/hexHelper"; +import { SearchExecutor } from "../../utils/nova/searchExecutor"; +import { SearchQueryBuilder } from "../../utils/nova/searchQueryBuilder"; /** * Class to interact with the nova API. */ export class NovaApiService { + /** + * The network in context. + */ + private readonly network: INetwork; + /** * The client to use for requests. */ private readonly client: Client; constructor(network: INetwork) { + this.network = network; this.client = ServiceFactory.get(`client-${network.network}`); } @@ -154,4 +163,14 @@ export class NovaApiService { return manaRewardsResponse ? { outputId, manaRewards: manaRewardsResponse } : { outputId, message: "Rewards data not found" }; } + + /** + * Find item on the stardust network. + * @param query The query to use for finding items. + * @returns The item found. + */ + public async search(query: string): Promise { + const searchQuery = new SearchQueryBuilder(query, this.network.bechHrp).build(); + return new SearchExecutor(this.network, searchQuery).run(); + } } diff --git a/api/src/utils/nova/addressHelper.ts b/api/src/utils/nova/addressHelper.ts new file mode 100644 index 000000000..6a0c5e023 --- /dev/null +++ b/api/src/utils/nova/addressHelper.ts @@ -0,0 +1,146 @@ +/* eslint-disable import/no-unresolved */ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { + Address, + AddressType, + AccountAddress, + Ed25519Address, + NftAddress, + AnchorAddress, + Utils, + ImplicitAccountCreationAddress, + RestrictedAddress, +} from "@iota/sdk-nova"; +import { plainToInstance } from "class-transformer"; +import { IAddressDetails } from "../../models/api/nova/IAddressDetails"; +import { HexHelper } from "../hexHelper"; + +export class AddressHelper { + /** + * Build the address details. + * @param hrp The human readable part of the address. + * @param address The address to source the data from. + * @param typeHint The type of the address. + * @returns The parts of the address. + */ + public static buildAddress(hrp: string, address: string | Address, typeHint?: number): IAddressDetails { + return typeof address === "string" ? this.buildAddressFromString(hrp, address, typeHint) : this.buildAddressFromTypes(address, hrp); + } + + private static buildAddressFromString(hrp: string, addressString: string, typeHint?: number): IAddressDetails { + let bech32: string; + let hex: string; + let type: AddressType; + if (Utils.isAddressValid(addressString)) { + try { + const address: Address = Utils.parseBech32Address(addressString); + + if (address) { + bech32 = addressString; + type = address.type; + hex = Utils.bech32ToHex(addressString); + } + } catch (e) { + console.error(e); + } + } + + if (!bech32) { + // We assume this is hex + hex = addressString; + if (typeHint) { + bech32 = this.computeBech32FromHexAndType(hex, type, hrp); + } + } + + return { + bech32, + hex: hex ? HexHelper.addPrefix(hex) : hex, + type, + label: AddressHelper.typeLabel(type), + restricted: false, + }; + } + + private static buildAddressFromTypes( + address: Address, + hrp: string, + restricted: boolean = false, + capabilities?: number[], + ): IAddressDetails { + let hex: string = ""; + let bech32: string = ""; + + if (address.type === AddressType.Ed25519) { + hex = (address as Ed25519Address).pubKeyHash; + } else if (address.type === AddressType.Account) { + hex = (address as AccountAddress).accountId; + } else if (address.type === AddressType.Nft) { + hex = (address as NftAddress).nftId; + } else if (address.type === AddressType.Anchor) { + hex = (address as AnchorAddress).anchorId; + } else if (address.type === AddressType.ImplicitAccountCreation) { + const implicitAccountCreationAddress = plainToInstance(ImplicitAccountCreationAddress, address); + const innerAddress = implicitAccountCreationAddress.address(); + hex = innerAddress.pubKeyHash; + } else if (address.type === AddressType.Restricted) { + const restrictedAddress = plainToInstance(RestrictedAddress, address); + const innerAddress = restrictedAddress.address; + + return this.buildAddressFromTypes( + innerAddress, + hrp, + true, + Array.from(restrictedAddress.getAllowedCapabilities() as ArrayLike), + ); + } + + bech32 = this.computeBech32FromHexAndType(hex, address.type, hrp); + + return { + bech32, + hex, + type: address.type, + label: AddressHelper.typeLabel(address.type), + restricted, + capabilities, + }; + } + + private static computeBech32FromHexAndType(hex: string, addressType: AddressType, hrp: string) { + let bech32 = ""; + + if (addressType === AddressType.Ed25519) { + bech32 = Utils.hexToBech32(hex, hrp); + } else if (addressType === AddressType.Account) { + bech32 = Utils.accountIdToBech32(hex, hrp); + } else if (addressType === AddressType.Nft) { + bech32 = Utils.nftIdToBech32(hex, hrp); + } else if (addressType === AddressType.Anchor) { + // Update to Utils.anchorIdToBech32 when it gets implemented + bech32 = Utils.accountIdToBech32(hex, hrp); + } else if (addressType === AddressType.ImplicitAccountCreation) { + bech32 = Utils.hexToBech32(hex, hrp); + } + + return bech32; + } + + /** + * Convert the address type number to a label. + * @param addressType The address type to get the label for. + * @returns The label. + */ + private static typeLabel(addressType?: AddressType): string | undefined { + if (addressType === AddressType.Ed25519) { + return "Ed25519"; + } else if (addressType === AddressType.Account) { + return "Account"; + } else if (addressType === AddressType.Nft) { + return "NFT"; + } else if (addressType === AddressType.Anchor) { + return "Anchor"; + } + } +} diff --git a/api/src/utils/nova/associatedOutputsHelper.ts b/api/src/utils/nova/associatedOutputsHelper.ts index 688d26c6e..e87289895 100644 --- a/api/src/utils/nova/associatedOutputsHelper.ts +++ b/api/src/utils/nova/associatedOutputsHelper.ts @@ -14,8 +14,8 @@ import { NftOutputQueryParameters, } from "@iota/sdk-nova"; import { ServiceFactory } from "../../factories/serviceFactory"; +import { IAddressDetails } from "../../models/api/nova/IAddressDetails"; import { AssociationType } from "../../models/api/nova/IAssociationsResponse"; -import { IBech32AddressDetails } from "../../models/api/stardust/IBech32AddressDetails"; import { INetwork } from "../../models/db/INetwork"; /** @@ -26,9 +26,9 @@ export class AssociatedOutputsHelper { private readonly network: INetwork; - private readonly addressDetails: IBech32AddressDetails; + private readonly addressDetails: IAddressDetails; - constructor(network: INetwork, addressDetails: IBech32AddressDetails) { + constructor(network: INetwork, addressDetails: IAddressDetails) { this.network = network; this.addressDetails = addressDetails; } diff --git a/api/src/utils/nova/searchExecutor.ts b/api/src/utils/nova/searchExecutor.ts new file mode 100644 index 000000000..9c276abba --- /dev/null +++ b/api/src/utils/nova/searchExecutor.ts @@ -0,0 +1,127 @@ +import { SearchQuery } from "./searchQueryBuilder"; +import { ServiceFactory } from "../../factories/serviceFactory"; +import { ISearchResponse } from "../../models/api/nova/ISearchResponse"; +import { INetwork } from "../../models/db/INetwork"; +import { NovaApiService } from "../../services/nova/novaApiService"; + +export class SearchExecutor { + /** + * The search query. + */ + private readonly query: SearchQuery; + + private readonly apiService: NovaApiService; + + constructor(network: INetwork, query: SearchQuery) { + this.query = query; + this.apiService = ServiceFactory.get(`api-service-${network.network}`); + } + + public async run(): Promise { + const searchQuery = this.query; + const promises: Promise[] = []; + let promisesResult: ISearchResponse | null = null; + + if (searchQuery.blockId) { + promises.push( + this.executeQuery( + this.apiService.block(searchQuery.blockId), + (response) => { + promisesResult = { + block: response.block, + error: response.error || response.message, + }; + }, + "Block fetch failed", + ), + ); + } + + if (searchQuery.outputId) { + promises.push( + this.executeQuery( + this.apiService.outputDetails(searchQuery.outputId), + (response) => { + promisesResult = { + output: response.output, + error: response.error || response.message, + }; + }, + "Output fetch failed", + ), + ); + } + + if (searchQuery.accountId) { + promises.push( + this.executeQuery( + this.apiService.accountDetails(searchQuery.accountId), + (response) => { + promisesResult = { + accountId: response.accountOutputDetails ? searchQuery.accountId : undefined, + error: response.error || response.message, + }; + }, + "Account id fetch failed", + ), + ); + } + + if (searchQuery.nftId) { + promises.push( + this.executeQuery( + this.apiService.nftDetails(searchQuery.nftId), + (response) => { + promisesResult = { + nftId: response.nftOutputDetails ? searchQuery.nftId : undefined, + error: response.error || response.message, + }; + }, + "Nft id fetch failed", + ), + ); + } + + if (searchQuery.anchorId) { + promises.push( + this.executeQuery( + this.apiService.anchorDetails(searchQuery.anchorId), + (response) => { + promisesResult = { + anchorId: response.anchorOutputDetails ? searchQuery.anchorId : undefined, + error: response.error || response.message, + }; + }, + "Anchor id fetch failed", + ), + ); + } + + await Promise.any(promises).catch((_) => {}); + + if (promisesResult !== null) { + return promisesResult; + } + + if (searchQuery.address?.bech32) { + return { + addressDetails: searchQuery.address, + }; + } + + return {}; + } + + private async executeQuery(query: Promise, successHandler: (result: T) => void, failureMessage: string): Promise { + try { + const result = await query; + if (result) { + successHandler(result); + } else { + throw new Error(failureMessage); + } + } catch { + throw new Error(`${failureMessage}`); + } + } +} diff --git a/api/src/utils/nova/searchQueryBuilder.ts b/api/src/utils/nova/searchQueryBuilder.ts new file mode 100644 index 000000000..d977a8d8b --- /dev/null +++ b/api/src/utils/nova/searchQueryBuilder.ts @@ -0,0 +1,162 @@ +/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */ +/* eslint-disable import/no-unresolved */ +import { AddressType, HexEncodedString } from "@iota/sdk-nova"; +import { AddressHelper } from "./addressHelper"; +import { IAddressDetails } from "../../models/api/nova/IAddressDetails"; +import { Converter } from "../convertUtils"; +import { HexHelper } from "../hexHelper"; + +export interface SearchQuery { + /** + * The query string in lower case. + */ + queryLower: string; + /** + * The slotIndex query. + */ + slotIndex?: number; + /** + * The MaybeAddress query. + */ + address?: IAddressDetails; + /** + * The blockId query. + */ + blockId?: string; + /** + * The transactionId query. + */ + transactionId?: string; + /** + * The outputId query. + */ + outputId?: string; + /** + * The accountId query. + */ + accountId?: string; + /** + * The nftId query. + */ + nftId?: string; + /** + * The foundryId query. + */ + foundryId?: string; + /** + * The anchorId query. + */ + anchorId?: string; + /** + * The delegationId query. + */ + delegationId?: string; + /** + * The tag of an output. + */ + tag?: HexEncodedString; +} + +/** + * Builds SearchQuery object from query stirng + */ +export class SearchQueryBuilder { + /** + * The query string. + */ + private readonly query: string; + + /** + * The query string in lower case. + */ + private readonly queryLower: string; + + /** + * Thed human readable part to use for bech32. + */ + private readonly networkBechHrp: string; + + /** + * Creates a new instance of SearchQueryBuilder. + * @param query The query string. + * @param networkBechHrp The network bechHrp. + */ + constructor(query: string, networkBechHrp: string) { + this.query = query; + this.queryLower = query.toLowerCase(); + this.networkBechHrp = networkBechHrp; + } + + /** + * Builds the SearchQuery. + * @returns the SearchQuery object. + */ + public build(): SearchQuery { + let address: IAddressDetails; + let blockId: string; + let transactionId: string; + let outputId: string; + let accountId: string; + let nftId: string; + let foundryId: string; + let anchorId: string; + let delegationId: string; + let tag: string; + + const queryDetails = AddressHelper.buildAddress(this.networkBechHrp, this.queryLower); + const slotIndex = /^\d+$/.test(this.query) ? Number.parseInt(this.query, 10) : undefined; + + // if the source query was valid bech32, we should directly look for an address + if (queryDetails.bech32) { + address = queryDetails; + } else { + // if the hex has 74 characters it might be a block id or a tx id + if (queryDetails?.hex && queryDetails.hex.length === 74) { + blockId = queryDetails.hex; + transactionId = queryDetails.hex; + } else if (queryDetails?.hex && queryDetails.hex.length === 66) { + // if the hex has 66 characters it might be a accoount id or a nft id + accountId = queryDetails.hex; + nftId = queryDetails.hex; + anchorId = queryDetails.hex; + delegationId = queryDetails.hex; + } else if ( + // if the hex without prefix is 76 characters and first byte is 08, + // it might be a FoundryId (tokenId) + queryDetails.hex && + Converter.isHex(queryDetails.hex, true) && + queryDetails.hex.length === 78 && + Number.parseInt(HexHelper.stripPrefix(queryDetails.hex).slice(0, 2), 16) === AddressType.Account + ) { + foundryId = queryDetails.hex; + } else if ( + // if the hex is 70 characters it might be an outputId + queryDetails?.hex && + queryDetails.hex.length === 78 + ) { + outputId = queryDetails.hex; + } + + // also perform a tag search + const maybeTag = Converter.isHex(this.query, true) ? HexHelper.addPrefix(this.query) : Converter.utf8ToHex(this.query, true); + if (maybeTag.length < 66) { + tag = maybeTag; + } + } + + return { + queryLower: this.queryLower, + slotIndex, + address, + blockId, + transactionId, + outputId, + accountId, + nftId, + foundryId, + anchorId, + delegationId, + tag, + }; + } +} diff --git a/client/package-lock.json b/client/package-lock.json index 2c6e98a22..9b06c7010 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -105,15 +105,12 @@ }, "../iota-sdk/bindings/wasm": { "name": "@iota/sdk-wasm-nova", - "version": "1.1.2", + "version": "1.1.3", "license": "Apache-2.0", "dependencies": { "class-transformer": "^0.5.1", "node-fetch": "^2.6.7", - "qs": "^6.9.7", - "reflect-metadata": "^0.1.13", - "semver": "^7.5.2", - "text-encoding": "^0.7.0" + "reflect-metadata": "^0.1.13" }, "devDependencies": { "@types/jest": "^27.5.2", @@ -12316,10 +12313,7 @@ "jest-matcher-utils": "^28.1.3", "node-fetch": "^2.6.7", "prettier": "^2.7.1", - "qs": "^6.9.7", "reflect-metadata": "^0.1.13", - "semver": "^7.5.2", - "text-encoding": "^0.7.0", "ts-jest": "^27.1.5", "ts-node": "^10.9.1", "typedoc": "^0.24.0", diff --git a/client/src/app/AppUtils.tsx b/client/src/app/AppUtils.tsx index e03226443..cb6d9eb89 100644 --- a/client/src/app/AppUtils.tsx +++ b/client/src/app/AppUtils.tsx @@ -157,14 +157,20 @@ export const populateNetworkInfoNova = (networkName: string) => { nodeInfo?.protocolParameters.reduce((params, cur) => { return params.startEpoch > cur.startEpoch ? params : cur; }) ?? null; - const setNetworkInfoNova = useNetworkInfoNova.getState().setNetworkInfo; - setNetworkInfoNova({ - name: networkName, - tokenInfo: nodeInfo?.baseToken ?? {}, - protocolVersion: protocolInfo?.parameters.version ?? -1, - protocolInfo, - latestConfirmedSlot: nodeInfo?.status?.latestConfirmedBlockSlot ?? -1, - bech32Hrp: protocolInfo?.parameters.bech32Hrp ?? "", - }); + + const networkInfoState = useNetworkInfoNova.getState(); + const currentNetworkInfo = networkInfoState.networkInfo; + if (currentNetworkInfo?.name !== networkName || currentNetworkInfo?.protocolInfo === null) { + const setNetworkInfoNova = networkInfoState.setNetworkInfo; + + setNetworkInfoNova({ + name: networkName, + tokenInfo: nodeInfo?.baseToken ?? {}, + protocolVersion: protocolInfo?.parameters.version ?? -1, + protocolInfo, + latestConfirmedSlot: nodeInfo?.status?.latestConfirmedBlockSlot ?? -1, + bech32Hrp: protocolInfo?.parameters.bech32Hrp ?? "", + }); + } } }; diff --git a/client/src/app/components/nova/Input.tsx b/client/src/app/components/nova/Input.tsx index 1861a216c..c1919c441 100644 --- a/client/src/app/components/nova/Input.tsx +++ b/client/src/app/components/nova/Input.tsx @@ -4,7 +4,7 @@ import { Utils } from "@iota/sdk-wasm-nova/web"; import classNames from "classnames"; import React, { useState } from "react"; import { useHistory, Link } from "react-router-dom"; -import Bech32Address from "../stardust/address/Bech32Address"; +import Bech32Address from "../nova/address/Bech32Address"; import { useNetworkInfoNova } from "~helpers/nova/networkInfo"; import OutputView from "./OutputView"; import DropdownIcon from "~assets/dropdown-arrow.svg?react"; @@ -57,15 +57,7 @@ const Input: React.FC = ({ input, network }) => {
Address
- +
Transaction Id
diff --git a/client/src/app/components/nova/OutputView.tsx b/client/src/app/components/nova/OutputView.tsx index 5e445f594..5fe292f08 100644 --- a/client/src/app/components/nova/OutputView.tsx +++ b/client/src/app/components/nova/OutputView.tsx @@ -13,7 +13,6 @@ import { TokenSchemeType, SimpleTokenScheme, DelegationOutput, - AddressType, Utils, } from "@iota/sdk-wasm-nova/web"; import UnlockConditionView from "./UnlockConditionView"; @@ -22,7 +21,8 @@ import { Link } from "react-router-dom"; import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; import FeatureView from "./FeaturesView"; import TruncatedId from "../stardust/TruncatedId"; -import { Bech32AddressHelper } from "~/helpers/nova/bech32AddressHelper"; +import { HexHelper } from "~/helpers/stardust/hexHelper"; +import bigInt from "big-integer"; import "./OutputView.scss"; interface OutputViewProps { @@ -115,7 +115,7 @@ const OutputView: React.FC = ({ outputId, output, showCopyAmoun showCopyButton />
-
Staten index:
+
State index:
{(output as AnchorOutput).stateIndex}
)} @@ -217,20 +217,21 @@ const OutputView: React.FC = ({ outputId, output, showCopyAmoun }; function buildAddressForAliasOrNft(outputId: string, output: Output, bech32Hrp: string): string { - let address: string = ""; - let addressType: number = 0; + let bech32: string = ""; if (output.type === OutputType.Account) { - const accountId = Utils.computeAccountId(outputId); - address = accountId; - addressType = AddressType.Account; + const accountIdFromOutput = (output as AccountOutput).accountId; + const accountId = HexHelper.toBigInt256(accountIdFromOutput).eq(bigInt.zero) + ? Utils.computeAccountId(outputId) + : accountIdFromOutput; + bech32 = Utils.accountIdToBech32(accountId, bech32Hrp); } else if (output.type === OutputType.Nft) { - const nftId = Utils.computeNftId(outputId); - address = nftId; - addressType = AddressType.Nft; + const nftIdFromOutput = (output as NftOutput).nftId; + const nftId = HexHelper.toBigInt256(nftIdFromOutput).eq(bigInt.zero) ? Utils.computeNftId(outputId) : nftIdFromOutput; + bech32 = Utils.nftIdToBech32(nftId, bech32Hrp); } - return Bech32AddressHelper.buildAddress(bech32Hrp, address, addressType).bech32; + return bech32; } function getOutputTypeName(type: OutputType): string { diff --git a/client/src/app/components/nova/address/AccountAddressView.tsx b/client/src/app/components/nova/address/AccountAddressView.tsx index 418e6cf3a..a818ab1ba 100644 --- a/client/src/app/components/nova/address/AccountAddressView.tsx +++ b/client/src/app/components/nova/address/AccountAddressView.tsx @@ -2,16 +2,18 @@ import { AccountAddress } from "@iota/sdk-wasm-nova/web"; import React from "react"; import { useAccountAddressState } from "~/helpers/nova/hooks/useAccountAddressState"; import Spinner from "../../Spinner"; -import Bech32Address from "../../stardust/address/Bech32Address"; -import AssociatedOutputs from "./section/association/AssociatedOutputs"; +import Bech32Address from "../../nova/address/Bech32Address"; +import { AddressPageTabbedSections } from "./section/AddressPageTabbedSections"; +import AddressBalance from "./AddressBalance"; interface AccountAddressViewProps { accountAddress: AccountAddress; } const AccountAddressView: React.FC = ({ accountAddress }) => { - const { accountAddressDetails, isAccountDetailsLoading } = useAccountAddressState(accountAddress); - const isPageLoading = isAccountDetailsLoading; + const [state, setState] = useAccountAddressState(accountAddress); + const { accountAddressDetails, totalBalance, availableBalance, isAccountDetailsLoading, isAssociatedOutputsLoading } = state; + const isPageLoading = isAccountDetailsLoading || isAssociatedOutputsLoading; return (
@@ -20,7 +22,7 @@ const AccountAddressView: React.FC = ({ accountAddress
-

{accountAddressDetails.typeLabel?.replace("Ed25519", "Address")}

+

{accountAddressDetails.label?.replace("Ed25519", "Address")}

{isPageLoading && }
@@ -33,15 +35,21 @@ const AccountAddressView: React.FC = ({ accountAddress
+ {totalBalance !== null && ( + + )}
-
-
-

Associated Outputs

-
- -
+ setState({ isAssociatedOutputsLoading: val })} + />
)} diff --git a/client/src/app/components/nova/address/AddressBalance.scss b/client/src/app/components/nova/address/AddressBalance.scss new file mode 100644 index 000000000..ce9177294 --- /dev/null +++ b/client/src/app/components/nova/address/AddressBalance.scss @@ -0,0 +1,77 @@ +@import "../../../../scss/media-queries"; + +.balance-wrapper { + margin-top: 40px; + + .icon { + margin-right: 16px; + } + + .balance-wrapper--inner { + display: flex; + + .balance { + display: flex; + flex-direction: row; + + .icon { + align-self: center; + + @include tablet-down { + display: none; + } + } + + &:not(:last-child) { + margin-right: 40px; + + @include tablet-down { + margin-right: 0px; + } + } + + .balance-value { + display: flex; + flex-direction: column; + + @include tablet-down { + flex-direction: row; + } + + .balance-value--inline { + @include tablet-down { + margin-left: 5px; + } + } + } + + .balance-base-token, + .balance-fiat { + color: #b0bfd9; + font-size: 18px; + } + + .balance-heading { + height: 20px; + + .material-icons { + font-size: 18px; + color: #b0bfd9; + padding-left: 5px; + } + } + } + } + + @include tablet-down { + .balance-wrapper--inner { + flex-direction: column; + + .balance { + &:not(:first-child) { + margin-top: 26px; + } + } + } + } +} diff --git a/client/src/app/components/nova/address/AddressBalance.tsx b/client/src/app/components/nova/address/AddressBalance.tsx new file mode 100644 index 000000000..8d7434cb1 --- /dev/null +++ b/client/src/app/components/nova/address/AddressBalance.tsx @@ -0,0 +1,99 @@ +import React, { useState } from "react"; +import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; +import { formatAmount } from "~helpers/stardust/valueFormatHelper"; +import CopyButton from "../../CopyButton"; +import Icon from "../../Icon"; +import Tooltip from "../../Tooltip"; +import "./AddressBalance.scss"; + +interface AddressBalanceProps { + /** + * The totalBalance amount from chronicle (representing trivial + conditional balance). + */ + readonly totalBalance: number; + /** + * The trivially unlockable portion of the balance, fetched from chronicle. + */ + readonly availableBalance: number | null; + /** + * The storage rent balance. + */ + readonly storageDeposit: number | null; +} + +const CONDITIONAL_BALANCE_INFO = + "These funds reside within outputs with additional unlock conditions which might be potentially un-lockable"; + +const AddressBalance: React.FC = ({ totalBalance, availableBalance, storageDeposit }) => { + const { tokenInfo } = useNetworkInfoNova((s) => s.networkInfo); + const [formatBalanceFull, setFormatBalanceFull] = useState(false); + const [formatConditionalBalanceFull, setFormatConditionalBalanceFull] = useState(false); + const [formatStorageBalanceFull, setFormatStorageBalanceFull] = useState(false); + + const buildBalanceView = ( + label: string, + isFormatFull: boolean, + setIsFormatFull: React.Dispatch>, + showInfo: boolean, + showWallet: boolean, + amount: number | null, + ) => ( +
+ {showWallet && } +
+
+
{label}
+ {showInfo && ( + + info + + )} +
+
+ {amount !== null && amount > 0 ? ( +
+
+ setIsFormatFull(!isFormatFull)} className="balance-base-token pointer margin-r-5"> + {formatAmount(amount, tokenInfo, isFormatFull)} + + +
+
+ ) : ( + 0 + )} +
+
+
+ ); + + const conditionalBalance = availableBalance === null ? undefined : totalBalance - availableBalance; + const shouldShowExtendedBalance = conditionalBalance !== undefined && availableBalance !== undefined; + + return ( +
+
+ {buildBalanceView( + "Available Balance", + formatBalanceFull, + setFormatBalanceFull, + false, + true, + shouldShowExtendedBalance ? availableBalance : totalBalance, + )} + {shouldShowExtendedBalance && + buildBalanceView( + "Conditionally Locked Balance", + formatConditionalBalanceFull, + setFormatConditionalBalanceFull, + true, + false, + conditionalBalance, + )} + {buildBalanceView("Storage Deposit", formatStorageBalanceFull, setFormatStorageBalanceFull, false, false, storageDeposit)} +
+
+ ); +}; + +export default AddressBalance; diff --git a/client/src/app/components/nova/address/AddressView.tsx b/client/src/app/components/nova/address/AddressView.tsx index c62b02e7f..9b479d660 100644 --- a/client/src/app/components/nova/address/AddressView.tsx +++ b/client/src/app/components/nova/address/AddressView.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Address, AddressType } from "@iota/sdk-wasm-nova/web"; import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; -import { Bech32AddressHelper } from "~/helpers/nova/bech32AddressHelper"; +import { AddressHelper } from "~/helpers/nova/addressHelper"; import TruncatedId from "../../stardust/TruncatedId"; interface AddressViewProps { @@ -10,7 +10,7 @@ interface AddressViewProps { const AddressView: React.FC = ({ address }) => { const { name: networkName, bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); - const addressDetails = Bech32AddressHelper.buildAddress(bech32Hrp, address); + const addressDetails = AddressHelper.buildAddress(bech32Hrp, address); const link = `/${networkName}/addr/${addressDetails.bech32}`; return ( diff --git a/client/src/app/components/nova/address/AnchorAddressView.tsx b/client/src/app/components/nova/address/AnchorAddressView.tsx index 622c4d696..26133497d 100644 --- a/client/src/app/components/nova/address/AnchorAddressView.tsx +++ b/client/src/app/components/nova/address/AnchorAddressView.tsx @@ -2,16 +2,18 @@ import { AnchorAddress } from "@iota/sdk-wasm-nova/web"; import React from "react"; import { useAnchorAddressState } from "~/helpers/nova/hooks/useAnchorAddressState"; import Spinner from "../../Spinner"; -import Bech32Address from "../../stardust/address/Bech32Address"; -import AssociatedOutputs from "./section/association/AssociatedOutputs"; +import AddressBalance from "./AddressBalance"; +import Bech32Address from "./Bech32Address"; +import { AddressPageTabbedSections } from "./section/AddressPageTabbedSections"; interface AnchorAddressViewProps { anchorAddress: AnchorAddress; } const AnchorAddressView: React.FC = ({ anchorAddress }) => { - const { anchorAddressDetails, isAnchorDetailsLoading } = useAnchorAddressState(anchorAddress); - const isPageLoading = isAnchorDetailsLoading; + const [state, setState] = useAnchorAddressState(anchorAddress); + const { anchorAddressDetails, totalBalance, availableBalance, isAnchorDetailsLoading, isAssociatedOutputsLoading } = state; + const isPageLoading = isAnchorDetailsLoading || isAssociatedOutputsLoading; return (
@@ -20,7 +22,7 @@ const AnchorAddressView: React.FC = ({ anchorAddress })
-

{anchorAddressDetails.typeLabel?.replace("Ed25519", "Address")}

+

{anchorAddressDetails.label?.replace("Ed25519", "Address")}

{isPageLoading && }
@@ -33,15 +35,21 @@ const AnchorAddressView: React.FC = ({ anchorAddress })
+ {totalBalance !== null && ( + + )}
-
-
-

Associated Outputs

-
- -
+ setState({ isAssociatedOutputsLoading: val })} + />
)} diff --git a/client/src/app/components/nova/address/Bech32Address.tsx b/client/src/app/components/nova/address/Bech32Address.tsx new file mode 100644 index 000000000..c5a78aca7 --- /dev/null +++ b/client/src/app/components/nova/address/Bech32Address.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import * as H from "history"; +import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; +import TruncatedId from "../../stardust/TruncatedId"; + +interface Bech32AddressProps { + network?: string; + history?: H.History; + addressDetails?: IAddressDetails; + advancedMode: boolean; + hideLabel?: boolean; +} + +const Bech32Address: React.FC = ({ network, history, addressDetails, advancedMode, hideLabel }) => { + return ( +
+ {addressDetails?.bech32 && ( +
+ {!hideLabel &&
{addressDetails.label} Address
} +
+ {history && ( + + )} + {!history && } +
+
+ )} + {advancedMode && addressDetails?.label && addressDetails?.hex && ( +
+
{addressDetails.label} Id
+
+ {history && ( + + )} + {!history && } +
+
+ )} +
+ ); +}; + +export default Bech32Address; diff --git a/client/src/app/components/nova/address/Ed25519AddressView.tsx b/client/src/app/components/nova/address/Ed25519AddressView.tsx index c845d829c..937111802 100644 --- a/client/src/app/components/nova/address/Ed25519AddressView.tsx +++ b/client/src/app/components/nova/address/Ed25519AddressView.tsx @@ -1,15 +1,19 @@ import { Ed25519Address } from "@iota/sdk-wasm-nova/web"; import React from "react"; import { useEd25519AddressState } from "~/helpers/nova/hooks/useEd25519AddressState"; -import Bech32Address from "../../stardust/address/Bech32Address"; -import AssociatedOutputs from "./section/association/AssociatedOutputs"; +import AddressBalance from "./AddressBalance"; +import Bech32Address from "./Bech32Address"; +import { AddressPageTabbedSections } from "./section/AddressPageTabbedSections"; +import Spinner from "../../Spinner"; interface Ed25519AddressViewProps { ed25519Address: Ed25519Address; } const Ed25519AddressView: React.FC = ({ ed25519Address }) => { - const { ed25519AddressDetails } = useEd25519AddressState(ed25519Address); + const [state, setState] = useEd25519AddressState(ed25519Address); + const { ed25519AddressDetails, totalBalance, availableBalance, isAssociatedOutputsLoading } = state; + const isPageLoading = isAssociatedOutputsLoading; return (
@@ -18,7 +22,8 @@ const Ed25519AddressView: React.FC = ({ ed25519Address
-

{ed25519AddressDetails.typeLabel?.replace("Ed25519", "Address")}

+

{ed25519AddressDetails.label?.replace("Ed25519", "Address")}

+ {isPageLoading && }
@@ -30,15 +35,21 @@ const Ed25519AddressView: React.FC = ({ ed25519Address
+ {totalBalance !== null && ( + + )}
-
-
-

Associated Outputs

-
- -
+ setState({ isAssociatedOutputsLoading: val })} + />
)}
diff --git a/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx b/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx new file mode 100644 index 000000000..03e25303c --- /dev/null +++ b/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx @@ -0,0 +1,60 @@ +import { ImplicitAccountCreationAddress } from "@iota/sdk-wasm-nova/web"; +import React from "react"; +import { useImplicitAccountCreationAddressState } from "~/helpers/nova/hooks/useImplicitAccountCreationAddressState"; +import AddressBalance from "./AddressBalance"; +import Bech32Address from "./Bech32Address"; +import Spinner from "../../Spinner"; +import { AddressPageTabbedSections } from "./section/AddressPageTabbedSections"; + +interface ImplicitAccountCreationAddressViewProps { + implicitAccountCreationAddress: ImplicitAccountCreationAddress; +} + +const ImplicitAccountCreationAddressView: React.FC = ({ implicitAccountCreationAddress }) => { + const [state, setState] = useImplicitAccountCreationAddressState(implicitAccountCreationAddress); + const { implicitAccountCreationAddressDetails, totalBalance, availableBalance, isAssociatedOutputsLoading } = state; + const isPageLoading = isAssociatedOutputsLoading; + + return ( +
+
+ {implicitAccountCreationAddressDetails && ( +
+
+
+

{implicitAccountCreationAddressDetails.label?.replace("Ed25519", "Address")}

+
+ {isPageLoading && } +
+
+
+
+

General

+
+
+
+
+ + {totalBalance !== null && ( + + )} +
+
+
+ setState({ isAssociatedOutputsLoading: val })} + /> +
+ )} +
+
+ ); +}; + +export default ImplicitAccountCreationAddressView; diff --git a/client/src/app/components/nova/address/NftAddressView.tsx b/client/src/app/components/nova/address/NftAddressView.tsx index 358a6c11b..a13f2d3c1 100644 --- a/client/src/app/components/nova/address/NftAddressView.tsx +++ b/client/src/app/components/nova/address/NftAddressView.tsx @@ -2,16 +2,18 @@ import { NftAddress } from "@iota/sdk-wasm-nova/web"; import React from "react"; import { useNftAddressState } from "~/helpers/nova/hooks/useNftAddressState"; import Spinner from "../../Spinner"; -import Bech32Address from "../../stardust/address/Bech32Address"; -import AssociatedOutputs from "./section/association/AssociatedOutputs"; +import AddressBalance from "./AddressBalance"; +import Bech32Address from "./Bech32Address"; +import { AddressPageTabbedSections } from "./section/AddressPageTabbedSections"; interface NftAddressViewProps { nftAddress: NftAddress; } const NftAddressView: React.FC = ({ nftAddress }) => { - const { nftAddressDetails, isNftDetailsLoading } = useNftAddressState(nftAddress); - const isPageLoading = isNftDetailsLoading; + const [state, setState] = useNftAddressState(nftAddress); + const { nftAddressDetails, totalBalance, availableBalance, isNftDetailsLoading, isAssociatedOutputsLoading } = state; + const isPageLoading = isNftDetailsLoading || isAssociatedOutputsLoading; return (
@@ -20,7 +22,7 @@ const NftAddressView: React.FC = ({ nftAddress }) => {
-

{nftAddressDetails.typeLabel?.replace("Ed25519", "Address")}

+

{nftAddressDetails.label?.replace("Ed25519", "Address")}

{isPageLoading && }
@@ -33,15 +35,21 @@ const NftAddressView: React.FC = ({ nftAddress }) => {
+ {totalBalance !== null && ( + + )}
-
-
-

Associated Outputs

-
- -
+ setState({ isAssociatedOutputsLoading: val })} + />
)} diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx new file mode 100644 index 000000000..82ba54e4a --- /dev/null +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -0,0 +1,50 @@ +import React, { useState } from "react"; +import associatedOuputsMessage from "~assets/modals/stardust/address/associated-outputs.json"; +import TabbedSection from "../../../hoc/TabbedSection"; +import AssociatedOutputs from "./association/AssociatedOutputs"; +import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; + +enum DEFAULT_TABS { + AssocOutputs = "Outputs", +} + +const buildDefaultTabsOptions = (associatedOutputCount: number) => ({ + [DEFAULT_TABS.AssocOutputs]: { + disabled: associatedOutputCount === 0, + counter: associatedOutputCount, + infoContent: associatedOuputsMessage, + }, +}); + +interface IAddressPageTabbedSectionsProps { + readonly addressDetails: IAddressDetails; + readonly setAssociatedOutputsLoading: (isLoading: boolean) => void; +} + +export const AddressPageTabbedSections: React.FC = ({ addressDetails, setAssociatedOutputsLoading }) => { + const [outputCount, setOutputCount] = useState(0); + + if (!addressDetails) { + return null; + } + + const defaultSections = [ + , + ]; + + const tabEnums = DEFAULT_TABS; + const defaultTabsOptions = buildDefaultTabsOptions(outputCount); + const tabOptions = defaultTabsOptions; + const tabbedSections = defaultSections; + + return ( + + {tabbedSections} + + ); +}; diff --git a/client/src/app/components/nova/address/section/association/AssociatedOutputs.tsx b/client/src/app/components/nova/address/section/association/AssociatedOutputs.tsx index 186b1f1ec..cdda58666 100644 --- a/client/src/app/components/nova/address/section/association/AssociatedOutputs.tsx +++ b/client/src/app/components/nova/address/section/association/AssociatedOutputs.tsx @@ -4,7 +4,7 @@ import { AssociatedOutputTab, buildAssociatedOutputsTabs, outputTypeToAssociatio import AssociationSection from "./AssociationSection"; import { useAssociatedOutputs } from "~helpers/nova/hooks/useAssociatedOutputs"; import { useNetworkInfoNova } from "~helpers/nova/networkInfo"; -import { IBech32AddressDetails } from "~models/api/IBech32AddressDetails"; +import { IAddressDetails } from "~models/api/nova/IAddressDetails"; import { AssociationType, IAssociation } from "~models/api/nova/IAssociationsResponse"; import "./AssociatedOutputs.scss"; @@ -12,7 +12,7 @@ interface AssociatedOutputsProps { /** * Address details */ - readonly addressDetails: IBech32AddressDetails; + readonly addressDetails: IAddressDetails; /** * Callback setter to report the associated outputs count. */ diff --git a/client/src/app/routes.tsx b/client/src/app/routes.tsx index 446a84365..4654cdbb7 100644 --- a/client/src/app/routes.tsx +++ b/client/src/app/routes.tsx @@ -35,6 +35,7 @@ import StardustOutputList from "./routes/stardust/OutputList"; import StardustOutputPage from "./routes/stardust/OutputPage"; import NovaBlockPage from "./routes/nova/Block"; import NovaOutputPage from "./routes/nova/OutputPage"; +import NovaSearch from "./routes/nova/Search"; import StardustSearch from "./routes/stardust/Search"; import StardustStatisticsPage from "./routes/stardust/statistics/StatisticsPage"; import StardustTransactionPage from "./routes/stardust/TransactionPage"; @@ -174,6 +175,7 @@ const buildAppRoutes = (protocolVersion: string, withNetworkContext: (wrappedCom , , , + , ]; return ( diff --git a/client/src/app/routes/nova/AddressPage.tsx b/client/src/app/routes/nova/AddressPage.tsx index 7236ad519..ac13d7727 100644 --- a/client/src/app/routes/nova/AddressPage.tsx +++ b/client/src/app/routes/nova/AddressPage.tsx @@ -1,4 +1,13 @@ -import { AccountAddress, Address, AddressType, AnchorAddress, Ed25519Address, NftAddress, Utils } from "@iota/sdk-wasm-nova/web"; +import { + AccountAddress, + Address, + AddressType, + AnchorAddress, + Ed25519Address, + ImplicitAccountCreationAddress, + NftAddress, + Utils, +} from "@iota/sdk-wasm-nova/web"; import React from "react"; import { RouteComponentProps } from "react-router-dom"; import AddressNotFoundPage from "~/app/components/nova/address/AddressNotFoundPage"; @@ -7,6 +16,7 @@ import AccountAddressView from "~/app/components/nova/address/AccountAddressView import Ed25519AddressView from "~/app/components/nova/address/Ed25519AddressView"; import NftAddressView from "~/app/components/nova/address/NftAddressView"; import AnchorAddressView from "~/app/components/nova/address/AnchorAddressView"; +import ImplicitAccountCreationAddressView from "~/app/components/nova/address/ImplicitAccountCreationAddressView"; import "./AddressPage.scss"; const AddressPage: React.FC> = ({ @@ -32,6 +42,10 @@ const AddressPage: React.FC> = ({ return ; case AddressType.Anchor: return ; + case AddressType.ImplicitAccountCreation: + return ( + + ); default: return (
diff --git a/client/src/app/routes/nova/OutputPage.tsx b/client/src/app/routes/nova/OutputPage.tsx index 32ba9592c..1abdfaf8d 100644 --- a/client/src/app/routes/nova/OutputPage.tsx +++ b/client/src/app/routes/nova/OutputPage.tsx @@ -187,14 +187,19 @@ const OutputPage: React.FC> = ({ }; function computeOutputIndexFromOutputId(outputId: string | null) { + let outputIndex = null; + if (!outputId) { return null; } - const outputIndexPart = outputId.slice(-4); - const outputIndexBigEndian = Converter.convertToBigEndian(outputIndexPart); + try { + const outputIndexPart = outputId.slice(-4); + const outputIndexBigEndian = Converter.convertToBigEndian(outputIndexPart); + outputIndex = parseInt(outputIndexBigEndian, 16); + } catch {} - return Number(outputIndexBigEndian); + return outputIndex; } export default OutputPage; diff --git a/client/src/app/routes/nova/Search.tsx b/client/src/app/routes/nova/Search.tsx new file mode 100644 index 000000000..77702e554 --- /dev/null +++ b/client/src/app/routes/nova/Search.tsx @@ -0,0 +1,260 @@ +import React, { useState, useEffect } from "react"; +import { Redirect, RouteComponentProps, useLocation, useParams } from "react-router-dom"; +import { SearchRouteProps } from "../SearchRouteProps"; +import { NetworkService } from "~/services/networkService"; +import { ServiceFactory } from "~/factories/serviceFactory"; +import { NOVA, ProtocolVersion } from "~/models/config/protocolVersion"; +import { NovaApiClient } from "~/services/nova/novaApiClient"; +import { SearchState } from "../SearchState"; +import { scrollToTop } from "~/helpers/pageUtils"; +import { AddressType, Block } from "@iota/sdk-wasm-nova/web"; +import Spinner from "~/app/components/Spinner"; +import { AddressHelper } from "~/helpers/nova/addressHelper"; +import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; + +const Search: React.FC> = (props) => { + const { protocolInfo, bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); + const networkService = ServiceFactory.get("network"); + const protocolVersion: ProtocolVersion = + (props.match.params.network && networkService.get(props.match.params.network)?.protocolVersion) || NOVA; + + const _apiClient = ServiceFactory.get(`api-client-${NOVA}`); + + const [state, setState] = useState({ + protocolVersion, + statusBusy: true, + status: "", + completion: "", + redirect: "", + search: "", + invalidError: "", + }); + + const location = useLocation(); + const { network, query } = useParams(); + + useEffect(() => { + scrollToTop(); + updateState(); + }, [location.pathname]); + + const updateState = () => { + const queryTerm = (query ?? "").trim(); + + let status = ""; + let statusBusy = false; + let completion = ""; + const redirect = ""; + let invalidError = ""; + + if (queryTerm.length > 0) { + status = "Detecting query type..."; + statusBusy = true; + + setTimeout(async () => { + const response = await _apiClient.search({ + network, + query: queryTerm, + }); + if (!response || response?.error) { + setState((prevState) => ({ + ...prevState, + completion: response?.error ? "invalid" : "notFound", + invalidError: response?.error ?? "", + status: "", + statusBusy: false, + })); + } else if (Object.keys(response).length > 0) { + const routeSearch = new Map(); + let route = ""; + let routeParam = query; + let redirectState = {}; + + if (response.block) { + route = "block"; + if (protocolInfo) { + routeParam = Block.id(response.block, protocolInfo.parameters); + } + } else if (response.addressDetails) { + route = "addr"; + routeParam = response.addressDetails.bech32; + redirectState = { + addressDetails: response.addressDetails, + }; + } else if (response.accountId) { + route = "addr"; + const accountAddress = buildAddressFromIdAndType(response.accountId, AddressType.Account); + redirectState = { + addressDetails: accountAddress, + }; + routeParam = accountAddress.bech32; + } else if (response.nftId) { + route = "addr"; + const nftAddress = buildAddressFromIdAndType(response.nftId, AddressType.Nft); + redirectState = { + addressDetails: nftAddress, + }; + routeParam = nftAddress.bech32; + } else if (response.anchorId) { + route = "addr"; + const anchorAddress = buildAddressFromIdAndType(response.anchorId, AddressType.Anchor); + redirectState = { + addressDetails: anchorAddress, + }; + routeParam = anchorAddress.bech32; + } else if (response.output) { + route = "output"; + routeParam = response.output.metadata.outputId; + } else if (response.transactionId) { + route = "transaction"; + routeParam = response.transactionId; + } else if (response.foundryId) { + route = "foundry"; + routeParam = response.foundryId; + } + + const getEncodedSearch = () => { + if (routeSearch.size === 0) { + return ""; + } + + const searchParams = new URLSearchParams(); + for (const [key, value] of routeSearch.entries()) { + searchParams.append(key, value); + } + + return `?${searchParams.toString()}`; + }; + + setState((prevState) => ({ + ...prevState, + status: "", + statusBusy: false, + redirect: `/${network}/${route}/${routeParam}`, + search: getEncodedSearch(), + redirectState, + })); + } + }, 0); + } else { + invalidError = "the query is empty"; + completion = "invalid"; + } + + setState((prevState) => ({ + ...prevState, + statusBusy, + status, + completion, + redirect, + invalidError, + })); + }; + + const buildAddressFromIdAndType = (id: string, type: number) => { + return AddressHelper.buildAddress(bech32Hrp, id, type); + }; + + return state.redirect ? ( + + ) : ( +
+
+
+

Search

+ {!state.completion && state.status && ( +
+
+

Searching

+
+
+ {state.statusBusy && } +

{state.status}

+
+
+ )} + {state.completion === "notFound" && ( +
+
+

Not found

+
+
+

We could not find any blocks, addresses or outputs for the query.

+
+
+
    +
  • + Query + {params.query} +
  • +
+
+
+

The following formats are supported:

+
+
    +
  • + Blocks + 74 Hex characters +
  • +
  • + Block using Transaction Id + 74 Hex characters +
  • +
  • + Addresses + Bech32 Format +
  • +
  • + Nft/Account Addresses + 66 Hex characters or Bech32 Format +
  • +
  • + Outputs + 78 Hex characters +
  • +
  • + Account Id + 66 Hex characters +
  • +
  • + Foundry Id + 78 Hex characters +
  • +
  • + Token Id + 78 Hex characters +
  • +
  • + NFT Id + 66 Hex characters +
  • +
+
+

Please perform another search with a valid hash.

+
+
+ )} + {state.completion === "invalid" && ( +
+
+

Incorrect query format

+
+
+ {state.protocolVersion === NOVA &&

{state.invalidError}.

} +
+
+ )} +
+
+
+ ); +}; + +export default Search; diff --git a/client/src/features/visualizer-threejs/VisualizerInstance.tsx b/client/src/features/visualizer-threejs/VisualizerInstance.tsx index 8b8d6c521..dffbea9db 100644 --- a/client/src/features/visualizer-threejs/VisualizerInstance.tsx +++ b/client/src/features/visualizer-threejs/VisualizerInstance.tsx @@ -84,13 +84,11 @@ const VisualizerInstance: React.FC> = useEffect(() => { const handleVisibilityChange = async () => { if (document.hidden) { - await feedSubscriptionStop(); setIsPlaying(false); } }; const handleBlur = async () => { - await feedSubscriptionStop(); setIsPlaying(false); }; @@ -122,13 +120,9 @@ const VisualizerInstance: React.FC> = return; } - if (isPlaying) { - feedSubscriptionStart(); - } else { - await feedSubscriptionStop(); - } + feedSubscriptionStart(); })(); - }, [feedService, isPlaying, runListeners]); + }, [feedService, runListeners]); /** * Control width and height of canvas @@ -161,18 +155,57 @@ const VisualizerInstance: React.FC> = setRunListeners(false); setIsPlaying(false); resetConfigState(); - await feedSubscriptionStop(); + await feedSubscriptionFinalize(); setFeedService(ServiceFactory.get(`feed-${network}`)); })(); }, [network]); + useEffect(() => { + if (!runListeners) { + return; + } + setIsPlaying(true); + + return () => { + bpsCounter.stop(); + }; + }, [runListeners]); + + useEffect(() => { + if (!runListeners) { + return; + } + setIsPlaying(true); + + return () => { + bpsCounter.stop(); + }; + }, [runListeners]); + + const feedSubscriptionStart = () => { + if (!feedService) { + return; + } + feedService.subscribeBlocks(onNewBlock, onBlockMetadataUpdate, onSlotFinalized); + + bpsCounter.start(); + }; + + const feedSubscriptionFinalize = async () => { + if (!feedService) { + return; + } + await feedService.unsubscribeBlocks(); + bpsCounter.reset(); + }; + /** * Subscribe to updates * @param blockData The new block data */ const onNewBlock = (blockData: IFeedBlockData) => { const emitterObj = emitterRef.current; - if (emitterObj && blockData) { + if (emitterObj && blockData && isPlaying) { const emitterBox = new Box3().setFromObject(emitterObj); const emitterCenter = new THREE.Vector3(); @@ -246,33 +279,6 @@ const VisualizerInstance: React.FC> = removeConfirmedBlocksSlot(slot); } - useEffect(() => { - if (!runListeners) { - return; - } - setIsPlaying(true); - - return () => { - bpsCounter.stop(); - }; - }, [runListeners]); - - const feedSubscriptionStart = () => { - if (!feedService) { - return; - } - feedService.subscribeBlocks(onNewBlock, onBlockMetadataUpdate, onSlotFinalized); - bpsCounter.start(); - }; - - const feedSubscriptionStop = async () => { - if (!feedService) { - return; - } - await feedService.unsubscribeBlocks(); - bpsCounter.reset(); - }; - return ( []; addToColorQueue: (blockId: string, color: Color) => void; - removeFromColorQueue: (blockId: string) => void; + removeFromColorQueue: (blockIds: string[]) => void; // Map of blockId to index in Tangle 'InstancedMesh' blockIdToIndex: Map; @@ -162,11 +162,13 @@ export const useTangleStore = create()( colorQueue: [...state.colorQueue, { id, color }], })); }, - removeFromColorQueue: (blockId: string) => { - set((state) => ({ - ...state, - colorQueue: state.colorQueue.filter((block) => block.id !== blockId), - })); + removeFromColorQueue: (blockIds) => { + if (blockIds.length > 0) { + set((state) => ({ + ...state, + colorQueue: state.colorQueue.filter((block) => !blockIds.includes(block.id)), + })); + } }, updateBlockIdToIndex: (blockId: string, index: number) => { set((state) => { diff --git a/client/src/features/visualizer-threejs/useRenderTangle.tsx b/client/src/features/visualizer-threejs/useRenderTangle.tsx index 092429ede..4e6e93604 100644 --- a/client/src/features/visualizer-threejs/useRenderTangle.tsx +++ b/client/src/features/visualizer-threejs/useRenderTangle.tsx @@ -28,18 +28,6 @@ export const useRenderTangle = () => { const blockIdToPosition = useTangleStore((s) => s.blockIdToPosition); const blockIdToAnimationPosition = useTangleStore((s) => s.blockIdToAnimationPosition); - const updateBlockColor = (blockId: string, color: THREE.Color): void => { - const indexToUpdate = blockIdToIndex.get(blockId); - - if (indexToUpdate) { - tangleMeshRef.current.setColorAt(indexToUpdate, color); - if (tangleMeshRef.current.instanceColor) { - tangleMeshRef.current.instanceColor.needsUpdate = true; - } - removeFromColorQueue(blockId); - } - }; - function updateInstancedMeshPosition( instancedMesh: THREE.InstancedMesh, index: number, @@ -175,12 +163,23 @@ export const useRenderTangle = () => { }, [blockQueue, blockIdToAnimationPosition]); useEffect(() => { - if (colorQueue.length === 0) { - return; - } + if (colorQueue.length > 0) { + const removeIds: string[] = []; + for (const { id, color } of colorQueue) { + const indexToUpdate = blockIdToIndex.get(id); + + if (indexToUpdate) { + tangleMeshRef.current.setColorAt(indexToUpdate, color); + + if (tangleMeshRef.current.instanceColor) { + tangleMeshRef.current.instanceColor.needsUpdate = true; + } + + removeIds.push(id); + } + } - for (const { id, color } of colorQueue) { - updateBlockColor(id, color); + removeFromColorQueue(removeIds); } - }, [colorQueue]); + }, [colorQueue, blockIdToIndex]); }; diff --git a/client/src/features/visualizer-threejs/wrapper/KeyPanel.scss b/client/src/features/visualizer-threejs/wrapper/KeyPanel.scss index ef0a9631f..354e27b6a 100644 --- a/client/src/features/visualizer-threejs/wrapper/KeyPanel.scss +++ b/client/src/features/visualizer-threejs/wrapper/KeyPanel.scss @@ -36,31 +36,7 @@ width: 12px; height: 12px; margin-right: 10px; - border-radius: 3px; - - &.vertex-state--pending { - background-color: #bbbbbb; - } - - &.vertex-state--included { - background-color: #4caaff; - } - - &.vertex-state--referenced { - background-color: #61e884; - } - - &.vertex-state--conflicting { - background-color: #ff8b5c; - } - - &.vertex-state--milestone { - background-color: #666af6; - } - - &.vertex-state--search-result { - background-color: #c061e8; - } + border-radius: 50%; } .key-label { diff --git a/client/src/features/visualizer-threejs/wrapper/KeyPanel.tsx b/client/src/features/visualizer-threejs/wrapper/KeyPanel.tsx index b9e006a99..f2affbbea 100644 --- a/client/src/features/visualizer-threejs/wrapper/KeyPanel.tsx +++ b/client/src/features/visualizer-threejs/wrapper/KeyPanel.tsx @@ -1,34 +1,63 @@ import React from "react"; +import { BlockState } from "@iota/sdk-wasm-nova/web"; import "./KeyPanel.scss"; -export const KeyPanel: React.FC = () => ( -
-
-
-
-
Pending
-
-
-
-
Included
-
-
-
-
Referenced
-
-
-
-
Conflicting
-
-
-
-
Milestone
-
-
-
-
Search result
+export const KeyPanel: React.FC = () => { + const statuses: { + label: string; + state: BlockState; + color: string; + }[] = [ + { + label: "Pending", + state: "pending", + color: "#A6C3FC", + }, + { + label: "Accepted", + state: "accepted", + color: "#0101AB", + }, + { + label: "Confirmed", + state: "confirmed", + color: "#0000DB", + }, + { + label: "Finalized", + state: "finalized", + color: "#0101FF", + }, + { + label: "Rejected", + state: "rejected", + color: "#252525", + }, + { + label: "Failed", + state: "failed", + color: "#ff1d38", + }, + ]; + + return ( +
+
+ {statuses.map((s) => { + return ( +
+
+
{s.label}
+
+ ); + })}
-
-); + ); +}; diff --git a/client/src/helpers/nova/addressHelper.ts b/client/src/helpers/nova/addressHelper.ts new file mode 100644 index 000000000..55baf3641 --- /dev/null +++ b/client/src/helpers/nova/addressHelper.ts @@ -0,0 +1,136 @@ +import { + Address, + AddressType, + AccountAddress, + Ed25519Address, + NftAddress, + AnchorAddress, + Utils, + ImplicitAccountCreationAddress, + RestrictedAddress, +} from "@iota/sdk-wasm-nova/web"; +import { HexHelper } from "../stardust/hexHelper"; +import { IAddressDetails } from "~models/api/nova/IAddressDetails"; +import { plainToInstance } from "class-transformer"; + +export class AddressHelper { + /** + * Build the address details. + * @param hrp The human readable part of the address. + * @param address The address to source the data from. + * @param typeHint The type of the address. + * @returns The parts of the address. + */ + public static buildAddress(hrp: string, address: string | Address, typeHint?: number): IAddressDetails { + return typeof address === "string" ? this.buildAddressFromString(hrp, address, typeHint) : this.buildAddressFromTypes(address, hrp); + } + + private static buildAddressFromString(hrp: string, addressString: string, typeHint?: number): IAddressDetails { + let bech32; + let hex; + let type; + + if (Utils.isAddressValid(addressString)) { + try { + const address: Address = Utils.parseBech32Address(addressString); + + if (address) { + bech32 = addressString; + type = address.type; + hex = Utils.bech32ToHex(addressString); + } + } catch {} + } + + if (!bech32) { + // We assume this is hex and either use the hint or assume ed25519 + hex = addressString; + type = typeHint ?? AddressType.Ed25519; + bech32 = this.computeBech32FromHexAndType(hex, type, hrp); + } + + return { + bech32, + hex: hex ? HexHelper.addPrefix(hex) : hex, + type, + label: AddressHelper.typeLabel(type), + restricted: false, + }; + } + + private static buildAddressFromTypes( + address: Address, + hrp: string, + restricted: boolean = false, + capabilities?: number[], + ): IAddressDetails { + let hex: string = ""; + let bech32: string = ""; + + if (address.type === AddressType.Ed25519) { + hex = (address as Ed25519Address).pubKeyHash; + } else if (address.type === AddressType.Account) { + hex = (address as AccountAddress).accountId; + } else if (address.type === AddressType.Nft) { + hex = (address as NftAddress).nftId; + } else if (address.type === AddressType.Anchor) { + hex = (address as AnchorAddress).anchorId; + } else if (address.type === AddressType.ImplicitAccountCreation) { + const implicitAccountCreationAddress = plainToInstance(ImplicitAccountCreationAddress, address); + const innerAddress = implicitAccountCreationAddress.address(); + hex = (innerAddress as Ed25519Address).pubKeyHash; + } else if (address.type === AddressType.Restricted) { + const restrictedAddress = plainToInstance(RestrictedAddress, address); + const innerAddress = restrictedAddress.address; + + return this.buildAddressFromTypes(innerAddress, hrp, true, Array.from(restrictedAddress.getAllowedCapabilities())); + } + + bech32 = this.computeBech32FromHexAndType(hex, address.type, hrp); + + return { + bech32, + hex, + type: address.type, + label: AddressHelper.typeLabel(address.type), + restricted, + capabilities, + }; + } + + private static computeBech32FromHexAndType(hex: string, addressType: AddressType, hrp: string) { + let bech32 = ""; + + if (addressType === AddressType.Ed25519) { + bech32 = Utils.hexToBech32(hex, hrp); + } else if (addressType === AddressType.Account) { + bech32 = Utils.accountIdToBech32(hex, hrp); + } else if (addressType === AddressType.Nft) { + bech32 = Utils.nftIdToBech32(hex, hrp); + } else if (addressType === AddressType.Anchor) { + // TODO Utils.anchorIdToBech32 does not exist https://github.com/iotaledger/iota-sdk/issues/1973 + bech32 = Utils.accountIdToBech32(hex, hrp); + } else if (addressType === AddressType.ImplicitAccountCreation) { + bech32 = Utils.hexToBech32(hex, hrp); + } + + return bech32; + } + + /** + * Convert the address type number to a label. + * @param addressType The address type to get the label for. + * @returns The label. + */ + private static typeLabel(addressType?: number): string | undefined { + if (addressType === AddressType.Ed25519) { + return "Ed25519"; + } else if (addressType === AddressType.Account) { + return "Account"; + } else if (addressType === AddressType.Nft) { + return "NFT"; + } else if (addressType === AddressType.Anchor) { + return "Anchor"; + } + } +} diff --git a/client/src/helpers/nova/bech32AddressHelper.ts b/client/src/helpers/nova/bech32AddressHelper.ts deleted file mode 100644 index e178078c6..000000000 --- a/client/src/helpers/nova/bech32AddressHelper.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Bech32Helper } from "@iota/iota.js"; -import { Address, AddressType, AccountAddress, Ed25519Address, NftAddress, AnchorAddress } from "@iota/sdk-wasm-nova/web"; -import { Converter } from "../stardust/convertUtils"; -import { HexHelper } from "../stardust/hexHelper"; -import { IBech32AddressDetails } from "~models/api/IBech32AddressDetails"; - -export class Bech32AddressHelper { - /** - * Build the address details. - * @param hrp The human readable part of the address. - * @param address The address to source the data from. - * @param typeHint The type of the address. - * @returns The parts of the address. - */ - public static buildAddress(hrp: string, address: string | Address, typeHint?: number): IBech32AddressDetails { - return typeof address === "string" ? this.buildAddressFromString(hrp, address, typeHint) : this.buildAddressFromTypes(hrp, address); - } - - private static buildAddressFromString(hrp: string, address: string, typeHint?: number): IBech32AddressDetails { - let bech32; - let hex; - let type; - - if (Bech32Helper.matches(address, hrp)) { - try { - const result = Bech32Helper.fromBech32(address, hrp); - if (result) { - bech32 = address; - type = result.addressType; - hex = Converter.bytesToHex(result.addressBytes, true); - } - } catch {} - } - - if (!bech32) { - // We assume this is hex and either use the hint or assume ed25519 for now - hex = address; - type = typeHint ?? AddressType.Ed25519; - bech32 = Bech32Helper.toBech32(type, Converter.hexToBytes(hex), hrp); - } - - return { - bech32, - hex: hex ? HexHelper.addPrefix(hex) : hex, - type, - typeLabel: Bech32AddressHelper.typeLabel(type), - }; - } - - private static buildAddressFromTypes(hrp: string, address: Address): IBech32AddressDetails { - let hex: string = ""; - - if (address.type === AddressType.Ed25519) { - hex = HexHelper.stripPrefix((address as Ed25519Address).pubKeyHash); - } else if (address.type === AddressType.Account) { - hex = HexHelper.stripPrefix((address as AccountAddress).accountId); - } else if (address.type === AddressType.Nft) { - hex = HexHelper.stripPrefix((address as NftAddress).nftId); - } else if (address.type === AddressType.Anchor) { - hex = HexHelper.stripPrefix((address as AnchorAddress).anchorId); - } - - return this.buildAddressFromString(hrp, hex, address.type); - } - - /** - * Convert the address type number to a label. - * @param addressType The address type to get the label for. - * @returns The label. - */ - private static typeLabel(addressType?: number): string | undefined { - if (addressType === AddressType.Ed25519) { - return "Ed25519"; - } else if (addressType === AddressType.Account) { - return "Account"; - } else if (addressType === AddressType.Nft) { - return "NFT"; - } else if (addressType === AddressType.Anchor) { - return "Anchor"; - } - } -} diff --git a/client/src/helpers/nova/hooks/useAccountAddressState.ts b/client/src/helpers/nova/hooks/useAccountAddressState.ts index 8b8b2f468..76c649eb5 100644 --- a/client/src/helpers/nova/hooks/useAccountAddressState.ts +++ b/client/src/helpers/nova/hooks/useAccountAddressState.ts @@ -1,32 +1,39 @@ import { Reducer, useEffect, useReducer } from "react"; import { AccountAddress, AccountOutput } from "@iota/sdk-wasm-nova/web"; -import { IBech32AddressDetails } from "~/models/api/IBech32AddressDetails"; +import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; import { useAccountDetails } from "./useAccountDetails"; import { useLocation, useParams } from "react-router-dom"; import { AddressRouteProps } from "~/app/routes/AddressRouteProps"; import { useNetworkInfoNova } from "../networkInfo"; -import { Bech32AddressHelper } from "~/helpers/nova/bech32AddressHelper"; +import { AddressHelper } from "~/helpers/nova/addressHelper"; +import { useAddressBalance } from "./useAddressBalance"; export interface IAccountAddressState { - accountAddressDetails: IBech32AddressDetails | null; + accountAddressDetails: IAddressDetails | null; accountOutput: AccountOutput | null; + totalBalance: number | null; + availableBalance: number | null; isAccountDetailsLoading: boolean; + isAssociatedOutputsLoading: boolean; } const initialState = { accountAddressDetails: null, accountOutput: null, + totalBalance: null, + availableBalance: null, isAccountDetailsLoading: true, + isAssociatedOutputsLoading: false, }; /** * Route Location Props */ interface IAddressPageLocationProps { - addressDetails: IBech32AddressDetails; + addressDetails: IAddressDetails; } -export const useAccountAddressState = (address: AccountAddress): IAccountAddressState => { +export const useAccountAddressState = (address: AccountAddress): [IAccountAddressState, React.Dispatch>] => { const location = useLocation(); const { network } = useParams(); const { bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); @@ -36,12 +43,13 @@ export const useAccountAddressState = (address: AccountAddress): IAccountAddress ); const { accountOutput, isLoading: isAccountDetailsLoading } = useAccountDetails(network, address.accountId); + const { totalBalance, availableBalance } = useAddressBalance(network, state.accountAddressDetails, accountOutput); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; const { addressDetails } = locationState?.addressDetails ? locationState - : { addressDetails: Bech32AddressHelper.buildAddress(bech32Hrp, address) }; + : { addressDetails: AddressHelper.buildAddress(bech32Hrp, address) }; setState({ ...initialState, @@ -53,12 +61,10 @@ export const useAccountAddressState = (address: AccountAddress): IAccountAddress setState({ accountOutput, isAccountDetailsLoading, + totalBalance, + availableBalance, }); - }, [accountOutput, isAccountDetailsLoading]); + }, [accountOutput, totalBalance, availableBalance, isAccountDetailsLoading]); - return { - accountAddressDetails: state.accountAddressDetails, - accountOutput: state.accountOutput, - isAccountDetailsLoading: state.isAccountDetailsLoading, - }; + return [state, setState]; }; diff --git a/client/src/helpers/nova/hooks/useAddressBalance.ts b/client/src/helpers/nova/hooks/useAddressBalance.ts new file mode 100644 index 000000000..b7d0dd1cd --- /dev/null +++ b/client/src/helpers/nova/hooks/useAddressBalance.ts @@ -0,0 +1,57 @@ +import { AddressType, NftOutput, AccountOutput, AnchorOutput } from "@iota/sdk-wasm-nova/web"; +import { useEffect, useState } from "react"; +import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; +import { NovaApiClient } from "~/services/nova/novaApiClient"; +import { ServiceFactory } from "~factories/serviceFactory"; +import { useIsMounted } from "~helpers/hooks/useIsMounted"; +import { NOVA } from "~models/config/protocolVersion"; + +/** + * Fetch the address balance from chronicle nova. + * @param network The Network in context + * @param address The bech32 address + * @param output The output wrapping the address, used to add the output amount to the balance + * @returns The address balance, signature locked balance and a loading bool. + */ +export function useAddressBalance( + network: string, + addressDetails: IAddressDetails | null, + output: AccountOutput | NftOutput | AnchorOutput | null, +): { totalBalance: number | null; availableBalance: number | null; isLoading: boolean } { + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [totalBalance, setTotalBalance] = useState(null); + const [availableBalance, setAvailableBalance] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + const address = addressDetails?.bech32; + const needsOutputToProceed = + addressDetails?.type === AddressType.Account || + addressDetails?.type === AddressType.Nft || + addressDetails?.type === AddressType.Anchor; + const canLoad = address && (!needsOutputToProceed || (needsOutputToProceed && output)); + if (canLoad) { + // eslint-disable-next-line no-void + void (async () => { + const response = await apiClient.addressBalanceChronicle({ network, address }); + + if (response?.totalBalance !== undefined && isMounted) { + let totalBalance = response.totalBalance; + let availableBalance = response.availableBalance ?? 0; + if (output) { + totalBalance = Number(totalBalance) + Number(output.amount); + availableBalance = Number(availableBalance) + Number(output.amount); + } + setTotalBalance(totalBalance); + setAvailableBalance(availableBalance > 0 ? availableBalance : null); + } + })(); + } else { + setIsLoading(false); + } + }, [network, addressDetails, output]); + + return { totalBalance, availableBalance, isLoading }; +} diff --git a/client/src/helpers/nova/hooks/useAnchorAddressState.ts b/client/src/helpers/nova/hooks/useAnchorAddressState.ts index dc2ff3c73..b11062bce 100644 --- a/client/src/helpers/nova/hooks/useAnchorAddressState.ts +++ b/client/src/helpers/nova/hooks/useAnchorAddressState.ts @@ -1,32 +1,39 @@ import { Reducer, useEffect, useReducer } from "react"; import { AnchorAddress, AnchorOutput } from "@iota/sdk-wasm-nova/web"; -import { IBech32AddressDetails } from "~/models/api/IBech32AddressDetails"; +import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; import { useAnchorDetails } from "./useAnchorDetails"; import { useLocation, useParams } from "react-router-dom"; import { AddressRouteProps } from "~/app/routes/AddressRouteProps"; import { useNetworkInfoNova } from "../networkInfo"; -import { Bech32AddressHelper } from "~/helpers/nova/bech32AddressHelper"; +import { AddressHelper } from "~/helpers/nova/addressHelper"; +import { useAddressBalance } from "./useAddressBalance"; export interface IAnchorAddressState { - anchorAddressDetails: IBech32AddressDetails | null; + anchorAddressDetails: IAddressDetails | null; anchorOutput: AnchorOutput | null; + availableBalance: number | null; + totalBalance: number | null; isAnchorDetailsLoading: boolean; + isAssociatedOutputsLoading: boolean; } const initialState = { anchorAddressDetails: null, anchorOutput: null, + totalBalance: null, + availableBalance: null, isAnchorDetailsLoading: true, + isAssociatedOutputsLoading: false, }; /** * Route Location Props */ interface IAddressPageLocationProps { - addressDetails: IBech32AddressDetails; + addressDetails: IAddressDetails; } -export const useAnchorAddressState = (address: AnchorAddress): IAnchorAddressState => { +export const useAnchorAddressState = (address: AnchorAddress): [IAnchorAddressState, React.Dispatch>] => { const location = useLocation(); const { network } = useParams(); const { bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); @@ -36,12 +43,13 @@ export const useAnchorAddressState = (address: AnchorAddress): IAnchorAddressSta ); const { anchorOutput, isLoading: isAnchorDetailsLoading } = useAnchorDetails(network, address.anchorId); + const { totalBalance, availableBalance } = useAddressBalance(network, state.anchorAddressDetails, anchorOutput); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; const { addressDetails } = locationState?.addressDetails ? locationState - : { addressDetails: Bech32AddressHelper.buildAddress(bech32Hrp, address) }; + : { addressDetails: AddressHelper.buildAddress(bech32Hrp, address) }; setState({ ...initialState, @@ -52,13 +60,11 @@ export const useAnchorAddressState = (address: AnchorAddress): IAnchorAddressSta useEffect(() => { setState({ anchorOutput, + totalBalance, + availableBalance, isAnchorDetailsLoading, }); - }, [anchorOutput, isAnchorDetailsLoading]); + }, [anchorOutput, totalBalance, availableBalance, isAnchorDetailsLoading]); - return { - anchorAddressDetails: state.anchorAddressDetails, - anchorOutput: state.anchorOutput, - isAnchorDetailsLoading: state.isAnchorDetailsLoading, - }; + return [state, setState]; }; diff --git a/client/src/helpers/nova/hooks/useAssociatedOutputs.ts b/client/src/helpers/nova/hooks/useAssociatedOutputs.ts index 5eaf9d6db..e924e8034 100644 --- a/client/src/helpers/nova/hooks/useAssociatedOutputs.ts +++ b/client/src/helpers/nova/hooks/useAssociatedOutputs.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { ServiceFactory } from "~/factories/serviceFactory"; import { useIsMounted } from "~/helpers/hooks/useIsMounted"; -import { IBech32AddressDetails } from "~/models/api/IBech32AddressDetails"; +import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; import { IAssociation } from "~/models/api/nova/IAssociationsResponse"; import { NOVA } from "~/models/config/protocolVersion"; import { NovaApiClient } from "~/services/nova/novaApiClient"; @@ -15,7 +15,7 @@ import { NovaApiClient } from "~/services/nova/novaApiClient"; */ export function useAssociatedOutputs( network: string, - addressDetails: IBech32AddressDetails, + addressDetails: IAddressDetails, setOutputCount?: (count: number) => void, ): [IAssociation[], boolean] { const isMounted = useIsMounted(); diff --git a/client/src/helpers/nova/hooks/useEd25519AddressState.ts b/client/src/helpers/nova/hooks/useEd25519AddressState.ts index d13520a5f..d5cbffe8b 100644 --- a/client/src/helpers/nova/hooks/useEd25519AddressState.ts +++ b/client/src/helpers/nova/hooks/useEd25519AddressState.ts @@ -2,45 +2,58 @@ import { Ed25519Address } from "@iota/sdk-wasm-nova/web"; import { Reducer, useEffect, useReducer } from "react"; import { useLocation } from "react-router-dom"; import { useNetworkInfoNova } from "../networkInfo"; -import { IBech32AddressDetails } from "~/models/api/IBech32AddressDetails"; -import { Bech32AddressHelper } from "~/helpers/nova/bech32AddressHelper"; +import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; +import { AddressHelper } from "~/helpers/nova/addressHelper"; +import { useAddressBalance } from "./useAddressBalance"; export interface IEd25519AddressState { - ed25519AddressDetails: IBech32AddressDetails | null; + ed25519AddressDetails: IAddressDetails | null; + totalBalance: number | null; + availableBalance: number | null; + isAssociatedOutputsLoading: boolean; } const initialState = { ed25519AddressDetails: null, + totalBalance: null, + availableBalance: null, + isAssociatedOutputsLoading: false, }; /** * Route Location Props */ interface IAddressPageLocationProps { - addressDetails: IBech32AddressDetails; + addressDetails: IAddressDetails; } -export const useEd25519AddressState = (address: Ed25519Address) => { +export const useEd25519AddressState = (address: Ed25519Address): [IEd25519AddressState, React.Dispatch>] => { const location = useLocation(); - const { bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); + const { name: network, bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); const [state, setState] = useReducer>>( (currentState, newState) => ({ ...currentState, ...newState }), initialState, ); + const { totalBalance, availableBalance } = useAddressBalance(network, state.ed25519AddressDetails, null); + useEffect(() => { const locationState = location.state as IAddressPageLocationProps; const { addressDetails } = locationState?.addressDetails ? locationState - : { addressDetails: Bech32AddressHelper.buildAddress(bech32Hrp, address) }; + : { addressDetails: AddressHelper.buildAddress(bech32Hrp, address) }; setState({ - ...initialState, ed25519AddressDetails: addressDetails, }); }, []); - return { - ed25519AddressDetails: state.ed25519AddressDetails, - }; + useEffect(() => { + setState({ + totalBalance, + availableBalance, + }); + }, [totalBalance, availableBalance]); + + return [state, setState]; }; diff --git a/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts b/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts new file mode 100644 index 000000000..e569462b6 --- /dev/null +++ b/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts @@ -0,0 +1,64 @@ +import { ImplicitAccountCreationAddress } from "@iota/sdk-wasm-nova/web"; +import { Reducer, useEffect, useReducer } from "react"; +import { useLocation, useParams } from "react-router-dom"; +import { AddressRouteProps } from "~/app/routes/AddressRouteProps"; +import { useNetworkInfoNova } from "../networkInfo"; +import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; +import { AddressHelper } from "~/helpers/nova/addressHelper"; +import { useAddressBalance } from "./useAddressBalance"; + +export interface IImplicitAccountCreationAddressState { + implicitAccountCreationAddressDetails: IAddressDetails | null; + totalBalance: number | null; + availableBalance: number | null; + isAssociatedOutputsLoading: boolean; +} + +const initialState = { + implicitAccountCreationAddressDetails: null, + totalBalance: null, + availableBalance: null, + isAssociatedOutputsLoading: false, +}; + +/** + * Route Location Props + */ +interface IAddressPageLocationProps { + addressDetails: IAddressDetails; +} + +export const useImplicitAccountCreationAddressState = ( + address: ImplicitAccountCreationAddress, +): [IImplicitAccountCreationAddressState, React.Dispatch>] => { + const location = useLocation(); + const { network } = useParams(); + const { bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); + const [state, setState] = useReducer>>( + (currentState, newState) => ({ ...currentState, ...newState }), + initialState, + ); + + const { totalBalance, availableBalance } = useAddressBalance(network, state.implicitAccountCreationAddressDetails, null); + + useEffect(() => { + const locationState = location.state as IAddressPageLocationProps; + const { addressDetails } = locationState?.addressDetails + ? locationState + : { addressDetails: AddressHelper.buildAddress(bech32Hrp, address) }; + + setState({ + ...initialState, + implicitAccountCreationAddressDetails: addressDetails, + }); + }, []); + + useEffect(() => { + setState({ + totalBalance, + availableBalance, + }); + }, [totalBalance, availableBalance]); + + return [state, setState]; +}; diff --git a/client/src/helpers/nova/hooks/useNftAddressState.ts b/client/src/helpers/nova/hooks/useNftAddressState.ts index 87412dac0..aa361ccca 100644 --- a/client/src/helpers/nova/hooks/useNftAddressState.ts +++ b/client/src/helpers/nova/hooks/useNftAddressState.ts @@ -1,32 +1,39 @@ import { Reducer, useEffect, useReducer } from "react"; import { NftAddress, NftOutput } from "@iota/sdk-wasm-nova/web"; -import { IBech32AddressDetails } from "~/models/api/IBech32AddressDetails"; +import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; import { useNftDetails } from "./useNftDetails"; import { useLocation, useParams } from "react-router-dom"; import { AddressRouteProps } from "~/app/routes/AddressRouteProps"; import { useNetworkInfoNova } from "../networkInfo"; -import { Bech32AddressHelper } from "~/helpers/nova/bech32AddressHelper"; +import { AddressHelper } from "~/helpers/nova/addressHelper"; +import { useAddressBalance } from "./useAddressBalance"; export interface INftAddressState { - nftAddressDetails: IBech32AddressDetails | null; + nftAddressDetails: IAddressDetails | null; nftOutput: NftOutput | null; + totalBalance: number | null; + availableBalance: number | null; isNftDetailsLoading: boolean; + isAssociatedOutputsLoading: boolean; } const initialState = { nftAddressDetails: null, nftOutput: null, isNftDetailsLoading: true, + totalBalance: null, + availableBalance: null, + isAssociatedOutputsLoading: false, }; /** * Route Location Props */ interface IAddressPageLocationProps { - addressDetails: IBech32AddressDetails; + addressDetails: IAddressDetails; } -export const useNftAddressState = (address: NftAddress): INftAddressState => { +export const useNftAddressState = (address: NftAddress): [INftAddressState, React.Dispatch>] => { const location = useLocation(); const { network } = useParams(); const { bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); @@ -36,12 +43,13 @@ export const useNftAddressState = (address: NftAddress): INftAddressState => { ); const { nftOutput, isLoading: isNftDetailsLoading } = useNftDetails(network, address.nftId); + const { totalBalance, availableBalance } = useAddressBalance(network, state.nftAddressDetails, nftOutput); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; const { addressDetails } = locationState?.addressDetails ? locationState - : { addressDetails: Bech32AddressHelper.buildAddress(bech32Hrp, address) }; + : { addressDetails: AddressHelper.buildAddress(bech32Hrp, address) }; setState({ ...initialState, @@ -52,13 +60,11 @@ export const useNftAddressState = (address: NftAddress): INftAddressState => { useEffect(() => { setState({ nftOutput, + totalBalance, + availableBalance, isNftDetailsLoading, }); - }, [nftOutput, isNftDetailsLoading]); + }, [nftOutput, totalBalance, availableBalance, isNftDetailsLoading]); - return { - nftAddressDetails: state.nftAddressDetails, - nftOutput: state.nftOutput, - isNftDetailsLoading: state.isNftDetailsLoading, - }; + return [state, setState]; }; diff --git a/client/src/helpers/nova/hooks/useNovaTimeConvert.ts b/client/src/helpers/nova/hooks/useNovaTimeConvert.ts new file mode 100644 index 000000000..a4da973f3 --- /dev/null +++ b/client/src/helpers/nova/hooks/useNovaTimeConvert.ts @@ -0,0 +1,23 @@ +import { useNetworkInfoNova } from "../networkInfo"; +import { + slotIndexToEpochIndexConverter, + slotIndexToUnixTimeRangeConverter, + unixTimestampToEpochIndexConverter, + unixTimestampToSlotIndexConverter, +} from "../novaTimeUtils"; + +export function useNovaTimeConvert(): { + unixTimestampToSlotIndex: ((unixTimestampSeconds: number) => number) | null; + slotIndexToUnixTimeRange: ((slotIndex: number) => { from: number; to: number }) | null; + slotIndexToEpochIndex: ((targetSlotIndex: number) => number) | null; + unixTimestampToEpochIndex: ((unixTimestampSeconds: number) => number) | null; +} { + const { protocolInfo } = useNetworkInfoNova((s) => s.networkInfo); + + return { + unixTimestampToSlotIndex: protocolInfo ? unixTimestampToSlotIndexConverter(protocolInfo) : null, + slotIndexToUnixTimeRange: protocolInfo ? slotIndexToUnixTimeRangeConverter(protocolInfo) : null, + slotIndexToEpochIndex: protocolInfo ? slotIndexToEpochIndexConverter(protocolInfo) : null, + unixTimestampToEpochIndex: protocolInfo ? unixTimestampToEpochIndexConverter(protocolInfo) : null, + }; +} diff --git a/client/src/helpers/nova/novaTimeUtils.spec.ts b/client/src/helpers/nova/novaTimeUtils.spec.ts new file mode 100644 index 000000000..4821ec04c --- /dev/null +++ b/client/src/helpers/nova/novaTimeUtils.spec.ts @@ -0,0 +1,240 @@ +import { ProtocolInfo } from "@iota/sdk-wasm-nova/web"; +import { + unixTimestampToSlotIndexConverter, + slotIndexToUnixTimeRangeConverter, + slotIndexToEpochIndexConverter, + unixTimestampToEpochIndexConverter, +} from "./novaTimeUtils"; + +const mockProtocolInfo: ProtocolInfo = { + // @ts-expect-error Irrelevant fields omitted + parameters: { + type: 0, + version: 3, + networkName: "test", + bech32Hrp: "rms", + tokenSupply: 1813620509061365n, + + // + genesisSlot: 5, + genesisUnixTimestamp: 1707321857n, // 7 February 2024 16:04:17 + slotDurationInSeconds: 10, + slotsPerEpochExponent: 13, + // + + stakingUnbondingPeriod: 10, + validationBlocksPerSlot: 10, + punishmentEpochs: 10, + livenessThresholdLowerBound: 15, + livenessThresholdUpperBound: 30, + minCommittableAge: 10, + maxCommittableAge: 20, + epochNearingThreshold: 60, + targetCommitteeSize: 32, + chainSwitchingThreshold: 3, + }, + startEpoch: 0, +}; + +const genesisSlot = mockProtocolInfo.parameters.genesisSlot; +const genesisUnixTimestamp = Number(mockProtocolInfo.parameters.genesisUnixTimestamp); // 7 February 2024 16:04:17 +const slotDurationInSeconds = mockProtocolInfo.parameters.slotDurationInSeconds; +const slotsPerEpochExponent = mockProtocolInfo.parameters.slotsPerEpochExponent; + +const slotHalfSeconds = Math.floor(slotDurationInSeconds / 2); +const slotsInEpoch = Math.pow(2, slotsPerEpochExponent); // 8192 + +const unixTimestampToSlotIndex = unixTimestampToSlotIndexConverter(mockProtocolInfo); +const slotIndexToUnixTimeRange = slotIndexToUnixTimeRangeConverter(mockProtocolInfo); +const slotIndexToEpochIndex = slotIndexToEpochIndexConverter(mockProtocolInfo); +const unixTimestampToEpochIndex = unixTimestampToEpochIndexConverter(mockProtocolInfo); + +describe("unixTimestampToSlotIndex", () => { + test("should return genesis slot when timestamp is lower than genesisUnixTimestamp", () => { + const target = 1707321853; // 7 February 2024 16:04:13 + + const slotIndex = unixTimestampToSlotIndex(target); + + expect(slotIndex).toBe(mockProtocolInfo.parameters.genesisSlot); + }); + + test("should return genesis slot + 1 when passed genesisUnixTimestamp", () => { + const target = genesisUnixTimestamp; + + const slotIndex = unixTimestampToSlotIndex(target); + + expect(slotIndex).toBe(genesisSlot + 1); + }); + + test("should return the correct slot", () => { + const target = genesisUnixTimestamp + 42 * slotDurationInSeconds; // 42 slots after genesis (in unix seconds) + + const slotIndex = unixTimestampToSlotIndex(target); + + expect(slotIndex).toBe(genesisSlot + 43); // we are in 43rd slot + }); + + test("should work for big inputs", () => { + const target = 5680281601; // 1 January 2150 00:00:01 + + const slotIndex = unixTimestampToSlotIndex(target); + + expect(slotIndex).toBe(397295980); + }); +}); + +describe("slotIndexToUnixTimeRange", () => { + test("should return genesis slot timestamp when passed a slotIndex lower than genesisSlot", () => { + let target = genesisSlot - 1; // 4 + const expectedGenesisTimestampRange = { + from: genesisUnixTimestamp - slotDurationInSeconds, + to: genesisUnixTimestamp, + }; + + let slotUnixTimeRange = slotIndexToUnixTimeRange(target); + expect(slotUnixTimeRange).toStrictEqual(expectedGenesisTimestampRange); + + target = genesisSlot - 2; // 3 + slotUnixTimeRange = slotIndexToUnixTimeRange(target); + expect(slotUnixTimeRange).toStrictEqual(expectedGenesisTimestampRange); + + target = genesisSlot - 3; // 2 + slotUnixTimeRange = slotIndexToUnixTimeRange(target); + expect(slotUnixTimeRange).toStrictEqual(expectedGenesisTimestampRange); + }); + + test("should return correct genesis slot timestamp range", () => { + const target = genesisSlot; + + const slotUnixTimeRange = slotIndexToUnixTimeRange(target); + + expect(slotUnixTimeRange).toStrictEqual({ + from: genesisUnixTimestamp - slotDurationInSeconds, + to: genesisUnixTimestamp, + }); + }); + + test("should return timestamp range of 'genesis slot + x' when passed 'genesis slot + x'", () => { + let target = genesisSlot + 1; + + let slotUnixTimeRange = slotIndexToUnixTimeRange(target); + + expect(slotUnixTimeRange).toStrictEqual({ + from: genesisUnixTimestamp, + to: genesisUnixTimestamp + 1 * slotDurationInSeconds, + }); + + target = genesisSlot + 2; + + slotUnixTimeRange = slotIndexToUnixTimeRange(target); + + expect(slotUnixTimeRange).toStrictEqual({ + from: genesisUnixTimestamp + 1 * slotDurationInSeconds, + to: genesisUnixTimestamp + 2 * slotDurationInSeconds, + }); + + target = genesisSlot + 3; + + slotUnixTimeRange = slotIndexToUnixTimeRange(target); + + expect(slotUnixTimeRange).toStrictEqual({ + from: genesisUnixTimestamp + 2 * slotDurationInSeconds, + to: genesisUnixTimestamp + 3 * slotDurationInSeconds, + }); + + target = genesisSlot + 5; + + slotUnixTimeRange = slotIndexToUnixTimeRange(target); + + expect(slotUnixTimeRange).toStrictEqual({ + from: genesisUnixTimestamp + 4 * slotDurationInSeconds, + to: genesisUnixTimestamp + 5 * slotDurationInSeconds, + }); + + target = genesisSlot + 8; + + slotUnixTimeRange = slotIndexToUnixTimeRange(target); + + expect(slotUnixTimeRange).toStrictEqual({ + from: genesisUnixTimestamp + 7 * slotDurationInSeconds, + to: genesisUnixTimestamp + 8 * slotDurationInSeconds, + }); + }); + + test("should work for big inputs", () => { + const target = 397295980; // Slot of 1 January 2150 00:00:01 + + const slotUnixTimeRange = slotIndexToUnixTimeRange(target); + + expect(slotUnixTimeRange).toStrictEqual({ + from: 5680281597, + to: 5680281607, + }); + }); +}); + +describe("slotIndexToUnixTimeRange & unixTimestampToSlotIndex", () => { + test("should be able to go from slot to timestamp and back correctly", () => { + const targetSlotIndex = 12; // Slot of 1 January 2150 00:00:01 + + const targetSlotUnixTimeRange = slotIndexToUnixTimeRange(targetSlotIndex); + + expect(targetSlotUnixTimeRange).toStrictEqual({ + from: 1707321917, + to: 1707321927, + }); + + const resultSlotIndex = unixTimestampToSlotIndex(targetSlotUnixTimeRange.from + slotHalfSeconds); + + expect(resultSlotIndex).toBe(targetSlotIndex); + }); + + test("should be able to go from timestamp to slot and back correctly", () => { + const targetTimestamp = 1707484909; // 9 February 2024 13:21:49 + + const slotIndex = unixTimestampToSlotIndex(targetTimestamp); + + expect(slotIndex).toBe(16311); + + const slotUnixTimeRange = slotIndexToUnixTimeRange(slotIndex); + + expect(slotUnixTimeRange.from).toBeLessThan(targetTimestamp); + expect(slotUnixTimeRange.to).toBeGreaterThan(targetTimestamp); + }); +}); + +describe("slotIndexToEpochIndex", () => { + test("should return epoch 0 for slot index less then slotsInEpoch", () => { + const targetSlotIndex = slotsInEpoch - 100; + + const epochIndex = slotIndexToEpochIndex(targetSlotIndex); + + expect(epochIndex).toBe(0); + }); + + test("should return epoch 1 for slot index a bit after slotsInEpoch", () => { + const targetSlotIndex = slotsInEpoch + 100; + + const epochIndex = slotIndexToEpochIndex(targetSlotIndex); + + expect(epochIndex).toBe(1); + }); + + test("should return epoch 1 for slot index a bit after slotsInEpoch", () => { + const targetSlotIndex = 50000; + + const epochIndex = slotIndexToEpochIndex(targetSlotIndex); + + expect(epochIndex).toBe(6); // 50000 / 8192 = 6.1 + }); +}); + +describe("unixTimestampToEpochIndex", () => { + test("should return the correct epoch index based on timestamp", () => { + const targetTimestamp = 1707493847; // 9 February 2024 15:50:47 + + const epochIndex = unixTimestampToEpochIndex(targetTimestamp); + + expect(epochIndex).toBe(2); + }); +}); diff --git a/client/src/helpers/nova/novaTimeUtils.ts b/client/src/helpers/nova/novaTimeUtils.ts new file mode 100644 index 000000000..25d38e2d9 --- /dev/null +++ b/client/src/helpers/nova/novaTimeUtils.ts @@ -0,0 +1,84 @@ +import { ProtocolInfo } from "@iota/sdk-wasm-nova/web"; + +// Note: genesisUnixTimestamp is the first second that falls into genesisSlot + 1 + +/** + * Convert a UNIX timestamp to a slot index. + * @param protocolInfo The protocol information. + * @param unixTimestampSeconds The UNIX timestamp in seconds. + * @returns The slot index. + */ +export function unixTimestampToSlotIndexConverter(protocolInfo: ProtocolInfo): (unixTimestampSeconds: number) => number { + return (unixTimestampSeconds: number) => { + const genesisSlot = protocolInfo.parameters.genesisSlot; + const genesisUnixTimestamp = protocolInfo.parameters.genesisUnixTimestamp; + const slotDurationInSeconds = protocolInfo.parameters.slotDurationInSeconds; + + const elapsedTime = unixTimestampSeconds - Number(genesisUnixTimestamp); + + if (elapsedTime < 0) { + return genesisSlot; + } + + return genesisSlot + Math.floor(elapsedTime / slotDurationInSeconds) + 1; + }; +} + +/** + * Convert a slot index to a UNIX time range, in seconds. + * @param protocolInfo The protocol information. + * @param targetSlotIndex The target slot index. + * @returns The UNIX time range in seconds: from (inclusive) and to (exclusive). + */ +export function slotIndexToUnixTimeRangeConverter(protocolInfo: ProtocolInfo): (targetSlotIndex: number) => { from: number; to: number } { + return (targetSlotIndex: number) => { + const genesisSlot = protocolInfo.parameters.genesisSlot; + const genesisUnixTimestamp = Number(protocolInfo.parameters.genesisUnixTimestamp); + const slotDurationInSeconds = protocolInfo.parameters.slotDurationInSeconds; + + if (targetSlotIndex <= genesisSlot) { + return { + from: genesisUnixTimestamp - slotDurationInSeconds, + to: genesisUnixTimestamp, + }; + } + + const slotsElapsed = targetSlotIndex - genesisSlot - 1; + const elapsedTime = slotsElapsed * slotDurationInSeconds; + const targetSlotFromTimestamp = Number(genesisUnixTimestamp) + elapsedTime; + + return { + from: targetSlotFromTimestamp, + to: targetSlotFromTimestamp + slotDurationInSeconds, + }; + }; +} + +/** + * Convert a slot index to an epoch index. + * @param protocolInfo The protocol information. + * @param targetSlotIndex The target slot index. + * @returns The epoch index. + */ +export function slotIndexToEpochIndexConverter(protocolInfo: ProtocolInfo): (targetSlotIndex: number) => number { + return (targetSlotIndex: number) => { + const slotsPerEpochExponent = protocolInfo.parameters.slotsPerEpochExponent; + return targetSlotIndex >> slotsPerEpochExponent; + }; +} + +/** + * Convert a UNIX timestamp to an epoch index. + * @param protocolInfo The protocol information. + * @param unixTimestampSeconds The UNIX timestamp in seconds. + * @returns The epoch index. + */ +export function unixTimestampToEpochIndexConverter(protocolInfo: ProtocolInfo): (unixTimestampSeconds: number) => number { + return (unixTimestampSeconds: number) => { + const slotsPerEpochExponent = protocolInfo.parameters.slotsPerEpochExponent; + const unixTimestampToSlotIndex = unixTimestampToSlotIndexConverter(protocolInfo); + + const targetSlotIndex = unixTimestampToSlotIndex(unixTimestampSeconds); + return targetSlotIndex >> slotsPerEpochExponent; + }; +} diff --git a/client/src/helpers/nova/transactionsHelper.ts b/client/src/helpers/nova/transactionsHelper.ts index 486d5bdf7..57d06733d 100644 --- a/client/src/helpers/nova/transactionsHelper.ts +++ b/client/src/helpers/nova/transactionsHelper.ts @@ -19,17 +19,17 @@ import { Utils, UTXOInput, } from "@iota/sdk-wasm-nova/web"; -import { IBech32AddressDetails } from "~/models/api/IBech32AddressDetails"; +import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; import { IInput } from "~/models/api/nova/IInput"; import { IOutput } from "~/models/api/nova/IOutput"; import { NovaApiClient } from "~/services/nova/novaApiClient"; -import { Bech32AddressHelper } from "../stardust/bech32AddressHelper"; import { Converter } from "../stardust/convertUtils"; +import { AddressHelper } from "./addressHelper"; interface TransactionInputsAndOutputsResponse { inputs: IInput[]; outputs: IOutput[]; - unlockAddresses: IBech32AddressDetails[]; + unlockAddresses: IAddressDetails[]; transferTotal: number; } @@ -44,7 +44,7 @@ export class TransactionsHelper { const inputs: IInput[] = []; const outputs: IOutput[] = []; const remainderOutputs: IOutput[] = []; - const unlockAddresses: IBech32AddressDetails[] = []; + const unlockAddresses: IAddressDetails[] = []; let transferTotal = 0; let sortedOutputs: IOutput[] = []; @@ -81,7 +81,7 @@ export class TransactionsHelper { } if (signatureUnlock) { unlockAddresses.push( - Bech32AddressHelper.buildAddress( + AddressHelper.buildAddress( _bechHrp, Utils.hexPublicKeyToBech32Address(signatureUnlock.signature.publicKey, _bechHrp), ), @@ -144,7 +144,7 @@ export class TransactionsHelper { } else { const output = transaction.outputs[i] as CommonOutput; - const address: IBech32AddressDetails = TransactionsHelper.bechAddressFromAddressUnlockCondition( + const address: IAddressDetails = TransactionsHelper.bechAddressFromAddressUnlockCondition( output.unlockConditions, _bechHrp, output.type, @@ -204,8 +204,8 @@ export class TransactionsHelper { unlockConditions: UnlockCondition[], _bechHrp: string, outputType: number, - ): IBech32AddressDetails { - let address: IBech32AddressDetails = { bech32: "" }; + ): IAddressDetails { + let address: IAddressDetails = { bech32: "" }; let unlockCondition; if (outputType === OutputType.Basic || outputType === OutputType.Nft) { diff --git a/client/src/models/api/nova/IAddressDetails.ts b/client/src/models/api/nova/IAddressDetails.ts new file mode 100644 index 000000000..328a95899 --- /dev/null +++ b/client/src/models/api/nova/IAddressDetails.ts @@ -0,0 +1,10 @@ +import { AddressType } from "@iota/sdk-wasm-nova/web"; + +export interface IAddressDetails { + bech32: string; + hex?: string; + type?: AddressType; + label?: string; + restricted?: boolean; + capabilities?: number[]; +} diff --git a/client/src/models/api/nova/IAssociationsRequest.ts b/client/src/models/api/nova/IAssociationsRequest.ts new file mode 100644 index 000000000..188c0548c --- /dev/null +++ b/client/src/models/api/nova/IAssociationsRequest.ts @@ -0,0 +1,13 @@ +import { IAddressDetails } from "./IAddressDetails"; + +export interface IAssociationsRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The address details of the address to get the associated outputs for. + */ + addressDetails: IAddressDetails; +} diff --git a/client/src/models/api/nova/IInput.ts b/client/src/models/api/nova/IInput.ts index a9cd50399..c2ab16ce0 100644 --- a/client/src/models/api/nova/IInput.ts +++ b/client/src/models/api/nova/IInput.ts @@ -1,5 +1,5 @@ import { HexEncodedString, OutputResponse } from "@iota/sdk-wasm-nova/web"; -import { IBech32AddressDetails } from "../IBech32AddressDetails"; +import { IAddressDetails } from "./IAddressDetails"; export interface IInput { /** @@ -21,7 +21,7 @@ export interface IInput { /** * The transaction address details. */ - address: IBech32AddressDetails; + address: IAddressDetails; /** * The amount. */ diff --git a/client/src/models/api/nova/IOutput.ts b/client/src/models/api/nova/IOutput.ts index 59c34c61c..b13512c43 100644 --- a/client/src/models/api/nova/IOutput.ts +++ b/client/src/models/api/nova/IOutput.ts @@ -1,5 +1,5 @@ import { Output } from "@iota/sdk-wasm-nova/web"; -import { IBech32AddressDetails } from "../IBech32AddressDetails"; +import { IAddressDetails } from "./IAddressDetails"; export interface IOutput { /** @@ -9,7 +9,7 @@ export interface IOutput { /** * The Bech32 address details. */ - address?: IBech32AddressDetails; + address?: IAddressDetails; /** * The output. */ diff --git a/client/src/models/api/nova/ISearchRequest.ts b/client/src/models/api/nova/ISearchRequest.ts new file mode 100644 index 000000000..b0371bef2 --- /dev/null +++ b/client/src/models/api/nova/ISearchRequest.ts @@ -0,0 +1,11 @@ +export interface ISearchRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The query to look for. + */ + query: string; +} diff --git a/client/src/models/api/nova/ISearchResponse.ts b/client/src/models/api/nova/ISearchResponse.ts new file mode 100644 index 000000000..e84fda3d7 --- /dev/null +++ b/client/src/models/api/nova/ISearchResponse.ts @@ -0,0 +1,60 @@ +import { Block, OutputResponse } from "@iota/sdk-wasm-nova/web"; +import { IResponse } from "./IResponse"; +import { IAddressDetails } from "./IAddressDetails"; + +export interface ISearchResponse extends IResponse { + /** + * Block if it was found. + */ + block?: Block; + + /** + * Output if it was found (block will also be populated). + */ + output?: OutputResponse; + + /** + * Address details. + */ + addressDetails?: IAddressDetails; + + /** + * Transaction id if it was found. + */ + transactionId?: string; + + /** + * Account id if it was found. + */ + accountId?: string; + + /** + * Account details. + */ + accountDetails?: OutputResponse; + + /** + * Anchor id if it was found. + */ + anchorId?: string; + + /** + * Foundry id if it was found. + */ + foundryId?: string; + + /** + * Foundry details. + */ + foundryDetails?: OutputResponse; + + /** + * Nft id if it was found. + */ + nftId?: string; + + /** + * Nft details. + */ + nftDetails?: OutputResponse; +} diff --git a/client/src/models/api/nova/address/IAddressBalanceRequest.ts b/client/src/models/api/nova/address/IAddressBalanceRequest.ts new file mode 100644 index 000000000..7b632de16 --- /dev/null +++ b/client/src/models/api/nova/address/IAddressBalanceRequest.ts @@ -0,0 +1,11 @@ +export interface IAddressBalanceRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The bech32 address to get the balance for. + */ + address: string; +} diff --git a/client/src/models/api/nova/address/IAddressBalanceResponse.ts b/client/src/models/api/nova/address/IAddressBalanceResponse.ts new file mode 100644 index 000000000..4e65c9c7b --- /dev/null +++ b/client/src/models/api/nova/address/IAddressBalanceResponse.ts @@ -0,0 +1,18 @@ +import { IResponse } from "../../IResponse"; + +export interface IAddressBalanceResponse extends IResponse { + /** + * The total balance (including Expiration, Timelock and StorageDepositReturn outputs) + */ + totalBalance?: number; + + /** + * The balance of trivialy unlockable outputs with address unlock condition. + */ + availableBalance?: number; + + /** + * The ledger index at which this balance data was valid. + */ + ledgerIndex?: number; +} diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index 59e1dbee8..bd466c835 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -1,4 +1,6 @@ import { INetworkBoundGetRequest } from "~/models/api/INetworkBoundGetRequest"; +import { IAddressBalanceRequest } from "~/models/api/nova/address/IAddressBalanceRequest"; +import { IAddressBalanceResponse } from "~/models/api/nova/address/IAddressBalanceResponse"; import { IBlockRequest } from "~/models/api/nova/block/IBlockRequest"; import { IBlockResponse } from "~/models/api/nova/block/IBlockResponse"; import { IOutputDetailsRequest } from "~/models/api/IOutputDetailsRequest"; @@ -7,7 +9,7 @@ import { IAccountDetailsResponse } from "~/models/api/nova/IAccountDetailsRespon import { IAssociationsResponse } from "~/models/api/nova/IAssociationsResponse"; import { INodeInfoResponse } from "~/models/api/nova/INodeInfoResponse"; import { IOutputDetailsResponse } from "~/models/api/nova/IOutputDetailsResponse"; -import { IAssociationsRequest } from "~/models/api/stardust/IAssociationsRequest"; +import { IAssociationsRequest } from "~/models/api/nova/IAssociationsRequest"; import { ApiClient } from "../apiClient"; import { IBlockDetailsRequest } from "~/models/api/nova/block/IBlockDetailsRequest"; import { IBlockDetailsResponse } from "~/models/api/nova/block/IBlockDetailsResponse"; @@ -19,6 +21,8 @@ import { INftDetailsRequest } from "~/models/api/nova/INftDetailsRequest"; import { INftDetailsResponse } from "~/models/api/nova/INftDetailsResponse"; import { IAnchorDetailsRequest } from "~/models/api/nova/IAnchorDetailsRequest"; import { IAnchorDetailsResponse } from "~/models/api/nova/IAnchorDetailsResponse"; +import { ISearchRequest } from "~/models/api/nova/ISearchRequest"; +import { ISearchResponse } from "~/models/api/nova/ISearchResponse"; /** * Class to handle api communications on nova. @@ -33,6 +37,15 @@ export class NovaApiClient extends ApiClient { return this.callApi(`node-info/${request.network}`, "get"); } + /** + * Get the balance of and address from chronicle. + * @param request The Address Balance request. + * @returns The Address balance reponse + */ + public async addressBalanceChronicle(request: IAddressBalanceRequest): Promise { + return this.callApi(`nova/balance/chronicle/${request.network}/${request.address}`, "get"); + } + /** * Get a block. * @param request The request to send. @@ -120,4 +133,13 @@ export class NovaApiClient extends ApiClient { "get", ); } + + /** + * Find items from the tangle. + * @param request The request to send. + * @returns The response from the request. + */ + public async search(request: ISearchRequest): Promise { + return this.callApi(`nova/search/${request.network}/${request.query}`, "get"); + } }