16 Commits

Author SHA1 Message Date
FilipBl4gojevic
36c5cfc6be fix(wallet): make wallet-health idempotent - structural health predicate + circuit breakers
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
planConsolidate returned already-healthy only for wallets with EXACTLY
2 UTxOs, but buildConsolidateWalletTx lands 3 (collateral + working +
the fee-reserve remainder as change). The shape consolidation produces
can never satisfy its own health check, so every wallet-health tick
re-consolidates and pays the ~0.5 AP3X min fee again. Measured in a
production deployment: a 45s ticker submitted 232 consolidations and
burned ~116 AP3X in about 2.5 idle hours; at the default 600s interval
the same loop leaks ~3 AP3X per hour whenever the wallet is not exactly
2 UTxOs.

Healthy is now structural: a pure-lovelace UTxO >= collateralLovelace
exists, at least 1 AP3X sits outside it, and fragmentation is bounded
(<= 6 UTxOs, new optional maxUtxos param). The health check also runs
BEFORE the low-balance guard, so a wallet that can already run script
transactions is never reshaped or refused. walletHealth gains two
independent circuit breakers - halt after 3 consecutive consolidations
that never reach healthy, halt past 6 consolidations per 24h - so any
future non-convergence costs at most ~1.5 AP3X instead of the whole
wallet, plus a docstring that matches reality.

Tests: the exact post-consolidation-shape regression, one-step
idempotence against the builder output model, candidate boundaries
(native assets, 1,628-lovelace-short), healthy-below-reshape-threshold,
and breaker behavior including error and reset paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 12:10:45 +02:00
david
a54fdcb24c feat(deploy): terminate TLS at traefik for the gateway host via Let's Encrypt
api.marketplace.vector.apexfusion.org is no longer Cloudflare-proxied, so
traefik now terminates TLS itself: a websecure router with the letsencrypt
ACME resolver, and the :80 router demoted to a permanent HTTPS redirect.
Other hosts on the box stay Cloudflare-terminated on :80.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 13:30:53 +02:00
FilipBl4gojevic
926300dee5 Merge pull request #6 from FilipBl4gojevic/brand-vocab-scrub
Brand vocabulary scrub: settlement framing, UI strings, retire M1-era design docs
2026-07-30 14:24:52 +02:00
FilipBl4gojevic
73700f983a Brand vocabulary scrub: settlement framing, UI strings, retire M1-era design docs
- 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>
2026-07-30 13:23:35 +02:00
david
2aa1688a17 fix(ci): green the test suite — pnpm 10 build scripts, libsodium ESM, stale time fixtures
CI has been red since the pnpm 10 migration. Four independent causes:

- better-sqlite3: pnpm 10 blocks dependency build scripts by default, so the
  native binding was never compiled on CI. Allowlist it via
  onlyBuiltDependencies in pnpm-workspace.yaml.
- libsodium-wrappers-sumo 0.7.16: the published ESM wrapper does
  `import "./libsodium-sumo.mjs"` but the tarball does not ship that file
  (it lives in the libsodium-sumo package). Patch the import to the bare
  specifier; local runs only worked because of a hand-copied file in
  node_modules.
- tx-accept/claim [live] tests: the live path checks deadlines against
  Date.now(), and the fixed fixture timestamps expired in June. Freeze the
  clock inside the fixture windows with vi.useFakeTimers.
- CML set_exunits panic ("RuntimeError: unreachable"): with
  localUPLCEval: false the provider asks Ogmios for ex-units; the test mocks
  answered `{}`, so the provider fell back to a spend redeemer at index 0
  while the escrow input sorts to index 1 — CML panics on the unknown
  redeemer key. Mock evaluateTransaction with the true index like real
  Ogmios.

Also regenerate the advert-datum golden: the fixture builders passed plain JS
numbers to cbor-x (minimal ints, float64 for the timestamp) while the
production codec — whose output is live on mainnet — emits full-width uint64.
Builders now encode integer fields as BigInt so builders, codec, and golden
agree byte-for-byte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 11:16:19 +02:00
dcejtlin-apex
3ae2a827c4 Merge pull request #5 from FilipBl4gojevic/gateway-ap3x-strings
Gateway: AP3X terminology in user-facing strings
2026-07-29 10:32:28 +02:00
dcejtlin-apex
fec0ac8d0a Merge pull request #4 from FilipBl4gojevic/dispute-resolution-naming
Naming: Dispute Resolution module
2026-07-29 10:32:16 +02:00
david
be9febffb1 fix(wallet-monitor): query wallets sequentially with retry to avoid Ogmios 500s
The public Ogmios endpoint only serves a few concurrent ledger-state
queries and returns 500 for the rest, so the Promise.all burst over the
(now 19-wallet) list reliably failed several wallets every run. Query
sequentially instead (full sweep ~2.5s) and retry each wallet once after
2s so a lone blip no longer posts a false "query failed" alert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:19:39 +00:00
david
e5793031f8 chore(deploy): track mainnet compose files for ds-flash-chat suppliers 1-3
These were live on the box but never committed. Chat-session replicas of
the deepseek-flash suppliers (OpenRouter deepseek-v4-flash, llm.chat.v1,
ticket settle mode); secrets stay in the gitignored supplier/.env files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:14:42 +02:00
FilipBl4gojevic
716a8bf2ef Gateway: AP3X terminology in user-facing strings and docs
The signup page said '~5 ADA collateral' and two API error messages
told agents to 'deposit a little more ADA' - the coin is AP3X. Strings,
comments, and docs/gateway.md updated; code identifiers unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:14:32 +02:00
david
b10706cf69 fix(wallet-monitor): post only on state changes, drop reminder re-alerts
Slack posts now fire only when a wallet newly drops below its threshold
or recovers above it; a wallet that stays low never re-posts. The
reminder_hours mechanism is removed entirely — per-wallet reminder
clocks drifted apart (Ogmios blips skip wallets from a run), so with
many chronically-low wallets some clock came due nearly every run,
collapsing the 6h cadence to the hourly cron interval.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:12:45 +02:00
FilipBl4gojevic
1f587867c6 README: Dispute Resolution naming
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:06:14 +02:00
HalukG
0b253c73bb Merge pull request #3 from FilipBl4gojevic/docs-consolidation-readme
README: marketplace front door - status, flow, layout, links
2026-07-24 13:46:46 +03:00
FilipBl4gojevic
1648edeba7 README: vocabulary pass on Related line
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:26:58 +02:00
FilipBl4gojevic
4090e064cb README: note internal audit coverage
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:07:03 +02:00
FilipBl4gojevic
3fac04b00e README: marketplace front door - status, flow, layout, links
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 10:57:27 +02:00
47 changed files with 791 additions and 981 deletions

View File

