From 3da2a78122a9344f1cf23805c36c22b5c603aab9 Mon Sep 17 00:00:00 2001 From: Kyle Cox Date: Wed, 7 Oct 2026 00:56:40 -0500 Subject: [PATCH] fix: hash Plutus scripts over serialised bytes (ledger rule), not CBOR-unwrapped bytes resolveScriptHash, deserializePlutusScript (both serializer backends), resolvePlutusScriptAddress, serializePlutusScript and the deprecated Transaction mint/burn policy id derivation all took the script hash from the deserialized script object, whose hash() unwraps the CBOR byte string and hashes only the inner UPLC bytes. The ledger hashes the serialised script bytes exactly as they appear in the CBOR byte string: blake2b_224(language_tag || script_cbor_bytes). The results therefore disagreed with Aiken blueprint hashes and cardano-cli, deriving wrong script addresses and policy ids (funds sent to such an address are locked under a script that does not exist there). Adds hashPlutusScript() in @meshsdk/common implementing the ledger rule and uses it at every affected site in mesh-core, mesh-core-cst, mesh-core-csl and mesh-transaction. Script CBOR serialization (toCbor/scriptRef/witness paths) is unchanged. Tests: Aiken blueprint vectors from mesh-contract's giftcard workspace (mint 401c9670..., redeem b2386630...) now hash correctly in mesh-common, mesh-core, mesh-core-cst and mesh-core-csl suites; the mesh-core-cst resolvePlutusScriptAddress expectation that encoded the old unwrapped-bytes hash is corrected with a note. Fixes #763 --- packages/mesh-common/src/utils/data-hash.ts | 43 +++++++++++++++++++ .../mesh-common/test/utils/data-hash.test.ts | 34 ++++++++++++++- packages/mesh-core-csl/src/core/serializer.ts | 9 ++-- packages/mesh-core-csl/src/deser/resolver.ts | 9 ++-- .../test/utils/serializer.test.ts | 13 ++++++ packages/mesh-core-cst/src/resolvers/index.ts | 9 ++-- .../mesh-core-cst/src/serializer/index.ts | 6 ++- packages/mesh-core-cst/test/resolvers.test.ts | 5 ++- .../test/utils/serializer-utils.test.ts | 14 ++++++ packages/mesh-core/src/utils/resolver.ts | 6 ++- packages/mesh-core/src/utils/serializer.ts | 6 +-- packages/mesh-core/test/utils.test.ts | 14 +++++- .../mesh-transaction/src/transaction/index.ts | 13 ++---- 13 files changed, 152 insertions(+), 29 deletions(-) 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)