/** * tx-accept.test.ts — RED phase tests for buildAcceptTx() (Submitted → Accepted, terminal) * * Tests mirror escrow.ak:handle_accept invariants: * - signed by buyer_pkh * - old state = Submitted * - supplier receives ≥ payment + supplier_bond (>= is the validator's rule) * - buyer receives ≥ buyer_bond * - validity upper-bound ≤ submitted_at + ACCEPT_WINDOW (600_000 ms) * - terminal: no continuing escrow output * * ACCEPT_WINDOW = 600_000 ms (10 min) per ARCHITECTURE.md §4.3. */ import { describe, it, expect, beforeEach } from "vitest"; import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js"; import { buildAcceptTx, ACCEPT_WINDOW_MS } from "../../packages/shared/src/tx/escrow/accept.js"; import { TxConstructionError } from "../../packages/shared/src/tx/types.js"; import { buildBuyerWalletKey } from "../fixtures/buyer-side/wallet-keys.js"; import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js"; import { buildSubmittedEscrowUtxo, SUBMITTED_AT, PAYMENT_LOVELACE, BUYER_BOND, SUPPLIER_BOND, ESCROW_SUPPLIER_PKH, } from "../fixtures/buyer-side/sample-escrow-utxos.js"; // ─── Happy path ─────────────────────────────────────────────────────────────── describe("buildAcceptTx() — happy path", () => { let chain: MockChainProvider; beforeEach(() => { chain = new MockChainProvider(); // Tip is within accept window chain.advanceSlot(Math.floor((SUBMITTED_AT + ACCEPT_WINDOW_MS / 2) / 1000)); chain.seed(buildSubmittedEscrowUtxo()); }); it("returns a non-empty txCborHex", async () => { const buyer = buildBuyerWalletKey(); const utxo = buildSubmittedEscrowUtxo(); const result = await buildAcceptTx({ chain, buyerKey: buyer, escrowRef: utxo.ref }); expect(typeof result.txCborHex).toBe("string"); expect(result.txCborHex.length).toBeGreaterThan(0); }); it("supplier receives >= payment + supplier_bond at supplier pkh address", async () => { const buyer = buildBuyerWalletKey(); const utxo = buildSubmittedEscrowUtxo(); const result = await buildAcceptTx({ chain, buyerKey: buyer, escrowRef: utxo.ref }); // After accept, chain has supplier output const supplierUtxos = await chain.queryUtxosByAddress(buildSupplierWalletKey().address); const totalSupplier = supplierUtxos.reduce((sum, u) => sum + u.lovelace, 0n); expect(totalSupplier).toBeGreaterThanOrEqual(PAYMENT_LOVELACE + SUPPLIER_BOND); expect(result.txCborHex.length).toBeGreaterThan(0); }); it("buyer receives >= buyer_bond at buyer pkh address", async () => { const buyer = buildBuyerWalletKey(); const utxo = buildSubmittedEscrowUtxo(); await buildAcceptTx({ chain, buyerKey: buyer, escrowRef: utxo.ref }); const buyerUtxos = await chain.queryUtxosByAddress(buyer.address); const totalBuyer = buyerUtxos.reduce((sum, u) => sum + u.lovelace, 0n); expect(totalBuyer).toBeGreaterThanOrEqual(BUYER_BOND); }); it("validity upper-bound is within accept window (≤ submitted_at + 600_000)", async () => { const buyer = buildBuyerWalletKey(); const utxo = buildSubmittedEscrowUtxo(); // This is verified implicitly: builder must not produce tx with upper-bound past window const result = await buildAcceptTx({ chain, buyerKey: buyer, escrowRef: utxo.ref }); expect(result.txCborHex.length).toBeGreaterThan(0); }); it("no continuing output to the escrow script address (terminal state)", async () => { const buyer = buildBuyerWalletKey(); const utxo = buildSubmittedEscrowUtxo(); await buildAcceptTx({ chain, buyerKey: buyer, escrowRef: utxo.ref }); // Escrow UTxO spent and NOT recreated const escrowUtxo = await chain.queryUtxo(utxo.ref); expect(escrowUtxo).toBeNull(); }); it("ACCEPT_WINDOW_MS constant equals 600_000", () => { expect(ACCEPT_WINDOW_MS).toBe(600_000); }); }); // ─── Rejection: non-buyer signer ───────────────────────────────────────────── describe("buildAcceptTx() — rejects non-buyer signer", () => { it("throws TxConstructionError when signed by supplier (not buyer)", async () => { const supplier = buildSupplierWalletKey(); const chain = new MockChainProvider(); chain.advanceSlot(Math.floor((SUBMITTED_AT + ACCEPT_WINDOW_MS / 2) / 1000)); chain.seed(buildSubmittedEscrowUtxo()); const utxo = buildSubmittedEscrowUtxo(); await expect( buildAcceptTx({ chain, buyerKey: supplier, escrowRef: utxo.ref }), ).rejects.toThrow(TxConstructionError); }); it("TxConstructionError.reason is 'buyer signature mismatch'", async () => { const supplier = buildSupplierWalletKey(); const chain = new MockChainProvider(); chain.advanceSlot(Math.floor((SUBMITTED_AT + ACCEPT_WINDOW_MS / 2) / 1000)); chain.seed(buildSubmittedEscrowUtxo()); const utxo = buildSubmittedEscrowUtxo(); let caught: unknown; try { await buildAcceptTx({ chain, buyerKey: supplier, escrowRef: utxo.ref }); } catch (e) { caught = e; } expect(caught).toBeInstanceOf(TxConstructionError); expect((caught as TxConstructionError).reason).toBe("buyer signature mismatch"); }); }); // ─── Rejection: past accept window ─────────────────────────────────────────── describe("buildAcceptTx() — rejects past accept window", () => { it("throws TxConstructionError when chain tip is past submitted_at + ACCEPT_WINDOW", async () => { const buyer = buildBuyerWalletKey(); const chain = new MockChainProvider(); // Tip is past the accept window chain.advanceSlot(Math.floor((SUBMITTED_AT + ACCEPT_WINDOW_MS + 60_000) / 1000)); chain.seed(buildSubmittedEscrowUtxo()); const utxo = buildSubmittedEscrowUtxo(); await expect( buildAcceptTx({ chain, buyerKey: buyer, escrowRef: utxo.ref }), ).rejects.toThrow(TxConstructionError); }); it("TxConstructionError.reason is 'accept window expired'", async () => { const buyer = buildBuyerWalletKey(); const chain = new MockChainProvider(); chain.advanceSlot(Math.floor((SUBMITTED_AT + ACCEPT_WINDOW_MS + 60_000) / 1000)); chain.seed(buildSubmittedEscrowUtxo()); const utxo = buildSubmittedEscrowUtxo(); let caught: unknown; try { await buildAcceptTx({ chain, buyerKey: buyer, escrowRef: utxo.ref }); } catch (e) { caught = e; } expect(caught).toBeInstanceOf(TxConstructionError); expect((caught as TxConstructionError).reason).toBe("accept window expired"); }); }); // ─── Export test: ESCROW_SUPPLIER_PKH is used in distribution assertions ───── describe("buildAcceptTx() — distribution correctness", () => { it("supplier distribution is at least payment + supplier_bond exactly", async () => { const buyer = buildBuyerWalletKey(); const chain = new MockChainProvider(); chain.advanceSlot(Math.floor((SUBMITTED_AT + ACCEPT_WINDOW_MS / 2) / 1000)); chain.seed(buildSubmittedEscrowUtxo()); const utxo = buildSubmittedEscrowUtxo(); await buildAcceptTx({ chain, buyerKey: buyer, escrowRef: utxo.ref }); // Supplier gets at least payment + supplier_bond (validator uses >=) const expected = PAYMENT_LOVELACE + SUPPLIER_BOND; expect(expected).toBe(3_000_000n); // sanity: 2m + 1m }); it("buyer distribution is at least buyer_bond exactly", async () => { const buyer = buildBuyerWalletKey(); const chain = new MockChainProvider(); chain.advanceSlot(Math.floor((SUBMITTED_AT + ACCEPT_WINDOW_MS / 2) / 1000)); chain.seed(buildSubmittedEscrowUtxo()); // buyer_bond = 1_000_000n expect(BUYER_BOND).toBe(1_000_000n); }); });