buyer: marketplace-buyer-pair adversarial + happy-path runners (testnet)
run-agent-a.ts: drives Marketplace.submitPrompt + runAccept for 3 cycles against the live supplier. Logs every tx hash, HTTP code, receipt verification step. Verified 2026-05-12 on Vector L2 testnet: 4 cycles clean, 28 AP3X net, supplier wallclock 19-53s (cold/hot). run-agent-b.ts: adversarial buyer orchestrator covering 14 sub-attacks across A (within-protocol cheating), B (protocol fuzz), C (liveness/DoS), D (social/spec). Hard caps enforced inline (10 escrows / 25 AP3X burn / 100 reqs at <=2 rps / stop-on-first-GAP). Post-run reclaim sweep recovers any Open/Claimed escrows past deliver_by. Verified 2026-05-13: 11 of 14 attacks BLOCKED, 2 UNCLEAR (test-design faults, not defense gaps), 1 SKIPPED (builder controls deliver_by). Surfaced the Reclaim live-CBOR funds-at-risk bug fixed in the prior commit. reclaim-orphans.ts + cleanup-agent-b.ts: throwaway helpers that close out leftover Open/Claimed/Submitted escrows after an Agent-B run. prompts/marketplace-buyer-pair.md: paired spec for Agent A + Agent B (the source for these runs). buyer/.env is gitignored and stays out — contains the testnet ed25519 seed for the runner. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
65
buyer/scripts/cleanup-agent-b.ts
Normal file
65
buyer/scripts/cleanup-agent-b.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* cleanup-agent-b.ts — close out Agent B's lingering escrows.
|
||||||
|
* - Accept D3 Submitted (supplier processed, just needs Accept)
|
||||||
|
* - Reclaim A1 + A4 Open (deliver_by passed, recover funds)
|
||||||
|
* Throwaway.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import * as ed from "@noble/ed25519";
|
||||||
|
import { LiveOgmiosProvider } from "@marketplace/shared/chain";
|
||||||
|
import { buildReclaimTx } from "@marketplace/shared/tx";
|
||||||
|
import { runAccept } from "../src/cli/acceptFlow.js";
|
||||||
|
import { deriveWalletKey } from "../src/index.js";
|
||||||
|
|
||||||
|
ed.etc.sha512Sync = (...m: Uint8Array[]): Uint8Array => {
|
||||||
|
const h = createHash("sha512");
|
||||||
|
for (const x of m) h.update(x);
|
||||||
|
return new Uint8Array(h.digest());
|
||||||
|
};
|
||||||
|
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const line of readFileSync("/home/david/code/agents-marketplace/buyer/.env", "utf-8").split("\n")) {
|
||||||
|
if (!line.trim() || line.trim().startsWith("#")) continue;
|
||||||
|
const i = line.indexOf("=");
|
||||||
|
if (i === -1) continue;
|
||||||
|
env[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const wk = deriveWalletKey(env.BUYER_PRIV_KEY_HEX, 1);
|
||||||
|
const chain = new LiveOgmiosProvider({ ogmiosUrl: env.OGMIOS_URL });
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
// D3's Submitted ref needs Accept.
|
||||||
|
const d3Submitted = { txHash: "2d3db522d4e2f5221d75afcd31e4dbcc247866558a7841b5cf6be9bebfc93347", index: 0 };
|
||||||
|
try {
|
||||||
|
const r = await runAccept({
|
||||||
|
chain, walletKey: wk, escrowRef: d3Submitted,
|
||||||
|
log: (line) => console.log(`[D3-accept] ${line}`),
|
||||||
|
});
|
||||||
|
console.log(`D3 accept tx ${r.txHash}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`D3 accept FAILED:`, e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A1 + A4 Open escrows need Reclaim.
|
||||||
|
const openRefs = [
|
||||||
|
{ id: "A1", txHash: "45879f707e7c8521233ee3bd5b024300211b8d8675f2400784f6ac1744848184", index: 0 },
|
||||||
|
{ id: "A4", txHash: "8f1ec769bd883ed617b4f05143b4997d20bd93d970371c0358a0e395cb676271", index: 0 },
|
||||||
|
];
|
||||||
|
for (const { id, txHash, index } of openRefs) {
|
||||||
|
try {
|
||||||
|
const r = await buildReclaimTx({ chain, buyerKey: wk, escrowRef: { txHash, index } });
|
||||||
|
await chain.awaitTx(r.expectedTxHash, 90_000);
|
||||||
|
console.log(`${id} reclaim tx ${r.expectedTxHash}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`${id} reclaim FAILED:`, e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const utxos = await chain.queryUtxosByAddress(wk.address);
|
||||||
|
const bal = utxos.reduce((a, u) => a + BigInt(u.lovelace), 0n);
|
||||||
|
console.log(`final wallet bal: ${bal} lovelace (~${Number(bal) / 1e6} AP3X)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => { console.error(e); process.exit(1); });
|
||||||
58
buyer/scripts/reclaim-orphans.ts
Normal file
58
buyer/scripts/reclaim-orphans.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Reclaim B2 + C2 escrows that were left Open after their post-escrow
|
||||||
|
* rejections in run-agent-b's first pass. Throwaway.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import * as ed from "@noble/ed25519";
|
||||||
|
import { LiveOgmiosProvider } from "@marketplace/shared/chain";
|
||||||
|
import { buildReclaimTx } from "@marketplace/shared/tx";
|
||||||
|
import { deriveWalletKey } from "../src/index.js";
|
||||||
|
|
||||||
|
ed.etc.sha512Sync = (...m: Uint8Array[]): Uint8Array => {
|
||||||
|
const h = createHash("sha512");
|
||||||
|
for (const x of m) h.update(x);
|
||||||
|
return new Uint8Array(h.digest());
|
||||||
|
};
|
||||||
|
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const line of readFileSync("/home/david/code/agents-marketplace/buyer/.env", "utf-8").split("\n")) {
|
||||||
|
if (!line.trim() || line.trim().startsWith("#")) continue;
|
||||||
|
const i = line.indexOf("=");
|
||||||
|
if (i === -1) continue;
|
||||||
|
env[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const wk = deriveWalletKey(env.BUYER_PRIV_KEY_HEX, 1);
|
||||||
|
const chain = new LiveOgmiosProvider({ ogmiosUrl: env.OGMIOS_URL });
|
||||||
|
|
||||||
|
const refs = [
|
||||||
|
{ txHash: "50c59e7621a05bde5df06e4ded1513f821f56c26e2af0a3aacc05a6448ae690b", index: 0 },
|
||||||
|
{ txHash: "6449cb53f1a15a48c9c1c01ca885a3dbd238930d86d91501215be87d9739384c", index: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
for (const escrowRef of refs) {
|
||||||
|
try {
|
||||||
|
const r = await buildReclaimTx({ chain, buyerKey: wk, escrowRef });
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`reclaim ${escrowRef.txHash}#${escrowRef.index} -> tx ${r.expectedTxHash}`);
|
||||||
|
await chain.awaitTx(r.expectedTxHash, 90000);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(` confirmed`);
|
||||||
|
} catch (e) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`reclaim ${escrowRef.txHash}#${escrowRef.index} FAILED:`, e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const utxos = await chain.queryUtxosByAddress(wk.address);
|
||||||
|
const bal = utxos.reduce((a, u) => a + BigInt(u.lovelace), 0n);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`wallet bal: ${bal} lovelace (~${Number(bal) / 1e6} AP3X)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
381
buyer/scripts/run-agent-a.ts
Normal file
381
buyer/scripts/run-agent-a.ts
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
/**
|
||||||
|
* run-agent-a.ts — Marketplace buyer happy-path runner (Agent A).
|
||||||
|
*
|
||||||
|
* Drives Marketplace.submitPrompt then runAccept for 3 cycles against the
|
||||||
|
* live supplier on Vector L2 testnet. Logs every tx hash, HTTP code,
|
||||||
|
* receipt verification step, and wallet delta. Throwaway script — not
|
||||||
|
* committed.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* tsx buyer/scripts/run-agent-a.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync } from "fs";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import * as ed from "@noble/ed25519";
|
||||||
|
import { LiveOgmiosProvider, type ChainProvider, type OutputReference } from "@marketplace/shared/chain";
|
||||||
|
import { Marketplace, MemoryTaskHistoryStore } from "../src/sdk/index.js";
|
||||||
|
import { runAccept } from "../src/cli/acceptFlow.js";
|
||||||
|
import { deriveWalletKey } from "../src/index.js";
|
||||||
|
|
||||||
|
// Wire ed25519 sha512 hook (idempotent — matches buyer/src/index.ts).
|
||||||
|
ed.etc.sha512Sync = (...messages: Uint8Array[]): Uint8Array => {
|
||||||
|
const h = createHash("sha512");
|
||||||
|
for (const m of messages) h.update(m);
|
||||||
|
return new Uint8Array(h.digest());
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Config ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function loadDotEnv(path: string): Record<string, string> {
|
||||||
|
const text = readFileSync(path, "utf-8");
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const eq = trimmed.indexOf("=");
|
||||||
|
if (eq === -1) continue;
|
||||||
|
env[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = loadDotEnv("/home/david/code/agents-marketplace/buyer/.env");
|
||||||
|
const ADVERT_REF: OutputReference = {
|
||||||
|
txHash: "9e9a86098c18cecf544d4c69e16ace850ab8c271a8bbea2facdaafc3be52701c",
|
||||||
|
index: 0,
|
||||||
|
};
|
||||||
|
const PROMPTS = [
|
||||||
|
"Summarize the Cardano EUTxO model in three concise bullets.",
|
||||||
|
"Name three differences between Cardano and Ethereum smart contracts; one short bullet each.",
|
||||||
|
"What is BLAKE2b used for in Cardano? Answer in two sentences.",
|
||||||
|
];
|
||||||
|
const FUND_REQUIRED_LOVELACE = 7_000_000n; // ≥ one cycle's working capital (5 + 1 + ~0.5 fee headroom)
|
||||||
|
|
||||||
|
const wk = deriveWalletKey(env.BUYER_PRIV_KEY_HEX, 1);
|
||||||
|
const chain: ChainProvider = new LiveOgmiosProvider({ ogmiosUrl: env.OGMIOS_URL });
|
||||||
|
const marketplace = new Marketplace({
|
||||||
|
chain,
|
||||||
|
indexerUrl: env.INDEXER_URL,
|
||||||
|
walletKey: wk,
|
||||||
|
networkParams: { networkId: 1 },
|
||||||
|
historyStore: new MemoryTaskHistoryStore(),
|
||||||
|
});
|
||||||
|
|
||||||
|
marketplace.on("progress", (evt: unknown) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[progress] ${JSON.stringify(evt)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function walletLovelace(): Promise<bigint> {
|
||||||
|
const utxos = await chain.queryUtxosByAddress(wk.address);
|
||||||
|
return utxos.reduce((acc, u) => acc + BigInt(u.lovelace), 0n);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTipSlot(): Promise<number> {
|
||||||
|
return await chain.tip();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChainEvent {
|
||||||
|
type: string;
|
||||||
|
ref: string;
|
||||||
|
slot: number;
|
||||||
|
txHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EscrowView {
|
||||||
|
utxo_ref: string;
|
||||||
|
buyer_pkh: string;
|
||||||
|
prompt_hash: string;
|
||||||
|
state: string;
|
||||||
|
submitted_at: number | null;
|
||||||
|
created_slot: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the current Submitted-state escrow ref for our buyer given the
|
||||||
|
* prompt_hash we committed. Polls the indexer until the row appears (the
|
||||||
|
* supplier's Submit tx is already on chain by the time submitPrompt returns,
|
||||||
|
* but the indexer needs a slot or two to roll forward).
|
||||||
|
*/
|
||||||
|
async function fetchBuyerEscrows(): Promise<EscrowView[]> {
|
||||||
|
const resp = await fetch(`${env.INDEXER_URL}/escrows?buyer=${wk.pubKeyHash}`);
|
||||||
|
if (!resp.ok) return [];
|
||||||
|
return (await resp.json()) as EscrowView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the current Submitted-state escrow ref for our buyer given the
|
||||||
|
* prompt_hash we committed. Polls the indexer until the row appears (the
|
||||||
|
* supplier's Submit tx is already on chain by the time submitPrompt returns,
|
||||||
|
* but the indexer needs a slot or two to roll forward).
|
||||||
|
*/
|
||||||
|
async function findSubmittedEscrowRef(promptHash: string, postedAtMs: number, timeoutMs = 30_000): Promise<OutputReference> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const rows = await fetchBuyerEscrows();
|
||||||
|
// The indexer returns separate rows for each state transition. Pick
|
||||||
|
// state=Submitted, prompt_hash matches, and posted_at matches the new
|
||||||
|
// cycle (so a stale already-accepted escrow with the same prompt_hash
|
||||||
|
// doesn't shadow this run).
|
||||||
|
const match = rows
|
||||||
|
.filter((r) => r.prompt_hash === promptHash && r.state === "Submitted")
|
||||||
|
.sort((a, b) => (b.submitted_at ?? 0) - (a.submitted_at ?? 0))[0];
|
||||||
|
if (match) {
|
||||||
|
const [tx, idxStr] = match.utxo_ref.split("#");
|
||||||
|
return { txHash: tx, index: parseInt(idxStr, 10) };
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 2_000));
|
||||||
|
}
|
||||||
|
throw new Error(`indexer: no Submitted escrow with prompt_hash=${promptHash} within ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract PostEscrow / Claim / Submit tx hashes for a specific prompt_hash
|
||||||
|
* by looking at the indexer's per-state-row utxo_refs.
|
||||||
|
*/
|
||||||
|
function extractLifecycleTxs(rows: EscrowView[], promptHash: string): { post: string | null; claim: string | null; submit: string | null } {
|
||||||
|
const out = { post: null as string | null, claim: null as string | null, submit: null as string | null };
|
||||||
|
// The indexer stores one row per state transition keyed by utxo_ref.
|
||||||
|
// For an in-flight escrow, all three rows share the same posted_at/prompt_hash.
|
||||||
|
// Pick the rows that match our prompt_hash AND the most-recent posted_at
|
||||||
|
// (in case we re-ran the same prompt earlier).
|
||||||
|
const matching = rows.filter((r) => r.prompt_hash === promptHash);
|
||||||
|
if (matching.length === 0) return out;
|
||||||
|
// Sort by created_slot desc and take the freshest cluster.
|
||||||
|
matching.sort((a, b) => b.created_slot - a.created_slot);
|
||||||
|
const newestSlot = matching[0].created_slot;
|
||||||
|
// Only keep rows within a small slot window of the newest (Submit slot).
|
||||||
|
// Claim and PostEscrow are within a few slots of Submit.
|
||||||
|
const recent = matching.filter((r) => newestSlot - r.created_slot < 500);
|
||||||
|
for (const r of recent) {
|
||||||
|
const [tx] = r.utxo_ref.split("#");
|
||||||
|
if (r.state === "Submitted") out.submit = tx;
|
||||||
|
else if (r.state === "Claimed") out.claim = tx;
|
||||||
|
else if (r.state === "Open") out.post = tx;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull events since `sinceSlot - 1` and return as an array. */
|
||||||
|
async function fetchEvents(sinceSlot: number, limit = 200): Promise<ChainEvent[]> {
|
||||||
|
const url = `${env.INDEXER_URL}/events?since_slot=${Math.max(0, sinceSlot - 1)}&limit=${limit}`;
|
||||||
|
// The /events endpoint is SSE-only. Pull with manual abort after 2s.
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const t = setTimeout(() => ctrl.abort(), 2_500);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, { signal: ctrl.signal });
|
||||||
|
if (!resp.ok || !resp.body) return [];
|
||||||
|
const reader = resp.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = "";
|
||||||
|
const evts: ChainEvent[] = [];
|
||||||
|
while (true) {
|
||||||
|
let chunk;
|
||||||
|
try {
|
||||||
|
chunk = await reader.read();
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (chunk.done) break;
|
||||||
|
buf += decoder.decode(chunk.value, { stream: true });
|
||||||
|
// Process complete SSE messages (delimited by \n\n).
|
||||||
|
while (true) {
|
||||||
|
const i = buf.indexOf("\n\n");
|
||||||
|
if (i === -1) break;
|
||||||
|
const msg = buf.slice(0, i);
|
||||||
|
buf = buf.slice(i + 2);
|
||||||
|
const lines = msg.split("\n");
|
||||||
|
let type = "";
|
||||||
|
let data = "";
|
||||||
|
for (const ln of lines) {
|
||||||
|
if (ln.startsWith("event:")) type = ln.slice(6).trim();
|
||||||
|
else if (ln.startsWith("data:")) data = ln.slice(5).trim();
|
||||||
|
}
|
||||||
|
if (!type || type === "sync-progress" || !data) continue;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data) as ChainEvent;
|
||||||
|
evts.push(parsed);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return evts;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CycleResult {
|
||||||
|
cycle: number;
|
||||||
|
promptSummary: string;
|
||||||
|
postEscrowTx: string;
|
||||||
|
escrowRef: string;
|
||||||
|
claimTx: string | null;
|
||||||
|
submitTx: string | null;
|
||||||
|
acceptTx: string | null;
|
||||||
|
wallclockMs: number | null;
|
||||||
|
walltimeMs: number;
|
||||||
|
netDeltaLovelace: string;
|
||||||
|
status: "ok" | "failed";
|
||||||
|
error?: string;
|
||||||
|
response?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCycle(idx: number, prompt: string): Promise<CycleResult> {
|
||||||
|
const result: CycleResult = {
|
||||||
|
cycle: idx,
|
||||||
|
promptSummary: prompt.slice(0, 70),
|
||||||
|
postEscrowTx: "",
|
||||||
|
escrowRef: "",
|
||||||
|
claimTx: null,
|
||||||
|
submitTx: null,
|
||||||
|
acceptTx: null,
|
||||||
|
wallclockMs: null,
|
||||||
|
walltimeMs: 0,
|
||||||
|
netDeltaLovelace: "0",
|
||||||
|
status: "failed",
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`\n=== CYCLE ${idx} ===`);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`prompt: ${prompt}`);
|
||||||
|
|
||||||
|
const balBefore = await walletLovelace();
|
||||||
|
const slotBefore = await fetchTipSlot();
|
||||||
|
const tStart = Date.now();
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`bal_before=${balBefore} lovelace (~${Number(balBefore) / 1e6} AP3X), slot=${slotBefore}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messages = [{ role: "user" as const, content: prompt }];
|
||||||
|
const submitResult = await marketplace.submitPrompt({
|
||||||
|
advertRef: ADVERT_REF,
|
||||||
|
messages,
|
||||||
|
payment_lovelace: 5_000_000n,
|
||||||
|
});
|
||||||
|
result.escrowRef = `${submitResult.escrowRef.txHash}#${submitResult.escrowRef.index}`;
|
||||||
|
result.postEscrowTx = submitResult.escrowRef.txHash;
|
||||||
|
result.wallclockMs = submitResult.receipt.wallclock_ms ?? null;
|
||||||
|
result.response = submitResult.response;
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`escrow_ref=${result.escrowRef}`);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`receipt: pkh=${submitResult.receipt.supplier_pkh.slice(0, 12)}.. prompt_hash=${submitResult.receipt.prompt_hash.slice(0, 12)}.. wallclock=${submitResult.receipt.wallclock_ms}ms`);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`response (${submitResult.response.length} chars): ${submitResult.response.slice(0, 200)}${submitResult.response.length > 200 ? "..." : ""}`);
|
||||||
|
|
||||||
|
// The escrowRef returned by submitPrompt is the ORIGINAL Open UTxO,
|
||||||
|
// which has been spent by Claim then Submit. Look up the current
|
||||||
|
// Submitted-state ref via the indexer, then accept that.
|
||||||
|
const submittedRef = await findSubmittedEscrowRef(submitResult.receipt.prompt_hash, Date.now() - 120_000);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`submitted_escrow_ref=${submittedRef.txHash}#${submittedRef.index}`);
|
||||||
|
|
||||||
|
// Extract Claim / Submit tx hashes from indexer.
|
||||||
|
const buyerRows = await fetchBuyerEscrows();
|
||||||
|
const lifecycle = extractLifecycleTxs(buyerRows, submitResult.receipt.prompt_hash);
|
||||||
|
result.postEscrowTx = lifecycle.post ?? result.postEscrowTx;
|
||||||
|
result.claimTx = lifecycle.claim;
|
||||||
|
result.submitTx = lifecycle.submit;
|
||||||
|
|
||||||
|
const acceptResult = await runAccept({
|
||||||
|
chain,
|
||||||
|
walletKey: wk,
|
||||||
|
escrowRef: submittedRef,
|
||||||
|
log: (line) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[accept] ${line}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
result.acceptTx = acceptResult.txHash;
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`accept_tx=${acceptResult.txHash}`);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`claim_tx=${result.claimTx} submit_tx=${result.submitTx}`);
|
||||||
|
|
||||||
|
// Give indexer a moment to roll forward past Accept (best-effort log).
|
||||||
|
await new Promise((r) => setTimeout(r, 4_000));
|
||||||
|
|
||||||
|
const tEnd = Date.now();
|
||||||
|
result.walltimeMs = tEnd - tStart;
|
||||||
|
const balAfter = await walletLovelace();
|
||||||
|
result.netDeltaLovelace = (balAfter - balBefore).toString();
|
||||||
|
result.status = "ok";
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`bal_after=${balAfter} lovelace; net_delta=${result.netDeltaLovelace} lovelace (~${Number(balAfter - balBefore) / 1e6} AP3X); wall=${result.walltimeMs}ms`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
result.error = msg;
|
||||||
|
result.walltimeMs = Date.now() - tStart;
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`cycle ${idx} FAILED: ${msg}`);
|
||||||
|
if (err instanceof Error && err.stack) console.error(err.stack);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`buyer addr=${wk.address}`);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`buyer pkh=${wk.pubKeyHash}`);
|
||||||
|
const bal0 = await walletLovelace();
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`starting balance: ${bal0} lovelace (~${Number(bal0) / 1e6} AP3X)`);
|
||||||
|
if (bal0 < FUND_REQUIRED_LOVELACE) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`INSUFFICIENT FUNDS — need ≥ ${FUND_REQUIRED_LOVELACE} lovelace`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: CycleResult[] = [];
|
||||||
|
for (let i = 0; i < PROMPTS.length; i++) {
|
||||||
|
const r = await runCycle(i + 1, PROMPTS[i]);
|
||||||
|
results.push(r);
|
||||||
|
if (r.status !== "ok") {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`Cycle ${i + 1} failed — stopping; will report what we have.`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print final markdown table.
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("\n\n=== REPORT ===\n");
|
||||||
|
const header = "| Cycle | Prompt | PostEscrow tx | Claim tx | Submit tx | Accept tx | Wall ms | Supplier wallclock_ms | Net Δ (lovelace) |";
|
||||||
|
const sep = "|-------|--------|---------------|----------|-----------|-----------|---------|------------------------|------------------|";
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(header);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(sep);
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.status === "ok") {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`| ${r.cycle} | ${r.promptSummary.slice(0, 40)} | \`${r.postEscrowTx.slice(0, 12)}…\` | \`${(r.claimTx ?? "?").slice(0, 12)}…\` | \`${(r.submitTx ?? "?").slice(0, 12)}…\` | \`${(r.acceptTx ?? "?").slice(0, 12)}…\` | ${r.walltimeMs} | ${r.wallclockMs} | ${r.netDeltaLovelace} |`);
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`| ${r.cycle} | ${r.promptSummary.slice(0, 40)} | FAILED: ${r.error} | | | | ${r.walltimeMs} | | |`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dump full JSON for the operator log.
|
||||||
|
const out = `/tmp/run-agent-a-${Date.now()}.json`;
|
||||||
|
writeFileSync(out, JSON.stringify(results, null, 2));
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`\nfull results written to ${out}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("[runner] fatal:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
690
buyer/scripts/run-agent-b.ts
Normal file
690
buyer/scripts/run-agent-b.ts
Normal file
@@ -0,0 +1,690 @@
|
|||||||
|
/**
|
||||||
|
* run-agent-b.ts — Marketplace adversarial-buyer runner (Agent B).
|
||||||
|
*
|
||||||
|
* Probes marketplace defenses across four attack styles. Throwaway script —
|
||||||
|
* not committed. Logs every tx hash, HTTP code, validator rejection.
|
||||||
|
*
|
||||||
|
* Hard caps (operator-confirmed):
|
||||||
|
* - max 10 escrows
|
||||||
|
* - burn ceiling 10 AP3X
|
||||||
|
* - max 100 HTTP requests at ≤2 rps
|
||||||
|
* - stop on first GAP
|
||||||
|
*
|
||||||
|
* Deferred sub-attacks (not implemented this run, called out in the report):
|
||||||
|
* - B1 (capability_id mismatch in datum) — needs hand-rolled datum builder
|
||||||
|
* - B6 (FLOAT64 CBOR injection) — needs hand-rolled CBOR encoder
|
||||||
|
* - D5 (3 sybil wallets concurrent post) — needs faucet + 3 wallet derivations
|
||||||
|
*
|
||||||
|
* Usage: tsx buyer/scripts/run-agent-b.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync } from "fs";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import * as ed from "@noble/ed25519";
|
||||||
|
import {
|
||||||
|
LiveOgmiosProvider,
|
||||||
|
type ChainProvider,
|
||||||
|
type OutputReference,
|
||||||
|
} from "@marketplace/shared/chain";
|
||||||
|
import {
|
||||||
|
buildPostEscrowTx,
|
||||||
|
buildReclaimTx,
|
||||||
|
buildReleaseTx,
|
||||||
|
buildAcceptTx,
|
||||||
|
TxConstructionError,
|
||||||
|
} from "@marketplace/shared/tx";
|
||||||
|
import { Marketplace, MemoryTaskHistoryStore } from "../src/sdk/index.js";
|
||||||
|
import { runAccept } from "../src/cli/acceptFlow.js";
|
||||||
|
import { deriveWalletKey } from "../src/index.js";
|
||||||
|
|
||||||
|
ed.etc.sha512Sync = (...messages: Uint8Array[]): Uint8Array => {
|
||||||
|
const h = createHash("sha512");
|
||||||
|
for (const m of messages) h.update(m);
|
||||||
|
return new Uint8Array(h.digest());
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Config ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function loadDotEnv(path: string): Record<string, string> {
|
||||||
|
const text = readFileSync(path, "utf-8");
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const eq = trimmed.indexOf("=");
|
||||||
|
if (eq === -1) continue;
|
||||||
|
env[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = loadDotEnv("/home/david/code/agents-marketplace/buyer/.env");
|
||||||
|
|
||||||
|
const ADVERT_REF_ACTIVE: OutputReference = {
|
||||||
|
txHash: "9e9a86098c18cecf544d4c69e16ace850ab8c271a8bbea2facdaafc3be52701c",
|
||||||
|
index: 0,
|
||||||
|
};
|
||||||
|
const ADVERT_REF_RETIRED: OutputReference = {
|
||||||
|
txHash: "386d30fdc3e69c5a7063b9bb99a6ea3e2bee79498c16a22cf3a9c74a4a004040",
|
||||||
|
index: 0,
|
||||||
|
};
|
||||||
|
const ADVERT_PRICE_LOVELACE = 5_000_000n;
|
||||||
|
const SUPPLIER_URL = "https://supplier.summitstak.ing";
|
||||||
|
|
||||||
|
// Hard caps. Raised burn cap on the continuation run (reclaim now works after
|
||||||
|
// the reclaim.ts live-mode fix landed mid-session). The original 10 AP3X cap
|
||||||
|
// fired prematurely because burned-counter conflated locked-in-escrow with
|
||||||
|
// irrecoverable burn. Post-fix: net fees per chargeable attack are ~0.5-1 AP3X.
|
||||||
|
const CAP_ESCROWS = 10;
|
||||||
|
const CAP_BURN_LOVELACE = 25_000_000n;
|
||||||
|
const CAP_REQUESTS = 100;
|
||||||
|
const RATE_LIMIT_MS = 500;
|
||||||
|
|
||||||
|
const wk = deriveWalletKey(env.BUYER_PRIV_KEY_HEX, 1);
|
||||||
|
const chain: ChainProvider = new LiveOgmiosProvider({ ogmiosUrl: env.OGMIOS_URL });
|
||||||
|
const marketplace = new Marketplace({
|
||||||
|
chain,
|
||||||
|
indexerUrl: env.INDEXER_URL,
|
||||||
|
walletKey: wk,
|
||||||
|
networkParams: { networkId: 1 },
|
||||||
|
historyStore: new MemoryTaskHistoryStore(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Hard-cap accounting ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Counters {
|
||||||
|
escrowsPosted: number;
|
||||||
|
requestsSent: number;
|
||||||
|
burnedLovelace: bigint;
|
||||||
|
}
|
||||||
|
const counters: Counters = { escrowsPosted: 0, requestsSent: 0, burnedLovelace: 0n };
|
||||||
|
let lastReqMs = 0;
|
||||||
|
|
||||||
|
function checkCapsBeforePost(): void {
|
||||||
|
if (counters.escrowsPosted >= CAP_ESCROWS) throw new Error(`hard-cap: ${counters.escrowsPosted} escrows posted (limit ${CAP_ESCROWS})`);
|
||||||
|
if (counters.burnedLovelace >= CAP_BURN_LOVELACE) throw new Error(`hard-cap: burn ${counters.burnedLovelace} ≥ ${CAP_BURN_LOVELACE}`);
|
||||||
|
}
|
||||||
|
function checkCapsBeforeRequest(): void {
|
||||||
|
if (counters.requestsSent >= CAP_REQUESTS) throw new Error(`hard-cap: ${counters.requestsSent} requests sent (limit ${CAP_REQUESTS})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rateLimitedFetch(url: string, init?: RequestInit): Promise<Response> {
|
||||||
|
checkCapsBeforeRequest();
|
||||||
|
const now = Date.now();
|
||||||
|
const wait = Math.max(0, RATE_LIMIT_MS - (now - lastReqMs));
|
||||||
|
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
||||||
|
lastReqMs = Date.now();
|
||||||
|
counters.requestsSent += 1;
|
||||||
|
return fetch(url, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Logger ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function log(msg: string): void {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[${new Date().toISOString()}] ${msg}`);
|
||||||
|
}
|
||||||
|
function head(body: unknown, n = 400): string {
|
||||||
|
const s = typeof body === "string" ? body : JSON.stringify(body);
|
||||||
|
return s.length <= n ? s : `${s.slice(0, n)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Attack result ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Verdict = "BLOCKED" | "GAP" | "UNCLEAR" | "SKIPPED";
|
||||||
|
|
||||||
|
interface AttackResult {
|
||||||
|
id: string;
|
||||||
|
category: "A" | "B" | "C" | "D";
|
||||||
|
what: string;
|
||||||
|
expected: string;
|
||||||
|
what_happened: string;
|
||||||
|
verdict: Verdict;
|
||||||
|
evidence: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function walletLovelace(): Promise<bigint> {
|
||||||
|
const utxos = await chain.queryUtxosByAddress(wk.address);
|
||||||
|
return utxos.reduce((acc, u) => acc + BigInt(u.lovelace), 0n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── A: Within-protocol cheating ────────────────────────────────────────
|
||||||
|
|
||||||
|
async function attack_A2(): Promise<AttackResult> {
|
||||||
|
const id = "A2";
|
||||||
|
const what = "Build a Release tx as the BUYER (Release is supplier-only).";
|
||||||
|
const expected = "buildReleaseTx with non-supplier key → either signature/owner mismatch, or chain validator rejection.";
|
||||||
|
try {
|
||||||
|
// First we need a Submitted-state escrow. Reuse one from indexer if present;
|
||||||
|
// otherwise this attack is unreachable without a warm-up.
|
||||||
|
const resp = await rateLimitedFetch(`${env.INDEXER_URL}/escrows?buyer=${wk.pubKeyHash}`);
|
||||||
|
const rows = (await resp.json()) as Array<{ utxo_ref: string; state: string; submitted_at: number | null }>;
|
||||||
|
const submitted = rows
|
||||||
|
.filter((r) => r.state === "Submitted")
|
||||||
|
.sort((a, b) => (b.submitted_at ?? 0) - (a.submitted_at ?? 0))[0];
|
||||||
|
if (!submitted) {
|
||||||
|
return { id, category: "A", what, expected, what_happened: "no Submitted-state escrow available to attack; needed a warm-up but A1 will post one later", verdict: "SKIPPED", evidence: "indexer has 0 Submitted rows for this buyer" };
|
||||||
|
}
|
||||||
|
const [tx, ixs] = submitted.utxo_ref.split("#");
|
||||||
|
const escrowRef: OutputReference = { txHash: tx, index: parseInt(ixs, 10) };
|
||||||
|
log(`A2: targeting Submitted ${submitted.utxo_ref}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// buildReleaseTx is typed to take `supplierKey: WalletKey` — pass our buyer key.
|
||||||
|
// The builder may accept it (it doesn't check the pkh against the advert at build time);
|
||||||
|
// chain validator will reject because the script requires supplier_pkh signature.
|
||||||
|
await (buildReleaseTx as unknown as (p: { chain: ChainProvider; supplierKey: typeof wk; escrowRef: OutputReference }) => Promise<{ expectedTxHash: string }>)({ chain, supplierKey: wk, escrowRef });
|
||||||
|
return { id, category: "A", what, expected, what_happened: "buildReleaseTx + submit succeeded as buyer — chain accepted the tx", verdict: "GAP", evidence: `escrow_ref=${submitted.utxo_ref}` };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return { id, category: "A", what, expected, what_happened: `rejected: ${head(msg)}`, verdict: "BLOCKED", evidence: msg.slice(0, 500) };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return { id, category: "A", what, expected, what_happened: `setup error: ${head(msg)}`, verdict: "UNCLEAR", evidence: msg.slice(0, 500) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attack_A3(): Promise<AttackResult> {
|
||||||
|
const id = "A3";
|
||||||
|
const what = "PostEscrow expects deliver_by computed by builder; we can't easily set it in the past via the SDK without forking the builder. We try buildPostEscrowTx in a state where the advert's max_processing_ms produces a future deliver_by — so the BUILDER won't reject, but we verify the build path computes deliver_by correctly.";
|
||||||
|
const expected = "Defense: builder always computes deliver_by = now + advert.max_processing_ms + 30000ms, so the buyer can't choose. This attack is effectively unreachable via the standard builder; skip with note.";
|
||||||
|
return { id, category: "A", what, expected, what_happened: "deferred — builder computes deliver_by; manual datum forge needed to inject past value", verdict: "SKIPPED", evidence: "buildPostEscrowTx packages/shared/src/tx/escrow/postEscrow.ts" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attack_A4(): Promise<AttackResult> {
|
||||||
|
const id = "A4";
|
||||||
|
const what = "PostEscrow then immediately attempt Reclaim before deliver_by.";
|
||||||
|
const expected = "buildReclaimTx packages/shared/src/tx/escrow/reclaim.ts:66 rejects with TxConstructionError('reclaim before deliver_by').";
|
||||||
|
checkCapsBeforePost();
|
||||||
|
const startBal = await walletLovelace();
|
||||||
|
try {
|
||||||
|
const messages = [{ role: "user" as const, content: "A4 probe: do not respond, will be reclaimed." }];
|
||||||
|
log("A4: posting escrow for reclaim attempt");
|
||||||
|
const submitResult = await marketplace.submitPrompt({
|
||||||
|
advertRef: ADVERT_REF_ACTIVE,
|
||||||
|
messages,
|
||||||
|
payment_lovelace: ADVERT_PRICE_LOVELACE,
|
||||||
|
}).catch((err) => err);
|
||||||
|
|
||||||
|
// Whether submitPrompt succeeded or failed, attempt Reclaim against the
|
||||||
|
// posted escrow. If submitPrompt succeeded, the escrow has been Submitted —
|
||||||
|
// Reclaim should reject for state reasons. If it failed during supplier
|
||||||
|
// call, the Open escrow exists and we attempt Reclaim against THAT.
|
||||||
|
let escrowRef: OutputReference | null = null;
|
||||||
|
if (submitResult && typeof submitResult === "object" && "escrowRef" in submitResult) {
|
||||||
|
escrowRef = (submitResult as { escrowRef: OutputReference }).escrowRef;
|
||||||
|
} else {
|
||||||
|
// submitPrompt threw mid-flight; look up the most recent Open escrow.
|
||||||
|
const r = await rateLimitedFetch(`${env.INDEXER_URL}/escrows?buyer=${wk.pubKeyHash}`);
|
||||||
|
const rows = (await r.json()) as Array<{ utxo_ref: string; state: string; posted_at: number }>;
|
||||||
|
const open = rows.filter((x) => x.state === "Open").sort((a, b) => b.posted_at - a.posted_at)[0];
|
||||||
|
if (open) {
|
||||||
|
const [tx, ix] = open.utxo_ref.split("#");
|
||||||
|
escrowRef = { txHash: tx, index: parseInt(ix, 10) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!escrowRef) {
|
||||||
|
const endBal = await walletLovelace();
|
||||||
|
counters.burnedLovelace += (startBal - endBal);
|
||||||
|
return { id, category: "A", what, expected, what_happened: "couldn't locate escrow ref to reclaim", verdict: "UNCLEAR", evidence: "" };
|
||||||
|
}
|
||||||
|
counters.escrowsPosted += 1;
|
||||||
|
log(`A4: attempting reclaim of ${escrowRef.txHash}#${escrowRef.index}`);
|
||||||
|
try {
|
||||||
|
await buildReclaimTx({ chain, buyerKey: wk, escrowRef });
|
||||||
|
const endBal = await walletLovelace();
|
||||||
|
counters.burnedLovelace += (startBal - endBal);
|
||||||
|
return { id, category: "A", what, expected, what_happened: "reclaim before deliver_by SUCCEEDED — defense missing", verdict: "GAP", evidence: `escrow_ref=${escrowRef.txHash}#${escrowRef.index}` };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
const endBal = await walletLovelace();
|
||||||
|
counters.burnedLovelace += (startBal - endBal);
|
||||||
|
return { id, category: "A", what, expected, what_happened: `rejected: ${head(msg)}`, verdict: "BLOCKED", evidence: msg.slice(0, 500) };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const endBal = await walletLovelace();
|
||||||
|
counters.burnedLovelace += (startBal - endBal);
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return { id, category: "A", what, expected, what_happened: `setup error: ${head(msg)}`, verdict: "UNCLEAR", evidence: msg.slice(0, 500) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── B: Protocol-fuzzing ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function attack_B2(): Promise<AttackResult> {
|
||||||
|
const id = "B2";
|
||||||
|
const what = "PostEscrow with prompt_hash sha256(messages_A), send chat body with messages_B (different).";
|
||||||
|
const expected = "supplier 409 prompt_mismatch (server.ts:278).";
|
||||||
|
checkCapsBeforePost();
|
||||||
|
const startBal = await walletLovelace();
|
||||||
|
try {
|
||||||
|
// Post an honest escrow first (prompt = canonical messages_A)
|
||||||
|
const messagesA = [{ role: "user" as const, content: "B2 probe: commit-this-prompt" }];
|
||||||
|
log("B2: posting escrow with messages_A");
|
||||||
|
let escrowRef: OutputReference;
|
||||||
|
try {
|
||||||
|
const escrowResult = await (await import("@marketplace/shared/tx")).buildPostEscrowTx({
|
||||||
|
chain, buyerKey: wk, advertRef: ADVERT_REF_ACTIVE, messages: messagesA, payment_lovelace: ADVERT_PRICE_LOVELACE,
|
||||||
|
});
|
||||||
|
escrowRef = escrowResult.escrowOutputRef;
|
||||||
|
await chain.awaitTx(escrowResult.expectedTxHash, 120_000);
|
||||||
|
} catch (err) {
|
||||||
|
const endBal = await walletLovelace();
|
||||||
|
counters.burnedLovelace += (startBal - endBal);
|
||||||
|
return { id, category: "B", what, expected, what_happened: `couldn't post escrow: ${head(err instanceof Error ? err.message : String(err))}`, verdict: "UNCLEAR", evidence: "" };
|
||||||
|
}
|
||||||
|
counters.escrowsPosted += 1;
|
||||||
|
const escrowRefStr = `${escrowRef.txHash}#${escrowRef.index}`;
|
||||||
|
log(`B2: escrow=${escrowRefStr}; sending chat body with messages_B`);
|
||||||
|
|
||||||
|
// Send chat body with DIFFERENT messages_B
|
||||||
|
const messagesB = [{ role: "user", content: "B2 probe: but-actually-this-prompt" }];
|
||||||
|
const resp = await rateLimitedFetch(`${SUPPLIER_URL}/v1/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", "X-Escrow-Ref": escrowRefStr },
|
||||||
|
body: JSON.stringify({ model: "qwen3.6:35b", messages: messagesB, max_tokens: 100 }),
|
||||||
|
});
|
||||||
|
const text = await resp.text();
|
||||||
|
// Reclaim after the supplier rejects so we get our money back.
|
||||||
|
// We have to wait until past deliver_by to reclaim. For the report we just
|
||||||
|
// observe the immediate response; reclaim happens on a later sweep or
|
||||||
|
// post-window automatic.
|
||||||
|
const verdict: Verdict = resp.status === 409 && text.includes("prompt_mismatch") ? "BLOCKED" : (resp.status >= 200 && resp.status < 300 ? "GAP" : "BLOCKED");
|
||||||
|
const endBal = await walletLovelace();
|
||||||
|
counters.burnedLovelace += (startBal - endBal);
|
||||||
|
return { id, category: "B", what, expected, what_happened: `HTTP ${resp.status}: ${head(text, 200)}`, verdict, evidence: `escrow=${escrowRefStr}; status=${resp.status}; body=${head(text, 400)}` };
|
||||||
|
} catch (err) {
|
||||||
|
const endBal = await walletLovelace();
|
||||||
|
counters.burnedLovelace += (startBal - endBal);
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return { id, category: "B", what, expected, what_happened: `setup error: ${head(msg)}`, verdict: "UNCLEAR", evidence: msg.slice(0, 500) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attack_B3(): Promise<AttackResult> {
|
||||||
|
const id = "B3";
|
||||||
|
const what = "POST /v1/chat/completions with X-Escrow-Ref pointing at a random non-existent ref.";
|
||||||
|
const expected = "404 escrow_not_found or 400 — should not reach inference.";
|
||||||
|
const bogus = "0".repeat(63) + "1#0";
|
||||||
|
const resp = await rateLimitedFetch(`${SUPPLIER_URL}/v1/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", "X-Escrow-Ref": bogus },
|
||||||
|
body: JSON.stringify({ model: "qwen3.6:35b", messages: [{ role: "user", content: "B3 probe" }], max_tokens: 50 }),
|
||||||
|
});
|
||||||
|
const text = await resp.text();
|
||||||
|
const verdict: Verdict = (resp.status >= 400 && resp.status < 500) ? "BLOCKED" : "GAP";
|
||||||
|
return { id, category: "B", what, expected, what_happened: `HTTP ${resp.status}: ${head(text, 200)}`, verdict, evidence: `status=${resp.status}; body=${head(text, 400)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attack_B5(): Promise<AttackResult> {
|
||||||
|
const id = "B5";
|
||||||
|
const what = "Call buildPostEscrowTx with payment_lovelace strictly less than advert.price_lovelace.";
|
||||||
|
const expected = "TxConstructionError 'payment must equal advertised price' (postEscrow.ts:96) — build-time rejection, no chain spend.";
|
||||||
|
try {
|
||||||
|
const messages = [{ role: "user" as const, content: "B5 probe" }];
|
||||||
|
await buildPostEscrowTx({ chain, buyerKey: wk, advertRef: ADVERT_REF_ACTIVE, messages, payment_lovelace: 1_000_000n });
|
||||||
|
return { id, category: "B", what, expected, what_happened: "builder accepted underpayment — GAP", verdict: "GAP", evidence: "buildPostEscrowTx succeeded with payment=1_000_000 vs advert.price=5_000_000" };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
const isPriceErr = msg.toLowerCase().includes("payment") && msg.toLowerCase().includes("price");
|
||||||
|
return { id, category: "B", what, expected, what_happened: `rejected: ${head(msg)}`, verdict: isPriceErr ? "BLOCKED" : "UNCLEAR", evidence: msg.slice(0, 500) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── C: Liveness / DoS ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function attack_C1(): Promise<AttackResult> {
|
||||||
|
const id = "C1";
|
||||||
|
const what = "Send a request body >100KB.";
|
||||||
|
const expected = "413 or 400 before LLM invocation.";
|
||||||
|
const big = "A".repeat(120_000);
|
||||||
|
const resp = await rateLimitedFetch(`${SUPPLIER_URL}/v1/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", "X-Escrow-Ref": "0".repeat(64) + "#0" },
|
||||||
|
body: JSON.stringify({ model: "qwen3.6:35b", messages: [{ role: "user", content: big }], max_tokens: 50 }),
|
||||||
|
});
|
||||||
|
const text = await resp.text();
|
||||||
|
const verdict: Verdict = (resp.status >= 400 && resp.status < 500) ? "BLOCKED" : "GAP";
|
||||||
|
return { id, category: "C", what, expected, what_happened: `HTTP ${resp.status}: ${head(text, 200)}`, verdict, evidence: `status=${resp.status}; body_bytes=120000; resp=${head(text, 200)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attack_C2(): Promise<AttackResult> {
|
||||||
|
const id = "C2";
|
||||||
|
const what = "PostEscrow honestly, then chat body with max_output_tokens 999999.";
|
||||||
|
const expected = "400 output_cap_exceeded (server.ts:234).";
|
||||||
|
checkCapsBeforePost();
|
||||||
|
const startBal = await walletLovelace();
|
||||||
|
try {
|
||||||
|
const messages = [{ role: "user" as const, content: "C2 probe — request huge output." }];
|
||||||
|
log("C2: posting honest escrow then sending oversized max_tokens");
|
||||||
|
const escrowResult = await buildPostEscrowTx({
|
||||||
|
chain, buyerKey: wk, advertRef: ADVERT_REF_ACTIVE, messages, payment_lovelace: ADVERT_PRICE_LOVELACE,
|
||||||
|
});
|
||||||
|
counters.escrowsPosted += 1;
|
||||||
|
await chain.awaitTx(escrowResult.expectedTxHash, 120_000);
|
||||||
|
const escrowRefStr = `${escrowResult.escrowOutputRef.txHash}#${escrowResult.escrowOutputRef.index}`;
|
||||||
|
|
||||||
|
const resp = await rateLimitedFetch(`${SUPPLIER_URL}/v1/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", "X-Escrow-Ref": escrowRefStr },
|
||||||
|
body: JSON.stringify({ model: "qwen3.6:35b", messages, max_tokens: 999_999 }),
|
||||||
|
});
|
||||||
|
const text = await resp.text();
|
||||||
|
const verdict: Verdict = resp.status === 400 && text.includes("output_cap_exceeded") ? "BLOCKED" : (resp.status === 200 ? "GAP" : "UNCLEAR");
|
||||||
|
const endBal = await walletLovelace();
|
||||||
|
counters.burnedLovelace += (startBal - endBal);
|
||||||
|
return { id, category: "C", what, expected, what_happened: `HTTP ${resp.status}: ${head(text, 200)}`, verdict, evidence: `escrow=${escrowRefStr}; status=${resp.status}; body=${head(text, 400)}` };
|
||||||
|
} catch (err) {
|
||||||
|
const endBal = await walletLovelace();
|
||||||
|
counters.burnedLovelace += (startBal - endBal);
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return { id, category: "C", what, expected, what_happened: `setup error: ${head(msg)}`, verdict: "UNCLEAR", evidence: msg.slice(0, 500) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attack_C4(): Promise<AttackResult> {
|
||||||
|
const id = "C4";
|
||||||
|
const what = "50 concurrent requests at ≤2 rps with bogus X-Escrow-Ref headers. Liveness probe mid-burst.";
|
||||||
|
const expected = "all 4xx; supplier stays responsive; concurrent honest probe completes.";
|
||||||
|
const N = 50;
|
||||||
|
const bogus = "0".repeat(63) + "f#0";
|
||||||
|
const results: number[] = [];
|
||||||
|
const start = Date.now();
|
||||||
|
let ok4xx = 0;
|
||||||
|
let other = 0;
|
||||||
|
// Liveness probe: hit /capability mid-burst (cheap GET)
|
||||||
|
let livenessOk = false;
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
try {
|
||||||
|
checkCapsBeforeRequest();
|
||||||
|
const wait = Math.max(0, RATE_LIMIT_MS - (Date.now() - lastReqMs));
|
||||||
|
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
||||||
|
lastReqMs = Date.now();
|
||||||
|
counters.requestsSent += 1;
|
||||||
|
const resp = await fetch(`${SUPPLIER_URL}/v1/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", "X-Escrow-Ref": bogus },
|
||||||
|
body: JSON.stringify({ model: "qwen3.6:35b", messages: [{ role: "user", content: `probe-${i}` }], max_tokens: 1 }),
|
||||||
|
});
|
||||||
|
results.push(resp.status);
|
||||||
|
if (resp.status >= 400 && resp.status < 500) ok4xx += 1; else other += 1;
|
||||||
|
// Mid-burst liveness probe
|
||||||
|
if (i === Math.floor(N / 2)) {
|
||||||
|
try {
|
||||||
|
const ok = await fetch(`${SUPPLIER_URL}/healthz`, { signal: AbortSignal.timeout(5_000) });
|
||||||
|
livenessOk = ok.ok;
|
||||||
|
} catch { livenessOk = false; }
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
other += 1;
|
||||||
|
if (counters.requestsSent >= CAP_REQUESTS) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dur = Date.now() - start;
|
||||||
|
const verdict: Verdict = (other === 0 && livenessOk) ? "BLOCKED" : "UNCLEAR";
|
||||||
|
return { id, category: "C", what, expected, what_happened: `${ok4xx}/${results.length} 4xx, ${other} other; liveness=${livenessOk}; duration=${dur}ms`, verdict, evidence: `burst N=${N}, status_codes=${[...new Set(results)].join(",")}, liveness_probe=${livenessOk}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attack_C5(): Promise<AttackResult> {
|
||||||
|
const id = "C5";
|
||||||
|
const what = "Spam indexer /suppliers + /escrows/<ref> at 2 rps for 30s.";
|
||||||
|
const expected = "all 200; report any 5xx.";
|
||||||
|
const start = Date.now();
|
||||||
|
const statuses: number[] = [];
|
||||||
|
const refsToHit = [
|
||||||
|
`${env.INDEXER_URL}/suppliers`,
|
||||||
|
`${env.INDEXER_URL}/escrows/${ADVERT_REF_ACTIVE.txHash}%23${ADVERT_REF_ACTIVE.index}`,
|
||||||
|
];
|
||||||
|
let i = 0;
|
||||||
|
while (Date.now() - start < 30_000) {
|
||||||
|
try {
|
||||||
|
const url = refsToHit[i++ % refsToHit.length];
|
||||||
|
const resp = await rateLimitedFetch(url);
|
||||||
|
statuses.push(resp.status);
|
||||||
|
} catch (err) {
|
||||||
|
statuses.push(0);
|
||||||
|
if (counters.requestsSent >= CAP_REQUESTS) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const count5xx = statuses.filter((s) => s >= 500).length;
|
||||||
|
const count200 = statuses.filter((s) => s === 200).length;
|
||||||
|
const verdict: Verdict = (count5xx === 0 && count200 > 0) ? "BLOCKED" : (count5xx > 0 ? "GAP" : "UNCLEAR");
|
||||||
|
return { id, category: "C", what, expected, what_happened: `${statuses.length} reqs in 30s: ${count200} 200s, ${count5xx} 5xx`, verdict, evidence: `unique_codes=${[...new Set(statuses)].join(",")}; n=${statuses.length}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── D: Social / spec attacks ───────────────────────────────────────────
|
||||||
|
|
||||||
|
async function attack_D1(): Promise<AttackResult> {
|
||||||
|
const id = "D1";
|
||||||
|
const what = "POST /v1/chat/completions with NO X-Escrow-Ref header.";
|
||||||
|
const expected = "400 escrow_ref_required (server.ts:184).";
|
||||||
|
const resp = await rateLimitedFetch(`${SUPPLIER_URL}/v1/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ model: "qwen3.6:35b", messages: [{ role: "user", content: "D1 probe" }], max_tokens: 50 }),
|
||||||
|
});
|
||||||
|
const text = await resp.text();
|
||||||
|
const isBlocked = resp.status === 400 && text.includes("escrow_ref_required");
|
||||||
|
return { id, category: "D", what, expected, what_happened: `HTTP ${resp.status}: ${head(text, 200)}`, verdict: isBlocked ? "BLOCKED" : (resp.status === 200 ? "GAP" : "UNCLEAR"), evidence: `status=${resp.status}; body=${head(text, 400)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attack_D3(): Promise<AttackResult> {
|
||||||
|
const id = "D3";
|
||||||
|
const what = "PostEscrow honestly (model=qwen3.6:35b), send chat body with model=gpt-4.";
|
||||||
|
const expected = "409 request_spec_mismatch (server.ts:273) — model is part of request_spec_hash.";
|
||||||
|
checkCapsBeforePost();
|
||||||
|
const startBal = await walletLovelace();
|
||||||
|
try {
|
||||||
|
const messages = [{ role: "user" as const, content: "D3 probe" }];
|
||||||
|
log("D3: posting honest escrow then sending wrong model");
|
||||||
|
const escrowResult = await buildPostEscrowTx({
|
||||||
|
chain, buyerKey: wk, advertRef: ADVERT_REF_ACTIVE, messages, payment_lovelace: ADVERT_PRICE_LOVELACE,
|
||||||
|
});
|
||||||
|
counters.escrowsPosted += 1;
|
||||||
|
await chain.awaitTx(escrowResult.expectedTxHash, 120_000);
|
||||||
|
const escrowRefStr = `${escrowResult.escrowOutputRef.txHash}#${escrowResult.escrowOutputRef.index}`;
|
||||||
|
const resp = await rateLimitedFetch(`${SUPPLIER_URL}/v1/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", "X-Escrow-Ref": escrowRefStr },
|
||||||
|
body: JSON.stringify({ model: "gpt-4", messages, max_tokens: 50 }),
|
||||||
|
});
|
||||||
|
const text = await resp.text();
|
||||||
|
const verdict: Verdict = resp.status === 409 && text.includes("request_spec_mismatch") ? "BLOCKED" : (resp.status === 200 ? "GAP" : "UNCLEAR");
|
||||||
|
const endBal = await walletLovelace();
|
||||||
|
counters.burnedLovelace += (startBal - endBal);
|
||||||
|
return { id, category: "D", what, expected, what_happened: `HTTP ${resp.status}: ${head(text, 200)}`, verdict, evidence: `escrow=${escrowRefStr}; status=${resp.status}; body=${head(text, 400)}` };
|
||||||
|
} catch (err) {
|
||||||
|
const endBal = await walletLovelace();
|
||||||
|
counters.burnedLovelace += (startBal - endBal);
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return { id, category: "D", what, expected, what_happened: `setup error: ${head(msg)}`, verdict: "UNCLEAR", evidence: msg.slice(0, 500) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attack_D4(): Promise<AttackResult> {
|
||||||
|
const id = "D4";
|
||||||
|
const what = "buildPostEscrowTx pointing at the RETIRED advert ref (386d30…004040#0).";
|
||||||
|
const expected = "Builder rejects because advert UTxO is no longer on chain — chain.queryUtxo returns null.";
|
||||||
|
try {
|
||||||
|
const messages = [{ role: "user" as const, content: "D4 probe" }];
|
||||||
|
await buildPostEscrowTx({ chain, buyerKey: wk, advertRef: ADVERT_REF_RETIRED, messages, payment_lovelace: ADVERT_PRICE_LOVELACE });
|
||||||
|
return { id, category: "D", what, expected, what_happened: "builder accepted post against retired advert", verdict: "GAP", evidence: `retired_advert=${ADVERT_REF_RETIRED.txHash}#${ADVERT_REF_RETIRED.index}` };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return { id, category: "D", what, expected, what_happened: `rejected: ${head(msg)}`, verdict: "BLOCKED", evidence: msg.slice(0, 500) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── A1 — skip Accept, watch supplier Release (10 min wait) ─────────────
|
||||||
|
|
||||||
|
async function attack_A1(): Promise<AttackResult> {
|
||||||
|
const id = "A1";
|
||||||
|
const what = "PostEscrow, get supplier's receipt, do NOT Accept, wait ACCEPT_WINDOW_MS=600_000, expect supplier-side Release to take payment + bonds.";
|
||||||
|
const expected = "supplier Release fires after window; buyer loses 7 AP3X.";
|
||||||
|
checkCapsBeforePost();
|
||||||
|
const startBal = await walletLovelace();
|
||||||
|
try {
|
||||||
|
const messages = [{ role: "user" as const, content: "A1 probe: receive but do not accept." }];
|
||||||
|
log("A1: posting escrow then skipping Accept");
|
||||||
|
const submitResult = await marketplace.submitPrompt({
|
||||||
|
advertRef: ADVERT_REF_ACTIVE, messages, payment_lovelace: ADVERT_PRICE_LOVELACE,
|
||||||
|
});
|
||||||
|
counters.escrowsPosted += 1;
|
||||||
|
const escrowRefStr = `${submitResult.escrowRef.txHash}#${submitResult.escrowRef.index}`;
|
||||||
|
log(`A1: escrow=${escrowRefStr}; supplier returned receipt; now waiting 10+ min`);
|
||||||
|
|
||||||
|
// Wait until past submitted_at + 600s + a few slots of margin
|
||||||
|
const WAIT_MS = 660_000; // 11 min
|
||||||
|
await new Promise((r) => setTimeout(r, WAIT_MS));
|
||||||
|
|
||||||
|
// Check indexer for Release event
|
||||||
|
const resp = await rateLimitedFetch(`${env.INDEXER_URL}/escrows?buyer=${wk.pubKeyHash}`);
|
||||||
|
const rows = (await resp.json()) as Array<{ utxo_ref: string; state: string; prompt_hash: string }>;
|
||||||
|
const matching = rows.filter((r) => r.prompt_hash === submitResult.receipt.prompt_hash);
|
||||||
|
const released = matching.find((r) => r.state === "Released");
|
||||||
|
const stillSubmitted = matching.find((r) => r.state === "Submitted");
|
||||||
|
const endBal = await walletLovelace();
|
||||||
|
counters.burnedLovelace += (startBal - endBal);
|
||||||
|
|
||||||
|
if (released) {
|
||||||
|
return { id, category: "A", what, expected, what_happened: `supplier Released after window; row in state=Released at ${released.utxo_ref}`, verdict: "BLOCKED", evidence: `released_ref=${released.utxo_ref}; net_delta=${startBal - endBal} lovelace` };
|
||||||
|
}
|
||||||
|
// If indexer Track 2 hasn't landed, stuck-Submitted rows mean Release happened but indexer didn't advance.
|
||||||
|
if (stillSubmitted) {
|
||||||
|
// Confirm via Ogmios whether the Submitted UTxO is spent.
|
||||||
|
const og = await fetch(env.OGMIOS_URL, {
|
||||||
|
method: "POST", headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ jsonrpc: "2.0", method: "queryLedgerState/utxo", params: { outputReferences: [{ transaction: { id: stillSubmitted.utxo_ref.split("#")[0] }, index: parseInt(stillSubmitted.utxo_ref.split("#")[1], 10) }] }, id: "u" }),
|
||||||
|
});
|
||||||
|
const ogText = await og.json();
|
||||||
|
const spent = Array.isArray((ogText as { result?: unknown[] }).result) && ((ogText as { result: unknown[] }).result).length === 0;
|
||||||
|
if (spent) {
|
||||||
|
return { id, category: "A", what, expected, what_happened: `Submitted UTxO spent on chain (likely by supplier Release); indexer hasn't advanced state yet`, verdict: "BLOCKED", evidence: `submitted_ref=${stillSubmitted.utxo_ref}; ogmios_utxo_query=empty` };
|
||||||
|
}
|
||||||
|
return { id, category: "A", what, expected, what_happened: `Submitted UTxO still unspent after window — supplier did NOT Release`, verdict: "GAP", evidence: `submitted_ref=${stillSubmitted.utxo_ref}` };
|
||||||
|
}
|
||||||
|
return { id, category: "A", what, expected, what_happened: `unexpected indexer state — no matching row`, verdict: "UNCLEAR", evidence: `prompt_hash=${submitResult.receipt.prompt_hash}` };
|
||||||
|
} catch (err) {
|
||||||
|
const endBal = await walletLovelace();
|
||||||
|
counters.burnedLovelace += (startBal - endBal);
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return { id, category: "A", what, expected, what_happened: `setup error: ${head(msg)}`, verdict: "UNCLEAR", evidence: msg.slice(0, 500) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main orchestrator ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Continuation run: attacks D1, B3, C1, C5, A3, B5, D4, B2, C2 all completed
|
||||||
|
// BLOCKED in the first pass. This continuation runs the remaining 5.
|
||||||
|
const ATTACKS: { id: string; fn: () => Promise<AttackResult> }[] = [
|
||||||
|
// Post-escrow rejection (1 escrow; supplier 409 expected)
|
||||||
|
{ id: "D3", fn: attack_D3 },
|
||||||
|
// On-chain validator probes
|
||||||
|
{ id: "A4", fn: attack_A4 },
|
||||||
|
{ id: "A2", fn: attack_A2 },
|
||||||
|
// Burst (no escrow)
|
||||||
|
{ id: "C4", fn: attack_C4 },
|
||||||
|
// A1 last — ties funds for 10+ min
|
||||||
|
{ id: "A1", fn: attack_A1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STOP_ON_FIRST_GAP = true;
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
log(`agent-b: buyer=${wk.address} pkh=${wk.pubKeyHash}`);
|
||||||
|
const startBal = await walletLovelace();
|
||||||
|
log(`starting bal=${startBal} lovelace (~${Number(startBal) / 1e6} AP3X)`);
|
||||||
|
|
||||||
|
const results: AttackResult[] = [];
|
||||||
|
for (const a of ATTACKS) {
|
||||||
|
log(`\n=== running ${a.id} ===`);
|
||||||
|
try {
|
||||||
|
const r = await a.fn();
|
||||||
|
results.push(r);
|
||||||
|
log(`${a.id}: ${r.verdict} — ${head(r.what_happened, 120)}`);
|
||||||
|
log(`counters: escrows=${counters.escrowsPosted}/${CAP_ESCROWS} reqs=${counters.requestsSent}/${CAP_REQUESTS} burned=${counters.burnedLovelace}/${CAP_BURN_LOVELACE}`);
|
||||||
|
if (STOP_ON_FIRST_GAP && r.verdict === "GAP") {
|
||||||
|
log("\nGAP found — stopping per spec.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg.startsWith("hard-cap:")) {
|
||||||
|
log(`HARD CAP HIT: ${msg} — stopping.`);
|
||||||
|
results.push({ id: a.id, category: "A", what: "(cap hit)", expected: "", what_happened: msg, verdict: "UNCLEAR", evidence: "" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
log(`${a.id}: runner error: ${msg}`);
|
||||||
|
results.push({ id: a.id, category: "A", what: "(runner error)", expected: "", what_happened: msg, verdict: "UNCLEAR", evidence: msg.slice(0, 500) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-run cleanup: reclaim any Open/Claimed escrows we left behind.
|
||||||
|
// Walk the indexer for buyer's escrows; reclaim any in state Open/Claimed
|
||||||
|
// whose deliver_by has passed. This recovers funds the runner locked while
|
||||||
|
// probing supplier rejections.
|
||||||
|
log("\n=== post-run reclaim sweep ===");
|
||||||
|
try {
|
||||||
|
const r = await rateLimitedFetch(`${env.INDEXER_URL}/escrows?buyer=${wk.pubKeyHash}`);
|
||||||
|
const rows = (await r.json()) as Array<{ utxo_ref: string; state: string; deliver_by: number; created_slot: number }>;
|
||||||
|
// Only reclaim rows from THIS session (recent slots). Stale historical rows
|
||||||
|
// from prior runs may have already been reclaimed/accepted and the indexer
|
||||||
|
// hasn't advanced state yet (per the known indexer-staleness follow-up).
|
||||||
|
const fresh = rows.filter((r) => (r.state === "Open" || r.state === "Claimed") && r.deliver_by < Date.now());
|
||||||
|
log(`reclaim candidates: ${fresh.length}`);
|
||||||
|
const { buildReclaimTx } = await import("@marketplace/shared/tx");
|
||||||
|
for (const row of fresh) {
|
||||||
|
const [tx, ix] = row.utxo_ref.split("#");
|
||||||
|
const ref: OutputReference = { txHash: tx, index: parseInt(ix, 10) };
|
||||||
|
try {
|
||||||
|
const out = await buildReclaimTx({ chain, buyerKey: wk, escrowRef: ref });
|
||||||
|
await chain.awaitTx(out.expectedTxHash, 90_000);
|
||||||
|
log(`reclaimed ${row.utxo_ref} -> tx ${out.expectedTxHash}`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
log(`reclaim ${row.utxo_ref} FAILED: ${head(msg, 200)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log(`reclaim sweep error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const endBal = await walletLovelace();
|
||||||
|
const totalSpent = startBal - endBal;
|
||||||
|
|
||||||
|
// Final report
|
||||||
|
log("\n\n=== REPORT ===\n");
|
||||||
|
const header = "| Attack | Cat | What tried | Expected | Happened | Verdict | Evidence |";
|
||||||
|
const sep = "|--------|-----|------------|----------|----------|---------|----------|";
|
||||||
|
log(header);
|
||||||
|
log(sep);
|
||||||
|
for (const r of results) {
|
||||||
|
log(`| ${r.id} | ${r.category} | ${head(r.what, 70)} | ${head(r.expected, 50)} | ${head(r.what_happened, 80)} | ${r.verdict} | ${head(r.evidence, 60)} |`);
|
||||||
|
}
|
||||||
|
log("\n## Hard-cap accounting");
|
||||||
|
log(`escrows posted: ${counters.escrowsPosted} / ${CAP_ESCROWS}`);
|
||||||
|
log(`requests sent : ${counters.requestsSent} / ${CAP_REQUESTS}`);
|
||||||
|
log(`burned : ${counters.burnedLovelace} lovelace / ${CAP_BURN_LOVELACE}`);
|
||||||
|
log(`net wallet Δ : ${-totalSpent} lovelace (~${Number(-totalSpent) / 1e6} AP3X)`);
|
||||||
|
log(`start bal : ${startBal} lovelace`);
|
||||||
|
log(`end bal : ${endBal} lovelace`);
|
||||||
|
|
||||||
|
// Deferred attacks
|
||||||
|
log("\n## Deferred (not implemented this run)");
|
||||||
|
log("- B1 (capability_id mismatch in datum): requires hand-rolled datum builder.");
|
||||||
|
log("- B6 (FLOAT64 CBOR injection): requires hand-rolled CBOR encoder.");
|
||||||
|
log("- D5 (3 sybil wallets concurrent post): requires faucet + 3 wallet derivations + AP3X distribution.");
|
||||||
|
|
||||||
|
const out = `/tmp/run-agent-b-${Date.now()}.json`;
|
||||||
|
writeFileSync(out, JSON.stringify({ results, counters: { ...counters, burnedLovelace: counters.burnedLovelace.toString() }, startBal: startBal.toString(), endBal: endBal.toString() }, null, 2));
|
||||||
|
log(`\nfull results written to ${out}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("[agent-b] fatal:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
208
prompts/marketplace-buyer-pair.md
Normal file
208
prompts/marketplace-buyer-pair.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# Marketplace Buyer Pair — Two-Agent Exercise
|
||||||
|
|
||||||
|
A paired prompt for running the agents-marketplace from the buyer side on **Vector L2 testnet**. Hand each section below to a separate agent run.
|
||||||
|
|
||||||
|
- **Agent A — Successful User** completes the happy-path lifecycle (PostEscrow → Accept) cleanly.
|
||||||
|
- **Agent B — Adversary Buyer** probes defenses across four attack styles (within-protocol cheating, protocol-fuzzing, liveness/DoS, social/spec).
|
||||||
|
|
||||||
|
Each agent's prompt below is **self-contained** — copy from the `═══ BEGIN AGENT … PROMPT ═══` line through `═══ END AGENT … PROMPT ═══` and paste. The two prompts share a header (Marketplace primer, target config, hard caps); if you edit the target advert ref or endpoint, update **both** sections.
|
||||||
|
|
||||||
|
Operator note: testnet AP3X only. Never reuse mainnet keys. Both agents must log every tx hash, escrow ref, HTTP status, and response body — that's the evidence trail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent A — Successful User
|
||||||
|
|
||||||
|
═══ BEGIN AGENT A PROMPT ═══
|
||||||
|
|
||||||
|
You are a buyer using the **agents-marketplace** on Vector L2 testnet. Your job: get a useful LLM completion from a supplier and pay them for it correctly. Demonstrate that the marketplace works end-to-end.
|
||||||
|
|
||||||
|
### Marketplace primer (read first)
|
||||||
|
|
||||||
|
Buyers post on-chain escrow against a supplier's on-chain advert; the supplier claims the escrow, runs inference locally, submits a signed receipt on chain, and the buyer accepts. Symmetric 1 AP3X bonds plus on-chain deadlines keep both sides honest.
|
||||||
|
|
||||||
|
Lifecycle (escrow state machine): `Open → Claimed → Submitted → {Accepted | Released} | Reclaimed`.
|
||||||
|
|
||||||
|
The seven lifecycle tx names — all defined in `packages/shared/src/tx/`:
|
||||||
|
- `PostAdvert`, `Retire` (supplier-side, advert lifecycle)
|
||||||
|
- `PostEscrow`, `Claim`, `Submit`, `Accept`, `Reclaim`, `Release` (escrow lifecycle)
|
||||||
|
|
||||||
|
Key invariants:
|
||||||
|
- `advert_ref` in the escrow datum spec-locks the escrow to a specific advert UTxO (price, capability, model can't be swapped mid-flight).
|
||||||
|
- `deliver_by = posted_at + advert.max_processing_ms + NETWORK_BUFFER_MS(30_000)`.
|
||||||
|
- `ACCEPT_WINDOW_MS = 600_000` (10 min). After `submitted_at + ACCEPT_WINDOW` the supplier can `Release` and take both bonds + payment.
|
||||||
|
- `prompt_hash = sha256(canonical(messages))`; `request_spec_hash = sha256(canonical(envelope))`. Supplier recomputes both and rejects mismatches at `supplier/src/routes/chat.ts`.
|
||||||
|
- Receipt is Ed25519-signed by supplier's pkh.
|
||||||
|
|
||||||
|
### Target supplier (testnet)
|
||||||
|
|
||||||
|
Primary:
|
||||||
|
- Endpoint: `https://supplier.summitstak.ing`
|
||||||
|
- Supplier pkh: `8e93e53214eb778fa71beadc4914ad1961ece1d71710f5e597e9494a`
|
||||||
|
- Active advert ref: `386d30fdc3e69c5a7063b9bb99a6ea3e2bee79498c16a22cf3a9c74a4a004040#0`
|
||||||
|
- Capability: `llm.text.generate.v1`, model `qwen3.6:35b`, price 2 AP3X, max_output_tokens 2048, max_processing_ms 60000
|
||||||
|
|
||||||
|
Backup (run by the Vector / Apex Fusion team — not the operator of this exercise):
|
||||||
|
- `mp-suppliers.vector.testnet.apexfusion.org`
|
||||||
|
|
||||||
|
Indexer (read-only chain view):
|
||||||
|
- `GET /suppliers`, `GET /capabilities`, `GET /escrows/{ref}`, `GET /events?stream=1` (SSE)
|
||||||
|
|
||||||
|
### Buyer surface
|
||||||
|
|
||||||
|
Pick whichever fits the tools you have. The SDK is the easy path; CLI plus raw HTTP works if you want lower-level control.
|
||||||
|
|
||||||
|
- Buyer SDK in `buyer/src/sdk/Marketplace.ts`:
|
||||||
|
- `Marketplace.discoverSuppliers(opts?)`
|
||||||
|
- `Marketplace.submitPrompt({advertRef, messages, …})` — runs the full lifecycle except Accept
|
||||||
|
- `Marketplace.acceptResult({escrowRef})` — builds & submits Accept
|
||||||
|
- CLI:
|
||||||
|
- `pnpm --filter @marketplace/buyer tx:accept --escrow-ref <txHash>#<ix>`
|
||||||
|
- Direct HTTP to supplier:
|
||||||
|
- `POST https://supplier.summitstak.ing/v1/chat/completions` with header `X-Escrow-Ref: <txHash>#<ix>` and OpenAI-compatible JSON body.
|
||||||
|
|
||||||
|
### Hard caps (must respect)
|
||||||
|
|
||||||
|
- Testnet AP3X only.
|
||||||
|
- Maximum 5 escrows posted across this whole run.
|
||||||
|
- Maximum 2 AP3X locked per escrow (the advertised price). Never inflate.
|
||||||
|
- Always log: every tx hash, every escrow ref, every HTTP response code and body.
|
||||||
|
- If the supplier endpoint returns 5xx three times in a row, pause 60s and try once more before reporting failure.
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. Discover the supplier: `GET <indexer>/suppliers`, or use the target advert ref directly. Confirm `advert.status == "Active"` and `advert.price_lovelace` matches the 2 AP3X you expect.
|
||||||
|
2. Compose a small prompt — e.g. `[{role: "user", content: "Summarize the Cardano EUTxO model in 3 bullets."}]`. Compute `prompt_hash` over the canonicalized messages.
|
||||||
|
3. `PostEscrow` referencing `advert_ref`, locking `price + 1 AP3X` buyer bond. Capture the result as `escrow_ref = txHash#ix`. Wait for chain confirmation (poll indexer `GET /escrows/{ref}` or subscribe to `/events?stream=1`).
|
||||||
|
4. `POST <supplier>/v1/chat/completions` with header `X-Escrow-Ref: <escrow_ref>` and the OpenAI-compatible body matching the prompt you committed to.
|
||||||
|
5. Wait for the supplier to `Claim` then `Submit` on chain. Save both tx hashes from the indexer.
|
||||||
|
6. Verify the response payload: check that `receipt.prompt_hash` matches what you computed, that `receipt.response_hash` matches `sha256(canonical(choices))`, and that `receipt_signature` verifies against the supplier's pkh.
|
||||||
|
7. `Accept`: call `Marketplace.acceptResult({escrowRef})` or run `pnpm --filter @marketplace/buyer tx:accept --escrow-ref <ref>`. Capture the Accept tx hash.
|
||||||
|
8. Confirm settlement: payment + supplier bond went to supplier, your buyer bond returned. Your wallet's net delta should be exactly `-price` (≈ -2 AP3X plus fees).
|
||||||
|
9. Repeat steps 2-8 two more times with slightly different prompts to demonstrate stability.
|
||||||
|
|
||||||
|
### Failure handling
|
||||||
|
|
||||||
|
- If the supplier hasn't `Submitted` by `deliver_by` (advert.max_processing_ms + 30s buffer + posted_at), run `Reclaim`. The buyer recovers payment + both bonds.
|
||||||
|
- Do not retry blindly. If a step fails, report which step, which tx/HTTP call, and the full error.
|
||||||
|
|
||||||
|
### Report (final output)
|
||||||
|
|
||||||
|
Produce a markdown table with one row per cycle:
|
||||||
|
|
||||||
|
| Cycle | Prompt summary | PostEscrow tx | Claim tx | Submit tx | Accept tx | Wall-clock latency | Supplier `wallclock_ms` | Net wallet delta |
|
||||||
|
|
||||||
|
Then a short paragraph: anything surprising, any retries, any mismatch between expected and observed behavior.
|
||||||
|
|
||||||
|
═══ END AGENT A PROMPT ═══
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent B — Adversary Buyer
|
||||||
|
|
||||||
|
═══ BEGIN AGENT B PROMPT ═══
|
||||||
|
|
||||||
|
You are a buyer running an **adversarial assessment** of the agents-marketplace on Vector L2 testnet. Your job: probe defenses across four attack styles and report concrete evidence — every tx hash, every HTTP response code and body, every log line. Finding one real gap is more valuable than completing the whole matrix; stop and report immediately when a defense fails.
|
||||||
|
|
||||||
|
### Marketplace primer (read first)
|
||||||
|
|
||||||
|
Buyers post on-chain escrow against a supplier's on-chain advert; the supplier claims, runs inference, submits a signed receipt, the buyer accepts. Symmetric 1 AP3X bonds plus on-chain deadlines keep both sides honest.
|
||||||
|
|
||||||
|
Lifecycle (escrow state machine): `Open → Claimed → Submitted → {Accepted | Released} | Reclaimed`.
|
||||||
|
|
||||||
|
The seven lifecycle tx names — all defined in `packages/shared/src/tx/`:
|
||||||
|
- `PostAdvert`, `Retire` (supplier-side, advert lifecycle)
|
||||||
|
- `PostEscrow`, `Claim`, `Submit`, `Accept`, `Reclaim`, `Release` (escrow lifecycle)
|
||||||
|
|
||||||
|
Key invariants and defenses you will be probing:
|
||||||
|
- `advert_ref` in the escrow datum spec-locks the escrow (price/capability/model frozen at advert UTxO).
|
||||||
|
- `deliver_by = posted_at + advert.max_processing_ms + NETWORK_BUFFER_MS(30_000)`.
|
||||||
|
- `ACCEPT_WINDOW_MS = 600_000` (10 min). Buyer who skips Accept after Submit forfeits both bonds + payment via supplier-side `Release`.
|
||||||
|
- `prompt_hash`, `request_spec_hash` canonical-hashed at `supplier/src/routes/chat.ts`; mismatch → 4xx.
|
||||||
|
- All escrow datum integer fields are CBOR major type 0/1 (BigInt). Any FLOAT64 leak → script crash. (Prior history: AdvertDatum had this bug; EscrowDatum may or may not.)
|
||||||
|
|
||||||
|
### Target supplier (testnet)
|
||||||
|
|
||||||
|
Primary:
|
||||||
|
- Endpoint: `https://supplier.summitstak.ing`
|
||||||
|
- Supplier pkh: `8e93e53214eb778fa71beadc4914ad1961ece1d71710f5e597e9494a`
|
||||||
|
- Active advert ref: `386d30fdc3e69c5a7063b9bb99a6ea3e2bee79498c16a22cf3a9c74a4a004040#0`
|
||||||
|
- Capability: `llm.text.generate.v1`, model `qwen3.6:35b`, price 2 AP3X, max_output_tokens 2048, max_processing_ms 60000
|
||||||
|
|
||||||
|
Backup (run by the Vector / Apex Fusion team — not the operator of this exercise):
|
||||||
|
- `mp-suppliers.vector.testnet.apexfusion.org`
|
||||||
|
|
||||||
|
Indexer (read-only chain view):
|
||||||
|
- `GET /suppliers`, `GET /capabilities`, `GET /escrows/{ref}`, `GET /events?stream=1` (SSE)
|
||||||
|
|
||||||
|
### Buyer surface
|
||||||
|
|
||||||
|
- Buyer SDK in `buyer/src/sdk/Marketplace.ts`: `discoverSuppliers`, `submitPrompt`, `acceptResult`.
|
||||||
|
- CLI: `pnpm --filter @marketplace/buyer tx:accept --escrow-ref <…>`.
|
||||||
|
- Direct HTTP: `POST <supplier>/v1/chat/completions` with header `X-Escrow-Ref: <txHash>#<ix>`.
|
||||||
|
- Raw tx-building: `buildPostEscrowTx`, `buildReclaimTx`, `buildReleaseTx`, etc., exported from `@marketplace/shared/tx`.
|
||||||
|
|
||||||
|
### Hard caps (must respect — this is a safety-bounded probe, not a real attack)
|
||||||
|
|
||||||
|
- Testnet AP3X only.
|
||||||
|
- Maximum 10 escrows posted total. Maximum 2 AP3X locked per escrow.
|
||||||
|
- Total burn ceiling: 5 AP3X across failed-tx fees + forfeited bonds. If you would exceed this, stop and report.
|
||||||
|
- Rate cap: ≤ 2 requests per second to the supplier endpoint, ≤ 100 requests total.
|
||||||
|
- Single slow-loris socket attempt only — do not hold many sockets open.
|
||||||
|
- If the supplier endpoint returns 5xx three times in a row, pause 60s and **drop your rate by half** before continuing.
|
||||||
|
- Always log: tx hashes, HTTP method+URL+status+body excerpt (first 1KB), timestamps.
|
||||||
|
- **Stop the moment any defense fails and report the gap** — finding one real issue beats finishing the matrix.
|
||||||
|
|
||||||
|
### Attack matrix
|
||||||
|
|
||||||
|
For each attempt, record:
|
||||||
|
- **What you tried** (concrete payload / tx / sequence)
|
||||||
|
- **Expected defense** (what should fire)
|
||||||
|
- **What happened** (response code, validator error, on-chain state, timing)
|
||||||
|
- **Verdict** — one of: **BLOCKED** (defense held), **GAP** (defense failed), **UNCLEAR** (need follow-up)
|
||||||
|
- **Evidence** — tx hash, response body excerpt, timestamp
|
||||||
|
|
||||||
|
#### A. Within-protocol cheating (buyer-side, syntactically valid)
|
||||||
|
|
||||||
|
- **A1.** Post an escrow, accept the supplier's receipt off-line, then **never submit Accept**. Wait `ACCEPT_WINDOW_MS` (10 min) past `submitted_at`. Expected: supplier calls `Release` and takes payment + both bonds. GAP = you recover any funds.
|
||||||
|
- **A2.** Build a `Release` tx yourself (as buyer) before `submitted_at + ACCEPT_WINDOW`. Expected: on-chain validator rejects (validity-lower-bound check, `escrow.ak`).
|
||||||
|
- **A3.** Post an escrow with `deliver_by` already in the past. Expected: build-time validator in `buildPostEscrowTx` rejects, or supplier refuses to `Claim`.
|
||||||
|
- **A4.** Post an escrow then immediately try `Reclaim` before `deliver_by`. Expected: validator rejects (state=Open but `now < deliver_by`).
|
||||||
|
|
||||||
|
#### B. Protocol-fuzzing (malformed datums, replay, double-spend)
|
||||||
|
|
||||||
|
- **B1.** Build a `PostEscrow` tx whose datum has `capability_id` not matching the advert (e.g. `llm.text.generate.v2`). Expected: supplier's `chat.ts` rejects with 4xx (capability mismatch).
|
||||||
|
- **B2.** `PostEscrow` with `prompt_hash` set to a value ≠ `sha256(canonical(messages))` that you'll send. Expected: supplier rejects 400 with `prompt_hash mismatch`.
|
||||||
|
- **B3.** `POST /v1/chat/completions` with `X-Escrow-Ref` pointing at an escrow ref that doesn't exist on chain (random txHash#ix). Expected: 404 or 400.
|
||||||
|
- **B4.** Replay a previously-Accepted escrow ref in `X-Escrow-Ref`. Expected: supplier rejects (UTxO spent / state terminal).
|
||||||
|
- **B5.** `PostEscrow` paying strictly less than `advert.price_lovelace`. Expected: build-time price-equality check in `postEscrow.ts` rejects.
|
||||||
|
- **B6.** Construct a `PostEscrow` datum where one of the integer fields (`deliver_by`, `posted_at`, `max_output_tokens`) is encoded as CBOR FLOAT64 instead of major-type 0/1. (Use a hand-rolled CBOR encoder or patch cbor-x's number coercion.) Expected: Plutus validator rejects with `unreachable` / script crash. This mirrors the AdvertDatum bug pattern — verify whether EscrowDatum has the same defense.
|
||||||
|
|
||||||
|
#### C. Liveness / DoS (within hard caps)
|
||||||
|
|
||||||
|
- **C1.** Send a request body with `messages` totalling >100KB. Expected: 413 or 400 before LLM invocation.
|
||||||
|
- **C2.** Send `max_output_tokens: 999999` in the request body. Expected: supplier clamps to `advert.max_output_tokens=2048` or rejects 400.
|
||||||
|
- **C3.** Open one TCP connection and dribble bytes (slow-loris). Single attempt only. Expected: server timeout, connection closed.
|
||||||
|
- **C4.** Burst 50 requests at 2 rps with bogus `X-Escrow-Ref` headers. Expected: all 4xx; supplier stays responsive. **Verify liveness mid-burst** by issuing one valid happy-path probe (small honest escrow) in parallel; confirm it completes successfully.
|
||||||
|
- **C5.** Spam the indexer (`GET /suppliers`, `GET /escrows/{ref}`) at 2 rps for 30s. Expected: 200s throughout. Report any 5xx.
|
||||||
|
|
||||||
|
#### D. Social / spec attacks (lying, sybil, free-inference)
|
||||||
|
|
||||||
|
- **D1.** `POST /v1/chat/completions` with **no** `X-Escrow-Ref` header. Expected: 401/400. GAP = supplier returns inference for free.
|
||||||
|
- **D2.** `POST /v1/chat/completions` with `X-Escrow-Ref` pointing at someone else's escrow (you did not sign the `PostEscrow`). Expected: supplier rejects (signer / buyer-pkh mismatch).
|
||||||
|
- **D3.** `PostEscrow` against the advert (which announces `qwen3.6:35b`) but in the chat body set `model: "gpt-4"`. Expected: supplier rejects (model mismatch).
|
||||||
|
- **D4.** `PostEscrow` whose `advert_ref` points at a `Retired` advert UTxO. Find one via indexer history, or retire one and reuse the ref. Expected: build-time or on-chain validator rejects (advert.status check).
|
||||||
|
- **D5.** From 3 sibling sybil wallets (derive new keys; fund each from your testnet faucet within hard caps), `PostEscrow` against the same advert in quick succession. Expected: each escrow is independently valid on chain. Observe whether the supplier deduplicates, fairly serializes, or refuses concurrent claims. Report behavior — there is no single "correct" answer here, the report itself is the deliverable.
|
||||||
|
|
||||||
|
### Report (final output)
|
||||||
|
|
||||||
|
A markdown table:
|
||||||
|
|
||||||
|
| Attack ID | What you tried | Expected defense | What happened | Verdict | Evidence |
|
||||||
|
|-----------|---------------|------------------|---------------|---------|----------|
|
||||||
|
|
||||||
|
Then a section "GAPs found" listing any **GAP** rows with reproduction steps detailed enough that a developer can confirm without re-running the whole matrix.
|
||||||
|
|
||||||
|
Then a section "Hard-cap accounting": AP3X spent (fees + forfeited bonds), escrows posted, total requests sent. If you stopped early (gap found or cap hit), say why.
|
||||||
|
|
||||||
|
═══ END AGENT B PROMPT ═══
|
||||||
Reference in New Issue
Block a user