Compare commits
16 Commits
feat/huggi
...
fix/wallet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36c5cfc6be | ||
|
|
a54fdcb24c | ||
|
|
926300dee5 | ||
|
|
73700f983a | ||
|
|
2aa1688a17 | ||
|
|
3ae2a827c4 | ||
|
|
fec0ac8d0a | ||
|
|
be9febffb1 | ||
|
|
e5793031f8 | ||
|
|
716a8bf2ef | ||
|
|
b10706cf69 | ||
|
|
1f587867c6 | ||
|
|
0b253c73bb | ||
|
|
1648edeba7 | ||
|
|
4090e064cb | ||
|
|
3fac04b00e |
37
README.md
37
README.md
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
74
deploy/mainnet/docker-compose.supplier-ds-flash-chat-1.yml
Normal file
74
deploy/mainnet/docker-compose.supplier-ds-flash-chat-1.yml
Normal 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
|
||||
74
deploy/mainnet/docker-compose.supplier-ds-flash-chat-2.yml
Normal file
74
deploy/mainnet/docker-compose.supplier-ds-flash-chat-2.yml
Normal 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
|
||||
74
deploy/mainnet/docker-compose.supplier-ds-flash-chat-3.yml
Normal file
74
deploy/mainnet/docker-compose.supplier-ds-flash-chat-3.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
@@ -1,168 +0,0 @@
|
||||
# HuggingFace Router Supplier — Setup Runbook (V1 / W1)
|
||||
|
||||
> **Status:** shipped. Configures a Vector supplier node to use the HuggingFace
|
||||
> **Inference Providers router** as its **compute backend** for bonded inference
|
||||
> work commissioned on Vector. Config only — no code changes.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this is
|
||||
|
||||
The HuggingFace **Inference Providers router** (`https://router.huggingface.co`)
|
||||
is an OpenAI-compatible endpoint that fronts hundreds of hosted models across
|
||||
partner providers (Together, Fireworks, DeepInfra, Groq, …). It is the direct
|
||||
analogue of the OpenRouter backend the supplier already supports.
|
||||
|
||||
Because the supplier's upstream client (`supplier/src/openai.ts`) already speaks
|
||||
plain `POST /v1/chat/completions` with a Bearer token, pointing it at HuggingFace
|
||||
is **pure configuration**: set `LLM_BACKEND=openai` and point `OPENAI_BASE_URL` at
|
||||
the router. It is the same as adding any OpenAI-compatible compute backend, with
|
||||
the HuggingFace endpoint and token substituted in.
|
||||
|
||||
```
|
||||
buyer commissions bonded work ──▶ supplier node ──▶ HuggingFace router
|
||||
(AP3X escrow on Vector) │ (compute backend, hf_ token)
|
||||
▼
|
||||
supplier fulfils the work; the escrow settles on-chain
|
||||
```
|
||||
|
||||
The supplier node runs the inference to **fulfil work a buyer has commissioned
|
||||
under stake**; HuggingFace is simply the compute it uses to produce the result.
|
||||
|
||||
**What this does NOT do:** it does not add creator/model-author work-sharing,
|
||||
change protocol economics, or change how an advert's committed amount is sized.
|
||||
Those are separate, larger pieces of work tracked in the team's internal planning
|
||||
notes. This preset is the fast, low-risk on-ramp that proves the path.
|
||||
|
||||
---
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
1. A HuggingFace account with **Inference Providers access** (a free monthly
|
||||
credit allowance, then usage-based per token — this is HuggingFace's billing
|
||||
to you as the node operator).
|
||||
2. An access token from <https://huggingface.co/settings/tokens> with the
|
||||
**"Make calls to Inference Providers"** permission. It starts with `hf_`.
|
||||
Treat it like a password.
|
||||
3. A funded supplier wallet (~5 AP3X for tx fees + bonds). Generate a seed with
|
||||
`openssl rand -hex 32`.
|
||||
4. The usual supplier deploy prerequisites (Ogmios URL, a publicly reachable
|
||||
HTTPS endpoint for buyers). See `deploy/README.md`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Steps
|
||||
|
||||
### 3.1 Create the env file
|
||||
|
||||
```bash
|
||||
cp supplier/.env.huggingface-chat.example supplier/.env
|
||||
chmod 600 supplier/.env
|
||||
```
|
||||
|
||||
Fill in `SUPPLIER_PRIV_KEY_HEX`, `OPENAI_API_KEY` (your `hf_…` token),
|
||||
`OGMIOS_URL`, and the `ADVERT_*` values. Key settings for this preset:
|
||||
|
||||
| Var | Value | Why |
|
||||
|-----|-------|-----|
|
||||
| `LLM_BACKEND` | `openai` | routes chat through the OpenAI-compatible client |
|
||||
| `OPENAI_BASE_URL` | `https://router.huggingface.co` | HF router; the `/v1/chat/completions` suffix is appended by the client — do **not** add it |
|
||||
| `OPENAI_API_KEY` | `hf_…` | your HF token (Bearer) |
|
||||
| `OPENAI_REASONING` | **unset** | HF 400s on the OpenRouter-only `reasoning` param (see §5) |
|
||||
| `ADVERT_MODEL` | provider-qualified id, e.g. `deepseek-ai/DeepSeek-V3:fastest` | see §4 |
|
||||
|
||||
### 3.2 Choose a model
|
||||
|
||||
HF uses **provider-qualified** model names. The value goes on-chain in
|
||||
`AdvertDatum.model` and is forwarded verbatim to the router:
|
||||
|
||||
- `deepseek-ai/DeepSeek-V3:fastest` — bare id + `:fastest` picks the fastest provider
|
||||
- `deepseek-ai/DeepSeek-V3:together` — pin a specific provider
|
||||
- `openai/gpt-oss-120b` — open-weight, HF-hosted
|
||||
|
||||
Browse models and their providers at
|
||||
<https://huggingface.co/models?inference_provider=all>.
|
||||
|
||||
Because the model is on-chain data, you can advertise any router-supported chat
|
||||
model without changing the supplier code — just post a new advert.
|
||||
|
||||
### 3.3 Smoke-test the token before going on-chain
|
||||
|
||||
Confirm your token + model + base URL work end to end, independent of the chain:
|
||||
|
||||
```bash
|
||||
curl https://router.huggingface.co/v1/chat/completions \
|
||||
-H "Authorization: Bearer $HF_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"deepseek-ai/DeepSeek-V3:fastest",
|
||||
"messages":[{"role":"user","content":"say hi in 3 words"}],
|
||||
"stream":false}'
|
||||
```
|
||||
|
||||
A `choices[0].message.content` in the response means the compute backend is
|
||||
reachable. (Note: **no** `reasoning` field — see §5.)
|
||||
|
||||
### 3.4 Post the advert
|
||||
|
||||
```bash
|
||||
pnpm --filter @marketplace/supplier tx:post-advert \
|
||||
--capability-id llm.text.generate.v1 \
|
||||
--model deepseek-ai/DeepSeek-V3:fastest \
|
||||
--max-output-tokens 512 \
|
||||
--max-processing-ms 60000 \
|
||||
--price-lovelace 2000000 \
|
||||
--endpoint-url https://your-supplier.example.org
|
||||
```
|
||||
|
||||
Copy the printed `<txHash>#0` into `ADVERT_REF` in `supplier/.env`. Use
|
||||
`--dry-run` first to inspect the tx without submitting.
|
||||
|
||||
### 3.5 Boot
|
||||
|
||||
Start read-only first (`LIVE_CHAIN` unset) to confirm the node boots and can
|
||||
reach both Ogmios and the HF router, then set `LIVE_CHAIN=1` and restart to
|
||||
enable real Claim/Submit. Deploy via the supplier compose file as in
|
||||
`deploy/README.md`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Sizing the advert (read before you post one)
|
||||
|
||||
`ADVERT_PRICE_LOVELACE` is the **flat AP3X amount committed in escrow per job**,
|
||||
but the HuggingFace backend meters **per token**. The committed amount is fixed
|
||||
per job while upstream token usage varies, so:
|
||||
|
||||
- Size it for your **worst-case** prompt length, not the average.
|
||||
- Cap output tightly with `--max-output-tokens` — it is your main lever on
|
||||
upstream token usage.
|
||||
- Prefer **small** models here; large models have wide token-usage variance and
|
||||
are harder to size a fixed per-job amount around.
|
||||
- Buyer **input** tokens are not capped on-chain today. Until an input-token
|
||||
ceiling lands, stick to small models or add your own request-size guard.
|
||||
|
||||
This is a pre-existing protocol design point (fixed per-job advert amount vs.
|
||||
variable upstream token usage), not specific to HuggingFace. It is the main thing
|
||||
to account for when sizing an advert.
|
||||
|
||||
---
|
||||
|
||||
## 5. Footgun: `OPENAI_REASONING` must stay unset
|
||||
|
||||
`reasoning:{enabled:false}` is an **OpenRouter-specific** parameter. The HF
|
||||
router **rejects it with HTTP 400 on every request**. When copying an existing
|
||||
OpenRouter env, it is easy to carry `OPENAI_REASONING=off` over by accident.
|
||||
|
||||
The supplier guards against this: `loadConfig` **refuses to boot** if
|
||||
`OPENAI_REASONING` is disabled while `OPENAI_BASE_URL` resolves to
|
||||
`router.huggingface.co`, with a clear error naming the fix
|
||||
(`supplier/src/config.ts`). Just leave `OPENAI_REASONING` unset for HF.
|
||||
|
||||
---
|
||||
|
||||
## 6. Where this sits
|
||||
|
||||
This preset is the multi-model on-ramp that uses the HuggingFace router as a
|
||||
compute backend. Further work items (additional task adapters, advert-sizing
|
||||
refinements, and protocol-economics changes) are tracked separately in the team's
|
||||
internal planning notes, along with the upstream-terms check every operator must
|
||||
complete before pointing a node at a third-party compute backend.
|
||||
@@ -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
|
||||
|
||||
@@ -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]`.
|
||||
|
||||
@@ -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.
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,22 @@
|
||||
* planConsolidate.ts — pure pre-flight planner for the wallet-consolidate flow.
|
||||
*
|
||||
* Given a wallet's current UTxO set + a target collateral lovelace, decide
|
||||
* whether to consolidate (many → 2), split (1 → 2), or skip (already at
|
||||
* {≥collateral, working}). The same downstream tx-builder handles
|
||||
* consolidate and split — only the "reason" tag differs for logging.
|
||||
* whether the wallet needs reshaping or is already usable for script spends.
|
||||
*
|
||||
* Healthy is a STRUCTURAL predicate, not a UTxO count (F7, 2026-08-08):
|
||||
* (a) some pure-lovelace UTxO >= collateralLovelace exists (the collateral
|
||||
* candidate lucid's setCollateral needs), AND
|
||||
* (b) at least MIN_WORKING_LOVELACE sits OUTSIDE that candidate (fee and
|
||||
* spend money that is not the candidate), AND
|
||||
* (c) fragmentation is bounded (utxos.length <= maxUtxos).
|
||||
*
|
||||
* The earlier revision returned `already-healthy` only for EXACTLY 2 UTxOs.
|
||||
* The consolidate tx itself lands 3 (collateral + working + the fee-reserve
|
||||
* remainder as change), so the shape consolidation produced could never
|
||||
* satisfy its own health check — the wallet-health ticker re-consolidated
|
||||
* every tick and burned 0.5 AP3X per pass (116 AP3X in one 24h soak).
|
||||
* The health check also runs BEFORE the low-balance guard now: a wallet that
|
||||
* can already transact must never be told "balance too low to consolidate".
|
||||
*
|
||||
* No lucid / WASM deps so this is unit-testable from a vanilla vitest.
|
||||
*/
|
||||
@@ -26,12 +39,24 @@ export interface PlannedConsolidate {
|
||||
|
||||
export const DEFAULT_COLLATERAL_LOVELACE = 5_000_000n;
|
||||
export const DEFAULT_FEE_RESERVE = 2_000_000n;
|
||||
/** Reshape only when fragmentation passes this; receipt/change UTxOs
|
||||
* accumulating between jobs are fine as long as a candidate exists. */
|
||||
export const DEFAULT_MAX_UTXOS = 6;
|
||||
const MIN_WORKING_LOVELACE = 1_000_000n;
|
||||
|
||||
/** Lovelace of a UTxO iff it carries no native assets; null otherwise. */
|
||||
function pureLovelace(u: UtxoLike): bigint | null {
|
||||
for (const [unit, amount] of Object.entries(u.assets)) {
|
||||
if (unit !== "lovelace" && (amount ?? 0n) > 0n) return null;
|
||||
}
|
||||
return u.assets.lovelace ?? 0n;
|
||||
}
|
||||
|
||||
export function planConsolidate(
|
||||
utxos: UtxoLike[],
|
||||
collateralLovelace: bigint = DEFAULT_COLLATERAL_LOVELACE,
|
||||
feeReserve: bigint = DEFAULT_FEE_RESERVE,
|
||||
maxUtxos: number = DEFAULT_MAX_UTXOS,
|
||||
): PlannedConsolidate {
|
||||
if (utxos.length === 0) {
|
||||
throw new Error("wallet has no UTxOs — fund the address first");
|
||||
@@ -42,6 +67,32 @@ export function planConsolidate(
|
||||
0n,
|
||||
);
|
||||
|
||||
// Structural health check first — reshaping a usable wallet wastes a fee.
|
||||
if (utxos.length <= maxUtxos) {
|
||||
// Smallest qualifying candidate: leave larger UTxOs as working funds.
|
||||
let candidate: bigint | null = null;
|
||||
for (const u of utxos) {
|
||||
const pure = pureLovelace(u);
|
||||
if (pure !== null && pure >= collateralLovelace) {
|
||||
if (candidate === null || pure < candidate) candidate = pure;
|
||||
}
|
||||
}
|
||||
if (candidate !== null) {
|
||||
const workingOutside = totalLovelaceIn - candidate;
|
||||
if (workingOutside >= MIN_WORKING_LOVELACE) {
|
||||
return {
|
||||
inputCount: utxos.length,
|
||||
totalLovelaceIn,
|
||||
collateralOutput: candidate,
|
||||
workingOutput: workingOutside,
|
||||
alreadyHealthy: true,
|
||||
reason: "already-healthy",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reshape needed — only an actual reshape has a funds threshold.
|
||||
const required = collateralLovelace + feeReserve + MIN_WORKING_LOVELACE;
|
||||
if (totalLovelaceIn < required) {
|
||||
throw new Error(
|
||||
@@ -51,23 +102,6 @@ export function planConsolidate(
|
||||
);
|
||||
}
|
||||
|
||||
if (utxos.length === 2) {
|
||||
const lov0 = utxos[0].assets.lovelace ?? 0n;
|
||||
const lov1 = utxos[1].assets.lovelace ?? 0n;
|
||||
const smaller = lov0 < lov1 ? lov0 : lov1;
|
||||
const larger = lov0 < lov1 ? lov1 : lov0;
|
||||
if (smaller >= collateralLovelace) {
|
||||
return {
|
||||
inputCount: 2,
|
||||
totalLovelaceIn,
|
||||
collateralOutput: smaller,
|
||||
workingOutput: larger,
|
||||
alreadyHealthy: true,
|
||||
reason: "already-healthy",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const workingOutput = totalLovelaceIn - collateralLovelace - feeReserve;
|
||||
|
||||
return {
|
||||
|
||||
7
patches/libsodium-wrappers-sumo@0.7.16.patch
Normal file
7
patches/libsodium-wrappers-sumo@0.7.16.patch
Normal file
File diff suppressed because one or more lines are too long
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
|
||||
@@ -6,3 +6,6 @@ packages:
|
||||
- "indexer"
|
||||
- "indexer-ui"
|
||||
- "wallet-monitor"
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
# supplier/.env.huggingface-chat.example
|
||||
#
|
||||
# Preset for a chat supplier that uses the HuggingFace Inference Providers router
|
||||
# (https://router.huggingface.co) as its COMPUTE BACKEND to fulfil bonded
|
||||
# inference work commissioned on Vector. It is the OpenAI-compatible on-ramp:
|
||||
# the router is OpenAI-compatible, so this reuses the existing LLM_BACKEND=openai
|
||||
# path with NO code changes — it is config only.
|
||||
#
|
||||
# Copy to supplier/.env on the host (chmod 600) and fill in real values.
|
||||
# .env is gitignored — NEVER commit a populated copy.
|
||||
#
|
||||
# Runbook: docs/HUGGINGFACE_ROUTER_SETUP.md
|
||||
#
|
||||
# To generate a fresh Ed25519 wallet seed: openssl rand -hex 32
|
||||
# Lines marked SECRET must never be shared, logged, or committed.
|
||||
|
||||
# ─── Required ─────────────────────────────────────────────────────────────
|
||||
|
||||
# 64-char hex Ed25519 private key seed for this supplier's wallet. SECRET.
|
||||
SUPPLIER_PRIV_KEY_HEX=PASTE_64_HEX_CHARS_HERE
|
||||
|
||||
# Ogmios WebSocket URL. Testnet shown; use the mainnet URL for NETWORK_ID=1:
|
||||
# testnet: wss://ogmios.vector.testnet.apexfusion.org
|
||||
# mainnet: wss://ogmios.vector.mainnet.apexfusion.org
|
||||
OGMIOS_URL=wss://ogmios.vector.testnet.apexfusion.org
|
||||
|
||||
# OutputReference of this supplier's on-chain advertisement UTxO.
|
||||
# Format "<64-hex-tx-hash>#<int>". Post it first (see tx:post-advert below),
|
||||
# then paste the printed "<txHash>#0" here.
|
||||
ADVERT_REF=PASTE_TXHASH_HERE#0
|
||||
|
||||
# Cardano network ID. "0" = testnet, "1" = mainnet. Keep "0" until you have
|
||||
# validated the setup end-to-end on testnet.
|
||||
NETWORK_ID=0
|
||||
|
||||
# ─── Backend: HuggingFace router (OpenAI-compatible) ──────────────────────
|
||||
# Route the chat job runner at the HF Inference Providers router instead of a
|
||||
# local Ollama. The supplier POSTs to ${OPENAI_BASE_URL}/v1/chat/completions.
|
||||
LLM_BACKEND=openai
|
||||
|
||||
# HF router base URL. The supplier appends /v1/chat/completions itself, so do
|
||||
# NOT include the /v1 suffix here.
|
||||
OPENAI_BASE_URL=https://router.huggingface.co
|
||||
|
||||
# HuggingFace access token (starts with "hf_"). Create one at
|
||||
# https://huggingface.co/settings/tokens with "Make calls to Inference
|
||||
# Providers" permission. Forwarded as `Authorization: Bearer <token>`. SECRET.
|
||||
OPENAI_API_KEY=PASTE_HF_TOKEN_HERE
|
||||
|
||||
# First-token latency across hosted providers can exceed local Ollama — keep a
|
||||
# generous request timeout.
|
||||
OPENAI_TIMEOUT_MS=180000
|
||||
|
||||
# ─── IMPORTANT: do NOT set OPENAI_REASONING here ──────────────────────────
|
||||
# `reasoning:{enabled:false}` is an OpenRouter-only param. The HF router
|
||||
# rejects it with HTTP 400 on every request. The supplier refuses to boot if
|
||||
# OPENAI_REASONING is disabled while OPENAI_BASE_URL points at the HF router.
|
||||
# (This is the #1 footgun when copying an OpenRouter env — leave it unset.)
|
||||
# OPENAI_REASONING=
|
||||
|
||||
# Optional hard ceiling forwarded as `max_tokens`. Unset = provider grants the
|
||||
# max available (context − prompt). Keep below the model's context window.
|
||||
# OPENAI_MAX_TOKENS=
|
||||
|
||||
# ─── Enable real chain submissions ────────────────────────────────────────
|
||||
# LIVE_CHAIN: only the literal "1" opts in to real Claim/Submit txs. Leave
|
||||
# unset for a dry run (read-only, no chain writes) while testing.
|
||||
LIVE_CHAIN=
|
||||
|
||||
# HTTP listen port inside the container. Compose maps this to the host.
|
||||
PORT=8080
|
||||
|
||||
# ─── tx:post-advert — run once before first deploy ────────────────────────
|
||||
# Execute (from repo root):
|
||||
# pnpm --filter @marketplace/supplier tx:post-advert \
|
||||
# --capability-id llm.text.generate.v1 \
|
||||
# --model deepseek-ai/DeepSeek-V3:fastest \
|
||||
# --max-output-tokens 512 \
|
||||
# --max-processing-ms 60000 \
|
||||
# --price-lovelace 2000000 \
|
||||
# --endpoint-url https://mp-supplier.vector.testnet.apexfusion.org
|
||||
# Copy the printed "<txHash>#0" into ADVERT_REF above.
|
||||
#
|
||||
# NOTE on --model: HF uses PROVIDER-QUALIFIED model names. The bare id picks a
|
||||
# default provider; a ":<hint>" suffix pins routing:
|
||||
# deepseek-ai/DeepSeek-V3:fastest # cheapest/fastest provider for the model
|
||||
# deepseek-ai/DeepSeek-V3:together # pin a specific provider
|
||||
# openai/gpt-oss-120b # open-weight, HF-hosted
|
||||
# Browse models + providers at https://huggingface.co/models?inference_provider=all
|
||||
# The value lives on-chain in AdvertDatum.model — it is data, not code, so you
|
||||
# can advertise any router-supported model without touching the supplier.
|
||||
ADVERT_CAPABILITY_ID=llm.text.generate.v1
|
||||
ADVERT_MODEL=deepseek-ai/DeepSeek-V3:fastest
|
||||
ADVERT_MAX_OUTPUT_TOKENS=512
|
||||
ADVERT_MAX_PROCESSING_MS=60000
|
||||
|
||||
# Flat AP3X amount committed in escrow per job (1 AP3X = 1_000_000 lovelace).
|
||||
# The HF backend meters PER TOKEN while this committed amount is fixed per job,
|
||||
# so size it for your worst-case prompt length and cap output tightly via
|
||||
# ADVERT_MAX_OUTPUT_TOKENS. See docs/HUGGINGFACE_ROUTER_SETUP.md §4.
|
||||
ADVERT_PRICE_LOVELACE=2000000
|
||||
ADVERT_SUPPLIER_BOND_LOVELACE=1000000
|
||||
ADVERT_BUYER_BOND_LOVELACE=1000000
|
||||
|
||||
# HTTPS endpoint buyers will call (must be publicly reachable).
|
||||
ADVERT_ENDPOINT_URL=https://mp-supplier.vector.testnet.apexfusion.org
|
||||
|
||||
# Optional off-chain detail pointer + its sha256 (leave empty for v1).
|
||||
ADVERT_DETAIL_URI=
|
||||
ADVERT_DETAIL_HASH=
|
||||
|
||||
# ─── Optional: CIP-33 reference scripts (recommended on mainnet) ──────────
|
||||
# ESCROW_REF_UTXO=<txhash>#0
|
||||
# ADVERT_REF_UTXO=<txhash>#1
|
||||
@@ -15,10 +15,7 @@
|
||||
*
|
||||
* Required for CAPABILITY_KIND="chat" + LLM_BACKEND="openai":
|
||||
* OPENAI_BASE_URL — http(s):// URL for an OpenAI-compatible /v1/chat/completions
|
||||
* host. Examples: ChatMock localhost proxy (Codex OAuth),
|
||||
* OpenRouter (https://openrouter.ai/api), or the HuggingFace
|
||||
* Inference Providers router (https://router.huggingface.co).
|
||||
* See docs/HUGGINGFACE_ROUTER_SETUP.md for the HF preset.
|
||||
* host (e.g. ChatMock localhost proxy backed by Codex OAuth).
|
||||
*
|
||||
* Required for CAPABILITY_KIND="tts":
|
||||
* PIPER_URL — http(s):// URL for the openedai-speech-min PiperTTS host
|
||||
@@ -54,15 +51,6 @@ const TX_HASH_RE = /^[0-9a-fA-F]{64}$/;
|
||||
const NON_NEG_INT_RE = /^(?:0|[1-9]\d*)$/;
|
||||
const POS_INT_RE = /^[1-9]\d*$/;
|
||||
|
||||
/** Lowercased hostname of a URL, or "" if it doesn't parse. */
|
||||
function hostnameOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.toLowerCase();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdvertRef {
|
||||
txHash: string;
|
||||
index: number;
|
||||
@@ -281,24 +269,6 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
reasoningRaw === "0" ||
|
||||
reasoningRaw === "no";
|
||||
|
||||
// HuggingFace router guard. The `reasoning:{enabled:false}` param is
|
||||
// OpenRouter-specific; the HF Inference Providers router
|
||||
// (router.huggingface.co) rejects it with HTTP 400 on EVERY request. This is
|
||||
// the easy footgun when copying an OpenRouter env to the HF preset, so fail
|
||||
// fast at boot with a clear message instead of 400-ing every job.
|
||||
// See docs/HUGGINGFACE_ROUTER_SETUP.md.
|
||||
if (
|
||||
llmBackend === "openai" &&
|
||||
openaiReasoningDisabled &&
|
||||
hostnameOf(openaiBaseUrl) === "router.huggingface.co"
|
||||
) {
|
||||
throw new Error(
|
||||
"loadConfig: OPENAI_REASONING must be unset for the HuggingFace router " +
|
||||
"(OPENAI_BASE_URL=https://router.huggingface.co) — it rejects " +
|
||||
"reasoning:{enabled:false} with HTTP 400. Remove OPENAI_REASONING.",
|
||||
);
|
||||
}
|
||||
|
||||
// OPENAI_MAX_TOKENS — optional hard ceiling forwarded as `max_tokens`. Unset
|
||||
// or non-positive omits it (provider grants max available = context − prompt).
|
||||
// Keep it below the model context (kimi 262144) or the provider 400s when
|
||||
|
||||
@@ -7,15 +7,26 @@
|
||||
* and the lane stalls until an operator runs `tx:consolidate-wallet`.
|
||||
*
|
||||
* Two layers of self-healing:
|
||||
* startWalletHealthTicker — periodic background tick (every N ms). When
|
||||
* the supplier is idle, runs runConsolidateWallet. planConsolidate
|
||||
* returns `already-healthy` cheaply for clean 2-UTxO wallets, so this
|
||||
* is a no-op in steady state — no tx, no fee.
|
||||
* startWalletHealthTicker — periodic background tick (every N ms). Runs
|
||||
* runConsolidateWallet; planConsolidate's structural health predicate
|
||||
* short-circuits without a tx when a collateral candidate exists and
|
||||
* fragmentation is bounded. A tick on a healthy wallet costs one UTxO
|
||||
* query and nothing else.
|
||||
* triggerOnFailureConsolidate — fire-and-forget consolidate from the
|
||||
* Claim-failure path. Debounced so back-to-back failures don't queue
|
||||
* multiple consolidates. Returns 503 to the buyer for THIS request; the
|
||||
* next buyer retry (~30-60s later) finds the wallet healthy.
|
||||
*
|
||||
* F7 guards (2026-08-08 incident: a non-idempotent health check made every
|
||||
* tick consolidate, burning 0.5 AP3X per 45s until the wallet hit the
|
||||
* floor — 116 AP3X in one soak). Two independent circuit breakers protect
|
||||
* against ANY future non-convergence:
|
||||
* - consecutive guard: 3 consolidations in a row without ever reaching
|
||||
* `already-healthy` halts wallet-health for this process.
|
||||
* - budget guard: more than 6 consolidations inside 24h halts it too.
|
||||
* A halt logs loudly once; consolidate manually and restart the supplier
|
||||
* to re-arm.
|
||||
*
|
||||
* Wallet-lock discipline: consolidation spends EVERY wallet UTxO, so it runs
|
||||
* inside state.walletMutex — the same mutex that serializes Claims/Submits —
|
||||
* queueing behind any in-flight chain op instead of skipping. Session slots
|
||||
@@ -63,6 +74,29 @@ export interface WalletHealthTicker {
|
||||
const inFlight = new WeakSet<SupplierState>();
|
||||
const lastOnFailureAt = new WeakMap<SupplierState, number>();
|
||||
|
||||
// ── F7 circuit breakers ──────────────────────────────────────────────
|
||||
const MAX_CONSECUTIVE_CONSOLIDATIONS = 3;
|
||||
const MAX_CONSOLIDATIONS_PER_WINDOW = 6;
|
||||
const BUDGET_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface GuardCounters {
|
||||
consecutive: number;
|
||||
windowStart: number;
|
||||
windowCount: number;
|
||||
halted: boolean;
|
||||
}
|
||||
|
||||
const guardCounters = new WeakMap<SupplierState, GuardCounters>();
|
||||
|
||||
function guardsFor(state: SupplierState): GuardCounters {
|
||||
let g = guardCounters.get(state);
|
||||
if (!g) {
|
||||
g = { consecutive: 0, windowStart: Date.now(), windowCount: 0, halted: false };
|
||||
guardCounters.set(state, g);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function defaultLog(line: string): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[wallet-health] ${line}`);
|
||||
@@ -73,6 +107,8 @@ async function consolidateOnce(
|
||||
opts: { collateralLovelace: bigint; awaitTimeoutMs: number; log: (line: string) => void },
|
||||
): Promise<void> {
|
||||
const { state } = deps;
|
||||
const guards = guardsFor(state);
|
||||
if (guards.halted) return; // the halt already logged loudly, once
|
||||
if (inFlight.has(state)) {
|
||||
opts.log("skip: another consolidate already in flight");
|
||||
return;
|
||||
@@ -92,11 +128,36 @@ async function consolidateOnce(
|
||||
}),
|
||||
);
|
||||
if (result.reason === "already-healthy") {
|
||||
guards.consecutive = 0;
|
||||
opts.log("already-healthy");
|
||||
} else {
|
||||
const now = Date.now();
|
||||
if (now - guards.windowStart > BUDGET_WINDOW_MS) {
|
||||
guards.windowStart = now;
|
||||
guards.windowCount = 0;
|
||||
}
|
||||
guards.consecutive += 1;
|
||||
guards.windowCount += 1;
|
||||
opts.log(`consolidated: ${result.reason} txHash=${result.txHash}`);
|
||||
if (guards.consecutive >= MAX_CONSECUTIVE_CONSOLIDATIONS) {
|
||||
guards.halted = true;
|
||||
opts.log(
|
||||
`HALT: ${guards.consecutive} consecutive consolidations without reaching a healthy shape — ` +
|
||||
`wallet-health stopped to protect funds (F7 guard). Investigate planConsolidate vs the ` +
|
||||
`builder's output shape, consolidate manually, restart the supplier to re-arm.`,
|
||||
);
|
||||
} else if (guards.windowCount >= MAX_CONSOLIDATIONS_PER_WINDOW) {
|
||||
guards.halted = true;
|
||||
opts.log(
|
||||
`HALT: consolidation budget exhausted (${guards.windowCount} in 24h) — wallet-health ` +
|
||||
`stopped to protect funds (F7 guard). Restart the supplier to re-arm.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Neither healthy nor consolidated (e.g. balance too low): no fee was
|
||||
// spent, so the guards don't move — but nothing converged either, so
|
||||
// the consecutive counter is NOT reset.
|
||||
opts.log(`consolidate failed: ${(err as Error).message}`);
|
||||
} finally {
|
||||
inFlight.delete(state);
|
||||
@@ -143,8 +204,9 @@ export function triggerOnFailureConsolidate(
|
||||
});
|
||||
}
|
||||
|
||||
/** Test-only: reset the in-flight + debounce maps between tests. */
|
||||
/** Test-only: reset the in-flight + debounce + guard maps between tests. */
|
||||
export function _resetWalletHealthForTests(state: SupplierState): void {
|
||||
inFlight.delete(state);
|
||||
lastOnFailureAt.delete(state);
|
||||
guardCounters.delete(state);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
d8798d581cabcdef0123456789abcdef0123456789abcdef0123456789abcdef01546c6c6d2e746578742e67656e65726174652e76314c7177656e322e353a302e356219020019ea601b00000000001e84801b00000000000f42401b00000000000f4240581f68747470733a2f2f737570706c6965722e6578616d706c652e636f6d2f76315835697066733a2f2f516d597741504a7a7635435a736e4136323573335866326e656d7459675070486457457a37396f6a576e506264475820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaafb4279667e84f00000d87980
|
||||
d8798d581cabcdef0123456789abcdef0123456789abcdef0123456789abcdef01546c6c6d2e746578742e67656e65726174652e76314c7177656e322e353a302e35621b00000000000002001b000000000000ea601b00000000001e84801b00000000000f42401b00000000000f4240581f68747470733a2f2f737570706c6965722e6578616d706c652e636f6d2f76315835697066733a2f2f516d597741504a7a7635435a736e4136323573335866326e656d7459675070486457457a37396f6a576e506264475820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1b0000019667e84f00d87980
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -184,46 +184,6 @@ describe("loadConfig() — LIVE_CHAIN parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── loadConfig — HuggingFace router preset (W1) ────────────────────────────
|
||||
// The HF Inference Providers router (router.huggingface.co) is OpenAI-compatible
|
||||
// and reuses LLM_BACKEND=openai, but rejects the OpenRouter-specific
|
||||
// reasoning:{enabled:false} param with HTTP 400 — so OPENAI_REASONING must stay
|
||||
// unset for it. loadConfig fails fast at boot to catch the copy-from-OpenRouter
|
||||
// footgun. See docs/HUGGINGFACE_ROUTER_SETUP.md.
|
||||
|
||||
describe("loadConfig() — HuggingFace router guard", () => {
|
||||
const hfEnv = (over: Record<string, string> = {}) => ({
|
||||
...buildSampleEnv(),
|
||||
LLM_BACKEND: "openai",
|
||||
OPENAI_BASE_URL: "https://router.huggingface.co",
|
||||
OPENAI_API_KEY: "hf_sampletoken",
|
||||
...over,
|
||||
});
|
||||
|
||||
it("accepts the HF router with OPENAI_REASONING unset", () => {
|
||||
const cfg = loadConfig(hfEnv());
|
||||
expect(cfg.openaiBaseUrl).toBe("https://router.huggingface.co");
|
||||
expect(cfg.llmBackend).toBe("openai");
|
||||
expect(cfg.openaiReasoningDisabled).toBe(false);
|
||||
});
|
||||
|
||||
it("throws when OPENAI_REASONING=off is set against the HF router", () => {
|
||||
expect(() => loadConfig(hfEnv({ OPENAI_REASONING: "off" }))).toThrow(
|
||||
/OPENAI_REASONING must be unset for the HuggingFace router/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("still allows OPENAI_REASONING=off for non-HF openai backends (e.g. OpenRouter)", () => {
|
||||
const cfg = loadConfig({
|
||||
...buildSampleEnv(),
|
||||
LLM_BACKEND: "openai",
|
||||
OPENAI_BASE_URL: "https://openrouter.ai/api",
|
||||
OPENAI_REASONING: "off",
|
||||
});
|
||||
expect(cfg.openaiReasoningDisabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parseAdvertRef ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("parseAdvertRef()", () => {
|
||||
|
||||
@@ -242,3 +242,115 @@ describe("walletHealth — triggerOnFailureConsolidate", () => {
|
||||
expect(state.snapshot().status).toBe("working");
|
||||
});
|
||||
});
|
||||
|
||||
describe("walletHealth — F7 circuit breakers", () => {
|
||||
let state: SupplierState;
|
||||
let logLines: string[];
|
||||
let log: (line: string) => void;
|
||||
let consolidate: ReturnType<typeof vi.fn> & ConsolidateFn;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
state = new SupplierState();
|
||||
_resetWalletHealthForTests(state);
|
||||
logLines = [];
|
||||
log = (line) => logLines.push(line);
|
||||
consolidate = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("halts after 3 consecutive consolidations that never reach healthy", async () => {
|
||||
// The 2026-08-07 pump: every tick consolidates, none lands healthy.
|
||||
consolidate.mockResolvedValue(consolidatedResult);
|
||||
|
||||
const ticker = startWalletHealthTicker(
|
||||
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
||||
{ intervalMs: 1_000, log },
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(3));
|
||||
expect(logLines.some((l) => l.includes("HALT") && l.includes("consecutive"))).toBe(true);
|
||||
|
||||
// Two more ticks: the breaker holds, no further txs.
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(consolidate).toHaveBeenCalledTimes(3);
|
||||
|
||||
ticker.stop();
|
||||
});
|
||||
|
||||
it("an already-healthy result resets the consecutive counter", async () => {
|
||||
consolidate
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(healthyResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValue(healthyResult);
|
||||
|
||||
const ticker = startWalletHealthTicker(
|
||||
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
||||
{ intervalMs: 1_000, log },
|
||||
);
|
||||
|
||||
// 6 ticks: c,c,h,c,c,h — never 3 consecutive, so never halted.
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(6));
|
||||
expect(logLines.some((l) => l.includes("HALT"))).toBe(false);
|
||||
|
||||
ticker.stop();
|
||||
});
|
||||
|
||||
it("halts when the 24h consolidation budget is exhausted", async () => {
|
||||
// Interleave healthy results so the consecutive guard never fires:
|
||||
// c,c,h,c,c,h,c,c -> 6 consolidations inside the window at tick 8.
|
||||
consolidate
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(healthyResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(healthyResult)
|
||||
.mockResolvedValue(consolidatedResult);
|
||||
|
||||
const ticker = startWalletHealthTicker(
|
||||
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
||||
{ intervalMs: 1_000, log },
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(8_000);
|
||||
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(8));
|
||||
expect(logLines.some((l) => l.includes("HALT") && l.includes("budget"))).toBe(true);
|
||||
|
||||
// Breaker holds on subsequent ticks.
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(consolidate).toHaveBeenCalledTimes(8);
|
||||
|
||||
ticker.stop();
|
||||
});
|
||||
|
||||
it("failed consolidations neither trip nor reset the breakers", async () => {
|
||||
// Errors spend no fee: they must not count as consolidations, but they
|
||||
// must not clear the consecutive count either.
|
||||
consolidate
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockRejectedValueOnce(new Error("balance too low to consolidate"))
|
||||
.mockResolvedValue(consolidatedResult);
|
||||
|
||||
const ticker = startWalletHealthTicker(
|
||||
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
||||
{ intervalMs: 1_000, log },
|
||||
);
|
||||
|
||||
// c,c,error,c -> the 4th call is the 3rd consecutive consolidation.
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(4));
|
||||
expect(logLines.some((l) => l.includes("HALT") && l.includes("consecutive"))).toBe(true);
|
||||
|
||||
ticker.stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
|
||||
@@ -64,14 +64,17 @@ describe("planConsolidate", () => {
|
||||
expect(plan.workingOutput).toBe(8_650_000n);
|
||||
});
|
||||
|
||||
it("consolidates a 2-UTxO wallet where the smaller is below collateral threshold", () => {
|
||||
// Both fit budget but smaller (3 AP3X) is below 5 AP3X — needs reshape.
|
||||
it("accepts a 2-UTxO wallet where the LARGER is the collateral candidate", () => {
|
||||
// {3, 15}: the 15 qualifies as a pure >=5 candidate and the 3 covers
|
||||
// fees — this wallet can run script txs as-is. The old exact-2-slot
|
||||
// convention reshaped it for nothing (0.5 AP3X per pass).
|
||||
const utxos = [u(3_000_000n), u(15_000_000n)];
|
||||
const plan = planConsolidate(utxos);
|
||||
|
||||
expect(plan.reason).toBe("consolidate");
|
||||
expect(plan.alreadyHealthy).toBe(false);
|
||||
expect(plan.inputCount).toBe(2);
|
||||
expect(plan.reason).toBe("already-healthy");
|
||||
expect(plan.alreadyHealthy).toBe(true);
|
||||
expect(plan.collateralOutput).toBe(15_000_000n);
|
||||
expect(plan.workingOutput).toBe(3_000_000n);
|
||||
});
|
||||
|
||||
it("throws on 0 UTxOs", () => {
|
||||
@@ -93,3 +96,93 @@ describe("planConsolidate", () => {
|
||||
expect(plan.workingOutput).toBe(20_000_000n - 10_000_000n - DEFAULT_FEE_RESERVE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planConsolidate — structural health predicate (F7)", () => {
|
||||
// The tx buildConsolidateWalletTx actually lands: {collateral 5.0,
|
||||
// working = total - 5.0 - 2.0 reserve, change = reserve - fee}. The fee
|
||||
// observed on mainnet is exactly 501,628, so the change is 1,498,372.
|
||||
const MAINNET_CONSOLIDATE_FEE = 501_628n;
|
||||
|
||||
it("REGRESSION: the exact shape consolidation produces is healthy", () => {
|
||||
// This is the loop that burned 116 AP3X in the 2026-08-07 soak: the
|
||||
// planner called its own output shape unhealthy and re-consolidated
|
||||
// every 45s tick. Feed it precisely what the builder lands.
|
||||
const utxos = [
|
||||
u(5_000_000n),
|
||||
u(91_000_000n),
|
||||
u(DEFAULT_FEE_RESERVE - MAINNET_CONSOLIDATE_FEE), // 1,498,372 change
|
||||
];
|
||||
const plan = planConsolidate(utxos);
|
||||
|
||||
expect(plan.reason).toBe("already-healthy");
|
||||
expect(plan.alreadyHealthy).toBe(true);
|
||||
expect(plan.collateralOutput).toBe(5_000_000n);
|
||||
});
|
||||
|
||||
it("is idempotent: applying the builder's output model to any plan yields healthy", () => {
|
||||
const fragmented = [u(2_400_750n), u(4_100_000n), u(1_679_301n), u(1_679_301n)];
|
||||
const plan = planConsolidate(fragmented);
|
||||
expect(plan.alreadyHealthy).toBe(false);
|
||||
|
||||
// Model of what buildConsolidateWalletTx lands for this plan.
|
||||
const afterConsolidation = [
|
||||
u(plan.collateralOutput),
|
||||
u(plan.workingOutput),
|
||||
u(DEFAULT_FEE_RESERVE - MAINNET_CONSOLIDATE_FEE),
|
||||
];
|
||||
const replan = planConsolidate(afterConsolidation);
|
||||
|
||||
expect(replan.reason).toBe("already-healthy");
|
||||
expect(replan.alreadyHealthy).toBe(true);
|
||||
});
|
||||
|
||||
it("still consolidates when fragmentation exceeds the max UTxO bound", () => {
|
||||
// Candidate present, but 7 UTxOs > DEFAULT_MAX_UTXOS (6).
|
||||
const utxos = [
|
||||
u(5_000_000n), u(1_100_000n), u(1_100_000n), u(1_100_000n),
|
||||
u(1_100_000n), u(1_100_000n), u(1_100_000n),
|
||||
];
|
||||
const plan = planConsolidate(utxos);
|
||||
expect(plan.reason).toBe("consolidate");
|
||||
});
|
||||
|
||||
it("still consolidates when no pure UTxO reaches the collateral threshold", () => {
|
||||
// The 2026-08-07 batch-stall shape: fragments only, max 4,498,372.
|
||||
const utxos = [u(4_498_372n), u(3_098_372n), u(1_498_372n)];
|
||||
const plan = planConsolidate(utxos);
|
||||
expect(plan.reason).toBe("consolidate");
|
||||
expect(plan.alreadyHealthy).toBe(false);
|
||||
});
|
||||
|
||||
it("a UTxO 1,628 lovelace short of collateral is not a candidate", () => {
|
||||
// Seen live: 4,998,372 (= 5.0 minus a 501,628 fee twice-shuffled).
|
||||
const utxos = [u(4_998_372n), u(9_000_000n - 4_998_372n)];
|
||||
const plan = planConsolidate(utxos);
|
||||
expect(plan.reason).toBe("consolidate");
|
||||
});
|
||||
|
||||
it("a UTxO carrying native assets is not a collateral candidate", () => {
|
||||
const withToken: UtxoLike = {
|
||||
assets: { lovelace: 6_000_000n, "deadbeef.72737456": 1n },
|
||||
};
|
||||
const utxos = [withToken, u(4_000_000n)];
|
||||
const plan = planConsolidate(utxos);
|
||||
expect(plan.reason).toBe("consolidate");
|
||||
});
|
||||
|
||||
it("healthy shape below the reshape threshold is healthy, not an error", () => {
|
||||
// {5.0, 2.5} = 7.5 total: under the 8.0 reshape minimum but perfectly
|
||||
// able to run script txs. The old order threw "balance too low" here
|
||||
// on every tick (1,744 log lines in the soak).
|
||||
const utxos = [u(5_000_000n), u(2_500_000n)];
|
||||
const plan = planConsolidate(utxos);
|
||||
expect(plan.reason).toBe("already-healthy");
|
||||
});
|
||||
|
||||
it("candidate with only dust outside it needs a reshape it cannot afford", () => {
|
||||
// {5.0, 0.4}: no working money outside the candidate, and 5.4 total is
|
||||
// below the reshape minimum — loud error, funds needed.
|
||||
const utxos = [u(5_000_000n), u(400_000n)];
|
||||
expect(() => planConsolidate(utxos)).toThrow(/balance too low/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user