@@ -1,3 +1,36 @@
## Local Agents Marketplace # Local Agents Marketplace
Local Agents Marketplace MVP. See `docs/ARCHITECTURE.md` for the architecture spec. Monorepo managed with pnpm workspaces. Work-in-progress; not production-ready. Bonded-escrow coordination for AI agent work on **Vector**, the Apex Fusion eUTXO L2.
Buyers commission work. Suppliers claim it. Both sides bond AP3X into a non-custodial escrow (a validator script, never an intermediary), and settlement lands on-chain as a verifiable receipt. Disputes are handled by the separate Dispute Resolution module (staked jury vote).
**Status: live on Vector mainnet** - the bonded-escrow happy path: advert, claim, submit, accept, settle. The contracts have been through the internal audit pipeline (methodology published in [vector-ai-agents](https://github.com/Apex-Fusion/vector-ai-agents)); they have not yet undergone independent third-party audit.
## Flow
1. **Advert** - a buyer posts a job with escrow bonded
2. **Claim** - a supplier claims it, bonding their side
3. **Submit** - work is delivered
4. **Accept** - the buyer accepts the work
5. **Settle** - the escrow releases; the settlement is a signed on-chain receipt
## Repository layout
| Path | What |
|---|---|
| `contracts/advert`, `contracts/escrow`, `contracts/marketplace` | Aiken validators |
| `docs/ARCHITECTURE.md` | Architecture spec |
| `docs/buyer/`, `docs/supplier/` | Role guides |
| `deploy/` | Deployment and simulation tooling |
Monorepo managed with pnpm workspaces.
## Getting started
Developer docs: [Vector AI documentation](https://apex-fusion.github.io/vector-ai-documentation/). For the contract-level picture, start with [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
## Related
- [mcp-server](https://github.com/Apex-Fusion/mcp-server) - hosted MCP server for Vector (testnet and mainnet)
- [agent-sdk-py](https://github.com/Apex-Fusion/agent-sdk-py) / [agent-sdk-ts](https://github.com/Apex-Fusion/agent-sdk-ts) - Python and TypeScript SDKs
- [vector-agent-modules](https://github.com/Apex-Fusion/vector-agent-modules) - dispute resolution, reputation staking, self-improvement

View File

@@ -94,7 +94,7 @@ export default function JobEstimate({
<dd className="text-right">{est.mapCalls}</dd> <dd className="text-right">{est.mapCalls}</dd>
<dt>Reduce calls</dt> <dt>Reduce calls</dt>
<dd className="text-right">{est.reduceCalls}</dd> <dd className="text-right">{est.reduceCalls}</dd>
<dt className="font-medium">Total paid calls</dt> <dt className="font-medium">Total escrowed calls</dt>
<dd className="text-right font-medium">{est.totalCalls}</dd> <dd className="text-right font-medium">{est.totalCalls}</dd>
<dt className="font-medium">Est. max cost</dt> <dt className="font-medium">Est. max cost</dt>
<dd className="text-right font-medium">{est.totalAp3x} AP3X</dd> <dd className="text-right font-medium">{est.totalAp3x} AP3X</dd>
@@ -126,7 +126,7 @@ export default function JobEstimate({
disabled={blocked || starting} disabled={blocked || starting}
className="rounded bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:bg-gray-400" className="rounded bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:bg-gray-400"
> >
{starting ? "Starting…" : `Proceed & pay (~${est.totalAp3x} AP3X)`} {starting ? "Starting…" : `Proceed (locks ~${est.totalAp3x} AP3X in escrow)`}
</button> </button>
</div> </div>
); );

View File

@@ -50,7 +50,7 @@ export default function PdfUploadForm({ onUploaded }: { onUploaded: (info: Uploa
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Upload a text-based PDF book. The buyer will chunk it and pay marketplace Upload a text-based PDF book. The buyer will chunk it and commission marketplace
suppliers to summarize each chunk, then synthesize a full-book summary. suppliers to summarize each chunk, then synthesize a full-book summary.
</p> </p>
<input <input

View File

@@ -114,7 +114,7 @@ export default function ApiKeys() {
<h1 className="text-2xl font-semibold">API Keys</h1> <h1 className="text-2xl font-semibold">API Keys</h1>
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Generate an OpenAI-compatible API key for the marketplace gateway. Each key has its own Generate an OpenAI-compatible API key for the marketplace gateway. Each key has its own
custodial wallet — fund its deposit address with AP3X, then point any OpenAI SDK at{" "} gateway-managed wallet (the gateway holds this wallet's key) - fund its deposit address with AP3X, then point any OpenAI SDK at{" "}
<code className="font-mono text-xs">{baseUrl}</code>. <code className="font-mono text-xs">{baseUrl}</code>.
</p> </p>
</div> </div>

View File

@@ -152,7 +152,7 @@ export default function BookSummarizer() {
<div> <div>
<h1 className="text-2xl font-semibold">Book Summarizer</h1> <h1 className="text-2xl font-semibold">Book Summarizer</h1>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500">
Upload a PDF → pay suppliers to summarize each chunk → get a full-book summary. Upload a PDF → commission suppliers to summarize each chunk → get a full-book summary.
Jobs keep running if you navigate away; reopen them below. Jobs keep running if you navigate away; reopen them below.
</p> </p>
</div> </div>

View File

@@ -1,6 +1,6 @@
/** /**
* buyer/src/ui/pages/PendingReceipts.tsx — list of currently-Submitted escrows * buyer/src/ui/pages/PendingReceipts.tsx — list of currently-Submitted escrows
* for this buyer, with an "Accept & Pay" action per row. * for this buyer, with an "Accept & Settle" action per row.
* *
* Data flow: * Data flow:
* GET /v1/pending-receipts → buyer-app server proxies indexer /escrows?buyer=<pkh> * GET /v1/pending-receipts → buyer-app server proxies indexer /escrows?buyer=<pkh>
@@ -190,7 +190,7 @@ export default function PendingReceipts() {
<th className="px-2 py-2">Submitted</th> <th className="px-2 py-2">Submitted</th>
<th className="px-2 py-2">Supplier</th> <th className="px-2 py-2">Supplier</th>
<th className="px-2 py-2">Capability</th> <th className="px-2 py-2">Capability</th>
<th className="px-2 py-2">Payment</th> <th className="px-2 py-2">Escrowed</th>
<th className="px-2 py-2">Window left</th> <th className="px-2 py-2">Window left</th>
<th className="px-2 py-2">Action</th> <th className="px-2 py-2">Action</th>
</tr> </tr>
@@ -254,7 +254,7 @@ export default function PendingReceipts() {
: "bg-indigo-600 text-white hover:bg-indigo-700") : "bg-indigo-600 text-white hover:bg-indigo-700")
} }
> >
{isAccepting ? "accepting…" : expired ? "expired" : "Accept & Pay"} {isAccepting ? "accepting…" : expired ? "expired" : "Accept & Settle"}
</button> </button>
)} )}
</td> </td>

View File

@@ -304,7 +304,7 @@ function LifecycleCard({ lc }: { lc: Lifecycle }): JSX.Element {
</> </>
)} )}
<span className="text-gray-500">·</span> <span className="text-gray-500">·</span>
<span>{fmtAda(lc.payment_lovelace)} payment</span> <span>{fmtAda(lc.payment_lovelace)} escrowed</span>
<span className="text-gray-500">·</span> <span className="text-gray-500">·</span>
<span>{fmtAda(lc.buyer_bond_lovelace)} bond</span> <span>{fmtAda(lc.buyer_bond_lovelace)} bond</span>
<span className="text-gray-500">·</span> <span className="text-gray-500">·</span>

View File

@@ -59,8 +59,18 @@ services:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=traefik-net" - "traefik.docker.network=traefik-net"
# This host is NOT Cloudflare-proxied: traefik terminates TLS itself with
# a Let's Encrypt cert (resolver defined in /root/traefik/traefik.yml on
# the box). The :80 router only redirects; ACME HTTP-01 challenges are
# answered by traefik before the redirect applies.
- "traefik.http.routers.mp-gateway-mainnet.entrypoints=web" - "traefik.http.routers.mp-gateway-mainnet.entrypoints=web"
- "traefik.http.routers.mp-gateway-mainnet.rule=Host(`api.marketplace.vector.apexfusion.org`)" - "traefik.http.routers.mp-gateway-mainnet.rule=Host(`api.marketplace.vector.apexfusion.org`)"
- "traefik.http.routers.mp-gateway-mainnet.middlewares=mp-gateway-mainnet-https"
- "traefik.http.middlewares.mp-gateway-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-gateway-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-gateway-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-gateway-mainnet-tls.rule=Host(`api.marketplace.vector.apexfusion.org`)"
- "traefik.http.routers.mp-gateway-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-gateway-mainnet.loadbalancer.server.port=8080" - "traefik.http.services.mp-gateway-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -0,0 +1,74 @@
# deploy/mainnet/docker-compose.supplier-ds-flash-chat-1.yml — Supplier — MAINNET
# (OpenRouter / deepseek-v4-flash — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 1)
#
# Chat-session sibling of the deepseek-flash one-off suppliers. Same OpenRouter
# backend (deepseek/deepseek-v4-flash) but CAPABILITY_KIND=chat-session so it
# mounts /v1/chat/{start,message,end}. Ticket settle mode (matches the mainnet
# gateway): the buyer's Open escrow post is the only chain op; the supplier
# never Claims/Submits; the gateway sweeper reclaims after deliver_by.
#
# Pre-flight:
# 1. DNS A-record: mp-suppliers-ds-flash-chat-1.vector.apexfusion.org -> host IP
# 2. New supplier wallet (gen-keypair), funded (advert post + fee float).
# 3. Post a fresh llm.chat.v1 advert for this wallet:
# --capability-id llm.chat.v1 --model deepseek/deepseek-v4-flash
# --max-processing-ms 1800000 --price-lovelace 200000
# 4. supplier/.env.ds-flash-chat-1 written on host (chmod 600) with at least:
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
# LIVE_CHAIN=1, OPENAI_REASONING=off, OPENAI_MAX_TOKENS=131072
name: marketplace-mainnet-supplier-ds-flash-chat-1
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-ds-flash-chat-1
restart: unless-stopped
env_file:
- path: ../../supplier/.env.ds-flash-chat-1
required: false
environment:
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
CAPABILITY_KIND: chat-session
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
OPENAI_BASE_URL: https://openrouter.ai/api
OPENAI_TIMEOUT_MS: "300000"
CHAT_IDLE_TIMEOUT_MS: "900000"
CHAT_SETTLE_MODE: ticket
MAX_CHAT_SESSIONS: "8"
PORT: "8080"
# Enable LiveOgmiosProvider so wallet-health/consolidate can broadcast.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL come from supplier/.env.ds-flash-chat-1.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-1.vector.apexfusion.org`)"
- "traefik.http.services.mp-suppliers-ds-flash-chat-1-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,74 @@
# deploy/mainnet/docker-compose.supplier-ds-flash-chat-2.yml — Supplier — MAINNET
# (OpenRouter / deepseek-v4-flash — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 2)
#
# Chat-session sibling of the deepseek-flash one-off suppliers. Same OpenRouter
# backend (deepseek/deepseek-v4-flash) but CAPABILITY_KIND=chat-session so it
# mounts /v1/chat/{start,message,end}. Ticket settle mode (matches the mainnet
# gateway): the buyer's Open escrow post is the only chain op; the supplier
# never Claims/Submits; the gateway sweeper reclaims after deliver_by.
#
# Pre-flight:
# 1. DNS A-record: mp-suppliers-ds-flash-chat-2.vector.apexfusion.org -> host IP
# 2. New supplier wallet (gen-keypair), funded (advert post + fee float).
# 3. Post a fresh llm.chat.v1 advert for this wallet:
# --capability-id llm.chat.v1 --model deepseek/deepseek-v4-flash
# --max-processing-ms 1800000 --price-lovelace 200000
# 4. supplier/.env.ds-flash-chat-2 written on host (chmod 600) with at least:
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
# LIVE_CHAIN=1, OPENAI_REASONING=off, OPENAI_MAX_TOKENS=131072
name: marketplace-mainnet-supplier-ds-flash-chat-2
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-ds-flash-chat-2
restart: unless-stopped
env_file:
- path: ../../supplier/.env.ds-flash-chat-2
required: false
environment:
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
CAPABILITY_KIND: chat-session
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
OPENAI_BASE_URL: https://openrouter.ai/api
OPENAI_TIMEOUT_MS: "300000"
CHAT_IDLE_TIMEOUT_MS: "900000"
CHAT_SETTLE_MODE: ticket
MAX_CHAT_SESSIONS: "8"
PORT: "8080"
# Enable LiveOgmiosProvider so wallet-health/consolidate can broadcast.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL come from supplier/.env.ds-flash-chat-2.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-2.vector.apexfusion.org`)"
- "traefik.http.services.mp-suppliers-ds-flash-chat-2-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,74 @@
# deploy/mainnet/docker-compose.supplier-ds-flash-chat-3.yml — Supplier — MAINNET
# (OpenRouter / deepseek-v4-flash — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 3)
#
# Chat-session sibling of the deepseek-flash one-off suppliers. Same OpenRouter
# backend (deepseek/deepseek-v4-flash) but CAPABILITY_KIND=chat-session so it
# mounts /v1/chat/{start,message,end}. Ticket settle mode (matches the mainnet
# gateway): the buyer's Open escrow post is the only chain op; the supplier
# never Claims/Submits; the gateway sweeper reclaims after deliver_by.
#
# Pre-flight:
# 1. DNS A-record: mp-suppliers-ds-flash-chat-3.vector.apexfusion.org -> host IP
# 2. New supplier wallet (gen-keypair), funded (advert post + fee float).
# 3. Post a fresh llm.chat.v1 advert for this wallet:
# --capability-id llm.chat.v1 --model deepseek/deepseek-v4-flash
# --max-processing-ms 1800000 --price-lovelace 200000
# 4. supplier/.env.ds-flash-chat-3 written on host (chmod 600) with at least:
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
# LIVE_CHAIN=1, OPENAI_REASONING=off, OPENAI_MAX_TOKENS=131072
name: marketplace-mainnet-supplier-ds-flash-chat-3
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-ds-flash-chat-3
restart: unless-stopped
env_file:
- path: ../../supplier/.env.ds-flash-chat-3
required: false
environment:
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
CAPABILITY_KIND: chat-session
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
OPENAI_BASE_URL: https://openrouter.ai/api
OPENAI_TIMEOUT_MS: "300000"
CHAT_IDLE_TIMEOUT_MS: "900000"
CHAT_SETTLE_MODE: ticket
MAX_CHAT_SESSIONS: "8"
PORT: "8080"
# Enable LiveOgmiosProvider so wallet-health/consolidate can broadcast.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL come from supplier/.env.ds-flash-chat-3.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-3.vector.apexfusion.org`)"
- "traefik.http.services.mp-suppliers-ds-flash-chat-3-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -8,13 +8,13 @@
## 1. Goal and scope ## 1. Goal and scope
**What this is.** A two-sided marketplace where buyers (agents or humans) submit prompts to suppliers running inference on their own hardware, pay with AP3X via on-chain bonded escrow on Vector L2, and receive signed-receipt responses. The primary validation goal is **technical feasibility + lifecycle** ("can it be done end-to-end"), not demand modelling or economic stress testing. **What this is.** A two-sided marketplace where buyers (agents or humans) submit prompts to suppliers running inference on their own hardware, with each job settled in AP3X through on-chain bonded escrow on Vector L2, and receive signed-receipt responses. The primary validation goal is **technical feasibility + lifecycle** ("can it be done end-to-end"), not demand modelling or economic stress testing.
**What v1 is NOT.** **What v1 is NOT.**
- Not agentic on the supplier side. Suppliers answer single prompts. Buyer agents drive multiple prompts. - Not agentic on the supplier side. Suppliers answer single prompts. Buyer agents drive multiple prompts.
- Not LLM-only in design. Capability model is open from day 1 (whisper / kokoro / image generation are planned siblings), but the first supplier is an LLM. - Not LLM-only in design. Capability model is open from day 1 (whisper / kokoro / image generation are planned siblings), but the first supplier is an LLM.
- Not multi-slot. Single-slot per supplier process. Multi-capability operators run multiple processes. - Not multi-slot. Single-slot per supplier process. Multi-capability operators run multiple processes.
- Not multi-chain. Vector testnet first, mainnet later. Masumi / Sokosumi / USDM is target #2 (adapter, not fork). - Not multi-chain. Vector only (testnet and mainnet).
- Not disputed. M1 ships happy-path escrow only. Module-1 disputes wire in M2. - Not disputed. M1 ships happy-path escrow only. Module-1 disputes wire in M2.
- Not streaming. `stream: true` is rejected by suppliers. - Not streaming. `stream: true` is rejected by suppliers.
- Not tool-calling. `tools` / `tool_choice` / `functions` are rejected by suppliers. - Not tool-calling. `tools` / `tool_choice` / `functions` are rejected by suppliers.
@@ -24,7 +24,7 @@
| # | Decision | | # | Decision |
|---|---| |---|---|
| 1 | Payment token: **AP3X** (Vector testnet, later mainnet). Masumi = USDM, target #2. | | 1 | Settlement asset: **AP3X**, bonded in escrow; jobs settle in AP3X on accepted work. |
| 2 | v1 scope: **Option C** — happy-path escrow only, no disputes. Module-1 wiring is milestone 2. | | 2 | v1 scope: **Option C** — happy-path escrow only, no disputes. Module-1 wiring is milestone 2. |
| 3 | Module-1 contract source: TBD — to be located or restored before M2. Not blocking M0–M1. | | 3 | Module-1 contract source: TBD — to be located or restored before M2. Not blocking M0–M1. |
| 4 | Evidence: **signed receipt** `{prompt_hash, response_hash, model, tokens, wallclock}` + Ed25519 supplier signature. Hash on-chain, plaintext off-chain. | | 4 | Evidence: **signed receipt** `{prompt_hash, response_hash, model, tokens, wallclock}` + Ed25519 supplier signature. Hash on-chain, plaintext off-chain. |
@@ -283,7 +283,6 @@ Malformed datum · escrow to wrong script · supplier claims escrow addressed to
| **M1** | Happy-path end-to-end, Hetzner CPU supplier | 2–3 wks | `qwen2.5:0.5b` on Hetzner, buyer posts → supplier claims → submits → buyer accepts; full Vector testnet lifecycle; indexer serves discovery | | **M1** | Happy-path end-to-end, Hetzner CPU supplier | 2–3 wks | `qwen2.5:0.5b` on Hetzner, buyer posts → supplier claims → submits → buyer accepts; full Vector testnet lifecycle; indexer serves discovery |
| **M2** | Module-1 dispute wiring | 2–3 wks post-M1 | Contract source located; supplier=claimer / buyer=auditor mapping; Dispute state; evidence URI; mainnet-dry-run lifecycle | | **M2** | Module-1 dispute wiring | 2–3 wks post-M1 | Contract source located; supplier=claimer / buyer=auditor mapping; Dispute state; evidence URI; mainnet-dry-run lifecycle |
| **M3** | Capability expansion + local lab | 2–4 wks (may overlap M2) | `speech.transcribe.v1` via Whisper live; local-lab GPU supplier with larger model; heterogeneous discovery validated | | **M3** | Capability expansion + local lab | 2–4 wks (may overlap M2) | `speech.transcribe.v1` via Whisper live; local-lab GPU supplier with larger model; heterogeneous discovery validated |
| **M4** | Masumi / Sokosumi adapter | later | MIP-003 HTTP shim; MIP-002 NFT registration; USDM payment path; Sokosumi listing |
## 9. Open follow-ups (not blocking M0 / M1) ## 9. Open follow-ups (not blocking M0 / M1)
@@ -310,5 +309,3 @@ Malformed datum · escrow to wrong script · supplier claims escrow addressed to
- Apex-dashboard CBOR decoders: `apex-dashboard/server/cbor-decoder.ts` - Apex-dashboard CBOR decoders: `apex-dashboard/server/cbor-decoder.ts`
- Apex-dashboard test discipline: `apex-dashboard/tests/` - Apex-dashboard test discipline: `apex-dashboard/tests/`
- Prior Python reference (AP3X buyer/supplier): `./docs/buyer/`, `./docs/supplier/` - Prior Python reference (AP3X buyer/supplier): `./docs/buyer/`, `./docs/supplier/`
- Masumi docs: https://docs.masumi.network/
- MIP-003 spec: https://docs.masumi.network/mips/_mip-003

View File

@@ -1,412 +0,0 @@
---
marp: true
paginate: true
size: 16:9
---
<!--
EXECUTIVE_SUMMARY.md — slide-style overview of the Local Agents Marketplace.
Build PDF + PPTX with: bash docs/build-deck.sh
Edit this file; mermaid blocks are pre-rendered to docs/img/diagram-*.png.
-->
<style>
/* ── Local Agents Marketplace — unified dark theme ────────────────────
* Every slide uses the same navy→indigo gradient as the title slide so
* the deck reads as one continuous document. Title and closing slides
* keep the centered hero treatment; content slides stay top-aligned but
* share the same palette and accent rules. */
:root {
--ink: #f1f5f9; /* primary text on dark gradient */
--muted: #94a3b8;
--accent: #818cf8; /* indigo-400, readable on the dark gradient */
--accent-deep: #4f46e5; /* used for solid accents (h2 bar, table head) */
--accent-soft: rgba(129,140,248,0.12);
--gradient: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #312e81 100%);
--row-even: rgba(255,255,255,0.04);
--row-odd: rgba(255,255,255,0.02);
--border: rgba(255,255,255,0.10);
}
section {
background: var(--gradient);
color: var(--ink);
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
font-size: 22px;
padding: 40px 60px;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
section::after {
content: attr(data-marpit-pagination) ' / ' attr(data-marpit-pagination-total);
color: var(--muted);
font-size: 13px;
}
h1 {
font-size: 48px;
font-weight: 700;
color: #ffffff;
border-bottom: 4px solid var(--accent);
padding-bottom: 10px;
margin: 0 0 18px 0;
}
h2 {
font-size: 34px;
font-weight: 700;
color: #ffffff;
margin: 0 0 14px 0;
}
h2::before {
content: '';
display: inline-block;
width: 6px;
height: 28px;
background: var(--accent);
margin-right: 14px;
vertical-align: middle;
border-radius: 2px;
}
h3 { color: #c7d2fe; font-weight: 600; font-size: 24px; margin: 8px 0; }
p, li { margin: 6px 0; line-height: 1.45; }
ul, ol { margin: 8px 0 12px 0; padding-left: 28px; }
strong { color: #ffffff; }
a { color: #a5b4fc; }
blockquote {
border-left: 4px solid var(--accent);
background: rgba(255,255,255,0.06);
padding: 10px 18px;
color: #e0e7ff;
border-radius: 0 8px 8px 0;
margin: 10px 0;
}
code {
background: rgba(255,255,255,0.10);
color: #e0e7ff;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
/* Constrain mermaid images so they always fit on a 720px-tall slide
* (16:9 @ 1280×720 default). Soft white card behind the diagram so the
* mermaid SVG (which renders on a transparent canvas with dark ink)
* stays readable on the navy gradient. */
img {
display: block;
margin: 8px auto;
max-width: 92%;
max-height: 420px;
object-fit: contain;
background: #ffffff;
border-radius: 10px;
padding: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
/* Tile grid for the local-agent versatility slide. */
.backends {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin: 14px 0 12px 0;
}
.tile {
background: var(--accent-soft);
border: 2px solid var(--accent);
border-radius: 10px;
padding: 14px 16px;
text-align: center;
font-weight: 600;
color: #ffffff;
}
.tile small {
display: block;
margin-top: 4px;
font-weight: 400;
color: var(--muted);
font-size: 0.8em;
}
.tile.dots {
background: rgba(255,255,255,0.04);
border-style: dashed;
color: var(--muted);
font-size: 28px;
}
.pipeline {
text-align: center;
background: rgba(255,255,255,0.08);
border: 1px solid var(--border);
color: #f8fafc;
padding: 12px 18px;
border-radius: 10px;
margin: 6px auto 14px auto;
max-width: 760px;
font-weight: 600;
}
.pipeline span {
display: inline-block;
margin: 0 10px;
}
/* Tables sit on a solid white card so cell text reads cleanly against
* the dark gradient backdrop. Rounded outer corners + shadow match the
* mermaid card styling. `display:table` + `table-layout:fixed` are
* needed so the table actually consumes the full slide width — Marp's
* default theme leaves tables at content-natural width, which left
* blank gutters on either side. */
table {
display: table !important;
width: 100% !important;
table-layout: fixed;
border-collapse: collapse;
margin: 12px 0;
font-size: 0.82em;
color: #0f172a;
background: #ffffff;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
td, th { word-wrap: break-word; overflow-wrap: anywhere; }
th {
background: var(--accent-deep);
color: #ffffff;
text-align: left;
padding: 9px 12px;
font-weight: 700;
}
td {
padding: 7px 12px;
border-bottom: 1px solid #e2e8f0;
color: #0f172a;
}
tr:nth-child(even) td { background: #ffffff; }
tr:nth-child(odd) td { background: #f8fafc; }
/* code spans inside table cells need a light pill, not the dark one
* defined globally — otherwise they'd disappear into the white row. */
td code {
background: #eef2ff;
color: #1e293b;
}
/* Bold/strong inside cells must stay dark — the global rule paints
* <strong> white for the dark-gradient body text, which would render
* invisible against the white table card. */
td strong, th strong {
color: #0f172a;
}
th strong {
color: #ffffff;
}
/* Title slide — centered hero treatment on the same gradient. */
section.title {
text-align: center;
padding: 100px 80px;
justify-content: center;
}
section.title h1 {
display: inline-block;
font-size: 64px;
margin: 0 auto 20px auto;
}
section.title blockquote {
margin: 28px auto;
max-width: 900px;
}
/* Closing slide — same centered treatment. */
section.closing {
text-align: center;
padding: 100px 80px;
justify-content: center;
}
section.closing h2::before { display: none; }
section.closing blockquote { font-size: 26px; max-width: 900px; }
</style>
<!-- _class: title -->
# Local Agents Marketplace
### A trust-minimised, on-chain marketplace for LLM inference
Buy and sell prompt → response with **bonded escrow** on **Vector** (UTxO).
No platform fee. No central rate-limiter. Verifiable receipts on chain.
> *Status: M1 lifecycle proven end-to-end on Vector testnet — 2026-05-05.*
---
## The problem
* AI inference is **centralised** — a handful of providers, opaque pricing, account-level rate limits.
* Local-AI hardware is **idle most of the time** — GPUs and CPUs sit unused between user prompts.
* Buyers and sellers can't transact directly without a **trusted intermediary** taking a cut.
* "Verifiable" claims about which model ran are usually nothing more than the provider's word.
We need a **two-sided marketplace** where:
1. Sellers monetise spare compute,
2. Buyers get a **cryptographic receipt** of what was actually computed,
3. **Neither side has to trust the other** — bonds + on-chain settlement enforce honesty.
---
## How it works — three actors, one chain
```mermaid
flowchart LR
subgraph CHAIN[Vector — UTxO]
ADV[Advert UTxO<br/>capability, price, bonds]
ESC[Escrow UTxO<br/>locked funds + state]
end
SUP[🧠 Supplier<br/>local LLM node]
BUY[💼 Buyer<br/>app or agent]
IDX[🔭 Indexer<br/>read-only bulletin board]
SUP -->|publish advert| ADV
BUY -->|discover| IDX
IDX -.->|index events| CHAIN
BUY -->|post escrow| ESC
BUY -->|prompt + escrow_ref| SUP
SUP -->|signed receipt| BUY
SUP -->|claim & submit| ESC
BUY -->|accept & pay| ESC
```
* **Supplier** runs the inference and publishes a price.
* **Buyer** posts the work as a bonded escrow, sends the prompt, accepts the receipt.
* **Indexer** is a public mirror of chain state — anyone can run one. No platform middleman.
---
## The bonded-escrow lifecycle
```mermaid
sequenceDiagram
autonumber
participant B as Buyer
participant Chain as Vector
participant S as Supplier
participant LLM as Local LLM<br/>(OpenAI-compatible)
S->>Chain: PostAdvert (locks supplier_bond)
B->>Chain: PostEscrow → Open<br/>(locks payment + bonds)
B->>S: POST /v1/chat/completions
S->>Chain: ClaimEscrow → Claimed
S->>LLM: run inference
LLM-->>S: assistant message
S->>Chain: SubmitEscrow → Submitted
S-->>B: 200 OK + signed receipt
B->>Chain: AcceptEscrow — supplier paid
```
Every transition is on-chain, validated by an Aiken Plutus V3 script. The receipt is signed by the supplier's advert key; prompt and response hashes are bound on chain.
---
## What if someone misbehaves?
```mermaid
flowchart LR
OPEN[Open]:::norm --> CLAIMED[Claimed]:::norm --> SUBMITTED[Submitted]:::norm --> ACCEPTED[Accepted ✓]:::good
OPEN -->|deliver_by passed| RECLAIM[Reclaimed<br/>buyer recovers funds]:::warn
CLAIMED -->|deliver_by passed| RECLAIM
SUBMITTED -->|accept_window passed| RELEASE[Released<br/>supplier collects]:::warn
classDef norm fill:#e0e7ff,stroke:#4f46e5
classDef good fill:#dcfce7,stroke:#16a34a
classDef warn fill:#fef3c7,stroke:#d97706
```
| Failure mode | Recovery | Loss bearer |
|---|---|---|
| Supplier never claims/submits | Buyer **Reclaim** after `deliver_by` | Supplier — forfeits supplier_bond |
| Buyer disappears after Submit | Supplier **Release** after `submitted_at + 10 min` | Buyer — forfeits buyer_bond |
Bonds make honesty cheaper than running away. Both sides have skin in the game; no third-party arbiter is needed for the M1 happy/timeout paths.
---
## Local-agent versatility — any OpenAI-compatible engine
<div class="pipeline">
<span>💼 Buyer</span> →
<span>🧠 Supplier (OpenAI-compatible HTTP wrapper)</span> →
<span>backend ↓</span>
</div>
<div class="backends">
<div class="tile">Ollama<br/><small>local model runner</small></div>
<div class="tile">vLLM<br/><small>high-throughput GPU</small></div>
<div class="tile">llama.cpp<br/><small>CPU · Metal · ROCm</small></div>
<div class="tile">OpenRouter<br/><small>cloud API mux</small></div>
<div class="tile">openclaw / custom<br/><small>arbitrary stack</small></div>
<div class="tile dots">…<br/><small>any OpenAI-shaped API</small></div>
</div>
The supplier service is a thin HTTP wrapper — it forwards `/v1/chat/completions` to whatever backend is configured. Each supplier picks its own model, hardware, and engine, then advertises a `capability_id`. Buyers shop on capability + model + price.
---
## Capability + model = task selection
| Capability ID | Example models | Use case |
|---|---|---|
| `llm.text.generate.v1` | Llama 3 · Qwen 2.5 · Mistral · GPT-4 | Q&A, drafts, summarisation |
| `code.completion.v1` | Codestral · DeepSeek-Coder · StarCoder | IDE tab-complete, refactor |
| `vision.describe.v1` | LLaVA · Qwen-VL · Pixtral | Image captioning, OCR |
| `embedding.text.v1` | nomic-embed · BGE · ada-002 | RAG indexing |
| `audio.transcribe.v1` | whisper-large-v3 · distil-whisper | Voice-to-text |
| `…` | … | … |
* New capabilities = **just publish a new advert**. No marketplace governance gate.
* Today's deploy uses Ollama + Qwen 2.5; switching to vLLM with Llama 3 70B is a config change, not a code change.
---
## OpenClaw — agentic teams on your existing OpenAI seat
<div class="pipeline">
<span>🧠 Supplier wrapper</span> →
<span>🦾 OpenClaw</span> →
<span>🪪 OpenAI ChatGPT seat (flat-rate)</span>
</div>
* **Cost-efficient by default.** OpenClaw exposes your existing **OpenAI ChatGPT subscription** as an OpenAI-compatible API. The marketplace supplier dispatches its inference against it, so an entire **agentic team** (coders, QA automation testers, release curators…) draws from the **flat-rate seat you already pay for** — no per-token metering, no surprise invoices.
* **Reliability layer (optional, marginal cost).** OpenClaw can fall back to the **Anthropic API** or **OpenRouter** model pool when the primary seat is rate-limited, the prompt requires a different model, or you want a **second-layer hardening** of responses (e.g. cross-provider consensus on critical outputs). You only pay metered tokens for the requests that actually fall through.
---
## Proven on Vector testnet — 2026-05-05
| Step | Tx hash |
|---|---|
| Buyer posts escrow | `af762561…c2f7bc5` |
| Supplier claims | (lifecycle tx) |
| Supplier submits receipt | (lifecycle tx) |
| Buyer accepts | `125e3cfe…7dc100d` |
Funds flowed correctly: supplier received `payment + supplier_bond` (3 ADA); buyer got `buyer_bond` back.
* **Browser demo:** https://mp-buyers.vector.testnet.apexfusion.org
* **Live event stream:** https://mp-indexer.vector.testnet.apexfusion.org
* **Test suite:** 1098 unit tests passing, full lifecycle exercised in CI.
---
## Why UTxO + Vector
* **Bonded escrow is native** — Plutus validators enforce the state machine without a custom token or oracle.
* **Vector settles in seconds** — the buyer→claim→submit→accept loop fits inside a single chat-completion's wall clock.
* **AP3X token** for fees and bonds — fungible, transparent, on-chain.
* **No custom rollup, no bridge** — runs on a production chain today.
---
<!-- _class: closing -->
## The pitch in one sentence
> Local Agents Marketplace turns any OpenAI-compatible inference endpoint into a **trustlessly billable service** — buyer and supplier exchange prompt, response, and payment with on-chain proof that what was paid for is what was delivered.
### Try it
* **Indexer (live event stream):** https://mp-indexer.vector.testnet.apexfusion.org
* **Buyer:** https://mp-buyers.vector.testnet.apexfusion.org
* **Supplier:** https://mp-suppliers.vector.testnet.apexfusion.org/capability

Binary file not shown.

Binary file not shown.

View File

@@ -1,11 +1,11 @@
# Vector Buyer App # Vector Buyer App
A self-contained app for purchasing AI compute on the Vector L2 testnet (Cardano). You submit prompts, pay with AP3X tokens via on-chain escrow, and receive LLM responses from supplier nodes. A self-contained app for commissioning AI compute on the Vector L2 testnet. You submit prompts, bond AP3X in on-chain escrow, and receive LLM responses from supplier nodes; the escrow settles in AP3X on accepted work.
## How It Works ## How It Works
1. You type a **prompt** and set a **payment** amount in the web UI 1. You type a **prompt** and set a **price** in the web UI
2. The app posts an **escrow transaction** on-chain, locking your payment + bond 2. The app posts an **escrow transaction** on-chain, locking price + bond
3. The app sends the task to a **supplier node** via HTTP 3. The app sends the task to a **supplier node** via HTTP
4. The supplier calls an LLM, **claims** the escrow, and **submits** the result hash on-chain 4. The supplier calls an LLM, **claims** the escrow, and **submits** the result hash on-chain
5. You see the response in the UI and can **accept** to release payment, or **dispute** 5. You see the response in the UI and can **accept** to release payment, or **dispute**
@@ -193,8 +193,8 @@ If you accept the result, you get your 1 AP3X bond back. Total cost = payment +
- **Never share your .env file** - it contains your wallet seed phrase - **Never share your .env file** - it contains your wallet seed phrase
- **Never share your data/ folder** - it contains task history - **Never share your data/ folder** - it contains task history
- The seed phrase controls your wallet funds. Back it up securely. - The seed phrase controls your wallet. Back it up securely.
- Escrow protects you: the supplier cannot take your funds without delivering a result - Escrow protects you: the supplier cannot take your escrowed value without delivering a result
- You have a 10-minute dispute window after receiving a result - You have a 10-minute dispute window after receiving a result
## Troubleshooting ## Troubleshooting

View File

@@ -24,8 +24,8 @@ The audit confirmed 39 holes in the first draft (10 distinct themes). This docum
| One-shot | `/openai/v1/chat/completions` → `llm.text.generate.v1`, 1 escrow/call, buffered (+ pseudo-stream) | | One-shot | `/openai/v1/chat/completions` → `llm.text.generate.v1`, 1 escrow/call, buffered (+ pseudo-stream) |
| Chat session | `/openai/v1/chat/sessions*` → `llm.chat.v1`, 1 escrow/**session**, real SSE per turn, settle at close | | Chat session | `/openai/v1/chat/sessions*` → `llm.chat.v1`, 1 escrow/**session**, real SSE per turn, settle at close |
| Routing | match `model` + required capability; pick an available supplier (status `free`\|`unknown`); **not cheapest**; fallback to next match on busy/offline | | Routing | match `model` + required capability; pick an available supplier (status `free`\|`unknown`); **not cheapest**; fallback to next match on busy/offline |
| Pricing | cost = matched supplier `price_lovelace`; **required balance = price + buyer_bond + supplier_bond + 5 ADA collateral + fee reserve** | | Pricing | cost = matched supplier `price_lovelace`; **required balance = price + buyer_bond + supplier_bond + 5 AP3X collateral + fee reserve** |
| Wallet health | gateway **auto-consolidates** each wallet to `{5-ADA collateral, working}` after settle / on a tick | | Wallet health | gateway **auto-consolidates** each wallet to `{5-AP3X collateral, working}` after settle / on a tick |
| Settlement | resolve the **Submitted** ref via indexer, `acceptResult`, **await confirm** before returning success; sweeper is backstop | | Settlement | resolve the **Submitted** ref via indexer, `acceptResult`, **await confirm** before returning success; sweeper is backstop |
| Sweeper | state-aware: Open/Claimed past `deliver_by` → reclaim; Submitted → retry Accept in-window; never blind-reclaim Submitted | | Sweeper | state-aware: Open/Claimed past `deliver_by` → reclaim; Submitted → retry Accept in-window; never blind-reclaim Submitted |
| Response | OpenAI object `{id, object, created, model, choices, usage}` + `x_vector:{receipt, receipt_signature, escrow_ref}`; full streaming chunk transform + final usage chunk | | Response | OpenAI object `{id, object, created, model, choices, usage}` + `x_vector:{receipt, receipt_signature, escrow_ref}`; full streaming chunk transform + final usage chunk |
@@ -41,7 +41,7 @@ New standalone pnpm package `gateway/` (add to `pnpm-workspace.yaml`). Boots **o
- SDK methods: `discoverSuppliers`, `submitPrompt` (`Marketplace.ts:242`, verifies receipt but **does not settle**), `startChat`/`endChat` (chat session; `endChat` returns `acceptedRef` and awaits Accept), `acceptResult`, `reclaim`. - SDK methods: `discoverSuppliers`, `submitPrompt` (`Marketplace.ts:242`, verifies receipt but **does not settle**), `startChat`/`endChat` (chat session; `endChat` returns `acceptedRef` and awaits Accept), `acceptResult`, `reclaim`.
- Escrow value/bonds: buyer funds `totalLocked = price + buyer_bond + supplier_bond` at PostEscrow — `packages/shared/src/tx/escrow/postEscrow.ts:132-200`. On Accept supplier gets `payment+supplier_bond`, buyer gets `buyer_bond` back. - Escrow value/bonds: buyer funds `totalLocked = price + buyer_bond + supplier_bond` at PostEscrow — `packages/shared/src/tx/escrow/postEscrow.ts:132-200`. On Accept supplier gets `payment+supplier_bond`, buyer gets `buyer_bond` back.
- Submitted-ref resolution: the buyer `/v1/accept` queries the indexer and matches the Submitted row by `posted_at` — `buyer/src/server.ts` `/v1/accept`. Gateway must mirror this. - Submitted-ref resolution: the buyer `/v1/accept` queries the indexer and matches the Submitted row by `posted_at` — `buyer/src/server.ts` `/v1/accept`. Gateway must mirror this.
- Wallet health: `planConsolidate` / `consolidateWallet` (`{collateral, working}` shape, `DEFAULT_COLLATERAL_LOVELACE = 5 ADA`) — `packages/shared/src/tx/wallet/`. Collateral assertion: `assertCollateralCandidate` — `packages/shared/src/tx/internal/liveCbor.ts`. - Wallet health: `planConsolidate` / `consolidateWallet` (`{collateral, working}` shape, `DEFAULT_COLLATERAL_LOVELACE = 5 AP3X`) — `packages/shared/src/tx/wallet/`. Collateral assertion: `assertCollateralCandidate` — `packages/shared/src/tx/internal/liveCbor.ts`.
- Supplier chat-session SSE `{type:"token"|"done"|"error"}` + headers — `supplier/src/server.ts` `makeChatSessionHandlers`; buyer relay in `buyer/src/server.ts` `/v1/chat/message` (note: **verbatim** passthrough — the gateway must transform to OpenAI chunks). - Supplier chat-session SSE `{type:"token"|"done"|"error"}` + headers — `supplier/src/server.ts` `makeChatSessionHandlers`; buyer relay in `buyer/src/server.ts` `/v1/chat/message` (note: **verbatim** passthrough — the gateway must transform to OpenAI chunks).
- SQLite pattern (`better-sqlite3`, WAL) — `buyer/src/db/archive.ts`. Auth helpers `timingSafeCompareStrings`, `createRateLimiter` — `buyer/src/auth.ts`. - SQLite pattern (`better-sqlite3`, WAL) — `buyer/src/db/archive.ts`. Auth helpers `timingSafeCompareStrings`, `createRateLimiter` — `buyer/src/auth.ts`.
- Indexer `SupplierView` (`model`, `capability_id`, `price_lovelace`, `buyer_bond_lovelace`, `supplier_bond_lovelace`, `status` free/working/offline/unknown, `advert_status`) — `indexer/src/routes/suppliers.ts`. - Indexer `SupplierView` (`model`, `capability_id`, `price_lovelace`, `buyer_bond_lovelace`, `supplier_bond_lovelace`, `status` free/working/offline/unknown, `advert_status`) — `indexer/src/routes/suppliers.ts`.
@@ -100,7 +100,7 @@ Raw API key never persisted (only `sha256(key)`); `key_prefix` for display. `mas
## Request lifecycle — one-shot `/chat/completions` ## Request lifecycle — one-shot `/chat/completions`
All on-chain steps run inside the per-key `mutex.run(...)`. All on-chain steps run inside the per-key `mutex.run(...)`.
1. **Route** (`selectSupplier`): indexer rows where `model` matches, `capability_id==="llm.text.generate.v1"`, `advert_status==="Active"`, live `status ∈ {free, unknown}`. Pick an available one (not cheapest). No match → 404 `model_not_found` (`type: invalid_request_error`). 1. **Route** (`selectSupplier`): indexer rows where `model` matches, `capability_id==="llm.text.generate.v1"`, `advert_status==="Active"`, live `status ∈ {free, unknown}`. Pick an available one (not cheapest). No match → 404 `model_not_found` (`type: invalid_request_error`).
2. **Pre-flight**: required = `price + buyer_bond + supplier_bond + 5_000_000 (collateral) + ~2 AP3X fee`. Verify total balance **and** a pure-ADA UTxO ≥5 ADA exists (`assertCollateralCandidate`-style). Else 402 `insufficient_funds` + `x_vector:{required_lovelace, available_lovelace, collateral_ok, deposit_address}`. 2. **Pre-flight**: required = `price + buyer_bond + supplier_bond + 5_000_000 (collateral) + ~2 AP3X fee`. Verify total balance **and** a pure-AP3X UTxO ≥5 AP3X exists (`assertCollateralCandidate`-style). Else 402 `insufficient_funds` + `x_vector:{required_lovelace, available_lovelace, collateral_ok, deposit_address}`.
3. **Execute**: `submitPrompt({advertRef, messages, payment_lovelace:price, max_output_tokens})` (posts escrow → supplier → verifies receipt). 3. **Execute**: `submitPrompt({advertRef, messages, payment_lovelace:price, max_output_tokens})` (posts escrow → supplier → verifies receipt).
4. **Settle**: resolve the **Submitted** escrow ref via the indexer (mirror buyer `/v1/accept`: match by buyer pkh + `posted_at`), `acceptResult(submittedRef)`, **await confirmation**. On 409/offline mid-route, retry next matching supplier (bounded). On any post-escrow failure, record + leave for the sweeper (don't block the response on reclaim). 4. **Settle**: resolve the **Submitted** escrow ref via the indexer (mirror buyer `/v1/accept`: match by buyer pkh + `posted_at`), `acceptResult(submittedRef)`, **await confirmation**. On 409/offline mid-route, retry next matching supplier (bounded). On any post-escrow failure, record + leave for the sweeper (don't block the response on reclaim).
5. **Respond**: `{id:"chatcmpl-…", object:"chat.completion", created:<sec>, model, choices:[{index:0,message:{role:"assistant",content},finish_reason:"stop"}], usage:{prompt_tokens,completion_tokens,total_tokens}, x_vector:{receipt,receipt_signature,escrow_ref}}`. Record `usage`. 5. **Respond**: `{id:"chatcmpl-…", object:"chat.completion", created:<sec>, model, choices:[{index:0,message:{role:"assistant",content},finish_reason:"stop"}], usage:{prompt_tokens,completion_tokens,total_tokens}, x_vector:{receipt,receipt_signature,escrow_ref}}`. Record `usage`.
@@ -121,7 +121,7 @@ All on-chain steps run inside the per-key `mutex.run(...)`.
## Sweeper & wallet-health ## Sweeper & wallet-health
- **Sweeper** (interval ~60s): per key, classify escrows. Open/Claimed past `deliver_by` → `reclaim`. Submitted **with a verified receipt** still in accept-window → retry `acceptResult`. Submitted past accept-window → leave for supplier Release (log). Never blind-`reclaim` Submitted. - **Sweeper** (interval ~60s): per key, classify escrows. Open/Claimed past `deliver_by` → `reclaim`. Submitted **with a verified receipt** still in accept-window → retry `acceptResult`. Submitted past accept-window → leave for supplier Release (log). Never blind-`reclaim` Submitted.
- **Wallet-health tick**: periodically + after each settle, `planConsolidate` → `consolidateWallet` so every wallet keeps a ≥5 ADA collateral UTxO. Skip if already healthy. - **Wallet-health tick**: periodically + after each settle, `planConsolidate` → `consolidateWallet` so every wallet keeps a ≥5 AP3X collateral UTxO. Skip if already healthy.
## Errors (OpenAI shape `{error:{message, type, code, param:null}}`) ## Errors (OpenAI shape `{error:{message, type, code, param:null}}`)
no supplier → 404 `invalid_request_error`/`model_not_found`; underfunded/no-collateral → 402 `invalid_request_error`/`insufficient_funds`; bad/missing key → 401 `authentication_error`/`invalid_api_key`; rate limit → 429 `rate_limit_error` + `Retry-After`; supplier timeout → 504 `server_error`; supplier busy race (after retries) → 503 `server_error`/`overloaded`; receipt verify → 502 `server_error`/`receipt_verification_failed`; tx build → 409/502 `server_error`/`escrow_failed`; unsupported `tools`/`functions` → 400 `invalid_request_error`/`unsupported_parameter`. no supplier → 404 `invalid_request_error`/`model_not_found`; underfunded/no-collateral → 402 `invalid_request_error`/`insufficient_funds`; bad/missing key → 401 `authentication_error`/`invalid_api_key`; rate limit → 429 `rate_limit_error` + `Retry-After`; supplier timeout → 504 `server_error`; supplier busy race (after retries) → 503 `server_error`/`overloaded`; receipt verify → 502 `server_error`/`receipt_verification_failed`; tx build → 409/502 `server_error`/`escrow_failed`; unsupported `tools`/`functions` → 400 `invalid_request_error`/`unsupported_parameter`.
@@ -145,7 +145,7 @@ Real billing/markup & fee hook; trustless/self-custody recovery; multi-funding-U
## Verification (testnet, end-to-end) ## Verification (testnet, end-to-end)
1. Indexer + Ogmios + ≥1 `llm.text.generate.v1` and ≥1 `llm.chat.v1` supplier for the same `model`, Active. 1. Indexer + Ogmios + ≥1 `llm.text.generate.v1` and ≥1 `llm.chat.v1` supplier for the same `model`, Active.
2. Boot gateway: `LIVE_CHAIN=1`, `GATEWAY_MASTER_KEY=$(openssl rand -hex 32)`, `OGMIOS_URL`, `INDEXER_URL`, `NETWORK_ID`, `GATEWAY_DB_DIR`, `GATEWAY_PORT`. 2. Boot gateway: `LIVE_CHAIN=1`, `GATEWAY_MASTER_KEY=$(openssl rand -hex 32)`, `OGMIOS_URL`, `INDEXER_URL`, `NETWORK_ID`, `GATEWAY_DB_DIR`, `GATEWAY_PORT`.
3. `POST /signup` → key + deposit address. Fund ≥ price + both bonds + 5 ADA collateral + ~2 ADA fees; confirm via `GET /account` (`collateral_ok:true`). 3. `POST /signup` → key + deposit address. Fund ≥ price + both bonds + 5 AP3X collateral + ~2 AP3X fees; confirm via `GET /account` (`collateral_ok:true`).
4. One-shot non-stream via curl and via the OpenAI Python/JS SDK (`base_url=…/openai/v1`) → valid `chat.completion` + `x_vector`; confirm the escrow **settled (Accepted)** and a `usage` row exists. 4. One-shot non-stream via curl and via the OpenAI Python/JS SDK (`base_url=…/openai/v1`) → valid `chat.completion` + `x_vector`; confirm the escrow **settled (Accepted)** and a `usage` row exists.
5. **Run a 2nd and 3rd request** → confirm wallet-health kept `collateral_ok` and they succeed (the audit's request-#2 failure must not reproduce). 5. **Run a 2nd and 3rd request** → confirm wallet-health kept `collateral_ok` and they succeed (the audit's request-#2 failure must not reproduce).
6. One-shot `stream:true` → chunks + final usage + `[DONE]`. 6. One-shot `stream:true` → chunks + final usage + `[DONE]`.

View File

@@ -1,412 +0,0 @@
---
marp: true
paginate: true
size: 16:9
---
<!--
EXECUTIVE_SUMMARY.md — slide-style overview of the Local Agents Marketplace.
Build PDF + PPTX with: bash docs/build-deck.sh
Edit this file; mermaid blocks are pre-rendered to docs/img/diagram-*.png.
-->
<style>
/* ── Local Agents Marketplace — unified dark theme ────────────────────
* Every slide uses the same navy→indigo gradient as the title slide so
* the deck reads as one continuous document. Title and closing slides
* keep the centered hero treatment; content slides stay top-aligned but
* share the same palette and accent rules. */
:root {
--ink: #f1f5f9; /* primary text on dark gradient */
--muted: #94a3b8;
--accent: #818cf8; /* indigo-400, readable on the dark gradient */
--accent-deep: #4f46e5; /* used for solid accents (h2 bar, table head) */
--accent-soft: rgba(129,140,248,0.12);
--gradient: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #312e81 100%);
--row-even: rgba(255,255,255,0.04);
--row-odd: rgba(255,255,255,0.02);
--border: rgba(255,255,255,0.10);
}
section {
background: var(--gradient);
color: var(--ink);
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
font-size: 22px;
padding: 40px 60px;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
section::after {
content: attr(data-marpit-pagination) ' / ' attr(data-marpit-pagination-total);
color: var(--muted);
font-size: 13px;
}
h1 {
font-size: 48px;
font-weight: 700;
color: #ffffff;
border-bottom: 4px solid var(--accent);
padding-bottom: 10px;
margin: 0 0 18px 0;
}
h2 {
font-size: 34px;
font-weight: 700;
color: #ffffff;
margin: 0 0 14px 0;
}
h2::before {
content: '';
display: inline-block;
width: 6px;
height: 28px;
background: var(--accent);
margin-right: 14px;
vertical-align: middle;
border-radius: 2px;
}
h3 { color: #c7d2fe; font-weight: 600; font-size: 24px; margin: 8px 0; }
p, li { margin: 6px 0; line-height: 1.45; }
ul, ol { margin: 8px 0 12px 0; padding-left: 28px; }
strong { color: #ffffff; }
a { color: #a5b4fc; }
blockquote {
border-left: 4px solid var(--accent);
background: rgba(255,255,255,0.06);
padding: 10px 18px;
color: #e0e7ff;
border-radius: 0 8px 8px 0;
margin: 10px 0;
}
code {
background: rgba(255,255,255,0.10);
color: #e0e7ff;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
/* Constrain mermaid images so they always fit on a 720px-tall slide
* (16:9 @ 1280×720 default). Soft white card behind the diagram so the
* mermaid SVG (which renders on a transparent canvas with dark ink)
* stays readable on the navy gradient. */
img {
display: block;
margin: 8px auto;
max-width: 92%;
max-height: 420px;
object-fit: contain;
background: #ffffff;
border-radius: 10px;
padding: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
/* Tile grid for the local-agent versatility slide. */
.backends {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin: 14px 0 12px 0;
}
.tile {
background: var(--accent-soft);
border: 2px solid var(--accent);
border-radius: 10px;
padding: 14px 16px;
text-align: center;
font-weight: 600;
color: #ffffff;
}
.tile small {
display: block;
margin-top: 4px;
font-weight: 400;
color: var(--muted);
font-size: 0.8em;
}
.tile.dots {
background: rgba(255,255,255,0.04);
border-style: dashed;
color: var(--muted);
font-size: 28px;
}
.pipeline {
text-align: center;
background: rgba(255,255,255,0.08);
border: 1px solid var(--border);
color: #f8fafc;
padding: 12px 18px;
border-radius: 10px;
margin: 6px auto 14px auto;
max-width: 760px;
font-weight: 600;
}
.pipeline span {
display: inline-block;
margin: 0 10px;
}
/* Tables sit on a solid white card so cell text reads cleanly against
* the dark gradient backdrop. Rounded outer corners + shadow match the
* mermaid card styling. `display:table` + `table-layout:fixed` are
* needed so the table actually consumes the full slide width — Marp's
* default theme leaves tables at content-natural width, which left
* blank gutters on either side. */
table {
display: table !important;
width: 100% !important;
table-layout: fixed;
border-collapse: collapse;
margin: 12px 0;
font-size: 0.82em;
color: #0f172a;
background: #ffffff;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
td, th { word-wrap: break-word; overflow-wrap: anywhere; }
th {
background: var(--accent-deep);
color: #ffffff;
text-align: left;
padding: 9px 12px;
font-weight: 700;
}
td {
padding: 7px 12px;
border-bottom: 1px solid #e2e8f0;
color: #0f172a;
}
tr:nth-child(even) td { background: #ffffff; }
tr:nth-child(odd) td { background: #f8fafc; }
/* code spans inside table cells need a light pill, not the dark one
* defined globally — otherwise they'd disappear into the white row. */
td code {
background: #eef2ff;
color: #1e293b;
}
/* Bold/strong inside cells must stay dark — the global rule paints
* <strong> white for the dark-gradient body text, which would render
* invisible against the white table card. */
td strong, th strong {
color: #0f172a;
}
th strong {
color: #ffffff;
}
/* Title slide — centered hero treatment on the same gradient. */
section.title {
text-align: center;
padding: 100px 80px;
justify-content: center;
}
section.title h1 {
display: inline-block;
font-size: 64px;
margin: 0 auto 20px auto;
}
section.title blockquote {
margin: 28px auto;
max-width: 900px;
}
/* Closing slide — same centered treatment. */
section.closing {
text-align: center;
padding: 100px 80px;
justify-content: center;
}
section.closing h2::before { display: none; }
section.closing blockquote { font-size: 26px; max-width: 900px; }
</style>
<!-- _class: title -->
# Local Agents Marketplace
### A trust-minimised, on-chain marketplace for LLM inference
Buy and sell prompt → response with **bonded escrow** on **Vector** (UTxO).
No platform fee. No central rate-limiter. Verifiable receipts on chain.
> *Status: M1 lifecycle proven end-to-end on Vector testnet — 2026-05-05.*
---
## The problem
* AI inference is **centralised** — a handful of providers, opaque pricing, account-level rate limits.
* Local-AI hardware is **idle most of the time** — GPUs and CPUs sit unused between user prompts.
* Buyers and sellers can't transact directly without a **trusted intermediary** taking a cut.
* "Verifiable" claims about which model ran are usually nothing more than the provider's word.
We need a **two-sided marketplace** where:
1. Sellers monetise spare compute,
2. Buyers get a **cryptographic receipt** of what was actually computed,
3. **Neither side has to trust the other** — bonds + on-chain settlement enforce honesty.
---
## How it works — three actors, one chain
```mermaid
flowchart LR
subgraph CHAIN[Vector — UTxO]
ADV[Advert UTxO<br/>capability, price, bonds]
ESC[Escrow UTxO<br/>locked funds + state]
end
SUP[🧠 Supplier<br/>local LLM node]
BUY[💼 Buyer<br/>app or agent]
IDX[🔭 Indexer<br/>read-only bulletin board]
SUP -->|publish advert| ADV
BUY -->|discover| IDX
IDX -.->|index events| CHAIN
BUY -->|post escrow| ESC
BUY -->|prompt + escrow_ref| SUP
SUP -->|signed receipt| BUY
SUP -->|claim & submit| ESC
BUY -->|accept & pay| ESC
```
* **Supplier** runs the inference and publishes a price.
* **Buyer** posts the work as a bonded escrow, sends the prompt, accepts the receipt.
* **Indexer** is a public mirror of chain state — anyone can run one. No platform middleman.
---
## The bonded-escrow lifecycle
```mermaid
sequenceDiagram
autonumber
participant B as Buyer
participant Chain as Vector
participant S as Supplier
participant LLM as Local LLM<br/>(OpenAI-compatible)
S->>Chain: PostAdvert (locks supplier_bond)
B->>Chain: PostEscrow → Open<br/>(locks payment + bonds)
B->>S: POST /v1/chat/completions
S->>Chain: ClaimEscrow → Claimed
S->>LLM: run inference
LLM-->>S: assistant message
S->>Chain: SubmitEscrow → Submitted
S-->>B: 200 OK + signed receipt
B->>Chain: AcceptEscrow — supplier paid
```
Every transition is on-chain, validated by an Aiken Plutus V3 script. The receipt is signed by the supplier's advert key; prompt and response hashes are bound on chain.
---
## What if someone misbehaves?
```mermaid
flowchart LR
OPEN[Open]:::norm --> CLAIMED[Claimed]:::norm --> SUBMITTED[Submitted]:::norm --> ACCEPTED[Accepted ✓]:::good
OPEN -->|deliver_by passed| RECLAIM[Reclaimed<br/>buyer recovers funds]:::warn
CLAIMED -->|deliver_by passed| RECLAIM
SUBMITTED -->|accept_window passed| RELEASE[Released<br/>supplier collects]:::warn
classDef norm fill:#e0e7ff,stroke:#4f46e5
classDef good fill:#dcfce7,stroke:#16a34a
classDef warn fill:#fef3c7,stroke:#d97706
```
| Failure mode | Recovery | Loss bearer |
|---|---|---|
| Supplier never claims/submits | Buyer **Reclaim** after `deliver_by` | Supplier — forfeits supplier_bond |
| Buyer disappears after Submit | Supplier **Release** after `submitted_at + 10 min` | Buyer — forfeits buyer_bond |
Bonds make honesty cheaper than running away. Both sides have skin in the game; no third-party arbiter is needed for the M1 happy/timeout paths.
---
## Local-agent versatility — any OpenAI-compatible engine
<div class="pipeline">
<span>💼 Buyer</span> →
<span>🧠 Supplier (OpenAI-compatible HTTP wrapper)</span> →
<span>backend ↓</span>
</div>
<div class="backends">
<div class="tile">Ollama<br/><small>local model runner</small></div>
<div class="tile">vLLM<br/><small>high-throughput GPU</small></div>
<div class="tile">llama.cpp<br/><small>CPU · Metal · ROCm</small></div>
<div class="tile">OpenRouter<br/><small>cloud API mux</small></div>
<div class="tile">openclaw / custom<br/><small>arbitrary stack</small></div>
<div class="tile dots">…<br/><small>any OpenAI-shaped API</small></div>
</div>
The supplier service is a thin HTTP wrapper — it forwards `/v1/chat/completions` to whatever backend is configured. Each supplier picks its own model, hardware, and engine, then advertises a `capability_id`. Buyers shop on capability + model + price.
---
## Capability + model = task selection
| Capability ID | Example models | Use case |
|---|---|---|
| `llm.text.generate.v1` | Llama 3 · Qwen 2.5 · Mistral · GPT-4 | Q&A, drafts, summarisation |
| `code.completion.v1` | Codestral · DeepSeek-Coder · StarCoder | IDE tab-complete, refactor |
| `vision.describe.v1` | LLaVA · Qwen-VL · Pixtral | Image captioning, OCR |
| `embedding.text.v1` | nomic-embed · BGE · ada-002 | RAG indexing |
| `audio.transcribe.v1` | whisper-large-v3 · distil-whisper | Voice-to-text |
| `…` | … | … |
* New capabilities = **just publish a new advert**. No marketplace governance gate.
* Today's deploy uses Ollama + Qwen 2.5; switching to vLLM with Llama 3 70B is a config change, not a code change.
---
## OpenClaw — agentic teams on your existing OpenAI seat
<div class="pipeline">
<span>🧠 Supplier wrapper</span> →
<span>🦾 OpenClaw</span> →
<span>🪪 OpenAI ChatGPT seat (flat-rate)</span>
</div>
* **Cost-efficient by default.** OpenClaw exposes your existing **OpenAI ChatGPT subscription** as an OpenAI-compatible API. The marketplace supplier dispatches its inference against it, so an entire **agentic team** (coders, QA automation testers, release curators…) draws from the **flat-rate seat you already pay for** — no per-token metering, no surprise invoices.
* **Reliability layer (optional, marginal cost).** OpenClaw can fall back to the **Anthropic API** or **OpenRouter** model pool when the primary seat is rate-limited, the prompt requires a different model, or you want a **second-layer hardening** of responses (e.g. cross-provider consensus on critical outputs). You only pay metered tokens for the requests that actually fall through.
---
## Proven on Vector testnet — 2026-05-05
| Step | Tx hash |
|---|---|
| Buyer posts escrow | `af762561…c2f7bc5` |
| Supplier claims | (lifecycle tx) |
| Supplier submits receipt | (lifecycle tx) |
| Buyer accepts | `125e3cfe…7dc100d` |
Funds flowed correctly: supplier received `payment + supplier_bond` (3 ADA); buyer got `buyer_bond` back.
* **Browser demo:** https://mp-buyers.vector.testnet.apexfusion.org
* **Live event stream:** https://mp-indexer.vector.testnet.apexfusion.org
* **Test suite:** 1098 unit tests passing, full lifecycle exercised in CI.
---
## Why UTxO + Vector
* **Bonded escrow is native** — Plutus validators enforce the state machine without a custom token or oracle.
* **Vector settles in seconds** — the buyer→claim→submit→accept loop fits inside a single chat-completion's wall clock.
* **AP3X token** for fees and bonds — fungible, transparent, on-chain.
* **No custom rollup, no bridge** — runs on a production chain today.
---
<!-- _class: closing -->
## The pitch in one sentence
> Local Agents Marketplace turns any OpenAI-compatible inference endpoint into a **trustlessly billable service** — buyer and supplier exchange prompt, response, and payment with on-chain proof that what was paid for is what was delivered.
### Try it
* **Indexer (live event stream):** https://mp-indexer.vector.testnet.apexfusion.org
* **Buyer:** https://mp-buyers.vector.testnet.apexfusion.org
* **Supplier:** https://mp-suppliers.vector.testnet.apexfusion.org/capability

Binary file not shown.

Binary file not shown.

View File

@@ -164,8 +164,8 @@ If the buyer does NOT accept within the dispute window (10 minutes), you can col
- **Never share your .env file** - it contains your wallet seed phrase - **Never share your .env file** - it contains your wallet seed phrase
- **Never share your data/ folder** - it contains task history - **Never share your data/ folder** - it contains task history
- The seed phrase controls your wallet funds. Back it up securely. - The seed phrase controls your wallet. Back it up securely.
- The node only responds to buyers who have already locked funds on-chain, so you cannot be scammed out of compute. - The node only responds to buyers who have already locked value on-chain, so you cannot be scammed out of compute.
## Troubleshooting ## Troubleshooting

View File

@@ -3,9 +3,9 @@
* *
* The buyer wallet funds the FULL escrow at PostEscrow: price + buyer_bond + * The buyer wallet funds the FULL escrow at PostEscrow: price + buyer_bond +
* supplier_bond (postEscrow.ts:132). Accept/Reclaim are Plutus script-spends * supplier_bond (postEscrow.ts:132). Accept/Reclaim are Plutus script-spends
* that also need a pure-ADA collateral UTxO ≥ 5 ADA (assertCollateralCandidate). * that also need a pure-AP3X collateral UTxO ≥ 5 AP3X (assertCollateralCandidate).
* So required = price + buyer_bond + supplier_bond + collateral + fee reserve, * So required = price + buyer_bond + supplier_bond + collateral + fee reserve,
* AND the wallet must currently hold a pure-ADA UTxO ≥ collateral. We check both * AND the wallet must currently hold a pure-AP3X UTxO ≥ collateral. We check both
* up front and return a clean 402 instead of failing mid-flight. * up front and return a clean 402 instead of failing mid-flight.
*/ */
@@ -18,7 +18,7 @@ export function totalLovelace(utxos: Utxo[]): bigint {
return utxos.reduce((sum, u) => sum + u.lovelace, 0n); return utxos.reduce((sum, u) => sum + u.lovelace, 0n);
} }
/** True iff some UTxO is pure-ADA (no native assets) and ≥ collateral floor. */ /** True iff some UTxO is pure-AP3X (no native assets) and ≥ collateral floor. */
export function hasCollateral(utxos: Utxo[]): boolean { export function hasCollateral(utxos: Utxo[]): boolean {
return utxos.some( return utxos.some(
(u) => u.lovelace >= COLLATERAL_MIN_LOVELACE && Object.keys(u.assets).length === 0, (u) => u.lovelace >= COLLATERAL_MIN_LOVELACE && Object.keys(u.assets).length === 0,

View File

@@ -108,7 +108,7 @@ async function runOneShot(
throw paymentRequired( throw paymentRequired(
pf.collateralOk pf.collateralOk
? "insufficient balance for this request" ? "insufficient balance for this request"
: "wallet has no pure-ADA UTxO ≥ 5 ADA for collateral; deposit a little more ADA", : "wallet has no pure-AP3X UTxO ≥ 5 AP3X for collateral; deposit a little more AP3X",
{ {
x_vector: { x_vector: {
required_lovelace: pf.requiredLovelace.toString(), required_lovelace: pf.requiredLovelace.toString(),

View File

@@ -93,7 +93,7 @@ export async function openSessionCore(
const pf = await preflight(deps.chain, ctx.walletKey.address, primary); const pf = await preflight(deps.chain, ctx.walletKey.address, primary);
if (!pf.ok) { if (!pf.ok) {
throw paymentRequired( throw paymentRequired(
pf.collateralOk ? "insufficient balance to open a session" : "wallet has no ≥5 ADA pure-ADA collateral UTxO", pf.collateralOk ? "insufficient balance to open a session" : "wallet has no ≥5 AP3X pure-AP3X collateral UTxO",
{ {
x_vector: { x_vector: {
required_lovelace: pf.requiredLovelace.toString(), required_lovelace: pf.requiredLovelace.toString(),

View File

@@ -8,7 +8,7 @@ export const INDEX_HTML = `<!doctype html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vector Marketplace — OpenAI Gateway</title> <title>Vector Marketplace - OpenAI Gateway</title>
<style> <style>
body { font-family: system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; } body { font-family: system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
h1 { font-size: 1.5rem; } h2 { font-size: 1.1rem; margin-top: 2rem; } h1 { font-size: 1.5rem; } h2 { font-size: 1.1rem; margin-top: 2rem; }
@@ -22,7 +22,7 @@ export const INDEX_HTML = `<!doctype html>
</style> </style>
</head> </head>
<body> <body>
<h1>Vector Marketplace — OpenAI-compatible Gateway</h1> <h1>Vector Marketplace - OpenAI-compatible Gateway</h1>
<p class="muted">Point any OpenAI SDK at <code id="base"></code> using your API key as the bearer token.</p> <p class="muted">Point any OpenAI SDK at <code id="base"></code> using your API key as the bearer token.</p>
<h2>1. Create an API key</h2> <h2>1. Create an API key</h2>
@@ -33,7 +33,7 @@ export const INDEX_HTML = `<!doctype html>
<div id="signupOut"></div> <div id="signupOut"></div>
<h2>2. Fund your wallet</h2> <h2>2. Fund your wallet</h2>
<p class="muted">Send AP3X to your deposit address (you need price + bonds + ~5 ADA collateral + fees).</p> <p class="muted">Send AP3X to your deposit address (you need price + bonds + ~5 AP3X collateral + fees).</p>
<h2>3. Account</h2> <h2>3. Account</h2>
<div class="row"> <div class="row">

View File

@@ -2,8 +2,8 @@
* gateway/src/walletHealth.ts — keep each custodial wallet usable. * gateway/src/walletHealth.ts — keep each custodial wallet usable.
* *
* After every settled request (and on a periodic tick) we re-shape the wallet to * After every settled request (and on a periodic tick) we re-shape the wallet to
* {≥5 ADA collateral UTxO, working UTxO} via the shared consolidate builder, so * {≥5 AP3X collateral UTxO, working UTxO} via the shared consolidate builder, so
* the NEXT request's Accept/Reclaim script-spend always finds a pure-ADA * the NEXT request's Accept/Reclaim script-spend always finds a pure-AP3X
* collateral candidate. No-op when the wallet is already healthy. * collateral candidate. No-op when the wallet is already healthy.
*/ */

View File

@@ -37,5 +37,10 @@
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vitest": "^2.1.8" "vitest": "^2.1.8"
},
"pnpm": {
"patchedDependencies": {
"libsodium-wrappers-sumo@0.7.16": "patches/libsodium-wrappers-sumo@0.7.16.patch"
}
} }
} }

View File

@@ -2,9 +2,22 @@
* planConsolidate.ts — pure pre-flight planner for the wallet-consolidate flow. * planConsolidate.ts — pure pre-flight planner for the wallet-consolidate flow.
* *
* Given a wallet's current UTxO set + a target collateral lovelace, decide * Given a wallet's current UTxO set + a target collateral lovelace, decide
* whether to consolidate (many → 2), split (1 → 2), or skip (already at * whether the wallet needs reshaping or is already usable for script spends.
* {≥collateral, working}). The same downstream tx-builder handles *
* consolidate and split — only the "reason" tag differs for logging. * Healthy is a STRUCTURAL predicate, not a UTxO count (F7, 2026-08-08):
* (a) some pure-lovelace UTxO >= collateralLovelace exists (the collateral
* candidate lucid's setCollateral needs), AND
* (b) at least MIN_WORKING_LOVELACE sits OUTSIDE that candidate (fee and
* spend money that is not the candidate), AND
* (c) fragmentation is bounded (utxos.length <= maxUtxos).
*
* The earlier revision returned `already-healthy` only for EXACTLY 2 UTxOs.
* The consolidate tx itself lands 3 (collateral + working + the fee-reserve
* remainder as change), so the shape consolidation produced could never
* satisfy its own health check — the wallet-health ticker re-consolidated
* every tick and burned 0.5 AP3X per pass (116 AP3X in one 24h soak).
* The health check also runs BEFORE the low-balance guard now: a wallet that
* can already transact must never be told "balance too low to consolidate".
* *
* No lucid / WASM deps so this is unit-testable from a vanilla vitest. * No lucid / WASM deps so this is unit-testable from a vanilla vitest.
*/ */
@@ -26,12 +39,24 @@ export interface PlannedConsolidate {
export const DEFAULT_COLLATERAL_LOVELACE = 5_000_000n; export const DEFAULT_COLLATERAL_LOVELACE = 5_000_000n;
export const DEFAULT_FEE_RESERVE = 2_000_000n; export const DEFAULT_FEE_RESERVE = 2_000_000n;
/** Reshape only when fragmentation passes this; receipt/change UTxOs
* accumulating between jobs are fine as long as a candidate exists. */
export const DEFAULT_MAX_UTXOS = 6;
const MIN_WORKING_LOVELACE = 1_000_000n; const MIN_WORKING_LOVELACE = 1_000_000n;
/** Lovelace of a UTxO iff it carries no native assets; null otherwise. */
function pureLovelace(u: UtxoLike): bigint | null {
for (const [unit, amount] of Object.entries(u.assets)) {
if (unit !== "lovelace" && (amount ?? 0n) > 0n) return null;
}
return u.assets.lovelace ?? 0n;
}
export function planConsolidate( export function planConsolidate(
utxos: UtxoLike[], utxos: UtxoLike[],
collateralLovelace: bigint = DEFAULT_COLLATERAL_LOVELACE, collateralLovelace: bigint = DEFAULT_COLLATERAL_LOVELACE,
feeReserve: bigint = DEFAULT_FEE_RESERVE, feeReserve: bigint = DEFAULT_FEE_RESERVE,
maxUtxos: number = DEFAULT_MAX_UTXOS,
): PlannedConsolidate { ): PlannedConsolidate {
if (utxos.length === 0) { if (utxos.length === 0) {
throw new Error("wallet has no UTxOs — fund the address first"); throw new Error("wallet has no UTxOs — fund the address first");
@@ -42,6 +67,32 @@ export function planConsolidate(
0n, 0n,
); );
// Structural health check first — reshaping a usable wallet wastes a fee.
if (utxos.length <= maxUtxos) {
// Smallest qualifying candidate: leave larger UTxOs as working funds.
let candidate: bigint | null = null;
for (const u of utxos) {
const pure = pureLovelace(u);
if (pure !== null && pure >= collateralLovelace) {
if (candidate === null || pure < candidate) candidate = pure;
}
}
if (candidate !== null) {
const workingOutside = totalLovelaceIn - candidate;
if (workingOutside >= MIN_WORKING_LOVELACE) {
return {
inputCount: utxos.length,
totalLovelaceIn,
collateralOutput: candidate,
workingOutput: workingOutside,
alreadyHealthy: true,
reason: "already-healthy",
};
}
}
}
// Reshape needed — only an actual reshape has a funds threshold.
const required = collateralLovelace + feeReserve + MIN_WORKING_LOVELACE; const required = collateralLovelace + feeReserve + MIN_WORKING_LOVELACE;
if (totalLovelaceIn < required) { if (totalLovelaceIn < required) {
throw new Error( throw new Error(
@@ -51,23 +102,6 @@ export function planConsolidate(
); );
} }
if (utxos.length === 2) {
const lov0 = utxos[0].assets.lovelace ?? 0n;
const lov1 = utxos[1].assets.lovelace ?? 0n;
const smaller = lov0 < lov1 ? lov0 : lov1;
const larger = lov0 < lov1 ? lov1 : lov0;
if (smaller >= collateralLovelace) {
return {
inputCount: 2,
totalLovelaceIn,
collateralOutput: smaller,
workingOutput: larger,
alreadyHealthy: true,
reason: "already-healthy",
};
}
}
const workingOutput = totalLovelaceIn - collateralLovelace - feeReserve; const workingOutput = totalLovelaceIn - collateralLovelace - feeReserve;
return { return {

File diff suppressed because one or more lines are too long

9
pnpm-lock.yaml generated
View File

@@ -4,6 +4,11 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
patchedDependencies:
libsodium-wrappers-sumo@0.7.16:
hash: 761a204004f889b65fdf148159f4bd90733703d63a0ff00eb0671c92ea085096
path: patches/libsodium-wrappers-sumo@0.7.16.patch
importers: importers:
.: .:
@@ -3166,7 +3171,7 @@ snapshots:
'@cardano-sdk/util': 0.16.0 '@cardano-sdk/util': 0.16.0
blake2b: 2.1.4 blake2b: 2.1.4
i: 0.3.7 i: 0.3.7
libsodium-wrappers-sumo: 0.7.16 libsodium-wrappers-sumo: 0.7.16(patch_hash=761a204004f889b65fdf148159f4bd90733703d63a0ff00eb0671c92ea085096)
lodash: 4.18.1 lodash: 4.18.1
npm: 9.9.4 npm: 9.9.4
pbkdf2: 3.1.5 pbkdf2: 3.1.5
@@ -4743,7 +4748,7 @@ snapshots:
libsodium-sumo@0.7.16: {} libsodium-sumo@0.7.16: {}
libsodium-wrappers-sumo@0.7.16: libsodium-wrappers-sumo@0.7.16(patch_hash=761a204004f889b65fdf148159f4bd90733703d63a0ff00eb0671c92ea085096):
dependencies: dependencies:
libsodium-sumo: 0.7.16 libsodium-sumo: 0.7.16

View File

@@ -6,3 +6,6 @@ packages:
- "indexer" - "indexer"
- "indexer-ui" - "indexer-ui"
- "wallet-monitor" - "wallet-monitor"
onlyBuiltDependencies:
- better-sqlite3

View File

@@ -7,15 +7,26 @@
* and the lane stalls until an operator runs `tx:consolidate-wallet`. * and the lane stalls until an operator runs `tx:consolidate-wallet`.
* *
* Two layers of self-healing: * Two layers of self-healing:
* startWalletHealthTicker — periodic background tick (every N ms). When * startWalletHealthTicker — periodic background tick (every N ms). Runs
* the supplier is idle, runs runConsolidateWallet. planConsolidate * runConsolidateWallet; planConsolidate's structural health predicate
* returns `already-healthy` cheaply for clean 2-UTxO wallets, so this * short-circuits without a tx when a collateral candidate exists and
* is a no-op in steady state — no tx, no fee. * fragmentation is bounded. A tick on a healthy wallet costs one UTxO
* query and nothing else.
* triggerOnFailureConsolidate — fire-and-forget consolidate from the * triggerOnFailureConsolidate — fire-and-forget consolidate from the
* Claim-failure path. Debounced so back-to-back failures don't queue * Claim-failure path. Debounced so back-to-back failures don't queue
* multiple consolidates. Returns 503 to the buyer for THIS request; the * multiple consolidates. Returns 503 to the buyer for THIS request; the
* next buyer retry (~30-60s later) finds the wallet healthy. * next buyer retry (~30-60s later) finds the wallet healthy.
* *
* F7 guards (2026-08-08 incident: a non-idempotent health check made every
* tick consolidate, burning 0.5 AP3X per 45s until the wallet hit the
* floor — 116 AP3X in one soak). Two independent circuit breakers protect
* against ANY future non-convergence:
* - consecutive guard: 3 consolidations in a row without ever reaching
* `already-healthy` halts wallet-health for this process.
* - budget guard: more than 6 consolidations inside 24h halts it too.
* A halt logs loudly once; consolidate manually and restart the supplier
* to re-arm.
*
* Wallet-lock discipline: consolidation spends EVERY wallet UTxO, so it runs * Wallet-lock discipline: consolidation spends EVERY wallet UTxO, so it runs
* inside state.walletMutex — the same mutex that serializes Claims/Submits — * inside state.walletMutex — the same mutex that serializes Claims/Submits —
* queueing behind any in-flight chain op instead of skipping. Session slots * queueing behind any in-flight chain op instead of skipping. Session slots
@@ -63,6 +74,29 @@ export interface WalletHealthTicker {
const inFlight = new WeakSet<SupplierState>(); const inFlight = new WeakSet<SupplierState>();
const lastOnFailureAt = new WeakMap<SupplierState, number>(); const lastOnFailureAt = new WeakMap<SupplierState, number>();
// ── F7 circuit breakers ──────────────────────────────────────────────
const MAX_CONSECUTIVE_CONSOLIDATIONS = 3;
const MAX_CONSOLIDATIONS_PER_WINDOW = 6;
const BUDGET_WINDOW_MS = 24 * 60 * 60 * 1000;
interface GuardCounters {
consecutive: number;
windowStart: number;
windowCount: number;
halted: boolean;
}
const guardCounters = new WeakMap<SupplierState, GuardCounters>();
function guardsFor(state: SupplierState): GuardCounters {
let g = guardCounters.get(state);
if (!g) {
g = { consecutive: 0, windowStart: Date.now(), windowCount: 0, halted: false };
guardCounters.set(state, g);
}
return g;
}
function defaultLog(line: string): void { function defaultLog(line: string): void {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`[wallet-health] ${line}`); console.log(`[wallet-health] ${line}`);
@@ -73,6 +107,8 @@ async function consolidateOnce(
opts: { collateralLovelace: bigint; awaitTimeoutMs: number; log: (line: string) => void }, opts: { collateralLovelace: bigint; awaitTimeoutMs: number; log: (line: string) => void },
): Promise<void> { ): Promise<void> {
const { state } = deps; const { state } = deps;
const guards = guardsFor(state);
if (guards.halted) return; // the halt already logged loudly, once
if (inFlight.has(state)) { if (inFlight.has(state)) {
opts.log("skip: another consolidate already in flight"); opts.log("skip: another consolidate already in flight");
return; return;
@@ -92,11 +128,36 @@ async function consolidateOnce(
}), }),
); );
if (result.reason === "already-healthy") { if (result.reason === "already-healthy") {
guards.consecutive = 0;
opts.log("already-healthy"); opts.log("already-healthy");
} else { } else {
const now = Date.now();
if (now - guards.windowStart > BUDGET_WINDOW_MS) {
guards.windowStart = now;
guards.windowCount = 0;
}
guards.consecutive += 1;
guards.windowCount += 1;
opts.log(`consolidated: ${result.reason} txHash=${result.txHash}`); opts.log(`consolidated: ${result.reason} txHash=${result.txHash}`);
if (guards.consecutive >= MAX_CONSECUTIVE_CONSOLIDATIONS) {
guards.halted = true;
opts.log(
`HALT: ${guards.consecutive} consecutive consolidations without reaching a healthy shape — ` +
`wallet-health stopped to protect funds (F7 guard). Investigate planConsolidate vs the ` +
`builder's output shape, consolidate manually, restart the supplier to re-arm.`,
);
} else if (guards.windowCount >= MAX_CONSOLIDATIONS_PER_WINDOW) {
guards.halted = true;
opts.log(
`HALT: consolidation budget exhausted (${guards.windowCount} in 24h) — wallet-health ` +
`stopped to protect funds (F7 guard). Restart the supplier to re-arm.`,
);
}
} }
} catch (err) { } catch (err) {
// Neither healthy nor consolidated (e.g. balance too low): no fee was
// spent, so the guards don't move — but nothing converged either, so
// the consecutive counter is NOT reset.
opts.log(`consolidate failed: ${(err as Error).message}`); opts.log(`consolidate failed: ${(err as Error).message}`);
} finally { } finally {
inFlight.delete(state); inFlight.delete(state);
@@ -143,8 +204,9 @@ export function triggerOnFailureConsolidate(
}); });
} }
/** Test-only: reset the in-flight + debounce maps between tests. */ /** Test-only: reset the in-flight + debounce + guard maps between tests. */
export function _resetWalletHealthForTests(state: SupplierState): void { export function _resetWalletHealthForTests(state: SupplierState): void {
inFlight.delete(state); inFlight.delete(state);
lastOnFailureAt.delete(state); lastOnFailureAt.delete(state);
guardCounters.delete(state);
} }

View File

@@ -118,15 +118,17 @@ export function encodeSampleAdvertDatumHex(): string {
hexToBytes(datum.supplier_pkh), hexToBytes(datum.supplier_pkh),
utf8Bytes(datum.capability_id), utf8Bytes(datum.capability_id),
utf8Bytes(datum.model), utf8Bytes(datum.model),
datum.max_output_tokens, // BigInt so cbor-x emits uint64 (0x1b) like the production codec — plain
datum.max_processing_ms, // numbers encode minimally (or as float64 past 2^32) and break golden parity.
BigInt(datum.max_output_tokens),
BigInt(datum.max_processing_ms),
datum.price_lovelace, datum.price_lovelace,
datum.supplier_bond_lovelace, datum.supplier_bond_lovelace,
datum.buyer_bond_lovelace, datum.buyer_bond_lovelace,
utf8Bytes(datum.endpoint_url), utf8Bytes(datum.endpoint_url),
utf8Bytes(datum.detail_uri), utf8Bytes(datum.detail_uri),
hexToBytes(datum.detail_hash), hexToBytes(datum.detail_hash),
datum.advertised_at, BigInt(datum.advertised_at),
statusTag, statusTag,
]); ]);

View File

@@ -1 +1 @@
d8798d581cabcdef0123456789abcdef0123456789abcdef0123456789abcdef01546c6c6d2e746578742e67656e65726174652e76314c7177656e322e353a302e356219020019ea601b00000000001e84801b00000000000f42401b00000000000f4240581f68747470733a2f2f737570706c6965722e6578616d706c652e636f6d2f76315835697066733a2f2f516d597741504a7a7635435a736e4136323573335866326e656d7459675070486457457a37396f6a576e506264475820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaafb4279667e84f00000d87980 d8798d581cabcdef0123456789abcdef0123456789abcdef0123456789abcdef01546c6c6d2e746578742e67656e65726174652e76314c7177656e322e353a302e35621b00000000000002001b000000000000ea601b00000000001e84801b00000000000f42401b00000000000f4240581f68747470733a2f2f737570706c6965722e6578616d706c652e636f6d2f76315835697066733a2f2f516d597741504a7a7635435a736e4136323573335866326e656d7459675070486457457a37396f6a576e506264475820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1b0000019667e84f00d87980

View File

@@ -107,15 +107,17 @@ export function encodeSampleAdvertDatumHex(): string {
hexToBytes(datum.supplier_pkh), hexToBytes(datum.supplier_pkh),
utf8Bytes(datum.capability_id), utf8Bytes(datum.capability_id),
utf8Bytes(datum.model), utf8Bytes(datum.model),
datum.max_output_tokens, // BigInt so cbor-x emits uint64 (0x1b) like the production codec — plain
datum.max_processing_ms, // numbers encode minimally (or as float64 past 2^32) and break golden parity.
BigInt(datum.max_output_tokens),
BigInt(datum.max_processing_ms),
datum.price_lovelace, datum.price_lovelace,
datum.supplier_bond_lovelace, datum.supplier_bond_lovelace,
datum.buyer_bond_lovelace, datum.buyer_bond_lovelace,
utf8Bytes(datum.endpoint_url), utf8Bytes(datum.endpoint_url),
utf8Bytes(datum.detail_uri), utf8Bytes(datum.detail_uri),
hexToBytes(datum.detail_hash), hexToBytes(datum.detail_hash),
datum.advertised_at, BigInt(datum.advertised_at),
statusTag, statusTag,
]); ]);

View File

@@ -86,28 +86,23 @@ function protocolParamsResponse() {
} }
/** /**
* Attempt to extract and decode the AdvertDatum from a submitted test-tx hex. * Attempt to extract and decode the AdvertDatum from a submitted tx CBOR hex.
* *
* The test tx format produced by encodeTxBody: * The live path emits real Cardano (Conway) CBOR. The advert datum rides in
* - First 8 hex chars: big-endian uint32 byte length of JSON payload * the script output as an inline datum: datum_option = [1, #6.24(bytes)], so
* - Next (length * 2) hex chars: JSON UTF-8 encoded payload * in hex it appears as "d818" (tag 24) followed by a definite-length byte
* string (0x58 <len> or 0x59 <len16>) holding the raw AdvertDatum CBOR.
* *
* The JSON has shape { type, outputs: [{..., datumHex: "<hex>"}], ... } * Returns null if no tag-24 wrapped datum is found or decoding fails.
* We extract the first output's datumHex and decode it with decodeAdvertDatum.
*
* Returns null if the hex is not in this format or has no datum.
*/ */
function extractAdvertisedAtFromTxCbor(txCborHex: string): number | null { function extractAdvertisedAtFromTxCbor(txCborHex: string): number | null {
try { try {
if (txCborHex.length < 8) return null; const m = /d818(?:58([0-9a-f]{2})|59([0-9a-f]{4}))/i.exec(txCborHex);
const lenHex = txCborHex.slice(0, 8); if (!m) return null;
const jsonLen = parseInt(lenHex, 16); const byteLen = parseInt(m[1] ?? m[2]!, 16);
if (!Number.isFinite(jsonLen) || jsonLen <= 0) return null; const start = m.index + m[0].length;
const jsonHex = txCborHex.slice(8, 8 + jsonLen * 2); const datumHex = txCborHex.slice(start, start + byteLen * 2);
const jsonStr = Buffer.from(jsonHex, "hex").toString("utf8"); if (datumHex.length !== byteLen * 2) return null;
const body = JSON.parse(jsonStr) as { outputs?: Array<{ datumHex?: string }> };
const datumHex = body.outputs?.[0]?.datumHex;
if (!datumHex) return null;
const datum = decodeAdvertDatum(datumHex); const datum = decodeAdvertDatum(datumHex);
return datum.advertised_at; return datum.advertised_at;
} catch { } catch {

View File

@@ -242,3 +242,115 @@ describe("walletHealth — triggerOnFailureConsolidate", () => {
expect(state.snapshot().status).toBe("working"); expect(state.snapshot().status).toBe("working");
}); });
}); });
describe("walletHealth — F7 circuit breakers", () => {
let state: SupplierState;
let logLines: string[];
let log: (line: string) => void;
let consolidate: ReturnType<typeof vi.fn> & ConsolidateFn;
beforeEach(() => {
vi.useFakeTimers();
state = new SupplierState();
_resetWalletHealthForTests(state);
logLines = [];
log = (line) => logLines.push(line);
consolidate = vi.fn() as never;
});
afterEach(() => {
vi.useRealTimers();
});
it("halts after 3 consecutive consolidations that never reach healthy", async () => {
// The 2026-08-07 pump: every tick consolidates, none lands healthy.
consolidate.mockResolvedValue(consolidatedResult);
const ticker = startWalletHealthTicker(
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
{ intervalMs: 1_000, log },
);
await vi.advanceTimersByTimeAsync(3_000);
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(3));
expect(logLines.some((l) => l.includes("HALT") && l.includes("consecutive"))).toBe(true);
// Two more ticks: the breaker holds, no further txs.
await vi.advanceTimersByTimeAsync(2_000);
expect(consolidate).toHaveBeenCalledTimes(3);
ticker.stop();
});
it("an already-healthy result resets the consecutive counter", async () => {
consolidate
.mockResolvedValueOnce(consolidatedResult)
.mockResolvedValueOnce(consolidatedResult)
.mockResolvedValueOnce(healthyResult)
.mockResolvedValueOnce(consolidatedResult)
.mockResolvedValueOnce(consolidatedResult)
.mockResolvedValue(healthyResult);
const ticker = startWalletHealthTicker(
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
{ intervalMs: 1_000, log },
);
// 6 ticks: c,c,h,c,c,h — never 3 consecutive, so never halted.
await vi.advanceTimersByTimeAsync(6_000);
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(6));
expect(logLines.some((l) => l.includes("HALT"))).toBe(false);
ticker.stop();
});
it("halts when the 24h consolidation budget is exhausted", async () => {
// Interleave healthy results so the consecutive guard never fires:
// c,c,h,c,c,h,c,c -> 6 consolidations inside the window at tick 8.
consolidate
.mockResolvedValueOnce(consolidatedResult)
.mockResolvedValueOnce(consolidatedResult)
.mockResolvedValueOnce(healthyResult)
.mockResolvedValueOnce(consolidatedResult)
.mockResolvedValueOnce(consolidatedResult)
.mockResolvedValueOnce(healthyResult)
.mockResolvedValue(consolidatedResult);
const ticker = startWalletHealthTicker(
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
{ intervalMs: 1_000, log },
);
await vi.advanceTimersByTimeAsync(8_000);
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(8));
expect(logLines.some((l) => l.includes("HALT") && l.includes("budget"))).toBe(true);
// Breaker holds on subsequent ticks.
await vi.advanceTimersByTimeAsync(2_000);
expect(consolidate).toHaveBeenCalledTimes(8);
ticker.stop();
});
it("failed consolidations neither trip nor reset the breakers", async () => {
// Errors spend no fee: they must not count as consolidations, but they
// must not clear the consecutive count either.
consolidate
.mockResolvedValueOnce(consolidatedResult)
.mockResolvedValueOnce(consolidatedResult)
.mockRejectedValueOnce(new Error("balance too low to consolidate"))
.mockResolvedValue(consolidatedResult);
const ticker = startWalletHealthTicker(
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
{ intervalMs: 1_000, log },
);
// c,c,error,c -> the 4th call is the 3rd consecutive consolidation.
await vi.advanceTimersByTimeAsync(4_000);
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(4));
expect(logLines.some((l) => l.includes("HALT") && l.includes("consecutive"))).toBe(true);
ticker.stop();
});
});

View File

@@ -120,6 +120,13 @@ function setupAcceptLiveHappyPath(escrowDatumHex: string): void {
} }
// Tip slot is LOW — live path uses Date.now() for the window check // Tip slot is LOW — live path uses Date.now() for the window check
if (method === "queryNetwork/tip") return rpcOk({ slot: 100_000, id: "a".repeat(64) }); if (method === "queryNetwork/tip") return rpcOk({ slot: 100_000, id: "a".repeat(64) });
// Like real Ogmios, return the script-spend redeemer with its true index:
// the escrow input ("ff…") sorts after the wallet input ("cc…"), so the
// spend redeemer index is 1. The provider's index-0 fallback for empty
// mock responses makes CML's set_exunits panic ("unreachable").
if (method === "evaluateTransaction") {
return rpcOk([{ validator: { purpose: "spend", index: 1 }, budget: { memory: 1_000_000, cpu: 500_000_000 } }]);
}
if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } }); if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } });
return rpcOk({}); return rpcOk({});
}); });

View File

@@ -11,7 +11,7 @@
* M1-F-4 RED — tests fail until Catherine implements the live CBOR path. * M1-F-4 RED — tests fail until Catherine implements the live CBOR path.
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { LiveOgmiosProvider } from "../../packages/shared/src/chain/LiveOgmiosProvider.js"; import { LiveOgmiosProvider } from "../../packages/shared/src/chain/LiveOgmiosProvider.js";
import { buildAcceptTx, ACCEPT_WINDOW_MS } from "../../packages/shared/src/tx/escrow/accept.js"; import { buildAcceptTx, ACCEPT_WINDOW_MS } from "../../packages/shared/src/tx/escrow/accept.js";
import { TxConstructionError } from "../../packages/shared/src/tx/types.js"; import { TxConstructionError } from "../../packages/shared/src/tx/types.js";
@@ -36,8 +36,20 @@ const COLLATERAL_LOVELACE = 100_000_000;
const mockFetch = vi.fn(); const mockFetch = vi.fn();
// Freeze Date.now() inside the fixture's accept window: the live path checks
// submitted_at + ACCEPT_WINDOW_MS against wall-clock time, and the fixture
// timestamps are fixed POSIX values that would otherwise expire as real time
// passes.
const FAKE_NOW_MS = SUBMITTED_AT + 60_000;
beforeEach(() => { beforeEach(() => {
mockFetch.mockReset(); mockFetch.mockReset();
vi.useFakeTimers();
vi.setSystemTime(FAKE_NOW_MS);
});
afterEach(() => {
vi.useRealTimers();
}); });
function rpcOk<T>(result: T) { function rpcOk<T>(result: T) {
@@ -89,6 +101,13 @@ function setupAcceptHappyPath(escrowDatumHex: string): void {
return rpcOk([makeOgmiosUtxo("c".repeat(64), 0, buyer.address, COLLATERAL_LOVELACE)]); return rpcOk([makeOgmiosUtxo("c".repeat(64), 0, buyer.address, COLLATERAL_LOVELACE)]);
} }
if (method === "queryNetwork/tip") return rpcOk({ slot: tipSlot, id: "a".repeat(64) }); if (method === "queryNetwork/tip") return rpcOk({ slot: tipSlot, id: "a".repeat(64) });
// Like real Ogmios, return the script-spend redeemer with its true index:
// the escrow input ("ff…") sorts after the wallet input ("cc…"), so the
// spend redeemer index is 1. The provider's index-0 fallback for empty
// mock responses makes CML's set_exunits panic ("unreachable").
if (method === "evaluateTransaction") {
return rpcOk([{ validator: { purpose: "spend", index: 1 }, budget: { memory: 1_000_000, cpu: 500_000_000 } }]);
}
if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } }); if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } });
return rpcOk({}); return rpcOk({});
}); });

View File

@@ -115,6 +115,13 @@ function setupClaimLiveHappyPath(escrowDatumHex: string): void {
if (method === "queryNetwork/tip") { if (method === "queryNetwork/tip") {
return rpcOk({ slot: 100_000, id: "a".repeat(64) }); return rpcOk({ slot: 100_000, id: "a".repeat(64) });
} }
// Like real Ogmios, return the script-spend redeemer with its true index:
// the escrow input ("ff…") sorts after the wallet input ("cc…"), so the
// spend redeemer index is 1. The provider's index-0 fallback for empty
// mock responses makes CML's set_exunits panic ("unreachable").
if (method === "evaluateTransaction") {
return rpcOk([{ validator: { purpose: "spend", index: 1 }, budget: { memory: 1_000_000, cpu: 500_000_000 } }]);
}
if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } }); if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } });
return rpcOk({}); return rpcOk({});
}); });

