Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/mesh-core-csl/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from "./utils";
export * from "./core";
export * from "./deser";
export * from "./offline-providers";
export * from "./tx-prototype";
117 changes: 117 additions & 0 deletions packages/mesh-core-csl/src/tx-prototype/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { js_tx_prototype_to_hex } from "@sidan-lab/whisky-js-nodejs";
import JSONbig from "json-bigint";

import type { TransactionPrototype } from "@meshsdk/common";

/**
* Serializes a `TransactionPrototype` (`@meshsdk/common`) to transaction CBOR hex via whisky's
* `js_tx_prototype_to_hex` WASM entry point.
*
* Uses `JSONbig.stringify`, not `JSON.stringify`, and that is load-bearing rather than stylistic:
* `TransactionPrototype` carries `bigint` on every field whose Conway CDDL range exceeds
* `Number.MAX_SAFE_INTEGER` (`PlutusDataPrototype`'s `INTEGER.value`/`CONSTR.alternative`,
* `MetadatumPrototype`'s `INT.value`, `ScriptNOfKPrototype.n`, `PoolRetirementPrototype.epoch`,
* `CommitteeMemberPrototype.term_limit`). Plain `JSON.stringify` throws outright on those
* (`TypeError: Do not know how to serialize a BigInt`), and the obvious `(k, v) => Number(v)`
* replacer would reintroduce exactly the precision loss the `bigint` typing exists to prevent.
* `JSONbig.stringify` emits them as unquoted JSON number literals, which is what serde on the
* Rust side expects. Same reason `mesh-core-csl/src/core/serializer.ts` already uses it for the
* `js_serialize_tx_body` boundary.
*
* BLOCKING whisky BUG — `i128 is not supported`
* ---------------------------------------------
* whisky types both `PlutusData::Integer { value }` and `Metadatum::Int { value }` as Rust
* `i128`, and `serde_json` refuses to deserialize `i128` unless built with its
* `arbitrary_precision` feature (which whisky's WASM build is not). So `js_tx_prototype_to_hex`
* rejects, with `Invalid TransactionPrototype JSON: Error("i128 is not supported")`:
* - ANY inline/manual Plutus datum containing an integer, and
* - ANY transaction metadata containing an integer.
* This is not a large-value problem: it was verified to fail for `value: 0`, serialized with a
* plain `JSON.stringify` and a plain `number`. Nothing on this side can work around it — the
* failure is in whisky's deserialize step, before any of our encoding matters.
*
* WORKAROUND that does work today: use the `{ type: "CBOR", hex }` arm of `PlutusDataVariant`
* instead of `{ type: "MANUAL", data }`. That bypasses whisky's `PlutusData` enum (and hence the
* `i128` field) entirely, and is verified to serialize successfully. Non-integer `MANUAL` arms
* (`BYTES`, `LIST`, `MAP`, `CONSTR` with no integer inside) also work.
*
* Until whisky is patched, `mesh-core-cst/src/tx-prototype-to-cbor/` is the only backend that
* handles the full `TransactionPrototype` surface — it is pure TypeScript with no JSON/serde
* boundary, so none of this applies there.
*
* SET ENCODING: whisky always emits the Conway `#6.258`-tagged form for every CBOR set, with no
* option to disable it (verified: a body with inputs + collateral + reference inputs + required
* signers comes back with all four tagged). That matches the default of
* `mesh-core-cst`'s `transactionPrototypeToHex`, so the two backends agree out of the box. If you
* specifically need untagged sets, only the CST converter can produce them
* (`transactionPrototypeToHex(proto, { taggedSets: false })`).
*
* Separately, whisky's `ScriptNOfKPrototype.n`, `PoolRetirementPrototype.epoch` and
* `CommitteeMemberPrototype.term_limit` are `u32` where the ledger allows `int64`/`uint .size 8`,
* and `PlutusData::Integer` is `i128` where the ledger's `big_int` is effectively
* arbitrary-precision. CDDL-legal values beyond those bounds are representable in
* `TransactionPrototype` and handled correctly by the `mesh-core-cst` converter, but serde will
* reject them here rather than truncate.
*/
/**
* whisky's Rust structs carry a single, version-less `plutus_scripts: Vec<String>` on both the
* witness set and the auxiliary data, whereas the Conway CDDL (and therefore
* `TransactionPrototype`) splits them across three keys — witness-set 3/6/7 and
* auxiliary-data 2/3/4 — because a Plutus script's language version is not recoverable from its
* bytes. There is no lossless mapping: sending only `plutus_v1_scripts` would silently drop V2/V3
* scripts, so the three lists are concatenated into whisky's single field. That means whisky
* treats every script as though it were the version its own converter assumes, and a V2/V3 script
* routed through this backend will be mis-tagged in the resulting CBOR.
*
* Rather than let that corrupt a transaction silently, this throws when V2/V3 scripts are present.
* Use `mesh-core-cst/src/tx-prototype-to-cbor/` (which maps all three keys correctly) for those.
*/
const toWhiskyWireShape = (prototype: TransactionPrototype) => {
const ws = prototype.witness_set;
const aux = prototype.auxiliary_data;

const misTagged =
(ws.plutus_v2_scripts?.length ?? 0) +
(ws.plutus_v3_scripts?.length ?? 0) +
(aux?.plutus_v2_scripts?.length ?? 0) +
(aux?.plutus_v3_scripts?.length ?? 0);
if (misTagged > 0) {
throw new Error(
"serializeTxPrototype error: whisky's tx_prototype has a single version-less " +
"`plutus_scripts` field and cannot represent PlutusV2/V3 scripts without mis-tagging " +
"them. Use the mesh-core-cst converter (tx-prototype-to-cbor) for these transactions.",
);
}

const flatten = (v1?: string[] | null) => (v1?.length ? v1 : undefined);

return {
...prototype,
witness_set: {
...ws,
plutus_v1_scripts: undefined,
plutus_v2_scripts: undefined,
plutus_v3_scripts: undefined,
plutus_scripts: flatten(ws.plutus_v1_scripts),
},
...(aux
? {
auxiliary_data: {
...aux,
plutus_v1_scripts: undefined,
plutus_v2_scripts: undefined,
plutus_v3_scripts: undefined,
plutus_scripts: flatten(aux.plutus_v1_scripts),
},
}
: {}),
};
};

