test(signer): port sign.test.ts's indefinite-length CBOR fixture to decode.test.ts

decode.ts's trailing-bytes consumption check re-serialises a parsed
transaction and compares it against the input, refusing on any mismatch.
That check relies on CML's parse-then-reserialise round trip preserving the
agent registry's indefinite-length CBOR constructor encoding (D8 79 9F ...
FF, required by the Conway-era Aiken validators; Lucid's Data.to emits the
definite-length form instead, which those validators reject) rather than
silently canonicalising it -- if it did not, the consumption check would
misread that harmless re-encoding as unrecognised trailing data and refuse
every transaction using this ecosystem's own real encoding.

sign.test.ts already pins this exact claim for sign.ts's own, independent
from_cbor_hex call, and its own header comment flagged decode.ts as having
"the exact same untested gap" -- out of that task's file scope, left for a
later task. This is that closure: the same fixture (built with the same
cborBytes/cborUint helpers, embedded both as a body datum and a witness-set
datum) ported to exercise decodeTransaction directly.

Verified by mutation (swapping the comparison to tx.to_canonical_cbor_hex()):
probed directly first and confirmed canonical re-serialisation really does
rewrite this fixture's bytes, then confirmed exactly the 4 tests that call
decodeTransaction() on it fail, while the sanity test and the byte-survival
test (neither of which call decodeTransaction) are unaffected -- see the
task report.

Final whole-branch review, Fix 3b of 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
FilipBl4gojevic
2026-07-28 18:32:42 +02:00
parent c464b5b0c5
commit 77d63f15ef

View File

