- H13 offer threads: the seller is ALWAYS the listing author (derived from the event pubkey), never a self-declared role tag — a non-owner can no longer appear as the seller with a payment pointer. - H12 trade reputation: a preimage/deposit proof counts at most once, so a real payment can't be reused across attestations to inflate payment-verified. - Crew alerts: listed crews are filtered against the user's own membership confirmation labels (one batched query), so a confirmed member no longer gets a phantom 'confirm to join' badge/notification when the crew is edited. - Error boundaries (app + route) and DesktopNavManager are translated; the class boundary renders a translated functional ErrorFallback. New batch4 namespaces errorsUi.* / desktopNav.*.
562 lines
21 KiB
TypeScript
562 lines
21 KiB
TypeScript
import { useQuery } from '@tanstack/react-query';
|
||
import { useNostr } from '@nostrify/react';
|
||
import type { NostrEvent } from '@nostrify/nostrify';
|
||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||
import { verifyZapReceipt } from '@/lib/zapReceipt';
|
||
import { verifyReceiptProof } from '@/lib/receipts';
|
||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||
import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
|
||
|
||
/**
|
||
* Trade attestations (completed deals) — built on the Attestations NIP
|
||
* (kind 31871), see NIP.md → "Trade Attestations".
|
||
*
|
||
* A **listing** (kind 30402) is the *Assertion Event*. When a deal closes:
|
||
* • the seller publishes a `31871` attesting it (`s=valid`) and naming the
|
||
* buyer in a `p` tag (our documented extension);
|
||
* • the buyer may publish a mirror `31871` naming the seller.
|
||
* Two reciprocal attestations on the same listing = a **verified trade**.
|
||
*
|
||
* Payment method is self-reported metadata; the app cannot observe cash/trade
|
||
* deals and never treats these as proof of payment.
|
||
*/
|
||
export const TRADE_ATTESTATION_KIND = 31871;
|
||
|
||
/**
|
||
* Attestation Request (kind 31872) — the request side of the flow. A buyer
|
||
* whose deal wasn't recorded by the seller can ask them to publish a `31871`;
|
||
* the seller's client surfaces it and fulfils it in one tap.
|
||
* See NIP.md → "Trade Attestations".
|
||
*/
|
||
export const TRADE_REQUEST_KIND = 31872;
|
||
|
||
export const TRADE_METHODS = {
|
||
cash: 'Cash',
|
||
lightning: 'Lightning',
|
||
trade: 'Trade',
|
||
other: 'Other',
|
||
} as const;
|
||
|
||
export type TradeMethod = keyof typeof TRADE_METHODS;
|
||
export type TradeRole = 'sold' | 'bought';
|
||
|
||
export interface ListingCoord {
|
||
kind: number;
|
||
pubkey: string;
|
||
identifier: string;
|
||
}
|
||
|
||
/** Parse an `a` tag ("30402:<pubkey>:<d>") into its parts, or null. */
|
||
export function parseListingCoord(aTag: string | undefined): ListingCoord | null {
|
||
if (!aTag) return null;
|
||
const [kindStr, pubkey, ...rest] = aTag.split(':');
|
||
const kind = Number(kindStr);
|
||
if (!Number.isFinite(kind) || !pubkey || rest.length === 0) return null;
|
||
return { kind, pubkey, identifier: rest.join(':') };
|
||
}
|
||
|
||
/**
|
||
* Is this kind-31871 event a *trade* attestation? We require a 30402 assertion
|
||
* (`a` tag) plus a counterparty (`p`) — our documented marker for trades.
|
||
*/
|
||
export function isTradeAttestation(ev: NostrEvent): boolean {
|
||
if (ev.kind !== TRADE_ATTESTATION_KIND) return false;
|
||
const a = ev.tags.find(([t]) => t === 'a')?.[1];
|
||
const coord = parseListingCoord(a);
|
||
if (!coord || coord.kind !== MARKETPLACE_ITEM_KIND) return false;
|
||
return ev.tags.some(([t]) => t === 'p');
|
||
}
|
||
|
||
/**
|
||
* Is this kind-31872 event a *trade* request? Same marker as attestations: a
|
||
* 30402 assertion (`a`) plus a requested attestor (`p`).
|
||
*/
|
||
export function isTradeRequest(ev: NostrEvent): boolean {
|
||
if (ev.kind !== TRADE_REQUEST_KIND) return false;
|
||
const coord = parseListingCoord(ev.tags.find(([t]) => t === 'a')?.[1]);
|
||
if (!coord || coord.kind !== MARKETPLACE_ITEM_KIND) return false;
|
||
return ev.tags.some(([t]) => t === 'p');
|
||
}
|
||
|
||
/**
|
||
* Publish a trade attestation (kind 31871, addressable).
|
||
*
|
||
* `d` is derived from the listing + role so re-publishing the same attestation
|
||
* replaces it (e.g. to set `s=revoked`), per the NIP's lifecycle.
|
||
*/
|
||
export function useTradeAttestation() {
|
||
const { user } = useCurrentUser();
|
||
const { mutateAsync: publish, isPending } = useNostrPublish();
|
||
|
||
const publishTrade = async ({
|
||
listing,
|
||
role,
|
||
counterparty,
|
||
method,
|
||
amountSats,
|
||
state = 'valid',
|
||
note,
|
||
rating,
|
||
requestCoord,
|
||
paymentProof,
|
||
depositProof,
|
||
}: {
|
||
/** The listing being attested. */
|
||
listing: { pubkey: string; identifier: string };
|
||
role: TradeRole;
|
||
/** The other party's pubkey. */
|
||
counterparty: string;
|
||
method?: TradeMethod;
|
||
amountSats?: number;
|
||
state?: 'verifying' | 'valid' | 'revoked';
|
||
note?: string;
|
||
/** Trade rating, 1–10 (skateboard decks). Optional. */
|
||
rating?: number;
|
||
/** The `31872` request coord this attestation fulfils, if any (spec `request` tag). */
|
||
requestCoord?: string;
|
||
/** A verifiable Lightning receipt: sha256(preimage) === paymentHash. */
|
||
paymentProof?: { preimage: string; paymentHash: string };
|
||
/** A refundable swap-deposit receipt (barter) — verified the same way. */
|
||
depositProof?: { preimage: string; paymentHash: string };
|
||
}) => {
|
||
if (!user) throw new Error('You must be logged in.');
|
||
if (counterparty === user.pubkey) throw new Error('Pick the other party, not yourself.');
|
||
|
||
const coord = `${MARKETPLACE_ITEM_KIND}:${listing.pubkey}:${listing.identifier}`;
|
||
|
||
const tags: string[][] = [
|
||
['d', `${user.pubkey}:trade:${role}:${listing.identifier}`],
|
||
['a', coord],
|
||
['s', state],
|
||
['p', counterparty],
|
||
['alt', role === 'sold' ? 'SkateSpots: sale completed' : 'SkateSpots: purchase confirmed'],
|
||
];
|
||
if (method) tags.push(['method', method]);
|
||
if (typeof amountSats === 'number' && amountSats > 0) {
|
||
tags.push(['amount', String(Math.round(amountSats)), 'SATS']);
|
||
}
|
||
if (typeof rating === 'number' && rating >= 1 && rating <= 10) {
|
||
tags.push(['rating', String(Math.round(rating))]);
|
||
}
|
||
if (requestCoord) tags.push(['request', requestCoord]);
|
||
// Verifiable receipts (anyone can check sha256(preimage) === payment_hash).
|
||
if (paymentProof?.preimage && paymentProof.paymentHash) {
|
||
tags.push(['proof', 'lnpreimage', paymentProof.preimage, paymentProof.paymentHash]);
|
||
}
|
||
if (depositProof?.preimage && depositProof.paymentHash) {
|
||
tags.push(['proof', 'deposit', depositProof.preimage, depositProof.paymentHash]);
|
||
}
|
||
|
||
const name = user.pubkey.slice(0, 8);
|
||
const content =
|
||
note?.trim() ||
|
||
(role === 'sold' ? `Sold by ${name}.` : `Bought from ${name}.`);
|
||
|
||
await publish({ kind: TRADE_ATTESTATION_KIND, content, tags });
|
||
};
|
||
|
||
return { publishTrade, isPending };
|
||
}
|
||
|
||
export interface TradeReputation {
|
||
/** Listings this pubkey sold (they attested as the listing author). */
|
||
sales: number;
|
||
/** Listings they attested as the non-owner party. */
|
||
purchases: number;
|
||
/** Deals where both sides attested the same listing. */
|
||
verified: number;
|
||
/**
|
||
* Mutually-confirmed trades that ALSO carry a `proof` tag (a payment claim).
|
||
* ⚠️ The `proof` is self-reported and the app does NOT verify it — it is a
|
||
* stronger *claim*, never evidence of payment. Show it as "with payment proof",
|
||
* not "verified".
|
||
*/
|
||
withPaymentProof: number;
|
||
/**
|
||
* Trades whose `proof` is a **verified** zap receipt (kind 9735 — signature,
|
||
* recipient and amount all checked). The only client-verifiable tier today.
|
||
*/
|
||
paymentVerified: number;
|
||
/**
|
||
* Distinct counterparties with at least one mutually-confirmed trade.
|
||
*
|
||
* This is the anti-collusion headline: two accounts trading only with each
|
||
* other can inflate `verified` without bound, but never `verifiedCounterparties`
|
||
* beyond 1. UI should lead with this instead of the raw count.
|
||
*/
|
||
verifiedCounterparties: number;
|
||
/** Per-counterparty count of mutually-confirmed trades (for WoT weighting). */
|
||
verifiedWith: { pubkey: string; count: number }[];
|
||
/** Distinct counterparties involved. */
|
||
counterparties: string[];
|
||
/** Raw trade attestations this pubkey is involved in. */
|
||
attestations: NostrEvent[];
|
||
}
|
||
|
||
/**
|
||
* Trade reputation for a pubkey: seller/buyer positions derived from kind-31871
|
||
* attestations, plus a "verified" count where seller and counterparty both
|
||
* attested the same listing (the strong, mutually-confirmed signal).
|
||
*
|
||
* NOTE: consumers should trust-weight counterparties with the existing WoT
|
||
* score so rings of fresh accounts can't inflate reputation.
|
||
*/
|
||
export function useTradeReputation(pubkey: string | undefined) {
|
||
const { nostr } = useNostr();
|
||
|
||
return useQuery<TradeReputation>({
|
||
queryKey: ['trade-reputation', pubkey],
|
||
enabled: !!pubkey,
|
||
staleTime: 5 * 60 * 1000,
|
||
queryFn: async (c): Promise<TradeReputation> => {
|
||
const empty: TradeReputation = { sales: 0, purchases: 0, verified: 0, withPaymentProof: 0, paymentVerified: 0, verifiedCounterparties: 0, verifiedWith: [], counterparties: [], attestations: [] };
|
||
if (!pubkey) return empty;
|
||
|
||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(12000)]);
|
||
const events = await nostr.query(
|
||
[
|
||
{ kinds: [TRADE_ATTESTATION_KIND], authors: [pubkey], limit: 200 },
|
||
{ kinds: [TRADE_ATTESTATION_KIND], '#p': [pubkey], limit: 200 },
|
||
],
|
||
{ signal },
|
||
);
|
||
|
||
const unique = Array.from(new Map(events.map((e) => [e.id, e])).values()).filter(
|
||
(e) => isTradeAttestation(e) && e.tags.find(([t]) => t === 's')?.[1] !== 'revoked',
|
||
);
|
||
|
||
// Group by the attested listing coordinate.
|
||
const byListing = new Map<string, NostrEvent[]>();
|
||
for (const ev of unique) {
|
||
const coord = parseListingCoord(ev.tags.find(([t]) => t === 'a')?.[1]);
|
||
if (!coord) continue;
|
||
const key = `${coord.kind}:${coord.pubkey}:${coord.identifier}`;
|
||
const list = byListing.get(key) ?? [];
|
||
list.push(ev);
|
||
byListing.set(key, list);
|
||
}
|
||
|
||
const counterparties = new Set<string>();
|
||
const verifiedWith = new Map<string, number>();
|
||
let sales = 0;
|
||
let purchases = 0;
|
||
let verified = 0;
|
||
let withPaymentProof = 0;
|
||
|
||
for (const [key, attestations] of byListing) {
|
||
const coord = parseListingCoord(key);
|
||
if (!coord) continue;
|
||
const seller = coord.pubkey;
|
||
const attestors = new Set(attestations.map((e) => e.pubkey));
|
||
const isVerified = attestors.has(seller) && attestors.size >= 2;
|
||
const involved = attestors.has(pubkey);
|
||
|
||
if (involved) {
|
||
if (pubkey === seller) sales++;
|
||
else purchases++;
|
||
if (isVerified) {
|
||
verified++;
|
||
// Attribute the verified trade to the other attestor(s).
|
||
for (const a of attestors) {
|
||
if (a !== pubkey) verifiedWith.set(a, (verifiedWith.get(a) ?? 0) + 1);
|
||
}
|
||
// A `proof` tag is a payment CLAIM, not verification — count it on its
|
||
// own tier so it is never confused with a confirmed trade.
|
||
if (attestations.some((e) => e.tags.some(([t]) => t === 'proof'))) {
|
||
withPaymentProof++;
|
||
}
|
||
}
|
||
|
||
// Record counterparties (everyone else who attested this listing).
|
||
for (const a of attestors) if (a !== pubkey) counterparties.add(a);
|
||
for (const ev of attestations) {
|
||
for (const [, value] of ev.tags.filter(([t]) => t === 'p')) {
|
||
if (value !== pubkey) counterparties.add(value);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Payment-VERIFIED tier — count only proofs we can actually check:
|
||
// • a Lightning preimage (`sha256(preimage) === payment_hash`), and
|
||
// • a NIP-57 zap receipt (signature + recipient + amount, fetched below).
|
||
const zapProofs = new Map<string, string>(); // receiptId → expected seller
|
||
const usedPreimages = new Set<string>(); // one payment counts once
|
||
let paymentVerified = 0;
|
||
for (const ev of unique) {
|
||
const proof = ev.tags.find(([t]) => t === 'proof');
|
||
if (!proof) continue;
|
||
if ((proof[1] === 'lnpreimage' || proof[1] === 'deposit') && proof[2] && proof[3]) {
|
||
// SECURITY: a real preimage reused across many attestations must not
|
||
// inflate the "payment-verified" tier — count each preimage once.
|
||
const preimage = proof[2];
|
||
if (!usedPreimages.has(preimage) && verifyReceiptProof(preimage, proof[3])) {
|
||
usedPreimages.add(preimage);
|
||
paymentVerified += 1;
|
||
}
|
||
} else if (proof[1] === 'zap' && proof[2]) {
|
||
const coord = parseListingCoord(ev.tags.find(([t]) => t === 'a')?.[1]);
|
||
if (coord) zapProofs.set(proof[2], coord.pubkey);
|
||
}
|
||
}
|
||
if (zapProofs.size > 0) {
|
||
const receipts = await nostr.query(
|
||
[{ kinds: [9735], ids: [...zapProofs.keys()], limit: 100 }],
|
||
{ signal },
|
||
);
|
||
for (const r of receipts) {
|
||
const expected = zapProofs.get(r.id);
|
||
if (expected && verifyZapReceipt(r, expected).valid) paymentVerified += 1;
|
||
}
|
||
}
|
||
|
||
return {
|
||
sales,
|
||
purchases,
|
||
verified,
|
||
withPaymentProof,
|
||
paymentVerified,
|
||
verifiedCounterparties: verifiedWith.size,
|
||
verifiedWith: [...verifiedWith.entries()].map(([pubkey, count]) => ({ pubkey, count })),
|
||
counterparties: [...counterparties],
|
||
attestations: unique,
|
||
};
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Trades where someone named the viewer as counterparty and the viewer has NOT
|
||
* yet published their own attestation — used to prompt "confirm this trade".
|
||
*/
|
||
export function usePendingTradeConfirmations(viewerPubkey: string | undefined) {
|
||
const { nostr } = useNostr();
|
||
|
||
return useQuery({
|
||
queryKey: ['pending-trade-confirmations', viewerPubkey],
|
||
enabled: !!viewerPubkey,
|
||
staleTime: 60 * 1000,
|
||
queryFn: async (c) => {
|
||
type Pending = {
|
||
coord: string;
|
||
listing: { pubkey: string; identifier: string };
|
||
seller: string;
|
||
/** When the counterparty attested (drives the notification ordering). */
|
||
createdAt: number;
|
||
/** The attestation event id — a stable notification key. */
|
||
eventId: string;
|
||
};
|
||
if (!viewerPubkey) return [] as Pending[];
|
||
|
||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
|
||
const [naming, mine] = await Promise.all([
|
||
nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], '#p': [viewerPubkey], limit: 200 }], { signal }),
|
||
nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], authors: [viewerPubkey], limit: 200 }], { signal }),
|
||
]);
|
||
|
||
const myCoords = new Set(
|
||
mine.filter(isTradeAttestation).map((e) => e.tags.find(([t]) => t === 'a')?.[1]).filter(Boolean),
|
||
);
|
||
|
||
const pending: Pending[] = [];
|
||
const seen = new Set<string>();
|
||
for (const ev of naming.filter(isTradeAttestation)) {
|
||
if (ev.pubkey === viewerPubkey) continue;
|
||
const coord = ev.tags.find(([t]) => t === 'a')?.[1];
|
||
const parsed = parseListingCoord(coord);
|
||
// The attestor must be the listing author (i.e. the seller's record).
|
||
if (!coord || !parsed || parsed.pubkey !== ev.pubkey) continue;
|
||
if (myCoords.has(coord) || seen.has(coord)) continue;
|
||
seen.add(coord);
|
||
pending.push({
|
||
coord,
|
||
listing: { pubkey: parsed.pubkey, identifier: parsed.identifier },
|
||
seller: ev.pubkey,
|
||
createdAt: ev.created_at,
|
||
eventId: ev.id,
|
||
});
|
||
}
|
||
return pending;
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Trade attestations for one listing, from the viewer's perspective:
|
||
* `iConfirmed` (they attested it) and `counterparty` (who else has).
|
||
*/
|
||
export function useListingTrade(
|
||
listing: { pubkey: string; identifier: string } | undefined,
|
||
viewerPubkey: string | undefined,
|
||
) {
|
||
const { nostr } = useNostr();
|
||
const coord = listing ? `${MARKETPLACE_ITEM_KIND}:${listing.pubkey}:${listing.identifier}` : undefined;
|
||
|
||
return useQuery({
|
||
queryKey: ['listing-trade', coord, viewerPubkey],
|
||
enabled: !!coord,
|
||
staleTime: 60 * 1000,
|
||
queryFn: async (c) => {
|
||
if (!coord) return { attestations: [] as NostrEvent[], iConfirmed: false, counterparties: [] as string[] };
|
||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
|
||
const events = await nostr.query(
|
||
[{ kinds: [TRADE_ATTESTATION_KIND], '#a': [coord], limit: 50 }],
|
||
{ signal },
|
||
);
|
||
const attestations = events.filter(
|
||
(e) => isTradeAttestation(e) && e.tags.find(([t]) => t === 's')?.[1] !== 'revoked',
|
||
);
|
||
const authorSet = new Set(attestations.map((e) => e.pubkey));
|
||
const counterparties = new Set<string>();
|
||
for (const ev of attestations) {
|
||
for (const [, value] of ev.tags.filter(([t]) => t === 'p')) {
|
||
if (value !== viewerPubkey) counterparties.add(value);
|
||
}
|
||
}
|
||
for (const a of authorSet) if (a !== viewerPubkey) counterparties.add(a);
|
||
return {
|
||
attestations,
|
||
iConfirmed: !!viewerPubkey && authorSet.has(viewerPubkey),
|
||
counterparties: [...counterparties],
|
||
};
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Publish a trade confirmation request (kind 31872, addressable).
|
||
*
|
||
* A buyer who completed a deal the seller hasn't recorded asks the seller (the
|
||
* listing author) to publish a `31871`. `d` is derived from the listing + the
|
||
* viewer, so re-requesting replaces the previous request.
|
||
*/
|
||
export function useTradeAttestationRequest() {
|
||
const { user } = useCurrentUser();
|
||
const { mutateAsync: publish, isPending } = useNostrPublish();
|
||
|
||
const publishRequest = async ({
|
||
listing,
|
||
attestor,
|
||
note,
|
||
cashuToken,
|
||
}: {
|
||
/** The listing the deal was for. */
|
||
listing: { pubkey: string; identifier: string };
|
||
/** The party asked to attest — normally the seller (listing author). */
|
||
attestor: string;
|
||
note?: string;
|
||
/**
|
||
* Optional escrowed Cashu token (spec `cashu_token`). Only relevant when
|
||
* you created and locked one yourself; a plain token is not escrow.
|
||
*/
|
||
cashuToken?: string;
|
||
}) => {
|
||
if (!user) throw new Error('You must be logged in.');
|
||
if (attestor === user.pubkey) throw new Error('Pick the other party, not yourself.');
|
||
|
||
const coord = `${MARKETPLACE_ITEM_KIND}:${listing.pubkey}:${listing.identifier}`;
|
||
const tags: string[][] = [
|
||
['d', `${user.pubkey}:trade-request:${listing.identifier}`],
|
||
['a', coord],
|
||
['p', attestor],
|
||
['alt', 'SkateSpots: trade confirmation requested'],
|
||
];
|
||
if (cashuToken?.trim()) tags.push(['cashu_token', cashuToken.trim()]);
|
||
|
||
await publish({
|
||
kind: TRADE_REQUEST_KIND,
|
||
content: note?.trim() || 'Please confirm our trade so it counts for reputation.',
|
||
tags,
|
||
});
|
||
};
|
||
|
||
return { publishRequest, isPending };
|
||
}
|
||
|
||
export interface IncomingTradeRequest {
|
||
coord: string;
|
||
listing: { pubkey: string; identifier: string };
|
||
requestor: string;
|
||
note: string;
|
||
event: NostrEvent;
|
||
}
|
||
|
||
/**
|
||
* Requests naming the viewer as the attestor (i.e. they are the seller) that
|
||
* they have not yet fulfilled. Drives the "confirm this sale" prompt.
|
||
*/
|
||
export function useIncomingTradeRequests(viewerPubkey: string | undefined) {
|
||
const { nostr } = useNostr();
|
||
|
||
return useQuery<IncomingTradeRequest[]>({
|
||
queryKey: ['incoming-trade-requests', viewerPubkey],
|
||
enabled: !!viewerPubkey,
|
||
staleTime: 60 * 1000,
|
||
queryFn: async (c) => {
|
||
if (!viewerPubkey) return [];
|
||
|
||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
|
||
const [requests, mine] = await Promise.all([
|
||
nostr.query([{ kinds: [TRADE_REQUEST_KIND], '#p': [viewerPubkey], limit: 200 }], { signal }),
|
||
nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], authors: [viewerPubkey], limit: 200 }], { signal }),
|
||
]);
|
||
|
||
// Requests the viewer already fulfilled (they attested that listing).
|
||
const fulfilled = new Set(
|
||
mine.filter(isTradeAttestation).map((e) => e.tags.find(([t]) => t === 'a')?.[1]).filter(Boolean),
|
||
);
|
||
|
||
const out: IncomingTradeRequest[] = [];
|
||
const seen = new Set<string>();
|
||
for (const ev of requests.filter(isTradeRequest)) {
|
||
if (ev.pubkey === viewerPubkey) continue;
|
||
const coord = ev.tags.find(([t]) => t === 'a')?.[1];
|
||
const parsed = parseListingCoord(coord);
|
||
// Only the listing author (seller) can attest the sale.
|
||
if (!coord || !parsed || parsed.pubkey !== viewerPubkey) continue;
|
||
if (fulfilled.has(coord) || seen.has(coord)) continue;
|
||
seen.add(coord);
|
||
out.push({
|
||
coord,
|
||
listing: { pubkey: parsed.pubkey, identifier: parsed.identifier },
|
||
requestor: ev.pubkey,
|
||
note: ev.content,
|
||
event: ev,
|
||
});
|
||
}
|
||
return out;
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* I added a new trade request for a listing. Replaces any previous one (same `d`).
|
||
*
|
||
* For a plain token this is just "a token you happen to include". Real escrow
|
||
* needs the token locked to spending conditions (P2PK/HTLC) plus a release
|
||
* witness — see NIP.md → Trade Attestations → Escrow (not yet implemented).
|
||
*/
|
||
export function useListingTradeRequest(
|
||
listing: { pubkey: string; identifier: string } | undefined,
|
||
viewerPubkey: string | undefined,
|
||
) {
|
||
const { nostr } = useNostr();
|
||
const coord = listing ? `${MARKETPLACE_ITEM_KIND}:${listing.pubkey}:${listing.identifier}` : undefined;
|
||
|
||
return useQuery({
|
||
queryKey: ['listing-trade-request', coord, viewerPubkey],
|
||
enabled: !!coord,
|
||
staleTime: 60 * 1000,
|
||
queryFn: async (c) => {
|
||
if (!coord) return { requests: [] as NostrEvent[], iRequested: false };
|
||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
|
||
const events = (
|
||
await nostr.query([{ kinds: [TRADE_REQUEST_KIND], '#a': [coord], limit: 50 }], { signal })
|
||
).filter(isTradeRequest);
|
||
return {
|
||
requests: events,
|
||
iRequested: !!viewerPubkey && events.some((e) => e.pubkey === viewerPubkey),
|
||
};
|
||
},
|
||
});
|
||
}
|