Skip to content
Open
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
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,10 @@ schemars = { version = "1", features = ["chrono04"] }

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

# Configuration — defaults ← YAML ← SCARCED_* env (ludovic, 2026-08-01)
Expand Down
8 changes: 7 additions & 1 deletion crates/studio-api/src/endpoints/create_rfq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,13 @@ pub async fn handler(

match studio_store::rfqs::insert(&state.db, &rfq).await {
Ok(()) => {
tracing::info!(rfq_id = %rfq.id, buyer = %rfq.buyer_npub, "rfq captured");
// Identity union: log whichever buyer key the capture carried.
let buyer = rfq
.buyer_npub
.as_deref()
.or(rfq.buyer_solana_pubkey.as_deref())
.unwrap_or("<none>");
tracing::info!(rfq_id = %rfq.id, buyer, "rfq captured");
state.emit(crate::LifecycleBeat::DemandCaptured {
rfq: Box::new(rfq.clone()),
});
Expand Down
1 change: 1 addition & 0 deletions crates/studio-api/tests/project_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ async fn contract(app: &axum::Router) -> String {
{ "recipient": "CrewAgentA111111111111111111111111111111111", "bps": 10000 }
]},
"channel": { "idle_timeout_seconds": 604_800 },
"engagement_endpoint": "https://scarce.sh/api/v1/engagements/rfq-1",
"expires_at": "2099-01-01T00:00:00Z"
})),
)
Expand Down
12 changes: 12 additions & 0 deletions crates/studio-api/tests/quote_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ fn quote_body(expires_at: &str) -> serde_json::Value {
{ "recipient": "CrewAgentA111111111111111111111111111111111", "bps": 10000 }
]},
"channel": { "idle_timeout_seconds": 604_800 },
"engagement_endpoint": "https://scarce.sh/api/v1/engagements/rfq-1",
"expires_at": expires_at
})
}
Expand Down Expand Up @@ -110,6 +111,17 @@ async fn quote_round_trip_with_defaults_applied() {
assert!(created["gate_policy"]["edges"]["QUOTED->FUNDED"].is_array());
assert!(created["created_at"].is_string());
assert_eq!(created["lapsed_at"], serde_json::Value::Null);
// commission-flow seam: the funding target and the quote commitment the
// session terms hash-commit at accept are both on the record
assert_eq!(
created["engagement_endpoint"],
"https://scarce.sh/api/v1/engagements/rfq-1"
);
assert_eq!(
created["quote_hash"].as_str().map(str::len),
Some(64),
"quote hash sealed at issue"
);

// buyer read is free (no bearer) and identical
let (status, fetched) = send(
Expand Down
51 changes: 51 additions & 0 deletions crates/studio-api/tests/rfq_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,57 @@ async fn post_get_round_trip() {
assert_eq!(fetched, created, "GET returns exactly what POST created");
}

#[tokio::test]
async fn pay_intake_rfq_round_trips() {
// The pay-side shape (capability-request draft-00 slice 1): Solana-keyed
// buyer, no npub, brief from the intake interview.
let app = app().await;

let (status, created) = send(
&app,
"POST",
"/api/v1/rfqs",
Some(serde_json::json!({
"query": "solana priority fee forecast api",
"buyer_solana_pubkey": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"brief": {
"example_exchange": {
"request": { "program_id": "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4" },
"response": { "p50_lamports": 12000, "p90_lamports": 55000 }
},
"freshness": { "kind": "cached", "ttl_seconds": 30 },
"upstream_dependencies": [
{ "name": "helius rpc", "est_cost_per_call": { "amount": 10, "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" } }
],
"volume": { "calls_per_month": 50_000, "avg_request_bytes": 128, "avg_response_bytes": 512 },
"compute_class": "cpu",
"state": { "kind": "cache" },
"interface": "request_response"
}
})),
)
.await;
assert_eq!(status, StatusCode::CREATED, "{created}");
assert_eq!(created["buyer_npub"], serde_json::Value::Null);
assert_eq!(created["brief"]["freshness"]["kind"], "cached");

let id = created["id"].as_str().expect("id assigned");
let (status, fetched) = send(&app, "GET", &format!("/api/v1/rfqs/{id}"), None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(fetched, created, "brief survives the store round trip");

// No identity at all refuses at the door.
let (status, body) = send(
&app,
"POST",
"/api/v1/rfqs",
Some(serde_json::json!({ "query": "anything" })),
)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
assert_eq!(body["errors"][0]["field"], "buyer_npub");
}

#[tokio::test]
async fn invalid_rfq_gets_422_with_field_errors() {
let app = app().await;
Expand Down
10 changes: 9 additions & 1 deletion crates/studio-core/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,12 @@ mod tests {
amount: 900_000_000,
mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into(),
}),
buyer_npub: "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy".into(),
buyer_npub: Some(
"npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy".into(),
),
buyer_solana_pubkey: None,
buyer_signature: None,
brief: None,
created_at: ts("2026-08-01T14:00:00Z"),
}
}
Expand Down Expand Up @@ -133,12 +137,16 @@ mod tests {
},
gate_policy: studio_types::GatePolicy::studio_default(),
policy_hash: "ab".repeat(32),
quote_hash: String::new(),
engagement_endpoint:
"https://scarce.sh/api/v1/engagements/3f6b2c1a-0000-4000-8000-000000000000".into(),
expires_at: ts("2026-09-01T00:00:00Z"),
status,
created_at: ts("2026-08-01T15:00:00Z"),
lapsed_at: None,
accepted_at: (status == QuoteStatus::Accepted).then(|| ts("2026-08-01T16:00:00Z")),
}
.with_commitment_hash()
}

