diff --git a/packages/mesh-common/src/utils/data-hash.ts b/packages/mesh-common/src/utils/data-hash.ts index 113f8ac9b..b2b3f526d 100644 --- a/packages/mesh-common/src/utils/data-hash.ts +++ b/packages/mesh-common/src/utils/data-hash.ts @@ -1,6 +1,49 @@ import { blake2b } from "blakejs"; +import { toBytes } from "../data"; +import { LanguageVersion } from "../types"; + export const hashDrepAnchor = (jsonLD: object): string => { const jsonHash = blake2b(JSON.stringify(jsonLD, null, 2), undefined, 32); return Buffer.from(jsonHash).toString("hex"); }; + +/** + * Language tag bytes prepended to the script bytes when hashing a Plutus + * script, as defined by the Cardano ledger (CIP-0057 / plutus script hash): + * script_hash = blake2b_224(tag || script_bytes). + */ +export const PLUTUS_LANGUAGE_TAGS: Record = { + V1: 0x01, + V2: 0x02, + V3: 0x03, +}; + +/** + * Hash a Plutus script from its CBOR hex (the form produced by Aiken + * blueprints and stored in `PlutusScript.code`). + * + * The ledger hashes the serialised script bytes exactly as they appear in + * the CBOR byte string — the bytes must NOT be unwrapped/decoded first. + * Hashing the unwrapped UPLC bytes instead produces a different hash, which + * derives wrong script addresses and policy ids (see MeshJS/mesh#763). + * + * @param scriptCbor The script CBOR hex (e.g. a blueprint's `compiledCode`) + * @param version The Plutus language version of the script + * @returns The 56-character hex script hash + */ +export const hashPlutusScript = ( + scriptCbor: string, + version: LanguageVersion, +): string => { + const tag = PLUTUS_LANGUAGE_TAGS[version]; + if (tag === undefined) { + throw new Error(`Invalid Plutus script version: ${version}`); + } + const scriptBytes = toBytes(scriptCbor); + const preimage = new Uint8Array(scriptBytes.length + 1); + preimage[0] = tag; + preimage.set(scriptBytes, 1); + const hash = blake2b(preimage, undefined, 28); + return Buffer.from(hash).toString("hex"); +}; diff --git a/packages/mesh-common/test/utils/data-hash.test.ts b/packages/mesh-common/test/utils/data-hash.test.ts index 0dceed77f..04fafa9b3 100644 --- a/packages/mesh-common/test/utils/data-hash.test.ts +++ b/packages/mesh-common/test/utils/data-hash.test.ts @@ -1,4 +1,4 @@ -import { hashDrepAnchor } from "@meshsdk/common"; +import { hashDrepAnchor, hashPlutusScript } from "@meshsdk/common"; describe("hashDrepAnchor", () => { it("with drep object", () => { @@ -84,3 +84,35 @@ describe("hashDrepAnchor", () => { ); }); }); + +describe("hashPlutusScript", () => { + // Ground truth: Aiken blueprint hashes from mesh-contract's giftcard + // workspace (plutusVersion v3). The ledger hashes the serialised script + // bytes exactly as they appear in the CBOR byte string. + it("hashes a V3 minting policy to its Aiken blueprint hash", () => { + expect( + hashPlutusScript( + "5901ae01010032323232323232232225333005323232323253323300b3001300c3754004264646464a66601e600a0022a66602460226ea801c540085854ccc03cc00c00454ccc048c044dd50038a8010b0b18079baa006132323232533301430170021323253330133009301437540162a666026601260286ea8c8cc004004018894ccc0600045300103d87a80001323253330173375e603860326ea80080504cdd2a40006603600497ae0133004004001301c002301a00115333013300700113371e00402229405854ccc04ccdc3800a4002266e3c0080445281bad3014002375c60240022c602a00264a666020600860226ea800452f5bded8c026eacc054c048dd500099198008009bab3015301630163016301600322533301400114c103d87a80001323232325333015337220140042a66602a66e3c0280084cdd2a4000660326e980052f5c02980103d87a80001330060060033756602c0066eb8c050008c060008c058004dd6180980098079baa007370e90011bae3010300d37540046e1d200016300e300f002300d001300d002300b0013007375400229309b2b1bae0015734aae7555cf2ab9f5740ae855d11", + "V3", + ), + ).toEqual("401c967008d42885400991f9225715e1c3a8e43757b1fd36a1328195"); + }); + + it("hashes a V3 spending validator to its Aiken blueprint hash", () => { + expect( + hashPlutusScript( + "59011501010032323232323232232232253330063232323232533300b3370e900118061baa001132323232325333013301600213253330113370e6eb4c04c009200113371e00201e2940dd718088008b180a00099299980799b8748008c040dd50008a5eb7bdb1804dd5980a18089baa001323300100137566028602a602a602a602a60226ea8020894ccc04c004530103d87a80001323232325333014337220200042a66602866e3c0400084cdd2a4000660306e980052f5c02980103d87a80001330060060033756602a0066eb8c04c008c05c008c054004c048c04c008c044004c034dd50008b1807980800118070009807001180600098041baa00114984d958dd70009bae0015734aae7555cf2ab9f5740ae855d101", + "V3", + ), + ).toEqual("b2386630f1b210c58d0e46f132e931b362c3f373685118018e4d956f"); + }); + + it("produces a different hash per language version tag", () => { + expect( + hashPlutusScript( + "59011501010032323232323232232232253330063232323232533300b3370e900118061baa001132323232325333013301600213253330113370e6eb4c04c009200113371e00201e2940dd718088008b180a00099299980799b8748008c040dd50008a5eb7bdb1804dd5980a18089baa001323300100137566028602a602a602a602a60226ea8020894ccc04c004530103d87a80001323232325333014337220200042a66602866e3c0400084cdd2a4000660306e980052f5c02980103d87a80001330060060033756602a0066eb8c04c008c05c008c054004c048c04c008c044004c034dd50008b1807980800118070009807001180600098041baa00114984d958dd70009bae0015734aae7555cf2ab9f5740ae855d101", + "V2", + ), + ).not.toEqual("b2386630f1b210c58d0e46f132e931b362c3f373685118018e4d956f"); + }); +}); diff --git a/packages/mesh-core-csl/src/core/serializer.ts b/packages/mesh-core-csl/src/core/serializer.ts index b635c73a2..be39d1c44 100644 --- a/packages/mesh-core-csl/src/core/serializer.ts +++ b/packages/mesh-core-csl/src/core/serializer.ts @@ -11,6 +11,7 @@ import { DeserializedAddress, DeserializedScript, emptyTxBuilderBody, + hashPlutusScript, IDeserializer, IMeshTxSerializer, IResolver, @@ -29,7 +30,6 @@ import { import { castDataToPlutusData, csl, - deserializePlutusScript, resolveDataHash, resolveEd25519KeyHash, resolvePrivateKey, @@ -143,9 +143,10 @@ export class CSLSerializer implements IMeshTxSerializer { deserializePlutusScript: function ( script: PlutusScript, ): DeserializedScript { - const scriptHash = deserializePlutusScript(script.code, script.version) - .hash() - .to_hex(); + // The ledger hashes the serialised script bytes as they appear in + // the CBOR byte string; the deserialized object's hash() unwraps + // the CBOR first and disagrees with Aiken/cardano-cli (#763). + const scriptHash = hashPlutusScript(script.code, script.version); return { scriptHash, scriptCbor: script.code }; }, }, diff --git a/packages/mesh-core-csl/src/deser/resolver.ts b/packages/mesh-core-csl/src/deser/resolver.ts index 70fd0b2dd..85aa598d8 100644 --- a/packages/mesh-core-csl/src/deser/resolver.ts +++ b/packages/mesh-core-csl/src/deser/resolver.ts @@ -1,5 +1,6 @@ import { BuilderData, + hashPlutusScript, mnemonicToEntropy, NativeScript, PlutusDataType, @@ -8,7 +9,6 @@ import { import { castDataToPlutusData, - deserializePlutusScript, fromUTF8, toAddress, toBaseAddress, @@ -72,11 +72,14 @@ export const resolvePlutusScriptAddress = ( script: PlutusScript, networkId = 0, ) => { - const plutusScript = deserializePlutusScript(script.code, script.version); + // Hash the serialised script bytes (ledger rule); the deserialized + // object's hash() unwraps the CBOR byte string first and disagrees + // with Aiken/cardano-cli (see MeshJS/mesh#763). + const scriptHash = hashPlutusScript(script.code, script.version); const enterpriseAddress = csl.EnterpriseAddress.new( networkId, - csl.Credential.from_scripthash(plutusScript.hash()), + csl.Credential.from_scripthash(csl.ScriptHash.from_hex(scriptHash)), ); return enterpriseAddress.to_address().to_bech32(); diff --git a/packages/mesh-core-csl/test/utils/serializer.test.ts b/packages/mesh-core-csl/test/utils/serializer.test.ts index eeab362c2..099d3b23a 100644 --- a/packages/mesh-core-csl/test/utils/serializer.test.ts +++ b/packages/mesh-core-csl/test/utils/serializer.test.ts @@ -120,4 +120,17 @@ describe("CSLSerializer", () => { const result = serializer.resolver.script.resolveScriptRef(nativeScript); expect(result).toEqual("d81846820082041864"); }); + + it("should deserialize plutus script with the ledger script hash", () => { + // Aiken blueprint vector (giftcard redeem validator, Plutus V3): the + // hash must be taken over the serialised script bytes, not the + // CBOR-unwrapped bytes (see MeshJS/mesh#763). + const result = serializer.deserializer.script.deserializePlutusScript({ + code: "59011501010032323232323232232232253330063232323232533300b3370e900118061baa001132323232325333013301600213253330113370e6eb4c04c009200113371e00201e2940dd718088008b180a00099299980799b8748008c040dd50008a5eb7bdb1804dd5980a18089baa001323300100137566028602a602a602a602a60226ea8020894ccc04c004530103d87a80001323232325333014337220200042a66602866e3c0400084cdd2a4000660306e980052f5c02980103d87a80001330060060033756602a0066eb8c04c008c05c008c054004c048c04c008c044004c034dd50008b1807980800118070009807001180600098041baa00114984d958dd70009bae0015734aae7555cf2ab9f5740ae855d101", + version: "V3", + }); + expect(result.scriptHash).toEqual( + "b2386630f1b210c58d0e46f132e931b362c3f373685118018e4d956f", + ); + }); }); diff --git a/packages/mesh-core-cst/src/resolvers/index.ts b/packages/mesh-core-cst/src/resolvers/index.ts index 169113db3..c67007fb6 100644 --- a/packages/mesh-core-cst/src/resolvers/index.ts +++ b/packages/mesh-core-cst/src/resolvers/index.ts @@ -13,6 +13,7 @@ import { bech32 } from "bech32"; import { BuilderData, fromUTF8, + hashPlutusScript, mnemonicToEntropy, NativeScript, PlutusDataType, @@ -29,7 +30,6 @@ import { PoolId, } from "../types"; import { - deserializePlutusScript, deserializeTx, fromBuilderToPlutusData, toAddress, @@ -93,10 +93,13 @@ export const resolvePlutusScriptAddress = ( script: PlutusScript, networkId = 0, ): string => { - const plutusScript = deserializePlutusScript(script.code, script.version); + // Hash the serialised script bytes (ledger rule); the SDK object's + // hash() unwraps the CBOR byte string first and disagrees with + // Aiken/cardano-cli (see MeshJS/mesh#763). + const scriptHash = hashPlutusScript(script.code, script.version); const enterpriseAddress = EnterpriseAddress.fromCredentials(networkId, { - hash: plutusScript.hash(), + hash: Hash28ByteBase16(scriptHash), type: Cardano.CredentialType.ScriptHash, }); diff --git a/packages/mesh-core-cst/src/serializer/index.ts b/packages/mesh-core-cst/src/serializer/index.ts index d983069d6..65272247d 100644 --- a/packages/mesh-core-cst/src/serializer/index.ts +++ b/packages/mesh-core-cst/src/serializer/index.ts @@ -33,6 +33,7 @@ import { DeserializedAddress, DeserializedScript, fromUTF8, + hashPlutusScript, IDeserializer, IMeshTxSerializer, IResolver, @@ -300,7 +301,10 @@ export class CardanoSDKSerializer implements IMeshTxSerializer { } } return { - scriptHash: cardanoPlutusScript.hash().toString(), + // The ledger hashes the serialised script bytes as they appear in + // the CBOR byte string; the SDK object hashes the unwrapped bytes, + // which disagrees with Aiken/cardano-cli (see MeshJS/mesh#763). + scriptHash: hashPlutusScript(script.code, script.version), scriptCbor: cardanoPlutusScript.toCbor().toString(), }; }, diff --git a/packages/mesh-core-cst/test/resolvers.test.ts b/packages/mesh-core-cst/test/resolvers.test.ts index 88b034a2a..24534e88f 100644 --- a/packages/mesh-core-cst/test/resolvers.test.ts +++ b/packages/mesh-core-cst/test/resolvers.test.ts @@ -108,8 +108,11 @@ describe("resolvePlutusScriptAddress", () => { version: "V1", }; + // The address must embed the ledger script hash, taken over the + // serialised script bytes. The previous expected value embedded a + // hash of the CBOR-unwrapped bytes instead (see MeshJS/mesh#763). expect(resolvePlutusScriptAddress(script, 0)).toEqual( - "addr_test1wpnlxv2xv9a9ucvnvzqakwepzl9ltx7jzgm53av2e9ncv4sysemm8", + "addr_test1wrrdx3xfy2n7rnrcalp37852a8yeyu3pg7rk0lu0c6qvgaq8yg6l2", ); }); }); diff --git a/packages/mesh-core-cst/test/utils/serializer-utils.test.ts b/packages/mesh-core-cst/test/utils/serializer-utils.test.ts index c14f4e942..fb1e5c691 100644 --- a/packages/mesh-core-cst/test/utils/serializer-utils.test.ts +++ b/packages/mesh-core-cst/test/utils/serializer-utils.test.ts @@ -429,4 +429,18 @@ describe("Serialization utils", () => { "b2040523c8f8b1cf6888c7d5ffdfbb94b8bc2e1465f2ad891fdce923", ); }); + + it("should deserialize plutus script with the ledger script hash", () => { + // Aiken blueprint vector (giftcard redeem validator, Plutus V3): the + // hash must be taken over the serialised script bytes, not the + // CBOR-unwrapped bytes (see MeshJS/mesh#763). + const result = serializer.deserializer.script.deserializePlutusScript({ + code: "59011501010032323232323232232232253330063232323232533300b3370e900118061baa001132323232325333013301600213253330113370e6eb4c04c009200113371e00201e2940dd718088008b180a00099299980799b8748008c040dd50008a5eb7bdb1804dd5980a18089baa001323300100137566028602a602a602a602a60226ea8020894ccc04c004530103d87a80001323232325333014337220200042a66602866e3c0400084cdd2a4000660306e980052f5c02980103d87a80001330060060033756602a0066eb8c04c008c05c008c054004c048c04c008c044004c034dd50008b1807980800118070009807001180600098041baa00114984d958dd70009bae0015734aae7555cf2ab9f5740ae855d101", + version: "V3", + }); + + expect(result.scriptHash).toEqual( + "b2386630f1b210c58d0e46f132e931b362c3f373685118018e4d956f", + ); + }); }); diff --git a/packages/mesh-core/src/utils/resolver.ts b/packages/mesh-core/src/utils/resolver.ts index 6605aa38b..57364b793 100644 --- a/packages/mesh-core/src/utils/resolver.ts +++ b/packages/mesh-core/src/utils/resolver.ts @@ -1,5 +1,6 @@ import { BuilderData, + hashPlutusScript, LanguageVersion, NativeScript, PlutusDataType, @@ -58,7 +59,10 @@ export const resolveScriptHash = ( if (!version) { return core.deserializeNativeScript(scriptCode).hash().toString(); } - return core.deserializePlutusScript(scriptCode, version).hash().toString(); + // Hash the serialised script bytes (ledger rule); the deserialized + // script object's hash() unwraps the CBOR byte string first and + // disagrees with Aiken/cardano-cli (see MeshJS/mesh#763). + return hashPlutusScript(scriptCode, version); }; /** diff --git a/packages/mesh-core/src/utils/serializer.ts b/packages/mesh-core/src/utils/serializer.ts index f1e74925e..102181b46 100644 --- a/packages/mesh-core/src/utils/serializer.ts +++ b/packages/mesh-core/src/utils/serializer.ts @@ -1,6 +1,7 @@ import { BuilderData, DeserializedAddress, + hashPlutusScript, NativeScript, PlutusDataType, PlutusScript, @@ -56,10 +57,7 @@ export const serializePlutusScript = ( networkId = 0, isScriptStakeCredential = false, ) => { - const scriptHash = core - .deserializePlutusScript(script.code, script.version) - .hash() - .toString(); + const scriptHash = hashPlutusScript(script.code, script.version); const address = core.scriptHashToBech32( scriptHash, stakeCredentialHash, diff --git a/packages/mesh-core/test/utils.test.ts b/packages/mesh-core/test/utils.test.ts index 80a74ab18..4371e6a3c 100644 --- a/packages/mesh-core/test/utils.test.ts +++ b/packages/mesh-core/test/utils.test.ts @@ -1,4 +1,4 @@ -import { deserializePoolId } from "../src"; +import { deserializePoolId, resolveScriptHash } from "../src"; describe("Util tests", () => { it("should correctly deserialize a pool id", () => { @@ -8,4 +8,16 @@ describe("Util tests", () => { ), ).toBe("f890ff23ad69119de934c5f4f231ff7a154517a93fae043b48b01bb8"); }); + + it("should resolve a Plutus V3 script hash matching its Aiken blueprint", () => { + // Giftcard redeem validator from mesh-contract's Aiken workspace. + // The ledger hashes the serialised script bytes; hashing the + // CBOR-unwrapped bytes instead gives a different hash (see #763). + expect( + resolveScriptHash( + "59011501010032323232323232232232253330063232323232533300b3370e900118061baa001132323232325333013301600213253330113370e6eb4c04c009200113371e00201e2940dd718088008b180a00099299980799b8748008c040dd50008a5eb7bdb1804dd5980a18089baa001323300100137566028602a602a602a602a60226ea8020894ccc04c004530103d87a80001323232325333014337220200042a66602866e3c0400084cdd2a4000660306e980052f5c02980103d87a80001330060060033756602a0066eb8c04c008c05c008c054004c048c04c008c044004c034dd50008b1807980800118070009807001180600098041baa00114984d958dd70009bae0015734aae7555cf2ab9f5740ae855d101", + "V3", + ), + ).toBe("b2386630f1b210c58d0e46f132e931b362c3f373685118018e4d956f"); + }); }); diff --git a/packages/mesh-transaction/src/transaction/index.ts b/packages/mesh-transaction/src/transaction/index.ts index bbf30cffb..2956dbf63 100644 --- a/packages/mesh-transaction/src/transaction/index.ts +++ b/packages/mesh-transaction/src/transaction/index.ts @@ -6,6 +6,7 @@ import { CIP68_222, Data, DEFAULT_REDEEMER_BUDGET, + hashPlutusScript, hexToString, IInitiator, metadataToCip68, @@ -26,7 +27,6 @@ import { Cardano, CardanoSDKUtil, deserializeNativeScript, - deserializePlutusScript, deserializeTx, fromScriptRef, Serialization, @@ -397,12 +397,7 @@ export class Transaction { ); if ("code" in forgeScript) { // Burn plutus script assets with provided script - policyId = deserializePlutusScript( - forgeScript.code, - forgeScript.version, - ) - .hash() - .toString(); + policyId = hashPlutusScript(forgeScript.code, forgeScript.version); this.isCollateralNeeded = true; this.mintPlutusScript(forgeScript) @@ -428,9 +423,7 @@ export class Transaction { } if ("code" in script) { - policyId = deserializePlutusScript(script.code, script.version) - .hash() - .toString(); + policyId = hashPlutusScript(script.code, script.version); this.isCollateralNeeded = true; this.mintPlutusScript(script)