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**
(5th tab, `Handshake`) listing them, with its own count. **Barter is supported
by design**: trade attestations carry `method` (`cash|lightning|trade|other` —
`MarkSoldDialog` picks it), so a swap feeds reputation exactly like a sale; an
offer with **no `amount`** plus a note is how a swap is proposed (there is
deliberately **no** "For trade" listing type yet — `LISTING_TYPES` is
`selling|wtb`).
`MarkSoldDialog` picks it), so a swap feeds reputation exactly like a sale.
**Barter is first-class on the offer side too (2026-09-29):** an offer/counter
may carry an `item` tag (`["item","my 8.25 deck"]`) instead of or alongside
`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
refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts`
(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) |
| `status` | `open` \| `accepted` \| `declined` \| `withdrawn` |
| `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` |
`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
> `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

View file

@ -43,7 +43,10 @@ function statusBadge(status: MarketplaceOffer['status']) {
}
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 [counterFor, setCounterFor] = useState<string | null>(null);
const [counterAmount, setCounterAmount] = useState('');
const [counterItem, setCounterItem] = useState('');
if (!user || !data) return null;
@ -114,8 +118,9 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
const sendCounter = async (thread: OfferThread) => {
const amount = Number(counterAmount);
if (!counterAmount.trim() || !Number.isFinite(amount) || amount <= 0) {
toast({ title: 'Enter a counter amount', variant: 'destructive' });
const hasAmount = counterAmount.trim() !== '' && Number.isFinite(amount) && amount > 0;
if (!hasAmount && !counterItem.trim()) {
toast({ title: 'Enter an amount or an item to trade', variant: 'destructive' });
return;
}
setBusy(thread.latest.id);
@ -123,12 +128,17 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
await counterOffer({
listing: { pubkey: item.pubkey, identifier: item.identifier },
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.' });
setCounterFor(null);
setCounterAmount('');
setCounterItem('');
} catch (e) {
toast({
title: 'Could not send the counter',
@ -212,6 +222,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
onClick={() => {
setCounterFor(counterFor === latest.id ? null : latest.id);
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"
>
@ -219,6 +230,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
</Button>
</div>
{counterFor === latest.id && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Input
autoFocus
@ -239,6 +251,13 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
Send
</Button>
</div>
<Input
value={counterItem}
onChange={(e) => setCounterItem(e.target.value)}
placeholder="…or counter with an item to trade (optional)"
className="h-9 border-white/15 bg-white/5 text-sm text-white"
/>
</div>
)}
</>
)}

View file

@ -33,6 +33,7 @@ export function OfferDialog({
const { mutateAsync: sendDM } = useSendDirectMessage();
const { toast } = useToast();
const [amount, setAmount] = useState('');
const [offerItem, setOfferItem] = useState('');
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
@ -42,17 +43,22 @@ export function OfferDialog({
toast({ title: 'Enter a valid amount', variant: 'destructive' });
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);
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.
try {
const amountLine = parsed ? ` for ${parsed.toLocaleString()} sats` : '';
const itemLine = offerItem.trim() ? ` (offering: ${offerItem.trim()})` : '';
const noteLine = note.trim() ? `\n\n“${note.trim()}”` : '';
await sendDM({
recipients: listing.pubkey,
content: `🛹 New offer on your listing “${title}”${amountLine}.${noteLine}`,
content: `🛹 New offer on your listing “${title}”${amountLine}${itemLine}.${noteLine}`,
});
} catch {
/* DM optional (no NIP-44 signer, etc.) */
@ -64,6 +70,7 @@ export function OfferDialog({
});
onOpenChange(false);
setAmount('');
setOfferItem('');
setNote('');
} catch (e) {
toast({
@ -84,8 +91,8 @@ export function OfferDialog({
<Handshake className="h-5 w-5 text-orange-400" /> Make an offer
</DialogTitle>
<DialogDescription className="text-sm text-gray-400">
Offer an amount for this listing. {sellerName} can accept or decline;
you arrange payment between yourselves.
Offer sats, or swap something — {sellerName} can accept, decline or counter.
You arrange the handover between yourselves.
</DialogDescription>
</DialogHeader>
@ -103,6 +110,21 @@ export function OfferDialog({
className="h-11 border-gray-700 bg-gray-900 text-white"
/>
</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">
<Label htmlFor="offer-note" className="text-xs uppercase tracking-wide text-gray-400">
Message (optional)

View file

@ -38,6 +38,8 @@ export interface MarketplaceOffer {
listingCoord: string;
amount?: number;
currency?: string;
/** What the sender is offering in kind — how a swap/barter is expressed. */
offerItem?: string;
status: OfferStatus;
message: string;
/** 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,
amount: amountTag?.[1] ? Number(amountTag[1]) : undefined,
currency: amountTag?.[2],
offerItem: tag(event, 'item'),
status,
message: event.content,
replyTo: tag(event, 'e'),
@ -237,6 +240,7 @@ export function useOfferActions() {
counterparty,
amount,
currency = 'SATS',
offerItem,
note,
status = 'open',
replyTo,
@ -246,6 +250,8 @@ export function useOfferActions() {
counterparty: string;
amount?: number;
currency?: string;
/** In-kind offer (a swap/barter) — free text, e.g. "my 8.25 deck". */
offerItem?: string;
note?: string;
status?: OfferStatus;
replyTo?: string;
@ -266,20 +272,27 @@ export function useOfferActions() {
];
if (replyTo) tags.push(['e', replyTo]);
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 });
};
/** Buyer: open the negotiation. */
const createOffer = (args: { listing: { pubkey: string; identifier: string }; amount?: number; currency?: string; note?: string }) =>
publishMessage({ ...args, counterparty: args.listing.pubkey });
const createOffer = (args: {
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: {
listing: { pubkey: string; identifier: string };
previous: MarketplaceOffer;
amount?: number;
currency?: string;
offerItem?: string;
note?: string;
}) =>
publishMessage({
@ -287,6 +300,7 @@ export function useOfferActions() {
counterparty: args.previous.author,
amount: args.amount,
currency: args.currency,
offerItem: args.offerItem,
note: args.note,
status: 'open',
replyTo: args.previous.id,

View file

@ -27,6 +27,8 @@ export interface TradeNotification {
counterparty: string;
amount?: number;
currency?: string;
/** In-kind offer (a swap/barter), when the sender offered an item. */
offerItem?: string;
listingPubkey: string;
listingIdentifier: string;
/** Present for offer/counter items, so the UI can act inline. */
@ -57,6 +59,7 @@ export function useTradeNotifications(pubkey: string | undefined) {
counterparty: latest.author,
amount: latest.amount,
currency: latest.currency,
offerItem: latest.offerItem,
listingPubkey: latest.listingPubkey,
listingIdentifier: latest.listingIdentifier,
offer: latest,
@ -81,12 +84,21 @@ export function useTradeNotifications(pubkey: string | undefined) {
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 {
const what = offerWhat(n);
switch (n.type) {
case 'offer':
return n.amount ? `offered ${n.amount.toLocaleString()} ${n.currency ?? 'SATS'}` : 'made an offer';
return what ? `offered ${what}` : 'made an offer';
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':
return n.amount ? `accepted your offer — ${n.amount.toLocaleString()} ${n.currency ?? 'SATS'}` : 'accepted your offer';
case 'declined':