diff --git a/apps/playground/src/pages/api/donate-mint-mesh.ts b/apps/playground/src/pages/api/donate-mint-mesh.ts index 2c3409340..76f2d4800 100644 --- a/apps/playground/src/pages/api/donate-mint-mesh.ts +++ b/apps/playground/src/pages/api/donate-mint-mesh.ts @@ -94,7 +94,7 @@ export default async function handler( .selectUtxosFrom(utxos) .mint("1", policyId, stringToHex(assetName)) .mintingScript(forgingScript) - .metadataValue("721", fullAssetMetadata) + .metadataValue(721, fullAssetMetadata) .txOut(donateAddress, [ { unit: "lovelace", quantity: costLovelace.toString() }, ]) diff --git a/apps/playground/src/pages/apis/transaction/basics/cip20.tsx b/apps/playground/src/pages/apis/transaction/basics/cip20.tsx index 2fc46c5e2..11ec8f601 100644 --- a/apps/playground/src/pages/apis/transaction/basics/cip20.tsx +++ b/apps/playground/src/pages/apis/transaction/basics/cip20.tsx @@ -34,7 +34,7 @@ function Left() { The specification for the individual strings follow the general design specification for JSON metadata, which is already implemented and in operation on the cardano blockchain. The used metadatum label is{" "} - 674:, this number was choosen because it is the T9 encoding + 674:, this number was chosen because it is the T9 encoding of the string msg. The message content has the key msg: and consists of an array of individual message-strings. The number of theses diff --git a/apps/playground/src/pages/apis/txbuilder/basics/cip20.tsx b/apps/playground/src/pages/apis/txbuilder/basics/cip20.tsx index 3d20ced5a..8d0903519 100644 --- a/apps/playground/src/pages/apis/txbuilder/basics/cip20.tsx +++ b/apps/playground/src/pages/apis/txbuilder/basics/cip20.tsx @@ -22,7 +22,7 @@ export default function TxbuilderCip20() { function Left() { let code = `txBuilder\n`; - code += ` .metadataValue(tag, metadata)\n`; + code += ` .metadataValue(label, metadata)\n`; return ( <> @@ -36,7 +36,7 @@ function Left() { The specification for the individual strings follow the general design specification for JSON metadata, which is already implemented and in operation on the cardano blockchain. The used metadatum label is{" "} - 674: this number was choosen because it is the T9 encoding + 674: this number was chosen because it is the T9 encoding of the string msg. The message content has the key msg: and consists of an array of individual message-strings. The number of theses @@ -60,14 +60,14 @@ function Right() { const changeAddress = await wallet.getChangeAddress(); const txBuilder = getTxBuilder(); - const tag = "674"; + const label = 674; const metadata = { msg: message.split("\n"), }; const unsignedTx = await txBuilder .changeAddress(changeAddress) - .metadataValue(tag.toString(), metadata) + .metadataValue(label, metadata) .selectUtxosFrom(utxos) .complete(); @@ -82,7 +82,7 @@ function Right() { codeSnippet += `const changeAddress = await wallet.getChangeAddress();\n`; codeSnippet += `const txBuilder = getTxBuilder();\n`; codeSnippet += `\n`; - codeSnippet += `const tag = "674";\n`; + codeSnippet += `const label = 674;\n`; codeSnippet += `const metadata = {\n`; codeSnippet += ` msg: [\n`; for (let line of message.split("\n")) { @@ -92,7 +92,7 @@ function Right() { codeSnippet += `});\n\n`; codeSnippet += `const unsignedTx = await txBuilder\n`; codeSnippet += ` .changeAddress(changeAddress)\n`; - codeSnippet += ` .metadataValue(tag, metadata)\n`; + codeSnippet += ` .metadataValue(label, metadata)\n`; codeSnippet += ` .selectUtxosFrom(utxos)\n`; codeSnippet += ` .complete();\n`; codeSnippet += `\n`; diff --git a/apps/playground/src/pages/apis/txbuilder/basics/multisig.tsx b/apps/playground/src/pages/apis/txbuilder/basics/multisig.tsx index f8297619a..9e209ef01 100644 --- a/apps/playground/src/pages/apis/txbuilder/basics/multisig.tsx +++ b/apps/playground/src/pages/apis/txbuilder/basics/multisig.tsx @@ -29,7 +29,7 @@ function Left() { codeTx += `const unsignedTx = await txBuilder\n`; codeTx += ` .mint("1", policyId, stringToHex("MeshToken"))\n`; codeTx += ` .mintingScript(forgingScript)\n`; - codeTx += ` .metadataValue("721", { [policyId]: { [assetName]: demoAssetMetadata } })\n`; + codeTx += ` .metadataValue(721, { [policyId]: { [assetName]: demoAssetMetadata } })\n`; codeTx += ` .changeAddress(address)\n`; codeTx += ` .selectUtxosFrom(utxos)\n`; codeTx += ` .complete();\n`; @@ -88,7 +88,7 @@ function Right() { const unsignedTx = await txBuilder .mint("1", policyId, stringToHex("MeshToken")) .mintingScript(forgingScript) - .metadataValue("721", { [policyId]: { [assetName]: demoAssetMetadata } }) + .metadataValue(721, { [policyId]: { [assetName]: demoAssetMetadata } }) .changeAddress(address) .selectUtxosFrom(utxos) .complete(); @@ -124,7 +124,7 @@ function Right() { codeSnippet += `const unsignedTx = await txBuilder\n`; codeSnippet += ` .mint("1", policyId, stringToHex("MeshToken"))\n`; codeSnippet += ` .mintingScript(forgingScript)\n`; - codeSnippet += ` .metadataValue("721", { [policyId]: { [assetName]: demoAssetMetadata } })\n`; + codeSnippet += ` .metadataValue(721, { [policyId]: { [assetName]: demoAssetMetadata } })\n`; codeSnippet += ` .changeAddress(address)\n`; codeSnippet += ` .selectUtxosFrom(utxos)\n`; codeSnippet += ` .complete();\n`; diff --git a/apps/playground/src/pages/apis/txbuilder/basics/set-metadata.tsx b/apps/playground/src/pages/apis/txbuilder/basics/set-metadata.tsx index ae9f7bda8..9dda03c70 100644 --- a/apps/playground/src/pages/apis/txbuilder/basics/set-metadata.tsx +++ b/apps/playground/src/pages/apis/txbuilder/basics/set-metadata.tsx @@ -22,7 +22,7 @@ export default function TxbuilderSetMetadata() { function Left() { let code = `txBuilder\n`; - code += ` .metadataValue(tag, metadata)\n`; + code += ` .metadataValue(label, metadata)\n`; return ( <> @@ -47,12 +47,12 @@ function Right() { const changeAddress = await wallet.getChangeAddress(); const txBuilder = getTxBuilder(); - const tag = "0"; + const label = 0; const metadata = "This is a message from the Mesh SDK"; const unsignedTx = await txBuilder .changeAddress(changeAddress) - .metadataValue(tag.toString(), metadata) + .metadataValue(label, metadata) .selectUtxosFrom(utxos) .complete(); @@ -66,11 +66,11 @@ function Right() { codeSnippet += `const address = await wallet.getChangeAddress();\n`; codeSnippet += `const txBuilder = getTxBuilder();\n`; codeSnippet += `\n`; - codeSnippet += `const tag = "0";\n`; + codeSnippet += `const label = 0;\n`; codeSnippet += `const metadata = "This is a message from the Mesh SDK";\n\n`; codeSnippet += `const unsignedTx = await txBuilder\n`; codeSnippet += ` .changeAddress(address)\n`; - codeSnippet += ` .metadataValue(tag, metadata)\n`; + codeSnippet += ` .metadataValue(label, metadata)\n`; codeSnippet += ` .selectUtxosFrom(utxos)\n`; codeSnippet += ` .complete();\n`; codeSnippet += `\n`; diff --git a/apps/playground/src/pages/apis/txbuilder/minting/minting-native-script.tsx b/apps/playground/src/pages/apis/txbuilder/minting/minting-native-script.tsx index f8526b1a6..a369af6c7 100644 --- a/apps/playground/src/pages/apis/txbuilder/minting/minting-native-script.tsx +++ b/apps/playground/src/pages/apis/txbuilder/minting/minting-native-script.tsx @@ -84,7 +84,7 @@ function Right() { const unsignedTx = await txBuilder .mint("1", policyId, tokenNameHex) .mintingScript(forgingScript) - .metadataValue("721", metadata) + .metadataValue(721, metadata) .changeAddress(changeAddress) .invalidHereafter(99999999) .selectUtxosFrom(utxos) @@ -125,7 +125,7 @@ const txBuilder = getTxBuilder(); const unsignedTx = await txBuilder .mint("1", policyId, tokenNameHex) .mintingScript(forgingScript) - .metadataValue("721", metadata) + .metadataValue(721, metadata) .changeAddress(changeAddress) .invalidHereafter(99999999) .selectUtxosFrom(utxos) diff --git a/apps/playground/src/pages/apis/txbuilder/minting/minting-one-signature.tsx b/apps/playground/src/pages/apis/txbuilder/minting/minting-one-signature.tsx index 956184cd8..4c2bbfe24 100644 --- a/apps/playground/src/pages/apis/txbuilder/minting/minting-one-signature.tsx +++ b/apps/playground/src/pages/apis/txbuilder/minting/minting-one-signature.tsx @@ -91,7 +91,7 @@ function Right() { const unsignedTx = await txBuilder .mint("1", policyId, tokenNameHex) .mintingScript(forgingScript) - .metadataValue("721", metadata) + .metadataValue(721, metadata) .changeAddress(changeAddress) .selectUtxosFrom(utxos) .complete(); @@ -124,7 +124,7 @@ function Right() { codeSnippet += `const unsignedTx = await txBuilder\n`; codeSnippet += ` .mint("1", policyId, tokenNameHex)\n`; codeSnippet += ` .mintingScript(forgingScript)\n`; - codeSnippet += ` .metadataValue("721", metadata)\n`; + codeSnippet += ` .metadataValue(721, metadata)\n`; codeSnippet += ` .changeAddress(changeAddress)\n`; codeSnippet += ` .selectUtxosFrom(utxos)\n`; codeSnippet += ` .complete();\n`; diff --git a/apps/playground/src/pages/apis/txbuilder/minting/minting-plutus-script.tsx b/apps/playground/src/pages/apis/txbuilder/minting/minting-plutus-script.tsx index b2acc3287..7f18f0441 100644 --- a/apps/playground/src/pages/apis/txbuilder/minting/minting-plutus-script.tsx +++ b/apps/playground/src/pages/apis/txbuilder/minting/minting-plutus-script.tsx @@ -42,7 +42,7 @@ function Left() { codeSnippet3 += ` .mint("1", policyId, tokenNameHex)\n`; codeSnippet3 += ` .mintingScript(demoPlutusMintingScript)\n`; codeSnippet3 += ` .mintRedeemerValue(mConStr0([userInput]))\n`; - codeSnippet3 += ` .metadataValue("721", metadata)\n`; + codeSnippet3 += ` .metadataValue(721, metadata)\n`; codeSnippet3 += ` .changeAddress(changeAddress)\n`; codeSnippet3 += ` .selectUtxosFrom(utxos)\n`; codeSnippet3 += ` .txInCollateral(\n`; @@ -125,7 +125,7 @@ function Right() { .mint("1", policyId, tokenNameHex) .mintingScript(demoPlutusMintingScript) .mintRedeemerValue(mConStr0([userInput])) - .metadataValue("721", metadata) + .metadataValue(721, metadata) .changeAddress(changeAddress) .selectUtxosFrom(utxos) .txInCollateral( @@ -159,7 +159,7 @@ function Right() { code += ` .mint("1", policyId, tokenNameHex)\n`; code += ` .mintingScript(demoPlutusMintingScript)\n`; code += ` .mintRedeemerValue(mConStr0(['${userInput}']))\n`; - code += ` .metadataValue("721", metadata)\n`; + code += ` .metadataValue(721, metadata)\n`; code += ` .changeAddress(changeAddress)\n`; code += ` .selectUtxosFrom(utxos)\n`; code += ` .txInCollateral(\n`; diff --git a/apps/playground/src/pages/apis/txbuilder/minting/minting-royalty-token.tsx b/apps/playground/src/pages/apis/txbuilder/minting/minting-royalty-token.tsx index c8d591a4c..d768e46d1 100644 --- a/apps/playground/src/pages/apis/txbuilder/minting/minting-royalty-token.tsx +++ b/apps/playground/src/pages/apis/txbuilder/minting/minting-royalty-token.tsx @@ -81,7 +81,7 @@ function Right() { const unsignedTx = await txBuilder .mint("1", policyId, "") .mintingScript(forgingScript) - .metadataValue("777", assetMetadata) + .metadataValue(777, assetMetadata) .changeAddress(address) .selectUtxosFrom(utxos) .complete(); @@ -114,7 +114,7 @@ function Right() { code += `const unsignedTx = await txBuilder\n`; code += ` .mint("1", policyId, "")\n`; code += ` .mintingScript(forgingScript)\n`; - code += ` .metadataValue("777", assetMetadata)\n`; + code += ` .metadataValue(777, assetMetadata)\n`; code += ` .changeAddress(address)\n`; code += ` .selectUtxosFrom(utxos)\n`; code += ` .complete();\n`; diff --git a/apps/playground/src/pages/apis/txbuilder/minting/multiple-assets.tsx b/apps/playground/src/pages/apis/txbuilder/minting/multiple-assets.tsx index 6a872b5a1..75835d2fd 100644 --- a/apps/playground/src/pages/apis/txbuilder/minting/multiple-assets.tsx +++ b/apps/playground/src/pages/apis/txbuilder/minting/multiple-assets.tsx @@ -35,7 +35,7 @@ function Left() { let codeSnippet2 = ``; codeSnippet2 += `txBuilder\n`; - codeSnippet2 += ` .metadataValue("721", metadata)\n`; + codeSnippet2 += ` .metadataValue(721, metadata)\n`; codeSnippet2 += ` .changeAddress(changeAddress)\n`; codeSnippet2 += ` .selectUtxosFrom(utxos);\n`; codeSnippet2 += `\n`; @@ -88,7 +88,7 @@ function Right() { } txBuilder - .metadataValue("721", metadata) + .metadataValue(721, metadata) .changeAddress(changeAddress) .selectUtxosFrom(utxos); @@ -125,7 +125,7 @@ function Right() { codeSnippet += `}\n`; codeSnippet += `\n`; codeSnippet += `txBuilder\n`; - codeSnippet += ` .metadataValue("721", metadata)\n`; + codeSnippet += ` .metadataValue(721, metadata)\n`; codeSnippet += ` .changeAddress(changeAddress)\n`; codeSnippet += ` .selectUtxosFrom(utxos);\n`; codeSnippet += `\n`; diff --git a/apps/playground/src/pages/apis/wallets/browserwallet/get-supported-extensions.tsx b/apps/playground/src/pages/apis/wallets/browserwallet/get-supported-extensions.tsx index 6db1a1bb3..e80235dcb 100644 --- a/apps/playground/src/pages/apis/wallets/browserwallet/get-supported-extensions.tsx +++ b/apps/playground/src/pages/apis/wallets/browserwallet/get-supported-extensions.tsx @@ -1,4 +1,4 @@ -import { use, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { BrowserWallet } from "@meshsdk/core"; import { useWalletList } from "@meshsdk/react"; diff --git a/apps/playground/src/pages/providers/hydra-endpoints/on-message.tsx b/apps/playground/src/pages/providers/hydra-endpoints/on-message.tsx index 2194b04d8..bd8062549 100644 --- a/apps/playground/src/pages/providers/hydra-endpoints/on-message.tsx +++ b/apps/playground/src/pages/providers/hydra-endpoints/on-message.tsx @@ -1,4 +1,4 @@ -import { use, useEffect } from "react"; +import { useEffect } from "react"; import { HydraProvider } from "@meshsdk/core"; diff --git a/packages/mesh-common/src/types/transaction-builder/index.ts b/packages/mesh-common/src/types/transaction-builder/index.ts index f3a8e37a6..495e43fb5 100644 --- a/packages/mesh-common/src/types/transaction-builder/index.ts +++ b/packages/mesh-common/src/types/transaction-builder/index.ts @@ -25,7 +25,7 @@ export type MeshTxBuilderBody = { referenceInputs: RefTxIn[]; mints: MintItem[]; changeAddress: string; - metadata: Metadata[]; + metadata: TxMetadata; validityRange: ValidityRange; certificates: Certificate[]; withdrawals: Withdrawal[]; @@ -50,7 +50,7 @@ export const emptyTxBuilderBody = (): MeshTxBuilderBody => ({ referenceInputs: [], mints: [], changeAddress: "", - metadata: [], + metadata: new Map(), validityRange: {}, certificates: [], withdrawals: [], @@ -73,6 +73,13 @@ export type ValidityRange = { // Mint Types +// Transaction Metadata + +export type MetadatumMap = Map; +export type Metadatum = bigint | number | string | Uint8Array | MetadatumMap | Metadatum[]; +export type TxMetadata = Map; + +// to be used for serialization export type Metadata = { tag: string; metadata: string; diff --git a/packages/mesh-contract/src/content-ownership/offchain/offchain.ts b/packages/mesh-contract/src/content-ownership/offchain/offchain.ts index ec14ac921..faa3acace 100644 --- a/packages/mesh-contract/src/content-ownership/offchain/offchain.ts +++ b/packages/mesh-contract/src/content-ownership/offchain/offchain.ts @@ -525,7 +525,7 @@ export class MeshContentOwnershipContract extends MeshTxInitiator { const txHex = await this.mesh .mint("1", policyId, tokenNameHex) .mintingScript(forgingScript) - .metadataValue("721", metadata) + .metadataValue(721, metadata) .changeAddress(walletAddress) .selectUtxosFrom(utxos) .complete(); diff --git a/packages/mesh-contract/src/plutus-nft/offchain.ts b/packages/mesh-contract/src/plutus-nft/offchain.ts index e12bb7b02..56820090c 100644 --- a/packages/mesh-contract/src/plutus-nft/offchain.ts +++ b/packages/mesh-contract/src/plutus-nft/offchain.ts @@ -200,7 +200,7 @@ export class MeshPlutusNFTContract extends MeshTxInitiator { if (assetMetadata) { const metadata = { [policyId]: { [tokenName]: { ...assetMetadata } } }; - tx.metadataValue("721", metadata); + tx.metadataValue(721, metadata); } tx.mintRedeemerValue(mConStr0([])) diff --git a/packages/mesh-core-csl/src/core/adaptor/index.ts b/packages/mesh-core-csl/src/core/adaptor/index.ts index 1169f94a3..9fec4a2f3 100644 --- a/packages/mesh-core-csl/src/core/adaptor/index.ts +++ b/packages/mesh-core-csl/src/core/adaptor/index.ts @@ -4,6 +4,7 @@ import { certificateToObj } from "./certificate"; import { mintItemToObj } from "./mint"; import { networkToObj } from "./network"; import { outputToObj } from "./output"; +import { txMetadataToObj } from "./metadata"; import { collateralTxInToObj, txInToObj } from "./txIn"; import { voteToObj } from "./vote"; import { withdrawalToObj } from "./withdrawal"; @@ -33,7 +34,7 @@ export const meshTxBuilderBodyToObj = ({ referenceInputs: referenceInputs, mints: mints.map((mint) => mintItemToObj(mint)), changeAddress, - metadata: metadata, + metadata: txMetadataToObj(metadata), validityRange: validityRangeToObj(validityRange), certificates: certificates.map(certificateToObj), signingKey: signingKey, diff --git a/packages/mesh-core-csl/src/core/adaptor/metadata.ts b/packages/mesh-core-csl/src/core/adaptor/metadata.ts new file mode 100644 index 000000000..c56fa1ce9 --- /dev/null +++ b/packages/mesh-core-csl/src/core/adaptor/metadata.ts @@ -0,0 +1,40 @@ +import JSONbig from "json-bigint"; + +import type { Metadata, Metadatum, TxMetadata } from "@meshsdk/common"; + +export const txMetadataToObj = (metadata: TxMetadata): Metadata[] => { + const result: Metadata[] = []; + metadata.forEach((value: Metadatum, key: bigint) => { + result.push({ + tag: key.toString(), + metadata: JSONbig.stringify(metadatumToObj(value)), + }); + }); + return result; +}; + +const metadatumToObj = (metadatum: Metadatum): any => { + if (typeof metadatum === "number" || typeof metadatum === "string") { + return metadatum; + } else if (typeof metadatum === "bigint") { + return metadatum.toString(); + } else if (metadatum instanceof Uint8Array) { + return uint8ArrayToHex(metadatum); + } else if (metadatum instanceof Map) { + const result: Record = {}; + metadatum.forEach((value, key) => { + result[metadatumToObj(key)] = metadatumToObj(value); + }); + return result; + } else if (Array.isArray(metadatum)) { + return metadatum.map(metadatumToObj); + } else { + throw new Error("metadatumToObj: Unsupported Metadatum type"); + } +}; + +const uint8ArrayToHex = (bytes: Uint8Array): string => { + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +}; diff --git a/packages/mesh-core-csl/test/core/builder.test.ts b/packages/mesh-core-csl/test/core/builder.test.ts index bc690b184..64cb0c499 100644 --- a/packages/mesh-core-csl/test/core/builder.test.ts +++ b/packages/mesh-core-csl/test/core/builder.test.ts @@ -85,7 +85,7 @@ describe("Builder", () => { mints: [], changeAddress: "addr_test1qq0yavv5uve45rwvfaw96qynrqt8ckpmkwcg08vlwxxdncxk82f5wz75mzaesmqzl79xqsmedwgucwtuav5str6untqqmykcpn", - metadata: [], + metadata: new Map(), validityRange: {}, certificates: [], withdrawals: [], diff --git a/packages/mesh-transaction/src/index.ts b/packages/mesh-transaction/src/index.ts index 83c938075..ac43b6b15 100644 --- a/packages/mesh-transaction/src/index.ts +++ b/packages/mesh-transaction/src/index.ts @@ -1,3 +1,4 @@ export * from "./mesh-tx-builder"; export * from "./scripts"; export * from "./transaction"; +export * from "./utils"; diff --git a/packages/mesh-transaction/src/mesh-tx-builder/tx-builder-core.ts b/packages/mesh-transaction/src/mesh-tx-builder/tx-builder-core.ts index 3c24a61f7..a023ba492 100644 --- a/packages/mesh-transaction/src/mesh-tx-builder/tx-builder-core.ts +++ b/packages/mesh-transaction/src/mesh-tx-builder/tx-builder-core.ts @@ -14,6 +14,7 @@ import { emptyTxBuilderBody, LanguageVersion, MeshTxBuilderBody, + Metadatum, MintItem, Network, Output, @@ -35,6 +36,8 @@ import { Withdrawal, } from "@meshsdk/common"; +import { metadataObjToMap } from "../utils"; + export class MeshTxBuilderCore { txEvaluationMultiplier = 1.1; private txOutput?: Output; @@ -1412,13 +1415,20 @@ export class MeshTxBuilderCore { /** * Add metadata to the transaction - * @param tag The tag of the metadata + * @param label The label of the metadata, preferably number * @param metadata The metadata in any format * @returns The MeshTxBuilder instance */ - metadataValue = (tag: string, metadata: any) => { - const metadataString = JSONBig.stringify(metadata); - this.meshTxBuilderBody.metadata.push({ tag, metadata: metadataString }); + metadataValue = ( + label: number | bigint | string, + metadata: Metadatum | object, + ) => { + label = BigInt(label); + if (typeof metadata === "object" && !(metadata instanceof Map)) { + this.meshTxBuilderBody.metadata.set(label, metadataObjToMap(metadata)); + } else { + this.meshTxBuilderBody.metadata.set(label, metadata); + } return this; }; diff --git a/packages/mesh-transaction/src/transaction/index.ts b/packages/mesh-transaction/src/transaction/index.ts index 5c1977336..ed7abf3bf 100644 --- a/packages/mesh-transaction/src/transaction/index.ts +++ b/packages/mesh-transaction/src/transaction/index.ts @@ -9,6 +9,7 @@ import { hexToString, IInitiator, metadataToCip68, + Metadatum, Mint, NativeScript, Network, @@ -33,6 +34,7 @@ import { } from "@meshsdk/core-cst"; import { MeshTxBuilder, MeshTxBuilderOptions } from "../mesh-tx-builder"; +import { mergeContents, metadataObjToMap } from "../utils"; export interface TransactionOptions extends MeshTxBuilderOptions { initiator: IInitiator; @@ -470,9 +472,22 @@ export class Transaction { } if (!mint.cip68ScriptAddress && mint.metadata && mint.label) { if (mint.label === "721" || mint.label === "20") { - this.setMetadata(Number(mint.label), { - [policyId]: { [mint.assetName]: mint.metadata }, - }); + let currentMetadata = this.txBuilder.meshTxBuilderBody.metadata; + if (currentMetadata.size === 0) { + this.setMetadata(Number(mint.label), { + [policyId]: { [mint.assetName]: mint.metadata }, + }); + } else { + let metadataMap = metadataObjToMap({ + [policyId]: { [mint.assetName]: mint.metadata }, + } as object); + let newMetadata = mergeContents( + currentMetadata.get(BigInt(mint.label)) as Metadatum, + metadataMap, + mint.label === "721" ? 2 : 0, + ); + this.setMetadata(Number(mint.label), newMetadata); + } } else { this.setMetadata(Number(mint.label), mint.metadata); } @@ -585,13 +600,13 @@ export class Transaction { /** * Add a JSON metadata entry to the transaction. * - * @param {number} key The key to use for the metadata entry. - * @param {unknown} value The value to use for the metadata entry. + * @param {number} label The label to use for the metadata entry. + * @param {unknown} metadata The value to use for the metadata entry. * @returns {Transaction} The Transaction object. * @see {@link https://meshjs.dev/apis/transaction#setMetadata} */ - setMetadata(key: number, value: unknown): Transaction { - this.txBuilder.metadataValue(key.toString(), value as object); + setMetadata(label: number, metadata: Metadatum | object): Transaction { + this.txBuilder.metadataValue(label, metadata); return this; } diff --git a/packages/mesh-transaction/src/transaction/transaction-v2.ts b/packages/mesh-transaction/src/transaction/transaction-v2.ts index 0fc7151db..4be0494e8 100644 --- a/packages/mesh-transaction/src/transaction/transaction-v2.ts +++ b/packages/mesh-transaction/src/transaction/transaction-v2.ts @@ -12,6 +12,8 @@ import { UTxO, } from "@meshsdk/common"; +import type { MetadataMergeLevel } from "../utils/metadata"; + export interface TransactionV2 { sendAssets( receiver: string, @@ -47,7 +49,10 @@ export interface TransactionV2 { setRequiredSigners(addresses: string[]): this; setTimeToExpire(slot: string): this; setTimeToStart(slot: string): this; - setMetadata(key: number, value: unknown): this; + setMetadata( + label: number, + metadata: unknown + ): this; withdrawRewards(rewardAddress: string, lovelace: string): this; delegateStake(rewardAddress: string, poolId: string): this; deregisterStake(rewardAddress: string): this; diff --git a/packages/mesh-transaction/src/utils/index.ts b/packages/mesh-transaction/src/utils/index.ts new file mode 100644 index 000000000..e6c55b6c9 --- /dev/null +++ b/packages/mesh-transaction/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./metadata"; diff --git a/packages/mesh-transaction/src/utils/metadata.ts b/packages/mesh-transaction/src/utils/metadata.ts new file mode 100644 index 000000000..1323f0ebe --- /dev/null +++ b/packages/mesh-transaction/src/utils/metadata.ts @@ -0,0 +1,150 @@ +import JSONBig from "json-bigint"; + +import type { Metadatum, MetadatumMap } from "@meshsdk/common"; + +export type MetadataMergeLevel = boolean | number; + +export const metadataObjToMap = (metadata: any): Metadatum => { + if (typeof metadata === "bigint") { + return metadata; + } else if (typeof metadata === "string") { + return metadata; + } else if (typeof metadata === "number") { + return metadata; + } else if (metadata instanceof Uint8Array) { + return metadata; + } else if (Array.isArray(metadata)) { + // Recursively process each element in the array + return metadata.map(metadataObjToMap); + } else if (metadata && typeof metadata === "object") { + // Convert to MetadatumMap recursively + const map: MetadatumMap = new Map(); + if (metadata instanceof Map) { + // for Map + metadata.forEach((value, key) => { + map.set(metadataObjToMap(key), metadataObjToMap(value)); + }); + } else { + // for Object + Object.entries(metadata).forEach(([key, value]) => { + map.set(metadataObjToMap(key), metadataObjToMap(value)); + }); + } + return map; + } else { + throw new Error("Metadata map conversion: Unsupported metadata type"); + } +}; + +/** + * Recursively merge two metadata. Returns the 2nd item if the maximum allowed + * merge depth has passed. + * + * Merging maps ({ key: value }): + * Two maps are merged by recursively including the (key, value) pairs from both the maps. + * When further merge isn't allowed (by currentDepth), the 2nd item is preferred, + * replacing the 1st item. + * + * Merging arrays: + * Two arrays are merged by concatenating them. + * When merge isn't allowed (by currentDepth), the 2nd array is returned. + * + * Merging primitive types (number, string, etc.): + * Primitive types are not merged in the sense of concatenating. In case they are the same, + * either of them can be considered as the "merged value". 2nd item is returned here. + * When merge isn't allowed (by currentDepth), the 2nd item is returned. + * + * @param a first item + * @param b second item + * @param currentDepth the current merge depth; decreases in a recursive call + * @returns merged item or a preferred item, chosen according to currentDepth + */ +export const mergeContents = ( + a: Metadatum, + b: Metadatum, + currentDepth: number, +): Metadatum => { + // Handle no merge + if (currentDepth <= 0) { + return b; + } + // Handle merging of maps + if (a instanceof Map && b instanceof Map) { + b.forEach((value: Metadatum, key: Metadatum) => { + if (a.has(key)) { + a.set( + key, + mergeContents(a.get(key) as Metadatum, value, currentDepth - 1), + ); + } else { + a.set(key, value); + } + }); + return a; + } + // Handle merging of arrays + else if (Array.isArray(a) && Array.isArray(b)) { + return [...a, ...b]; + } + // Handle merging of primitive types + if ( + (typeof a === "number" || + typeof a === "bigint" || + typeof a === "string" || + a instanceof Uint8Array) && + (typeof b === "number" || + typeof b === "bigint" || + typeof b === "string" || + b instanceof Uint8Array) + ) { + if (typeof a === typeof b) { + if (a === b) { + // Equal primitive types (string, number or bigint) + return b; + } + if ( + a instanceof Uint8Array && + b instanceof Uint8Array && + areUint8ArraysEqual(a, b) + ) { + // Equal Uint8Array values + return b; + } + } + // If values are not equal or types are mismatched + throw new Error( + `Tx metadata merge error: cannot merge ${JSONBig.stringify(a)} with ${JSONBig.stringify(b)}`, + ); + } + + // Unsupported or mismatched types + throw new Error( + `Tx metadata merge error: cannot merge ${getMetadatumType(a)} type with ${getMetadatumType(b)} type`, + ); +}; + +const getMergeDepth = (mergeOption: MetadataMergeLevel): number => { + return typeof mergeOption === "number" + ? mergeOption + : mergeOption === true + ? 1 + : 0; +}; + +const getMetadatumType = (a: Metadatum): string => { + if (a instanceof Map) return "map"; + if (Array.isArray(a)) return "array"; + return "primitive"; +}; + +const areUint8ArraysEqual = (a: Uint8Array, b: Uint8Array): boolean => { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +}; diff --git a/packages/mesh-transaction/test/transaction/txMetadata.test.ts b/packages/mesh-transaction/test/transaction/txMetadata.test.ts new file mode 100644 index 000000000..b36029197 --- /dev/null +++ b/packages/mesh-transaction/test/transaction/txMetadata.test.ts @@ -0,0 +1,276 @@ +import { mergeContents, metadataObjToMap } from "@meshsdk/transaction"; + +describe("Transaction Metadata Merge", () => { + it("should merge two identical number metadata entries", () => { + const currentMetadata = 42; + const newMetadata = 42; + const expectedOutput = 42; + mergeContents(currentMetadata, newMetadata, 1); + expect(currentMetadata).toEqual(expectedOutput); + }); + it("should merge two identical string metadata entries", () => { + const currentMetadata = "Hey!"; + const newMetadata = "Hey!"; + const expectedOutput = "Hey!"; + mergeContents(currentMetadata, newMetadata, 1); + expect(currentMetadata).toEqual(expectedOutput); + }); + it("should not merge two different numbers", () => { + const currentMetadata = 42; + const newMetadata = 43; + expect(() => mergeContents(currentMetadata, newMetadata, 1)).toThrow("cannot merge 42 with 43"); + }); + it("should not merge two different strings", () => { + const currentMetadata = "Alice"; + const newMetadata = "Bob"; + expect(() => mergeContents(currentMetadata, newMetadata, 1)).toThrow("cannot merge \"Alice\" with \"Bob\""); + }); + it("should not merge two same values of different types", () => { + const currentMetadata = 42; + const newMetadata = "42"; + expect(() => mergeContents(currentMetadata, newMetadata, 1)).toThrow("cannot merge 42 with \"42\""); + }); + it("should return the latest item if there is no merge", () => { + const currentMetadata = 42; + const newMetadata = 43; + const expectedOutput = 43; + // `currentMetadata` remains unchanged here + expect(mergeContents(currentMetadata, newMetadata, 0)).toEqual(expectedOutput); + }); + + it("should not merge two different values of the same object key", () => { + const currentMetadata = metadataObjToMap({ version: 1 }); + const newMetadata = metadataObjToMap({ version: 2 }); + expect(() => mergeContents(currentMetadata, newMetadata, 2)).toThrow("cannot merge 1 with 2"); + }); + it("should replace with the latest value of the same object key if values are not merged", () => { + const currentMetadata = metadataObjToMap({ version: 1 }); + const newMetadata = metadataObjToMap({ version: 2 }); + const expectedOutput = metadataObjToMap({ version: 2 }); + mergeContents(currentMetadata, newMetadata, 1); + expect(currentMetadata).toEqual(expectedOutput); + }); + it("should not merge different types", () => { + expect(() => mergeContents( + metadataObjToMap(0), + metadataObjToMap([]), + 1 + )).toThrow("cannot merge primitive type with array type"); + expect(() => mergeContents( + metadataObjToMap({}), + metadataObjToMap(""), + 1 + )).toThrow("cannot merge map type with primitive type"); + expect(() => mergeContents( + metadataObjToMap({}), + metadataObjToMap([]), + 1 + )).toThrow("cannot merge map type with array type"); + }); + it("plain object to map conversion should not allow nullish values", () => { + expect(() => metadataObjToMap(null)).toThrow("Unsupported metadata type"); + expect(() => metadataObjToMap({ "value": null })).toThrow("Unsupported metadata type"); + }); + + it("should replace 674 standard msg array for merge depth 1", () => { + const currentMetadata = metadataObjToMap({ + msg: ["A", "B", "C"], + msg2: ["X", "Y", "Z"] + }); + const newMetadata = metadataObjToMap({ + msg: ["D", "E", "F"] + }); + const expectedOutput = metadataObjToMap({ + msg: ["D", "E", "F"], + msg2: ["X", "Y", "Z"] + }); + mergeContents(currentMetadata, newMetadata, 1); + expect(currentMetadata).toEqual(expectedOutput); + }); + + it("should concatenate 674 standard msg arrays for merge depth 2", () => { + const currentMetadata = metadataObjToMap({ + msg: ["A", "B", "C"], + msg2: ["X", "Y", "Z"] + }); + const newMetadata = metadataObjToMap({ + msg: ["D", "E", "F"] + }); + const expectedOutput = metadataObjToMap({ + msg: ["A", "B", "C", "D", "E", "F"], + msg2: ["X", "Y", "Z"] + }); + mergeContents(currentMetadata, newMetadata, 2); + expect(currentMetadata).toEqual(expectedOutput); + }); + + it("should merge multiple CIP-25 NFTs metadata under the same policy id", () => { + const currentMetadata = metadataObjToMap({ + "policyId1": { + "My NFT 1": { + "name": "My NFT 1" + } + } + }); + const newMetadata = metadataObjToMap({ + "policyId1": { + "My NFT 2": { + "name": "My NFT 2", + "description": "My second NFT" + } + } + }); + const expectedOutput = metadataObjToMap({ + "policyId1": { + "My NFT 1": { + "name": "My NFT 1" + }, + "My NFT 2": { + "name": "My NFT 2", + "description": "My second NFT" + } + } + }); + mergeContents(currentMetadata, newMetadata, 2); + expect(currentMetadata).toEqual(expectedOutput); + }); + + it("should merge multiple CIP-25 NFTs metadata under different policy ids", () => { + const currentMetadata = metadataObjToMap({ + "policyId1": { + "My NFT 1": { + "name": "My NFT 1", + "files": [ + { name: "NFT 1 Image", src: "xyz", mediaType: "image/jpeg" } + ] + } + } + }); + const newMetadata1 = metadataObjToMap({ + "policyId2": { + "My NFT 1": { + "name": "My NFT 1 Policy 2", + "files": [ + { name: "NFT 1 P 2", src: "abc", mediaType: "image/png" } + ] + } + } + }); + const expectedOutput1 = metadataObjToMap({ + "policyId1": { + "My NFT 1": { + "name": "My NFT 1", + "files": [ + { name: "NFT 1 Image", src: "xyz", mediaType: "image/jpeg" } + ] + } + }, + "policyId2": { + "My NFT 1": { + "name": "My NFT 1 Policy 2", + "files": [ + { name: "NFT 1 P 2", src: "abc", mediaType: "image/png" } + ] + } + } + }); + mergeContents(currentMetadata, newMetadata1, 2); + expect(currentMetadata).toEqual(expectedOutput1); + // Merge more NFT metadata + const newMetadata2 = metadataObjToMap({ + "policyId1": { + "My NFT 2": { + "name": "My NFT 2", + "files": [ + { name: "NFT 2 Image", src: "pqr", mediaType: "image/jpeg" } + ] + } + } + }); + const expectedOutput2 = metadataObjToMap({ + "policyId1": { + "My NFT 1": { + "name": "My NFT 1", + "files": [ + { name: "NFT 1 Image", src: "xyz", mediaType: "image/jpeg" } + ] + }, + "My NFT 2": { + "name": "My NFT 2", + "files": [ + { name: "NFT 2 Image", src: "pqr", mediaType: "image/jpeg" } + ] + } + }, + "policyId2": { + "My NFT 1": { + "name": "My NFT 1 Policy 2", + "files": [ + { name: "NFT 1 P 2", src: "abc", mediaType: "image/png" } + ] + } + } + }); + mergeContents(currentMetadata, newMetadata2, 2); + expect(currentMetadata).toEqual(expectedOutput2); + }); + + it("should replace with the latest CIP-25 NFT metadata of the same policy and asset id", () => { + const currentMetadata = metadataObjToMap({ + "policyId1": { + "My NFT 1": { name: "NFT 1 Name", files: [{ name: "NFT Image" }] }, // old metadata here + "My NFT 2": { name: "NFT 2 Name" } + } + }); + const newMetadata = metadataObjToMap({ + "policyId1": { + "My NFT 1": { name: "Latest NFT 1", image: "xyz", description: "Latest NFT here" } + } + }); + const expectedOutput = metadataObjToMap({ + "policyId1": { + "My NFT 1": { name: "Latest NFT 1", image: "xyz", description: "Latest NFT here" }, + "My NFT 2": { name: "NFT 2 Name" } + } + }); + mergeContents(currentMetadata, newMetadata, 2); + expect(currentMetadata).toEqual(expectedOutput); + }); + + it("should attach version to CIP-25 metadata", () => { + const currentMetadata = metadataObjToMap({ + "policyId1": { "My NFT 1": { name: "My NFT 1" } }, + "policyId2": { "My NFT 1": { name: "My NFT 1 Policy 2" } } + }); + const newMetadata = metadataObjToMap({ + version: 1 + }); + const expectedOutput = metadataObjToMap({ + "policyId1": { "My NFT 1": { name: "My NFT 1" } }, + "policyId2": { "My NFT 1": { name: "My NFT 1 Policy 2" } }, + "version": 1 + }); + mergeContents(currentMetadata, newMetadata, 2); + expect(currentMetadata).toEqual(expectedOutput); + }); + + it("should preserve metadata entries with other tags in the original order", () => { + const currentMetadata = new Map([ + [0n, metadataObjToMap("line 1")], + [674n, metadataObjToMap({ msg: "line 2" })], + [721n, metadataObjToMap({ policyId1: { NFT1: { name: "line 3" } } })], + [1n, metadataObjToMap("line 4")] + ]); + const newMetadata = new Map([ + [721n, metadataObjToMap({ policyId1: { NFT2: { name: "line 5" } } })] + ]); + const expectedOutput = new Map([ + [0n, metadataObjToMap("line 1")], + [674n, metadataObjToMap({ msg: "line 2" })], + [721n, metadataObjToMap({ policyId1: { NFT1: { name: "line 3" }, NFT2: { name: "line 5" } } })], + [1n, metadataObjToMap("line 4")] + ]); + mergeContents(currentMetadata, newMetadata, 3); + expect(currentMetadata).toEqual(expectedOutput); + }); +});