View File

@@ -10,7 +10,7 @@
* M1-F-4 RED — tests fail until Catherine implements the live CBOR path. * M1-F-4 RED — tests fail until Catherine implements the live CBOR path.
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { LiveOgmiosProvider } from "../../packages/shared/src/chain/LiveOgmiosProvider.js"; import { LiveOgmiosProvider } from "../../packages/shared/src/chain/LiveOgmiosProvider.js";
import { buildClaimTx } from "../../packages/shared/src/tx/escrow/claim.js"; import { buildClaimTx } from "../../packages/shared/src/tx/escrow/claim.js";
import { TxConstructionError } from "../../packages/shared/src/tx/types.js"; import { TxConstructionError } from "../../packages/shared/src/tx/types.js";
@@ -32,8 +32,19 @@ const COLLATERAL_LOVELACE = 100_000_000;
const mockFetch = vi.fn(); const mockFetch = vi.fn();
// Freeze Date.now() inside the fixture's claim window: the live path checks
// deliver_by against wall-clock time, and the fixture deadlines are fixed
// POSIX timestamps that would otherwise expire as real time passes.
const FAKE_NOW_MS = DELIVER_BY - 30_000;
beforeEach(() => { beforeEach(() => {
mockFetch.mockReset(); mockFetch.mockReset();
vi.useFakeTimers();
vi.setSystemTime(FAKE_NOW_MS);
});
afterEach(() => {
vi.useRealTimers();
}); });
function rpcOk<T>(result: T) { function rpcOk<T>(result: T) {
@@ -87,6 +98,13 @@ function setupClaimHappyPath(escrowDatumHex: string): void {
// Tip well before deliver_by // Tip well before deliver_by
return rpcOk({ slot: Math.floor((DELIVER_BY - 60_000) / 1000), id: "a".repeat(64) }); return rpcOk({ slot: Math.floor((DELIVER_BY - 60_000) / 1000), id: "a".repeat(64) });
} }
// Like real Ogmios, return the script-spend redeemer with its true index:
// the escrow input ("ff…") sorts after the wallet input ("cc…"), so the
// spend redeemer index is 1. The provider's index-0 fallback for empty
// mock responses makes CML's set_exunits panic ("unreachable").
if (method === "evaluateTransaction") {
return rpcOk([{ validator: { purpose: "spend", index: 1 }, budget: { memory: 1_000_000, cpu: 500_000_000 } }]);
}
if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } }); if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } });
return rpcOk({}); return rpcOk({});
}); });

