16 Commits

Author SHA1 Message Date
FilipBl4gojevic
c391b98af8 fix(escrow): lock min-ada for the largest future datum so long-capability Submits settle
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
The escrow locks payment + both bonds and the validator requires exact
value preservation (value_equal) on Claim and Submit. Submit grows the
inline datum ~43 bytes (submitted_at None->Some, result_receipt_hash
None->Some 32 bytes). When capability_id is long enough, the
Submitted-state output's min-ada exceeds the locked total; lucid bumps
the continuing output, value_equal fails, and the validator errors with
no traces (opaque evaluateTransaction 3010/3012). Measured on mainnet at
utxoCostPerByte 4310: a 20-byte chat capability clears a 2.2M lock by
49,310, a 33-byte OCR capability is over by 11,030 - so short-capability
escrows settle and long ones never can, and because Claim's datum is
smaller it always passes, making a Submit-only failure look chain-side.

escrowLockFloor() computes min-ada for the Submitted state plus a margin
and locks max(economic total, that floor) in the post builders. Buyer-
side only, no contract or supplier changes; lucid never bumps once the
lock is sufficient, and the surplus returns through the >= terminal
distribution. postOcrEscrow's identical change ships with the OCR
feature branch. New unit test covers the floor across capability lengths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 14:22:02 +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
48 changed files with 632 additions and 954 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>
<dt>Reduce calls</dt>
<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>
<dt className="font-medium">Est. max cost</dt>
<dd className="text-right font-medium">{est.totalAp3x} AP3X</dd>
@@ -126,7 +126,7 @@ export default function JobEstimate({
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"
>
{starting ? "Starting…" : `Proceed & pay (~${est.totalAp3x} AP3X)`}
{starting ? "Starting…" : `Proceed (locks ~${est.totalAp3x} AP3X in escrow)`}
</button>
</div>
);

View File

@@ -50,7 +50,7 @@ export default function PdfUploadForm({ onUploaded }: { onUploaded: (info: Uploa
return (
<form onSubmit={handleSubmit} className="space-y-4">
<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.
</p>
<input

View File

@@ -114,7 +114,7 @@ export default function ApiKeys() {
<h1 className="text-2xl font-semibold">API Keys</h1>
<p className="text-sm text-gray-600">
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>.
</p>
</div>

View File

@@ -152,7 +152,7 @@ export default function BookSummarizer() {
<div>
<h1 className="text-2xl font-semibold">Book Summarizer</h1>
<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.
</p>
</div>

View File

@@ -1,6 +1,6 @@
/**
* 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:
* 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">Supplier</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">Action</th>
</tr>
@@ -254,7 +254,7 @@ export default function PendingReceipts() {
: "bg-indigo-600 text-white hover:bg-indigo-700")
}
>
{isAccepting ? "accepting…" : expired ? "expired" : "Accept & Pay"}
{isAccepting ? "accepting…" : expired ? "expired" : "Accept & Settle"}
</button>
)}
</td>

View File

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

View File

@@ -59,8 +59,18 @@ services:
labels:
- "traefik.enable=true"
- "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.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"
logging:
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
**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.**
- 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 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 streaming. `stream: true` is rejected by suppliers.
- Not tool-calling. `tools` / `tool_choice` / `functions` are rejected by suppliers.
@@ -24,7 +24,7 @@
| # | 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. |
| 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. |
@@ -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 |
| **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 |
| **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)
@@ -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 test discipline: `apex-dashboard/tests/`
- 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
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
1. You type a **prompt** and set a **payment** amount in the web UI
2. The app posts an **escrow transaction** on-chain, locking your payment + bond
1. You type a **prompt** and set a **price** in the web UI
2. The app posts an **escrow transaction** on-chain, locking price + bond
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
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 data/ folder** - it contains task history
- The seed phrase controls your wallet funds. Back it up securely.
- Escrow protects you: the supplier cannot take your funds without delivering a result
- The seed phrase controls your wallet. Back it up securely.
- 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
## 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) |
| 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 |
| Pricing | cost = matched supplier `price_lovelace`; **required balance = price + buyer_bond + supplier_bond + 5 ADA collateral + fee reserve** |
| Wallet health | gateway **auto-consolidates** each wallet to `{5-ADA collateral, working}` after settle / on a tick |
| 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-AP3X collateral, working}` after settle / on a tick |
| 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 |
| 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`.
- 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.
- 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).
- 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`.
@@ -100,7 +100,7 @@ Raw API key never persisted (only `sha256(key)`); `key_prefix` for display. `mas
## Request lifecycle — one-shot `/chat/completions`
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`).
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).
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`.
@@ -121,7 +121,7 @@ All on-chain steps run inside the per-key `mutex.run(...)`.
## 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.
- **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}}`)
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)
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`.
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.
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]`.

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 data/ folder** - it contains task history
- The seed phrase controls your wallet funds. 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 seed phrase controls your wallet. Back it up securely.
- The node only responds to buyers who have already locked value on-chain, so you cannot be scammed out of compute.
## Troubleshooting

View File

@@ -3,9 +3,9 @@
*
* The buyer wallet funds the FULL escrow at PostEscrow: price + buyer_bond +
* 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,
* 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.
*/
@@ -18,7 +18,7 @@ export function totalLovelace(utxos: Utxo[]): bigint {
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 {
return utxos.some(
(u) => u.lovelace >= COLLATERAL_MIN_LOVELACE && Object.keys(u.assets).length === 0,

View File

@@ -108,7 +108,7 @@ async function runOneShot(
throw paymentRequired(
pf.collateralOk
? "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: {
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);
if (!pf.ok) {
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: {
required_lovelace: pf.requiredLovelace.toString(),

View File

@@ -8,7 +8,7 @@ export const INDEX_HTML = `<!doctype html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vector Marketplace — OpenAI Gateway</title>
<title>Vector Marketplace - OpenAI Gateway</title>
<style>
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; }
@@ -22,7 +22,7 @@ export const INDEX_HTML = `<!doctype html>
</style>
</head>
<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>
<h2>1. Create an API key</h2>
@@ -33,7 +33,7 @@ export const INDEX_HTML = `<!doctype html>
<div id="signupOut"></div>
<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>
<div class="row">

View File

@@ -2,8 +2,8 @@
* gateway/src/walletHealth.ts — keep each custodial wallet usable.
*
* 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
* the NEXT request's Accept/Reclaim script-spend always finds a pure-ADA
* {≥5 AP3X collateral UTxO, working UTxO} via the shared consolidate builder, so
* the NEXT request's Accept/Reclaim script-spend always finds a pure-AP3X
* collateral candidate. No-op when the wallet is already healthy.
*/

View File

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

View File

@@ -42,6 +42,7 @@ import { loadBlueprint } from "../blueprint.js";
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
import { detectCborBackend } from "../internal/cborBackend.js";
import { escrowLockFloor } from "../internal/minAdaFloor.js";
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
export interface PostChatEscrowParams {
@@ -132,7 +133,7 @@ export async function buildPostChatEscrowTx(
const requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
const promptHash = chatSessionPromptHash({ session_nonce });
const totalLocked =
const economicTotal =
advertDatum.price_lovelace +
advertDatum.buyer_bond_lovelace +
advertDatum.supplier_bond_lovelace;
@@ -154,6 +155,11 @@ export async function buildPostChatEscrowTx(
state: "Open",
};
// Lock enough that the escrow output satisfies min-ada in its LARGEST
// future state (Submitted) - value_equal on Claim/Submit forbids any
// later bump (min-ada floor).
const totalLocked = escrowLockFloor(escrowDatum, economicTotal);
// Live path: reuse the capability-agnostic chat builder (messages = []).
// The on-chain commitment is the escrowDatum (carrying our session-init
// prompt_hash); messages are only used for the mock cbor body, which we omit.

View File

@@ -38,6 +38,7 @@ import { loadBlueprint } from "../blueprint.js";
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
import { detectCborBackend } from "../internal/cborBackend.js";
import { escrowLockFloor } from "../internal/minAdaFloor.js";
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
export interface PostEscrowParams {
@@ -129,7 +130,7 @@ export async function buildPostEscrowTx(
// supplier-side validation in M1-C can verify role/content/system integrity.
const promptHash = sha256Utf8Hex(canonicalize(messages));
const totalLocked =
const economicTotal =
advertDatum.price_lovelace +
advertDatum.buyer_bond_lovelace +
advertDatum.supplier_bond_lovelace;
@@ -151,6 +152,11 @@ export async function buildPostEscrowTx(
state: "Open",
};
// Lock enough that the escrow output satisfies min-ada in its LARGEST
// future state (Submitted) - value_equal on Claim/Submit forbids any
// later bump (min-ada floor).
const totalLocked = escrowLockFloor(escrowDatum, economicTotal);
// Live path: produce real Cardano CBOR via lucid-evolution.
if (detectCborBackend(chain) === "live") {
const liveCborPath = "../internal/liveCbor.js";

View File

@@ -31,6 +31,7 @@ import { loadBlueprint } from "../blueprint.js";
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
import { detectCborBackend } from "../internal/cborBackend.js";
import { escrowLockFloor } from "../internal/minAdaFloor.js";
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
/** TTS request envelope. The buyer-side hash and the supplier-side hash
@@ -149,7 +150,7 @@ export async function buildPostTtsEscrowTx(
const requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
const promptHash = ttsPromptHash(request);
const totalLocked =
const economicTotal =
advertDatum.price_lovelace +
advertDatum.buyer_bond_lovelace +
advertDatum.supplier_bond_lovelace;
@@ -171,6 +172,11 @@ export async function buildPostTtsEscrowTx(
state: "Open",
};
// Lock enough that the escrow output satisfies min-ada in its LARGEST
// future state (Submitted) - value_equal on Claim/Submit forbids any
// later bump (min-ada floor).
const totalLocked = escrowLockFloor(escrowDatum, economicTotal);
// Live path: reuse the chat builder. The live cbor path is already
// capability-agnostic — `messages` is `void`-marked there. We pass an
// empty array so the type checker is satisfied; the actual on-chain

View File

@@ -0,0 +1,67 @@
/**
* minAdaFloor.ts — min-ada floor for the escrow's LARGEST future datum.
*
* The escrow validator requires value_equal(continuing, in) on Claim and
* Submit, so the lovelace locked at POST time must already satisfy min-ada
* for every datum the output will ever carry. The largest is the Submitted
* state: submitted_at None -> Some(POSIX ms) and result_receipt_hash
* None -> Some(32 bytes) add ~43 bytes over Open/Claimed. If the posted
* lovelace sits below that state's min-ada, lucid bumps the Submit
* continuing output to min-ada, value_equal fails, and the validator
* errors with no traces (the 2026-08-07 mainnet incident: the 33-byte
* "ocr.page.extract.chandra-ocr-2.v1" capability_id pushed the Submitted
* output 11,030 lovelace over a 2,200,000 lock; the 20-byte chat
* capability stayed 49,310 under, which is why only OCR Submits failed).
*
* escrowLockFloor() returns the lovelace to lock: the economic total
* (payment + both bonds), raised to the Submitted-state min-ada plus a
* safety margin when the total alone is too small.
*/
import type { EscrowDatum } from "../../cbor/types.js";
import { encodeEscrowDatum } from "../../cbor/EscrowDatum.js";
/** Conway/Babbage utxoCostPerByte on Vector mainnet (protocol params). */
const UTXO_COST_PER_BYTE = 4310n;
/** Ledger constant: min-ada = (160 + serialized output bytes) * costPerByte. */
const OUTPUT_OVERHEAD_BYTES = 160n;
/** Covers CBOR framing drift between this estimate and the ledger's exact
* serialization (address, coin width, datum wrapper). Generous on purpose:
* 50k lovelace = 0.05 AP3X, dwarfed by per-leg tx fees. */
const SAFETY_MARGIN_LOVELACE = 50_000n;
/** Serialized-output byte estimate for an enterprise script address output
* carrying `lovelace` and an inline datum of `datumBytes`. Mirrors the
* arithmetic validated against mainnet on 2026-08-07 (minada-calc). */
function estimateOutputBytes(datumBytes: number, lovelace: bigint): bigint {
const addr = 1n + 29n;
const coin = 1n + (lovelace > 0xffffffffn ? 8n : 4n);
const datumField = 1n + 2n + 3n + BigInt(datumBytes);
const mapOverhead = 3n;
return addr + coin + datumField + mapOverhead;
}
/** Min-ada for the escrow output when it carries `datum`. */
export function minAdaForEscrowDatum(datum: EscrowDatum, lovelace: bigint): bigint {
const datumBytes = encodeEscrowDatum(datum).length / 2;
const size = estimateOutputBytes(datumBytes, lovelace);
return (OUTPUT_OVERHEAD_BYTES + size) * UTXO_COST_PER_BYTE;
}
/** The lovelace a POST must lock so the escrow output satisfies min-ada in
* every reachable state, including Submitted. `economicTotal` is
* payment + buyer_bond + supplier_bond. */
export function escrowLockFloor(openDatum: EscrowDatum, economicTotal: bigint): bigint {
const submittedShape: EscrowDatum = {
...openDatum,
// Representative worst-case Submit fields: a 13-digit POSIX ms stamp
// and a 32-byte receipt hash — sizes, not values, matter here.
submitted_at: openDatum.deliver_by,
result_receipt_hash: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
state: "Submitted",
};
const floor = minAdaForEscrowDatum(submittedShape, economicTotal) + SAFETY_MARGIN_LOVELACE;
return floor > economicTotal ? floor : economicTotal;
}

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

View File

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

View File

@@ -118,15 +118,17 @@ export function encodeSampleAdvertDatumHex(): string {
hexToBytes(datum.supplier_pkh),
utf8Bytes(datum.capability_id),
utf8Bytes(datum.model),
datum.max_output_tokens,
datum.max_processing_ms,
// BigInt so cbor-x emits uint64 (0x1b) like the production codec — plain
// 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.supplier_bond_lovelace,
datum.buyer_bond_lovelace,
utf8Bytes(datum.endpoint_url),
utf8Bytes(datum.detail_uri),
hexToBytes(datum.detail_hash),
datum.advertised_at,
BigInt(datum.advertised_at),
statusTag,
]);

View File

@@ -1 +1 @@
d8798d581cabcdef0123456789abcdef0123456789abcdef0123456789abcdef01546c6c6d2e746578742e67656e65726174652e76314c7177656e322e353a302e356219020019ea601b00000000001e84801b00000000000f42401b00000000000f4240581f68747470733a2f2f737570706c6965722e6578616d706c652e636f6d2f76315835697066733a2f2f516d597741504a7a7635435a736e4136323573335866326e656d7459675070486457457a37396f6a576e506264475820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaafb4279667e84f00000d87980
d8798d581cabcdef0123456789abcdef0123456789abcdef0123456789abcdef01546c6c6d2e746578742e67656e65726174652e76314c7177656e322e353a302e35621b00000000000002001b000000000000ea601b00000000001e84801b00000000000f42401b00000000000f4240581f68747470733a2f2f737570706c6965722e6578616d706c652e636f6d2f76315835697066733a2f2f516d597741504a7a7635435a736e4136323573335866326e656d7459675070486457457a37396f6a576e506264475820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1b0000019667e84f00d87980

View File

@@ -107,15 +107,17 @@ export function encodeSampleAdvertDatumHex(): string {
hexToBytes(datum.supplier_pkh),
utf8Bytes(datum.capability_id),
utf8Bytes(datum.model),
datum.max_output_tokens,
datum.max_processing_ms,
// BigInt so cbor-x emits uint64 (0x1b) like the production codec — plain
// 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.supplier_bond_lovelace,
datum.buyer_bond_lovelace,
utf8Bytes(datum.endpoint_url),
utf8Bytes(datum.detail_uri),
hexToBytes(datum.detail_hash),
datum.advertised_at,
BigInt(datum.advertised_at),
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:
* - First 8 hex chars: big-endian uint32 byte length of JSON payload
* - Next (length * 2) hex chars: JSON UTF-8 encoded payload
* The live path emits real Cardano (Conway) CBOR. The advert datum rides in
* the script output as an inline datum: datum_option = [1, #6.24(bytes)], so
* 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>"}], ... }
* 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.
* Returns null if no tag-24 wrapped datum is found or decoding fails.
*/
function extractAdvertisedAtFromTxCbor(txCborHex: string): number | null {
try {
if (txCborHex.length < 8) return null;
const lenHex = txCborHex.slice(0, 8);
const jsonLen = parseInt(lenHex, 16);
if (!Number.isFinite(jsonLen) || jsonLen <= 0) return null;
const jsonHex = txCborHex.slice(8, 8 + jsonLen * 2);
const jsonStr = Buffer.from(jsonHex, "hex").toString("utf8");
const body = JSON.parse(jsonStr) as { outputs?: Array<{ datumHex?: string }> };
const datumHex = body.outputs?.[0]?.datumHex;
if (!datumHex) return null;
const m = /d818(?:58([0-9a-f]{2})|59([0-9a-f]{4}))/i.exec(txCborHex);
if (!m) return null;
const byteLen = parseInt(m[1] ?? m[2]!, 16);
const start = m.index + m[0].length;
const datumHex = txCborHex.slice(start, start + byteLen * 2);
if (datumHex.length !== byteLen * 2) return null;
const datum = decodeAdvertDatum(datumHex);
return datum.advertised_at;
} catch {

View File

@@ -120,6 +120,13 @@ function setupAcceptLiveHappyPath(escrowDatumHex: string): void {
}
// 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) });
// 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) } });
return rpcOk({});
});

View File

@@ -11,7 +11,7 @@
* 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 { buildAcceptTx, ACCEPT_WINDOW_MS } from "../../packages/shared/src/tx/escrow/accept.js";
import { TxConstructionError } from "../../packages/shared/src/tx/types.js";
@@ -36,8 +36,20 @@ const COLLATERAL_LOVELACE = 100_000_000;
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(() => {
mockFetch.mockReset();
vi.useFakeTimers();
vi.setSystemTime(FAKE_NOW_MS);
});
afterEach(() => {
vi.useRealTimers();
});
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)]);
}
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) } });
return rpcOk({});
});

View File

@@ -115,6 +115,13 @@ function setupClaimLiveHappyPath(escrowDatumHex: string): void {
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) } });
return rpcOk({});
});

View File

@@ -10,7 +10,7 @@
* 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 { buildClaimTx } from "../../packages/shared/src/tx/escrow/claim.js";
import { TxConstructionError } from "../../packages/shared/src/tx/types.js";
@@ -32,8 +32,19 @@ const COLLATERAL_LOVELACE = 100_000_000;
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(() => {
mockFetch.mockReset();
vi.useFakeTimers();
vi.setSystemTime(FAKE_NOW_MS);
});
afterEach(() => {
vi.useRealTimers();
});
function rpcOk<T>(result: T) {
@@ -87,6 +98,13 @@ function setupClaimHappyPath(escrowDatumHex: string): void {
// Tip well before deliver_by
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) } });
return rpcOk({});
});

View File

@@ -0,0 +1,84 @@
/**
* tx-minada-floor.test.ts — the escrow min-ada floor.
*
* The escrow output locks payment + both bonds and the validator requires
* exact value preservation (value_equal) on Claim and Submit. Submit grows
* the inline datum (submitted_at None->Some, result_receipt_hash None->Some
* 32 bytes), so the lovelace locked at POST time must already cover min-ada
* for the Submitted state. A capability_id long enough to push the
* Submitted output's min-ada past the economic total made lucid bump the
* continuing output, value_equal failed, and the validator errored with no
* traces — so escrows with short capability ids settled and long ones never
* could. escrowLockFloor closes that by locking the max of the economic
* total and the Submitted-state min-ada plus a margin.
*/
import { describe, it, expect } from "vitest";
import {
escrowLockFloor,
minAdaForEscrowDatum,
} from "../../packages/shared/src/tx/internal/minAdaFloor.js";
import type { EscrowDatum } from "../../packages/shared/src/cbor/types.js";
function openDatum(capabilityId: string): EscrowDatum {
return {
buyer_pkh: "3b2fdaed0398485ff35887fb7d0ad2025348864407854f7831dc6a55",
supplier_pkh: "401af418a7e6fb106277292881fcdeaa1cc15b5b112e9c8bdb2d46c9",
advert_ref: { txHash: "a6".repeat(32), index: 0 },
capability_id: capabilityId,
request_spec_hash: "1b".repeat(32),
prompt_hash: "23".repeat(32),
payment_lovelace: 200_000n,
buyer_bond_lovelace: 1_000_000n,
supplier_bond_lovelace: 1_000_000n,
deliver_by: 1_786_105_955_257,
posted_at: 1_786_105_625_257,
submitted_at: null,
result_receipt_hash: null,
state: "Open",
};
}
const ECONOMIC_TOTAL = 2_200_000n; // 0.2 payment + 1.0 + 1.0 bonds
describe("escrowLockFloor", () => {
it("barely lifts a short-capability escrow (margin only, no capability-driven bump)", () => {
// The 20-byte chat capability's Submitted-state min-ada is ~2.15M, under
// the 2.2M economic total; only the safety margin nudges the lock, so it
// stays within a fraction of a percent of payment + bonds.
const datum = openDatum("llm.text.generate.v1");
const floor = escrowLockFloor(datum, ECONOMIC_TOTAL);
expect(floor).toBeGreaterThanOrEqual(ECONOMIC_TOTAL);
expect(floor).toBeLessThan(ECONOMIC_TOTAL + 100_000n); // << the OCR lift
});
it("raises a long-capability escrow above the economic total", () => {
// The 33-byte OCR capability that could not settle: its Submitted-state
// output needs more than 2.2M, so the floor must lift the lock.
const datum = openDatum("ocr.page.extract.chandra-ocr-2.v1");
const floor = escrowLockFloor(datum, ECONOMIC_TOTAL);
expect(floor).toBeGreaterThan(ECONOMIC_TOTAL);
// The locked value must satisfy the Submitted-state min-ada it is sized for.
const submitted: EscrowDatum = {
...datum,
submitted_at: datum.deliver_by,
result_receipt_hash: "ff".repeat(32),
state: "Submitted",
};
expect(floor).toBeGreaterThanOrEqual(minAdaForEscrowDatum(submitted, floor));
});
it("never returns less than the economic total", () => {
for (const cap of ["a", "x".repeat(64), "tts.speak.v1"]) {
expect(escrowLockFloor(openDatum(cap), ECONOMIC_TOTAL)).toBeGreaterThanOrEqual(
ECONOMIC_TOTAL,
);
}
});
it("the floor is monotonic in capability-id length", () => {
const short = escrowLockFloor(openDatum("a.b.v1"), ECONOMIC_TOTAL);
const long = escrowLockFloor(openDatum("a.very.long.capability.id.that.keeps.going.v1"), ECONOMIC_TOTAL);
expect(long).toBeGreaterThanOrEqual(short);
});
});

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).
# OGMIOS_URL=https://ogmios.vector.mainnet.apexfusion.org
# OPTIONAL — override the wallets.json default threshold / reminder cadence
# without editing the mounted wallets file.
# OPTIONAL — override the wallets.json default threshold without editing the
# mounted wallets file.
# DEFAULT_MIN_AP3X=10
# REMINDER_HOURS=6
# OPTIONAL — paths inside the container (defaults shown; bind-mounted by compose).
# 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.
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
`reminder_hours` while a wallet stays low, and posts a recovery message when it
climbs back above. Each alert lists **every** monitored wallet — low (🔴) and
healthy (🟢) — with its full, copy-pasteable address; it stays fully silent
when all wallets are healthy. Balances are read from Ogmios via
Checks every wallet in `wallets.json` against a threshold (default **10 APEX**)
and posts a combined Slack message only when something **changed** since the
last run — a wallet newly dropped below its threshold, or climbed back above
it. A wallet that merely stays low never re-posts. Each alert lists **every**
monitored wallet — low (🔴) and healthy (🟢) — with its full, copy-pasteable
address; it stays fully silent while nothing changes. Balances are read from Ogmios via
`ReadOnlyOgmiosProvider` (`@marketplace/shared/chain`) — the same client behind
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).
* Default /repo/wallet-monitor/data/state.json.
* 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.
*
* wallets.json owns the wallet list + the default threshold / reminder cadence;
* the env overrides exist only so an operator can retune without editing the
* mounted wallets file.
* wallets.json owns the wallet list + the default threshold; the env override
* exists only so an operator can retune without editing the mounted wallets
* file.
*/
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 FALLBACK_DEFAULT_MIN_AP3X = 10;
const FALLBACK_REMINDER_HOURS = 6;
export interface EnvConfig {
slackWebhookUrl: string;
@@ -30,8 +28,6 @@ export interface EnvConfig {
statePath: string;
/** When set, overrides the wallets-file default_min_ap3x. */
defaultMinAp3xOverride: number | null;
/** When set, overrides the wallets-file reminder_hours. */
reminderHoursOverride: number | null;
testMode: boolean;
}
@@ -44,7 +40,6 @@ export interface WalletEntry {
export interface WalletsFile {
defaultMinAp3x: number;
reminderHours: number;
wallets: WalletEntry[];
}
@@ -81,7 +76,6 @@ export function loadConfig(env: Record<string, string | undefined>): EnvConfig {
walletsPath: nonEmpty(env, "WALLETS_PATH", DEFAULT_WALLETS_PATH),
statePath: nonEmpty(env, "STATE_PATH", DEFAULT_STATE_PATH),
defaultMinAp3xOverride: parseOptionalPositiveNumber(env.DEFAULT_MIN_AP3X, "DEFAULT_MIN_AP3X"),
reminderHoursOverride: parseOptionalPositiveNumber(env.REMINDER_HOURS, "REMINDER_HOURS"),
testMode: env.TEST === "1",
};
}
@@ -113,11 +107,6 @@ export function parseWalletsFile(raw: unknown): WalletsFile {
"default_min_ap3x",
FALLBACK_DEFAULT_MIN_AP3X,
);
const reminderHours = parsePositiveNumberField(
raw.reminder_hours,
"reminder_hours",
FALLBACK_REMINDER_HOURS,
);
const walletsRaw = raw.wallets;
if (!Array.isArray(walletsRaw) || walletsRaw.length === 0) {
@@ -152,5 +141,5 @@ export function parseWalletsFile(raw: unknown): WalletsFile {
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`).
* 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
* dedups so a still-low wallet only re-alerts every `reminder_hours`, and a
* recovery message fires when a wallet climbs back above its threshold.
* post a combined Slack alert ONLY when something changed since the last run —
* a wallet newly dropped below threshold, or climbed back above it. A wallet
* that merely stays low never re-posts (state persisted across runs).
*
* Reuses ReadOnlyOgmiosProvider from @marketplace/shared/chain — the same
* 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";
const LOVELACE_PER_AP3X = 1_000_000;
const MS_PER_HOUR = 3_600_000;
type Status = "ok" | "low";
@@ -70,6 +69,31 @@ function saveState(path: string, state: StateFile): void {
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 ────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
@@ -94,24 +118,16 @@ async function main(): Promise<void> {
JSON.parse(readFileSync(cfg.walletsPath, "utf-8")) as unknown,
);
const defaultMinAp3x = cfg.defaultMinAp3xOverride ?? walletsFile.defaultMinAp3x;
const reminderMs = (cfg.reminderHoursOverride ?? walletsFile.reminderHours) * MS_PER_HOUR;
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: cfg.ogmiosUrl });
const checked: CheckedWallet[] = await Promise.all(
walletsFile.wallets.map(async (w: WalletEntry): Promise<CheckedWallet> => {
const minLovelace = ap3xToLovelace(w.minAp3x ?? defaultMinAp3x);
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) {
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 };
}
}),
);
// Query SEQUENTIALLY, not Promise.all: the public Ogmios endpoint only
// serves a few concurrent ledger-state queries and 500s the rest, so a
// burst over the full wallet list reliably fails several wallets.
const checked: CheckedWallet[] = [];
for (const w of walletsFile.wallets) {
checked.push(await checkWallet(provider, w, ap3xToLovelace(w.minAp3x ?? defaultMinAp3x)));
}
const anyQueryFailed = checked.some((c) => c.error !== null);
@@ -119,11 +135,11 @@ async function main(): Promise<void> {
const now = Date.now();
const nextState: StateFile = { ...prevState };
// Classify each successfully-queried wallet, advancing dedup state. The
// decision to POST is driven only by transitions (a newly-low wallet, a low
// wallet past its reminder window, or a recovery). But when we DO post, the
// message lists EVERY monitored wallet for full context (built below).
let anyDueLow = false;
// Classify each successfully-queried wallet. The decision to POST is driven
// only by state CHANGES (a wallet newly dropping low, or recovering) — a
// wallet that stays low never re-posts. But when we DO post, the message
// lists EVERY monitored wallet for full context (built below).
let anyNewlyLow = false;
let anyRecovered = false;
const recoveredNames = new Set<string>();
@@ -136,14 +152,12 @@ async function main(): Promise<void> {
const isLow = c.lovelace < c.minLovelace;
if (isLow) {
const wasLow = prev?.status === "low";
const due = !wasLow || now - (prev?.lastAlertMs ?? 0) >= reminderMs;
if (due) {
anyDueLow = true;
nextState[c.name] = { status: "low", lastAlertMs: now };
if (prev?.status === "low") {
// Still low — already alerted when it dropped; stay silent.
nextState[c.name] = prev;
} else {
// Still low but the reminder window hasn't elapsed — keep prev as-is.
nextState[c.name] = prev ?? { status: "low", lastAlertMs: now };
anyNewlyLow = true;
nextState[c.name] = { status: "low", lastAlertMs: now };
}
} else {
if (prev?.status === "low") {
@@ -154,7 +168,7 @@ async function main(): Promise<void> {
}
}
const needPost = anyDueLow || anyRecovered;
const needPost = anyNewlyLow || anyRecovered;
if (needPost) {
const anyLow = checked.some(
@@ -200,12 +214,12 @@ async function main(): Promise<void> {
).length;
console.log(
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",
);
}
// 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.
saveState(cfg.statePath, nextState);

View File

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