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>
|
<dd className="text-right">{est.mapCalls}</dd>
|
||||||
<dt>Reduce calls</dt>
|
<dt>Reduce calls</dt>
|
||||||
<dd className="text-right">{est.reduceCalls}</dd>
|
<dd className="text-right">{est.reduceCalls}</dd>
|
||||||
<dt className="font-medium">Total paid calls</dt>
|
<dt className="font-medium">Total escrowed calls</dt>
|
||||||
<dd className="text-right font-medium">{est.totalCalls}</dd>
|
<dd className="text-right font-medium">{est.totalCalls}</dd>
|
||||||
<dt className="font-medium">Est. max cost</dt>
|
<dt className="font-medium">Est. max cost</dt>
|
||||||
<dd className="text-right font-medium">{est.totalAp3x} AP3X</dd>
|
<dd className="text-right font-medium">{est.totalAp3x} AP3X</dd>
|
||||||
@@ -126,7 +126,7 @@ export default function JobEstimate({
|
|||||||
disabled={blocked || starting}
|
disabled={blocked || starting}
|
||||||
className="rounded bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:bg-gray-400"
|
className="rounded bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:bg-gray-400"
|
||||||
>
|
>
|
||||||
{starting ? "Starting…" : `Proceed & pay (~${est.totalAp3x} AP3X)`}
|
{starting ? "Starting…" : `Proceed (locks ~${est.totalAp3x} AP3X in escrow)`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function PdfUploadForm({ onUploaded }: { onUploaded: (info: Uploa
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Upload a text-based PDF book. The buyer will chunk it and pay marketplace
|
Upload a text-based PDF book. The buyer will chunk it and commission marketplace
|
||||||
suppliers to summarize each chunk, then synthesize a full-book summary.
|
suppliers to summarize each chunk, then synthesize a full-book summary.
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export default function ApiKeys() {
|
|||||||
<h1 className="text-2xl font-semibold">API Keys</h1>
|
<h1 className="text-2xl font-semibold">API Keys</h1>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Generate an OpenAI-compatible API key for the marketplace gateway. Each key has its own
|
Generate an OpenAI-compatible API key for the marketplace gateway. Each key has its own
|
||||||
custodial wallet — fund its deposit address with AP3X, then point any OpenAI SDK at{" "}
|
gateway-managed wallet (the gateway holds this wallet's key) - fund its deposit address with AP3X, then point any OpenAI SDK at{" "}
|
||||||
<code className="font-mono text-xs">{baseUrl}</code>.
|
<code className="font-mono text-xs">{baseUrl}</code>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export default function BookSummarizer() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Book Summarizer</h1>
|
<h1 className="text-2xl font-semibold">Book Summarizer</h1>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
Upload a PDF → pay suppliers to summarize each chunk → get a full-book summary.
|
Upload a PDF → commission suppliers to summarize each chunk → get a full-book summary.
|
||||||
Jobs keep running if you navigate away; reopen them below.
|
Jobs keep running if you navigate away; reopen them below.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* buyer/src/ui/pages/PendingReceipts.tsx — list of currently-Submitted escrows
|
* buyer/src/ui/pages/PendingReceipts.tsx — list of currently-Submitted escrows
|
||||||
* for this buyer, with an "Accept & Pay" action per row.
|
* for this buyer, with an "Accept & Settle" action per row.
|
||||||
*
|
*
|
||||||
* Data flow:
|
* Data flow:
|
||||||
* GET /v1/pending-receipts → buyer-app server proxies indexer /escrows?buyer=<pkh>
|
* GET /v1/pending-receipts → buyer-app server proxies indexer /escrows?buyer=<pkh>
|
||||||
@@ -190,7 +190,7 @@ export default function PendingReceipts() {
|
|||||||
<th className="px-2 py-2">Submitted</th>
|
<th className="px-2 py-2">Submitted</th>
|
||||||
<th className="px-2 py-2">Supplier</th>
|
<th className="px-2 py-2">Supplier</th>
|
||||||
<th className="px-2 py-2">Capability</th>
|
<th className="px-2 py-2">Capability</th>
|
||||||
<th className="px-2 py-2">Payment</th>
|
<th className="px-2 py-2">Escrowed</th>
|
||||||
<th className="px-2 py-2">Window left</th>
|
<th className="px-2 py-2">Window left</th>
|
||||||
<th className="px-2 py-2">Action</th>
|
<th className="px-2 py-2">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -254,7 +254,7 @@ export default function PendingReceipts() {
|
|||||||
: "bg-indigo-600 text-white hover:bg-indigo-700")
|
: "bg-indigo-600 text-white hover:bg-indigo-700")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isAccepting ? "accepting…" : expired ? "expired" : "Accept & Pay"}
|
{isAccepting ? "accepting…" : expired ? "expired" : "Accept & Settle"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ function LifecycleCard({ lc }: { lc: Lifecycle }): JSX.Element {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="text-gray-500">·</span>
|
<span className="text-gray-500">·</span>
|
||||||
<span>{fmtAda(lc.payment_lovelace)} payment</span>
|
<span>{fmtAda(lc.payment_lovelace)} escrowed</span>
|
||||||
<span className="text-gray-500">·</span>
|
<span className="text-gray-500">·</span>
|
||||||
<span>{fmtAda(lc.buyer_bond_lovelace)} bond</span>
|
<span>{fmtAda(lc.buyer_bond_lovelace)} bond</span>
|
||||||
<span className="text-gray-500">·</span>
|
<span className="text-gray-500">·</span>
|
||||||
|
|||||||
@@ -59,8 +59,18 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.docker.network=traefik-net"
|
- "traefik.docker.network=traefik-net"
|
||||||
|
# This host is NOT Cloudflare-proxied: traefik terminates TLS itself with
|
||||||
|
# a Let's Encrypt cert (resolver defined in /root/traefik/traefik.yml on
|
||||||
|
# the box). The :80 router only redirects; ACME HTTP-01 challenges are
|
||||||
|
# answered by traefik before the redirect applies.
|
||||||
- "traefik.http.routers.mp-gateway-mainnet.entrypoints=web"
|
- "traefik.http.routers.mp-gateway-mainnet.entrypoints=web"
|
||||||
- "traefik.http.routers.mp-gateway-mainnet.rule=Host(`api.marketplace.vector.apexfusion.org`)"
|
- "traefik.http.routers.mp-gateway-mainnet.rule=Host(`api.marketplace.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-gateway-mainnet.middlewares=mp-gateway-mainnet-https"
|
||||||
|
- "traefik.http.middlewares.mp-gateway-mainnet-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.mp-gateway-mainnet-https.redirectscheme.permanent=true"
|
||||||
|
- "traefik.http.routers.mp-gateway-mainnet-tls.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mp-gateway-mainnet-tls.rule=Host(`api.marketplace.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-gateway-mainnet-tls.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.mp-gateway-mainnet.loadbalancer.server.port=8080"
|
- "traefik.http.services.mp-gateway-mainnet.loadbalancer.server.port=8080"
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
|
|||||||
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
|
## 1. Goal and scope
|
||||||
|
|
||||||
**What this is.** A two-sided marketplace where buyers (agents or humans) submit prompts to suppliers running inference on their own hardware, pay with AP3X via on-chain bonded escrow on Vector L2, and receive signed-receipt responses. The primary validation goal is **technical feasibility + lifecycle** ("can it be done end-to-end"), not demand modelling or economic stress testing.
|
**What this is.** A two-sided marketplace where buyers (agents or humans) submit prompts to suppliers running inference on their own hardware, with each job settled in AP3X through on-chain bonded escrow on Vector L2, and receive signed-receipt responses. The primary validation goal is **technical feasibility + lifecycle** ("can it be done end-to-end"), not demand modelling or economic stress testing.
|
||||||
|
|
||||||
**What v1 is NOT.**
|
**What v1 is NOT.**
|
||||||
- Not agentic on the supplier side. Suppliers answer single prompts. Buyer agents drive multiple prompts.
|
- Not agentic on the supplier side. Suppliers answer single prompts. Buyer agents drive multiple prompts.
|
||||||
- Not LLM-only in design. Capability model is open from day 1 (whisper / kokoro / image generation are planned siblings), but the first supplier is an LLM.
|
- Not LLM-only in design. Capability model is open from day 1 (whisper / kokoro / image generation are planned siblings), but the first supplier is an LLM.
|
||||||
- Not multi-slot. Single-slot per supplier process. Multi-capability operators run multiple processes.
|
- Not multi-slot. Single-slot per supplier process. Multi-capability operators run multiple processes.
|
||||||
- Not multi-chain. Vector testnet first, mainnet later. Masumi / Sokosumi / USDM is target #2 (adapter, not fork).
|
- Not multi-chain. Vector only (testnet and mainnet).
|
||||||
- Not disputed. M1 ships happy-path escrow only. Module-1 disputes wire in M2.
|
- Not disputed. M1 ships happy-path escrow only. Module-1 disputes wire in M2.
|
||||||
- Not streaming. `stream: true` is rejected by suppliers.
|
- Not streaming. `stream: true` is rejected by suppliers.
|
||||||
- Not tool-calling. `tools` / `tool_choice` / `functions` are rejected by suppliers.
|
- Not tool-calling. `tools` / `tool_choice` / `functions` are rejected by suppliers.
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
| # | Decision |
|
| # | Decision |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 1 | Payment token: **AP3X** (Vector testnet, later mainnet). Masumi = USDM, target #2. |
|
| 1 | Settlement asset: **AP3X**, bonded in escrow; jobs settle in AP3X on accepted work. |
|
||||||
| 2 | v1 scope: **Option C** — happy-path escrow only, no disputes. Module-1 wiring is milestone 2. |
|
| 2 | v1 scope: **Option C** — happy-path escrow only, no disputes. Module-1 wiring is milestone 2. |
|
||||||
| 3 | Module-1 contract source: TBD — to be located or restored before M2. Not blocking M0–M1. |
|
| 3 | Module-1 contract source: TBD — to be located or restored before M2. Not blocking M0–M1. |
|
||||||
| 4 | Evidence: **signed receipt** `{prompt_hash, response_hash, model, tokens, wallclock}` + Ed25519 supplier signature. Hash on-chain, plaintext off-chain. |
|
| 4 | Evidence: **signed receipt** `{prompt_hash, response_hash, model, tokens, wallclock}` + Ed25519 supplier signature. Hash on-chain, plaintext off-chain. |
|
||||||
@@ -283,7 +283,6 @@ Malformed datum · escrow to wrong script · supplier claims escrow addressed to
|
|||||||
| **M1** | Happy-path end-to-end, Hetzner CPU supplier | 2–3 wks | `qwen2.5:0.5b` on Hetzner, buyer posts → supplier claims → submits → buyer accepts; full Vector testnet lifecycle; indexer serves discovery |
|
| **M1** | Happy-path end-to-end, Hetzner CPU supplier | 2–3 wks | `qwen2.5:0.5b` on Hetzner, buyer posts → supplier claims → submits → buyer accepts; full Vector testnet lifecycle; indexer serves discovery |
|
||||||
| **M2** | Module-1 dispute wiring | 2–3 wks post-M1 | Contract source located; supplier=claimer / buyer=auditor mapping; Dispute state; evidence URI; mainnet-dry-run lifecycle |
|
| **M2** | Module-1 dispute wiring | 2–3 wks post-M1 | Contract source located; supplier=claimer / buyer=auditor mapping; Dispute state; evidence URI; mainnet-dry-run lifecycle |
|
||||||
| **M3** | Capability expansion + local lab | 2–4 wks (may overlap M2) | `speech.transcribe.v1` via Whisper live; local-lab GPU supplier with larger model; heterogeneous discovery validated |
|
| **M3** | Capability expansion + local lab | 2–4 wks (may overlap M2) | `speech.transcribe.v1` via Whisper live; local-lab GPU supplier with larger model; heterogeneous discovery validated |
|
||||||
| **M4** | Masumi / Sokosumi adapter | later | MIP-003 HTTP shim; MIP-002 NFT registration; USDM payment path; Sokosumi listing |
|
|
||||||
|
|
||||||
## 9. Open follow-ups (not blocking M0 / M1)
|
## 9. Open follow-ups (not blocking M0 / M1)
|
||||||
|
|
||||||
@@ -310,5 +309,3 @@ Malformed datum · escrow to wrong script · supplier claims escrow addressed to
|
|||||||
- Apex-dashboard CBOR decoders: `apex-dashboard/server/cbor-decoder.ts`
|
- Apex-dashboard CBOR decoders: `apex-dashboard/server/cbor-decoder.ts`
|
||||||
- Apex-dashboard test discipline: `apex-dashboard/tests/`
|
- Apex-dashboard test discipline: `apex-dashboard/tests/`
|
||||||
- Prior Python reference (AP3X buyer/supplier): `./docs/buyer/`, `./docs/supplier/`
|
- Prior Python reference (AP3X buyer/supplier): `./docs/buyer/`, `./docs/supplier/`
|
||||||
- Masumi docs: https://docs.masumi.network/
|
|
||||||
- MIP-003 spec: https://docs.masumi.network/mips/_mip-003
|
|
||||||
|
|||||||
@@ -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,11 +1,11 @@
|
|||||||
# Vector Buyer App
|
# Vector Buyer App
|
||||||
|
|
||||||
A self-contained app for purchasing AI compute on the Vector L2 testnet (Cardano). You submit prompts, pay with AP3X tokens via on-chain escrow, and receive LLM responses from supplier nodes.
|
A self-contained app for commissioning AI compute on the Vector L2 testnet. You submit prompts, bond AP3X in on-chain escrow, and receive LLM responses from supplier nodes; the escrow settles in AP3X on accepted work.
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
1. You type a **prompt** and set a **payment** amount in the web UI
|
1. You type a **prompt** and set a **price** in the web UI
|
||||||
2. The app posts an **escrow transaction** on-chain, locking your payment + bond
|
2. The app posts an **escrow transaction** on-chain, locking price + bond
|
||||||
3. The app sends the task to a **supplier node** via HTTP
|
3. The app sends the task to a **supplier node** via HTTP
|
||||||
4. The supplier calls an LLM, **claims** the escrow, and **submits** the result hash on-chain
|
4. The supplier calls an LLM, **claims** the escrow, and **submits** the result hash on-chain
|
||||||
5. You see the response in the UI and can **accept** to release payment, or **dispute**
|
5. You see the response in the UI and can **accept** to release payment, or **dispute**
|
||||||
@@ -193,8 +193,8 @@ If you accept the result, you get your 1 AP3X bond back. Total cost = payment +
|
|||||||
|
|
||||||
- **Never share your .env file** - it contains your wallet seed phrase
|
- **Never share your .env file** - it contains your wallet seed phrase
|
||||||
- **Never share your data/ folder** - it contains task history
|
- **Never share your data/ folder** - it contains task history
|
||||||
- The seed phrase controls your wallet funds. Back it up securely.
|
- The seed phrase controls your wallet. Back it up securely.
|
||||||
- Escrow protects you: the supplier cannot take your funds without delivering a result
|
- Escrow protects you: the supplier cannot take your escrowed value without delivering a result
|
||||||
- You have a 10-minute dispute window after receiving a result
|
- You have a 10-minute dispute window after receiving a result
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ The audit confirmed 39 holes in the first draft (10 distinct themes). This docum
|
|||||||
| One-shot | `/openai/v1/chat/completions` → `llm.text.generate.v1`, 1 escrow/call, buffered (+ pseudo-stream) |
|
| One-shot | `/openai/v1/chat/completions` → `llm.text.generate.v1`, 1 escrow/call, buffered (+ pseudo-stream) |
|
||||||
| Chat session | `/openai/v1/chat/sessions*` → `llm.chat.v1`, 1 escrow/**session**, real SSE per turn, settle at close |
|
| Chat session | `/openai/v1/chat/sessions*` → `llm.chat.v1`, 1 escrow/**session**, real SSE per turn, settle at close |
|
||||||
| Routing | match `model` + required capability; pick an available supplier (status `free`\|`unknown`); **not cheapest**; fallback to next match on busy/offline |
|
| Routing | match `model` + required capability; pick an available supplier (status `free`\|`unknown`); **not cheapest**; fallback to next match on busy/offline |
|
||||||
| Pricing | cost = matched supplier `price_lovelace`; **required balance = price + buyer_bond + supplier_bond + 5 ADA collateral + fee reserve** |
|
| Pricing | cost = matched supplier `price_lovelace`; **required balance = price + buyer_bond + supplier_bond + 5 AP3X collateral + fee reserve** |
|
||||||
| Wallet health | gateway **auto-consolidates** each wallet to `{5-ADA collateral, working}` after settle / on a tick |
|
| Wallet health | gateway **auto-consolidates** each wallet to `{5-AP3X collateral, working}` after settle / on a tick |
|
||||||
| Settlement | resolve the **Submitted** ref via indexer, `acceptResult`, **await confirm** before returning success; sweeper is backstop |
|
| Settlement | resolve the **Submitted** ref via indexer, `acceptResult`, **await confirm** before returning success; sweeper is backstop |
|
||||||
| Sweeper | state-aware: Open/Claimed past `deliver_by` → reclaim; Submitted → retry Accept in-window; never blind-reclaim Submitted |
|
| Sweeper | state-aware: Open/Claimed past `deliver_by` → reclaim; Submitted → retry Accept in-window; never blind-reclaim Submitted |
|
||||||
| Response | OpenAI object `{id, object, created, model, choices, usage}` + `x_vector:{receipt, receipt_signature, escrow_ref}`; full streaming chunk transform + final usage chunk |
|
| Response | OpenAI object `{id, object, created, model, choices, usage}` + `x_vector:{receipt, receipt_signature, escrow_ref}`; full streaming chunk transform + final usage chunk |
|
||||||
@@ -41,7 +41,7 @@ New standalone pnpm package `gateway/` (add to `pnpm-workspace.yaml`). Boots **o
|
|||||||
- SDK methods: `discoverSuppliers`, `submitPrompt` (`Marketplace.ts:242`, verifies receipt but **does not settle**), `startChat`/`endChat` (chat session; `endChat` returns `acceptedRef` and awaits Accept), `acceptResult`, `reclaim`.
|
- SDK methods: `discoverSuppliers`, `submitPrompt` (`Marketplace.ts:242`, verifies receipt but **does not settle**), `startChat`/`endChat` (chat session; `endChat` returns `acceptedRef` and awaits Accept), `acceptResult`, `reclaim`.
|
||||||
- Escrow value/bonds: buyer funds `totalLocked = price + buyer_bond + supplier_bond` at PostEscrow — `packages/shared/src/tx/escrow/postEscrow.ts:132-200`. On Accept supplier gets `payment+supplier_bond`, buyer gets `buyer_bond` back.
|
- Escrow value/bonds: buyer funds `totalLocked = price + buyer_bond + supplier_bond` at PostEscrow — `packages/shared/src/tx/escrow/postEscrow.ts:132-200`. On Accept supplier gets `payment+supplier_bond`, buyer gets `buyer_bond` back.
|
||||||
- Submitted-ref resolution: the buyer `/v1/accept` queries the indexer and matches the Submitted row by `posted_at` — `buyer/src/server.ts` `/v1/accept`. Gateway must mirror this.
|
- Submitted-ref resolution: the buyer `/v1/accept` queries the indexer and matches the Submitted row by `posted_at` — `buyer/src/server.ts` `/v1/accept`. Gateway must mirror this.
|
||||||
- Wallet health: `planConsolidate` / `consolidateWallet` (`{collateral, working}` shape, `DEFAULT_COLLATERAL_LOVELACE = 5 ADA`) — `packages/shared/src/tx/wallet/`. Collateral assertion: `assertCollateralCandidate` — `packages/shared/src/tx/internal/liveCbor.ts`.
|
- Wallet health: `planConsolidate` / `consolidateWallet` (`{collateral, working}` shape, `DEFAULT_COLLATERAL_LOVELACE = 5 AP3X`) — `packages/shared/src/tx/wallet/`. Collateral assertion: `assertCollateralCandidate` — `packages/shared/src/tx/internal/liveCbor.ts`.
|
||||||
- Supplier chat-session SSE `{type:"token"|"done"|"error"}` + headers — `supplier/src/server.ts` `makeChatSessionHandlers`; buyer relay in `buyer/src/server.ts` `/v1/chat/message` (note: **verbatim** passthrough — the gateway must transform to OpenAI chunks).
|
- Supplier chat-session SSE `{type:"token"|"done"|"error"}` + headers — `supplier/src/server.ts` `makeChatSessionHandlers`; buyer relay in `buyer/src/server.ts` `/v1/chat/message` (note: **verbatim** passthrough — the gateway must transform to OpenAI chunks).
|
||||||
- SQLite pattern (`better-sqlite3`, WAL) — `buyer/src/db/archive.ts`. Auth helpers `timingSafeCompareStrings`, `createRateLimiter` — `buyer/src/auth.ts`.
|
- SQLite pattern (`better-sqlite3`, WAL) — `buyer/src/db/archive.ts`. Auth helpers `timingSafeCompareStrings`, `createRateLimiter` — `buyer/src/auth.ts`.
|
||||||
- Indexer `SupplierView` (`model`, `capability_id`, `price_lovelace`, `buyer_bond_lovelace`, `supplier_bond_lovelace`, `status` free/working/offline/unknown, `advert_status`) — `indexer/src/routes/suppliers.ts`.
|
- Indexer `SupplierView` (`model`, `capability_id`, `price_lovelace`, `buyer_bond_lovelace`, `supplier_bond_lovelace`, `status` free/working/offline/unknown, `advert_status`) — `indexer/src/routes/suppliers.ts`.
|
||||||
@@ -100,7 +100,7 @@ Raw API key never persisted (only `sha256(key)`); `key_prefix` for display. `mas
|
|||||||
## Request lifecycle — one-shot `/chat/completions`
|
## Request lifecycle — one-shot `/chat/completions`
|
||||||
All on-chain steps run inside the per-key `mutex.run(...)`.
|
All on-chain steps run inside the per-key `mutex.run(...)`.
|
||||||
1. **Route** (`selectSupplier`): indexer rows where `model` matches, `capability_id==="llm.text.generate.v1"`, `advert_status==="Active"`, live `status ∈ {free, unknown}`. Pick an available one (not cheapest). No match → 404 `model_not_found` (`type: invalid_request_error`).
|
1. **Route** (`selectSupplier`): indexer rows where `model` matches, `capability_id==="llm.text.generate.v1"`, `advert_status==="Active"`, live `status ∈ {free, unknown}`. Pick an available one (not cheapest). No match → 404 `model_not_found` (`type: invalid_request_error`).
|
||||||
2. **Pre-flight**: required = `price + buyer_bond + supplier_bond + 5_000_000 (collateral) + ~2 AP3X fee`. Verify total balance **and** a pure-ADA UTxO ≥5 ADA exists (`assertCollateralCandidate`-style). Else 402 `insufficient_funds` + `x_vector:{required_lovelace, available_lovelace, collateral_ok, deposit_address}`.
|
2. **Pre-flight**: required = `price + buyer_bond + supplier_bond + 5_000_000 (collateral) + ~2 AP3X fee`. Verify total balance **and** a pure-AP3X UTxO ≥5 AP3X exists (`assertCollateralCandidate`-style). Else 402 `insufficient_funds` + `x_vector:{required_lovelace, available_lovelace, collateral_ok, deposit_address}`.
|
||||||
3. **Execute**: `submitPrompt({advertRef, messages, payment_lovelace:price, max_output_tokens})` (posts escrow → supplier → verifies receipt).
|
3. **Execute**: `submitPrompt({advertRef, messages, payment_lovelace:price, max_output_tokens})` (posts escrow → supplier → verifies receipt).
|
||||||
4. **Settle**: resolve the **Submitted** escrow ref via the indexer (mirror buyer `/v1/accept`: match by buyer pkh + `posted_at`), `acceptResult(submittedRef)`, **await confirmation**. On 409/offline mid-route, retry next matching supplier (bounded). On any post-escrow failure, record + leave for the sweeper (don't block the response on reclaim).
|
4. **Settle**: resolve the **Submitted** escrow ref via the indexer (mirror buyer `/v1/accept`: match by buyer pkh + `posted_at`), `acceptResult(submittedRef)`, **await confirmation**. On 409/offline mid-route, retry next matching supplier (bounded). On any post-escrow failure, record + leave for the sweeper (don't block the response on reclaim).
|
||||||
5. **Respond**: `{id:"chatcmpl-…", object:"chat.completion", created:<sec>, model, choices:[{index:0,message:{role:"assistant",content},finish_reason:"stop"}], usage:{prompt_tokens,completion_tokens,total_tokens}, x_vector:{receipt,receipt_signature,escrow_ref}}`. Record `usage`.
|
5. **Respond**: `{id:"chatcmpl-…", object:"chat.completion", created:<sec>, model, choices:[{index:0,message:{role:"assistant",content},finish_reason:"stop"}], usage:{prompt_tokens,completion_tokens,total_tokens}, x_vector:{receipt,receipt_signature,escrow_ref}}`. Record `usage`.
|
||||||
@@ -121,7 +121,7 @@ All on-chain steps run inside the per-key `mutex.run(...)`.
|
|||||||
|
|
||||||
## Sweeper & wallet-health
|
## Sweeper & wallet-health
|
||||||
- **Sweeper** (interval ~60s): per key, classify escrows. Open/Claimed past `deliver_by` → `reclaim`. Submitted **with a verified receipt** still in accept-window → retry `acceptResult`. Submitted past accept-window → leave for supplier Release (log). Never blind-`reclaim` Submitted.
|
- **Sweeper** (interval ~60s): per key, classify escrows. Open/Claimed past `deliver_by` → `reclaim`. Submitted **with a verified receipt** still in accept-window → retry `acceptResult`. Submitted past accept-window → leave for supplier Release (log). Never blind-`reclaim` Submitted.
|
||||||
- **Wallet-health tick**: periodically + after each settle, `planConsolidate` → `consolidateWallet` so every wallet keeps a ≥5 ADA collateral UTxO. Skip if already healthy.
|
- **Wallet-health tick**: periodically + after each settle, `planConsolidate` → `consolidateWallet` so every wallet keeps a ≥5 AP3X collateral UTxO. Skip if already healthy.
|
||||||
|
|
||||||
## Errors (OpenAI shape `{error:{message, type, code, param:null}}`)
|
## Errors (OpenAI shape `{error:{message, type, code, param:null}}`)
|
||||||
no supplier → 404 `invalid_request_error`/`model_not_found`; underfunded/no-collateral → 402 `invalid_request_error`/`insufficient_funds`; bad/missing key → 401 `authentication_error`/`invalid_api_key`; rate limit → 429 `rate_limit_error` + `Retry-After`; supplier timeout → 504 `server_error`; supplier busy race (after retries) → 503 `server_error`/`overloaded`; receipt verify → 502 `server_error`/`receipt_verification_failed`; tx build → 409/502 `server_error`/`escrow_failed`; unsupported `tools`/`functions` → 400 `invalid_request_error`/`unsupported_parameter`.
|
no supplier → 404 `invalid_request_error`/`model_not_found`; underfunded/no-collateral → 402 `invalid_request_error`/`insufficient_funds`; bad/missing key → 401 `authentication_error`/`invalid_api_key`; rate limit → 429 `rate_limit_error` + `Retry-After`; supplier timeout → 504 `server_error`; supplier busy race (after retries) → 503 `server_error`/`overloaded`; receipt verify → 502 `server_error`/`receipt_verification_failed`; tx build → 409/502 `server_error`/`escrow_failed`; unsupported `tools`/`functions` → 400 `invalid_request_error`/`unsupported_parameter`.
|
||||||
@@ -145,7 +145,7 @@ Real billing/markup & fee hook; trustless/self-custody recovery; multi-funding-U
|
|||||||
## Verification (testnet, end-to-end)
|
## Verification (testnet, end-to-end)
|
||||||
1. Indexer + Ogmios + ≥1 `llm.text.generate.v1` and ≥1 `llm.chat.v1` supplier for the same `model`, Active.
|
1. Indexer + Ogmios + ≥1 `llm.text.generate.v1` and ≥1 `llm.chat.v1` supplier for the same `model`, Active.
|
||||||
2. Boot gateway: `LIVE_CHAIN=1`, `GATEWAY_MASTER_KEY=$(openssl rand -hex 32)`, `OGMIOS_URL`, `INDEXER_URL`, `NETWORK_ID`, `GATEWAY_DB_DIR`, `GATEWAY_PORT`.
|
2. Boot gateway: `LIVE_CHAIN=1`, `GATEWAY_MASTER_KEY=$(openssl rand -hex 32)`, `OGMIOS_URL`, `INDEXER_URL`, `NETWORK_ID`, `GATEWAY_DB_DIR`, `GATEWAY_PORT`.
|
||||||
3. `POST /signup` → key + deposit address. Fund ≥ price + both bonds + 5 ADA collateral + ~2 ADA fees; confirm via `GET /account` (`collateral_ok:true`).
|
3. `POST /signup` → key + deposit address. Fund ≥ price + both bonds + 5 AP3X collateral + ~2 AP3X fees; confirm via `GET /account` (`collateral_ok:true`).
|
||||||
4. One-shot non-stream via curl and via the OpenAI Python/JS SDK (`base_url=…/openai/v1`) → valid `chat.completion` + `x_vector`; confirm the escrow **settled (Accepted)** and a `usage` row exists.
|
4. One-shot non-stream via curl and via the OpenAI Python/JS SDK (`base_url=…/openai/v1`) → valid `chat.completion` + `x_vector`; confirm the escrow **settled (Accepted)** and a `usage` row exists.
|
||||||
5. **Run a 2nd and 3rd request** → confirm wallet-health kept `collateral_ok` and they succeed (the audit's request-#2 failure must not reproduce).
|
5. **Run a 2nd and 3rd request** → confirm wallet-health kept `collateral_ok` and they succeed (the audit's request-#2 failure must not reproduce).
|
||||||
6. One-shot `stream:true` → chunks + final usage + `[DONE]`.
|
6. One-shot `stream:true` → chunks + final usage + `[DONE]`.
|
||||||
|
|||||||
@@ -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 .env file** - it contains your wallet seed phrase
|
||||||
- **Never share your data/ folder** - it contains task history
|
- **Never share your data/ folder** - it contains task history
|
||||||
- The seed phrase controls your wallet funds. Back it up securely.
|
- The seed phrase controls your wallet. Back it up securely.
|
||||||
- The node only responds to buyers who have already locked funds on-chain, so you cannot be scammed out of compute.
|
- The node only responds to buyers who have already locked value on-chain, so you cannot be scammed out of compute.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
*
|
*
|
||||||
* The buyer wallet funds the FULL escrow at PostEscrow: price + buyer_bond +
|
* The buyer wallet funds the FULL escrow at PostEscrow: price + buyer_bond +
|
||||||
* supplier_bond (postEscrow.ts:132). Accept/Reclaim are Plutus script-spends
|
* supplier_bond (postEscrow.ts:132). Accept/Reclaim are Plutus script-spends
|
||||||
* that also need a pure-ADA collateral UTxO ≥ 5 ADA (assertCollateralCandidate).
|
* that also need a pure-AP3X collateral UTxO ≥ 5 AP3X (assertCollateralCandidate).
|
||||||
* So required = price + buyer_bond + supplier_bond + collateral + fee reserve,
|
* So required = price + buyer_bond + supplier_bond + collateral + fee reserve,
|
||||||
* AND the wallet must currently hold a pure-ADA UTxO ≥ collateral. We check both
|
* AND the wallet must currently hold a pure-AP3X UTxO ≥ collateral. We check both
|
||||||
* up front and return a clean 402 instead of failing mid-flight.
|
* up front and return a clean 402 instead of failing mid-flight.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ export function totalLovelace(utxos: Utxo[]): bigint {
|
|||||||
return utxos.reduce((sum, u) => sum + u.lovelace, 0n);
|
return utxos.reduce((sum, u) => sum + u.lovelace, 0n);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True iff some UTxO is pure-ADA (no native assets) and ≥ collateral floor. */
|
/** True iff some UTxO is pure-AP3X (no native assets) and ≥ collateral floor. */
|
||||||
export function hasCollateral(utxos: Utxo[]): boolean {
|
export function hasCollateral(utxos: Utxo[]): boolean {
|
||||||
return utxos.some(
|
return utxos.some(
|
||||||
(u) => u.lovelace >= COLLATERAL_MIN_LOVELACE && Object.keys(u.assets).length === 0,
|
(u) => u.lovelace >= COLLATERAL_MIN_LOVELACE && Object.keys(u.assets).length === 0,
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ async function runOneShot(
|
|||||||
throw paymentRequired(
|
throw paymentRequired(
|
||||||
pf.collateralOk
|
pf.collateralOk
|
||||||
? "insufficient balance for this request"
|
? "insufficient balance for this request"
|
||||||
: "wallet has no pure-ADA UTxO ≥ 5 ADA for collateral; deposit a little more ADA",
|
: "wallet has no pure-AP3X UTxO ≥ 5 AP3X for collateral; deposit a little more AP3X",
|
||||||
{
|
{
|
||||||
x_vector: {
|
x_vector: {
|
||||||
required_lovelace: pf.requiredLovelace.toString(),
|
required_lovelace: pf.requiredLovelace.toString(),
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export async function openSessionCore(
|
|||||||
const pf = await preflight(deps.chain, ctx.walletKey.address, primary);
|
const pf = await preflight(deps.chain, ctx.walletKey.address, primary);
|
||||||
if (!pf.ok) {
|
if (!pf.ok) {
|
||||||
throw paymentRequired(
|
throw paymentRequired(
|
||||||
pf.collateralOk ? "insufficient balance to open a session" : "wallet has no ≥5 ADA pure-ADA collateral UTxO",
|
pf.collateralOk ? "insufficient balance to open a session" : "wallet has no ≥5 AP3X pure-AP3X collateral UTxO",
|
||||||
{
|
{
|
||||||
x_vector: {
|
x_vector: {
|
||||||
required_lovelace: pf.requiredLovelace.toString(),
|
required_lovelace: pf.requiredLovelace.toString(),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const INDEX_HTML = `<!doctype html>
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Vector Marketplace — OpenAI Gateway</title>
|
<title>Vector Marketplace - OpenAI Gateway</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
|
body { font-family: system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
|
||||||
h1 { font-size: 1.5rem; } h2 { font-size: 1.1rem; margin-top: 2rem; }
|
h1 { font-size: 1.5rem; } h2 { font-size: 1.1rem; margin-top: 2rem; }
|
||||||
@@ -22,7 +22,7 @@ export const INDEX_HTML = `<!doctype html>
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Vector Marketplace — OpenAI-compatible Gateway</h1>
|
<h1>Vector Marketplace - OpenAI-compatible Gateway</h1>
|
||||||
<p class="muted">Point any OpenAI SDK at <code id="base"></code> using your API key as the bearer token.</p>
|
<p class="muted">Point any OpenAI SDK at <code id="base"></code> using your API key as the bearer token.</p>
|
||||||
|
|
||||||
<h2>1. Create an API key</h2>
|
<h2>1. Create an API key</h2>
|
||||||
@@ -33,7 +33,7 @@ export const INDEX_HTML = `<!doctype html>
|
|||||||
<div id="signupOut"></div>
|
<div id="signupOut"></div>
|
||||||
|
|
||||||
<h2>2. Fund your wallet</h2>
|
<h2>2. Fund your wallet</h2>
|
||||||
<p class="muted">Send AP3X to your deposit address (you need price + bonds + ~5 ADA collateral + fees).</p>
|
<p class="muted">Send AP3X to your deposit address (you need price + bonds + ~5 AP3X collateral + fees).</p>
|
||||||
|
|
||||||
<h2>3. Account</h2>
|
<h2>3. Account</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* gateway/src/walletHealth.ts — keep each custodial wallet usable.
|
* gateway/src/walletHealth.ts — keep each custodial wallet usable.
|
||||||
*
|
*
|
||||||
* After every settled request (and on a periodic tick) we re-shape the wallet to
|
* After every settled request (and on a periodic tick) we re-shape the wallet to
|
||||||
* {≥5 ADA collateral UTxO, working UTxO} via the shared consolidate builder, so
|
* {≥5 AP3X collateral UTxO, working UTxO} via the shared consolidate builder, so
|
||||||
* the NEXT request's Accept/Reclaim script-spend always finds a pure-ADA
|
* the NEXT request's Accept/Reclaim script-spend always finds a pure-AP3X
|
||||||
* collateral candidate. No-op when the wallet is already healthy.
|
* collateral candidate. No-op when the wallet is already healthy.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -37,5 +37,10 @@
|
|||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vitest": "^2.1.8"
|
"vitest": "^2.1.8"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"patchedDependencies": {
|
||||||
|
"libsodium-wrappers-sumo@0.7.16": "patches/libsodium-wrappers-sumo@0.7.16.patch"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,22 @@
|
|||||||
* planConsolidate.ts — pure pre-flight planner for the wallet-consolidate flow.
|
* planConsolidate.ts — pure pre-flight planner for the wallet-consolidate flow.
|
||||||
*
|
*
|
||||||
* Given a wallet's current UTxO set + a target collateral lovelace, decide
|
* Given a wallet's current UTxO set + a target collateral lovelace, decide
|
||||||
* whether to consolidate (many → 2), split (1 → 2), or skip (already at
|
* whether the wallet needs reshaping or is already usable for script spends.
|
||||||
* {≥collateral, working}). The same downstream tx-builder handles
|
*
|
||||||
* consolidate and split — only the "reason" tag differs for logging.
|
* Healthy is a STRUCTURAL predicate, not a UTxO count (F7, 2026-08-08):
|
||||||
|
* (a) some pure-lovelace UTxO >= collateralLovelace exists (the collateral
|
||||||
|
* candidate lucid's setCollateral needs), AND
|
||||||
|
* (b) at least MIN_WORKING_LOVELACE sits OUTSIDE that candidate (fee and
|
||||||
|
* spend money that is not the candidate), AND
|
||||||
|
* (c) fragmentation is bounded (utxos.length <= maxUtxos).
|
||||||
|
*
|
||||||
|
* The earlier revision returned `already-healthy` only for EXACTLY 2 UTxOs.
|
||||||
|
* The consolidate tx itself lands 3 (collateral + working + the fee-reserve
|
||||||
|
* remainder as change), so the shape consolidation produced could never
|
||||||
|
* satisfy its own health check — the wallet-health ticker re-consolidated
|
||||||
|
* every tick and burned 0.5 AP3X per pass (116 AP3X in one 24h soak).
|
||||||
|
* The health check also runs BEFORE the low-balance guard now: a wallet that
|
||||||
|
* can already transact must never be told "balance too low to consolidate".
|
||||||
*
|
*
|
||||||
* No lucid / WASM deps so this is unit-testable from a vanilla vitest.
|
* No lucid / WASM deps so this is unit-testable from a vanilla vitest.
|
||||||
*/
|
*/
|
||||||
@@ -26,12 +39,24 @@ export interface PlannedConsolidate {
|
|||||||
|
|
||||||
export const DEFAULT_COLLATERAL_LOVELACE = 5_000_000n;
|
export const DEFAULT_COLLATERAL_LOVELACE = 5_000_000n;
|
||||||
export const DEFAULT_FEE_RESERVE = 2_000_000n;
|
export const DEFAULT_FEE_RESERVE = 2_000_000n;
|
||||||
|
/** Reshape only when fragmentation passes this; receipt/change UTxOs
|
||||||
|
* accumulating between jobs are fine as long as a candidate exists. */
|
||||||
|
export const DEFAULT_MAX_UTXOS = 6;
|
||||||
const MIN_WORKING_LOVELACE = 1_000_000n;
|
const MIN_WORKING_LOVELACE = 1_000_000n;
|
||||||
|
|
||||||
|
/** Lovelace of a UTxO iff it carries no native assets; null otherwise. */
|
||||||
|
function pureLovelace(u: UtxoLike): bigint | null {
|
||||||
|
for (const [unit, amount] of Object.entries(u.assets)) {
|
||||||
|
if (unit !== "lovelace" && (amount ?? 0n) > 0n) return null;
|
||||||
|
}
|
||||||
|
return u.assets.lovelace ?? 0n;
|
||||||
|
}
|
||||||
|
|
||||||
export function planConsolidate(
|
export function planConsolidate(
|
||||||
utxos: UtxoLike[],
|
utxos: UtxoLike[],
|
||||||
collateralLovelace: bigint = DEFAULT_COLLATERAL_LOVELACE,
|
collateralLovelace: bigint = DEFAULT_COLLATERAL_LOVELACE,
|
||||||
feeReserve: bigint = DEFAULT_FEE_RESERVE,
|
feeReserve: bigint = DEFAULT_FEE_RESERVE,
|
||||||
|
maxUtxos: number = DEFAULT_MAX_UTXOS,
|
||||||
): PlannedConsolidate {
|
): PlannedConsolidate {
|
||||||
if (utxos.length === 0) {
|
if (utxos.length === 0) {
|
||||||
throw new Error("wallet has no UTxOs — fund the address first");
|
throw new Error("wallet has no UTxOs — fund the address first");
|
||||||
@@ -42,6 +67,32 @@ export function planConsolidate(
|
|||||||
0n,
|
0n,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Structural health check first — reshaping a usable wallet wastes a fee.
|
||||||
|
if (utxos.length <= maxUtxos) {
|
||||||
|
// Smallest qualifying candidate: leave larger UTxOs as working funds.
|
||||||
|
let candidate: bigint | null = null;
|
||||||
|
for (const u of utxos) {
|
||||||
|
const pure = pureLovelace(u);
|
||||||
|
if (pure !== null && pure >= collateralLovelace) {
|
||||||
|
if (candidate === null || pure < candidate) candidate = pure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidate !== null) {
|
||||||
|
const workingOutside = totalLovelaceIn - candidate;
|
||||||
|
if (workingOutside >= MIN_WORKING_LOVELACE) {
|
||||||
|
return {
|
||||||
|
inputCount: utxos.length,
|
||||||
|
totalLovelaceIn,
|
||||||
|
collateralOutput: candidate,
|
||||||
|
workingOutput: workingOutside,
|
||||||
|
alreadyHealthy: true,
|
||||||
|
reason: "already-healthy",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reshape needed — only an actual reshape has a funds threshold.
|
||||||
const required = collateralLovelace + feeReserve + MIN_WORKING_LOVELACE;
|
const required = collateralLovelace + feeReserve + MIN_WORKING_LOVELACE;
|
||||||
if (totalLovelaceIn < required) {
|
if (totalLovelaceIn < required) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -51,23 +102,6 @@ export function planConsolidate(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (utxos.length === 2) {
|
|
||||||
const lov0 = utxos[0].assets.lovelace ?? 0n;
|
|
||||||
const lov1 = utxos[1].assets.lovelace ?? 0n;
|
|
||||||
const smaller = lov0 < lov1 ? lov0 : lov1;
|
|
||||||
const larger = lov0 < lov1 ? lov1 : lov0;
|
|
||||||
if (smaller >= collateralLovelace) {
|
|
||||||
return {
|
|
||||||
inputCount: 2,
|
|
||||||
totalLovelaceIn,
|
|
||||||
collateralOutput: smaller,
|
|
||||||
workingOutput: larger,
|
|
||||||
alreadyHealthy: true,
|
|
||||||
reason: "already-healthy",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const workingOutput = totalLovelaceIn - collateralLovelace - feeReserve;
|
const workingOutput = totalLovelaceIn - collateralLovelace - feeReserve;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
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
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
patchedDependencies:
|
||||||
|
libsodium-wrappers-sumo@0.7.16:
|
||||||
|
hash: 761a204004f889b65fdf148159f4bd90733703d63a0ff00eb0671c92ea085096
|
||||||
|
path: patches/libsodium-wrappers-sumo@0.7.16.patch
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -3166,7 +3171,7 @@ snapshots:
|
|||||||
'@cardano-sdk/util': 0.16.0
|
'@cardano-sdk/util': 0.16.0
|
||||||
blake2b: 2.1.4
|
blake2b: 2.1.4
|
||||||
i: 0.3.7
|
i: 0.3.7
|
||||||
libsodium-wrappers-sumo: 0.7.16
|
libsodium-wrappers-sumo: 0.7.16(patch_hash=761a204004f889b65fdf148159f4bd90733703d63a0ff00eb0671c92ea085096)
|
||||||
lodash: 4.18.1
|
lodash: 4.18.1
|
||||||
npm: 9.9.4
|
npm: 9.9.4
|
||||||
pbkdf2: 3.1.5
|
pbkdf2: 3.1.5
|
||||||
@@ -4743,7 +4748,7 @@ snapshots:
|
|||||||
|
|
||||||
libsodium-sumo@0.7.16: {}
|
libsodium-sumo@0.7.16: {}
|
||||||
|
|
||||||
libsodium-wrappers-sumo@0.7.16:
|
libsodium-wrappers-sumo@0.7.16(patch_hash=761a204004f889b65fdf148159f4bd90733703d63a0ff00eb0671c92ea085096):
|
||||||
dependencies:
|
dependencies:
|
||||||
libsodium-sumo: 0.7.16
|
libsodium-sumo: 0.7.16
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ packages:
|
|||||||
- "indexer"
|
- "indexer"
|
||||||
- "indexer-ui"
|
- "indexer-ui"
|
||||||
- "wallet-monitor"
|
- "wallet-monitor"
|
||||||
|
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- better-sqlite3
|
||||||
|
|||||||
@@ -7,15 +7,26 @@
|
|||||||
* and the lane stalls until an operator runs `tx:consolidate-wallet`.
|
* and the lane stalls until an operator runs `tx:consolidate-wallet`.
|
||||||
*
|
*
|
||||||
* Two layers of self-healing:
|
* Two layers of self-healing:
|
||||||
* startWalletHealthTicker — periodic background tick (every N ms). When
|
* startWalletHealthTicker — periodic background tick (every N ms). Runs
|
||||||
* the supplier is idle, runs runConsolidateWallet. planConsolidate
|
* runConsolidateWallet; planConsolidate's structural health predicate
|
||||||
* returns `already-healthy` cheaply for clean 2-UTxO wallets, so this
|
* short-circuits without a tx when a collateral candidate exists and
|
||||||
* is a no-op in steady state — no tx, no fee.
|
* fragmentation is bounded. A tick on a healthy wallet costs one UTxO
|
||||||
|
* query and nothing else.
|
||||||
* triggerOnFailureConsolidate — fire-and-forget consolidate from the
|
* triggerOnFailureConsolidate — fire-and-forget consolidate from the
|
||||||
* Claim-failure path. Debounced so back-to-back failures don't queue
|
* Claim-failure path. Debounced so back-to-back failures don't queue
|
||||||
* multiple consolidates. Returns 503 to the buyer for THIS request; the
|
* multiple consolidates. Returns 503 to the buyer for THIS request; the
|
||||||
* next buyer retry (~30-60s later) finds the wallet healthy.
|
* next buyer retry (~30-60s later) finds the wallet healthy.
|
||||||
*
|
*
|
||||||
|
* F7 guards (2026-08-08 incident: a non-idempotent health check made every
|
||||||
|
* tick consolidate, burning 0.5 AP3X per 45s until the wallet hit the
|
||||||
|
* floor — 116 AP3X in one soak). Two independent circuit breakers protect
|
||||||
|
* against ANY future non-convergence:
|
||||||
|
* - consecutive guard: 3 consolidations in a row without ever reaching
|
||||||
|
* `already-healthy` halts wallet-health for this process.
|
||||||
|
* - budget guard: more than 6 consolidations inside 24h halts it too.
|
||||||
|
* A halt logs loudly once; consolidate manually and restart the supplier
|
||||||
|
* to re-arm.
|
||||||
|
*
|
||||||
* Wallet-lock discipline: consolidation spends EVERY wallet UTxO, so it runs
|
* Wallet-lock discipline: consolidation spends EVERY wallet UTxO, so it runs
|
||||||
* inside state.walletMutex — the same mutex that serializes Claims/Submits —
|
* inside state.walletMutex — the same mutex that serializes Claims/Submits —
|
||||||
* queueing behind any in-flight chain op instead of skipping. Session slots
|
* queueing behind any in-flight chain op instead of skipping. Session slots
|
||||||
@@ -63,6 +74,29 @@ export interface WalletHealthTicker {
|
|||||||
const inFlight = new WeakSet<SupplierState>();
|
const inFlight = new WeakSet<SupplierState>();
|
||||||
const lastOnFailureAt = new WeakMap<SupplierState, number>();
|
const lastOnFailureAt = new WeakMap<SupplierState, number>();
|
||||||
|
|
||||||
|
// ── F7 circuit breakers ──────────────────────────────────────────────
|
||||||
|
const MAX_CONSECUTIVE_CONSOLIDATIONS = 3;
|
||||||
|
const MAX_CONSOLIDATIONS_PER_WINDOW = 6;
|
||||||
|
const BUDGET_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
interface GuardCounters {
|
||||||
|
consecutive: number;
|
||||||
|
windowStart: number;
|
||||||
|
windowCount: number;
|
||||||
|
halted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guardCounters = new WeakMap<SupplierState, GuardCounters>();
|
||||||
|
|
||||||
|
function guardsFor(state: SupplierState): GuardCounters {
|
||||||
|
let g = guardCounters.get(state);
|
||||||
|
if (!g) {
|
||||||
|
g = { consecutive: 0, windowStart: Date.now(), windowCount: 0, halted: false };
|
||||||
|
guardCounters.set(state, g);
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
function defaultLog(line: string): void {
|
function defaultLog(line: string): void {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`[wallet-health] ${line}`);
|
console.log(`[wallet-health] ${line}`);
|
||||||
@@ -73,6 +107,8 @@ async function consolidateOnce(
|
|||||||
opts: { collateralLovelace: bigint; awaitTimeoutMs: number; log: (line: string) => void },
|
opts: { collateralLovelace: bigint; awaitTimeoutMs: number; log: (line: string) => void },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { state } = deps;
|
const { state } = deps;
|
||||||
|
const guards = guardsFor(state);
|
||||||
|
if (guards.halted) return; // the halt already logged loudly, once
|
||||||
if (inFlight.has(state)) {
|
if (inFlight.has(state)) {
|
||||||
opts.log("skip: another consolidate already in flight");
|
opts.log("skip: another consolidate already in flight");
|
||||||
return;
|
return;
|
||||||
@@ -92,11 +128,36 @@ async function consolidateOnce(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (result.reason === "already-healthy") {
|
if (result.reason === "already-healthy") {
|
||||||
|
guards.consecutive = 0;
|
||||||
opts.log("already-healthy");
|
opts.log("already-healthy");
|
||||||
} else {
|
} else {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - guards.windowStart > BUDGET_WINDOW_MS) {
|
||||||
|
guards.windowStart = now;
|
||||||
|
guards.windowCount = 0;
|
||||||
|
}
|
||||||
|
guards.consecutive += 1;
|
||||||
|
guards.windowCount += 1;
|
||||||
opts.log(`consolidated: ${result.reason} txHash=${result.txHash}`);
|
opts.log(`consolidated: ${result.reason} txHash=${result.txHash}`);
|
||||||
|
if (guards.consecutive >= MAX_CONSECUTIVE_CONSOLIDATIONS) {
|
||||||
|
guards.halted = true;
|
||||||
|
opts.log(
|
||||||
|
`HALT: ${guards.consecutive} consecutive consolidations without reaching a healthy shape — ` +
|
||||||
|
`wallet-health stopped to protect funds (F7 guard). Investigate planConsolidate vs the ` +
|
||||||
|
`builder's output shape, consolidate manually, restart the supplier to re-arm.`,
|
||||||
|
);
|
||||||
|
} else if (guards.windowCount >= MAX_CONSOLIDATIONS_PER_WINDOW) {
|
||||||
|
guards.halted = true;
|
||||||
|
opts.log(
|
||||||
|
`HALT: consolidation budget exhausted (${guards.windowCount} in 24h) — wallet-health ` +
|
||||||
|
`stopped to protect funds (F7 guard). Restart the supplier to re-arm.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Neither healthy nor consolidated (e.g. balance too low): no fee was
|
||||||
|
// spent, so the guards don't move — but nothing converged either, so
|
||||||
|
// the consecutive counter is NOT reset.
|
||||||
opts.log(`consolidate failed: ${(err as Error).message}`);
|
opts.log(`consolidate failed: ${(err as Error).message}`);
|
||||||
} finally {
|
} finally {
|
||||||
inFlight.delete(state);
|
inFlight.delete(state);
|
||||||
@@ -143,8 +204,9 @@ export function triggerOnFailureConsolidate(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test-only: reset the in-flight + debounce maps between tests. */
|
/** Test-only: reset the in-flight + debounce + guard maps between tests. */
|
||||||
export function _resetWalletHealthForTests(state: SupplierState): void {
|
export function _resetWalletHealthForTests(state: SupplierState): void {
|
||||||
inFlight.delete(state);
|
inFlight.delete(state);
|
||||||
lastOnFailureAt.delete(state);
|
lastOnFailureAt.delete(state);
|
||||||
|
guardCounters.delete(state);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,15 +118,17 @@ export function encodeSampleAdvertDatumHex(): string {
|
|||||||
hexToBytes(datum.supplier_pkh),
|
hexToBytes(datum.supplier_pkh),
|
||||||
utf8Bytes(datum.capability_id),
|
utf8Bytes(datum.capability_id),
|
||||||
utf8Bytes(datum.model),
|
utf8Bytes(datum.model),
|
||||||
datum.max_output_tokens,
|
// BigInt so cbor-x emits uint64 (0x1b) like the production codec — plain
|
||||||
datum.max_processing_ms,
|
// numbers encode minimally (or as float64 past 2^32) and break golden parity.
|
||||||
|
BigInt(datum.max_output_tokens),
|
||||||
|
BigInt(datum.max_processing_ms),
|
||||||
datum.price_lovelace,
|
datum.price_lovelace,
|
||||||
datum.supplier_bond_lovelace,
|
datum.supplier_bond_lovelace,
|
||||||
datum.buyer_bond_lovelace,
|
datum.buyer_bond_lovelace,
|
||||||
utf8Bytes(datum.endpoint_url),
|
utf8Bytes(datum.endpoint_url),
|
||||||
utf8Bytes(datum.detail_uri),
|
utf8Bytes(datum.detail_uri),
|
||||||
hexToBytes(datum.detail_hash),
|
hexToBytes(datum.detail_hash),
|
||||||
datum.advertised_at,
|
BigInt(datum.advertised_at),
|
||||||
statusTag,
|
statusTag,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
d8798d581cabcdef0123456789abcdef0123456789abcdef0123456789abcdef01546c6c6d2e746578742e67656e65726174652e76314c7177656e322e353a302e356219020019ea601b00000000001e84801b00000000000f42401b00000000000f4240581f68747470733a2f2f737570706c6965722e6578616d706c652e636f6d2f76315835697066733a2f2f516d597741504a7a7635435a736e4136323573335866326e656d7459675070486457457a37396f6a576e506264475820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaafb4279667e84f00000d87980
|
d8798d581cabcdef0123456789abcdef0123456789abcdef0123456789abcdef01546c6c6d2e746578742e67656e65726174652e76314c7177656e322e353a302e35621b00000000000002001b000000000000ea601b00000000001e84801b00000000000f42401b00000000000f4240581f68747470733a2f2f737570706c6965722e6578616d706c652e636f6d2f76315835697066733a2f2f516d597741504a7a7635435a736e4136323573335866326e656d7459675070486457457a37396f6a576e506264475820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1b0000019667e84f00d87980
|
||||||
|
|||||||
@@ -107,15 +107,17 @@ export function encodeSampleAdvertDatumHex(): string {
|
|||||||
hexToBytes(datum.supplier_pkh),
|
hexToBytes(datum.supplier_pkh),
|
||||||
utf8Bytes(datum.capability_id),
|
utf8Bytes(datum.capability_id),
|
||||||
utf8Bytes(datum.model),
|
utf8Bytes(datum.model),
|
||||||
datum.max_output_tokens,
|
// BigInt so cbor-x emits uint64 (0x1b) like the production codec — plain
|
||||||
datum.max_processing_ms,
|
// numbers encode minimally (or as float64 past 2^32) and break golden parity.
|
||||||
|
BigInt(datum.max_output_tokens),
|
||||||
|
BigInt(datum.max_processing_ms),
|
||||||
datum.price_lovelace,
|
datum.price_lovelace,
|
||||||
datum.supplier_bond_lovelace,
|
datum.supplier_bond_lovelace,
|
||||||
datum.buyer_bond_lovelace,
|
datum.buyer_bond_lovelace,
|
||||||
utf8Bytes(datum.endpoint_url),
|
utf8Bytes(datum.endpoint_url),
|
||||||
utf8Bytes(datum.detail_uri),
|
utf8Bytes(datum.detail_uri),
|
||||||
hexToBytes(datum.detail_hash),
|
hexToBytes(datum.detail_hash),
|
||||||
datum.advertised_at,
|
BigInt(datum.advertised_at),
|
||||||
statusTag,
|
statusTag,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -86,28 +86,23 @@ function protocolParamsResponse() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempt to extract and decode the AdvertDatum from a submitted test-tx hex.
|
* Attempt to extract and decode the AdvertDatum from a submitted tx CBOR hex.
|
||||||
*
|
*
|
||||||
* The test tx format produced by encodeTxBody:
|
* The live path emits real Cardano (Conway) CBOR. The advert datum rides in
|
||||||
* - First 8 hex chars: big-endian uint32 byte length of JSON payload
|
* the script output as an inline datum: datum_option = [1, #6.24(bytes)], so
|
||||||
* - Next (length * 2) hex chars: JSON UTF-8 encoded payload
|
* in hex it appears as "d818" (tag 24) followed by a definite-length byte
|
||||||
|
* string (0x58 <len> or 0x59 <len16>) holding the raw AdvertDatum CBOR.
|
||||||
*
|
*
|
||||||
* The JSON has shape { type, outputs: [{..., datumHex: "<hex>"}], ... }
|
* Returns null if no tag-24 wrapped datum is found or decoding fails.
|
||||||
* We extract the first output's datumHex and decode it with decodeAdvertDatum.
|
|
||||||
*
|
|
||||||
* Returns null if the hex is not in this format or has no datum.
|
|
||||||
*/
|
*/
|
||||||
function extractAdvertisedAtFromTxCbor(txCborHex: string): number | null {
|
function extractAdvertisedAtFromTxCbor(txCborHex: string): number | null {
|
||||||
try {
|
try {
|
||||||
if (txCborHex.length < 8) return null;
|
const m = /d818(?:58([0-9a-f]{2})|59([0-9a-f]{4}))/i.exec(txCborHex);
|
||||||
const lenHex = txCborHex.slice(0, 8);
|
if (!m) return null;
|
||||||
const jsonLen = parseInt(lenHex, 16);
|
const byteLen = parseInt(m[1] ?? m[2]!, 16);
|
||||||
if (!Number.isFinite(jsonLen) || jsonLen <= 0) return null;
|
const start = m.index + m[0].length;
|
||||||
const jsonHex = txCborHex.slice(8, 8 + jsonLen * 2);
|
const datumHex = txCborHex.slice(start, start + byteLen * 2);
|
||||||
const jsonStr = Buffer.from(jsonHex, "hex").toString("utf8");
|
if (datumHex.length !== byteLen * 2) return null;
|
||||||
const body = JSON.parse(jsonStr) as { outputs?: Array<{ datumHex?: string }> };
|
|
||||||
const datumHex = body.outputs?.[0]?.datumHex;
|
|
||||||
if (!datumHex) return null;
|
|
||||||
const datum = decodeAdvertDatum(datumHex);
|
const datum = decodeAdvertDatum(datumHex);
|
||||||
return datum.advertised_at;
|
return datum.advertised_at;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -242,3 +242,115 @@ describe("walletHealth — triggerOnFailureConsolidate", () => {
|
|||||||
expect(state.snapshot().status).toBe("working");
|
expect(state.snapshot().status).toBe("working");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("walletHealth — F7 circuit breakers", () => {
|
||||||
|
let state: SupplierState;
|
||||||
|
let logLines: string[];
|
||||||
|
let log: (line: string) => void;
|
||||||
|
let consolidate: ReturnType<typeof vi.fn> & ConsolidateFn;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
state = new SupplierState();
|
||||||
|
_resetWalletHealthForTests(state);
|
||||||
|
logLines = [];
|
||||||
|
log = (line) => logLines.push(line);
|
||||||
|
consolidate = vi.fn() as never;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("halts after 3 consecutive consolidations that never reach healthy", async () => {
|
||||||
|
// The 2026-08-07 pump: every tick consolidates, none lands healthy.
|
||||||
|
consolidate.mockResolvedValue(consolidatedResult);
|
||||||
|
|
||||||
|
const ticker = startWalletHealthTicker(
|
||||||
|
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
||||||
|
{ intervalMs: 1_000, log },
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(3_000);
|
||||||
|
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(3));
|
||||||
|
expect(logLines.some((l) => l.includes("HALT") && l.includes("consecutive"))).toBe(true);
|
||||||
|
|
||||||
|
// Two more ticks: the breaker holds, no further txs.
|
||||||
|
await vi.advanceTimersByTimeAsync(2_000);
|
||||||
|
expect(consolidate).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
ticker.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an already-healthy result resets the consecutive counter", async () => {
|
||||||
|
consolidate
|
||||||
|
.mockResolvedValueOnce(consolidatedResult)
|
||||||
|
.mockResolvedValueOnce(consolidatedResult)
|
||||||
|
.mockResolvedValueOnce(healthyResult)
|
||||||
|
.mockResolvedValueOnce(consolidatedResult)
|
||||||
|
.mockResolvedValueOnce(consolidatedResult)
|
||||||
|
.mockResolvedValue(healthyResult);
|
||||||
|
|
||||||
|
const ticker = startWalletHealthTicker(
|
||||||
|
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
||||||
|
{ intervalMs: 1_000, log },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6 ticks: c,c,h,c,c,h — never 3 consecutive, so never halted.
|
||||||
|
await vi.advanceTimersByTimeAsync(6_000);
|
||||||
|
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(6));
|
||||||
|
expect(logLines.some((l) => l.includes("HALT"))).toBe(false);
|
||||||
|
|
||||||
|
ticker.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("halts when the 24h consolidation budget is exhausted", async () => {
|
||||||
|
// Interleave healthy results so the consecutive guard never fires:
|
||||||
|
// c,c,h,c,c,h,c,c -> 6 consolidations inside the window at tick 8.
|
||||||
|
consolidate
|
||||||
|
.mockResolvedValueOnce(consolidatedResult)
|
||||||
|
.mockResolvedValueOnce(consolidatedResult)
|
||||||
|
.mockResolvedValueOnce(healthyResult)
|
||||||
|
.mockResolvedValueOnce(consolidatedResult)
|
||||||
|
.mockResolvedValueOnce(consolidatedResult)
|
||||||
|
.mockResolvedValueOnce(healthyResult)
|
||||||
|
.mockResolvedValue(consolidatedResult);
|
||||||
|
|
||||||
|
const ticker = startWalletHealthTicker(
|
||||||
|
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
||||||
|
{ intervalMs: 1_000, log },
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(8_000);
|
||||||
|
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(8));
|
||||||
|
expect(logLines.some((l) => l.includes("HALT") && l.includes("budget"))).toBe(true);
|
||||||
|
|
||||||
|
// Breaker holds on subsequent ticks.
|
||||||
|
await vi.advanceTimersByTimeAsync(2_000);
|
||||||
|
expect(consolidate).toHaveBeenCalledTimes(8);
|
||||||
|
|
||||||
|
ticker.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("failed consolidations neither trip nor reset the breakers", async () => {
|
||||||
|
// Errors spend no fee: they must not count as consolidations, but they
|
||||||
|
// must not clear the consecutive count either.
|
||||||
|
consolidate
|
||||||
|
.mockResolvedValueOnce(consolidatedResult)
|
||||||
|
.mockResolvedValueOnce(consolidatedResult)
|
||||||
|
.mockRejectedValueOnce(new Error("balance too low to consolidate"))
|
||||||
|
.mockResolvedValue(consolidatedResult);
|
||||||
|
|
||||||
|
const ticker = startWalletHealthTicker(
|
||||||
|
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
||||||
|
{ intervalMs: 1_000, log },
|
||||||
|
);
|
||||||
|
|
||||||
|
// c,c,error,c -> the 4th call is the 3rd consecutive consolidation.
|
||||||
|
await vi.advanceTimersByTimeAsync(4_000);
|
||||||
|
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(4));
|
||||||
|
expect(logLines.some((l) => l.includes("HALT") && l.includes("consecutive"))).toBe(true);
|
||||||
|
|
||||||
|
ticker.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -120,6 +120,13 @@ function setupAcceptLiveHappyPath(escrowDatumHex: string): void {
|
|||||||
}
|
}
|
||||||
// Tip slot is LOW — live path uses Date.now() for the window check
|
// Tip slot is LOW — live path uses Date.now() for the window check
|
||||||
if (method === "queryNetwork/tip") return rpcOk({ slot: 100_000, id: "a".repeat(64) });
|
if (method === "queryNetwork/tip") return rpcOk({ slot: 100_000, id: "a".repeat(64) });
|
||||||
|
// Like real Ogmios, return the script-spend redeemer with its true index:
|
||||||
|
// the escrow input ("ff…") sorts after the wallet input ("cc…"), so the
|
||||||
|
// spend redeemer index is 1. The provider's index-0 fallback for empty
|
||||||
|
// mock responses makes CML's set_exunits panic ("unreachable").
|
||||||
|
if (method === "evaluateTransaction") {
|
||||||
|
return rpcOk([{ validator: { purpose: "spend", index: 1 }, budget: { memory: 1_000_000, cpu: 500_000_000 } }]);
|
||||||
|
}
|
||||||
if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } });
|
if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } });
|
||||||
return rpcOk({});
|
return rpcOk({});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* M1-F-4 RED — tests fail until Catherine implements the live CBOR path.
|
* M1-F-4 RED — tests fail until Catherine implements the live CBOR path.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { LiveOgmiosProvider } from "../../packages/shared/src/chain/LiveOgmiosProvider.js";
|
import { LiveOgmiosProvider } from "../../packages/shared/src/chain/LiveOgmiosProvider.js";
|
||||||
import { buildAcceptTx, ACCEPT_WINDOW_MS } from "../../packages/shared/src/tx/escrow/accept.js";
|
import { buildAcceptTx, ACCEPT_WINDOW_MS } from "../../packages/shared/src/tx/escrow/accept.js";
|
||||||
import { TxConstructionError } from "../../packages/shared/src/tx/types.js";
|
import { TxConstructionError } from "../../packages/shared/src/tx/types.js";
|
||||||
@@ -36,8 +36,20 @@ const COLLATERAL_LOVELACE = 100_000_000;
|
|||||||
|
|
||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
|
// Freeze Date.now() inside the fixture's accept window: the live path checks
|
||||||
|
// submitted_at + ACCEPT_WINDOW_MS against wall-clock time, and the fixture
|
||||||
|
// timestamps are fixed POSIX values that would otherwise expire as real time
|
||||||
|
// passes.
|
||||||
|
const FAKE_NOW_MS = SUBMITTED_AT + 60_000;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockFetch.mockReset();
|
mockFetch.mockReset();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(FAKE_NOW_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
function rpcOk<T>(result: T) {
|
function rpcOk<T>(result: T) {
|
||||||
@@ -89,6 +101,13 @@ function setupAcceptHappyPath(escrowDatumHex: string): void {
|
|||||||
return rpcOk([makeOgmiosUtxo("c".repeat(64), 0, buyer.address, COLLATERAL_LOVELACE)]);
|
return rpcOk([makeOgmiosUtxo("c".repeat(64), 0, buyer.address, COLLATERAL_LOVELACE)]);
|
||||||
}
|
}
|
||||||
if (method === "queryNetwork/tip") return rpcOk({ slot: tipSlot, id: "a".repeat(64) });
|
if (method === "queryNetwork/tip") return rpcOk({ slot: tipSlot, id: "a".repeat(64) });
|
||||||
|
// Like real Ogmios, return the script-spend redeemer with its true index:
|
||||||
|
// the escrow input ("ff…") sorts after the wallet input ("cc…"), so the
|
||||||
|
// spend redeemer index is 1. The provider's index-0 fallback for empty
|
||||||
|
// mock responses makes CML's set_exunits panic ("unreachable").
|
||||||
|
if (method === "evaluateTransaction") {
|
||||||
|
return rpcOk([{ validator: { purpose: "spend", index: 1 }, budget: { memory: 1_000_000, cpu: 500_000_000 } }]);
|
||||||
|
}
|
||||||
if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } });
|
if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } });
|
||||||
return rpcOk({});
|
return rpcOk({});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -115,6 +115,13 @@ function setupClaimLiveHappyPath(escrowDatumHex: string): void {
|
|||||||
if (method === "queryNetwork/tip") {
|
if (method === "queryNetwork/tip") {
|
||||||
return rpcOk({ slot: 100_000, id: "a".repeat(64) });
|
return rpcOk({ slot: 100_000, id: "a".repeat(64) });
|
||||||
}
|
}
|
||||||
|
// Like real Ogmios, return the script-spend redeemer with its true index:
|
||||||
|
// the escrow input ("ff…") sorts after the wallet input ("cc…"), so the
|
||||||
|
// spend redeemer index is 1. The provider's index-0 fallback for empty
|
||||||
|
// mock responses makes CML's set_exunits panic ("unreachable").
|
||||||
|
if (method === "evaluateTransaction") {
|
||||||
|
return rpcOk([{ validator: { purpose: "spend", index: 1 }, budget: { memory: 1_000_000, cpu: 500_000_000 } }]);
|
||||||
|
}
|
||||||
if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } });
|
if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } });
|
||||||
return rpcOk({});
|
return rpcOk({});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* M1-F-4 RED — tests fail until Catherine implements the live CBOR path.
|
* M1-F-4 RED — tests fail until Catherine implements the live CBOR path.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { LiveOgmiosProvider } from "../../packages/shared/src/chain/LiveOgmiosProvider.js";
|
import { LiveOgmiosProvider } from "../../packages/shared/src/chain/LiveOgmiosProvider.js";
|
||||||
import { buildClaimTx } from "../../packages/shared/src/tx/escrow/claim.js";
|
import { buildClaimTx } from "../../packages/shared/src/tx/escrow/claim.js";
|
||||||
import { TxConstructionError } from "../../packages/shared/src/tx/types.js";
|
import { TxConstructionError } from "../../packages/shared/src/tx/types.js";
|
||||||
@@ -32,8 +32,19 @@ const COLLATERAL_LOVELACE = 100_000_000;
|
|||||||
|
|
||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
|
// Freeze Date.now() inside the fixture's claim window: the live path checks
|
||||||
|
// deliver_by against wall-clock time, and the fixture deadlines are fixed
|
||||||
|
// POSIX timestamps that would otherwise expire as real time passes.
|
||||||
|
const FAKE_NOW_MS = DELIVER_BY - 30_000;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockFetch.mockReset();
|
mockFetch.mockReset();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(FAKE_NOW_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
function rpcOk<T>(result: T) {
|
function rpcOk<T>(result: T) {
|
||||||
@@ -87,6 +98,13 @@ function setupClaimHappyPath(escrowDatumHex: string): void {
|
|||||||
// Tip well before deliver_by
|
// Tip well before deliver_by
|
||||||
return rpcOk({ slot: Math.floor((DELIVER_BY - 60_000) / 1000), id: "a".repeat(64) });
|
return rpcOk({ slot: Math.floor((DELIVER_BY - 60_000) / 1000), id: "a".repeat(64) });
|
||||||
}
|
}
|
||||||
|
// Like real Ogmios, return the script-spend redeemer with its true index:
|
||||||
|
// the escrow input ("ff…") sorts after the wallet input ("cc…"), so the
|
||||||
|
// spend redeemer index is 1. The provider's index-0 fallback for empty
|
||||||
|
// mock responses makes CML's set_exunits panic ("unreachable").
|
||||||
|
if (method === "evaluateTransaction") {
|
||||||
|
return rpcOk([{ validator: { purpose: "spend", index: 1 }, budget: { memory: 1_000_000, cpu: 500_000_000 } }]);
|
||||||
|
}
|
||||||
if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } });
|
if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } });
|
||||||
return rpcOk({});
|
return rpcOk({});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,14 +64,17 @@ describe("planConsolidate", () => {
|
|||||||
expect(plan.workingOutput).toBe(8_650_000n);
|
expect(plan.workingOutput).toBe(8_650_000n);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("consolidates a 2-UTxO wallet where the smaller is below collateral threshold", () => {
|
it("accepts a 2-UTxO wallet where the LARGER is the collateral candidate", () => {
|
||||||
// Both fit budget but smaller (3 AP3X) is below 5 AP3X — needs reshape.
|
// {3, 15}: the 15 qualifies as a pure >=5 candidate and the 3 covers
|
||||||
|
// fees — this wallet can run script txs as-is. The old exact-2-slot
|
||||||
|
// convention reshaped it for nothing (0.5 AP3X per pass).
|
||||||
const utxos = [u(3_000_000n), u(15_000_000n)];
|
const utxos = [u(3_000_000n), u(15_000_000n)];
|
||||||
const plan = planConsolidate(utxos);
|
const plan = planConsolidate(utxos);
|
||||||
|
|
||||||
expect(plan.reason).toBe("consolidate");
|
expect(plan.reason).toBe("already-healthy");
|
||||||
expect(plan.alreadyHealthy).toBe(false);
|
expect(plan.alreadyHealthy).toBe(true);
|
||||||
expect(plan.inputCount).toBe(2);
|
expect(plan.collateralOutput).toBe(15_000_000n);
|
||||||
|
expect(plan.workingOutput).toBe(3_000_000n);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws on 0 UTxOs", () => {
|
it("throws on 0 UTxOs", () => {
|
||||||
@@ -93,3 +96,93 @@ describe("planConsolidate", () => {
|
|||||||
expect(plan.workingOutput).toBe(20_000_000n - 10_000_000n - DEFAULT_FEE_RESERVE);
|
expect(plan.workingOutput).toBe(20_000_000n - 10_000_000n - DEFAULT_FEE_RESERVE);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("planConsolidate — structural health predicate (F7)", () => {
|
||||||
|
// The tx buildConsolidateWalletTx actually lands: {collateral 5.0,
|
||||||
|
// working = total - 5.0 - 2.0 reserve, change = reserve - fee}. The fee
|
||||||
|
// observed on mainnet is exactly 501,628, so the change is 1,498,372.
|
||||||
|
const MAINNET_CONSOLIDATE_FEE = 501_628n;
|
||||||
|
|
||||||
|
it("REGRESSION: the exact shape consolidation produces is healthy", () => {
|
||||||
|
// This is the loop that burned 116 AP3X in the 2026-08-07 soak: the
|
||||||
|
// planner called its own output shape unhealthy and re-consolidated
|
||||||
|
// every 45s tick. Feed it precisely what the builder lands.
|
||||||
|
const utxos = [
|
||||||
|
u(5_000_000n),
|
||||||
|
u(91_000_000n),
|
||||||
|
u(DEFAULT_FEE_RESERVE - MAINNET_CONSOLIDATE_FEE), // 1,498,372 change
|
||||||
|
];
|
||||||
|
const plan = planConsolidate(utxos);
|
||||||
|
|
||||||
|
expect(plan.reason).toBe("already-healthy");
|
||||||
|
expect(plan.alreadyHealthy).toBe(true);
|
||||||
|
expect(plan.collateralOutput).toBe(5_000_000n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent: applying the builder's output model to any plan yields healthy", () => {
|
||||||
|
const fragmented = [u(2_400_750n), u(4_100_000n), u(1_679_301n), u(1_679_301n)];
|
||||||
|
const plan = planConsolidate(fragmented);
|
||||||
|
expect(plan.alreadyHealthy).toBe(false);
|
||||||
|
|
||||||
|
// Model of what buildConsolidateWalletTx lands for this plan.
|
||||||
|
const afterConsolidation = [
|
||||||
|
u(plan.collateralOutput),
|
||||||
|
u(plan.workingOutput),
|
||||||
|
u(DEFAULT_FEE_RESERVE - MAINNET_CONSOLIDATE_FEE),
|
||||||
|
];
|
||||||
|
const replan = planConsolidate(afterConsolidation);
|
||||||
|
|
||||||
|
expect(replan.reason).toBe("already-healthy");
|
||||||
|
expect(replan.alreadyHealthy).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still consolidates when fragmentation exceeds the max UTxO bound", () => {
|
||||||
|
// Candidate present, but 7 UTxOs > DEFAULT_MAX_UTXOS (6).
|
||||||
|
const utxos = [
|
||||||
|
u(5_000_000n), u(1_100_000n), u(1_100_000n), u(1_100_000n),
|
||||||
|
u(1_100_000n), u(1_100_000n), u(1_100_000n),
|
||||||
|
];
|
||||||
|
const plan = planConsolidate(utxos);
|
||||||
|
expect(plan.reason).toBe("consolidate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still consolidates when no pure UTxO reaches the collateral threshold", () => {
|
||||||
|
// The 2026-08-07 batch-stall shape: fragments only, max 4,498,372.
|
||||||
|
const utxos = [u(4_498_372n), u(3_098_372n), u(1_498_372n)];
|
||||||
|
const plan = planConsolidate(utxos);
|
||||||
|
expect(plan.reason).toBe("consolidate");
|
||||||
|
expect(plan.alreadyHealthy).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a UTxO 1,628 lovelace short of collateral is not a candidate", () => {
|
||||||
|
// Seen live: 4,998,372 (= 5.0 minus a 501,628 fee twice-shuffled).
|
||||||
|
const utxos = [u(4_998_372n), u(9_000_000n - 4_998_372n)];
|
||||||
|
const plan = planConsolidate(utxos);
|
||||||
|
expect(plan.reason).toBe("consolidate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a UTxO carrying native assets is not a collateral candidate", () => {
|
||||||
|
const withToken: UtxoLike = {
|
||||||
|
assets: { lovelace: 6_000_000n, "deadbeef.72737456": 1n },
|
||||||
|
};
|
||||||
|
const utxos = [withToken, u(4_000_000n)];
|
||||||
|
const plan = planConsolidate(utxos);
|
||||||
|
expect(plan.reason).toBe("consolidate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("healthy shape below the reshape threshold is healthy, not an error", () => {
|
||||||
|
// {5.0, 2.5} = 7.5 total: under the 8.0 reshape minimum but perfectly
|
||||||
|
// able to run script txs. The old order threw "balance too low" here
|
||||||
|
// on every tick (1,744 log lines in the soak).
|
||||||
|
const utxos = [u(5_000_000n), u(2_500_000n)];
|
||||||
|
const plan = planConsolidate(utxos);
|
||||||
|
expect(plan.reason).toBe("already-healthy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("candidate with only dust outside it needs a reshape it cannot afford", () => {
|
||||||
|
// {5.0, 0.4}: no working money outside the candidate, and 5.4 total is
|
||||||
|
// below the reshape minimum — loud error, funds needed.
|
||||||
|
const utxos = [u(5_000_000n), u(400_000n)];
|
||||||
|
expect(() => planConsolidate(utxos)).toThrow(/balance too low/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,10 +9,9 @@ SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXXX/YYYY/ZZZZ
|
|||||||
# Defaults to the mainnet public endpoint (also pinned in the compose file).
|
# Defaults to the mainnet public endpoint (also pinned in the compose file).
|
||||||
# OGMIOS_URL=https://ogmios.vector.mainnet.apexfusion.org
|
# OGMIOS_URL=https://ogmios.vector.mainnet.apexfusion.org
|
||||||
|
|
||||||
# OPTIONAL — override the wallets.json default threshold / reminder cadence
|
# OPTIONAL — override the wallets.json default threshold without editing the
|
||||||
# without editing the mounted wallets file.
|
# mounted wallets file.
|
||||||
# DEFAULT_MIN_AP3X=10
|
# DEFAULT_MIN_AP3X=10
|
||||||
# REMINDER_HOURS=6
|
|
||||||
|
|
||||||
# OPTIONAL — paths inside the container (defaults shown; bind-mounted by compose).
|
# OPTIONAL — paths inside the container (defaults shown; bind-mounted by compose).
|
||||||
# WALLETS_PATH=/repo/wallet-monitor/wallets.json
|
# WALLETS_PATH=/repo/wallet-monitor/wallets.json
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
One-shot wallet-balance Slack alerter for the `vector-marketplace` mainnet box.
|
One-shot wallet-balance Slack alerter for the `vector-marketplace` mainnet box.
|
||||||
|
|
||||||
Checks every wallet in `wallets.json` against a threshold (default **10 APEX**),
|
Checks every wallet in `wallets.json` against a threshold (default **10 APEX**)
|
||||||
posts a combined Slack message when any wallet is below it, re-reminds every
|
and posts a combined Slack message only when something **changed** since the
|
||||||
`reminder_hours` while a wallet stays low, and posts a recovery message when it
|
last run — a wallet newly dropped below its threshold, or climbed back above
|
||||||
climbs back above. Each alert lists **every** monitored wallet — low (🔴) and
|
it. A wallet that merely stays low never re-posts. Each alert lists **every**
|
||||||
healthy (🟢) — with its full, copy-pasteable address; it stays fully silent
|
monitored wallet — low (🔴) and healthy (🟢) — with its full, copy-pasteable
|
||||||
when all wallets are healthy. Balances are read from Ogmios via
|
address; it stays fully silent while nothing changes. Balances are read from Ogmios via
|
||||||
`ReadOnlyOgmiosProvider` (`@marketplace/shared/chain`) — the same client behind
|
`ReadOnlyOgmiosProvider` (`@marketplace/shared/chain`) — the same client behind
|
||||||
the buyer's `/v1/wallet/balance` and `buyer/scripts/monitor-wallets.ts`.
|
the buyer's `/v1/wallet/balance` and `buyer/scripts/monitor-wallets.ts`.
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,11 @@
|
|||||||
* STATE_PATH — path to the dedup state file (read/written each run).
|
* STATE_PATH — path to the dedup state file (read/written each run).
|
||||||
* Default /repo/wallet-monitor/data/state.json.
|
* Default /repo/wallet-monitor/data/state.json.
|
||||||
* DEFAULT_MIN_AP3X — (optional) overrides wallets.json default_min_ap3x.
|
* DEFAULT_MIN_AP3X — (optional) overrides wallets.json default_min_ap3x.
|
||||||
* REMINDER_HOURS — (optional) overrides wallets.json reminder_hours.
|
|
||||||
* TEST — "1" posts a one-line "configured" message to Slack and exits.
|
* TEST — "1" posts a one-line "configured" message to Slack and exits.
|
||||||
*
|
*
|
||||||
* wallets.json owns the wallet list + the default threshold / reminder cadence;
|
* wallets.json owns the wallet list + the default threshold; the env override
|
||||||
* the env overrides exist only so an operator can retune without editing the
|
* exists only so an operator can retune without editing the mounted wallets
|
||||||
* mounted wallets file.
|
* file.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const DEFAULT_OGMIOS_URL = "https://ogmios.vector.mainnet.apexfusion.org";
|
const DEFAULT_OGMIOS_URL = "https://ogmios.vector.mainnet.apexfusion.org";
|
||||||
@@ -21,7 +20,6 @@ const DEFAULT_WALLETS_PATH = "/repo/wallet-monitor/wallets.json";
|
|||||||
const DEFAULT_STATE_PATH = "/repo/wallet-monitor/data/state.json";
|
const DEFAULT_STATE_PATH = "/repo/wallet-monitor/data/state.json";
|
||||||
|
|
||||||
const FALLBACK_DEFAULT_MIN_AP3X = 10;
|
const FALLBACK_DEFAULT_MIN_AP3X = 10;
|
||||||
const FALLBACK_REMINDER_HOURS = 6;
|
|
||||||
|
|
||||||
export interface EnvConfig {
|
export interface EnvConfig {
|
||||||
slackWebhookUrl: string;
|
slackWebhookUrl: string;
|
||||||
@@ -30,8 +28,6 @@ export interface EnvConfig {
|
|||||||
statePath: string;
|
statePath: string;
|
||||||
/** When set, overrides the wallets-file default_min_ap3x. */
|
/** When set, overrides the wallets-file default_min_ap3x. */
|
||||||
defaultMinAp3xOverride: number | null;
|
defaultMinAp3xOverride: number | null;
|
||||||
/** When set, overrides the wallets-file reminder_hours. */
|
|
||||||
reminderHoursOverride: number | null;
|
|
||||||
testMode: boolean;
|
testMode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +40,6 @@ export interface WalletEntry {
|
|||||||
|
|
||||||
export interface WalletsFile {
|
export interface WalletsFile {
|
||||||
defaultMinAp3x: number;
|
defaultMinAp3x: number;
|
||||||
reminderHours: number;
|
|
||||||
wallets: WalletEntry[];
|
wallets: WalletEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +76,6 @@ export function loadConfig(env: Record<string, string | undefined>): EnvConfig {
|
|||||||
walletsPath: nonEmpty(env, "WALLETS_PATH", DEFAULT_WALLETS_PATH),
|
walletsPath: nonEmpty(env, "WALLETS_PATH", DEFAULT_WALLETS_PATH),
|
||||||
statePath: nonEmpty(env, "STATE_PATH", DEFAULT_STATE_PATH),
|
statePath: nonEmpty(env, "STATE_PATH", DEFAULT_STATE_PATH),
|
||||||
defaultMinAp3xOverride: parseOptionalPositiveNumber(env.DEFAULT_MIN_AP3X, "DEFAULT_MIN_AP3X"),
|
defaultMinAp3xOverride: parseOptionalPositiveNumber(env.DEFAULT_MIN_AP3X, "DEFAULT_MIN_AP3X"),
|
||||||
reminderHoursOverride: parseOptionalPositiveNumber(env.REMINDER_HOURS, "REMINDER_HOURS"),
|
|
||||||
testMode: env.TEST === "1",
|
testMode: env.TEST === "1",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -113,11 +107,6 @@ export function parseWalletsFile(raw: unknown): WalletsFile {
|
|||||||
"default_min_ap3x",
|
"default_min_ap3x",
|
||||||
FALLBACK_DEFAULT_MIN_AP3X,
|
FALLBACK_DEFAULT_MIN_AP3X,
|
||||||
);
|
);
|
||||||
const reminderHours = parsePositiveNumberField(
|
|
||||||
raw.reminder_hours,
|
|
||||||
"reminder_hours",
|
|
||||||
FALLBACK_REMINDER_HOURS,
|
|
||||||
);
|
|
||||||
|
|
||||||
const walletsRaw = raw.wallets;
|
const walletsRaw = raw.wallets;
|
||||||
if (!Array.isArray(walletsRaw) || walletsRaw.length === 0) {
|
if (!Array.isArray(walletsRaw) || walletsRaw.length === 0) {
|
||||||
@@ -152,5 +141,5 @@ export function parseWalletsFile(raw: unknown): WalletsFile {
|
|||||||
return entry;
|
return entry;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { defaultMinAp3x, reminderHours, wallets };
|
return { defaultMinAp3x, wallets };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
*
|
*
|
||||||
* Runs once and exits (invoked hourly by cron via `docker compose run --rm`).
|
* Runs once and exits (invoked hourly by cron via `docker compose run --rm`).
|
||||||
* For each wallet in wallets.json: query its on-chain balance via Ogmios, and
|
* For each wallet in wallets.json: query its on-chain balance via Ogmios, and
|
||||||
* if it's below threshold post a combined Slack alert. A persisted state file
|
* post a combined Slack alert ONLY when something changed since the last run —
|
||||||
* dedups so a still-low wallet only re-alerts every `reminder_hours`, and a
|
* a wallet newly dropped below threshold, or climbed back above it. A wallet
|
||||||
* recovery message fires when a wallet climbs back above its threshold.
|
* that merely stays low never re-posts (state persisted across runs).
|
||||||
*
|
*
|
||||||
* Reuses ReadOnlyOgmiosProvider from @marketplace/shared/chain — the same
|
* Reuses ReadOnlyOgmiosProvider from @marketplace/shared/chain — the same
|
||||||
* client behind the buyer's /v1/wallet/balance and buyer/scripts/monitor-wallets.ts.
|
* client behind the buyer's /v1/wallet/balance and buyer/scripts/monitor-wallets.ts.
|
||||||
@@ -19,7 +19,6 @@ import { loadConfig, parseWalletsFile, type WalletEntry } from "./config.js";
|
|||||||
import { postSlack } from "./slack.js";
|
import { postSlack } from "./slack.js";
|
||||||
|
|
||||||
const LOVELACE_PER_AP3X = 1_000_000;
|
const LOVELACE_PER_AP3X = 1_000_000;
|
||||||
const MS_PER_HOUR = 3_600_000;
|
|
||||||
|
|
||||||
type Status = "ok" | "low";
|
type Status = "ok" | "low";
|
||||||
|
|
||||||
@@ -70,6 +69,31 @@ function saveState(path: string, state: StateFile): void {
|
|||||||
renameSync(tmp, path); // atomic replace
|
renameSync(tmp, path); // atomic replace
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const QUERY_ATTEMPTS = 2;
|
||||||
|
const QUERY_RETRY_DELAY_MS = 2_000;
|
||||||
|
|
||||||
|
async function checkWallet(
|
||||||
|
provider: ReadOnlyOgmiosProvider,
|
||||||
|
w: WalletEntry,
|
||||||
|
minLovelace: bigint,
|
||||||
|
): Promise<CheckedWallet> {
|
||||||
|
let lastError = "unknown error";
|
||||||
|
for (let attempt = 1; attempt <= QUERY_ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
|
const utxos = await provider.queryUtxosByAddress(w.address);
|
||||||
|
const lovelace = utxos.reduce((a, u) => a + BigInt(u.lovelace), 0n);
|
||||||
|
return { name: w.name, address: w.address, minLovelace, lovelace, error: null };
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e instanceof Error ? e.message : String(e);
|
||||||
|
if (attempt < QUERY_ATTEMPTS) {
|
||||||
|
await new Promise((r) => setTimeout(r, QUERY_RETRY_DELAY_MS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error(`[wallet-monitor] query failed for ${w.name} (${w.address}): ${lastError}`);
|
||||||
|
return { name: w.name, address: w.address, minLovelace, lovelace: null, error: lastError };
|
||||||
|
}
|
||||||
|
|
||||||
// ── main ────────────────────────────────────────────────────────────────
|
// ── main ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
@@ -94,24 +118,16 @@ async function main(): Promise<void> {
|
|||||||
JSON.parse(readFileSync(cfg.walletsPath, "utf-8")) as unknown,
|
JSON.parse(readFileSync(cfg.walletsPath, "utf-8")) as unknown,
|
||||||
);
|
);
|
||||||
const defaultMinAp3x = cfg.defaultMinAp3xOverride ?? walletsFile.defaultMinAp3x;
|
const defaultMinAp3x = cfg.defaultMinAp3xOverride ?? walletsFile.defaultMinAp3x;
|
||||||
const reminderMs = (cfg.reminderHoursOverride ?? walletsFile.reminderHours) * MS_PER_HOUR;
|
|
||||||
|
|
||||||
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: cfg.ogmiosUrl });
|
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: cfg.ogmiosUrl });
|
||||||
|
|
||||||
const checked: CheckedWallet[] = await Promise.all(
|
// Query SEQUENTIALLY, not Promise.all: the public Ogmios endpoint only
|
||||||
walletsFile.wallets.map(async (w: WalletEntry): Promise<CheckedWallet> => {
|
// serves a few concurrent ledger-state queries and 500s the rest, so a
|
||||||
const minLovelace = ap3xToLovelace(w.minAp3x ?? defaultMinAp3x);
|
// burst over the full wallet list reliably fails several wallets.
|
||||||
try {
|
const checked: CheckedWallet[] = [];
|
||||||
const utxos = await provider.queryUtxosByAddress(w.address);
|
for (const w of walletsFile.wallets) {
|
||||||
const lovelace = utxos.reduce((a, u) => a + BigInt(u.lovelace), 0n);
|
checked.push(await checkWallet(provider, w, ap3xToLovelace(w.minAp3x ?? defaultMinAp3x)));
|
||||||
return { name: w.name, address: w.address, minLovelace, lovelace, error: null };
|
}
|
||||||
} catch (e) {
|
|
||||||
const error = e instanceof Error ? e.message : String(e);
|
|
||||||
console.error(`[wallet-monitor] query failed for ${w.name} (${w.address}): ${error}`);
|
|
||||||
return { name: w.name, address: w.address, minLovelace, lovelace: null, error };
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const anyQueryFailed = checked.some((c) => c.error !== null);
|
const anyQueryFailed = checked.some((c) => c.error !== null);
|
||||||
|
|
||||||
@@ -119,11 +135,11 @@ async function main(): Promise<void> {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const nextState: StateFile = { ...prevState };
|
const nextState: StateFile = { ...prevState };
|
||||||
|
|
||||||
// Classify each successfully-queried wallet, advancing dedup state. The
|
// Classify each successfully-queried wallet. The decision to POST is driven
|
||||||
// decision to POST is driven only by transitions (a newly-low wallet, a low
|
// only by state CHANGES (a wallet newly dropping low, or recovering) — a
|
||||||
// wallet past its reminder window, or a recovery). But when we DO post, the
|
// wallet that stays low never re-posts. But when we DO post, the message
|
||||||
// message lists EVERY monitored wallet for full context (built below).
|
// lists EVERY monitored wallet for full context (built below).
|
||||||
let anyDueLow = false;
|
let anyNewlyLow = false;
|
||||||
let anyRecovered = false;
|
let anyRecovered = false;
|
||||||
const recoveredNames = new Set<string>();
|
const recoveredNames = new Set<string>();
|
||||||
|
|
||||||
@@ -136,14 +152,12 @@ async function main(): Promise<void> {
|
|||||||
const isLow = c.lovelace < c.minLovelace;
|
const isLow = c.lovelace < c.minLovelace;
|
||||||
|
|
||||||
if (isLow) {
|
if (isLow) {
|
||||||
const wasLow = prev?.status === "low";
|
if (prev?.status === "low") {
|
||||||
const due = !wasLow || now - (prev?.lastAlertMs ?? 0) >= reminderMs;
|
// Still low — already alerted when it dropped; stay silent.
|
||||||
if (due) {
|
nextState[c.name] = prev;
|
||||||
anyDueLow = true;
|
|
||||||
nextState[c.name] = { status: "low", lastAlertMs: now };
|
|
||||||
} else {
|
} else {
|
||||||
// Still low but the reminder window hasn't elapsed — keep prev as-is.
|
anyNewlyLow = true;
|
||||||
nextState[c.name] = prev ?? { status: "low", lastAlertMs: now };
|
nextState[c.name] = { status: "low", lastAlertMs: now };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (prev?.status === "low") {
|
if (prev?.status === "low") {
|
||||||
@@ -154,7 +168,7 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const needPost = anyDueLow || anyRecovered;
|
const needPost = anyNewlyLow || anyRecovered;
|
||||||
|
|
||||||
if (needPost) {
|
if (needPost) {
|
||||||
const anyLow = checked.some(
|
const anyLow = checked.some(
|
||||||
@@ -200,12 +214,12 @@ async function main(): Promise<void> {
|
|||||||
).length;
|
).length;
|
||||||
console.log(
|
console.log(
|
||||||
lowCount > 0
|
lowCount > 0
|
||||||
? `[wallet-monitor] ${lowCount} wallet(s) low but already alerted within the reminder window — no Slack post`
|
? `[wallet-monitor] ${lowCount} wallet(s) still low (already alerted) — no change, no Slack post`
|
||||||
: "[wallet-monitor] all wallets healthy — no Slack post",
|
: "[wallet-monitor] all wallets healthy — no Slack post",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist the new state (records ok/low statuses + advanced reminder stamps).
|
// Persist the new state (records ok/low statuses + alert stamps).
|
||||||
// Only reached when there was nothing to post or the post succeeded.
|
// Only reached when there was nothing to post or the post succeeded.
|
||||||
saveState(cfg.statePath, nextState);
|
saveState(cfg.statePath, nextState);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"default_min_ap3x": 10,
|
"default_min_ap3x": 10,
|
||||||
"reminder_hours": 6,
|
|
||||||
"wallets": [
|
"wallets": [
|
||||||
{ "name": "buyer", "address": "addr1_REPLACE_ME" },
|
{ "name": "buyer", "address": "addr1_REPLACE_ME" },
|
||||||
{ "name": "supplier-ollama", "address": "addr1_REPLACE_ME" },
|
{ "name": "supplier-ollama", "address": "addr1_REPLACE_ME" },
|
||||||
|
|||||||
Reference in New Issue
Block a user