View File

@@ -64,14 +64,17 @@ describe("planConsolidate", () => {
expect(plan.workingOutput).toBe(8_650_000n); expect(plan.workingOutput).toBe(8_650_000n);
}); });
it("consolidates a 2-UTxO wallet where the smaller is below collateral threshold", () => { it("accepts a 2-UTxO wallet where the LARGER is the collateral candidate", () => {
// Both fit budget but smaller (3 AP3X) is below 5 AP3X — needs reshape. // {3, 15}: the 15 qualifies as a pure >=5 candidate and the 3 covers
// fees — this wallet can run script txs as-is. The old exact-2-slot
// convention reshaped it for nothing (0.5 AP3X per pass).
const utxos = [u(3_000_000n), u(15_000_000n)]; const utxos = [u(3_000_000n), u(15_000_000n)];
const plan = planConsolidate(utxos); const plan = planConsolidate(utxos);
expect(plan.reason).toBe("consolidate"); expect(plan.reason).toBe("already-healthy");
expect(plan.alreadyHealthy).toBe(false); expect(plan.alreadyHealthy).toBe(true);
expect(plan.inputCount).toBe(2); expect(plan.collateralOutput).toBe(15_000_000n);
expect(plan.workingOutput).toBe(3_000_000n);
}); });
it("throws on 0 UTxOs", () => { it("throws on 0 UTxOs", () => {
@@ -93,3 +96,93 @@ describe("planConsolidate", () => {
expect(plan.workingOutput).toBe(20_000_000n - 10_000_000n - DEFAULT_FEE_RESERVE); expect(plan.workingOutput).toBe(20_000_000n - 10_000_000n - DEFAULT_FEE_RESERVE);
}); });
}); });
describe("planConsolidate — structural health predicate (F7)", () => {
// The tx buildConsolidateWalletTx actually lands: {collateral 5.0,
// working = total - 5.0 - 2.0 reserve, change = reserve - fee}. The fee
// observed on mainnet is exactly 501,628, so the change is 1,498,372.
const MAINNET_CONSOLIDATE_FEE = 501_628n;
it("REGRESSION: the exact shape consolidation produces is healthy", () => {
// This is the loop that burned 116 AP3X in the 2026-08-07 soak: the
// planner called its own output shape unhealthy and re-consolidated
// every 45s tick. Feed it precisely what the builder lands.
const utxos = [
u(5_000_000n),
u(91_000_000n),
u(DEFAULT_FEE_RESERVE - MAINNET_CONSOLIDATE_FEE), // 1,498,372 change
];
const plan = planConsolidate(utxos);
expect(plan.reason).toBe("already-healthy");
expect(plan.alreadyHealthy).toBe(true);
expect(plan.collateralOutput).toBe(5_000_000n);
});
it("is idempotent: applying the builder's output model to any plan yields healthy", () => {
const fragmented = [u(2_400_750n), u(4_100_000n), u(1_679_301n), u(1_679_301n)];
const plan = planConsolidate(fragmented);
expect(plan.alreadyHealthy).toBe(false);
// Model of what buildConsolidateWalletTx lands for this plan.
const afterConsolidation = [
u(plan.collateralOutput),
u(plan.workingOutput),
u(DEFAULT_FEE_RESERVE - MAINNET_CONSOLIDATE_FEE),
];
const replan = planConsolidate(afterConsolidation);
expect(replan.reason).toBe("already-healthy");
expect(replan.alreadyHealthy).toBe(true);
});
it("still consolidates when fragmentation exceeds the max UTxO bound", () => {
// Candidate present, but 7 UTxOs > DEFAULT_MAX_UTXOS (6).
const utxos = [
u(5_000_000n), u(1_100_000n), u(1_100_000n), u(1_100_000n),
u(1_100_000n), u(1_100_000n), u(1_100_000n),
];
const plan = planConsolidate(utxos);
expect(plan.reason).toBe("consolidate");
});
it("still consolidates when no pure UTxO reaches the collateral threshold", () => {
// The 2026-08-07 batch-stall shape: fragments only, max 4,498,372.
const utxos = [u(4_498_372n), u(3_098_372n), u(1_498_372n)];
const plan = planConsolidate(utxos);
expect(plan.reason).toBe("consolidate");
expect(plan.alreadyHealthy).toBe(false);
});
it("a UTxO 1,628 lovelace short of collateral is not a candidate", () => {
// Seen live: 4,998,372 (= 5.0 minus a 501,628 fee twice-shuffled).
const utxos = [u(4_998_372n), u(9_000_000n - 4_998_372n)];
const plan = planConsolidate(utxos);
expect(plan.reason).toBe("consolidate");
});
it("a UTxO carrying native assets is not a collateral candidate", () => {
const withToken: UtxoLike = {
assets: { lovelace: 6_000_000n, "deadbeef.72737456": 1n },
};
const utxos = [withToken, u(4_000_000n)];
const plan = planConsolidate(utxos);
expect(plan.reason).toBe("consolidate");
});
it("healthy shape below the reshape threshold is healthy, not an error", () => {
// {5.0, 2.5} = 7.5 total: under the 8.0 reshape minimum but perfectly
// able to run script txs. The old order threw "balance too low" here
// on every tick (1,744 log lines in the soak).
const utxos = [u(5_000_000n), u(2_500_000n)];
const plan = planConsolidate(utxos);
expect(plan.reason).toBe("already-healthy");
});
it("candidate with only dust outside it needs a reshape it cannot afford", () => {
// {5.0, 0.4}: no working money outside the candidate, and 5.4 total is
// below the reshape minimum — loud error, funds needed.
const utxos = [u(5_000_000n), u(400_000n)];
expect(() => planConsolidate(utxos)).toThrow(/balance too low/i);
});
});

