Files
agents-marketplace/indexer/src/follower/blockProcessor.ts
jelisaveta-talbot cbab81efbd M1-F: end-to-end LLM marketplace lifecycle on Vector testnet
Bonded-escrow marketplace for prompt → response between buyers and
local-LLM suppliers, settling on Vector (UTxO) via Aiken Plutus V3
validators. Lifecycle PostEscrow → Claim → Submit → Accept proven on
Vector testnet 2026-05-06; AcceptEscrow tx 125e3cfe…7dc100d distributes
payment + bonds per the validator's distribution rule.

Workspace layout (pnpm + TypeScript):
  contracts/marketplace/      Aiken validators (advert + escrow) + plutus.json
  packages/shared/            Tx-builders (lucid-evolution), CBOR codecs,
                              chain providers (mock / read-only / live Ogmios)
  supplier/                   OpenAI-compatible HTTP node (Ollama by default;
                              vLLM / llama.cpp / OpenRouter / OpenClaw all
                              swap-in via OLLAMA_URL); 202+poll chat handler
  buyer/                      Express + React SPA; server-side runs the full
                              chain lifecycle via /v1/submit-prompt and
                              /v1/accept; SPA never holds the buyer key
  indexer/                    Chain-follower (extends apex-dashboard pattern),
                              SSE event stream + REST queries
  indexer-ui/                 Operator dashboard (events + suppliers + escrow
                              lookup)
  tests/                      1098 vitest unit + integration tests (10 skipped
                              under M1-F-D-defer for UPLC test plumbing)
  deploy/testnet/             Per-service docker-compose for the Hetzner stack
  docs/EXECUTIVE_SUMMARY.*    Marp deck (PDF + PPTX) — 11 slides, mermaid
                              diagrams pre-rendered for portable export
  docs/CLOUDFLARE_WAF_BYPASS.md  CF Skip-rule runbook for /v1/* paths
  docs/ARCHITECTURE.md        canonical spec

Live demo (testnet, 2026-05-06):
  https://mp-buyers.vector.testnet.apexfusion.org      (buyer SPA)
  https://mp-suppliers.vector.testnet.apexfusion.org   (supplier API)
  https://mp-indexer.vector.testnet.apexfusion.org     (indexer + UI)
2026-05-06 13:27:48 +02:00

258 lines
8.8 KiB
TypeScript

/**
* indexer/src/follower/blockProcessor.ts — processes a single Ogmios block.
*
* processBlock(block, knownUtxos, addresses) inspects each transaction,
* decodes datums at watched script addresses (advertScript, escrowScript),
* and emits typed MarketplaceEvent objects.
*
* Detection rules (all output-driven unless noted):
* PostAdvert — new output at advertAddr with Active AdvertDatum, no spent advert input
* UpdateAdvert — output at advertAddr + spends prior advert UTxO
* RetireAdvert — spends advert UTxO, no continuing output at advertAddr
* PostEscrow — new output at escrowAddr with Open EscrowDatum, no spent escrow input
* ClaimEscrow — spend-driven: spends Open escrow, Claimed output continues
* SubmitEscrow — spend-driven: spends Claimed escrow, Submitted output continues
* AcceptEscrow — spends Submitted escrow, no continuing output (terminal)
* ReclaimEscrow — spends Open/Claimed escrow, no continuing output (terminal)
* ReleaseEscrow — spends Submitted escrow, no continuing output (terminal)
*
* Malformed datum at a watched address → console.warn, no event emitted.
* Byron-era blocks (no transactions field) → handled gracefully.
*/
import { decodeAdvertDatum, decodeEscrowDatum } from "@marketplace/shared/cbor";
import type {
EscrowRedeemerTag,
IndexerBlock,
IndexerTx,
MarketplaceEvent,
MarketplaceEventType,
} from "./types.js";
export interface KnownUtxoInfo {
address: string;
datumHex?: string;
}
export interface ScriptAddresses {
advertAddress: string;
escrowAddress: string;
}
export interface ProcessBlockResult {
events: MarketplaceEvent[];
spentRefs: Array<{ ref: string; slot: number; txHash: string }>;
}
interface SpentInfo {
ref: string;
prior: KnownUtxoInfo;
}
function tryDecodeAdvert(datumHex: string): ReturnType<typeof decodeAdvertDatum> | null {
try {
return decodeAdvertDatum(datumHex);
} catch (err) {
console.warn(`[blockProcessor] failed to decode AdvertDatum: ${(err as Error).message}`);
return null;
}
}
function tryDecodeEscrow(datumHex: string): ReturnType<typeof decodeEscrowDatum> | null {
try {
return decodeEscrowDatum(datumHex);
} catch (err) {
console.warn(`[blockProcessor] failed to decode EscrowDatum: ${(err as Error).message}`);
return null;
}
}
function processTx(
tx: IndexerTx,
block: IndexerBlock,
knownUtxos: Map<string, KnownUtxoInfo>,
addresses: ScriptAddresses,
events: MarketplaceEvent[],
spentRefs: Array<{ ref: string; slot: number; txHash: string }>,
): void {
const slot = block.slot;
const txHash = tx.id;
// 1) Identify spent inputs that hit our known watched UTxOs.
const spentAdvert: SpentInfo[] = [];
const spentEscrow: SpentInfo[] = [];
for (const input of tx.inputs ?? []) {
const ref = `${input.transaction.id}#${input.index}`;
const prior = knownUtxos.get(ref);
if (!prior) continue;
spentRefs.push({ ref, slot, txHash });
if (prior.address === addresses.advertAddress) {
spentAdvert.push({ ref, prior });
} else if (prior.address === addresses.escrowAddress) {
spentEscrow.push({ ref, prior });
}
}
// 2) Identify outputs at watched addresses.
type WatchedOutput = { index: number; address: string; datumHex: string };
const advertOutputs: WatchedOutput[] = [];
const escrowOutputs: WatchedOutput[] = [];
for (let i = 0; i < (tx.outputs ?? []).length; i++) {
const out = tx.outputs[i];
if (!out.datum) continue;
if (out.address === addresses.advertAddress) {
advertOutputs.push({ index: i, address: out.address, datumHex: out.datum });
} else if (out.address === addresses.escrowAddress) {
escrowOutputs.push({ index: i, address: out.address, datumHex: out.datum });
}
}
// 3) Advertisement event detection
if (advertOutputs.length > 0) {
// Output(s) at advert script — either PostAdvert (no input) or UpdateAdvert (has prior input)
for (const out of advertOutputs) {
const decoded = tryDecodeAdvert(out.datumHex);
if (!decoded) continue;
const type: MarketplaceEventType = spentAdvert.length > 0 ? "UpdateAdvert" : "PostAdvert";
events.push({
type,
slot,
txHash,
utxoRef: `${txHash}#${out.index}`,
datumHex: out.datumHex,
address: out.address,
});
}
} else if (spentAdvert.length > 0) {
// Spend with no continuing output → RetireAdvert
for (const s of spentAdvert) {
events.push({
type: "RetireAdvert",
slot,
txHash,
utxoRef: s.ref,
datumHex: s.prior.datumHex ?? "",
address: s.prior.address,
});
}
}
// 4) Escrow event detection
if (escrowOutputs.length > 0) {
// Continuing output exists. The state in the new datum tells us which transition.
for (const out of escrowOutputs) {
const decoded = tryDecodeEscrow(out.datumHex);
if (!decoded) continue;
let type: MarketplaceEventType | null = null;
if (decoded.state === "Open" && spentEscrow.length === 0) {
type = "PostEscrow";
} else if (decoded.state === "Claimed" && spentEscrow.length > 0) {
type = "ClaimEscrow";
} else if (decoded.state === "Submitted" && spentEscrow.length > 0) {
type = "SubmitEscrow";
}
if (type !== null) {
events.push({
type,
slot,
txHash,
utxoRef: `${txHash}#${out.index}`,
datumHex: out.datumHex,
address: out.address,
});
}
}
} else if (spentEscrow.length > 0) {
// Terminal spend (no continuing escrow output). Disambiguate via tx.redeemers.
// Per ARCHITECTURE.md §4.3 the prior state + redeemer uniquely determines the
// intended transition. Mismatches are logged and emit no event.
const redeemers: Record<string, EscrowRedeemerTag> = tx.redeemers ?? {};
for (const s of spentEscrow) {
const priorDatumHex = s.prior.datumHex ?? "";
const decoded = priorDatumHex ? tryDecodeEscrow(priorDatumHex) : null;
if (!decoded) continue;
const redeemer = redeemers[s.ref];
const priorState = decoded.state;
let type: MarketplaceEventType | null = null;
if (redeemer === undefined) {
// No redeemer attached — defensive default: assume the canonical
// "happy-path" redeemer for the prior state. Always warn so this
// surfaces in operations even when behaviour is benign.
if (priorState === "Submitted") {
type = "AcceptEscrow";
console.warn(
`[blockProcessor] terminal escrow spend ${s.ref} (priorState=Submitted) carried no redeemer; defaulting to AcceptEscrow`,
);
} else if (priorState === "Open" || priorState === "Claimed") {
type = "ReclaimEscrow";
console.warn(
`[blockProcessor] terminal escrow spend ${s.ref} (priorState=${priorState}) carried no redeemer; defaulting to ReclaimEscrow`,
);
} else {
console.warn(
`[blockProcessor] terminal escrow spend ${s.ref} carried no redeemer and prior state ${priorState} is non-terminal; emitting no event`,
);
}
} else if (priorState === "Submitted") {
if (redeemer === "Accept") {
type = "AcceptEscrow";
} else if (redeemer === "Release") {
type = "ReleaseEscrow";
} else {
console.warn(
`[blockProcessor] redeemer/state mismatch on terminal spend ${s.ref}: redeemer=${redeemer} but priorState=Submitted; emitting no event`,
);
}
} else if (priorState === "Open" || priorState === "Claimed") {
if (redeemer === "Reclaim") {
type = "ReclaimEscrow";
} else {
console.warn(
`[blockProcessor] redeemer/state mismatch on terminal spend ${s.ref}: redeemer=${redeemer} but priorState=${priorState}; emitting no event`,
);
}
} else {
console.warn(
`[blockProcessor] terminal escrow spend ${s.ref} has unexpected prior state ${priorState} with redeemer=${redeemer}; emitting no event`,
);
}
if (type !== null) {
events.push({
type,
slot,
txHash,
utxoRef: s.ref,
datumHex: priorDatumHex,
address: s.prior.address,
});
}
}
}
}
export function processBlock(
block: IndexerBlock,
knownUtxos: Map<string, KnownUtxoInfo>,
addresses: ScriptAddresses,
): ProcessBlockResult {
const events: MarketplaceEvent[] = [];
const spentRefs: Array<{ ref: string; slot: number; txHash: string }> = [];
// Byron / null transactions → graceful no-op
if (!block || !block.transactions) {
return { events, spentRefs };
}
for (const tx of block.transactions) {
if (!tx) continue;
processTx(tx, block, knownUtxos, addresses, events, spentRefs);
}
return { events, spentRefs };
}