Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
764 changes: 755 additions & 9 deletions Cargo.lock

Large diffs are not rendered by default.

30 changes: 29 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,25 @@ path = "src/main.rs"

[dependencies]
studio-api = { path = "crates/studio-api" }
studio-buzz = { path = "crates/studio-buzz" }
studio-store = { path = "crates/studio-store" }
studio-types = { path = "crates/studio-types" }

anyhow = { workspace = true }
axum = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true }
figment = { workspace = true }
rustls = { workspace = true }
serde = { workspace = true }
sqlx = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }

[dev-dependencies]
figment = { workspace = true, features = ["test"] }

[workspace]
members = [
Expand Down Expand Up @@ -48,13 +60,29 @@ sqlx = { version = "0.9", features = ["runtime-tokio", "tls-rustls", "sqlite", "
# Serialization — schemars pin matches the buzz workspace (version = "1")
serde = { version = "1", features = ["derive"] }
serde_json = "1"
schemars = "1"
schemars = { version = "1", features = ["chrono04"] }

# Domain
bech32 = "0.11"
chrono = { version = "0.4", features = ["serde"] }
sha2 = "0.10"
uuid = { version = "1", features = ["v4"] }

# Configuration — defaults ← YAML ← SCARCED_* env (ludovic, 2026-08-01)
clap = { version = "4", features = ["derive"] }
figment = { version = "0.10", features = ["yaml", "env"] }

# Buzz coordination substrate — crates consumed directly from the buzz repo
# (ludovic, 2026-08-01). Pinned by rev: the repo is a workspace, not a
# registry; bump deliberately, in lockstep with the relay we run against.
buzz-sdk = { git = "https://github.com/block/buzz", rev = "22be8bb35177e27efc2dca2534df9a8dd871eae0" }
buzz-ws-client = { git = "https://github.com/block/buzz", rev = "22be8bb35177e27efc2dca2534df9a8dd871eae0" }
nostr = "0.44"
# WSS needs a process-level rustls CryptoProvider; every binary entry point
# installs ring explicitly (same pin/reason as buzz-cli) — relying on feature
# unification to pick one silently breaks when the dep graph shifts.
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }

# Errors + observability
anyhow = "1"
thiserror = "2"
Expand Down
104 changes: 101 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,115 @@ crates:
| `studio-pay` | `PayPort`: stub impl through M4, live MPP session impl in M5 |
| `studio-api` | axum routes, auth, SSE |

Implemented so far: RFQ capture (M1) and quote issuance + gate-policy engine
(M2). The orchestrator loop and Buzz integration arrive in M3.

## Install

```bash
just install scarce # cargo-installs the `scarced` binary
```

## Run

```bash
just run # SCARCED_BIND (default 127.0.0.1:7380),
# SCARCED_DB (default sqlite://scarced.db)
curl http://127.0.0.1:7380/healthz
cp scarced.example.yaml scarced.yaml # points at wss://scarce.communities.buzz.xyz
scarced --config scarced.yaml # or `just run --config scarced.yaml`
```

Config precedence: defaults ← YAML ← `SCARCED_*` env (figment). Nested keys
join with `__` in env form. `--config` is optional — env-only also works:

```bash
SCARCED_STUDIO_TOKEN=dev-token scarced
```

| Key | Env | Default | |
|---|---|---|---|
| `bind` | `SCARCED_BIND` | `127.0.0.1:7380` | HTTP bind address |
| `db` | `SCARCED_DB` | `sqlite://scarced.db` | projection store (droppable — rebuildable from substrates) |
| `studio_token` | `SCARCED_STUDIO_TOKEN` | unset | bearer token for quote issuance; unset disables those routes (fail-closed) |
| `sweep_seconds` | `SCARCED_SWEEP_SECONDS` | `30` | quote-expiry sweep cadence |
| `buzz.relay_url` | `SCARCED_BUZZ__RELAY_URL` | unset | community relay the M3 orchestrator connects to |

## Try it

The API is self-describing — start at the index:

```bash
curl -s localhost:7380/api/v1 | jq # every endpoint + schema links
curl -s localhost:7380/api/v1/schemas/rfq | jq # JSON Schema of any wire type
```

Capture demand (open, no auth — this is the signal intake):

```bash
RFQ_ID=$(curl -s localhost:7380/api/v1/rfqs --json '{
"query": "solana priority fee forecast api",
"buyer_npub": "npub1vadgs8qfwsgf7ak3jqvsys6dprae6eyyzzwr8v345l39yz77af4s7eg4zn"
}' | jq -r .id)
```

Invalid input returns `422` with `{ "errors": [{ "field", "message" }] }`.

Issue the quote (studio-authenticated; one per RFQ — a second POST is `409`):

```bash
curl -s localhost:7380/api/v1/rfqs/$RFQ_ID/quote \
-H 'authorization: Bearer dev-token' --json '{
"price": { "amount": 250000000, "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" },
"milestones": [
{ "title": "Forecast model", "description": "p50/p90 per program id", "amount": 150000000 },
{ "title": "Gated endpoint", "description": "pay.sh-gated REST endpoint", "amount": 100000000 }
],
"timeline": "2 weeks, weekly demos",
"payout_destination": { "kind": "splits", "splits": [
{ "recipient": "CrewAgentA111111111111111111111111111111111", "bps": 10000 }
]},
"channel": { "idle_timeout_seconds": 604800 },
"expires_at": "2026-09-01T00:00:00Z"
}' | jq
```

The response carries the defaulted studio gate policy and its `policy_hash`
commitment. The buyer read is free: `GET /api/v1/rfqs/$RFQ_ID/quote` — status
is computed fail-closed against `expires_at`, so a lapsed quote reads
`LAPSED` even before the sweep stamps it.

Accept the quote (buyer, free, once — a second POST is `409`, and a lapsed
quote refuses):

```bash
curl -s -X POST localhost:7380/api/v1/rfqs/$RFQ_ID/quote/accept | jq .status
```

Acceptance stands in for funding while payments are stubbed (PLAN.md §6
override path): the contract starts.

## Watch it in Buzz

With the `buzz` config section present (see `scarced.example.yaml`), every
lifecycle beat is mirrored to the community relay: demand captured, quote
issued, and quote accepted post to the ops channel, and acceptance creates a
per-project workroom channel (`proj-<slug>-<shortid>`) where the
contract-starting post lands. The workroom's channel-create event id is
stored as the FUNDED → WORKROOM_ACTIVE evidence.

Channels are **private**: workrooms carry commercial terms, so only members
see them. The mirror adds the RFQ's buyer to the workroom on creation; the
studio identity is the channel owner. Operator one-offs (flip visibility,
add a member by npub) live in
`cargo run -p studio-buzz --example channel_admin`.

The daemon signs as the studio identity (`buzz.private_key`); a managed-agent
identity also needs the NIP-OA tag (`buzz.auth_tag`, env
`SCARCED_BUZZ__AUTH_TAG`). Omit the whole `buzz` section for a ledger-only
run.

## Develop

```bash
just ci # fmt + clippy -D warnings + test — what CI runs
just schemas # regenerate schemas/*.json from studio-types (drift-tested in CI)
just --list # everything else
```
1 change: 1 addition & 0 deletions crates/studio-api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ chrono = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true }

Expand Down
105 changes: 105 additions & 0 deletions crates/studio-api/src/endpoints/accept_quote.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//! `POST /api/v1/rfqs/{id}/quote/accept` — buyer acceptance. Free like every
//! buyer surface (buyers never authenticate — ARCHITECTURE.md §2.1); the
//! buyer-signed upgrade rides the same reserved-signature path as the RFQ.
//! ACCEPTED stands in for FUNDED while payments are stubbed (PLAN.md §6
//! override path): accepting a live quote starts the contract.

use std::sync::Arc;

use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use studio_core::quote::AcceptError;

use crate::{AppState, LifecycleBeat};

pub async fn handler(
State(state): State<Arc<AppState>>,
Path(rfq_id): Path<String>,
) -> impl IntoResponse {
let quote = match studio_store::quotes::get_by_rfq(&state.db, &rfq_id).await {
Ok(Some(quote)) => quote,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "no quote exists for this rfq" })),
)
}
Err(e) => {
tracing::error!(error = %e, rfq_id = %rfq_id, "quote lookup failed");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "storage failure" })),
);
}
};

