CI has been red since the pnpm 10 migration. Four independent causes:
- better-sqlite3: pnpm 10 blocks dependency build scripts by default, so the
native binding was never compiled on CI. Allowlist it via
onlyBuiltDependencies in pnpm-workspace.yaml.
- libsodium-wrappers-sumo 0.7.16: the published ESM wrapper does
`import "./libsodium-sumo.mjs"` but the tarball does not ship that file
(it lives in the libsodium-sumo package). Patch the import to the bare
specifier; local runs only worked because of a hand-copied file in
node_modules.
- tx-accept/claim [live] tests: the live path checks deadlines against
Date.now(), and the fixed fixture timestamps expired in June. Freeze the
clock inside the fixture windows with vi.useFakeTimers.
- CML set_exunits panic ("RuntimeError: unreachable"): with
localUPLCEval: false the provider asks Ogmios for ex-units; the test mocks
answered `{}`, so the provider fell back to a spend redeemer at index 0
while the escrow input sorts to index 1 — CML panics on the unknown
redeemer key. Mock evaluateTransaction with the true index like real
Ogmios.
Also regenerate the advert-datum golden: the fixture builders passed plain JS
numbers to cbor-x (minimal ints, float64 for the timestamp) while the
production codec — whose output is live on mainnet — emits full-width uint64.
Builders now encode integer fields as BigInt so builders, codec, and golden
agree byte-for-byte.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
271 lines
11 KiB
TypeScript
271 lines
11 KiB
TypeScript
/**
|
|
* post-advert-flow-live-time.test.ts — RED phase tests for M1-F-time-cleanup
|
|
*
|
|
* Proves that runPostAdvert() computes advertised_at:
|
|
* - via mockSlotToWallclockMs(tipSlot) when chain is a MockChainProvider
|
|
* (existing convention — must continue to pass after the live branch is added)
|
|
* - via Date.now() when chain is a LiveOgmiosProvider
|
|
* (new behavior — RED until Catherine implements the branch in postAdvertFlow.ts)
|
|
*
|
|
* Observation strategy for advertised_at on live chain:
|
|
* - Spy on chain.submitTx to capture the txCborHex submitted by buildPostAdvertTx.
|
|
* - The test tx format (encodeTxBody) is length-prefixed JSON over hex.
|
|
* - Parse the JSON to find the datumHex output field.
|
|
* - Decode the datumHex with decodeAdvertDatum to read advertised_at.
|
|
*
|
|
* Uses vi.useFakeTimers() + vi.setSystemTime() for deterministic Date.now().
|
|
*
|
|
* M1-F-time-cleanup RED — live backend tests fail until Catherine adds the
|
|
* live-path branch in supplier/src/cli/postAdvertFlow.ts.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import { runPostAdvert } from "../../supplier/src/cli/postAdvertFlow.js";
|
|
import { LiveOgmiosProvider } from "../../packages/shared/src/chain/LiveOgmiosProvider.js";
|
|
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
|
|
import { decodeAdvertDatum } from "../../packages/shared/src/cbor/AdvertDatum.js";
|
|
import {
|
|
buildCliAdvertDatum,
|
|
VALID_TIP_SLOT,
|
|
} from "../fixtures/supplier-side/sample-advert-datum.js";
|
|
import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js";
|
|
|
|
// ─── Fixed fake time ──────────────────────────────────────────────────────────
|
|
|
|
const FAKE_NOW_DATE = new Date("2026-04-28T12:00:00Z");
|
|
const FAKE_NOW_MS = FAKE_NOW_DATE.getTime(); // 1777377600000
|
|
|
|
// ─── Discriminating tip slot ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* TIP_SLOT_LIVE is chosen so that:
|
|
* mockSlotToWallclockMs(TIP_SLOT_LIVE) = TIP_SLOT_LIVE * 1000 = 50_000_000
|
|
* which is far below FAKE_NOW_MS (1_745_841_600_000).
|
|
*
|
|
* buildPostAdvertTx has a ±5min validity check (±300_000ms). Neither
|
|
* |FAKE_NOW_MS - 50_000_000| nor |50_000_000 - 50_000_000|
|
|
* can satisfy the validity window simultaneously — so the test implicitly
|
|
* validates that Catherine's implementation branches correctly AND also
|
|
* updates the validity check in buildPostAdvertTx to use Date.now() for live chains.
|
|
*
|
|
* For RED phase: when postAdvertFlow uses mockSlotToWallclockMs on a live chain,
|
|
* it sets advertised_at = 50_000_000. buildPostAdvertTx also uses mockSlotToWallclockMs,
|
|
* so the ±5min check passes (|50_000_000 - 50_000_000| = 0). The datum gets written.
|
|
* We then read it back and verify it is NOT FAKE_NOW_MS — proving the test is RED.
|
|
*/
|
|
const TIP_SLOT_LIVE = 50_000;
|
|
const TIP_SLOT_LIVE_MS = TIP_SLOT_LIVE * 1_000; // = 50_000_000 (mock convention, NOT real POSIX)
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function rpcOk<T>(result: T) {
|
|
return { ok: true, json: async () => ({ jsonrpc: "2.0", result }) };
|
|
}
|
|
|
|
function protocolParamsResponse() {
|
|
return rpcOk({
|
|
minFeeCoefficient: 44,
|
|
minFeeConstant: { ada: { lovelace: 155381 } },
|
|
maxTransactionSize: { bytes: 16384 },
|
|
maxValueSize: { bytes: 5000 },
|
|
stakeCredentialDeposit: { ada: { lovelace: 2000000 } },
|
|
stakePoolDeposit: { ada: { lovelace: 500000000 } },
|
|
prices: { memory: "0.0577", steps: "0.0000721" },
|
|
maxExecutionUnitsPerTransaction: { memory: 14000000, cpu: 10000000000 },
|
|
coinsPerUtxoByte: { ada: { lovelace: 4310 } },
|
|
collateralPercentage: 150,
|
|
maxCollateralInputs: 3,
|
|
plutusCostModels: { "plutus:v1": {}, "plutus:v2": {}, "plutus:v3": {} },
|
|
monetaryExpansion: "0.003",
|
|
treasuryExpansion: "0.2",
|
|
minStakePoolCost: { ada: { lovelace: 340000000 } },
|
|
minFeeReferenceScripts: { base: 15 },
|
|
governanceActionDeposit: { ada: { lovelace: 100000000000 } },
|
|
delegateRepresentativeDeposit: { ada: { lovelace: 500000000 } },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Attempt to extract and decode the AdvertDatum from a submitted tx CBOR hex.
|
|
*
|
|
* The live path emits real Cardano (Conway) CBOR. The advert datum rides in
|
|
* the script output as an inline datum: datum_option = [1, #6.24(bytes)], so
|
|
* in hex it appears as "d818" (tag 24) followed by a definite-length byte
|
|
* string (0x58 <len> or 0x59 <len16>) holding the raw AdvertDatum CBOR.
|
|
*
|
|
* Returns null if no tag-24 wrapped datum is found or decoding fails.
|
|
*/
|
|
function extractAdvertisedAtFromTxCbor(txCborHex: string): number | null {
|
|
try {
|
|
const m = /d818(?:58([0-9a-f]{2})|59([0-9a-f]{4}))/i.exec(txCborHex);
|
|
if (!m) return null;
|
|
const byteLen = parseInt(m[1] ?? m[2]!, 16);
|
|
const start = m.index + m[0].length;
|
|
const datumHex = txCborHex.slice(start, start + byteLen * 2);
|
|
if (datumHex.length !== byteLen * 2) return null;
|
|
const datum = decodeAdvertDatum(datumHex);
|
|
return datum.advertised_at;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a LiveOgmiosProvider with a mocked fetch returning TIP_SLOT_LIVE.
|
|
* The submitTx and awaitTx methods are spied on so we can capture submitted CBOR.
|
|
*/
|
|
function buildLiveChain(): {
|
|
chain: LiveOgmiosProvider;
|
|
capturedTxCborHex: { value: string | undefined };
|
|
} {
|
|
const supplier = buildSupplierWalletKey();
|
|
const captured: { value: string | undefined } = { value: undefined };
|
|
|
|
const mockFetch = vi.fn().mockImplementation(
|
|
async (_url: string, init?: RequestInit) => {
|
|
const body = JSON.parse((init?.body as string) ?? "{}");
|
|
const method: string = body.method ?? "";
|
|
|
|
if (method === "queryNetwork/tip") {
|
|
return rpcOk({ slot: TIP_SLOT_LIVE, id: "a".repeat(64) });
|
|
}
|
|
if (method === "queryLedgerState/protocolParameters") {
|
|
return protocolParamsResponse();
|
|
}
|
|
if (method === "queryLedgerState/utxo") {
|
|
return rpcOk([
|
|
{
|
|
transaction: { id: "c".repeat(64) },
|
|
index: 0,
|
|
address: supplier.address,
|
|
value: { ada: { lovelace: 10_000_000 } },
|
|
datum: null,
|
|
datumHash: null,
|
|
script: null,
|
|
},
|
|
]);
|
|
}
|
|
if (method === "submitTransaction") {
|
|
return rpcOk({ transaction: { id: "e".repeat(64) } });
|
|
}
|
|
return rpcOk({});
|
|
},
|
|
);
|
|
|
|
const chain = new LiveOgmiosProvider({
|
|
ogmiosUrl: "http://ogmios:1337",
|
|
fetch: mockFetch,
|
|
});
|
|
|
|
// Capture the tx CBOR from buildPostAdvertTx's chain.submitTx call
|
|
vi.spyOn(chain, "submitTx").mockImplementation(async (txCborHex: string) => {
|
|
captured.value = txCborHex;
|
|
return "e".repeat(64);
|
|
});
|
|
|
|
vi.spyOn(chain, "awaitTx").mockResolvedValue(undefined);
|
|
|
|
return { chain, capturedTxCborHex: captured };
|
|
}
|
|
|
|
/**
|
|
* buildMockChain — matches the convention from cli-post-advert-flow.test.ts.
|
|
* VALID_TIP_SLOT = 0 → mockSlotToWallclockMs(0) = 0.
|
|
*/
|
|
function buildMockChain(): MockChainProvider {
|
|
const chain = new MockChainProvider();
|
|
chain.advanceSlot(VALID_TIP_SLOT);
|
|
return chain;
|
|
}
|
|
|
|
// ─── Setup / teardown ─────────────────────────────────────────────────────────
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(FAKE_NOW_DATE);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// ─── Mock backend — existing convention preserved ─────────────────────────────
|
|
|
|
describe("runPostAdvert() [mock backend] — advertised_at = mockSlotToWallclockMs(tipSlot)", () => {
|
|
it("advert.advertised_at === mockSlotToWallclockMs(VALID_TIP_SLOT)", async () => {
|
|
// VALID_TIP_SLOT = 0 → mockSlotToWallclockMs(0) = 0
|
|
const EXPECTED_ADVERTISED_AT = VALID_TIP_SLOT * 1_000; // = 0
|
|
|
|
const chain = buildMockChain();
|
|
const result = await runPostAdvert({
|
|
chain,
|
|
walletKey: buildSupplierWalletKey(),
|
|
advertDatum: buildCliAdvertDatum(),
|
|
});
|
|
|
|
// With MockChainProvider the seeded UTxO is queryable directly
|
|
const utxo = await chain.queryUtxo(result.advertRef);
|
|
expect(utxo).not.toBeNull();
|
|
const storedDatum = decodeAdvertDatum(utxo!.datumHex!);
|
|
expect(storedDatum.advertised_at).toBe(EXPECTED_ADVERTISED_AT);
|
|
});
|
|
});
|
|
|
|
// ─── Live backend — uses Date.now() ──────────────────────────────────────────
|
|
|
|
describe("runPostAdvert() [live backend] — advertised_at = Date.now()", () => {
|
|
it("advert.advertised_at === Date.now() (FAKE_NOW_MS) when chain is LiveOgmiosProvider", async () => {
|
|
// RED phase: the current implementation uses mockSlotToWallclockMs(TIP_SLOT_LIVE)
|
|
// = 50_000_000. The test asserts advertised_at === FAKE_NOW_MS (1_745_841_600_000).
|
|
// This FAILS until Catherine branches postAdvertFlow.ts on LiveOgmiosProvider.
|
|
//
|
|
// Note: for this test to pass GREEN, Catherine must also update buildPostAdvertTx
|
|
// to use Date.now() for the ±5min validity check on live chains, since
|
|
// |FAKE_NOW_MS - 50_000_000| >> 5min and would otherwise throw
|
|
// "advertised_at out of validity range".
|
|
const { chain, capturedTxCborHex } = buildLiveChain();
|
|
|
|
await runPostAdvert({
|
|
chain,
|
|
walletKey: buildSupplierWalletKey(),
|
|
advertDatum: buildCliAdvertDatum(),
|
|
});
|
|
|
|
// Extract advertised_at from the datum embedded in the submitted tx CBOR
|
|
expect(capturedTxCborHex.value).not.toBeUndefined();
|
|
const advertisedAt = extractAdvertisedAtFromTxCbor(capturedTxCborHex.value!);
|
|
expect(advertisedAt).not.toBeNull();
|
|
// Must be FAKE_NOW_MS, not TIP_SLOT_LIVE * 1000
|
|
expect(advertisedAt).toBe(FAKE_NOW_MS);
|
|
});
|
|
|
|
it("callee uses Date.now() not tipSlot*1000 — advertised_at is in real-POSIX-ms range (>1.7e12)", async () => {
|
|
// Complementary discriminating assertion:
|
|
// - TIP_SLOT_LIVE_MS = 50_000_000 (NOT in real POSIX range)
|
|
// - FAKE_NOW_MS = 1_745_841_600_000 (in real POSIX range > 1.7e12)
|
|
// RED: current code sets advertised_at = 50_000_000 (< 1.7e12 threshold).
|
|
// GREEN: Catherine's code must set advertised_at = Date.now() = FAKE_NOW_MS (> 1.7e12).
|
|
const { chain, capturedTxCborHex } = buildLiveChain();
|
|
|
|
await runPostAdvert({
|
|
chain,
|
|
walletKey: buildSupplierWalletKey(),
|
|
advertDatum: buildCliAdvertDatum(),
|
|
});
|
|
|
|
expect(capturedTxCborHex.value).not.toBeUndefined();
|
|
const advertisedAt = extractAdvertisedAtFromTxCbor(capturedTxCborHex.value!);
|
|
expect(advertisedAt).not.toBeNull();
|
|
|
|
// Sanity checks on constants
|
|
expect(FAKE_NOW_MS).toBeGreaterThan(1.7e12);
|
|
expect(TIP_SLOT_LIVE_MS).toBeLessThan(1e9); // far below real-clock range
|
|
|
|
// The actual assertion: advertised_at must be in the real-POSIX-ms range
|
|
expect(advertisedAt!).toBeGreaterThan(1.7e12);
|
|
// And must NOT be the mock-convention value
|
|
expect(advertisedAt!).not.toBe(TIP_SLOT_LIVE_MS);
|
|
});
|
|
});
|