@@ -2,6 +2,15 @@ import { describe, test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as CML from '@anastasia-labs/cardano-multiplatform-lib-nodejs'; import * as CML from '@anastasia-labs/cardano-multiplatform-lib-nodejs';
import { decodeTransaction } from '../src/decode.ts'; import { decodeTransaction } from '../src/decode.ts';
// /tx is an explicitly allowed pure subpath (boundary.test.ts's own
// ALLOWLIST — /tx and /types are pure, /provider is network-capable). Used
// below to build the exact same indefinite-length CBOR constructor shape
// deriveNftAssetName() builds, via the same helper functions sign.test.ts
// already uses for the identical fixture, rather than a hand-rolled
// reimplementation that could subtly diverge from the real encoding this
// ecosystem actually produces. Test-only import — decode.ts itself imports
// nothing beyond CML.
import { cborBytes, cborUint } from '@apexfusion/vector-mcp-shared/tx';
// Real unsigned transaction from Vector testnet. 1 input, 2 outputs // Real unsigned transaction from Vector testnet. 1 input, 2 outputs
// (one foreign, one change back to OWN), fee 168223. // (one foreign, one change back to OWN), fee 168223.
@@ -184,3 +193,129 @@ describe('native asset units — asset names at and beyond the 24-byte CBOR-head
assert.deepEqual(quantities, [1_000n, 2_000n, 3_000n]); assert.deepEqual(quantities, [1_000n, 2_000n, 3_000n]);
}); });
}); });
// Ported from sign.test.ts, which covers this exact shape for sign.ts's own
// (separate) from_cbor_hex call. decode.ts runs the identical
// re-serialise-and-compare consumption check over the same encodings — see
// the long comment above decodeTransaction() in decode.ts — but had no test
// of its own exercising it against indefinite-length CBOR; sign.test.ts's
// own header comment flagged this as an out-of-scope gap for a later task to
// close. This is that closure.
describe('indefinite-length CBOR — the agent registry\'s D8 79 9F … FF constructor encoding', () => {
// packages/shared/src/tx.ts's deriveNftAssetName() documents that the
// agent registry's Conway-era Aiken validators require INDEFINITE-length
// CBOR for a Plutus constructor's field array (D8 79 9F … FF) — Lucid's
// Data.to instead emits definite-length arrays (D8 79 82 …), which those
// validators reject. decode.ts's consumption check asserts in a comment
// that CML's parse-then-reserialise round trip preserves this exact
// encoding rather than silently canonicalising it to definite-length —
// which matters because if it did NOT, the consumption check would misread
// that harmless re-encoding as "unrecognised trailing data" and refuse to
// decode every transaction using this ecosystem's own real encoding. That
// claim was asserted only in prose until now; this fixture makes it an
// executable, permanent check for decode.ts specifically (sign.test.ts
// already covers the equivalent claim for sign.ts's own, independent
// from_cbor_hex call).
//
// Built using the SAME helper functions (cborBytes, cborUint) that
// packages/shared/src/tx.ts's deriveNftAssetName() itself uses to build its
// OutputReference constructor, for maximum fidelity to the real encoding.
function indefiniteLengthConstructor(): Buffer {
// Same shape as deriveNftAssetName's outRefCbor: tag 121 (Plutus
// constructor index 0), indefinite-length array open, one bytestring
// field (reusing FIXTURE_CBOR's own input tx hash, so this is a
// realistic 32-byte field rather than arbitrary filler), one small uint
// field, indefinite-length array close.
const someHash32 = 'e17a2ebab8eae3850f959290041b3bef3f3597584f8cf4a728e14777dddb8c3';
return Buffer.concat([
Buffer.from([0xd8, 0x79, 0x9f]),
cborBytes(someHash32),
cborUint(0n),
Buffer.from([0xff]),
]);
}
test('sanity: the hand-rolled bytes are actually the D8 79 9F … FF shape under test', () => {
const hex = indefiniteLengthConstructor().toString('hex');
assert.ok(hex.startsWith('d8799f'), 'must open with tag 121 + indefinite-length array');
assert.ok(hex.endsWith('ff'), 'must close with the indefinite-length-array break byte');
});
describe('embedded in the transaction BODY (an inline datum on an output)', () => {
// Body-embedded specifically because this is the placement where the
// encoding actually participates in the body hash: if CML's round trip
// ever DID mangle this encoding, this is the fixture that would show up
// as a changed hash, not just a rejected consumption check.
function fixtureWithBodyEmbeddedDatum(): string {
const unsigned = CML.Transaction.from_cbor_hex(FIXTURE_CBOR);
const body = unsigned.body();
const datum = CML.PlutusData.from_cbor_hex(indefiniteLengthConstructor().toString('hex'));
const existingOutput = body.outputs().get(0);
const outputWithDatum = CML.TransactionOutput.new(
existingOutput.address(),
existingOutput.amount(),
CML.DatumOption.new_datum(datum),
undefined
);
const outputs = CML.TransactionOutputList.new();
outputs.add(outputWithDatum);
const newBody = CML.TransactionBody.new(body.inputs(), outputs, body.fee());
const tx = CML.Transaction.new(newBody, CML.TransactionWitnessSet.new(), true, undefined);
return tx.to_cbor_hex();
}
test('decodeTransaction accepts it — no false-positive "unrecognised trailing data" rejection', () => {
const cborHex = fixtureWithBodyEmbeddedDatum();
assert.doesNotThrow(() => decodeTransaction(cborHex));
});
test('the reported hash matches an independent hash_transaction call over the same body', () => {
const cborHex = fixtureWithBodyEmbeddedDatum();
const { txHashHex } = decodeTransaction(cborHex);
const independentHash = CML.hash_transaction(CML.Transaction.from_cbor_hex(cborHex).body()).to_hex();
assert.equal(txHashHex, independentHash);
});
test('the indefinite-length datum itself survives decode-then-reserialise byte-for-byte', () => {
const cborHex = fixtureWithBodyEmbeddedDatum();
// decodeTransaction() only returns plain data, not CBOR — this checks
// the same property sign.test.ts pins for sign.ts's output by going
// through CML directly on decode.ts's own accepted input, confirming
// the encoding decode.ts just validated really is byte-identical to
// what was supplied, not merely "close enough to parse".
const survivingDatum = CML.Transaction.from_cbor_hex(cborHex).body().outputs().get(0).datum()!.as_datum()!;
assert.equal(survivingDatum.to_cbor_hex(), indefiniteLengthConstructor().toString('hex'));
});
});
describe('embedded in the WITNESS SET (a plutus_datums entry)', () => {
// Unlike the body-embedded case, this can never affect the body hash
// (plutus_datums lives on TransactionWitnessSet, not TransactionBody) —
// it instead exercises the OTHER risk: the consumption check compares
// the WHOLE transaction's re-serialised CBOR, witness set included, so a
// mangled round trip here would still cause a false-positive rejection
// even though it could never show up as a changed hash.
function fixtureWithWitnessEmbeddedDatum(): string {
const unsigned = CML.Transaction.from_cbor_hex(FIXTURE_CBOR);
const body = unsigned.body();
const datum = CML.PlutusData.from_cbor_hex(indefiniteLengthConstructor().toString('hex'));
const witnessSet = CML.TransactionWitnessSet.new();
const datums = CML.PlutusDataList.new();
datums.add(datum);
witnessSet.set_plutus_datums(datums);
const tx = CML.Transaction.new(body, witnessSet, true, undefined);
return tx.to_cbor_hex();
}
test('decodeTransaction accepts it — no false-positive "unrecognised trailing data" rejection', () => {
const cborHex = fixtureWithWitnessEmbeddedDatum();
assert.doesNotThrow(() => decodeTransaction(cborHex));
});
test('the body hash is unchanged (same body as FIXTURE_CBOR — only the witness set differs)', () => {
const cborHex = fixtureWithWitnessEmbeddedDatum();
const { txHashHex } = decodeTransaction(cborHex);
assert.equal(txHashHex, decodeTransaction(FIXTURE_CBOR).txHashHex);
});
});
});