export const serializeTxPrototype = (prototype: TransactionPrototype): string => {
const result = js_tx_prototype_to_hex(JSONbig.stringify(toWhiskyWireShape(prototype)));
if (result.get_status() !== "success") {
throw new Error(`serializeTxPrototype error: ${result.get_error()}`);
}
return result.get_data();
};
111 changes: 111 additions & 0 deletions packages/mesh-core-csl/test/tx-prototype/serialize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import JSONbig from "json-bigint";

import type { TransactionPrototype } from "@meshsdk/common";

import { serializeTxPrototype } from "../../src/tx-prototype";

const TX_HASH = "11".repeat(32);
const ADDRESS =
"addr_test1qpvx0sacufuypa2k4sngk7q40zc5c4npl337uusdh64kv0uafhxhu32dys6pvn6wlw8dav6cmp4pmtv7cc3yel9uu0nq93swx9";

const minimal = (): TransactionPrototype => ({
body: {
fee: "170000",
inputs: [{ transaction_id: TX_HASH, index: 0 }],
outputs: [{ address: ADDRESS, amount: { coin: "5000000" } }],
},
is_valid: true,
witness_set: {},
});

describe("serializeTxPrototype", () => {
it("serializes a minimal prototype to CBOR hex", () => {
const hex = serializeTxPrototype(minimal());
expect(typeof hex).toEqual("string");
expect(hex.length).toBeGreaterThan(0);
expect(/^[0-9a-f]+$/i.test(hex)).toBe(true);
});

const withDatum = (value: TransactionPrototype["body"]["outputs"][number]["plutus_data"]) => ({
...minimal(),
body: {
...minimal().body,
outputs: [{ address: ADDRESS, amount: { coin: "5000000" }, plutus_data: value }],
},
});

// Documents a BLOCKING whisky bug, not desired behaviour: whisky types PlutusData::Integer and
// Metadatum::Int as Rust i128, and serde_json cannot deserialize i128 without its
// `arbitrary_precision` feature. Verified to fail for value: 0 — it is not a large-value issue,
// and nothing on this side can work around it. If whisky is ever patched, these two tests
// should start failing and must be inverted.
it("REJECTS any manual integer datum — whisky i128/serde_json limitation", () => {
expect(() =>
serializeTxPrototype(
withDatum({ type: "DATA", value: { type: "MANUAL", data: { type: "INTEGER", value: 0n } } }),
),
).toThrow(/i128 is not supported/);
});

it("REJECTS integer transaction metadata — same whisky i128 limitation", () => {
const proto: TransactionPrototype = {
...minimal(),
auxiliary_data: { metadata: { "674": { type: "INT", value: 1n } }, prefer_alonzo_format: true },
};
expect(() => serializeTxPrototype(proto)).toThrow(/i128 is not supported/);
});

it("accepts a CBOR-variant datum — the working workaround for the i128 bug", () => {
const hex = serializeTxPrototype(withDatum({ type: "DATA", value: { type: "CBOR", hex: "00" } }));
expect(/^[0-9a-f]+$/i.test(hex)).toBe(true);
});

it("accepts non-integer MANUAL arms (BYTES), so only the integer arm is affected", () => {
const hex = serializeTxPrototype(
withDatum({ type: "DATA", value: { type: "MANUAL", data: { type: "BYTES", value: "cafe" } } }),
);
expect(hex.toLowerCase()).toContain("cafe");
});

// The prototype follows the CDDL's three version-specific plutus-script keys; whisky's wire
// shape has only one version-less `plutus_scripts`. V1-only is mappable; V2/V3 are not, and
// must fail loudly rather than be silently mis-tagged in the output CBOR.
it("maps a V1-only witness set onto whisky's single plutus_scripts field", () => {
const proto: TransactionPrototype = {
...minimal(),
witness_set: { plutus_v1_scripts: ["4d01000033222220051200120011"] },
};
expect(/^[0-9a-f]+$/i.test(serializeTxPrototype(proto))).toBe(true);
});

it.each(["plutus_v2_scripts", "plutus_v3_scripts"] as const)(
"refuses to mis-tag %s through whisky's version-less field",
(field) => {
const proto: TransactionPrototype = {
...minimal(),
witness_set: { [field]: ["4d01000033222220051200120011"] },
};
expect(() => serializeTxPrototype(proto)).toThrow(/cannot represent PlutusV2\/V3/);
},
);

it("throws a descriptive error when whisky rejects the payload", () => {
const broken = {
body: { fee: "not-a-number", inputs: [], outputs: [] },
is_valid: true,
witness_set: {},
} as unknown as TransactionPrototype;
expect(() => serializeTxPrototype(broken)).toThrow(/serializeTxPrototype error/);
});
});

