use abi::repo::{get_record, put_record}; use abi::{AtUri, Call, Event, Handle, Run, entries}; use serde::Deserialize; use serde_json::{Value, json}; // A bidder's market state, all by getRecord at computed keys (no listRecords): // offering/self and heartbeat/self in the bidder's repo (and the offering the beat names, if another); // badgeBlueKeys/association: the operator it claims; the operator's market.association // bidder_associate- '-'> confirms it. // The bidders an account follows here: one computer.socialweb.bidders/self record {bidders: [DID]}. const OFFERING: &str = "com.publicdomainrelay.temp.market.offering"; const HEARTBEAT: &str = "com.publicdomainrelay.temp.market.heartbeat"; const BADGE: &str = "com.publicdomainrelay.temp.badgeBlueKeys"; const ASSOCIATION: &str = "com.publicdomainrelay.temp.market.association"; const INDEX: &str = "computer.socialweb.bidders"; const MAX: usize = 50; #[derive(Deserialize)] struct Args { #[serde(default)] did: String, #[serde(default)] now: String, #[serde(default)] dids: Vec, #[serde(default)] of: Option, #[serde(default)] bidder: Option, } fn read(id: String, repo: &str, collection: &str, rkey: &str) -> Call { get_record(&AtUri::new(repo, collection, rkey)).id(id).service(repo) } // A record, or null when it is not there (any failed read: the row says what it could see). fn found(result: &abi::CallResult) -> Value { result.output().ok().filter(|o| o["value"].is_object()).map(|o| json!({ "uri": o["uri"], "cid": o["cid"], "value": o["value"] })).unwrap_or(Value::Null) } fn endpoints(offering: &Value) -> Value { let list = offering["value"]["endpoints"].as_array().cloned().unwrap_or_default(); let mut out: Vec = list.iter().filter_map(|e| Some(json!({ "kind": e["kind"].as_str()?, "address": e["address"] }))).collect(); if let Some(url) = offering["value"]["endpointUrl"].as_str() { out.push(json!({ "kind": "https", "address": url })); } json!(out) } // bidders {dids}: one row per DID: {did, offering, heartbeat, beatAt, endpoints, appliesTo, withdrawn, operator, claimedOperator}. fn bidders_handler(run: Run) -> Result { match run.event { Event::Start => { let mut dids: Vec = Vec::new(); for d in run.args.dids.iter().map(|d| d.trim()) { if !d.starts_with("did:") { return Err(format!("not a DID: {d}")); } if !dids.iter().any(|x| x == d) { dids.push(d.to_string()); } } if dids.len() > MAX { return Err(format!("at most {MAX} DIDs")); } if dids.is_empty() { return Ok(Handle::new().result(json!({ "bidders": [] })).done()); } let mut h = Handle::new(); for (i, d) in dids.iter().enumerate() { h = h.call(read(format!("o:{i}"), d, OFFERING, "self")).call(read(format!("h:{i}"), d, HEARTBEAT, "self")).call(read(format!("b:{i}"), d, BADGE, "association")); } let rows: Vec = dids.iter().map(|d| json!({ "did": d, "offering": null, "heartbeat": null, "operator": null, "claimedOperator": null })).collect(); Ok(h.state(json!({ "rows": rows, "pending": dids.len() * 3 }))) } Event::Result { id, result, .. } => { let mut s = run.state; let (kind, i) = id.split_once(':').ok_or("bad id")?; let i: usize = i.parse().map_err(|_| "bad id")?; let did = s["rows"][i]["did"].as_str().unwrap_or_default().to_string(); let mut h = Handle::new(); let mut more = 0; let record = found(&result); match kind { "o" | "n" => { if !record.is_null() { s["rows"][i]["offering"] = record; } } "h" => { let named = record["value"]["offering"].as_str().unwrap_or_default().to_string(); s["rows"][i]["heartbeat"] = record; let own = format!("at://{did}/{OFFERING}/"); if let Some(rkey) = named.strip_prefix(&own).filter(|r| *r != "self" && !r.is_empty()) { h = h.call(read(format!("n:{i}"), &did, OFFERING, rkey)); more += 1; } } "b" => { let v = &record["value"]; let op = v["challenge"].as_str().unwrap_or_default(); if op.starts_with("did:") && op != did && v["keyId"] == did.as_str() && v["service"] == "bidder_associate" { s["rows"][i]["claimedOperator"] = json!(op); let rkey = format!("bidder_associate-{}", did.replace(':', "-")); h = h.call(read(format!("a:{i}"), op, ASSOCIATION, &rkey)).call(read(format!("c:{i}"), op, BADGE, &rkey)); more += 2; } } // the operator's side: a standing association, or the same claim in its own badgeBlueKeys "a" | "c" => { let op = s["rows"][i]["claimedOperator"].clone(); let v = &record["value"]; let held = if kind == "a" { v["subject"] == did.as_str() && v["service"] == "bidder_associate" && v.get("contract").is_none() && v.get("expiresAt").is_none() } else { v["challenge"] == op && v["keyId"] == did.as_str() && v["service"] == "bidder_associate" }; if held { s["rows"][i]["operator"] = op; } } _ => {} } let pending = s["pending"].as_u64().unwrap_or(1) - 1 + more; s["pending"] = json!(pending); if pending > 0 { return Ok(h.state(s)); } let rows: Vec = s["rows"].as_array().cloned().unwrap_or_default().into_iter().map(finish).collect(); Ok(Handle::new().result(json!({ "bidders": rows, "at": run.args.now })).done()) } _ => Ok(Handle::new()), } } fn finish(mut row: Value) -> Value { let offering = row["offering"].clone(); row["endpoints"] = endpoints(&offering); row["appliesTo"] = offering["value"]["appliesTo"].clone(); row["withdrawn"] = json!(offering["value"]["withdrawnAt"].is_string()); row["beatAt"] = row["heartbeat"]["value"]["createdAt"].clone(); row } fn index_of(run: &Run) -> Result { let of = run.args.of.clone().filter(|s| !s.is_empty()).unwrap_or(run.args.did.clone()); of.starts_with("did:").then_some(of).ok_or("of: a DID (or sign in)".into()) } fn listed(result: &abi::CallResult) -> Vec { found(result)["value"]["bidders"].as_array().into_iter().flatten().filter_map(|d| d.as_str().map(str::to_string)).collect() } // known {of?}: the bidders an account follows here (default: you). fn known_handler(run: Run) -> Result { let of = index_of(&run)?; Ok(match run.event { Event::Start => Handle::new().call(read("index".into(), &of, INDEX, "self")), Event::Result { id, result, .. } if id == "index" => Handle::new().result(json!({ "of": of, "bidders": listed(&result) })).done(), _ => Handle::new(), }) } fn edit(run: Run, add: bool) -> Result { if !run.args.did.starts_with("did:") { return Err("sign in first".into()); } let bidder = run.args.bidder.clone().map(|b| b.trim().to_string()).filter(|b| b.starts_with("did:")).ok_or("bidder: a DID")?; let me = run.args.did.clone(); Ok(match run.event { Event::Start => Handle::new().call(read("index".into(), &me, INDEX, "self")), Event::Result { id, result, .. } if id == "index" => { let mut list = listed(&result); list.retain(|d| *d != bidder); if add { if list.len() >= MAX { return Err(format!("at most {MAX} bidders")); } list.push(bidder.clone()); } let record = json!({ "$type": INDEX, "bidders": list, "updatedAt": run.args.now }); Handle::new().call(put_record(&AtUri::new(&me, INDEX, "self"), record).id("put")).state(json!({ "bidders": list })) } Event::Result { id, result, .. } if id == "put" => { result.output()?; Handle::new().result(json!({ "bidders": run.state["bidders"] })).done() } _ => Handle::new(), }) } fn track_handler(run: Run) -> Result { edit(run, true) } fn untrack_handler(run: Run) -> Result { edit(run, false) } entries!( bidders => bidders_handler, known => known_handler, track => track_handler, untrack => untrack_handler ); #[cfg(test)] mod tests { use serde_json::{Value, json}; type H = fn(abi::Run) -> Result; fn step(f: H, args: &Value, event: Value, state: &Value) -> Value { let mut input = args.clone(); input["event"] = event; input["state"] = state.clone(); serde_json::to_value(f(serde_json::from_value(input).unwrap()).unwrap()).unwrap() } fn res(id: &str, output: Value) -> Value { json!({ "type": "result", "id": id, "ok": true, "output": output }) } fn miss(id: &str) -> Value { json!({ "type": "result", "id": id, "ok": false, "error": "RecordNotFound" }) } const B: &str = "did:plc:bidder"; const OP: &str = "did:plc:op"; #[test] fn a_bidder_with_a_named_offering_and_a_confirmed_operator() { let args = json!({ "did": "did:plc:me", "now": "2026-11-10T00:00:00Z", "dids": [B, B] }); let s = step(super::bidders_handler, &args, json!({ "type": "start" }), &Value::Null); assert_eq!(s["calls"].as_array().unwrap().len(), 3, "a repeated DID is read once"); let mut state = s["state"].clone(); let s = step(super::bidders_handler, &args, miss("o:0"), &state); state = s["state"].clone(); let named = format!("at://{B}/com.publicdomainrelay.temp.market.offering/3abc"); let s = step(super::bidders_handler, &args, res("h:0", json!({ "uri": "h", "cid": "c", "value": { "offering": named, "createdAt": "2026-11-09T00:00:00Z" } })), &state); assert_eq!(s["calls"][0]["params"]["rkey"], "3abc"); state = s["state"].clone(); let s = step(super::bidders_handler, &args, res("b:0", json!({ "uri": "b", "cid": "c", "value": { "challenge": OP, "keyId": B, "service": "bidder_associate" } })), &state); assert_eq!(s["calls"][0]["params"]["rkey"], "bidder_associate-did-plc-bidder"); assert_eq!(s["calls"][0]["service"], OP); state = s["state"].clone(); let s = step(super::bidders_handler, &args, miss("c:0"), &state); state = s["state"].clone(); let s = step(super::bidders_handler, &args, res("n:0", json!({ "uri": named, "cid": "c", "value": { "appliesTo": ["x"], "endpoints": [{ "kind": "iroh", "address": "t" }] } })), &state); state = s["state"].clone(); let done = step(super::bidders_handler, &args, res("a:0", json!({ "uri": "a", "cid": "c", "value": { "subject": B, "service": "bidder_associate" } })), &state); let row = &done["result"]["bidders"][0]; assert_eq!(row["operator"], OP); assert_eq!(row["beatAt"], "2026-11-09T00:00:00Z"); assert_eq!(row["endpoints"], json!([{ "kind": "iroh", "address": "t" }])); assert_eq!(row["withdrawn"], false); } #[test] fn track_and_untrack_rewrite_the_one_index_record() { let args = json!({ "did": "did:plc:me", "now": "n", "bidder": B }); let s = step(super::track_handler, &args, json!({ "type": "start" }), &Value::Null); assert_eq!(s["calls"][0]["params"]["collection"], "computer.socialweb.bidders"); let s = step(super::track_handler, &args, res("index", json!({ "uri": "u", "cid": "c", "value": { "bidders": ["did:plc:x", B] } })), &Value::Null); assert_eq!(s["calls"][0]["input"]["record"]["bidders"], json!(["did:plc:x", B])); let s = step(super::untrack_handler, &args, miss("index"), &Value::Null); assert_eq!(s["calls"][0]["input"]["record"]["bidders"], json!([])); } }