diff --git a/cadence/contracts/utils/Serialize.cdc b/cadence/contracts/utils/Serialize.cdc index 0e2dc9ef..41c9892f 100644 --- a/cadence/contracts/utils/Serialize.cdc +++ b/cadence/contracts/utils/Serialize.cdc @@ -5,7 +5,7 @@ import "NonFungibleToken" /// This contract is a utility for serializing primitive types, arrays, and common metadata mapping formats to JSON /// compatible strings. Also included are interfaces enabling custom serialization for structs and resources. /// -/// Special thanks to @austinkline for the idea and initial implementation. +/// Special thanks to @austinkline for the idea and initial implementation & @bjartek + @bluesign for optimizations. /// access(all) contract Serialize { @@ -27,59 +27,59 @@ contract Serialize { case Type(): return "\"nil\"" case Type(): - return "\"".concat(value as! String).concat("\"") + return String.join(["\"", value as! String, "\"" ], separator: "") case Type(): - return "\"".concat(value as? String ?? "nil").concat("\"") + return String.join(["\"", value as? String ?? "nil", "\"" ], separator: "") case Type(): - return "\"".concat((value as! Character).toString()).concat("\"") + return String.join(["\"", (value as! Character).toString(), "\"" ], separator: "") case Type(): - return "\"".concat(value as! Bool ? "true" : "false").concat("\"") + return String.join(["\"", value as! Bool ? "true" : "false", "\"" ], separator: "") case Type
(): - return "\"".concat((value as! Address).toString()).concat("\"") + return String.join(["\"", (value as! Address).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as? Address)?.toString() ?? "nil").concat("\"") + return String.join(["\"", (value as? Address)?.toString() ?? "nil", "\"" ], separator: "") case Type(): - return "\"".concat((value as! Int8).toString()).concat("\"") + return String.join(["\"", (value as! Int8).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as! Int16).toString()).concat("\"") + return String.join(["\"", (value as! Int16).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as! Int32).toString()).concat("\"") + return String.join(["\"", (value as! Int32).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as! Int64).toString()).concat("\"") + return String.join(["\"", (value as! Int64).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as! Int128).toString()).concat("\"") + return String.join(["\"", (value as! Int128).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as! Int256).toString()).concat("\"") + return String.join(["\"", (value as! Int256).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as! Int).toString()).concat("\"") + return String.join(["\"", (value as! Int).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as! UInt8).toString()).concat("\"") + return String.join(["\"", (value as! UInt8).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as! UInt16).toString()).concat("\"") + return String.join(["\"", (value as! UInt16).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as! UInt32).toString()).concat("\"") + return String.join(["\"", (value as! UInt32).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as! UInt64).toString()).concat("\"") + return String.join(["\"", (value as! UInt64).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as! UInt128).toString()).concat("\"") + return String.join(["\"", (value as! UInt128).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as! UInt256).toString()).concat("\"") + return String.join(["\"", (value as! UInt256).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as! UInt).toString()).concat("\"") + return String.join(["\"", (value as! UInt).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as! Word8).toString()).concat("\"") + return String.join(["\"", (value as! Word8).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as! Word16).toString()).concat("\"") + return String.join(["\"", (value as! Word16).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as! Word32).toString()).concat("\"") + return String.join(["\"", (value as! Word32).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as! Word64).toString()).concat("\"") + return String.join(["\"", (value as! Word64).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as! Word128).toString()).concat("\"") + return String.join(["\"", (value as! Word128).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as! Word256).toString()).concat("\"") + return String.join(["\"", (value as! Word256).toString(), "\"" ], separator: "") case Type(): - return "\"".concat((value as! UFix64).toString()).concat("\"") + return String.join(["\"", (value as! UFix64).toString(), "\"" ], separator: "") default: return nil } @@ -89,24 +89,15 @@ contract Serialize { /// access(all) fun arrayToJSONString(_ arr: [AnyStruct]): String? { - var serializedArr = "[" - let arrLength = arr.length - for i, element in arr { + let parts: [String]= [] + for element in arr { let serializedElement = self.tryToJSONString(element) if serializedElement == nil { - if i == arrLength - 1 && serializedArr.length > 1 && serializedArr[serializedArr.length - 2] == "," { - // Remove trailing comma as this element could not be serialized - serializedArr = serializedArr.slice(from: 0, upTo: serializedArr.length - 2) - } continue } - serializedArr = serializedArr.concat(serializedElement!) - // Add a comma if there are more elements to serialize - if i < arr.length - 1 { - serializedArr = serializedArr.concat(", ") - } + parts.append(serializedElement!) } - return serializedArr.concat("]") + return "[".concat(String.join(parts, separator: ", ")).concat("]") } /// Returns a serialized representation of the given String-indexed mapping or nil if the value is not serializable. @@ -120,22 +111,15 @@ contract Serialize { dict.remove(key: k) } } - var serializedDict = "{" - let dictLength = dict.length - for i, key in dict.keys { + let parts: [String] = [] + for key in dict.keys { let serializedValue = self.tryToJSONString(dict[key]!) if serializedValue == nil { - if i == dictLength - 1 && serializedDict.length > 1 && serializedDict[serializedDict.length - 2] == "," { - // Remove trailing comma as this element could not be serialized - serializedDict = serializedDict.slice(from: 0, upTo: serializedDict.length - 2) - } continue } - serializedDict = serializedDict.concat(self.tryToJSONString(key)!).concat(": ").concat(serializedValue!) - if i < dict.length - 1 { - serializedDict = serializedDict.concat(", ") - } + let serialializedKeyValue = String.join([self.tryToJSONString(key)!, serializedValue!], separator: ": ") + parts.append(serialializedKeyValue) } - return serializedDict.concat("}") + return "{".concat(String.join(parts, separator: ", ")).concat("}") } } diff --git a/cadence/contracts/utils/SerializeMetadata.cdc b/cadence/contracts/utils/SerializeMetadata.cdc index 7a93695b..1ceca382 100644 --- a/cadence/contracts/utils/SerializeMetadata.cdc +++ b/cadence/contracts/utils/SerializeMetadata.cdc @@ -8,6 +8,8 @@ import "Serialize" /// This contract defines methods for serializing NFT metadata as a JSON compatible string, according to the common /// OpenSea metadata format. NFTs and metadata views can be serialized by reference via contract methods. /// +/// Special thanks to @austinkline for the idea and initial implementation & @bjartek + @bluesign for optimizations. +/// access(all) contract SerializeMetadata { /// Serializes the metadata (as a JSON compatible String) for a given NFT according to formats expected by EVM @@ -31,31 +33,28 @@ access(all) contract SerializeMetadata { // Serialize the display values from the NFT's Display & NFTCollectionDisplay views let nftDisplay = nft.resolveView(Type()) as! MetadataViews.Display? let collectionDisplay = nft.resolveView(Type()) as! MetadataViews.NFTCollectionDisplay? + // Serialize the display & collection display views - nil if both views are nil let display = self.serializeFromDisplays(nftDisplay: nftDisplay, collectionDisplay: collectionDisplay) // Get the Traits view from the NFT, returning early if no traits are found let traits = nft.resolveView(Type()) as! MetadataViews.Traits? let attributes = self.serializeNFTTraitsAsAttributes(traits ?? MetadataViews.Traits([])) - // Return an empty string if nothing is serializable - if display == nil && attributes == nil { + // Return an empty string if all views are nil + if display == nil && traits == nil { return "" } // Init the data format prefix & concatenate the serialized display & attributes - var serializedMetadata = "data:application/json;utf8,{" + let parts: [String] = ["data:application/json;utf8,{"] if display != nil { - serializedMetadata = serializedMetadata.concat(display!) - } - if display != nil && attributes != nil { - serializedMetadata = serializedMetadata.concat(", ") - } - if attributes != nil { - serializedMetadata = serializedMetadata.concat(attributes) + parts.appendAll([display!, ", "]) // Include display if present & separate with a comma } - return serializedMetadata.concat("}") + parts.appendAll([attributes, "}"]) // Include attributes & close the JSON object + + return String.join(parts, separator: "") } - /// Serializes the display & collection display views of a given NFT as a JSON compatible string. If nftDisplay is + /// Serializes the display & collection display views of a given NFT as a JSON compatible string. If nftDisplay is /// present, the value is returned as token-level metadata. If nftDisplay is nil and collectionDisplay is present, /// the value is returned as contract-level metadata. If both values are nil, nil is returned. /// @@ -80,30 +79,34 @@ access(all) contract SerializeMetadata { let externalURL = "\"external_url\": " let externalLink = "\"external_link\": " var serializedResult = "" + let parts: [String] = [] // Append results from the token-level Display view to the serialized JSON compatible string if nftDisplay != nil { - serializedResult = serializedResult - .concat(name).concat(Serialize.tryToJSONString(nftDisplay!.name)!).concat(", ") - .concat(description).concat(Serialize.tryToJSONString(nftDisplay!.description)!).concat(", ") - .concat(image).concat(Serialize.tryToJSONString(nftDisplay!.thumbnail.uri())!) - // Append the `externa_url` value from NFTCollectionDisplay view if present + parts.appendAll([ + name, Serialize.tryToJSONString(nftDisplay!.name)!, ", ", + description, Serialize.tryToJSONString(nftDisplay!.description)!, ", ", + image, Serialize.tryToJSONString(nftDisplay!.thumbnail.uri())! + ]) + // Append the `external_url` value from NFTCollectionDisplay view if present if collectionDisplay != nil { - return serializedResult.concat(", ") - .concat(externalURL).concat(Serialize.tryToJSONString(collectionDisplay!.externalURL.url)!) + parts.appendAll([", ", externalURL, Serialize.tryToJSONString(collectionDisplay!.externalURL.url)!]) + return String.join(parts, separator: "") } } if collectionDisplay == nil { - return serializedResult + return String.join(parts, separator: "") } // Without token-level view, serialize as contract-level metadata - return serializedResult - .concat(name).concat(Serialize.tryToJSONString(collectionDisplay!.name)!).concat(", ") - .concat(description).concat(Serialize.tryToJSONString(collectionDisplay!.description)!).concat(", ") - .concat(image).concat(Serialize.tryToJSONString(collectionDisplay!.squareImage.file.uri())!).concat(", ") - .concat(externalLink).concat(Serialize.tryToJSONString(collectionDisplay!.externalURL.url)!) + parts.appendAll([ + name, Serialize.tryToJSONString(collectionDisplay!.name)!, ", ", + description, Serialize.tryToJSONString(collectionDisplay!.description)!, ", ", + image, Serialize.tryToJSONString(collectionDisplay!.squareImage.file.uri())!, ", ", + externalLink, Serialize.tryToJSONString(collectionDisplay!.externalURL.url)! + ]) + return String.join(parts, separator: "") } /// Serializes given Traits view as a JSON compatible string. If a given Trait is not serializable, it is skipped @@ -111,42 +114,50 @@ access(all) contract SerializeMetadata { /// /// @param traits: The Traits view to be serialized /// - /// @returns: A JSON compatible string containing the serialized traits as: + /// @returns: A JSON compatible string containing the serialized traits as follows + /// (display_type omitted if trait.displayType == nil): /// `\"attributes\": [{\"trait_type\": \"\", \"display_type\": \"\", \"value\": \"\"}, {...}]` /// access(all) fun serializeNFTTraitsAsAttributes(_ traits: MetadataViews.Traits): String { // Serialize each trait as an attribute, building the serialized JSON compatible string - var serializedResult = "\"attributes\": [" + let parts: [String] = [] let traitsLength = traits.traits.length - for i, trait in traits.traits { - let value = Serialize.tryToJSONString(trait.value) - if value == nil { - // Remove trailing comma if last trait is not serializable - if i == traitsLength - 1 && serializedResult[serializedResult.length - 1] == "," { - serializedResult = serializedResult.slice(from: 0, upTo: serializedResult.length - 1) - } + for trait in traits.traits { + let attribute = self.serializeNFTTraitAsAttribute(trait) + if attribute == nil { continue } - serializedResult = serializedResult.concat("{") - .concat("\"trait_type\": ").concat(Serialize.tryToJSONString(trait.name)!) - if trait.displayType != nil { - serializedResult = serializedResult.concat(", \"display_type\": ") - .concat(Serialize.tryToJSONString(trait.displayType)!) - } - serializedResult = serializedResult.concat(", \"value\": ").concat(value!) - .concat("}") - if i < traits!.traits.length - 1 { - serializedResult = serializedResult.concat(",") - } + parts.append(attribute!) + } + // Join all serialized attributes with a comma separator, wrapping the result in square brackets under the + // `attributes` key + return "\"attributes\": [".concat(String.join(parts, separator: ", ")).concat("]") + } + + /// Serializes a given Trait as an attribute in a JSON compatible format. If the trait's value is not serializable, + /// nil is returned. + /// The format of the serialized trait is as follows (display_type omitted if trait.displayType == nil): + /// `{"trait_type": "", "display_type": "", "value": ""}` + access(all) + fun serializeNFTTraitAsAttribute(_ trait: MetadataViews.Trait): String? { + let value = Serialize.tryToJSONString(trait.value) + if value == nil { + return nil + } + let parts: [String] = ["{"] + parts.appendAll( [ "\"trait_type\": ", Serialize.tryToJSONString(trait.name)! ] ) + if trait.displayType != nil { + parts.appendAll( [ ", \"display_type\": ", Serialize.tryToJSONString(trait.displayType)! ] ) } - return serializedResult.concat("]") + parts.appendAll( [ ", \"value\": ", value! , "}" ] ) + return String.join(parts, separator: "") } - /// Serializes the FTDisplay view of a given fungible token as a JSON compatible data URL. The value is returned as + /// Serializes the FTDisplay view of a given fungible token as a JSON compatible data URL. The value is returned as /// contract-level metadata. /// - /// @param ftDisplay: The tokens's FTDisplay view from which values `name`, `symbol`, `description`, and + /// @param ftDisplay: The tokens's FTDisplay view from which values `name`, `symbol`, `description`, and /// `externaURL` are serialized /// /// @returns: A JSON compatible data URL string containing the serialized view as: @@ -162,13 +173,15 @@ access(all) contract SerializeMetadata { let symbol = "\"symbol\": " let description = "\"description\": " let externalLink = "\"external_link\": " + let parts: [String] = ["data:application/json;utf8,{"] - return "data:application/json;utf8,{" - .concat(name).concat(Serialize.tryToJSONString(ftDisplay.name)!).concat(", ") - .concat(symbol).concat(Serialize.tryToJSONString(ftDisplay.symbol)!).concat(", ") - .concat(description).concat(Serialize.tryToJSONString(ftDisplay.description)!).concat(", ") - .concat(externalLink).concat(Serialize.tryToJSONString(ftDisplay.externalURL.url)!) - .concat("}") + parts.appendAll([ + name, Serialize.tryToJSONString(ftDisplay.name)!, ", ", + symbol, Serialize.tryToJSONString(ftDisplay.symbol)!, ", ", + description, Serialize.tryToJSONString(ftDisplay.description)!, ", ", + externalLink, Serialize.tryToJSONString(ftDisplay.externalURL.url)! + ]) + return String.join(parts, separator: "") } /// Derives a symbol for use as an ERC20 or ERC721 symbol from a given string, presumably a Cadence contract name. diff --git a/cadence/tests/serialize_metadata_tests.cdc b/cadence/tests/serialize_metadata_tests.cdc index 40498d5c..f35ddb23 100644 --- a/cadence/tests/serialize_metadata_tests.cdc +++ b/cadence/tests/serialize_metadata_tests.cdc @@ -56,9 +56,10 @@ fun testSerializeNFTSucceeds() { mintedBlockHeight = heightResult.returnValue! as! UInt64 let heightString = mintedBlockHeight.toString() + // Cadence dictionaries are not ordered by insertion order, so we need to check for both possible orderings let expectedPrefix = "data:application/json;utf8,{\"name\": \"ExampleNFT\", \"description\": \"Example NFT Collection\", \"image\": \"https://flow.com/examplenft.jpg\", \"external_url\": \"https://example-nft.onflow.org\", " - let altSuffix1 = "\"attributes\": [{\"trait_type\": \"mintedBlock\", \"value\": \"".concat(heightString).concat("\"},{\"trait_type\": \"foo\", \"value\": \"nil\"}]}") - let altSuffix2 = "\"attributes\": [{\"trait_type\": \"foo\", \"value\": \"nil\"}]}, {\"trait_type\": \"mintedBlock\", \"value\": \"".concat(heightString).concat("\"}") + let altSuffix1 = "\"attributes\": [{\"trait_type\": \"mintedBlock\", \"value\": \"".concat(heightString).concat("\"}, {\"trait_type\": \"foo\", \"value\": \"nil\"}]}") + let altSuffix2 = "\"attributes\": [{\"trait_type\": \"foo\", \"value\": \"nil\"}, {\"trait_type\": \"mintedBlock\", \"value\": \"".concat(heightString).concat("\"}]}") let idsResult = executeScript( "../scripts/nft/get_ids.cdc", @@ -74,7 +75,6 @@ fun testSerializeNFTSucceeds() { Test.expect(serializeMetadataResult, Test.beSucceeded()) let serializedMetadata = serializeMetadataResult.returnValue! as! String - Test.assertEqual(true, serializedMetadata == expectedPrefix.concat(altSuffix1) || serializedMetadata == expectedPrefix.concat(altSuffix2)) } diff --git a/flow.json b/flow.json index f39cf1d0..65384829 100644 --- a/flow.json +++ b/flow.json @@ -264,7 +264,6 @@ "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "e467b9dd11fa00df", - "previewnet": "b6763b4399a888c8", "testnet": "8c5303eaa26202d6" } }, @@ -274,7 +273,6 @@ "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "e467b9dd11fa00df", - "previewnet": "b6763b4399a888c8", "testnet": "8c5303eaa26202d6" } }, @@ -284,7 +282,6 @@ "aliases": { "emulator": "0ae53cb6e3f42a79", "mainnet": "1654653399040a61", - "previewnet": "4445e7ad11568276", "testnet": "7e60df042a9c0868" } }, @@ -294,7 +291,6 @@ "aliases": { "emulator": "ee82856bf20e2aa6", "mainnet": "f233dcee88fe0abe", - "previewnet": "a0225e7000ac82a9", "testnet": "9a0766d93b6608b7" } }, @@ -304,7 +300,6 @@ "aliases": { "emulator": "ee82856bf20e2aa6", "mainnet": "f233dcee88fe0abe", - "previewnet": "a0225e7000ac82a9", "testnet": "9a0766d93b6608b7" } }, @@ -314,7 +309,6 @@ "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "1d7e57aa55817448", - "previewnet": "b6763b4399a888c8", "testnet": "631e88ae7f1d7c20" } }, @@ -324,7 +318,6 @@ "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "1d7e57aa55817448", - "previewnet": "b6763b4399a888c8", "testnet": "631e88ae7f1d7c20" } }, @@ -334,7 +327,6 @@ "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "1d7e57aa55817448", - "previewnet": "b6763b4399a888c8", "testnet": "631e88ae7f1d7c20" } }