fn links() -> ProjectLinks {
Expand Down
16 changes: 12 additions & 4 deletions crates/studio-core/src/quote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ use studio_types::{FieldError, NewQuote, Quote, QuoteStatus};
use crate::gate::commitment_hash;

/// Validate `new` against `now` and assemble the issued quote, including the
/// gate-policy commitment hash (PLAN.md §2.1(4)). The single path from
/// submission to `Quote` — handlers only supply `rfq_id`, `id`, and `now`.
/// gate-policy commitment hash (PLAN.md §2.1(4)) and the quote's own
/// commitment hash (capability-request draft-00 §4 — session terms hash-commit
/// the quote at accept). The single path from submission to `Quote` —
/// handlers only supply `rfq_id`, `id`, and `now`.
pub fn issue(
new: NewQuote,
rfq_id: String,
Expand All @@ -23,18 +25,21 @@ pub fn issue(
id,
rfq_id,
policy_hash: commitment_hash(&new.gate_policy),
quote_hash: String::new(),
price: new.price,
milestones: new.milestones,
timeline: new.timeline,
payout_destination: new.payout_destination,
channel: new.channel,
engagement_endpoint: new.engagement_endpoint,
gate_policy: new.gate_policy,
expires_at: new.expires_at,
status: QuoteStatus::Quoted,
created_at: now,
lapsed_at: None,
accepted_at: None,
})
}
.with_commitment_hash())
}

/// Why an acceptance was refused. Fail-closed like the gate engine: anything
Expand Down Expand Up @@ -91,13 +96,14 @@ mod tests {
grace_seconds: 172_800,
idle_timeout_seconds: 3_600,
},
engagement_endpoint: "https://scarce.sh/api/v1/engagements/rfq-1".into(),
gate_policy: GatePolicy::studio_default(),
expires_at: ts("2026-08-08T15:00:00Z"),
}
}