View File

@@ -9,10 +9,9 @@ SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXXX/YYYY/ZZZZ
# Defaults to the mainnet public endpoint (also pinned in the compose file). # Defaults to the mainnet public endpoint (also pinned in the compose file).
# OGMIOS_URL=https://ogmios.vector.mainnet.apexfusion.org # OGMIOS_URL=https://ogmios.vector.mainnet.apexfusion.org
# OPTIONAL — override the wallets.json default threshold / reminder cadence # OPTIONAL — override the wallets.json default threshold without editing the
# without editing the mounted wallets file. # mounted wallets file.
# DEFAULT_MIN_AP3X=10 # DEFAULT_MIN_AP3X=10
# REMINDER_HOURS=6
# OPTIONAL — paths inside the container (defaults shown; bind-mounted by compose). # OPTIONAL — paths inside the container (defaults shown; bind-mounted by compose).
# WALLETS_PATH=/repo/wallet-monitor/wallets.json # WALLETS_PATH=/repo/wallet-monitor/wallets.json

View File

@@ -2,12 +2,12 @@
One-shot wallet-balance Slack alerter for the `vector-marketplace` mainnet box. One-shot wallet-balance Slack alerter for the `vector-marketplace` mainnet box.
Checks every wallet in `wallets.json` against a threshold (default **10 APEX**), Checks every wallet in `wallets.json` against a threshold (default **10 APEX**)
posts a combined Slack message when any wallet is below it, re-reminds every and posts a combined Slack message only when something **changed** since the
`reminder_hours` while a wallet stays low, and posts a recovery message when it last run — a wallet newly dropped below its threshold, or climbed back above
climbs back above. Each alert lists **every** monitored wallet — low (🔴) and it. A wallet that merely stays low never re-posts. Each alert lists **every**
healthy (🟢) — with its full, copy-pasteable address; it stays fully silent monitored wallet — low (🔴) and healthy (🟢) — with its full, copy-pasteable
when all wallets are healthy. Balances are read from Ogmios via address; it stays fully silent while nothing changes. Balances are read from Ogmios via
`ReadOnlyOgmiosProvider` (`@marketplace/shared/chain`) — the same client behind `ReadOnlyOgmiosProvider` (`@marketplace/shared/chain`) — the same client behind
the buyer's `/v1/wallet/balance` and `buyer/scripts/monitor-wallets.ts`. the buyer's `/v1/wallet/balance` and `buyer/scripts/monitor-wallets.ts`.