describe("JSONbig vs JSON (the reason this module exists)", () => {
it("plain JSON.stringify cannot serialize the prototype's bigint fields at all", () => {
expect(() => JSON.stringify({ value: 1n })).toThrow(TypeError);
});

it("JSONbig emits bigints as unquoted JSON numbers, preserving full precision", () => {
const out = JSONbig.stringify({ value: 18446744073709551615n });
expect(out).toEqual('{"value":18446744073709551615}');
});
});
2 changes: 2 additions & 0 deletions packages/mesh-core-cst/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export * from "./serializer";
export * from "./utils";
export * from "./plutus-tools";
export * from "./offline-providers";
export * from "./tx-prototype-to-cbor";
export * from "./tx-prototype-from-cbor";

export * as CardanoSDKUtil from "@cardano-sdk/util";
export * as Crypto from "@cardano-sdk/crypto";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Serialization } from "@cardano-sdk/core";

import type {
AuxiliaryDataPrototype,
MetadatumPrototype,
TxMetadataPrototype,
} from "@meshsdk/common";

import { AuxiliaryData, TransactionMetadatum } from "../types";
import { nativeScriptToPrototype } from "./native-script";

const { TransactionMetadatumKind } = Serialization;

const metadatumToPrototype = (metadatum: TransactionMetadatum): MetadatumPrototype => {
switch (metadatum.getKind()) {
case TransactionMetadatumKind.Integer:
return { type: "INT", value: metadatum.asInteger()! };
case TransactionMetadatumKind.Bytes:
return { type: "BYTES", value: [...metadatum.asBytes()!] };
case TransactionMetadatumKind.Text:
return { type: "STRING", value: metadatum.asText()! };
case TransactionMetadatumKind.List: {
const list = metadatum.asList()!;
const value: MetadatumPrototype[] = [];
for (let i = 0; i < list.getLength(); i++) value.push(metadatumToPrototype(list.get(i)));
return { type: "LIST", value };
}
case TransactionMetadatumKind.Map: {
const map = metadatum.asMap()!;
const keys = map.getKeys();
const value: [MetadatumPrototype, MetadatumPrototype][] = [];
for (let i = 0; i < keys.getLength(); i++) {
const key = keys.get(i);
value.push([metadatumToPrototype(key), metadatumToPrototype(map.get(key)!)]);
}
return { type: "MAP", value };
}
default:
throw new Error(`Unsupported metadatum kind: ${metadatum.getKind()}`);
}
};

/** Inverse of `../tx-prototype-to-cbor/auxiliary-data.ts`. */
export const auxiliaryDataToPrototype = (aux: AuxiliaryData): AuxiliaryDataPrototype => {
const result: AuxiliaryDataPrototype = { prefer_alonzo_format: true };

const metadata = aux.metadata();
if (metadata) {
const entries = metadata.metadata();
if (entries && entries.size > 0) {
const out: TxMetadataPrototype = {};
for (const [label, value] of entries) {
out[label.toString()] = metadatumToPrototype(value);
}
result.metadata = out;
}
}

const nativeScripts = aux.nativeScripts();
if (nativeScripts?.length) {
result.native_scripts = nativeScripts.map(nativeScriptToPrototype);
}

// Auxiliary-data map keys 2 / 3 / 4, one per Plutus language version.
const v1 = aux.plutusV1Scripts();
if (v1?.length) result.plutus_v1_scripts = v1.map((s) => s.toCbor().toString());
const v2 = aux.plutusV2Scripts();
if (v2?.length) result.plutus_v2_scripts = v2.map((s) => s.toCbor().toString());
const v3 = aux.plutusV3Scripts();
if (v3?.length) result.plutus_v3_scripts = v3.map((s) => s.toCbor().toString());

return result;
};
Loading
Loading