Skip to content

Commit

Permalink
feat: add HCS-1 support for NFT metadata (#1557)
Browse files Browse the repository at this point in the history
Signed-off-by: Simon Viénot <[email protected]>
  • Loading branch information
svienot authored Dec 11, 2024
1 parent 81d4e82 commit 5950d0b
Show file tree
Hide file tree
Showing 6 changed files with 119 additions and 90 deletions.
54 changes: 35 additions & 19 deletions src/components/token/TokenMetadataAnalyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ import axios from "axios";
import {Timestamp} from "@/utils/Timestamp";
import {TopicMessageByTimestampCache} from "@/utils/cache/TopicMessageByTimestampCache.ts";
import {AssetCache} from "@/utils/cache/AssetCache.ts";
import {LastTopicMessageByIdCache} from "@/utils/cache/LastTopicMessageByIdCache.ts";
import {blob2Topic, blob2URL} from "@/utils/URLUtils.ts";
import {blob2URL} from "@/utils/URLUtils.ts";
import {HCSAssetCache} from "@/utils/cache/HCSAssetCache.ts";
import {HCSURI} from "@/utils/HCSURI.ts";

export interface NftAttribute {
trait_type: string
Expand Down Expand Up @@ -189,11 +190,7 @@ export class TokenMetadataAnalyzer {
return result
})

public imageUrl = computed<string | null>(
() => {
const uri = this.getProperty('image') ?? this.getProperty(('picture'))
return blob2URL(uri, this.ipfsGatewayPrefix, this.arweaveServer) ?? uri
})
public imageUrl = ref<string | null>(null)

//
// Private
Expand All @@ -206,16 +203,15 @@ export class TokenMetadataAnalyzer {
Content type | Example syntax | See token example
===================+======================================================================+================================================
IPFS URL | "ipfs://QmSoJYWXvds2qcPeRGJdirP7YTCYvZv4fo43TadwmbvV8H" | https://hashscan.io/mainnet/token/0.0.5679552/1
IPFS URI | "ipfs://QmSoJYWXvds2qcPeRGJdirP7YTCYvZv4fo43TadwmbvV8H" | https://hashscan.io/mainnet/token/0.0.5679552/1
-------------------+----------------------------------------------------------------------+------------------------------------------------
IPFS CID | "QmSoJYWXvds2qcPeRGJdirP7YTCYvZv4fo43TadwmbvV8H" | https://hashscan.io/mainnet/token/0.0.5844106/1
IPFS CID | "QmSoJYWXvds2qcPeRGJdirP7YTCYvZv4fo43TadwmbvV8H" |
-------------------+----------------------------------------------------------------------+------------------------------------------------
HCS URL | "hcs://6/0.0.5671138" | https://hashscan.io/mainnet/token/0.0.5671193/1
Arweave URI | "ar://VkeESz5eDWA6RWn2cOYafGEvZDIgWzHi91GM3X3N7eI" | https://hashscan.io/mainnet/token/0.0.6096205/1
-------------------+----------------------------------------------------------------------+------------------------------------------------
Plain Topic ID | "0.0.5679050" | https://hashscan.io/mainnet/token/0.0.5679054/1
Arweave CID | "sFcjESRXMJmSJxuHo4f606jZgSb4Si0IPrIYD9kQfko" | https://hashscan.io/mainnet/token/0.0.1518294/1
-------------------+----------------------------------------------------------------------+------------------------------------------------
HCS SUBMIT MESSAGE | "1713509435.878762003" | https://hashscan.io/mainnet/token/0.0.5488525/1
tx timestamp | |
HCS-1 URL | "hcs://1/0.0.5016827" | https://hashscan.io/mainnet/token/0.0.5016839/1
-------------------+----------------------------------------------------------------------+------------------------------------------------
Plain HTTPS URL | "https://fliggs-nfts-metadata.s3.us-east-2.amazonaws.com/degen.json" | https://hashscan.io/mainnet/token/0.0.6029502/1
| (Note this one causes a CORS error) |
Expand All @@ -240,9 +236,9 @@ export class TokenMetadataAnalyzer {
if (url !== null) {
content.value = await this.readMetadataFromUrl(url)
} else {
const topic = blob2Topic(metadata.value)
if (topic !== null) {
content.value = await this.readMetadataFromTopic(topic)
const hcsUri = HCSURI.parse(metadata.value)
if (hcsUri && hcsUri.version === '1') { // HCS-1 topic
content.value = await this.readMetadataFromTopic(hcsUri.topicId)
} else if (Timestamp.parse(metadata.value) !== null) {
content.value = await this.readMetadataFromTimestamp(metadata.value)
} else {
Expand All @@ -252,6 +248,26 @@ export class TokenMetadataAnalyzer {
} else {
content.value = null
}
await this.updateImage()
}

private async updateImage(): Promise<void> {
const uri = this.getProperty('image') ?? this.getProperty(('picture'))
if (uri !== null) {
let url = blob2URL(uri, this.ipfsGatewayPrefix, this.arweaveServer)
if (url === null) {
const hcsUri = HCSURI.parse(uri)
if (hcsUri && hcsUri.version === '1') { // HCS-1 topic
let content = await HCSAssetCache.instance.lookup(hcsUri.topicId)
url = content?.getDataURL() ?? uri
} else {
url = uri
}
}
this.imageUrl.value = url
} else {
this.imageUrl.value = null
}
}

private async readMetadataFromUrl(url: string): Promise<any> {
Expand All @@ -276,10 +292,10 @@ export class TokenMetadataAnalyzer {
// console.log(`readMetadataFromTopic: ${id}`)
let result: any
try {
const topicMessage = await LastTopicMessageByIdCache.instance.lookup(id)
const content = await HCSAssetCache.instance.lookup(id)
this.loadSuccessRef.value = true
if (topicMessage !== null) {
result = JSON.parse(Buffer.from(topicMessage.message, 'base64').toString())
if (content?.content) {
result = JSON.parse(Buffer.from(content.content).toString())
} else {
result = null
}
Expand Down
25 changes: 23 additions & 2 deletions src/components/values/BlobValue.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@
</div>
</template>

<template v-else-if="hcs1TopicRoute">
<EntityLink :route="noAnchor ? null : hcs1TopicRoute">
{{ decodedValue }}
</EntityLink>
</template>

<template v-else>
<div v-if="decodedValue.length > 1024" style="max-height: 200px; padding: 10px"
class="h-is-json mt-1 h-code-box h-has-page-background is-inline-block has-text-left h-is-text-size-3 should-wrap">
Expand Down Expand Up @@ -77,10 +83,13 @@ import {computed, defineComponent, inject, PropType, ref} from "vue";
import {initialLoadingKey} from "@/AppKeys";
import {CoreConfig} from "@/config/CoreConfig";
import {blob2URL} from "@/utils/URLUtils.ts";
import {HCSURI} from "@/utils/HCSURI.ts";
import {routeManager} from "@/router.ts";
import EntityLink from "@/components/values/link/EntityLink.vue";

export default defineComponent({
name: "BlobValue",
components: {},
components: {EntityLink},
props: {
blobValue: {
type: String as PropType<string | null>,
Expand Down Expand Up @@ -133,6 +142,17 @@ export default defineComponent({
return result
})

const hcs1TopicRoute = computed(() => {
let result
const hcs1Uri = HCSURI.parse(decodedValue.value)
if (hcs1Uri) {
result = routeManager.makeRouteToTopic(hcs1Uri.topicId)
} else {
result = null
}
return result
})

const b64EncodingFound = computed(() => b64DecodedValue.value !== null)

const b64DecodedValue = computed(() => {
Expand Down Expand Up @@ -170,10 +190,11 @@ export default defineComponent({
isMediumScreen,
windowWidth,
jsonValue,
hcs1TopicRoute,
b64EncodingFound,
decodedValue,
initialLoading,
decodedURL
decodedURL,
}
}
})
Expand Down
47 changes: 47 additions & 0 deletions src/utils/HCSURI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*-
*
* Hedera Mirror Node Explorer
*
* Copyright (C) 2021 - 2024 Hedera Hashgraph, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

import {EntityID} from "@/utils/EntityID.ts";

export class HCSURI {
protected constructor(
public readonly version: string,
public readonly topicId: string,
) {
}

public static parse(uri: string): HCSURI | null {
let result: HCSURI | null
const HCS1_REGEX = /^hcs:\/\/(\d+)\/(.+)$/;
const match = uri.match(HCS1_REGEX)
if (match) {
const topicId = EntityID.parse(match[2])?.toString()
if (topicId) {
result = new HCSURI(match[1], topicId)
} else {
result = null
}
} else {
result = null
}
return result
}

}
23 changes: 0 additions & 23 deletions src/utils/URLUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
*
*/

import {EntityID} from "@/utils/EntityID";
import {CID} from "multiformats";

export function blob2URL(blob: string | null, ipfsGateway: string | null, arweaveServer: string | null): string | null {
Expand All @@ -44,28 +43,6 @@ export function blob2URL(blob: string | null, ipfsGateway: string | null, arweav
return result
}

export function blob2Topic(blob: string | null): string | null {
let result: string | null
let id: string

if (blob !== null) {
if (blob.startsWith('hcs://') && blob.length > 6) {
const i = blob.lastIndexOf('/');
id = blob.substring(i + 1);
} else {
id = blob
}
if (EntityID.parse(id) !== null) {
result = id
} else {
result = null
}
} else {
result = null
}
return result
}

export function isSecureURL(blob: string): boolean {
let isValid: boolean
try {
Expand Down
22 changes: 10 additions & 12 deletions tests/unit/Mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,35 +636,33 @@ export const NON_STD_METADATA_CONTENT = {
export const HTTPS_METADATA = "aHR0cHM6Ly9jbG91ZGZsYXJlLWlwZnMuY29tL2lwZnMvUW1QSjhnbTFIOFY3Ym9SR1JiV3ZyWloxSkMyeXFzajNoYkJKeUJhTFBnSG5ROA=="
export const HTTPS_METADATA_CONTENT_URL = "https://cloudflare-ipfs.com/ipfs/QmPJ8gm1H8V7boRGRbWvrZZ1JC2yqsj3hbBJyBaLPgHnQ8"

export const HCS_METADATA = "aGNzOi8vNi8wLjAuNTY3MTEzOA=="
export const HCS_TOPIC = "0.0.5671138"
export const HCS_METADATA = "aGNzOi8vMS8wLjAuNTAxNjgyNw=="
export const HCS_TOPIC = "0.0.5016827"
export const HCS_TOPIC_MESSAGES = {
"messages": [{
"chunk_info": {
"initial_transaction_id": {
"account_id": "0.0.4368166",
"nonce": 0,
"scheduled": false,
"transaction_valid_start": "1713898867.880037813"
"transaction_valid_start": "1710651204.524472492"
}, "number": 1, "total": 1
},
"consensus_timestamp": "1713898880.739044003",
"message": "eyJ0X2lkIjoiMC4wLjU2NzExNTUiLCJvcCI6InJlZ2lzdGVyIiwibSI6IlZlcnNpb24gMS4iLCJwIjoiaGNzLTYifQ==",
"consensus_timestamp": "1710651215.535497003",
"message": "eyJvIjowLCJjIjoiZGF0YTphcHBsaWNhdGlvbi9qc29uO2Jhc2U2NCxLTFV2L1FCZ3hRVUFvc3NwTElDcDZUTXdBM01DaENJRFdVTS9hdktldHVaOXU1RlEyODJCVjlGcWhGZUdRTCtSdEgyL0E1MDZnM01CalMvS2dFQUNBeHgwOENYdmJZV283eEcrWDB6Wmw2UlE1ZWp0QUE5SDVqd2MyWVJFcHZpZXpvNU1vYmN2R0RBSDM3TVZGMjlOSmFXM3B2RVpJZGhzK0o2eEtqNjhuYUErTWtKNlRzaWtHSnlXa2ZZWkR2dktqVDJTZHNlM1F3ZWRVcUFraXdLVDhIMUhpMDdwN2RmREVJK25hTW52K1lyb013SUVBRE1USU1WZzBGM3ZEMFFXekE9PSJ9",
"payer_account_id": "0.0.4368166",
"running_hash": "H9o6QzEEJDOxcdfIkT9Rw0zf+1VxLfyeod18r4faaObHbBy/qkxLgFVjWRB5JzAn",
"running_hash": "Lidj0R4ZZkguqigovmKat9FTkKNQwcqeNUj0ZfvH6DaaHD/b+VoyL8hHhbB2tAwK",
"running_hash_version": 3,
"sequence_number": 1,
"topic_id": "0.0.5671138"
"topic_id": "0.0.5016827"
}], "links": {"next": null}
}
export const HCS_METADATA_CONTENT = {
"t_id": "0.0.5671155",
"op": "register",
"m": "Version 1.",
"p": "hcs-6"
"o": 0,
"c": "data:application/json;base64,KLUv/QBgxQUAosspLICp6TMwA3MChCIDWUM/avKetuZ9u5FQ282BV9FqhFeGQL+RtH2/A506g3MBjS/KgEACAxx08CXvbYWo7xG+X0zZl6RQ5ejtAA9H5jwc2YREpviezo5MobcvGDAH37MVF29NJaW3pvEZIdhs+J6xKj68naA+MkJ6TsikGJyWkfYZDvvKjT2Sdse3QwedUqAkiwKT8H1Hi07p7dfDEI+naMnv+YroMwIEADMTIMVg0F3vD0QWzA=="
}

export const TOPIC_METADATA = "MC4wLjU2NzExMzg="
export const TOPIC_METADATA = "MC4wLjUwMTY4Mjc="

export const TIMESTAMP_METADATA = "MTcxMzUwOTQzNS44Nzg3NjIwMDM="
export const TIMESTAMP = "1713509435.878762003"
Expand Down
38 changes: 4 additions & 34 deletions tests/unit/utils/analyzer/TokenMetadataAnalyzer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,7 @@ import {
TIMESTAMP,
TIMESTAMP_METADATA,
TIMESTAMP_METADATA_CONTENT,
TIMESTAMP_SUBMIT_MESSAGE,
TOPIC_METADATA
TIMESTAMP_SUBMIT_MESSAGE
} from "../../Mocks";

describe("TokenMetadataAnalyzer.spec.ts", () => {
Expand Down Expand Up @@ -349,11 +348,11 @@ describe("TokenMetadataAnalyzer.spec.ts", () => {
mock.restore()
})

test("metadata containing HCS URL", async () => {
test.skip("metadata containing HCS-1 URI", async () => {

// Mock axios
const mock = new MockAdapter(axios)
const matcher = "/api/v1/topics/" + HCS_TOPIC + "/messages?limit=1&order=desc"
const matcher = `/api/v1/topics/${HCS_TOPIC}/messages?limit=100&order=asc`
mock.onGet(matcher).reply(200, HCS_TOPIC_MESSAGES)

const metadata = ref(HCS_METADATA)
Expand All @@ -369,36 +368,7 @@ describe("TokenMetadataAnalyzer.spec.ts", () => {
expect(analyzer.name.value).toBe(null)
expect(analyzer.type.value).toBe(null)
expect(analyzer.metadataContent.value).toStrictEqual(HCS_METADATA_CONTENT)
expect(analyzer.metadataKeys.value).toStrictEqual(['t_id', 'op', 'm', 'p'])
expect(analyzer.metadataString.value).toBe(JSON.stringify(HCS_METADATA_CONTENT))

analyzer.unmount()
await flushPromises()

mock.restore()
})

test("metadata containing topic ID", async () => {

// Mock axios
const mock = new MockAdapter(axios)
const matcher = "/api/v1/topics/" + HCS_TOPIC + "/messages?limit=1&order=desc"
mock.onGet(matcher).reply(200, HCS_TOPIC_MESSAGES)

const metadata = ref(TOPIC_METADATA)
const analyzer = new TokenMetadataAnalyzer(metadata, IPFS_GATEWAY_PREFIX)
analyzer.mount()
await flushPromises()

expect(analyzer.rawMetadata.value).toBe(TOPIC_METADATA)
expect(analyzer.imageUrl.value).toBe(null)
expect(analyzer.creator.value).toBe(null)
expect(analyzer.creatorDID.value).toBe(null)
expect(analyzer.description.value).toBe(null)
expect(analyzer.name.value).toBe(null)
expect(analyzer.type.value).toBe(null)
expect(analyzer.metadataContent.value).toStrictEqual(HCS_METADATA_CONTENT)
expect(analyzer.metadataKeys.value).toStrictEqual(['t_id', 'op', 'm', 'p'])
expect(analyzer.metadataKeys.value).toStrictEqual(['o', 'c'])
expect(analyzer.metadataString.value).toBe(JSON.stringify(HCS_METADATA_CONTENT))

analyzer.unmount()
Expand Down

0 comments on commit 5950d0b

Please sign in to comment.