View File

@@ -8,12 +8,11 @@
* STATE_PATH — path to the dedup state file (read/written each run). * STATE_PATH — path to the dedup state file (read/written each run).
* Default /repo/wallet-monitor/data/state.json. * Default /repo/wallet-monitor/data/state.json.
* DEFAULT_MIN_AP3X — (optional) overrides wallets.json default_min_ap3x. * DEFAULT_MIN_AP3X — (optional) overrides wallets.json default_min_ap3x.
* REMINDER_HOURS — (optional) overrides wallets.json reminder_hours.
* TEST — "1" posts a one-line "configured" message to Slack and exits. * TEST — "1" posts a one-line "configured" message to Slack and exits.
* *
* wallets.json owns the wallet list + the default threshold / reminder cadence; * wallets.json owns the wallet list + the default threshold; the env override
* the env overrides exist only so an operator can retune without editing the * exists only so an operator can retune without editing the mounted wallets
* mounted wallets file. * file.
*/ */
const DEFAULT_OGMIOS_URL = "https://ogmios.vector.mainnet.apexfusion.org"; const DEFAULT_OGMIOS_URL = "https://ogmios.vector.mainnet.apexfusion.org";
@@ -21,7 +20,6 @@ const DEFAULT_WALLETS_PATH = "/repo/wallet-monitor/wallets.json";
const DEFAULT_STATE_PATH = "/repo/wallet-monitor/data/state.json"; const DEFAULT_STATE_PATH = "/repo/wallet-monitor/data/state.json";
const FALLBACK_DEFAULT_MIN_AP3X = 10; const FALLBACK_DEFAULT_MIN_AP3X = 10;
const FALLBACK_REMINDER_HOURS = 6;
export interface EnvConfig { export interface EnvConfig {
slackWebhookUrl: string; slackWebhookUrl: string;
@@ -30,8 +28,6 @@ export interface EnvConfig {
statePath: string; statePath: string;
/** When set, overrides the wallets-file default_min_ap3x. */ /** When set, overrides the wallets-file default_min_ap3x. */
defaultMinAp3xOverride: number | null; defaultMinAp3xOverride: number | null;
/** When set, overrides the wallets-file reminder_hours. */
reminderHoursOverride: number | null;
testMode: boolean; testMode: boolean;
} }
@@ -44,7 +40,6 @@ export interface WalletEntry {
export interface WalletsFile { export interface WalletsFile {
defaultMinAp3x: number; defaultMinAp3x: number;
reminderHours: number;
wallets: WalletEntry[]; wallets: WalletEntry[];
} }
@@ -81,7 +76,6 @@ export function loadConfig(env: Record<string, string | undefined>): EnvConfig {
walletsPath: nonEmpty(env, "WALLETS_PATH", DEFAULT_WALLETS_PATH), walletsPath: nonEmpty(env, "WALLETS_PATH", DEFAULT_WALLETS_PATH),
statePath: nonEmpty(env, "STATE_PATH", DEFAULT_STATE_PATH), statePath: nonEmpty(env, "STATE_PATH", DEFAULT_STATE_PATH),
defaultMinAp3xOverride: parseOptionalPositiveNumber(env.DEFAULT_MIN_AP3X, "DEFAULT_MIN_AP3X"), defaultMinAp3xOverride: parseOptionalPositiveNumber(env.DEFAULT_MIN_AP3X, "DEFAULT_MIN_AP3X"),
reminderHoursOverride: parseOptionalPositiveNumber(env.REMINDER_HOURS, "REMINDER_HOURS"),
testMode: env.TEST === "1", testMode: env.TEST === "1",
}; };
} }
@@ -113,11 +107,6 @@ export function parseWalletsFile(raw: unknown): WalletsFile {
"default_min_ap3x", "default_min_ap3x",
FALLBACK_DEFAULT_MIN_AP3X, FALLBACK_DEFAULT_MIN_AP3X,
); );
const reminderHours = parsePositiveNumberField(
raw.reminder_hours,
"reminder_hours",
FALLBACK_REMINDER_HOURS,
);
const walletsRaw = raw.wallets; const walletsRaw = raw.wallets;
if (!Array.isArray(walletsRaw) || walletsRaw.length === 0) { if (!Array.isArray(walletsRaw) || walletsRaw.length === 0) {
@@ -152,5 +141,5 @@ export function parseWalletsFile(raw: unknown): WalletsFile {
return entry; return entry;
}); });
return { defaultMinAp3x, reminderHours, wallets }; return { defaultMinAp3x, wallets };
} }

