mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
feat(desktop): project live market notes
Signed-off-by: Wintermute <c0fc581234c3585602139eec347ced7b82af65b6f6c10728348515c0c06c51c3@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
189
desktop/src/features/market/lib/marketProtocol.test.mjs
Normal file
189
desktop/src/features/market/lib/marketProtocol.test.mjs
Normal 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",
|
||||
);
|
||||
});
|
||||
511
desktop/src/features/market/lib/marketProtocol.ts
Normal file
511
desktop/src/features/market/lib/marketProtocol.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
35
desktop/src/features/market/lib/useLiveMarketScenario.ts
Normal file
35
desktop/src/features/market/lib/useLiveMarketScenario.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
35
docs/market-sandbox-v0.md
Normal 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
111
scripts/market-sandbox.sh
Executable 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
|
||||
Reference in New Issue
Block a user