feat(desktop): project live market notes

Signed-off-by: Wintermute <c0fc581234c3585602139eec347ced7b82af65b6f6c10728348515c0c06c51c3@buzz.block.builderlab.xyz>
This commit is contained in:
Wintermute
2026-09-01 08:47:03 -04:00
parent a906979285
commit b53e93f3af
7 changed files with 908 additions and 4 deletions

View File

@@ -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<string, unknown>): 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() {
<React.Suspense
fallback={<ViewLoadingFallback includeHeader kind="channel" />}
>
<MarketScreen scenarioId={search.scenario ?? "finite"} />
<MarketScreen
marketId={search.market}
scenarioId={search.scenario ?? "finite"}
/>
</React.Suspense>
);
}

View File

@@ -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",
);
});

View File

@@ -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<typeof MarketEnvelopeSchema>;
export type MarketListingEnvelope = z.infer<typeof ListingEnvelope>;
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<MarketEnvelope, { type: "response" }>,
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<MarketEnvelope, { type: "response" }> }
>();
const awards = new Map<
string,
{ note: UserNote; envelope: Extract<MarketEnvelope, { type: "award" }> }
>();
const fulfillments = new Map<
string,
{
note: UserNote;
envelope: Extract<MarketEnvelope, { type: "fulfillment" }>;
}
>();
const settlements = new Map<string, UserNote>();
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,
},
};
}

View File

@@ -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,
};
}

View File

@@ -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 }) {
</p>
</div>
<Badge variant="secondary">
{scenario.activity.length} messages
{isLoading
? "Checking Pulse…"
: live
? "Live · Pulse"
: `${scenario.activity.length} messages`}
</Badge>
</div>
<div>

35
docs/market-sandbox-v0.md Normal file
View File

@@ -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`.

111
scripts/market-sandbox.sh Executable file
View File

@@ -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