/** * tx-claim.test.ts — RED phase tests for buildClaimTx() (Open → Claimed) * * Tests mirror escrow.ak:handle_claim invariants: * - signed by supplier_pkh * - old state = Open * - new datum state = Claimed, all other fields unchanged * - value preserved * - validity upper-bound ≤ deliver_by * - redeemer is Claim (Constr index 0) * * Uses buyer-side fixture UTxOs for the "existing escrow" input. */ import { describe, it, expect, beforeEach } from "vitest"; import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js"; import { buildClaimTx } from "../../packages/shared/src/tx/escrow/claim.js"; import { TxConstructionError } from "../../packages/shared/src/tx/types.js"; import { decodeEscrowDatum } from "../../packages/shared/src/cbor/EscrowDatum.js"; import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js"; import { buildBuyerWalletKey } from "../fixtures/buyer-side/wallet-keys.js"; import { buildOpenEscrowUtxo, buildClaimedEscrowUtxo, DELIVER_BY, TOTAL_LOCKED, } from "../fixtures/buyer-side/sample-escrow-utxos.js"; // ─── Happy path ─────────────────────────────────────────────────────────────── describe("buildClaimTx() — happy path", () => { let chain: MockChainProvider; beforeEach(() => { chain = new MockChainProvider(); // Synthetic tip well before deliver_by chain.advanceSlot(Math.floor((DELIVER_BY - 100_000) / 1000)); const utxo = buildOpenEscrowUtxo(); chain.seed(utxo); }); it("returns a non-empty txCborHex", async () => { const supplier = buildSupplierWalletKey(); const utxo = buildOpenEscrowUtxo(); const result = await buildClaimTx({ chain, supplierKey: supplier, escrowRef: utxo.ref }); expect(typeof result.txCborHex).toBe("string"); expect(result.txCborHex.length).toBeGreaterThan(0); }); it("continuing output datum has state = Claimed", async () => { const supplier = buildSupplierWalletKey(); const utxo = buildOpenEscrowUtxo(); const result = await buildClaimTx({ chain, supplierKey: supplier, escrowRef: utxo.ref }); // After claim, chain has the new UTxO at same address with Claimed state const txHash = result.expectedTxHash; const newUtxo = await chain.queryUtxo({ txHash, index: 0 }); const datum = decodeEscrowDatum(newUtxo!.datumHex!); expect(datum.state).toBe("Claimed"); }); it("continuing output value equals locked value (preserved)", async () => { const supplier = buildSupplierWalletKey(); const utxo = buildOpenEscrowUtxo(); const result = await buildClaimTx({ chain, supplierKey: supplier, escrowRef: utxo.ref }); const txHash = result.expectedTxHash; const newUtxo = await chain.queryUtxo({ txHash, index: 0 }); expect(newUtxo!.lovelace).toBe(TOTAL_LOCKED); }); it("continuing output datum fields other than state are unchanged", async () => { const supplier = buildSupplierWalletKey(); const openUtxo = buildOpenEscrowUtxo(); const oldDatum = decodeEscrowDatum(openUtxo.datumHex!); const result = await buildClaimTx({ chain, supplierKey: supplier, escrowRef: openUtxo.ref }); const txHash = result.expectedTxHash; const newUtxo = await chain.queryUtxo({ txHash, index: 0 }); const newDatum = decodeEscrowDatum(newUtxo!.datumHex!); // All fields except state must be identical expect(newDatum.buyer_pkh).toBe(oldDatum.buyer_pkh); expect(newDatum.supplier_pkh).toBe(oldDatum.supplier_pkh); expect(newDatum.advert_ref.txHash).toBe(oldDatum.advert_ref.txHash); expect(newDatum.prompt_hash).toBe(oldDatum.prompt_hash); expect(newDatum.payment_lovelace).toBe(oldDatum.payment_lovelace); expect(newDatum.deliver_by).toBe(oldDatum.deliver_by); expect(newDatum.submitted_at).toBeNull(); expect(newDatum.result_receipt_hash).toBeNull(); }); it("validity upper-bound is ≤ deliver_by", async () => { const supplier = buildSupplierWalletKey(); const utxo = buildOpenEscrowUtxo(); const result = await buildClaimTx({ chain, supplierKey: supplier, escrowRef: utxo.ref }); // The tx CBOR must encode a validity interval with upper bound ≤ deliver_by // We verify indirectly via the produced tx non-emptiness + no throw expect(result.txCborHex.length).toBeGreaterThan(0); }); it("escrow UTxO is the only spending input", async () => { const supplier = buildSupplierWalletKey(); const utxo = buildOpenEscrowUtxo(); await buildClaimTx({ chain, supplierKey: supplier, escrowRef: utxo.ref }); // Original UTxO should be spent (removed) const spent = await chain.queryUtxo(utxo.ref); expect(spent).toBeNull(); }); }); // ─── Rejection: signed by non-supplier ─────────────────────────────────────── describe("buildClaimTx() — rejects non-supplier signer", () => { it("throws TxConstructionError when signed by buyer (not supplier)", async () => { const buyer = buildBuyerWalletKey(); const chain = new MockChainProvider(); chain.advanceSlot(Math.floor((DELIVER_BY - 100_000) / 1000)); const utxo = buildOpenEscrowUtxo(); chain.seed(utxo); await expect( buildClaimTx({ chain, supplierKey: buyer, escrowRef: utxo.ref }), ).rejects.toThrow(TxConstructionError); }); it("TxConstructionError.reason is 'supplier signature mismatch'", async () => { const buyer = buildBuyerWalletKey(); const chain = new MockChainProvider(); chain.advanceSlot(Math.floor((DELIVER_BY - 100_000) / 1000)); const utxo = buildOpenEscrowUtxo(); chain.seed(utxo); let caught: unknown; try { await buildClaimTx({ chain, supplierKey: buyer, escrowRef: utxo.ref }); } catch (e) { caught = e; } expect(caught).toBeInstanceOf(TxConstructionError); expect((caught as TxConstructionError).reason).toBe("supplier signature mismatch"); }); }); // ─── Rejection: claim after deliver_by ─────────────────────────────────────── describe("buildClaimTx() — rejects claim after deliver_by", () => { it("throws TxConstructionError when chain tip is past deliver_by", async () => { const supplier = buildSupplierWalletKey(); const chain = new MockChainProvider(); // Tip is past deliver_by chain.advanceSlot(Math.floor((DELIVER_BY + 100_000) / 1000)); const utxo = buildOpenEscrowUtxo(); chain.seed(utxo); await expect( buildClaimTx({ chain, supplierKey: supplier, escrowRef: utxo.ref }), ).rejects.toThrow(TxConstructionError); }); it("TxConstructionError.reason is 'claim after deliver_by'", async () => { const supplier = buildSupplierWalletKey(); const chain = new MockChainProvider(); chain.advanceSlot(Math.floor((DELIVER_BY + 100_000) / 1000)); const utxo = buildOpenEscrowUtxo(); chain.seed(utxo); let caught: unknown; try { await buildClaimTx({ chain, supplierKey: supplier, escrowRef: utxo.ref }); } catch (e) { caught = e; } expect(caught).toBeInstanceOf(TxConstructionError); expect((caught as TxConstructionError).reason).toBe("claim after deliver_by"); }); }); // ─── Rejection: wrong state ─────────────────────────────────────────────────── describe("buildClaimTx() — rejects wrong state", () => { it("throws TxConstructionError when escrow is already Claimed", async () => { const supplier = buildSupplierWalletKey(); const chain = new MockChainProvider(); chain.advanceSlot(Math.floor((DELIVER_BY - 100_000) / 1000)); const utxo = buildClaimedEscrowUtxo(); // already Claimed chain.seed(utxo); await expect( buildClaimTx({ chain, supplierKey: supplier, escrowRef: utxo.ref }), ).rejects.toThrow(TxConstructionError); }); });