feat(market): first-class barter in offers (item tag)

Listings already supported price_type=trade, and attestations carry method=trade,
so swaps counted for reputation. Offers, though, could only name a sats amount.

- 30403 messages may now carry an optional 'item' tag: what the sender is
  offering in kind. It combines with amount (e.g. 2,000 sats + an 8.25 deck) or
  stands alone for a pure swap.
- OfferDialog: 'Or trading something' field + validation (amount, item or note).
- ListingOffersPanel: the thread shows the offered item, and the counter UI takes
  an optional item alongside the amount.
- Bell/OS text reads it ('offered 2,000 SATS + an 8.25 deck').
- NIP.md documents the item tag; AGENTS notes barter is now first-class.
This commit is contained in:
SkateSpots 2026-09-29 18:50:40 +07:00
parent 0b7371263c
commit d9a906ab6e
6 changed files with 109 additions and 35 deletions

View file

@ -92,10 +92,13 @@ session with **no other memory of this project**. So:
wired in `OsNotificationBridge`). The bell also has a dedicated **Trades tab** wired in `OsNotificationBridge`). The bell also has a dedicated **Trades tab**
(5th tab, `Handshake`) listing them, with its own count. **Barter is supported (5th tab, `Handshake`) listing them, with its own count. **Barter is supported
by design**: trade attestations carry `method` (`cash|lightning|trade|other` — by design**: trade attestations carry `method` (`cash|lightning|trade|other` —
`MarkSoldDialog` picks it), so a swap feeds reputation exactly like a sale; an `MarkSoldDialog` picks it), so a swap feeds reputation exactly like a sale.
offer with **no `amount`** plus a note is how a swap is proposed (there is **Barter is first-class on the offer side too (2026-09-29):** an offer/counter
deliberately **no** "For trade" listing type yet — `LISTING_TYPES` is may carry an `item` tag (`["item","my 8.25 deck"]`) instead of or alongside
`selling|wtb`). `amount` — `OfferDialog` and the counter UI expose it, and the bell/OS text
reads it ("offered 2,000 SATS + an 8.25 deck"). Listings already support
`price_type=trade` ("Looking to swap"); `LISTING_TYPES` itself is still
`selling|wtb`.
- **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer - **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer
refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts` refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts`
(unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a (unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a

6
NIP.md
View file

@ -795,6 +795,7 @@ of a negotiation is simply its newest message.
| `role` | `offer` (authored by the buyer) \| `response` (authored by the seller) | | `role` | `offer` (authored by the buyer) \| `response` (authored by the seller) |
| `status` | `open` \| `accepted` \| `declined` \| `withdrawn` | | `status` | `open` \| `accepted` \| `declined` \| `withdrawn` |
| `amount` | optional `["amount","45000","SATS"]` | | `amount` | optional `["amount","45000","SATS"]` |
| `item` | optional `["item","my 8.25 deck + spare wheels"]` — an **in-kind offer (barter)**; may be combined with `amount` |
| `alt` | `SkateSpots: marketplace offer` | | `alt` | `SkateSpots: marketplace offer` |
`content` is an optional human message. Either party may answer the newest `open` `content` is an optional human message. Either party may answer the newest `open`
@ -808,7 +809,10 @@ its newest `open` message.
> Clients MUST still parse those (`role` inferred from the `d` marker when the > Clients MUST still parse those (`role` inferred from the `d` marker when the
> `role` tag is absent), but new messages use the unique-`d` thread form. > `role` tag is absent), but new messages use the unique-`d` thread form.
Payment is still arranged directly between the two skaters. Payment is still arranged directly between the two skaters. **Bartering is
first-class**: an offer or counter may carry an `item` (what the sender is
offering) instead of, or alongside, `amount` — and listings already support
`price_type=trade`.
## Community Contributions ## Community Contributions

View file

@ -43,7 +43,10 @@ function statusBadge(status: MarketplaceOffer['status']) {
} }
function amountLabel(m: MarketplaceOffer): string { function amountLabel(m: MarketplaceOffer): string {
return m.amount ? `${m.amount.toLocaleString()} ${m.currency ?? 'SATS'}` : 'no amount'; const parts: string[] = [];
if (m.amount) parts.push(`${m.amount.toLocaleString()} ${m.currency ?? 'SATS'}`);
if (m.offerItem) parts.push(m.offerItem);
return parts.length ? parts.join(' + ') : 'no amount';
} }
/** /**
@ -60,6 +63,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const [counterFor, setCounterFor] = useState<string | null>(null); const [counterFor, setCounterFor] = useState<string | null>(null);
const [counterAmount, setCounterAmount] = useState(''); const [counterAmount, setCounterAmount] = useState('');
const [counterItem, setCounterItem] = useState('');
if (!user || !data) return null; if (!user || !data) return null;
@ -114,8 +118,9 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
const sendCounter = async (thread: OfferThread) => { const sendCounter = async (thread: OfferThread) => {
const amount = Number(counterAmount); const amount = Number(counterAmount);
if (!counterAmount.trim() || !Number.isFinite(amount) || amount <= 0) { const hasAmount = counterAmount.trim() !== '' && Number.isFinite(amount) && amount > 0;
toast({ title: 'Enter a counter amount', variant: 'destructive' }); if (!hasAmount && !counterItem.trim()) {
toast({ title: 'Enter an amount or an item to trade', variant: 'destructive' });
return; return;
} }
setBusy(thread.latest.id); setBusy(thread.latest.id);
@ -123,12 +128,17 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
await counterOffer({ await counterOffer({
listing: { pubkey: item.pubkey, identifier: item.identifier }, listing: { pubkey: item.pubkey, identifier: item.identifier },
previous: thread.latest, previous: thread.latest,
amount, amount: hasAmount ? amount : undefined,
offerItem: counterItem,
}); });
await nudge(thread.latest.author, `↩️ Counter-offer: ${amount.toLocaleString()} sats`); const what = [hasAmount ? `${amount.toLocaleString()} sats` : null, counterItem.trim() || null]
.filter(Boolean)
.join(' + ');
await nudge(thread.latest.author, `↩️ Counter-offer: ${what}`);
toast({ title: 'Counter-offer sent', description: 'It’s their turn now.' }); toast({ title: 'Counter-offer sent', description: 'It’s their turn now.' });
setCounterFor(null); setCounterFor(null);
setCounterAmount(''); setCounterAmount('');
setCounterItem('');
} catch (e) { } catch (e) {
toast({ toast({
title: 'Could not send the counter', title: 'Could not send the counter',
@ -212,6 +222,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
onClick={() => { onClick={() => {
setCounterFor(counterFor === latest.id ? null : latest.id); setCounterFor(counterFor === latest.id ? null : latest.id);
setCounterAmount(latest.amount ? String(Math.round(latest.amount)) : ''); setCounterAmount(latest.amount ? String(Math.round(latest.amount)) : '');
setCounterItem(latest.offerItem ?? '');
}} }}
className="flex-1 gap-1.5 border-orange-500/40 text-orange-400 hover:bg-orange-500/10" className="flex-1 gap-1.5 border-orange-500/40 text-orange-400 hover:bg-orange-500/10"
> >
@ -219,25 +230,33 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
</Button> </Button>
</div> </div>
{counterFor === latest.id && ( {counterFor === latest.id && (
<div className="flex items-center gap-2"> <div className="space-y-2">
<div className="flex items-center gap-2">
<Input
autoFocus
type="number"
inputMode="numeric"
value={counterAmount}
onChange={(e) => setCounterAmount(e.target.value)}
placeholder="Your counter (sats)"
className="h-9 border-white/15 bg-white/5 text-sm text-white"
/>
<Button
size="sm"
onClick={() => sendCounter(thread)}
disabled={busy === latest.id}
className="shrink-0 gap-1.5 bg-orange-500 font-bold text-black hover:bg-orange-500/90"
>
{busy === latest.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
Send
</Button>
</div>
<Input <Input
autoFocus value={counterItem}
type="number" onChange={(e) => setCounterItem(e.target.value)}
inputMode="numeric" placeholder="…or counter with an item to trade (optional)"
value={counterAmount}
onChange={(e) => setCounterAmount(e.target.value)}
placeholder="Your counter (sats)"
className="h-9 border-white/15 bg-white/5 text-sm text-white" className="h-9 border-white/15 bg-white/5 text-sm text-white"
/> />
<Button
size="sm"
onClick={() => sendCounter(thread)}
disabled={busy === latest.id}
className="shrink-0 gap-1.5 bg-orange-500 font-bold text-black hover:bg-orange-500/90"
>
{busy === latest.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
Send
</Button>
</div> </div>
)} )}
</> </>

View file

@ -33,6 +33,7 @@ export function OfferDialog({
const { mutateAsync: sendDM } = useSendDirectMessage(); const { mutateAsync: sendDM } = useSendDirectMessage();
const { toast } = useToast(); const { toast } = useToast();
const [amount, setAmount] = useState(''); const [amount, setAmount] = useState('');
const [offerItem, setOfferItem] = useState('');
const [note, setNote] = useState(''); const [note, setNote] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -42,17 +43,22 @@ export function OfferDialog({
toast({ title: 'Enter a valid amount', variant: 'destructive' }); toast({ title: 'Enter a valid amount', variant: 'destructive' });
return; return;
} }
if (!parsed && !offerItem.trim() && !note.trim()) {
toast({ title: 'Describe your offer', description: 'Add an amount, an item to trade, or a message.', variant: 'destructive' });
return;
}
setBusy(true); setBusy(true);
try { try {
await createOffer({ listing, amount: parsed, currency: 'SATS', note }); await createOffer({ listing, amount: parsed, currency: 'SATS', offerItem, note });
// Best-effort nudge to the seller via NIP-17 DM — never fail the offer. // Best-effort nudge to the seller via NIP-17 DM — never fail the offer.
try { try {
const amountLine = parsed ? ` for ${parsed.toLocaleString()} sats` : ''; const amountLine = parsed ? ` for ${parsed.toLocaleString()} sats` : '';
const itemLine = offerItem.trim() ? ` (offering: ${offerItem.trim()})` : '';
const noteLine = note.trim() ? `\n\n“${note.trim()}”` : ''; const noteLine = note.trim() ? `\n\n“${note.trim()}”` : '';
await sendDM({ await sendDM({
recipients: listing.pubkey, recipients: listing.pubkey,
content: `🛹 New offer on your listing “${title}”${amountLine}.${noteLine}`, content: `🛹 New offer on your listing “${title}”${amountLine}${itemLine}.${noteLine}`,
}); });
} catch { } catch {
/* DM optional (no NIP-44 signer, etc.) */ /* DM optional (no NIP-44 signer, etc.) */
@ -64,6 +70,7 @@ export function OfferDialog({
}); });
onOpenChange(false); onOpenChange(false);
setAmount(''); setAmount('');
setOfferItem('');
setNote(''); setNote('');
} catch (e) { } catch (e) {
toast({ toast({
@ -84,8 +91,8 @@ export function OfferDialog({
<Handshake className="h-5 w-5 text-orange-400" /> Make an offer <Handshake className="h-5 w-5 text-orange-400" /> Make an offer
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm text-gray-400"> <DialogDescription className="text-sm text-gray-400">
Offer an amount for this listing. {sellerName} can accept or decline; Offer sats, or swap something — {sellerName} can accept, decline or counter.
you arrange payment between yourselves. You arrange the handover between yourselves.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -103,6 +110,21 @@ export function OfferDialog({
className="h-11 border-gray-700 bg-gray-900 text-white" className="h-11 border-gray-700 bg-gray-900 text-white"
/> />
</div> </div>
<div className="space-y-2">
<Label htmlFor="offer-item" className="text-xs uppercase tracking-wide text-gray-400">
Or trading something (optional)
</Label>
<Input
id="offer-item"
value={offerItem}
onChange={(e) => setOfferItem(e.target.value)}
placeholder="e.g. my 8.25 deck + spare wheels"
className="h-11 border-gray-700 bg-gray-900 text-white"
/>
<p className="text-[11px] text-gray-500">
Swapping? Put what you&apos;re offering here — you can combine it with sats.
</p>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="offer-note" className="text-xs uppercase tracking-wide text-gray-400"> <Label htmlFor="offer-note" className="text-xs uppercase tracking-wide text-gray-400">
Message (optional) Message (optional)

View file

@ -38,6 +38,8 @@ export interface MarketplaceOffer {
listingCoord: string; listingCoord: string;
amount?: number; amount?: number;
currency?: string; currency?: string;
/** What the sender is offering in kind — how a swap/barter is expressed. */
offerItem?: string;
status: OfferStatus; status: OfferStatus;
message: string; message: string;
/** The message this one answers (`e`), when it's a counter/accept/decline. */ /** The message this one answers (`e`), when it's a counter/accept/decline. */
@ -92,6 +94,7 @@ export function parseOffer(event: NostrEvent): MarketplaceOffer | null {
listingCoord: a, listingCoord: a,
amount: amountTag?.[1] ? Number(amountTag[1]) : undefined, amount: amountTag?.[1] ? Number(amountTag[1]) : undefined,
currency: amountTag?.[2], currency: amountTag?.[2],
offerItem: tag(event, 'item'),
status, status,
message: event.content, message: event.content,
replyTo: tag(event, 'e'), replyTo: tag(event, 'e'),
@ -237,6 +240,7 @@ export function useOfferActions() {
counterparty, counterparty,
amount, amount,
currency = 'SATS', currency = 'SATS',
offerItem,
note, note,
status = 'open', status = 'open',
replyTo, replyTo,
@ -246,6 +250,8 @@ export function useOfferActions() {
counterparty: string; counterparty: string;
amount?: number; amount?: number;
currency?: string; currency?: string;
/** In-kind offer (a swap/barter) — free text, e.g. "my 8.25 deck". */
offerItem?: string;
note?: string; note?: string;
status?: OfferStatus; status?: OfferStatus;
replyTo?: string; replyTo?: string;
@ -266,20 +272,27 @@ export function useOfferActions() {
]; ];
if (replyTo) tags.push(['e', replyTo]); if (replyTo) tags.push(['e', replyTo]);
if (amount && amount > 0) tags.push(['amount', String(amount), currency]); if (amount && amount > 0) tags.push(['amount', String(amount), currency]);
if (offerItem?.trim()) tags.push(['item', offerItem.trim().slice(0, 120)]);
await publish({ kind: OFFER_KIND, content: note?.trim() || '', tags }); await publish({ kind: OFFER_KIND, content: note?.trim() || '', tags });
}; };
/** Buyer: open the negotiation. */ /** Buyer: open the negotiation. */
const createOffer = (args: { listing: { pubkey: string; identifier: string }; amount?: number; currency?: string; note?: string }) => const createOffer = (args: {
publishMessage({ ...args, counterparty: args.listing.pubkey }); listing: { pubkey: string; identifier: string };
amount?: number;
currency?: string;
offerItem?: string;
note?: string;
}) => publishMessage({ ...args, counterparty: args.listing.pubkey });
/** Either side answers with a new amount — the counter-offer volley. */ /** Either side answers with a new amount and/or a different item offered. */
const counterOffer = (args: { const counterOffer = (args: {
listing: { pubkey: string; identifier: string }; listing: { pubkey: string; identifier: string };
previous: MarketplaceOffer; previous: MarketplaceOffer;
amount?: number; amount?: number;
currency?: string; currency?: string;
offerItem?: string;
note?: string; note?: string;
}) => }) =>
publishMessage({ publishMessage({
@ -287,6 +300,7 @@ export function useOfferActions() {
counterparty: args.previous.author, counterparty: args.previous.author,
amount: args.amount, amount: args.amount,
currency: args.currency, currency: args.currency,
offerItem: args.offerItem,
note: args.note, note: args.note,
status: 'open', status: 'open',
replyTo: args.previous.id, replyTo: args.previous.id,

View file

@ -27,6 +27,8 @@ export interface TradeNotification {
counterparty: string; counterparty: string;
amount?: number; amount?: number;
currency?: string; currency?: string;
/** In-kind offer (a swap/barter), when the sender offered an item. */
offerItem?: string;
listingPubkey: string; listingPubkey: string;
listingIdentifier: string; listingIdentifier: string;
/** Present for offer/counter items, so the UI can act inline. */ /** Present for offer/counter items, so the UI can act inline. */
@ -57,6 +59,7 @@ export function useTradeNotifications(pubkey: string | undefined) {
counterparty: latest.author, counterparty: latest.author,
amount: latest.amount, amount: latest.amount,
currency: latest.currency, currency: latest.currency,
offerItem: latest.offerItem,
listingPubkey: latest.listingPubkey, listingPubkey: latest.listingPubkey,
listingIdentifier: latest.listingIdentifier, listingIdentifier: latest.listingIdentifier,
offer: latest, offer: latest,
@ -81,12 +84,21 @@ export function useTradeNotifications(pubkey: string | undefined) {
return { notifications, isLoading: offersLoading || requestsLoading }; return { notifications, isLoading: offersLoading || requestsLoading };
} }
/** "2,000 SATS + an 8.25 deck" — sats, an item, or both. */
function offerWhat(n: TradeNotification): string {
const parts: string[] = [];
if (n.amount) parts.push(`${n.amount.toLocaleString()} ${n.currency ?? 'SATS'}`);
if (n.offerItem) parts.push(n.offerItem);
return parts.join(' + ');
}
export function tradeNotificationText(n: TradeNotification): string { export function tradeNotificationText(n: TradeNotification): string {
const what = offerWhat(n);
switch (n.type) { switch (n.type) {
case 'offer': case 'offer':
return n.amount ? `offered ${n.amount.toLocaleString()} ${n.currency ?? 'SATS'}` : 'made an offer'; return what ? `offered ${what}` : 'made an offer';
case 'counter': case 'counter':
return n.amount ? `countered with ${n.amount.toLocaleString()} ${n.currency ?? 'SATS'}` : 'sent a counter-offer'; return what ? `countered with ${what}` : 'sent a counter-offer';
case 'accepted': case 'accepted':
return n.amount ? `accepted your offer — ${n.amount.toLocaleString()} ${n.currency ?? 'SATS'}` : 'accepted your offer'; return n.amount ? `accepted your offer — ${n.amount.toLocaleString()} ${n.currency ?? 'SATS'}` : 'accepted your offer';
case 'declined': case 'declined':