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