#[test]
fn issue_assigns_identity_time_status_and_policy_hash() {
fn issue_assigns_identity_time_status_and_both_hashes() {
let now = ts("2026-08-01T15:00:00Z");
let quote = issue(valid(), "rfq-1".into(), "q-1".into(), now).unwrap();
assert_eq!(quote.id, "q-1");
Expand All @@ -109,6 +115,8 @@ mod tests {
quote.policy_hash,
commitment_hash(&GatePolicy::studio_default())
);
// sealed at issue: the recorded hash is the recomputable commitment
assert_eq!(quote.quote_hash, quote.commitment_hash());
}

#[test]
Expand Down
10 changes: 8 additions & 2 deletions crates/studio-core/src/rfq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ pub fn capture(new: NewRfq, id: String, now: DateTime<Utc>) -> Result<Rfq, Vec<F
competition: new.competition,
budget_ceiling: new.budget_ceiling,
buyer_npub: new.buyer_npub,
buyer_solana_pubkey: new.buyer_solana_pubkey,
buyer_signature: new.buyer_signature,
brief: new.brief,
created_at: now,
})
}
Expand All @@ -40,8 +42,10 @@ mod tests {
monetization: None,
competition: vec!["openssl s_client".into()],
budget_ceiling: None,
buyer_npub: GOOD_NPUB.into(),
buyer_npub: Some(GOOD_NPUB.into()),
buyer_solana_pubkey: None,
buyer_signature: None,
brief: None,
},
"rfq-1".into(),
now,
Expand All @@ -61,8 +65,10 @@ mod tests {
monetization: None,
competition: vec![],
budget_ceiling: None,
buyer_npub: GOOD_NPUB.into(),
buyer_npub: Some(GOOD_NPUB.into()),
buyer_solana_pubkey: None,
buyer_signature: None,
brief: None,
},
"rfq-1".into(),
Utc::now(),
Expand Down
24 changes: 18 additions & 6 deletions crates/studio-store/src/quotes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ pub async fn insert(pool: &SqlitePool, quote: &Quote) -> Result<()> {
"INSERT INTO quotes (rfq_id, id, price_amount, price_mint, milestones,
timeline, payout_destination, grace_seconds,
idle_timeout_seconds, gate_policy, policy_hash,
expires_at, status, created_at, lapsed_at,
accepted_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
engagement_endpoint, expires_at, status,
created_at, lapsed_at, accepted_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
)
.bind(&quote.rfq_id)
.bind(&quote.id)
Expand All @@ -29,6 +29,7 @@ pub async fn insert(pool: &SqlitePool, quote: &Quote) -> Result<()> {
.bind(quote.channel.idle_timeout_seconds as i64)
.bind(serde_json::to_string(&quote.gate_policy).expect("gate policy serializes"))
.bind(&quote.policy_hash)
.bind(&quote.engagement_endpoint)
.bind(quote.expires_at.to_rfc3339())
.bind(status_str(quote.status))
.bind(quote.created_at.to_rfc3339())
Expand Down Expand Up @@ -114,6 +115,8 @@ fn from_row(row: sqlx::sqlite::SqliteRow) -> Result<Quote> {
let status: String = row.get("status");
let lapsed_at: Option<String> = row.get("lapsed_at");
let accepted_at: Option<String> = row.get("accepted_at");
// quote_hash is derived, never stored (studio-types Quote docs) — the
// sealing step below recomputes it from the commitment fields.
Ok(Quote {
id: row.get("id"),
rfq_id: row.get("rfq_id"),
Expand All @@ -130,6 +133,8 @@ fn from_row(row: sqlx::sqlite::SqliteRow) -> Result<Quote> {
},
gate_policy: json("gate_policy", row.get("gate_policy"))?,
policy_hash: row.get("policy_hash"),
engagement_endpoint: row.get("engagement_endpoint"),
quote_hash: String::new(),
expires_at: ts("expires_at", row.get("expires_at"))?,
status: match status.as_str() {
"QUOTED" => QuoteStatus::Quoted,
Expand All @@ -140,7 +145,8 @@ fn from_row(row: sqlx::sqlite::SqliteRow) -> Result<Quote> {
created_at: ts("created_at", row.get("created_at"))?,
lapsed_at: lapsed_at.map(|raw| ts("lapsed_at", raw)).transpose()?,
accepted_at: accepted_at.map(|raw| ts("accepted_at", raw)).transpose()?,
})
}
.with_commitment_hash())
}

#[cfg(test)]
Expand All @@ -165,9 +171,12 @@ mod tests {
monetization: None,
competition: vec![],
budget_ceiling: None,
buyer_npub: "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy"
.into(),
buyer_npub: Some(
"npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy".into(),
),
buyer_solana_pubkey: None,
buyer_signature: None,
brief: None,
created_at: ts("2026-08-01T14:00:00Z"),
},
)
Expand Down Expand Up @@ -210,13 +219,16 @@ mod tests {
idle_timeout_seconds: 604_800,
},
policy_hash: "policy-hash-set-at-issue-time".into(),
quote_hash: String::new(),
gate_policy,
engagement_endpoint: format!("https://scarce.sh/api/v1/engagements/{rfq_id}"),
expires_at: ts(expires_at),
status: QuoteStatus::Quoted,
created_at: ts("2026-08-01T15:00:00Z"),
lapsed_at: None,
accepted_at: None,
}
.with_commitment_hash()
}

#[tokio::test]
Expand Down
Loading
Loading