PDF_ENABLED=0 skips the JobStore/PdfJobDb wiring in runMain, so every
/v1/pdf-* route responds 503 (the pre-existing disabled path). Default
stays enabled; only "0"/"1" accepted, misconfig fails boot loudly.
OpenAI-compatible supplier backed by the HuggingFace Inference Providers router
(meta-llama/Llama-3.3-70B-Instruct, llm.text.generate.v1). Config only; mirrors
the glm one-off template with OPENAI_BASE_URL=https://router.huggingface.co.
- deploy/mainnet/docker-compose.supplier-hf-llama33.yml
- register the supplier wallet in monitor-wallets OPERATOR_SOURCES
The env file (supplier/.env.hf-llama33) holds the wallet key + HF token and is
never committed; wallet-monitor/wallets.json is gitignored runtime data.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Second supplier on the inference-proxy box, proxying to an OpenClaw gateway
(agent 'main') on the web-tools KVM guest over the tailnet
(100.101.143.72:18789). Same vuk llama.cpp brain as supplier 'local'; the
product is the agent loop (searxng web search + web fetch + browser).
Capability llm.chat.v1, advertised model 'openclaw-web-agent', 0.2 AP3X,
ticket settle mode, MAX_CHAT_SESSIONS=1.
Uses the new stateful-upstream contract: OPENAI_SESSION_PASSTHROUGH=1 (escrow
ref as OpenAI 'user' → sticky per-escrow openclaw session, delta-only sends)
and OPENAI_MODEL_OVERRIDE=openclaw (endpoint only accepts 'openclaw').
- docker-compose.supplier-openclaw.yml: new supplier compose (traefik host
mp-suppliers-openclaw.vector.apexfusion.org).
- README: gateway config (bind=tailnet, chatCompletions on, GLOBAL tool
allow+deny lockdown), the tailnet-ACL blocker (inference-proxy can't yet
reach web-tools:18789), bring-up runbook, and the sudo-gated browser +
egress-firewall follow-ups.
- monitor-wallets.ts: register supplier-openclaw@inference-proxy.
Wallet generated + funded (200 AP3X); .env.openclaw written on the host.
Not yet advertised — go-live (post-advert) waits on the ACL + a healthy
end-to-end path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the ocr.page.extract.chandra-ocr-2.v1 capability end to end: the
postOcrEscrow builder (locking the min-ada floor for the Submitted-state
datum, required since the min-ada fix), the gateway OCR route that selects
an OCR supplier by capability, posts a per-page escrow, calls the supplier,
verifies and settles, and returns content plus the signed receipt labelled
first-party-brokered. Supplier OCR handler with a hosted document-API
upstream and buyer-SDK OCR support included.
Squashed from the 2026-08-06 working subset; the deploy pack is
intentionally excluded (it lives in a private operator repo).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The lockfile gained patchedDependencies (libsodium-wrappers-sumo) in
2aa1688, and the package manager hashes the patch file during install —
every image build since then fails with ENOENT on /repo/patches. No
image had been rebuilt since, so it surfaced only on the next deploy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- docs/ARCHITECTURE.md: settlement-asset framing (AP3X bonded in escrow,
settles on accepted work); remove references to external network
adapters that are not part of the project
- docs/buyer + docs/supplier READMEs: commission/bond/settle vocabulary
- Buyer web UI: "Accept & Settle", escrow-framed labels, gateway-managed
wallet wording; gateway landing title punctuation
- Delete EXECUTIVE_SUMMARY and local-llm-marketplace docs + exports:
M1-era internal design snapshots superseded by ARCHITECTURE.md and the
public docs site
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two structural fixes for demo concurrency (3 single-slot suppliers thrashed
under Open WebUI's ~3 conversations per message):
Multi-slot suppliers (MAX_CHAT_SESSIONS, default 1):
- SupplierState is now capacity-aware: a Set of active escrow refs with
free-until-full status semantics, ref-scoped release(ref) (also fixes the
latent bug where handler catch-alls released another request's slot), and
a walletMutex serializing every wallet-spending chain op — Claim, Submit,
and wallet-health consolidation queue instead of racing the single
working/collateral UTxO. /status gains active_sessions/max_sessions.
Entry-ticket settle mode (CHAT_SETTLE_MODE=ticket, default full, both sides):
- The buyer's escrow post at session open is the ONLY chain op. The supplier
verifies the Open escrow as an entry ticket but never Claims/Submits
(/v1/chat/start → {status:"ticket"}; /v1/chat/end → receipt-less
{status:"ended"}); no hard-cap timer, so sessions may outlive deliver_by.
Used tickets can't reopen sessions (records retained; 409 session_exists).
- Buyer SDK: startChat/endChat are settle-mode aware, driven by the
supplier's response — mixed-mode rollout windows settle correctly.
- Gateway: ticket closes bill cost 0; the sweeper Reclaims Open tickets
after deliver_by WITHOUT closing live sessions (the conversation and its
affinity mirror survive their reclaimed ticket), gates reclaims on
deliver_by, and keeps an in-process settled-refs memory so terminal
indexer rows aren't retried every sweep forever.
Deployment (kimi-chat trio + gateway): ticket mode, 8 slots per supplier,
idle policy gateway 10 min / supplier 15 min (fixes the inversion where the
supplier idle-closed before the gateway janitor).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MVP demo: one operator-funded API key anyone can plug into an IDE/harness,
running real escrow-per-session marketplace conversations (llm.chat.v1)
behind the standard OpenAI /chat/completions wire shape.
- shared: ChatMessage gains tool role + tool_calls; normalizeChatMessage/
chatMessagesEquivalent keep supplier transcript and gateway mirror
hash-identical for receipts (field presence is part of the identity)
- supplier: /v1/chat/message accepts {messages,tools,tool_choice} alongside
legacy {content}; callOpenAiStream forwards tools, accumulates fragmented
delta.tool_calls, allows content-less tool turns; done frame carries the
assistant message verbatim + finish_reason + usage
- gateway: demo flag on api_keys (additive migration), create-demo-key CLI,
withdraw 403 + hidden deposit address for demo keys, per-IP demo rate
limit (demo keys skip the shared per-key limit), models filtered to chat
capability
- gateway executor (demoChat.ts): transcript-prefix affinity maps stateless
calls onto open sessions, forwards only unseen deltas, transparently
reopens on supplier idle-close; never falls back to one-shot (its settle
path assumes <=1 in-flight escrow per wallet)
- mutex scope: key mutex held only for chain work (open/close); off-chain
turns serialize on per-session locks so replicas serve users concurrently
- sweeper: chat-aware reclaim guard (35min vs 11min) and abandoned-session
cleanup; terminal state confirmed via on-chain queryUtxo (indexer keeps
lineage rows at their old state forever)
- deploy: kimi-chat replicas 2+3 compose files; chat idle timeout 2.5min
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add 3 single-slot suppliers serving deepseek/deepseek-v4-flash via OpenRouter,
increasing concurrent capacity for the independent buyer machines. Same shape
as the kimi replicas: capability llm.text.generate.v1, price 200000 lovelace,
max_output_tokens=262144, max_processing_ms=3600000, OPENAI_TIMEOUT_MS=3600000,
sharing the one OpenRouter key (accepted concurrency ceiling; no 429/backoff).
- deploy/mainnet/docker-compose.supplier-deepseek-flash-{1,2,3}.yml (new)
- buyer/scripts/monitor-wallets.ts: register the 3 replica wallets
Per-instance wallets, .env files, and on-chain adverts are provisioned on the
mainnet host (gitignored); the 3 containers are live and Active on-chain.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add 3 parallel-capacity replicas of supplier-kimi (kimi-2/3/4) so the
marketplace can serve more concurrent jobs from the independent buyer
machines. Each is a single-slot worker on the same capability
(llm.text.generate.v1) and model (moonshotai/kimi-k2.6) at the same 0.2 AP3X
flat price, sharing the one OpenRouter key (accepted concurrency ceiling).
Also bring the original supplier-kimi up to the same config: raise its advert
caps (re-posted on-chain) and OPENAI_TIMEOUT_MS to match. All four kimi
suppliers now advertise max_output_tokens=262144, max_processing_ms=3600000
(OpenRouter's ceiling for kimi-k2.6), with OPENAI_TIMEOUT_MS=3600000 so the
upstream call isn't cut short before the advertised deadline.
- deploy/mainnet/docker-compose.supplier-kimi-{2,3,4}.yml (new)
- deploy/mainnet/docker-compose.supplier-kimi.yml: OPENAI_TIMEOUT_MS 300000 -> 3600000
- buyer/scripts/monitor-wallets.ts: register the 3 replica wallets
Per-instance wallets, .env files, and on-chain adverts are provisioned on the
mainnet host (gitignored); all four containers are live and Active on-chain.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an "API Keys" page to the buyer SPA (marketplace.vector.apexfusion.org)
that mints a custodial gateway API key by POSTing directly to the gateway's
public /signup, then shows the key once + deposit address + a base_url/curl
usage snippet. Page lives behind the SPA's existing operator login; per-user
registration is a later feature.
Gateway:
- GATEWAY_CORS_ORIGINS env -> corsOrigins allowlist; corsMiddleware echoes
Access-Control-* only for allowlisted Origins, answers OPTIONS preflight
204, no credentials. Empty allowlist = CORS off (default safe).
Buyer:
- GATEWAY_PUBLIC_URL env -> injected into __BUYER_BOOT__.gatewayUrl (only in
the authenticated boot block). SPA resolveGatewayUrl() falls back to
deriving "api." + location.host so the post-login-no-reload case (boot
block absent) still works.
- New ApiKeys page + /api-keys route + nav link.
Deploy (mainnet compose): gateway GATEWAY_CORS_ORIGINS and buyer
GATEWAY_PUBLIC_URL set to the marketplace/api hosts. NOTE: both images must be
REBUILT (gateway runs tsx from copied source; buyer SPA is vite-built at image
build) — an env-only recreate would silently ship the old code.
Typecheck + vite build clean; 205 tests pass. Adversarial multi-agent review
(4 dimensions, per-finding verification) surfaced only the rebuild-required
deploy note above; no code/security defects.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New multi-tenant gateway package: external developers point any OpenAI SDK at
the marketplace. Each API key is its own custodial buyer wallet (AES-256-GCM
sealed under GATEWAY_MASTER_KEY); every request posts an on-chain escrow from
that user's wallet.
- POST /openai/v1/chat/completions (one escrow/call, llm.text.generate.v1,
buffered + pseudo-stream), chat sessions (one escrow/session, llm.chat.v1,
real SSE), GET /openai/v1/models, /signup, /account, /account/withdraw.
- Settles by resolving the Submitted ref via the indexer + awaited Accept;
state-aware reclaim sweeper + per-wallet auto-consolidation for collateral.
- Reuses the buyer Marketplace SDK via new buyer exports ./sdk; new shared
buildWithdrawTx (custodial exit). Runs via tsx; Dockerfile + mainnet compose.
Design in docs/gateway.md. Typecheck passes for all packages; 20 vitest tests
pass. Not yet exercised against a live chain.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the static "…" in the streaming bubble with a "Thinking" label plus
three staggered bouncing dots, shown until the model's first token streams in.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The supplier is single-slot, but the buyer posted the escrow BEFORE learning
the supplier was busy, stranding a second user's funds in an Open escrow. Now:
- startChat does a pre-flight GET /status and refuses (409, no escrow posted)
when the supplier is "working".
- ChatForm polls the supplier status (indexer) every 8s and disables the
Start chat button (\"Supplier busy\") until it returns to free.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds GET /v1/wallet/balance (sums the buyer wallet UTxOs via
queryUtxosByAddress from Ogmios) and renders it on the Wallet page as AP3X
with a UTxO count and a refresh button.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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)