diff --git a/TODO.md b/TODO.md index efebb95..5816907 100644 --- a/TODO.md +++ b/TODO.md @@ -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` 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. diff --git a/wrappers/js/src/index.js b/wrappers/js/src/index.js index 9aaf02c..6abf118 100644 --- a/wrappers/js/src/index.js +++ b/wrappers/js/src/index.js @@ -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 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) { @@ -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. diff --git a/wrappers/js/test/ccl.test.js b/wrappers/js/test/ccl.test.js index b403566..5e3e5b1 100644 --- a/wrappers/js/test/ccl.test.js +++ b/wrappers/js/test/ccl.test.js @@ -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'; @@ -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); + }); +}); diff --git a/wrappers/js/test/quicktx.integration.test.js b/wrappers/js/test/quicktx.integration.test.js index 3a7d5aa..6e104b4 100644 --- a/wrappers/js/test/quicktx.integration.test.js +++ b/wrappers/js/test/quicktx.integration.test.js @@ -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; @@ -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"]);