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