End chat 502'd with "escrow ref not on chain": endChat queried the original
Open escrow ref, but the supplier's Claim already spent it (Open->Claimed),
so queryUtxo returned null before the supplier was ever called. Now endChat
uses the supplier endpoint cached by the buyer-app at startChat, resolves
supplier identity from /capability for verification, and Accepts the Submitted
UTxO the supplier returns. Adds supplierBaseUrl to EndChatOptions; the
/v1/chat/end route passes it from chatRouteState (404 if the session is gone).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The static import of verifyReceipt pulled receipt/sign.ts (top-level
`import { createHash } from "crypto"`) into the Vite SPA bundle, breaking
`build:ui`. endChat now does structural-only receipt verification, matching
the existing submitPrompt/submitTts discipline (M1-E/M1-F note).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a conversational supplier capability and a free demo, both streaming.
New paid `llm.chat.v1` type: one escrow per conversation (flat fee). "Start
chat" posts the escrow with a session-init placeholder prompt_hash and the
supplier Claims (reserving its single slot); turns then stream off-chain via
SSE with zero chain interaction; "End chat" has the supplier Submit a receipt
over the full transcript and the buyer Accept — the moment the user is charged.
The on-chain validator is content-agnostic (pins but never verifies the
hashes), so this reuses the existing contract unchanged. A supplier-side idle
watchdog auto-ends abandoned sessions before deliver_by.
Free Kimi K2.6 chat demo: a synthetic "demo:kimi-chat" tile that uses the same
chat UI but proxies straight to OpenRouter (no escrow, no payment), mirroring
the existing Piper demo.
Shared:
- postChatEscrow.ts + chatSessionPromptHash() (session-init prompt_hash helper
shared by the buyer builder and the supplier start gate).
Supplier:
- CAPABILITY_KIND="chat-session" (+ CHAT_IDLE_TIMEOUT_MS); callOpenAiStream()
streaming client; ChatSessionStore + endChatSession(); /v1/chat/{start,
message,end} handlers with idle + hard-cap watchdog timers.
Buyer:
- Marketplace.startChat()/endChat() with relaxed (session-init) receipt
verification + crypto verifyReceipt; /v1/chat/{start,message,end} routes
(SSE passthrough) and a free /v1/chat-demo/message OpenRouter proxy;
OPENROUTER_API_KEY/OPENROUTER_BASE_URL config; ChatForm.tsx + Dashboard
KIMI_DEMO_SUPPLIER tile and llm.chat.v1 dispatch.
Deploy:
- docker-compose.supplier-kimi-chat.yml + supplier/.env.kimi-chat.example.
Typecheck passes across all workspaces; full vitest suite green except 15
pre-existing time-bomb/RED failures (tx-claim/accept/post-advert live-CBOR
fixtures with hardcoded past deadlines + the M0-C golden-datum test), confirmed
identical on a clean tree.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mainnet buyer mounts a named volume at /repo/data/archive, but the
container runs as a non-root user, so a fresh root-owned volume left the
SQLite response archive + pdf-jobs.db unopenable ("unable to open
database file"). Create the dir with the runtime user's ownership before
the chown so Docker seeds the empty volume with the right ownership.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The summarization runs server-side, but the SPA held the job only in
component state and jobs lived only in memory — so navigating away from
the Book Summarizer reset the UI (losing the handle to a still-running
job) and past summaries vanished on restart.
- PdfJobDb: SQLite store (its own file under ARCHIVE_DIR) persisting
every job on each progress update.
- JobStore: createdAt, persist-on-update, viewOf()/list() that serve
live jobs first then fall back to the durable record; non-live jobs
whose worker died surface as a new "interrupted" status.
- routes: GET /v1/pdf-jobs (newest-first list); GET /:id + summary.md
serve past jobs via viewOf; /:id/events sends a single terminal frame
for a finished/past job (no hang) and live frames otherwise.
- SPA: RecentJobs list (open any job → reconnect to live progress or
show result), localStorage resume of the active job on return, and a
"back to list (keeps running)" affordance.
- mainnet buyer compose: persistent named volume + ARCHIVE_DIR so the
archive + pdf-jobs.db survive container recreation/redeploys.
Tests: persistence-across-restart, interrupted detection, list, and
past-job serving (incl. terminal SSE). Full buyer suite green (185).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds an authenticated upload flow to the buyer web app: an operator
uploads a PDF, the server extracts + paragraph-aligned-chunks it
(~2.2k tokens), then runs a hierarchical map-reduce summarization by
paying marketplace suppliers per chunk — one on-chain escrow per map
call and per reduce node. One book upload fans out into dozens of real
mainnet escrows (the intended marketplace traffic) while producing a
genuine full-book summary.
Server module (buyer/src/pdf/): PDF extraction (text-only, rejects
scanned), token-aware chunker, supplier discovery/round-robin filtered
to capable models (kimi/deepseek/gpt; excludes qwen2.5:0.5b), pre-spend
cost estimate + wallet-floor guard, and a JobStore orchestrator that
reuses submitPrompt → findSubmittedEscrowRef → runAccept per call.
v1 serializes PostEscrow under a mutex (the shared tx builder can't pin
wallet inputs, so parallel escrows would double-spend); retries failed
calls on a different supplier then continues with gaps. 6 /v1/pdf-*
routes (multipart upload, estimate, confirm-gated start, SSE progress,
result, .md download) behind the existing session auth + page/chunk/
balance caps. SPA adds a 4-step BookSummarizer wizard.
Tests: chunk/estimate/supplier-pool/extract unit tests + orchestrator
(happy/gap/failover) + routes integration. Full buyer suite green
(181 tests); SPA build + typecheck clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Periodic setInterval ticker + on-failure backstop from the buildClaimTx
catch in server.ts. Both reuse the existing single-slot lock and call
runConsolidateWallet under the hood — planConsolidate short-circuits to
already-healthy in the steady state so no tx, no fee on a clean wallet.
Driven by deepseek-lane outage 2026-05-28 (supplier wallet drifted to 14
UTxOs with 9 dust 2-AP3X outputs; Claim tx coin-selector picked dust as
working input → chain_submit_failed; lane stalled until manual
tx:consolidate-wallet).
buyer/scripts/monitor-wallets: surface deepseek + kimi as operator wallets.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Caught the indexer stuck for 3.5h at slot 23463870 today while the
chain tip had advanced to 23477134. Ogmios TCP socket was alive but
the server had stopped pushing nextBlock responses; /healthz reported
ogmios_status: "connected" because that flag only tracked tip_slot > 0,
not actual block flow.
OgmiosSource now arms a 90s response watchdog on every nextBlock /
findIntersection send and clears it on any well-formed protocol reply.
On timeout it ws.close()s, re-entering the existing onclose →
scheduleReconnect → findIntersection path that resumes from the saved
cursor. Errors and malformed messages intentionally leave the watchdog
armed so a stuck Ogmios error path also recycles instead of wedging.
OGMIOS_RESPONSE_TIMEOUT_MS env var tunes the window (0 disables).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fourth mainnet supplier, branded by model rather than provider so future
OpenRouter-backed suppliers can claim their own compose files without
naming collisions. LLM_BACKEND=openai pointed at https://openrouter.ai/api
with a Bearer-token API key in supplier/.env.kimi. Traefik host
mp-suppliers-kimi.vector.apexfusion.org. No code changes needed — the
OPENAI_API_KEY plumbing landed in 1466442.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an optional OPENAI_API_KEY env var that supplier/src/openai.ts forwards as
`Authorization: Bearer <key>`. Empty/unset preserves the ChatMock behaviour
(no auth header). Unlocks pointing the openai backend at DeepSeek directly
(https://api.deepseek.com) — no local proxy needed.
New compose: deploy/mainnet/docker-compose.supplier-deepseek.yml (third mainnet
supplier, model deepseek-v4-pro, mp-suppliers-deepseek.vector.apexfusion.org).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The two mainnet buyer-bot wallets on vector-modules-simulation
(178.105.3.133, SSH user `user`, env files at ~/marketplace-agents/
buyer-{a,b}.env) were missing from OPERATOR_SOURCES so monitor:wallets
didn't report on them.
State at time of discovery (2026-05-20): both BROKEN with the same
fragmentation pattern as the open-webui supplier — 11 and 12 UTxOs
respectively, max 3.00 AP3X, no UTxO ≥ 5 AP3X for collateral.
Cron-driven Accept/Reclaim cycles were failing silently.
Both consolidated to {5 AP3X collateral, ~22 AP3X working, 1.5 AP3X
collateral-return} via the tx:consolidate-wallet CLI (commit 972c0f9)
run inside the vector-marketplace buyer container with --priv-key:
- buyer-a@vector-modules-simulation
addr1vytah0zpcn3gn00lvu83g4qur4th0cg02v9hslaqlq9k88g5squ09
tx b789c2719bcee221f935578b4ac277d96295fa6e240ec2b63e0f4c83efc18cad
- buyer-b@vector-modules-simulation
addr1vy2wlyyat59fx572jgecwdmkxze7v65dl9jvg3pulgxudngvlpeau
tx ee1269be8aa01113db827cf02b6d9ffa0309524fb5cd2d3a9744cab9ef89f923
Total operator wallets monitored: 6 → 8.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a one-shot operational tx that re-shapes any operator wallet to
{5 AP3X collateral candidate, working remainder} so subsequent Plutus
script-spends (Claim/Submit/Accept/Reclaim/RetireAdvert) find a UTxO
that satisfies lucid's setCollateral=5_000_000n default coin selector.
Symptom this addresses: open-webui mainnet supplier wallet held 8 UTxOs
max 2.40 AP3X, every Accept tx failed at lucid .complete() with the
"wallet must hold a pure-ADA UTxO ≥ 5 ADA" error path. Same fragmentation
risk on the other 4 mainnet operator wallets (one big UTxO surrounded by
dust — first script-spend consumes the big one and leaves nothing usable
as collateral).
Phase 1 — new tx:consolidate-wallet CLI (supplier + buyer):
- planConsolidate.ts — pure planner; supports consolidate (N>1 → 2),
split (1 → 2), and already-healthy skip.
- consolidateWallet.ts — live builder; presetWalletInputs pins all
UTxOs, two pay.ToAddress outputs (collateral + working).
- runConsolidateWallet.ts — orchestration (build + submit + awaitTx).
- supplier/src/cli/consolidate-wallet.ts + buyer mirror.
- monitor-wallets.ts — add supplier-tts@vector-marketplace (was
misclassified as 3rd-party).
Phase 2 — defensive pre-check in the 5 script-spend builders:
- assertCollateralCandidate(utxos) fires at the top of
buildSpendOpenOrClaimedTx (Claim/Submit), buildLiveTxForAccept,
buildLiveTxForReclaim, buildLiveTxForRetireAdvert. Throws a
TxConstructionError("collateral_required") pointing at
tx:consolidate-wallet instead of waiting for lucid's deep failure.
Tests: 12 new passing (planConsolidate edge cases + assert helper).
Pre-existing lucid 0.4.30 WASM "unreachable" failures in tx-*-live.test.ts
fixtures are unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- jobRunner.ts: console.warn at each jobs.fail site (chat + tts paths, 6 sites). Three mainnet GPT-supplier 502s on 2026-05-19 left no trace beyond Traefik — adding a one-line log restores grep-ability.
- docker-compose.supplier-gpt.yml: OPENAI_TIMEOUT_MS 180000→300000. The 3 observed 502s were all exactly 90 polls × 2s, i.e. the supplier's openai_timeout firing.
- docker-compose.chatmock.yml: serve --reasoning-effort low --reasoning-summary none. Default 'medium'/'auto' pushed GPT-5.4 think-time over the prior 180s budget.
Note: on-chain advert ADVERT_MAX_PROCESSING_MS still pins 180000 until a re-advert tx is posted — buyers compare against the on-chain value, not the runtime env.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reads BUYER_/SUPPLIER_PRIV_KEY_HEX from the 5 mainnet wallets across
vector-marketplace + open-webui via batch-mode ssh (hex never touches
local disk), derives addresses, and queries lovelace via
ReadOnlyOgmiosProvider. Cross-references mp-indexer /suppliers and
labels any on-chain supplier whose pkh isn't an operator wallet as
third-party. Per-role thresholds (--buyer-min, --supplier-min,
defaults 10/3 AP3X) flag LOW operator wallets; exit 1 on any LOW or
ssh/query error.
Run from the buyer workspace: `monitor:wallets [--json] [--buyer-min N] [--supplier-min N]`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors deploy/testnet/docker-compose.supplier-tts.yml and the simulation
PiperTTS stack, co-located on vector-marketplace as a private internal
service on marketplace-mainnet-net (no Traefik labels, no public DNS for
Piper itself). Supplier reaches Piper at http://marketplace-mainnet-tts-piper:8000.
Voice: en_US-lessac-high. New supplier wallet + on-chain advert are
published out-of-band by the operator per the runbook header.
Files:
- deploy/mainnet/docker-compose.tts-piper.yml openedai-speech-min, private
- deploy/mainnet/docker-compose.supplier-tts.yml CAPABILITY_KIND=tts supplier
- deploy/mainnet/install-voice-extras.sh voice download + yaml mapping
- deploy/mainnet/voice-mappings.py idempotent yaml editor
- supplier/.env.tts.example operator env template
- .gitignore whitelist .env.*.example
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The comment at every setMinFee call site said "500K is a comfortable
floor" but the actual value was 200K — the floor wasn't high enough to
cover lucid 0.4.30's Conway-era fee underestimation. Smoke-tested
mainnet Accept tx hit Ogmios code 3122: lucid declared 343,132 lovelace
fee but Ogmios required 347,726 (off by ~5K). With the floor at 200K
the floor was inert (343K > 200K), so the underestimated fee stood.
Bumping all six builder paths (PostEscrow, Claim/Submit, Accept,
Reclaim, PostAdvert, UpdateAdvert/RetireAdvert) to 500K — comfortably
above the empirical minimum across operations, ~0.15 AP3X surcharge
per tx, excess becomes change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restore the canonical mainnet docker-compose.supplier.yml to mirror
testnet's ollama-backed supplier (LLM_BACKEND=ollama, OLLAMA_URL points
at marketplace-mainnet-ollama, Traefik routes mp-suppliers.vector.apex-
fusion.org). The ChatGPT/Codex variant introduced in 965fabd moves to
docker-compose.supplier-gpt.yml with project name + container name
suffixed -gpt so both can coexist on the same host.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The mainnet marketplace-indexer was stuck in an event-replay loop since
2026-05-14. Diagnosis: OgmiosSource.intersectAt is captured ONCE in
start() from the cursor at process startup. On every WebSocket reconnect
(Cloudflare-fronted WS times out at ~100s idle, plus periodic Ogmios
server restarts), scheduleReconnect calls sendFindIntersection with the
stale start-time intersect, never the latest persisted cursor. Ogmios
seeks to that old point, sends nextBlock forward from there, the worker
re-processes the same blocks and writes saveCursor over the advanced
cursor with the old slot — DB cursor effectively never moves past the
startup point, and events get duplicated each iteration.
Fix:
- Add ChainSyncSource.updateIntersect({ slot, id }) to the interface.
- OgmiosSource.updateIntersect mutates this.intersectAt, so the next
reconnect's findIntersection picks up from the latest saved cursor.
- worker.handleBlock calls source.updateIntersect after every saveCursor.
- worker.handleRollback also calls it so a WS drop right after a rollback
doesn't resume past the rollback point.
- MockChainSyncSource implements as no-op (no reconnect path in tests).
Verified: same shape as the testnet projection-gap bug (memory 95fe6946),
manifesting differently. After this fix the cursor will only ever advance,
and reconnects resume from the freshest persisted point.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The storagetime/chatmock image has neither wget nor curl, so the
previous wget-based healthcheck always failed with "executable file
not found" and the container sat in "starting" forever. The image
does carry a Python interpreter (chatmock is a Flask app), so swap
to a 1-liner urllib probe of /health. /health returns 200 even
before OAuth login, so a red healthcheck now means the Flask server
itself is down.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
storagetime/chatmock on Docker Hub publishes only {latest, 1.36, v1.36}
(all the same digest sha256:757c65c8b28941...). The :v1.35 pin from
the initial commit resolves to "not found". The compose's own comment
forbids :latest, so bump to :v1.36.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a Codex/ChatGPT backend path for the supplier alongside the
existing Ollama one. The mainnet supplier is repointed at a localhost
ChatMock proxy (RayBytes/ChatMock, pinned :v1.35) that authenticates
against a ChatGPT Pro subscription via OAuth.
- supplier/src/openai.ts: HTTP client for OpenAI-compatible
/v1/chat/completions. Same return shape as ollama.callOllama so
jobRunner can branch cleanly.
- supplier/src/jobRunner.ts: branch on config.llmBackend.
- supplier/src/config.ts: LLM_BACKEND ("ollama" default | "openai"),
OPENAI_BASE_URL, OPENAI_TIMEOUT_MS (default 180_000 — GPT first-token
latency).
- supplier/src/cli/gen-keypair.ts: standalone Ed25519 keypair generator
+ bech32 address derivation. Used to bootstrap a fresh supplier
wallet without round-tripping through tx:post-advert --dry-run.
- deploy/mainnet/docker-compose.chatmock.yml: long-running chatmock
serve + one-shot --profile login flow.
- deploy/mainnet/docker-compose.supplier.yml: rewritten for openai
backend; Traefik routes at mp-suppliers-gpt.vector.apexfusion.org so
the unsuffixed slot stays free for a future supplier with a
different backend.
- tests/unit/supplier-openai.test.ts: unit coverage for callOpenAi.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The mainnet host (vector-marketplace, 91.98.147.172) has no local
apex-dashboard-mainnet stack — the buyer + indexer already reach the
chain via the public HTTPS endpoint
https://ogmios.vector.mainnet.apexfusion.org. Pin the supplier to the
same pattern: drop the ws://ogmios:1337 env override (let supplier/.env
supply OGMIOS_URL) and drop the apex-net binding so compose-up doesn't
fail on a missing external network.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lucidContext.ts captured VECTOR_ZERO_TIME at module-load time, so CLIs
that read a .env file at runtime (rather than letting Docker env_file
populate process.env before the runtime starts) got the testnet default
and built txs with a wrong slot origin -- Ogmios rejected with code
3118 "outside validity interval" on mainnet.
Two fixes:
- lucidContext.ts: read process.env.VECTOR_ZERO_TIME_MS inside
applyVectorSlotConfig() so the defensive re-apply in createLucidContext
picks up env vars set after module-load.
- run-cycle.ts / reclaim-orphans.ts: inject VECTOR_ZERO_TIME_MS,
OGMIOS_URL, INDEXER_URL, NETWORK_ID into process.env right after
reading the --env file, before any tx work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the "DEFERRED — Do NOT compose up" banner from
docker-compose.ollama.yml and docker-compose.supplier.yml (mirrors
cd0edd7 for buyer.yml + indexer.yml) and adds the missing LIVE_CHAIN=1
inline override that the testnet supplier compose has at line 45 —
without it the mainnet supplier silently falls back to
ReadOnlyOgmiosProvider and Claim/Submit never broadcast.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
run-cycle.ts: one full PostEscrow → Submit-wait → Accept cycle per
invocation, then exit. Lifts the stale-Open-ref workaround from the
run-agent-a.ts demo (Marketplace.submitPrompt returns a spent ref;
findSubmittedEscrowRef polls the indexer for the current Submitted
state). Flags: --env, --prompt-text, --payment-lovelace, --log-file,
--tag. Exit codes 0/2/4.
reclaim-orphans.ts: scans indexer for buyer's Open|Claimed escrows and
reclaims any past deliver_by. Skips not-yet-expired via the
"reclaim before deliver_by" TxConstructionError. Generalizes the
hardcoded-ref demo script.
Both for the two-mainnet-bot deploy on vector-modules-simulation
(see ~/.claude/plans/i-have-an-instance-ticklish-newt.md). No Claude
Code in the per-cycle path; cron + hand-curated prompts.txt files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symptom: first login on a fresh session lands on Dashboard but Tasks shows
"buyer pkh missing — boot script not injected" until the operator manually
refreshes.
Cause: __BUYER_BOOT__ is injected by the Express server at SPA-shell-serve
time, gated on a valid session cookie. The pre-login HTML payload has no
boot block, so the in-flight Marketplace SDK is constructed with an empty
walletKey.pubKeyHash. After the 204 from /v1/auth/login the cookie is set
but the SDK instance in this page keeps its empty pkh, breaking any
component that reads it (TaskHistory.tsx:202).
Fix: replace auth.refresh() with window.location.reload() on success. The
browser re-fetches / with the new cookie, the server injects the boot
block, and the SPA mounts cleanly with the right walletKey.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
buyer and indexer mainnet stacks have been running on vector-marketplace
(marketplace-mainnet-buyer + marketplace-mainnet-indexer healthy, 4+ days
uptime for indexer, buyer redeployed today with the auth gate). The
"Wired but not yet exercised" header contradicts reality and would spook
the next operator who reads it.
Note in the new headers points at marketplace.vector.apexfusion.org and
mp-indexer.vector.apexfusion.org so the connection between the compose
file and the live URL is explicit. The genuinely-still-open mainnet-
safety follow-up (ARCHITECTURE.md §9 #5: env-gate / wallet allowlist /
MAINNET=1 flag) is kept as a one-line reference in the buyer header.
supplier and ollama mainnet composes are intentionally left alone — I
don't have evidence of those being deployed via these compose files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an Express requireAuth middleware on /v1/* + the SPA shell and a
Vite/React login screen so anyone who reaches mp-buyers.* must authenticate
before driving the operator's wallet via /v1/submit-prompt or /v1/accept.
Why: the buyer-app sits on a public HTTP port and signs txs with a
server-held private key. Until now the only gate was network reachability.
Mechanics:
- BUYER_PASSWORD + SESSION_SECRET (>=32 chars) loaded by config.ts and
surfaced as createApp() deps. Production refuses to start without both.
- buyer/src/auth.ts: HMAC-SHA256 signed buyer_session cookie (HttpOnly,
SameSite=Lax, Path=/, optional Secure), 7-day rolling expiry,
timing-safe password compare, in-memory 5/15-min per-IP rate limiter.
- /v1/auth/{login,logout,whoami}. Login is rate-limited; 6th wrong
attempt returns 429 + Retry-After. Success resets the bucket.
- SPA boot block (__BUYER_BOOT__ injected by server.ts) is gated by the
same cookie so the buyer's address never reaches an unauthenticated
browser.
- New <AuthProvider>/<RequireAuth> in buyer/src/ui/state/AuthContext.tsx
probes /v1/auth/whoami on mount and a scoped window.fetch patch drops
to the login screen on any mid-session 401 from /v1/* (excluding
/v1/auth/* so wrong-password 401s stay local to <Login>).
- Tailwind login card at buyer/src/ui/pages/Login.tsx. App.tsx wraps in
<RequireAuth> and adds a "Sign out" button in the nav.
Tests: tests/unit/buyer-server-auth.test.ts covers sign/verify, rate
limiter, cookie helpers, login flow (400/401/204/429), whoami
(no-cookie/valid/tampered/expired), rolling renewal, logout, gated
/v1/*, /healthz still open, and the no-auth-deps construction shape.
Updated buyer-server-config + buyer-ui-smoke for the new required env
vars and the AuthProvider wrap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lucid's auto-fee calculation under-estimates the publish-refs tx by
~5K lovelace on Vector L2 mainnet (observed: required 372,658, lucid
computed 367,197 → ogmios rejected with code 3122 InsufficientFee).
Bumping the floor to 500K guarantees lucid pays enough. Cost: 0.13 AP3X
overhead, one-shot. Doesn't affect ongoing script-spending txs (those
have their own setMinFee already at 200K, unchanged).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Splits the single `lovelacePerOutput` param in publishReferenceScripts.ts
into `escrowLovelace` and `advertLovelace`, exposed via env vars
ESCROW_PUBLISH_LOVELACE / ADVERT_PUBLISH_LOVELACE in the CLI. Defaults
unchanged at 30 AP3X each (60 AP3X total).
Motivation: mainnet rollout where the publishing wallet has only ~57 AP3X
(buyer wallet) — needs to publish at calculated min-utxo (~17 AP3X escrow
+ ~7 AP3X advert = 24 AP3X total) instead of the safe 60 AP3X default.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 500K lovelace floor was set higher than the natural fee for both
inlined (~340K) and ref-script (~242K) Plutus V3 spend txs. With it
in place, every script-spending tx pays the same 0.5 AP3X regardless
of whether scripts are inlined or referenced — masking the on-chain
size reduction of ~3.4 KB / -82% per Claim/Submit/Accept measured
end-to-end on Vector L2 testnet.
200K is below both natural fees (so lucid's auto-estimate wins) and
still ~5x above the lucid-evolution 0.4.30 ~5K under-estimate the
floor was originally guarding against.
Expected per-cycle saving on Vector L2 testnet: ~0.32 AP3X
(Claim: 0.34 → 0.24, Submit: 0.34 → 0.25, Accept: 0.35 → 0.23).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ogmiosUtxoToLucidUtxo expected `o.script` to be a hex string, but
Ogmios v6 returns it as `{ language: "plutus:v3", cbor: "<hex>" }`
(or "native"). The typeof === "string" guard never matched, so
scriptRef was always null on returned UTxOs — making
.readFrom([refUtxo]) silently produce a tx with no script witness
and the supplier reject the resulting Claim with 503.
Wired ogmiosScriptToLucidScript() to translate the Ogmios shape into
the lucid-evolution shape across all four script languages.
Verified end-to-end: tmp-repro now reports
"scriptRef: present type=PlutusV3 cborlen=6884" against the published
ref UTxO (250928de…#0).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Static re-exports from tx/index.ts caused vite to trace
loadEscrowScript/loadAdvertScript/buildLiveTxForPublishReferenceScripts
into the buyer UI bundle, hitting the CML WASM "ESM integration proposal"
limitation. Sibling builders avoid this with /* @vite-ignore */ dynamic
imports; we keep symmetry by parking the helpers on a separate
@marketplace/shared/tx/server entry that the buyer never imports.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When ESCROW_REF_UTXO / ADVERT_REF_UTXO env vars are set (format
<txhash>#<idx>), Claim/Submit/Accept/Reclaim/RetireAdvert call
.readFrom([refUtxo]) instead of inlining the validator CBOR via
.attach.SpendingValidator(). Lucid-evolution 0.4.30 auto-extracts
utxo.scriptRef into the script-witness map (dist/index.js:1903–1913),
so no separate attach call is needed. Default (env unset) is unchanged.
Adds buildLiveTxForPublishReferenceScripts (a one-shot tx that
publishes both validators as scriptRef-bearing UTxOs at a caller-
supplied address — typically the all-zero-pkh enterprise burn address)
and the supplier tx:publish-reference-scripts CLI that wraps it.
Targeted at testnet trial; mainnet rollout is a separate flip after
verification.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removing the hardcoded OGMIOS_URL=ws://ogmios:1337 (apex-dashboard-mainnet
internal hostname) lets indexer/.env supply the real value — hosted WSS for
deployments that don't run a co-located ogmios container.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The legacy dashboard_default name leaked from apex-dashboard on the
testnet host. On a fresh mainnet box with no apex-dashboard, traefik-net
is more accurate. Mainnet-only — testnet compose files keep
dashboard_default since they depend on the existing apex-dashboard host.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
shared: VECTOR_ZERO_TIME now reads VECTOR_ZERO_TIME_MS from env, defaults
to testnet genesis (1_752_057_484_000 ms). Mainnet deploys set
VECTOR_ZERO_TIME_MS=1756485600000. Existing testnet deployments unaffected
since env unset → testnet default. Patched in both lucidContext.ts (lucid
slot config override) and liveCbor.ts buildLiveTxForSubmit (submitted_at
slot-alignment).
deploy/mainnet: buyer host changed mp.vector.* → marketplace.vector.* per
operator-owned DNS. Indexer compose drops apex-net network (we point at
the hosted mainnet ogmios over public WSS, not via apex-dashboard-mainnet
which is not deployed on this box).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Closes 2/4 builders from the M1-F-D-defer list (postAdvert already live in
7ea7328; updateAdvert and release remain).
Reclaim:
- buildLiveTxForReclaim in liveCbor.ts (mirrors buildLiveTxForAccept):
spends the escrow UTxO with REDEEMER_RECLAIM (Constr3), pays
payment + buyer_bond + supplier_bond to buyer, delegates UPLC eval
to Ogmios via localUPLCEval:false. Validity range = tipMs ± 60s.
- reclaim.ts: replace the unconditional mockSlotToWallclockMs tipMs
computation with `isLive ? Date.now() : mockSlotToWallclockMs(tipSlot)`,
matching accept.ts:77. Adds the live-CBOR dispatch branch.
Funds-at-risk before this fix: with the broken tipMs path, every live
Reclaim was rejected client-side ("tip < deliver_by" using a Nov-1970
tip). If a supplier never Claimed, buyer's 6-7 AP3X stayed locked
indefinitely. Verified recovery of 4 stuck escrows on Vector L2 testnet
(txs f6660e52, 769f16fd, 79970d86, 9f4db14b — 14 AP3X total recovered).
RetireAdvert:
- buildLiveTxForRetireAdvert in liveCbor.ts: spends advert UTxO with
REDEEMER_RETIRE_ADVERT (Constr2), advert script address.
- retireAdvert.ts: live-CBOR dispatch.
- supplier CLI: retire-advert.ts + retireAdvertFlow.ts.
- supplier/package.json: tx:retire-advert script.
Indexer:
- follower/blockProcessor + worker: handle RetireAdvert events,
transition advert row state to Retired.
- db/cache.ts: markAdvertTerminal helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
While checking whether last night's localUPLCEval: false fix unblocks
the 5 tx-submit-live tests skipped under TODO(M1-F-D-defer), found
that those tests still crash — but at a DIFFERENT spot than before.
Original UPLC issue (now fixed in production via 7ea7328 + fd24598):
- lucid's CML WASM UPLC interpreter falsely rejected our Plutus V3
Submit/Claim/Accept txs. End-to-end chat + TTS lifecycle now works
on Vector testnet by routing eval to Ogmios.
New / remaining test-side issue:
- Even with a proper evaluateTransaction mock response, lucid's
applyUPLCEvalProvider → CML TransactionBuilder.set_exunits still
panics with wasm "unreachable" inside the test fixture. The
production path doesn't hit this (real Ogmios + real wallet UTxOs).
Whatever the test-mock + CML interaction trips, it's separate from
the original UPLC bug.
Refinements landed
- liveCbor.ts: opt-in LIVECBOR_DEBUG=1 env that prints the full inner
err + WASM stack from rethrowAsTxError, so the next investigation
doesn't need to re-add this scaffolding.
- tx-submit-live.test.ts:
- setupSubmitHappyPath() now mocks "evaluateTransaction" with a
proper EvalRedeemer-shaped response (useful baseline for any
future un-skip attempt; not enough on its own to make the tests
pass).
- The first skipped test's TODO comment updated to record what's
fixed in production vs. what remains as a test-infra bug.
Test counts unchanged (3 pass, 5 still skipped); production path
still verified end-to-end on testnet. Task M1-F-D-defer stays pending
but the original UPLC-interpreter blocker is gone.
The on-chain Submit tx anchors sha256(canonicalize(receipt)) so the
receipt's signature is provably tied to a specific Cardano block — but
without persisting the receipt content + the actual response bytes
nowhere can buyer or supplier produce the artefacts a dispute would
need to argue about. This commit closes that gap on the buyer side.
What's new
- buyer/src/db/schema.ts (new):
`responses` table keyed by escrow_ref, indexes on completed_at /
capability_id / supplier_pkh. Stores metadata + receipt JSON +
Ed25519 signature; large blobs (request envelope, response bytes)
live on disk per-escrow.
- buyer/src/db/archive.ts (new):
ResponseArchive class — persistChat / persistTts / list / get /
readRequest / readResponse. Filesystem layout under ARCHIVE_DIR:
<txhash>_<index>/request.json (always JSON)
<txhash>_<index>/response.{ext} (txt/json for chat, mp3/wav/… for TTS)
Synchronous (better-sqlite3 + fs.writeFileSync) — volume is low and
the simplicity is worth more than async overhead.
- buyer/src/server.ts:
- AppDeps.archive plumbed through createApp.
- /v1/submit-prompt + /v1/submit-tts persist BEFORE returning to
the SPA, using canonicalize() for chat (same bytes the supplier
hashed; JSON.stringify produced different bytes → wrong sha256).
Persist failures are logged but never abort the response — the
on-chain receipt is still authoritative.
- New read-only endpoints:
GET /v1/responses?limit=… list metadata + parsed receipts
GET /v1/responses/:ref single-row detail
GET /v1/responses/:ref/request request artefact (application/json)
GET /v1/responses/:ref/response response artefact (verbatim bytes)
- URL path uses "<txhash>_<index>" (the dir-safe form) since "#"
can't appear unencoded in URLs (fragment delimiter); server
normalises back to canonical "<txhash>#<index>" for SQLite lookup.
- 503 service_unavailable on /v1/responses* when boot has no
archive (ARCHIVE_DIR unset or DB unopenable) — the rest of
the buyer-app still works.
- buyer/src/ui/pages/TaskHistory.tsx:
Joins indexer escrows (chain state) + /v1/responses (archived
artefacts) by escrow_ref. Each completed lifecycle gets an
archive panel showing:
- Request panel (above, chronological order): human-readable
summary — first user message for chat, `text + voice + format +
speed` for TTS, raw JSON fallback otherwise.
- Response panel: assistant text inline for chat, <audio controls>
for TTS; Download response / Download request anchors.
- "Verify hash" button — fetches the response bytes, computes
sha256 via crypto.subtle, compares to receipt.response_hash;
green ✓ on match, red ✗ with truncated computed/expected on
mismatch.
- Collapsible "Receipt + signature" with the full canonical
receipt JSON + the supplier's Ed25519 hex sig.
- buyer/src/config.ts: + ARCHIVE_DIR env (default "./data/archive").
- buyer/Dockerfile: builder gains python3/make/g++ + the
better-sqlite3 native-binding fetch step (mirrors indexer/Dockerfile);
runtime image stays toolchain-free.
- deploy/testnet/docker-compose.buyer.yml: bind-mounts
../../data/buyer/archive (repo-root-relative, not file-relative)
into /data/archive. ARCHIVE_DIR env points at that path.
Tests
- tests/unit/buyer-archive.test.ts (new, 14 tests): persistChat /
persistTts unit coverage (filesystem layout, INSERT-OR-REPLACE
semantics, content-type → extension mapping for mp3/wav/opus/aac/
flac, list ordering + limit, single-row get/read miss-vs-hit) +
HTTP integration (503 when disabled, happy paths returning
metadata / request / response artefact bytes with correct
content-types, 404 on unknown ref).
- tests/unit/buyer-ui-smoke.test.tsx: TaskHistory fetch mock now
also handles /v1/responses (returns empty list — same code path as
"archive disabled" production case).
45 buyer tests passing; chat + TTS lifecycle verified end-to-end on
Vector testnet; "Verify hash" button confirms sha256(response bytes)
matches receipt.response_hash for both capabilities.
buildLiveTxForAccept has its own .complete() call separate from
buildSpendOpenOrClaimedTx (which already routed eval to Ogmios in
7ea7328). The same lucid 0.4.30 CML WASM false-rejection bit Accept
on every escrow — confirmed by 502s on three different pending
receipts in the SPA after the chat/TTS Submit fix landed.
Adding `localUPLCEval: false` to Accept's .complete() options closes
the gap; verified end-to-end against Vector testnet (Accept tx
0385442fd7ef72743194f8fa0b468c2c56bacb90b023f86ec2c3df22b18b7ca8
confirmed under the prior workaround; subsequent Accepts on the
patched build also confirm cleanly).
Second supplier instance on apex-dashboard, dedicated to the new
audio.synthesize.piper.v1 capability. Reuses supplier/Dockerfile but
sets CAPABILITY_KIND=tts so the boot dispatch mounts /v1/audio/
synthesize routes instead of /v1/chat/completions.
PIPER_URL points at the public CF hostname for the openedai-speech-min
host on apex-simulation (no shared docker network with this host).
Wallet, advert UTxO, .env.tts, and Cloudflare DNS+Skip rule are
managed out-of-band; this compose just brings up the container that
serves the marketplace-side of the TTS lifecycle.
Two production-essential fixes that surfaced during the path-A TTS
deployment to Vector testnet:
1. localUPLCEval: false on every Plutus V3 spend
──────────────────────────────────────────────
lucid-evolution 0.4.30's CML WASM UPLC evaluator has a false-rejection
bug for our V3 spend pattern: it rejects perfectly valid txs with
"failed script execution Spend[0] the validator crashed / exited
prematurely" while the chain itself accepts them without complaint.
Routing eval to Ogmios's evaluateTransaction (cardano-node's
evaluator) sidesteps the WASM bug and returns correct ex-units.
OgmiosLucidProvider.evaluateTx already implements this path.
This also unblocks the 5 tx-submit-live tests previously skipped
with the comment "UPLC wasm crashes" (M1-F-D-defer / task #28).
Without this fix, every Claim/Submit/Accept tx from our supplier
was failing locally before reaching the chain. The third-party
summitstak.ing supplier worked because it uses a different
toolchain that does not run client-side phase-2 evaluation.
2. AdvertDatum BigInt coercion
────────────────────────────
cbor-x emits float64 (`fb` prefix) for JS Number values that can't
fit CBOR's compact int forms (e.g. POSIX-ms timestamps). Plutus
Data has no float type, so CML.PlutusData.from_cbor_hex panics
with "RuntimeError: unreachable" when it sees one. Coerce
max_output_tokens, max_processing_ms, advertised_at to BigInt
before encoding so cbor-x always emits major-type-0/1 integers.
Mirrors the intAsBig pattern already used in EscrowDatum.
3. Live-CBOR PostAdvert builder
─────────────────────────────
M1-F-4 deferred postAdvert/updateAdvert/retireAdvert/reclaim/
release on the live path. With (1) and (2) in place, posting an
advert via tx:post-advert against Vector testnet now works.
Added buildLiveTxForAdvert in liveCbor.ts (mirrors PostEscrow)
and routed buildPostAdvertTx through it when chain is live.
40 advert/post-advert tests still pass. Verified end-to-end on
Vector testnet: TTS advert posted at
5df793ac12dc426fae7a7a01d56d3e923d3985103d62f0182d9ba63dda871f96#0,
full chat + TTS marketplace lifecycle (PostEscrow → Claim → Submit →
Accept) confirmed completing successfully on chain.
Path-A buyer side: PostEscrow → supplier /v1/audio/synthesize → 202+poll
→ on-chain Submit → receipt verify, returning the audio bytes + signed
receipt to the SPA. Pairs with the supplier-side scaffolding in 3c3e009.
Files
- packages/shared/src/tx/escrow/postTtsEscrow.ts (new):
buildPostTtsEscrowTx() mirrors postEscrow.ts but commits a TTS-shaped
prompt_hash = sha256(canonical({text, voice, format, speed})). The
same hash is exposed as ttsPromptHash() so the buyer SDK and supplier
validation can pin the canonicalisation; any drift fails the
supplier's prompt_mismatch gate. Validates body shape (text non-empty,
voice/format in allow-list, speed ∈ [0.5,1.5]); reuses the chat live-
cbor path which is already capability-agnostic.
- packages/shared/src/tx/index.ts: export the new TTS surface.
- buyer/src/sdk/Marketplace.ts: + submitTts() — full lifecycle mirror of
submitPrompt with TTS-shaped POST body, /v1/audio/synthesize endpoint,
and audio_b64 in the polled response. Receipt verification gates on
prompt_hash via ttsPromptHash() instead of canonicalize(messages).
- buyer/src/sdk/types.ts: + SubmitTtsOptions / SubmitTtsResult.
- buyer/src/server.ts: + POST /v1/submit-tts (mirrors /v1/submit-prompt
shape; routes to marketplace.submitTts).
- buyer/src/ui/components/PiperTTSForm.tsx: two-mode rendering. When
advertRef + payment_lovelace are passed, posts to /v1/submit-tts and
decodes base64 audio into a blob URL. When neither is passed, falls
back to /v1/synth-speech (the path-B direct demo proxy).
- buyer/src/ui/pages/Dashboard.tsx: capability dispatch routes a real
on-chain TTS supplier to the marketplace mode and the synthetic demo
card to the direct mode (its utxo_ref="demo:piper-tts" fails the ref
parser → advertRef is null → demo mode). Same component for both.
Tests still passing: 31 buyer + 29 supplier; both typechecks clean.
Chat path behaviourally untouched.
Three buyer-app changes that all landed during the same browser-debug
session and were verified end-to-end on Vector testnet:
UX-3-history — /tasks page is now indexer-driven (M1-F-5 followup)
- TaskHistory.tsx fetches /v1/indexer/escrows?buyer=<pkh> from the
buyer-app proxy, groups rows by (posted_at, supplier_pkh) into
lifecycle cards, and renders newest-first with state pill,
capability/model/price, and the tx-hash chain
- main.tsx drops the LocalStorageTaskHistoryStore (chain is the source
of truth; SDK uses MemoryTaskHistoryStore for in-flight only)
Path-B PiperTTS demo — capability-routed UI without escrow yet
- buyer/src/server.ts: POST /v1/synth-speech proxies to the openedai-
speech-min host; validates {text, voice, format, speed} and returns
audio bytes with the upstream Content-Type
- buyer/src/config.ts: TTS_PIPER_BASE_URL env (optional; absence keeps
/v1/synth-speech disabled with a clean 503)
- buyer/src/ui/components/PiperTTSForm.tsx: capability-specific form
(text + voice + format + speed slider) with inline <audio controls>
and a Download .mp3 anchor; blob URLs revoked on each submit + unmount
- buyer/src/ui/pages/Dashboard.tsx: capability registry — switch on
selected.capability_id to PromptForm vs PiperTTSForm; appends a
synthetic "PiperTTS (apex-simulation)" demo card so the capability
routing is exercisable before the on-chain TTS supplier ships
- deploy/testnet/docker-compose.buyer.yml: TTS_PIPER_BASE_URL points
at https://tts-piper.vector.testnet.apexfusion.org
Server-side error logging on /v1/submit-prompt
- buyer/src/server.ts: catch block now logs reason + message + stack
(the demo session lost ~30 minutes to a "<!DOCTYPE..." JSON-parse
error caused by Cloudflare replacing origin's clean 502+JSON with
its own HTML page; the actual upstream cause was a "wallet must
hold a pure-ADA UTxO >=5 ADA" lucid-evolution error that was
invisible because the Express handler had no logger)
Tests: 10 new for /v1/synth-speech (validation, defaults, success
shape, upstream 5xx, fetch-throw); existing buyer-ui-smoke supplier
count expectation updated for the synthetic Piper card (N+1)
Stand up a second deploy target on apex-simulation dedicated to
"simulation"-style supplier capabilities, starting with CPU-only PiperTTS
served via openedai-speech-min behind Traefik on :80.
- deploy/simulation/docker-compose.traefik.yml: Traefik latest with
introspection API, settles on Docker daemon 29.x API negotiation
- deploy/simulation/docker-compose.tts-piper.yml: openedai-speech-min
with curl-based healthcheck (image lacks wget, which Docker would have
marked unhealthy and Traefik would filter from the docker provider)
- deploy/simulation/deploy.sh: rsync-based deploy mirroring the
apex-dashboard pattern; selective --traefik-only / --tts-only flags
- deploy/simulation/install-voice-extras.sh + voice-mappings.py:
idempotent post-deploy step that downloads en_US-lessac-high voice
files into the bind-mount and registers a "lessac" alias in
voice_to_speaker.yaml; survives a host wipe without manual steps
- deploy/simulation/README.md: full host runbook
- .gitignore: exclude data/ recursively, *.log, .vitest-cache, .DS_Store,
__pycache__/, *.pyc, docs/*.zip