// Pure decision first (shared with any CLI/MCP), atomic guard second —
// the UPDATE's WHERE clause re-checks the same rule, so a race loses
// instead of double-accepting.
let now = chrono::Utc::now();
if let Err(refusal) = studio_core::quote::accept(&quote, now) {
return refuse(refusal, &quote);
}
match studio_store::quotes::mark_accepted(&state.db, &rfq_id, now).await {
Ok(1) => {}
Ok(_) => {
// Lost the race between read and update; re-derive the refusal.
let refusal = studio_core::quote::accept(&quote.clone().at(now), now)
.err()
.unwrap_or(AcceptError::AlreadyAccepted);
return refuse(refusal, &quote);
}
Err(e) => {
tracing::error!(error = %e, rfq_id = %rfq_id, "accept update failed");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "storage failure" })),
);
}
}

let accepted = match studio_store::quotes::get_by_rfq(&state.db, &rfq_id).await {
Ok(Some(quote)) => quote,
other => {
tracing::error!(?other, rfq_id = %rfq_id, "accepted quote re-read failed");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "storage failure" })),
);
}
};
tracing::info!(quote_id = %accepted.id, rfq_id = %rfq_id, "quote accepted — contract starting");

match studio_store::rfqs::get(&state.db, &rfq_id).await {
Ok(Some(rfq)) => state.emit(LifecycleBeat::QuoteAccepted {
rfq: Box::new(rfq),
quote: Box::new(accepted.clone()),
}),
other => {
tracing::error!(?other, rfq_id = %rfq_id, "rfq re-read failed; accept beat not mirrored")
}
}

