Skip to content
Open
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
2 changes: 1 addition & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ untouched and the helpers are optional and swappable. This is the sibling of §2
## 3. Testing

- [x] `P1` ~~Add JS integration tests for the script/Plutus paths.~~ **Done (and the item's premise was superseded by the TxPlan refactor):** the old fluent `ScriptTxBuilder` / `collectFromScript` / `mintPlutusAssets` / `readFrom` API was deleted — script/Plutus paths are now TxPlan YAML fixtures, covered at the build level in `test/intents.e2e.test.js`: all 20 top-level intents (incl. `reference_input`, `compose`, `native_script`) plus the three `plutus/` fixtures — **mint**, **spend**, and **lock** — each asserting non-empty CBOR + 64-char hash + positive fee, that mint/spend **require** caller-supplied exec units (build throws without them), and that `plutus.dataHash` reproduces the lock fixture's datum hash. Node-level (DevKit): a Plutus-mint **build → sign → submit → assert the minted asset landed on-chain** round-trip in `test/quicktx.integration.test.js`, mirroring Go's `TestIntegrationPlutusMint`.
- [ ] `P1` **Fix JS cost-model key ordering for Plutus builds.** Surfaced while adding the JS Plutus-mint submit test: passing the cost models fetched from a Blockfrost-style provider (`/epochs/parameters` returns them as a map keyed by zero-padded indices `"000".."165"`) into a Plutus `build()` yields a tx the node rejects with `PPViewHashesDontMatch` — JS's JSON parse reorders the non-padded integer-like keys (`"100".."165"`) ahead of the padded ones, scrambling the cost-model order vs the ledger's canonical order and corrupting the script-integrity hash. (Go's `json.Marshal` sorts keys lexicographically, which for zero-padded keys equals numeric order, so the Go path is unaffected.) The integration test works around it by dropping the fetched cost models so the lib uses its built-in standard set; the real fix is to preserve canonical cost-model order in the JS wrapper before handing params to the native lib. Likely affects the §2c provider helpers, which will fetch and pass these params.
- [x] `P1` ~~**Fix JS cost-model key ordering for Plutus builds.**~~ **Done.** Passing cost models fetched from a Blockfrost-style provider (`/epochs/parameters` returns them as a map keyed by zero-padded indices `"000".."165"`) into a Plutus `build()` yielded a tx the node rejected with `PPViewHashesDontMatch` — JS's JSON parse reorders the non-padded integer-like keys (`"100".."165"`) ahead of the padded ones, scrambling the cost-model order vs the ledger's canonical order and corrupting the script-integrity hash. (Go's `json.Marshal` sorts keys lexicographically, which for zero-padded keys equals numeric order, so Go is unaffected; Python preserves the provider's order.) Fixed in the JS wrapper (`normalizeCostModels` in `wrappers/js/src/index.js`): numerically-keyed cost models are converted to CCL's ordered `cost_models_raw` array form (a `List<Long>` CCL consumes in order, ahead of the order-sensitive named map), which serializes order-stably. The Plutus-mint DevKit round-trip now submits with the devnet's real fetched cost models (no workaround), and unit tests cover the conversion. _(Other wrappers are unaffected, but the §2c provider helpers should pass params through an equivalent normalization.)_
- [ ] `P1` Raise Go and Rust test breadth toward Python's (~100 cases vs ~61); port Python's per-module unit tests.
- [ ] `P1` Add a cross-wrapper parity test matrix asserting every `@CEntryPoint` is exercised in every language.
- [ ] `P2` Run the Yaci DevKit integration tests in CI (containerized DevKit) instead of skip-if-not-running.
Expand Down
40 changes: 39 additions & 1 deletion wrappers/js/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,44 @@ function cstr(str) {
return Buffer.from(str + '\0', 'utf-8');
}

// Protocol-params providers (e.g. Yaci DevKit / Blockfrost-style backends) return Plutus cost models
// as a map keyed by numeric indices, e.g. {"PlutusV2": {"0": 100788, ..., "165": 10}}. JavaScript
// object semantics iterate canonical integer-string keys ("100") in ascending order *before*
// zero-padded ones ("000"), so JSON.stringify emits them out of order. CCL reads the cost-model
// entries in document order to rebuild the per-language cost array, so this scrambled order yields a
// wrong script-integrity hash and the node rejects the tx with PPViewHashesDontMatch. (Go/Python are
// unaffected: Go's json.Marshal sorts keys, and Python preserves the provider's order.)
//
// Fix: convert any numerically-keyed language into CCL's ordered `cost_models_raw` array form (a
// List<Long> CCL consumes directly, in order), which JSON serializes order-stably. Languages with
// named-operation keys (which JS does not reorder) are left as a cost_models map untouched.
export function normalizeCostModels(protocolParams) {
if (!protocolParams || typeof protocolParams !== 'object') return protocolParams;
const costModels = protocolParams.cost_models ?? protocolParams.costModels;
if (!costModels || typeof costModels !== 'object') return protocolParams;

const raw = {};
const named = {};
let converted = false;
for (const [lang, model] of Object.entries(costModels)) {
const keys = model && typeof model === 'object' && !Array.isArray(model) ? Object.keys(model) : null;
if (keys && keys.length > 0 && keys.every((k) => /^\d+$/.test(k))) {
raw[lang] = keys.sort((a, b) => Number(a) - Number(b)).map((k) => model[k]);
converted = true;
} else {
named[lang] = model;
}
}
if (!converted) return protocolParams;

const out = { ...protocolParams };
delete out.cost_models;
delete out.costModels;
if (Object.keys(named).length > 0) out.cost_models = named;
out.cost_models_raw = { ...(protocolParams.cost_models_raw ?? {}), ...raw };
return out;
}

export class CclBridge {
constructor(libPath) {
if (!libPath) {
Expand Down Expand Up @@ -380,7 +418,7 @@ class QuickTxApi {
this._b._thread,
cstr(txplanYaml),
cstr(JSON.stringify(utxos)),
cstr(JSON.stringify(protocolParams)),
cstr(JSON.stringify(normalizeCostModels(protocolParams))),
execUnits != null ? cstr(JSON.stringify(execUnits)) : null,
);
// The build result is a YAML document.
Expand Down
40 changes: 39 additions & 1 deletion wrappers/js/test/ccl.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { CclBridge, CclError, MAINNET, TESTNET } from '../src/index.js';
import { CclBridge, CclError, MAINNET, TESTNET, normalizeCostModels } from '../src/index.js';

// A known valid transaction CBOR hex (built from Java tests)
const SAMPLE_TX_CBOR = '84a300d901028182582073198b7ad003862b9798106b88fbccfca464b1a38afb34958275c4a7d7d8d002010181825839009493315cd92eb5d8c4304e67b7e16ae36d61d34502694657811a2c8e32c728d3861e164cab28cb8f006448139c8f1740ffb8e7aa9e5232dc1a001e8480021a00029810a0f5f6';
Expand Down Expand Up @@ -435,3 +435,41 @@ transaction:
expect(bridge.crypto.validateMnemonic('zzz xxx yyy www vvv uuu ttt sss rrr qqq ppp ooo')).toBe(false);
});
});

// normalizeCostModels: numerically-keyed cost models (Yaci DevKit / Blockfrost-style providers) must
// be converted to the ordered cost_models_raw array form, because JS object iteration reorders the
// canonical integer-string keys ahead of the zero-padded ones, which would corrupt the Plutus
// script-integrity hash. See the comment on normalizeCostModels in src/index.js.
describe('normalizeCostModels', () => {
it('converts numeric-keyed cost models to ordered cost_models_raw arrays', () => {
// 0..120 inclusive: spans zero-padded keys ("000".."099") and canonical-integer keys
// ("100".."120"), which is exactly the pair JS would otherwise reorder.
const model = {};
for (let i = 0; i <= 120; i++) model[String(i).padStart(3, '0')] = 1000 + i;

const out = normalizeCostModels({ min_fee_a: 44, cost_models: { PlutusV2: model } });

// Array is in ascending numeric-key order regardless of the source object's iteration order.
expect(out.cost_models_raw.PlutusV2).toHaveLength(121);
expect(out.cost_models_raw.PlutusV2[0]).toBe(1000);
expect(out.cost_models_raw.PlutusV2[100]).toBe(1100);
expect(out.cost_models_raw.PlutusV2[120]).toBe(1120);
expect(out.cost_models_raw.PlutusV2).toEqual(
Array.from({ length: 121 }, (_, i) => 1000 + i));
// The numeric-keyed cost_models map is removed once converted; other params are preserved.
expect(out.cost_models).toBeUndefined();
expect(out.min_fee_a).toBe(44);
});

it('leaves named-operation cost models (which JS does not reorder) as a cost_models map', () => {
const named = { 'addInteger-cpu-arguments-intercept': 205665, 'addInteger-cpu-arguments-slope': 812 };
const out = normalizeCostModels({ cost_models: { PlutusV2: named } });
expect(out.cost_models.PlutusV2).toEqual(named);
expect(out.cost_models_raw).toBeUndefined();
});

it('passes through params with no cost models unchanged', () => {
const pp = { min_fee_a: 44, min_fee_b: 155381 };
expect(normalizeCostModels(pp)).toEqual(pp);
});
});
18 changes: 5 additions & 13 deletions wrappers/js/test/quicktx.integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,15 +156,10 @@ transaction:
// accepted" alone doesn't prove the script ran and minted — the receiver holding a non-lovelace
// asset does. Mirrors the Go TestIntegrationPlutusMint.
//
// We build WITHOUT the devnet's fetched cost models, so the native lib uses its built-in standard
// Conway cost models (which the devnet runs). Passing DevKit's fetched cost models instead is
// rejected with PPViewHashesDontMatch: /epochs/parameters returns them as a map keyed by
// zero-padded indices ("000".."165"), and JS's JSON parse reorders the non-padded integer-like
// keys ("100".."165") ahead of the padded ones, scrambling the cost-model order vs the ledger's
// canonical order and corrupting the script-integrity hash. Go's lexicographic map marshalling
// preserves the order, which is why its equivalent test passes with the fetched params. Threading
// fetched cost models through to Plutus builds needs an order-preserving fix in the wrapper —
// tracked as a follow-up in TODO.md §3.
// This passes the devnet's fetched protocol parameters *including* its cost models, exercising the
// wrapper's cost-model ordering fix (normalizeCostModels): the devnet returns cost models keyed by
// numeric indices, and without the fix JS reorders those keys and the node rejects the tx with
// PPViewHashesDontMatch (a script-integrity hash mismatch).
it("should build, sign, and submit a Plutus mint", async () => {
if (skip) return;

Expand All @@ -177,10 +172,7 @@ transaction:
const pp = await devkit.getProtocolParams();
const yaml = readFileSync(join(FIXTURES, "plutus", "script_minting.yaml"), "utf8");

const ppForBuild = { ...pp };
for (const k of ["cost_models", "costModels", "cost_mdls", "costMdls"]) delete ppForBuild[k];

const result = bridge.quicktx.build(yaml, utxos, ppForBuild, [{ mem: 2000000, steps: 500000000 }]);
const result = bridge.quicktx.build(yaml, utxos, pp, [{ mem: 2000000, steps: 500000000 }]);
expect(result.tx_hash.length).toBe(64);

const signedTx = bridge.account.signTxWithKeys(INTENT_MNEMONIC, TESTNET, 0, 0, result.tx_cbor, ["payment"]);
Expand Down
Loading