View File

@@ -3,9 +3,9 @@
* *
* Runs once and exits (invoked hourly by cron via `docker compose run --rm`). * Runs once and exits (invoked hourly by cron via `docker compose run --rm`).
* For each wallet in wallets.json: query its on-chain balance via Ogmios, and * For each wallet in wallets.json: query its on-chain balance via Ogmios, and
* if it's below threshold post a combined Slack alert. A persisted state file * post a combined Slack alert ONLY when something changed since the last run —
* dedups so a still-low wallet only re-alerts every `reminder_hours`, and a * a wallet newly dropped below threshold, or climbed back above it. A wallet
* recovery message fires when a wallet climbs back above its threshold. * that merely stays low never re-posts (state persisted across runs).
* *
* Reuses ReadOnlyOgmiosProvider from @marketplace/shared/chain — the same * Reuses ReadOnlyOgmiosProvider from @marketplace/shared/chain — the same
* client behind the buyer's /v1/wallet/balance and buyer/scripts/monitor-wallets.ts. * client behind the buyer's /v1/wallet/balance and buyer/scripts/monitor-wallets.ts.
@@ -19,7 +19,6 @@ import { loadConfig, parseWalletsFile, type WalletEntry } from "./config.js";
import { postSlack } from "./slack.js"; import { postSlack } from "./slack.js";
const LOVELACE_PER_AP3X = 1_000_000; const LOVELACE_PER_AP3X = 1_000_000;
const MS_PER_HOUR = 3_600_000;
type Status = "ok" | "low"; type Status = "ok" | "low";
@@ -70,6 +69,31 @@ function saveState(path: string, state: StateFile): void {
renameSync(tmp, path); // atomic replace renameSync(tmp, path); // atomic replace
} }
const QUERY_ATTEMPTS = 2;
const QUERY_RETRY_DELAY_MS = 2_000;
async function checkWallet(
provider: ReadOnlyOgmiosProvider,
w: WalletEntry,
minLovelace: bigint,
): Promise<CheckedWallet> {
let lastError = "unknown error";
for (let attempt = 1; attempt <= QUERY_ATTEMPTS; attempt++) {
try {
const utxos = await provider.queryUtxosByAddress(w.address);
const lovelace = utxos.reduce((a, u) => a + BigInt(u.lovelace), 0n);
return { name: w.name, address: w.address, minLovelace, lovelace, error: null };
} catch (e) {
lastError = e instanceof Error ? e.message : String(e);
if (attempt < QUERY_ATTEMPTS) {
await new Promise((r) => setTimeout(r, QUERY_RETRY_DELAY_MS));
}
}
}
console.error(`[wallet-monitor] query failed for ${w.name} (${w.address}): ${lastError}`);
return { name: w.name, address: w.address, minLovelace, lovelace: null, error: lastError };
}
// ── main ──────────────────────────────────────────────────────────────── // ── main ────────────────────────────────────────────────────────────────
async function main(): Promise<void> { async function main(): Promise<void> {
@@ -94,24 +118,16 @@ async function main(): Promise<void> {
JSON.parse(readFileSync(cfg.walletsPath, "utf-8")) as unknown, JSON.parse(readFileSync(cfg.walletsPath, "utf-8")) as unknown,
); );
const defaultMinAp3x = cfg.defaultMinAp3xOverride ?? walletsFile.defaultMinAp3x; const defaultMinAp3x = cfg.defaultMinAp3xOverride ?? walletsFile.defaultMinAp3x;
const reminderMs = (cfg.reminderHoursOverride ?? walletsFile.reminderHours) * MS_PER_HOUR;
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: cfg.ogmiosUrl }); const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: cfg.ogmiosUrl });
const checked: CheckedWallet[] = await Promise.all( // Query SEQUENTIALLY, not Promise.all: the public Ogmios endpoint only
walletsFile.wallets.map(async (w: WalletEntry): Promise<CheckedWallet> => { // serves a few concurrent ledger-state queries and 500s the rest, so a
const minLovelace = ap3xToLovelace(w.minAp3x ?? defaultMinAp3x); // burst over the full wallet list reliably fails several wallets.
try { const checked: CheckedWallet[] = [];
const utxos = await provider.queryUtxosByAddress(w.address); for (const w of walletsFile.wallets) {
const lovelace = utxos.reduce((a, u) => a + BigInt(u.lovelace), 0n); checked.push(await checkWallet(provider, w, ap3xToLovelace(w.minAp3x ?? defaultMinAp3x)));
return { name: w.name, address: w.address, minLovelace, lovelace, error: null }; }
} catch (e) {
const error = e instanceof Error ? e.message : String(e);
console.error(`[wallet-monitor] query failed for ${w.name} (${w.address}): ${error}`);
return { name: w.name, address: w.address, minLovelace, lovelace: null, error };
}
}),
);
const anyQueryFailed = checked.some((c) => c.error !== null); const anyQueryFailed = checked.some((c) => c.error !== null);
@@ -119,11 +135,11 @@ async function main(): Promise<void> {
const now = Date.now(); const now = Date.now();
const nextState: StateFile = { ...prevState }; const nextState: StateFile = { ...prevState };
// Classify each successfully-queried wallet, advancing dedup state. The // Classify each successfully-queried wallet. The decision to POST is driven
// decision to POST is driven only by transitions (a newly-low wallet, a low // only by state CHANGES (a wallet newly dropping low, or recovering) — a
// wallet past its reminder window, or a recovery). But when we DO post, the // wallet that stays low never re-posts. But when we DO post, the message
// message lists EVERY monitored wallet for full context (built below). // lists EVERY monitored wallet for full context (built below).
let anyDueLow = false; let anyNewlyLow = false;
let anyRecovered = false; let anyRecovered = false;
const recoveredNames = new Set<string>(); const recoveredNames = new Set<string>();
@@ -136,14 +152,12 @@ async function main(): Promise<void> {
const isLow = c.lovelace < c.minLovelace; const isLow = c.lovelace < c.minLovelace;
if (isLow) { if (isLow) {
const wasLow = prev?.status === "low"; if (prev?.status === "low") {
const due = !wasLow || now - (prev?.lastAlertMs ?? 0) >= reminderMs; // Still low — already alerted when it dropped; stay silent.
if (due) { nextState[c.name] = prev;
anyDueLow = true;
nextState[c.name] = { status: "low", lastAlertMs: now };
} else { } else {
// Still low but the reminder window hasn't elapsed — keep prev as-is. anyNewlyLow = true;
nextState[c.name] = prev ?? { status: "low", lastAlertMs: now }; nextState[c.name] = { status: "low", lastAlertMs: now };
} }
} else { } else {
if (prev?.status === "low") { if (prev?.status === "low") {
@@ -154,7 +168,7 @@ async function main(): Promise<void> {
} }
} }
const needPost = anyDueLow || anyRecovered; const needPost = anyNewlyLow || anyRecovered;
if (needPost) { if (needPost) {
const anyLow = checked.some( const anyLow = checked.some(
@@ -200,12 +214,12 @@ async function main(): Promise<void> {
).length; ).length;
console.log( console.log(
lowCount > 0 lowCount > 0
? `[wallet-monitor] ${lowCount} wallet(s) low but already alerted within the reminder window — no Slack post` ? `[wallet-monitor] ${lowCount} wallet(s) still low (already alerted) — no change, no Slack post`
: "[wallet-monitor] all wallets healthy — no Slack post", : "[wallet-monitor] all wallets healthy — no Slack post",
); );
} }
// Persist the new state (records ok/low statuses + advanced reminder stamps). // Persist the new state (records ok/low statuses + alert stamps).
// Only reached when there was nothing to post or the post succeeded. // Only reached when there was nothing to post or the post succeeded.
saveState(cfg.statePath, nextState); saveState(cfg.statePath, nextState);

View File

@@ -1,6 +1,5 @@
{ {
"default_min_ap3x": 10, "default_min_ap3x": 10,
"reminder_hours": 6,
"wallets": [ "wallets": [
{ "name": "buyer", "address": "addr1_REPLACE_ME" }, { "name": "buyer", "address": "addr1_REPLACE_ME" },
{ "name": "supplier-ollama", "address": "addr1_REPLACE_ME" }, { "name": "supplier-ollama", "address": "addr1_REPLACE_ME" },