(StatusCode::OK, Json(serde_json::json!(accepted)))
}

fn refuse(
refusal: AcceptError,
quote: &studio_types::Quote,
) -> (StatusCode, Json<serde_json::Value>) {
let message = match refusal {
AcceptError::AlreadyAccepted => "quote already accepted".to_string(),
AcceptError::Lapsed => format!(
"quote lapsed at {} and can no longer be accepted",
quote.expires_at.to_rfc3339()
),
};
(
StatusCode::CONFLICT,
Json(serde_json::json!({ "error": message })),
)
}
3 changes: 3 additions & 0 deletions crates/studio-api/src/endpoints/api_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ pub async fn handler() -> impl IntoResponse {
{ "method": "POST", "path": "/api/v1/rfqs", "description": "capture a demand record (schema: rfq)" },
{ "method": "GET", "path": "/api/v1/rfqs", "description": "list captured RFQs, oldest first (?since=<rfc3339>)" },
{ "method": "GET", "path": "/api/v1/rfqs/{id}", "description": "fetch one captured RFQ" },
{ "method": "POST", "path": "/api/v1/rfqs/{id}/quote", "description": "issue the quote for an RFQ (studio bearer token; schema: quote)" },
{ "method": "GET", "path": "/api/v1/rfqs/{id}/quote", "description": "fetch the quote for an RFQ (status fail-closed against expiry)" },
{ "method": "POST", "path": "/api/v1/rfqs/{id}/quote/accept", "description": "accept a live quote (buyer, free; once) — starts the contract" },
],
"schemas": schemas,
"errors": "validation failures return 422 with { errors: [{ field, message }] }",
Expand Down
Loading
Loading