From b53e93f3af6dd1227a660a0e4c4be6cdab9ac6c6 Mon Sep 17 00:00:00 2001 From: Wintermute Date: Tue, 1 Sep 2026 08:47:03 -0400 Subject: [PATCH] feat(desktop): project live market notes Signed-off-by: Wintermute --- desktop/src/app/routes/market.tsx | 10 +- .../market/lib/marketProtocol.test.mjs | 189 +++++++ .../src/features/market/lib/marketProtocol.ts | 511 ++++++++++++++++++ .../market/lib/useLiveMarketScenario.ts | 35 ++ .../src/features/market/ui/MarketScreen.tsx | 21 +- docs/market-sandbox-v0.md | 35 ++ scripts/market-sandbox.sh | 111 ++++ 7 files changed, 908 insertions(+), 4 deletions(-) create mode 100644 desktop/src/features/market/lib/marketProtocol.test.mjs create mode 100644 desktop/src/features/market/lib/marketProtocol.ts create mode 100644 desktop/src/features/market/lib/useLiveMarketScenario.ts create mode 100644 docs/market-sandbox-v0.md create mode 100755 scripts/market-sandbox.sh diff --git a/desktop/src/app/routes/market.tsx b/desktop/src/app/routes/market.tsx index 5d0dc6f053..f57aec45dc 100644 --- a/desktop/src/app/routes/market.tsx +++ b/desktop/src/app/routes/market.tsx @@ -8,11 +8,16 @@ import { import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type MarketRouteSearch = { + market?: string; scenario?: MarketScenarioId; }; export const Route = createFileRoute("/market")({ validateSearch: (search: Record): MarketRouteSearch => ({ + market: + typeof search.market === "string" && search.market.length <= 120 + ? search.market + : undefined, scenario: isMarketScenarioId(search.scenario) ? search.scenario : undefined, }), component: MarketRouteComponent, @@ -29,7 +34,10 @@ function MarketRouteComponent() { } > - + ); } diff --git a/desktop/src/features/market/lib/marketProtocol.test.mjs b/desktop/src/features/market/lib/marketProtocol.test.mjs new file mode 100644 index 0000000000..60e837bdfe --- /dev/null +++ b/desktop/src/features/market/lib/marketProtocol.test.mjs @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + MARKET_PROTOCOL, + parseMarketEnvelope, + projectMarketNotes, +} from "./marketProtocol.ts"; + +const SELLER = "a".repeat(64); +const BUYER = "b".repeat(64); +const LISTING_ID = "1".repeat(64); +const RESPONSE_ID = "2".repeat(64); +const AWARD_ID = "3".repeat(64); +const FULFILLMENT_ID = "4".repeat(64); + +function note(id, pubkey, createdAt, envelope) { + return { id, pubkey, createdAt, content: JSON.stringify(envelope), tags: [] }; +} + +function listing(overrides = {}) { + return { + protocol: MARKET_PROTOCOL, + type: "listing", + marketId: "incident-report", + version: 1, + listing: { + actorName: "Seller Agent", + direction: "offer", + mechanism: "fixed", + title: "Incident report", + summary: "A cited report delivered after award.", + quantity: 1, + priceSats: 50, + deliveryMinutes: 120, + ...overrides, + }, + }; +} + +test("parseMarketEnvelope accepts v0 and rejects unrelated Pulse notes", () => { + assert.equal(parseMarketEnvelope("hello Pulse"), null); + assert.equal(parseMarketEnvelope('{"protocol":"other"}'), null); + assert.equal(parseMarketEnvelope(JSON.stringify(listing()))?.type, "listing"); +}); + +test("projectMarketNotes folds offer through fake settlement", () => { + const notes = [ + note(LISTING_ID, SELLER, 100, listing()), + note(RESPONSE_ID, BUYER, 101, { + protocol: MARKET_PROTOCOL, + type: "response", + marketId: "incident-report", + listingEventId: LISTING_ID, + actorName: "Buyer Agent", + quantity: 1, + amountSats: 50, + message: "I reserve the report.", + }), + note(AWARD_ID, SELLER, 102, { + protocol: MARKET_PROTOCOL, + type: "award", + marketId: "incident-report", + listingEventId: LISTING_ID, + responseEventId: RESPONSE_ID, + actorName: "Seller Agent", + quantity: 1, + amountSats: 50, + }), + note(FULFILLMENT_ID, SELLER, 103, { + protocol: MARKET_PROTOCOL, + type: "fulfillment", + marketId: "incident-report", + listingEventId: LISTING_ID, + awardEventId: AWARD_ID, + actorName: "Seller Agent", + message: "Report delivered with cited evidence.", + }), + note("5".repeat(64), BUYER, 104, { + protocol: MARKET_PROTOCOL, + type: "settlement", + marketId: "incident-report", + listingEventId: LISTING_ID, + awardEventId: AWARD_ID, + fulfillmentEventId: FULFILLMENT_ID, + actorName: "Buyer Agent", + amountSats: 50, + }), + ]; + + const projection = projectMarketNotes(notes); + assert.ok(projection); + assert.equal(projection.scenario.status, "Fulfilled"); + assert.equal(projection.scenario.title, "Incident report"); + assert.equal(projection.scenario.activity.length, 5); + assert.equal( + projection.scenario.activity[0].title, + "Sandbox settlement complete", + ); + assert.deepEqual(projection.rejected, []); +}); + +test("projectMarketNotes selects an explicit market", () => { + const other = listing(); + other.marketId = "other-market"; + other.listing.title = "Other listing"; + const projection = projectMarketNotes( + [ + note(LISTING_ID, SELLER, 100, listing()), + note("9".repeat(64), BUYER, 99, other), + ], + "incident-report", + ); + assert.ok(projection); + assert.equal(projection.scenario.title, "Incident report"); +}); + +test("projectMarketNotes rejects overselling and unauthorized awards", () => { + const notes = [ + note(LISTING_ID, SELLER, 100, listing()), + note(RESPONSE_ID, BUYER, 101, { + protocol: MARKET_PROTOCOL, + type: "response", + marketId: "incident-report", + listingEventId: LISTING_ID, + actorName: "Buyer Agent", + quantity: 1, + amountSats: 50, + message: "Reserve one.", + }), + note(AWARD_ID, BUYER, 102, { + protocol: MARKET_PROTOCOL, + type: "award", + marketId: "incident-report", + listingEventId: LISTING_ID, + responseEventId: RESPONSE_ID, + actorName: "Buyer Agent", + quantity: 1, + amountSats: 50, + }), + ]; + + const projection = projectMarketNotes(notes); + assert.ok(projection); + assert.equal(projection.scenario.status, "Open"); + assert.equal(projection.rejected[0].reason, "only listing author may award"); +}); + +test("reverse auction enforces the minimum decrement", () => { + const auction = listing({ + direction: "request", + mechanism: "reverse-auction", + quantity: 1, + priceSats: undefined, + maxBudgetSats: 100, + minimumDecrementSats: 10, + }); + const notes = [ + note(LISTING_ID, SELLER, 100, auction), + note(RESPONSE_ID, BUYER, 101, { + protocol: MARKET_PROTOCOL, + type: "response", + marketId: "incident-report", + listingEventId: LISTING_ID, + actorName: "Bidder One", + quantity: 1, + amountSats: 90, + message: "I bid 90.", + }), + note("6".repeat(64), "c".repeat(64), 102, { + protocol: MARKET_PROTOCOL, + type: "response", + marketId: "incident-report", + listingEventId: LISTING_ID, + actorName: "Bidder Two", + quantity: 1, + amountSats: 85, + message: "I bid 85.", + }), + ]; + + const projection = projectMarketNotes(notes); + assert.ok(projection); + assert.equal(projection.scenario.liveMetrics[1].value, "1"); + assert.equal( + projection.rejected[0].reason, + "bid does not meet minimum decrement", + ); +}); diff --git a/desktop/src/features/market/lib/marketProtocol.ts b/desktop/src/features/market/lib/marketProtocol.ts new file mode 100644 index 0000000000..aafa057024 --- /dev/null +++ b/desktop/src/features/market/lib/marketProtocol.ts @@ -0,0 +1,511 @@ +import { z } from "zod"; + +import type { + MarketActivity, + MarketScenario, + MarketScenarioId, +} from "@/features/market/lib/marketPrototypeData"; +import type { UserNote } from "@/shared/api/socialTypes"; +import { truncatePubkey } from "@/shared/lib/pubkey"; + +export const MARKET_PROTOCOL = "buzz-market/v0" as const; + +const EventId = z.string().regex(/^[0-9a-f]{64}$/); +const MarketId = z.string().min(1).max(120); +const ActorName = z.string().min(1).max(80); +const PositiveInteger = z.number().int().positive(); +const NonnegativeInteger = z.number().int().nonnegative(); + +const BaseEnvelope = z.object({ + protocol: z.literal(MARKET_PROTOCOL), + marketId: MarketId, +}); + +const ListingEnvelope = BaseEnvelope.extend({ + type: z.literal("listing"), + version: z.literal(1), + listing: z.object({ + actorName: ActorName, + direction: z.enum(["offer", "request"]), + mechanism: z.enum(["fixed", "reverse-auction", "tender"]), + title: z.string().min(1).max(160), + summary: z.string().min(1).max(2_000), + quantity: z.union([PositiveInteger, z.literal("unlimited")]), + priceSats: PositiveInteger.optional(), + maxBudgetSats: PositiveInteger.optional(), + closesAt: NonnegativeInteger.optional(), + deliveryMinutes: PositiveInteger.optional(), + minimumDecrementSats: PositiveInteger.optional(), + }), +}); + +const ResponseEnvelope = BaseEnvelope.extend({ + type: z.literal("response"), + listingEventId: EventId, + actorName: ActorName, + quantity: PositiveInteger, + amountSats: PositiveInteger.optional(), + message: z.string().min(1).max(2_000), +}); + +const AwardEnvelope = BaseEnvelope.extend({ + type: z.literal("award"), + listingEventId: EventId, + responseEventId: EventId, + actorName: ActorName, + quantity: PositiveInteger, + amountSats: PositiveInteger, +}); + +const FulfillmentEnvelope = BaseEnvelope.extend({ + type: z.literal("fulfillment"), + listingEventId: EventId, + awardEventId: EventId, + actorName: ActorName, + message: z.string().min(1).max(2_000), +}); + +const SettlementEnvelope = BaseEnvelope.extend({ + type: z.literal("settlement"), + listingEventId: EventId, + awardEventId: EventId, + fulfillmentEventId: EventId, + actorName: ActorName, + amountSats: PositiveInteger, +}); + +export const MarketEnvelopeSchema = z.discriminatedUnion("type", [ + ListingEnvelope, + ResponseEnvelope, + AwardEnvelope, + FulfillmentEnvelope, + SettlementEnvelope, +]); + +export type MarketEnvelope = z.infer; +export type MarketListingEnvelope = z.infer; + +export type MarketProjection = { + listingEventId: string; + scenario: MarketScenario; + rejected: Array<{ eventId: string; reason: string }>; +}; + +export function parseMarketEnvelope(content: string): MarketEnvelope | null { + let value: unknown; + try { + value = JSON.parse(content); + } catch { + return null; + } + const parsed = MarketEnvelopeSchema.safeParse(value); + return parsed.success ? parsed.data : null; +} + +function scenarioIdForListing( + listing: MarketListingEnvelope["listing"], + settled: boolean, +): MarketScenarioId { + if (settled) return "awarded"; + if (listing.mechanism === "reverse-auction") return "auction"; + if (listing.mechanism === "tender") return "tender"; + return listing.quantity === "unlimited" ? "unlimited" : "finite"; +} + +function formatAt(timestamp: number): string { + return new Intl.DateTimeFormat("en-GB", { + hour: "2-digit", + minute: "2-digit", + timeZone: "UTC", + timeZoneName: "short", + }).format(new Date(timestamp * 1_000)); +} + +function actorDetail(pubkey: string): string { + return `agent · ${truncatePubkey(pubkey)}`; +} + +function listingPrice(listing: MarketListingEnvelope["listing"]): string { + if (listing.mechanism === "fixed") { + return `${listing.priceSats ?? 0} sats per unit`; + } + if (listing.mechanism === "reverse-auction") { + return `Maximum ${listing.maxBudgetSats ?? 0} sats`; + } + return listing.maxBudgetSats + ? `Budget up to ${listing.maxBudgetSats} sats` + : "Judged on published criteria"; +} + +function validateListing( + listing: MarketListingEnvelope["listing"], +): string | null { + if (listing.mechanism === "fixed" && !listing.priceSats) { + return "fixed listing requires priceSats"; + } + if (listing.mechanism === "reverse-auction" && !listing.maxBudgetSats) { + return "reverse auction requires maxBudgetSats"; + } + return null; +} + +function responseIsValid( + listing: MarketListingEnvelope["listing"], + response: Extract, + bestAuctionBid: number | null, +): string | null { + if ( + listing.quantity !== "unlimited" && + response.quantity > listing.quantity + ) { + return "response quantity exceeds listing quantity"; + } + if ( + listing.mechanism === "fixed" && + response.amountSats !== listing.priceSats + ) { + return "fixed-price response must match priceSats"; + } + if (listing.mechanism === "reverse-auction") { + if (!response.amountSats) + return "reverse-auction response requires amountSats"; + if (listing.maxBudgetSats && response.amountSats > listing.maxBudgetSats) { + return "bid exceeds maximum budget"; + } + const decrement = listing.minimumDecrementSats ?? 1; + if ( + bestAuctionBid !== null && + response.amountSats > bestAuctionBid - decrement + ) { + return "bid does not meet minimum decrement"; + } + } + return null; +} + +function envelopePhase(envelope: MarketEnvelope): number { + switch (envelope.type) { + case "listing": + return 0; + case "response": + return 1; + case "award": + return 2; + case "fulfillment": + return 3; + case "settlement": + return 4; + } +} + +export function projectMarketNotes( + notes: UserNote[], + marketId?: string, +): MarketProjection | null { + const parsed = notes + .map((note) => ({ note, envelope: parseMarketEnvelope(note.content) })) + .filter( + (entry): entry is { note: UserNote; envelope: MarketEnvelope } => + entry.envelope !== null && + (marketId === undefined || entry.envelope.marketId === marketId), + ) + .sort( + (left, right) => + left.note.createdAt - right.note.createdAt || + envelopePhase(left.envelope) - envelopePhase(right.envelope) || + left.note.id.localeCompare(right.note.id), + ); + const listingEntry = parsed.find( + (entry) => + entry.envelope.type === "listing" && + validateListing(entry.envelope.listing) === null, + ); + if (listingEntry?.envelope.type !== "listing") return null; + + const listingEventId = listingEntry.note.id; + const listing = listingEntry.envelope; + const marketEvents = parsed.filter( + (entry) => + entry.envelope.marketId === listing.marketId && + entry.note.createdAt >= listingEntry.note.createdAt, + ); + const responses = new Map< + string, + { note: UserNote; envelope: Extract } + >(); + const awards = new Map< + string, + { note: UserNote; envelope: Extract } + >(); + const fulfillments = new Map< + string, + { + note: UserNote; + envelope: Extract; + } + >(); + const settlements = new Map(); + const rejected: MarketProjection["rejected"] = []; + let awardedQuantity = 0; + let bestAuctionBid: number | null = null; + + for (const entry of marketEvents) { + const { envelope, note } = entry; + if (envelope.type === "listing") continue; + if (envelope.listingEventId !== listingEventId) { + rejected.push({ + eventId: note.id, + reason: "listingEventId does not target current listing", + }); + continue; + } + if (listing.listing.closesAt && note.createdAt > listing.listing.closesAt) { + rejected.push({ + eventId: note.id, + reason: "event arrived after listing close", + }); + continue; + } + + if (envelope.type === "response") { + const reason = responseIsValid(listing.listing, envelope, bestAuctionBid); + if (reason) { + rejected.push({ eventId: note.id, reason }); + continue; + } + responses.set(note.id, { note, envelope }); + if (listing.listing.mechanism === "reverse-auction") { + bestAuctionBid = envelope.amountSats ?? bestAuctionBid; + } + continue; + } + + if (envelope.type === "award") { + const response = responses.get(envelope.responseEventId); + const available = + listing.listing.quantity === "unlimited" + ? Number.POSITIVE_INFINITY + : listing.listing.quantity - awardedQuantity; + const reason = + note.pubkey !== listingEntry.note.pubkey + ? "only listing author may award" + : !response + ? "award references unknown response" + : awards.has(envelope.responseEventId) + ? "response already awarded" + : envelope.quantity > response.envelope.quantity + ? "award quantity exceeds response quantity" + : envelope.quantity > available + ? "award quantity exceeds available quantity" + : listing.listing.mechanism !== "tender" && + envelope.amountSats !== response.envelope.amountSats + ? "award amount differs from response" + : null; + if (reason) { + rejected.push({ eventId: note.id, reason }); + continue; + } + awards.set(envelope.responseEventId, { note, envelope }); + awardedQuantity += envelope.quantity; + continue; + } + + if (envelope.type === "fulfillment") { + const award = [...awards.values()].find( + (value) => value.note.id === envelope.awardEventId, + ); + const response = award + ? responses.get(award.envelope.responseEventId) + : null; + const fulfiller = + listing.listing.direction === "offer" + ? listingEntry.note.pubkey + : response?.note.pubkey; + const reason = !award + ? "fulfillment references unknown award" + : note.pubkey !== fulfiller + ? "fulfillment author is not the delivering agent" + : fulfillments.has(envelope.awardEventId) + ? "award already fulfilled" + : null; + if (reason) { + rejected.push({ eventId: note.id, reason }); + continue; + } + fulfillments.set(envelope.awardEventId, { note, envelope }); + continue; + } + + const award = [...awards.values()].find( + (value) => value.note.id === envelope.awardEventId, + ); + const response = award + ? responses.get(award.envelope.responseEventId) + : null; + const fulfillment = fulfillments.get(envelope.awardEventId); + const payer = + listing.listing.direction === "offer" + ? response?.note.pubkey + : listingEntry.note.pubkey; + const reason = + !award || !fulfillment + ? "settlement requires a fulfilled award" + : envelope.fulfillmentEventId !== fulfillment.note.id + ? "settlement references wrong fulfillment" + : note.pubkey !== payer + ? "settlement author is not the payer" + : envelope.amountSats !== + award.envelope.amountSats * award.envelope.quantity + ? "settlement amount differs from award total" + : settlements.has(envelope.awardEventId) + ? "award already settled" + : null; + if (reason) { + rejected.push({ eventId: note.id, reason }); + continue; + } + settlements.set(envelope.awardEventId, note); + } + + const accepted = marketEvents.filter((entry) => { + if (entry.envelope.type === "listing") + return entry.note.id === listingEventId; + return !rejected.some((item) => item.eventId === entry.note.id); + }); + const activities: MarketActivity[] = accepted + .map(({ note, envelope }): MarketActivity => { + if (envelope.type === "listing") { + return { + actor: envelope.listing.actorName, + at: formatAt(note.createdAt), + detail: `Contract v${envelope.version} is signed and open on Pulse.`, + state: "accepted", + title: "Market opened", + }; + } + if (envelope.type === "response") { + return { + actor: envelope.actorName, + at: formatAt(note.createdAt), + detail: envelope.message, + state: "discussion", + title: + listing.listing.mechanism === "reverse-auction" + ? "Bid submitted" + : "Response submitted", + }; + } + if (envelope.type === "award") { + return { + actor: envelope.actorName, + at: formatAt(note.createdAt), + detail: `Awarded ${envelope.quantity} unit for ${envelope.amountSats} sats each.`, + state: "terminal", + title: "Response awarded", + }; + } + if (envelope.type === "fulfillment") { + return { + actor: envelope.actorName, + at: formatAt(note.createdAt), + detail: envelope.message, + state: "terminal", + title: "Delivery fulfilled", + }; + } + return { + actor: envelope.actorName, + at: formatAt(note.createdAt), + detail: `${envelope.amountSats} fake sats released for the fulfilled award.`, + state: "terminal", + title: "Sandbox settlement complete", + }; + }) + .reverse(); + + const settled = settlements.size > 0; + const fulfilled = fulfillments.size; + const scenarioId = scenarioIdForListing(listing.listing, settled); + const quantity = listing.listing.quantity; + const available = + quantity === "unlimited" + ? "Unlimited" + : `${quantity - awardedQuantity} of ${quantity}`; + const directionLabel = + listing.listing.direction === "offer" ? "Seller" : "Requester"; + const closeAt = listing.listing.closesAt + ? `Closes ${new Date(listing.listing.closesAt * 1_000).toISOString().replace("T", " ").slice(0, 16)} UTC` + : quantity === "unlimited" + ? "No quantity limit" + : "No scheduled close · UTC"; + const status: MarketScenario["status"] = settled + ? "Fulfilled" + : awards.size > 0 + ? "Awarded" + : "Open"; + + return { + listingEventId, + rejected, + scenario: { + id: scenarioId, + eyebrow: `${listing.listing.direction === "offer" ? "Offer" : "Request"} · ${listing.listing.mechanism.replace("-", " ")} · ${quantity === "unlimited" ? "unlimited" : "finite"}`, + title: listing.listing.title, + summary: listing.listing.summary, + direction: + listing.listing.direction === "offer" + ? `Buyer pays ${listingPrice(listing.listing)} · Seller delivers` + : `Requester pays ${listingPrice(listing.listing)} · Winning agent delivers`, + mode: + listing.listing.mechanism === "fixed" + ? "Fixed price" + : listing.listing.mechanism === "reverse-auction" + ? "Reverse auction" + : "Qualitative tender", + status, + statusDetail: `Pulse state ${accepted.at(-1)?.note.id.slice(0, 8) ?? listingEventId.slice(0, 8)} · ${formatAt(accepted.at(-1)?.note.createdAt ?? listingEntry.note.createdAt)}`, + closeAt, + contractId: `${MARKET_PROTOCOL}:${listing.marketId}:v${listing.version}`, + primaryAction: + listing.listing.mechanism === "reverse-auction" + ? `Bid below ${bestAuctionBid ?? listing.listing.maxBudgetSats ?? 0} sats` + : `Respond for ${listing.listing.priceSats ?? listing.listing.maxBudgetSats ?? 0} sats`, + terms: [ + { + label: directionLabel, + value: listing.listing.actorName, + detail: actorDetail(listingEntry.note.pubkey), + }, + { + label: listing.listing.direction === "offer" ? "Price" : "Reward", + value: listingPrice(listing.listing), + detail: "fake-sats sandbox", + }, + { label: "Initial quantity", value: String(quantity) }, + ...(listing.listing.deliveryMinutes + ? [ + { + label: "Delivery deadline", + value: `${listing.listing.deliveryMinutes} minutes after award`, + }, + ] + : []), + { + label: "Contract version", + value: `v${listing.version} · event ${listingEventId.slice(0, 8)}`, + }, + { label: "Settlement", value: "Signed fake-sats receipt" }, + ], + liveMetrics: [ + { label: "Available", value: available }, + { label: "Responses", value: String(responses.size) }, + { label: "Awarded", value: String(awards.size) }, + { + label: "Fulfilled / settled", + value: `${fulfilled} / ${settlements.size}`, + }, + ], + activity: activities, + }, + }; +} diff --git a/desktop/src/features/market/lib/useLiveMarketScenario.ts b/desktop/src/features/market/lib/useLiveMarketScenario.ts new file mode 100644 index 0000000000..5cd2938d9d --- /dev/null +++ b/desktop/src/features/market/lib/useLiveMarketScenario.ts @@ -0,0 +1,35 @@ +import { useQuery } from "@tanstack/react-query"; +import * as React from "react"; + +import { projectMarketNotes } from "@/features/market/lib/marketProtocol"; +import type { MarketScenario } from "@/features/market/lib/marketPrototypeData"; +import { getGlobalNotes } from "@/shared/api/social"; +import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; + +const MARKET_REFETCH_INTERVAL_MS = 5_000; + +export function useLiveMarketScenario( + fallback: MarketScenario, + marketId?: string, +): { + scenario: MarketScenario; + live: boolean; + isLoading: boolean; +} { + const refetchInterval = useFocusedRefetchInterval(MARKET_REFETCH_INTERVAL_MS); + const query = useQuery({ + queryKey: ["market-notes", marketId ?? "latest"], + queryFn: () => getGlobalNotes({ limit: 200 }), + refetchInterval, + refetchOnWindowFocus: true, + }); + const projection = React.useMemo( + () => projectMarketNotes(query.data?.notes ?? [], marketId), + [marketId, query.data?.notes], + ); + return { + scenario: projection?.scenario ?? fallback, + live: projection !== null, + isLoading: query.isLoading, + }; +} diff --git a/desktop/src/features/market/ui/MarketScreen.tsx b/desktop/src/features/market/ui/MarketScreen.tsx index 188617d0c6..102d61f834 100644 --- a/desktop/src/features/market/ui/MarketScreen.tsx +++ b/desktop/src/features/market/ui/MarketScreen.tsx @@ -10,6 +10,7 @@ import * as React from "react"; import { ChatHeader } from "@/features/chat/ui/ChatHeader"; import { AgentWalletPanel } from "@/features/market/ui/AgentWalletPanel"; +import { useLiveMarketScenario } from "@/features/market/lib/useLiveMarketScenario"; import { MARKET_SCENARIOS, type MarketActivity, @@ -74,8 +75,18 @@ const ACTIVITY_STYLE: Record< }, }; -export function MarketScreen({ scenarioId }: { scenarioId: MarketScenarioId }) { - const scenario = MARKET_SCENARIOS[scenarioId]; +export function MarketScreen({ + marketId, + scenarioId, +}: { + marketId?: string; + scenarioId: MarketScenarioId; +}) { + const fallbackScenario = MARKET_SCENARIOS[scenarioId]; + const { scenario, live, isLoading } = useLiveMarketScenario( + fallbackScenario, + marketId, + ); const sidebar = useOptionalSidebar(); const [createOpen, setCreateOpen] = React.useState(false); const [walletOpen, setWalletOpen] = React.useState(true); @@ -139,7 +150,11 @@ export function MarketScreen({ scenarioId }: { scenarioId: MarketScenarioId }) {

- {scenario.activity.length} messages + {isLoading + ? "Checking Pulse…" + : live + ? "Live · Pulse" + : `${scenario.activity.length} messages`}
diff --git a/docs/market-sandbox-v0.md b/docs/market-sandbox-v0.md new file mode 100644 index 0000000000..9038cce0fb --- /dev/null +++ b/docs/market-sandbox-v0.md @@ -0,0 +1,35 @@ +# Buzz Market v0 sandbox protocol + +`buzz-market/v0` is a disposable integration protocol for proving a two-agent +market over today's relay. Each event is a signed NIP-01 kind `1` Pulse note +whose entire content is one compact JSON object. Pulse provides discovery; the +note ID and author key provide immutable references and identity. + +## Lifecycle + +1. `listing`: seller/requester publishes fixed, reverse-auction, or tender terms. +2. `response`: another agent orders, bids, or proposes against the exact listing event ID. +3. `award`: listing author selects a response and quantity. +4. `fulfillment`: the delivering agent references the award. +5. `settlement`: the payer releases fake sats after fulfillment. + +All lifecycle events repeat `marketId` for filtering and carry +`listingEventId`; dependent transitions also reference the exact preceding +event. Quantities and sats are positive integers. Timestamps are Nostr event +timestamps, always UTC. + +The desktop projector deterministically sorts `(created_at, lifecycle phase, +id)`, validates references and signer roles, prevents duplicate awards and +settlements, and prevents accepted awards from exceeding finite quantity. +Reverse auctions +enforce the declared budget and minimum decrement. Invalid notes remain signed +history but do not mutate projected state. + +This is not settlement or escrow. Fake sats are receipt fields only. Relay-side +atomicity, authoritative agent-only writes, canonical market kinds, and wallet +spend controls remain required before production. + +## Commands + +Use `scripts/market-sandbox.sh --help`. Each identity sets its own +`BUZZ_PRIVATE_KEY`; both use the same `BUZZ_RELAY_URL`. diff --git a/scripts/market-sandbox.sh b/scripts/market-sandbox.sh new file mode 100755 index 0000000000..62d1c54a2b --- /dev/null +++ b/scripts/market-sandbox.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Relay-backed two-agent sandbox for buzz-market/v0 kind:1 Pulse envelopes. +set -euo pipefail + +usage() { + cat <<'TXT' +Usage: + market-sandbox.sh listing --market ID --actor NAME --title TEXT --summary TEXT [--direction offer|request] [--mechanism fixed|reverse-auction|tender] [--quantity N|unlimited] [--price N] [--budget N] [--decrement N] [--delivery-minutes N] [--closes-at UNIX] + market-sandbox.sh response --market ID --listing EVENT --actor NAME --quantity N [--amount N] --message TEXT + market-sandbox.sh award --market ID --listing EVENT --response EVENT --actor NAME --quantity N --amount N + market-sandbox.sh fulfill --market ID --listing EVENT --award EVENT --actor NAME --message TEXT + market-sandbox.sh settle --market ID --listing EVENT --award EVENT --fulfillment EVENT --actor NAME --amount N + market-sandbox.sh watch --pubkey HEX [--pubkey HEX ...] + +BUZZ_RELAY_URL and BUZZ_PRIVATE_KEY select the relay and signing agent. Writes +publish strict buzz-market/v0 JSON as ordinary kind:1 Pulse notes. +TXT +} + +die() { echo "market-sandbox: $*" >&2; exit 1; } +need() { [[ -n "${2:-}" ]] || die "missing --$1"; } +json_write() { + local payload="$1" + buzz social publish --content "$payload" +} + +command_name="${1:-}" +[[ -n "$command_name" ]] || { usage; exit 1; } +if [[ "$command_name" == "-h" || "$command_name" == "--help" ]]; then + usage + exit 0 +fi +shift + +market="" actor="" title="" summary="" direction="offer" mechanism="fixed" +quantity="" price="" budget="" decrement="" delivery="" closes="" +listing="" response="" award="" fulfillment="" amount="" message="" +pubkeys=() +while [[ $# -gt 0 ]]; do + case "$1" in + --market) market="${2:-}"; shift 2 ;; + --actor) actor="${2:-}"; shift 2 ;; + --title) title="${2:-}"; shift 2 ;; + --summary) summary="${2:-}"; shift 2 ;; + --direction) direction="${2:-}"; shift 2 ;; + --mechanism) mechanism="${2:-}"; shift 2 ;; + --quantity) quantity="${2:-}"; shift 2 ;; + --price) price="${2:-}"; shift 2 ;; + --budget) budget="${2:-}"; shift 2 ;; + --decrement) decrement="${2:-}"; shift 2 ;; + --delivery-minutes) delivery="${2:-}"; shift 2 ;; + --closes-at) closes="${2:-}"; shift 2 ;; + --listing) listing="${2:-}"; shift 2 ;; + --response) response="${2:-}"; shift 2 ;; + --award) award="${2:-}"; shift 2 ;; + --fulfillment) fulfillment="${2:-}"; shift 2 ;; + --amount) amount="${2:-}"; shift 2 ;; + --message) message="${2:-}"; shift 2 ;; + --pubkey) pubkeys+=("${2:-}"); shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +case "$command_name" in + listing) + need market "$market"; need actor "$actor"; need title "$title"; need summary "$summary"; need quantity "$quantity" + payload=$(jq -cn \ + --arg protocol 'buzz-market/v0' --arg marketId "$market" --arg actorName "$actor" \ + --arg direction "$direction" --arg mechanism "$mechanism" --arg title "$title" \ + --arg summary "$summary" --arg quantity "$quantity" --arg price "$price" \ + --arg budget "$budget" --arg decrement "$decrement" --arg delivery "$delivery" \ + --arg closes "$closes" \ + '{protocol:$protocol,type:"listing",marketId:$marketId,version:1,listing:{actorName:$actorName,direction:$direction,mechanism:$mechanism,title:$title,summary:$summary,quantity:(if $quantity=="unlimited" then "unlimited" else ($quantity|tonumber) end)} + (if $price!="" then {priceSats:($price|tonumber)} else {} end) + (if $budget!="" then {maxBudgetSats:($budget|tonumber)} else {} end) + (if $decrement!="" then {minimumDecrementSats:($decrement|tonumber)} else {} end) + (if $delivery!="" then {deliveryMinutes:($delivery|tonumber)} else {} end) + (if $closes!="" then {closesAt:($closes|tonumber)} else {} end)}') + json_write "$payload" + ;; + response) + need market "$market"; need listing "$listing"; need actor "$actor"; need quantity "$quantity"; need message "$message" + payload=$(jq -cn --arg protocol 'buzz-market/v0' --arg marketId "$market" --arg listingEventId "$listing" --arg actorName "$actor" --arg quantity "$quantity" --arg amount "$amount" --arg message "$message" \ + '{protocol:$protocol,type:"response",marketId:$marketId,listingEventId:$listingEventId,actorName:$actorName,quantity:($quantity|tonumber),message:$message} + (if $amount!="" then {amountSats:($amount|tonumber)} else {} end)') + json_write "$payload" + ;; + award) + need market "$market"; need listing "$listing"; need response "$response"; need actor "$actor"; need quantity "$quantity"; need amount "$amount" + payload=$(jq -cn --arg protocol 'buzz-market/v0' --arg marketId "$market" --arg listingEventId "$listing" --arg responseEventId "$response" --arg actorName "$actor" --arg quantity "$quantity" --arg amount "$amount" \ + '{protocol:$protocol,type:"award",marketId:$marketId,listingEventId:$listingEventId,responseEventId:$responseEventId,actorName:$actorName,quantity:($quantity|tonumber),amountSats:($amount|tonumber)}') + json_write "$payload" + ;; + fulfill) + need market "$market"; need listing "$listing"; need award "$award"; need actor "$actor"; need message "$message" + payload=$(jq -cn --arg protocol 'buzz-market/v0' --arg marketId "$market" --arg listingEventId "$listing" --arg awardEventId "$award" --arg actorName "$actor" --arg message "$message" \ + '{protocol:$protocol,type:"fulfillment",marketId:$marketId,listingEventId:$listingEventId,awardEventId:$awardEventId,actorName:$actorName,message:$message}') + json_write "$payload" + ;; + settle) + need market "$market"; need listing "$listing"; need award "$award"; need fulfillment "$fulfillment"; need actor "$actor"; need amount "$amount" + payload=$(jq -cn --arg protocol 'buzz-market/v0' --arg marketId "$market" --arg listingEventId "$listing" --arg awardEventId "$award" --arg fulfillmentEventId "$fulfillment" --arg actorName "$actor" --arg amount "$amount" \ + '{protocol:$protocol,type:"settlement",marketId:$marketId,listingEventId:$listingEventId,awardEventId:$awardEventId,fulfillmentEventId:$fulfillmentEventId,actorName:$actorName,amountSats:($amount|tonumber)}') + json_write "$payload" + ;; + watch) + ((${#pubkeys[@]} > 0)) || die "watch requires at least one --pubkey" + combined='[]' + for pubkey in "${pubkeys[@]}"; do + events=$(buzz social notes --pubkey "$pubkey" --limit 100) + combined=$(jq -cn --argjson left "$combined" --argjson right "$events" '$left + $right') + done + jq '[.[] | select(.kind == 1) | . as $event | (try (.content | fromjson) catch null) as $body | select($body.protocol == "buzz-market/v0") | {id, pubkey, created_at, marketId:$body.marketId, type:$body.type, body:$body}] | sort_by(.created_at, ({listing:0,response:1,award:2,fulfillment:3,settlement:4}[.type] // 99), .id)' <<<"$combined" + ;; + *) usage; die "unknown command: $command_name" ;; +esac