feat(marketplace): trade confirmation requests (kind 31872) + fix boot smoke test
Marketplace:
- Buyer can 'Ask seller to confirm' a sold listing -> publishes a 31872
Attestation Request (d/a/p), which the seller fulfils in one tap
- Seller sees incoming requests in the DM thread ('Confirm this sale?') and the
fulfilling 31871 carries the spec's 'request' tag
- useTradeAttestationRequest / useIncomingTradeRequests / useListingTradeRequest
- NIP.md documents 31872 and states escrow is intentionally NOT implemented
(coco-cashu has no P2PK/HTLC locking, wallet is native-only, and release-on-
attestation needs a witness) so nothing looks trustless that isn't
Hardening:
- scripts/smoke-test.mjs navigated to /settings etc. WITHOUT a hash, so with
HashRouter it only ever loaded the homepage and reported PASS. It now puts the
route in the hash, covers settings/stash/collections/stats/marketplace, and
fails on the 'This page slammed' error boundary (not just boot failure)
This commit is contained in:
parent
4e17852df3
commit
b91df1b4b0
5 changed files with 340 additions and 12 deletions
56
NIP.md
56
NIP.md
|
|
@ -3125,6 +3125,37 @@ cannot observe off-app (cash/trade) settlement.
|
||||||
`31871` is addressable, re-publishing the same `d` with `["s","revoked"]`
|
`31871` is addressable, re-publishing the same `d` with `["s","revoked"]`
|
||||||
retracts an attestation (e.g. a disputed or cancelled deal).
|
retracts an attestation (e.g. a disputed or cancelled deal).
|
||||||
|
|
||||||
|
## Attestation Request (kind 31872)
|
||||||
|
|
||||||
|
When a buyer's deal wasn't recorded by the seller, the buyer publishes a `31872`
|
||||||
|
asking the seller to attest it. The seller's client surfaces the request and
|
||||||
|
fulfils it in one tap, publishing a `31871` that carries the spec's `request` tag
|
||||||
|
pointing back at the `31872`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 31872,
|
||||||
|
"tags": [
|
||||||
|
["d", "<buyer_pubkey>:trade-request:<listing-d>"],
|
||||||
|
["a", "30402:<seller>:<listing-d>"],
|
||||||
|
["p", "<seller_pubkey>"],
|
||||||
|
["alt", "SkateSpots: trade confirmation requested"]
|
||||||
|
],
|
||||||
|
"content": "Please confirm our trade for “Deck”."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Tag | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `d` | `<requestor>:trade-request:<listing-d>` — re-requesting replaces the prior one |
|
||||||
|
| `a` | The listing (Assertion Event) being attested |
|
||||||
|
| `p` | The requested **attestor** (the seller / listing author) |
|
||||||
|
| `request` (on the reply) | On the fulfilling `31871`: `31872:<requestor>:<d>` |
|
||||||
|
|
||||||
|
Only the listing author can attest a sale, so the UI only prompts a seller to
|
||||||
|
fulfil requests whose `a` author is them, and hides requests they've already
|
||||||
|
answered (they have a `31871` for that listing).
|
||||||
|
|
||||||
## Reputation semantics
|
## Reputation semantics
|
||||||
|
|
||||||
- **sales** — listings where the attestor is the listing author
|
- **sales** — listings where the attestor is the listing author
|
||||||
|
|
@ -3133,10 +3164,29 @@ retracts an attestation (e.g. a disputed or cancelled deal).
|
||||||
- Consumers **trust-weight counterparties** with the existing web-of-trust score
|
- Consumers **trust-weight counterparties** with the existing web-of-trust score
|
||||||
(as with Scene vouches) so fresh-account rings cannot inflate reputation.
|
(as with Scene vouches) so fresh-account rings cannot inflate reputation.
|
||||||
|
|
||||||
|
## Escrow (planned — not implemented)
|
||||||
|
|
||||||
|
The NIP allows a `31872` to carry a `["cashu_token", "<token>"]`: a Cashu token
|
||||||
|
locked to spending conditions (P2PK/HTLC) so funds release when the requested
|
||||||
|
attestation is observed. **We do not implement this yet**, and the client accepts
|
||||||
|
an optional `cashu_token` only as a passthrough (no locking). Fully shipping it
|
||||||
|
needs:
|
||||||
|
|
||||||
|
1. **Locked tokens** — `@cashu/cashu-ts` can create P2PK/HTLC outputs, but our
|
||||||
|
wallet (`coco-cashu-core`) only exposes plain `send()`. Escrow would bypass
|
||||||
|
coco and use `cashu-ts` directly with the same seed.
|
||||||
|
2. **Native only** — the bundled Cashu wallet is gated to Capacitor builds
|
||||||
|
(`useCashu` returns early on web, where mints are CORS-blocked), so an escrow
|
||||||
|
UI can only run in the app.
|
||||||
|
3. **A release witness** — Cashu conditions lock to a key or a hash preimage, not
|
||||||
|
to "a Nostr event was seen". Releasing *on attestation* requires a trusted
|
||||||
|
witness/oracle that watches for the `31871` and releases. Without one, a token
|
||||||
|
locked to the seller's key is prepayment, not escrow.
|
||||||
|
|
||||||
|
Until all three exist, escrow stays off to avoid a payment flow that looks
|
||||||
|
trustless but isn't.
|
||||||
|
|
||||||
## Future (from the NIP)
|
## Future (from the NIP)
|
||||||
|
|
||||||
- `31872` **Attestation Request** — a buyer can request the seller's attestation;
|
|
||||||
it carries a `cashu_token` lockable to spending conditions (HTLC/P2PK) → a
|
|
||||||
Cashu-based escrow / release-on-attestation flow.
|
|
||||||
- `31873` **Attestor Recommendation** / `11871` **Proficiency** — trusted-trader
|
- `31873` **Attestor Recommendation** / `11871` **Proficiency** — trusted-trader
|
||||||
endorsements and attestor discovery.
|
endorsements and attestor discovery.
|
||||||
|
|
|
||||||
|
|
@ -122,25 +122,43 @@ await send('Page.enable');
|
||||||
const evalJS = async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }))?.result?.result?.value;
|
const evalJS = async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }))?.result?.result?.value;
|
||||||
|
|
||||||
// ── boot each route (lazy route chunks can fail independently) ──────────────
|
// ── boot each route (lazy route chunks can fail independently) ──────────────
|
||||||
const ROUTES = ['/', '/leaderboards', '/bounties', '/messages/group/abc123'];
|
// NOTE: the app uses HashRouter, so the route MUST be in the hash — a bare
|
||||||
|
// `/settings` path loads index.html with no hash and silently renders the
|
||||||
|
// homepage, which made this test pass without exercising those chunks.
|
||||||
|
const ROUTES = [
|
||||||
|
'/',
|
||||||
|
'/marketplace',
|
||||||
|
'/leaderboards',
|
||||||
|
'/bounties',
|
||||||
|
'/settings',
|
||||||
|
'/stash',
|
||||||
|
'/collections',
|
||||||
|
'/stats',
|
||||||
|
'/messages/group/abc123',
|
||||||
|
];
|
||||||
const problems = [];
|
const problems = [];
|
||||||
let lastText = '';
|
let lastText = '';
|
||||||
|
|
||||||
for (const route of ROUTES) {
|
for (const route of ROUTES) {
|
||||||
const url = appUrl.replace(/\/$/, '') + route;
|
const url = appUrl.replace(/\/$/, '') + '/#' + route;
|
||||||
await send('Page.navigate', { url });
|
await send('Page.navigate', { url });
|
||||||
|
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
|
let stable = 0;
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
lastText = (await evalJS('document.body ? document.body.innerText : ""')) || '';
|
lastText = (await evalJS('document.body ? document.body.innerText : ""')) || '';
|
||||||
const kids = await evalJS('document.getElementById("root")?.children.length ?? 0');
|
const kids = await evalJS('document.getElementById("root")?.children.length ?? 0');
|
||||||
if (kids > 0) { mounted = true; break; }
|
if (kids > 0) mounted = true;
|
||||||
if (/failed to start/i.test(lastText)) break;
|
// Stop early on a hard crash; otherwise require the page to stay rendered
|
||||||
await sleep(400);
|
// for a few polls so a lazy route chunk has time to load (and, if broken,
|
||||||
|
// to fall through to the error boundary).
|
||||||
|
if (/failed to start|this page slammed/i.test(lastText)) break;
|
||||||
|
if (mounted) { stable++; if (stable >= 3) break; }
|
||||||
|
await sleep(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
const crashed = /failed to start/i.test(lastText);
|
const crashed = /failed to start|this page slammed/i.test(lastText);
|
||||||
if (!mounted) problems.push(`${route}: #root never mounted`);
|
if (!mounted) problems.push(`${route}: #root never mounted`);
|
||||||
if (crashed) problems.push(`${route}: crash screen`);
|
if (crashed) problems.push(`${route}: crash screen`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ import { useSellerInfo } from '@/hooks/useSellerInfo';
|
||||||
import { useTrustScore } from '@/hooks/useTrustScore';
|
import { useTrustScore } from '@/hooks/useTrustScore';
|
||||||
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
|
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
|
||||||
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
||||||
import { useListingTrade, useTradeAttestation, useTradeReputation } from '@/hooks/useTradeAttestations';
|
import { useListingTrade, useTradeAttestation, useTradeReputation, useListingTradeRequest, useTradeAttestationRequest } from '@/hooks/useTradeAttestations';
|
||||||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { TrustBadge } from '@/components/TrustBadge';
|
import { TrustBadge } from '@/components/TrustBadge';
|
||||||
|
|
@ -53,6 +53,7 @@ import {
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Trash2,
|
Trash2,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Send,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import type { MarketplaceItem } from '@/lib/types';
|
import type { MarketplaceItem } from '@/lib/types';
|
||||||
|
|
@ -97,6 +98,8 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
const { data: trade } = useListingTrade(listingRef, user?.pubkey);
|
const { data: trade } = useListingTrade(listingRef, user?.pubkey);
|
||||||
const { data: sellerRep } = useTradeReputation(item.pubkey);
|
const { data: sellerRep } = useTradeReputation(item.pubkey);
|
||||||
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
|
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
|
||||||
|
const { data: requestState } = useListingTradeRequest(listingRef, user?.pubkey);
|
||||||
|
const { publishRequest, isPending: requestingTrade } = useTradeAttestationRequest();
|
||||||
|
|
||||||
// Format price display
|
// Format price display
|
||||||
const priceDisplay = item.price
|
const priceDisplay = item.price
|
||||||
|
|
@ -200,6 +203,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
(ev) => ev.pubkey === item.pubkey && ev.tags.some(([t, v]) => t === 'p' && v === user.pubkey),
|
(ev) => ev.pubkey === item.pubkey && ev.tags.some(([t, v]) => t === 'p' && v === user.pubkey),
|
||||||
);
|
);
|
||||||
const iConfirmedTrade = !!trade?.iConfirmed;
|
const iConfirmedTrade = !!trade?.iConfirmed;
|
||||||
|
const iRequestedConfirmation = !!requestState?.iRequested;
|
||||||
|
|
||||||
const handleConfirmTrade = async () => {
|
const handleConfirmTrade = async () => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
@ -215,6 +219,27 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRequestConfirmation = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
try {
|
||||||
|
await publishRequest({
|
||||||
|
listing: listingRef,
|
||||||
|
attestor: item.pubkey,
|
||||||
|
note: `Please confirm our trade for “${item.title}”.`,
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: 'Confirmation requested',
|
||||||
|
description: 'The seller can now confirm the sale and build both reputations.',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: 'Could not send the request',
|
||||||
|
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRelist = async () => {
|
const handleRelist = async () => {
|
||||||
try {
|
try {
|
||||||
await relist(item.event);
|
await relist(item.event);
|
||||||
|
|
@ -500,6 +525,25 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Buyer: ask the seller to record the sale (kind 31872 request) */}
|
||||||
|
{user && !isOwnListing && isSold && !iConfirmedTrade && !sellerNamedMe && !iRequestedConfirmation && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleRequestConfirmation}
|
||||||
|
disabled={requestingTrade}
|
||||||
|
className="w-full gap-2 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
|
||||||
|
>
|
||||||
|
{requestingTrade ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
|
Ask seller to confirm
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{iRequestedConfirmation && !iConfirmedTrade && (
|
||||||
|
<p className="text-xs text-center text-gray-500">
|
||||||
|
Confirmation requested — waiting on the seller.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Contact button */}
|
{/* Contact button */}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,14 @@ import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
|
||||||
*/
|
*/
|
||||||
export const TRADE_ATTESTATION_KIND = 31871;
|
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 = {
|
export const TRADE_METHODS = {
|
||||||
cash: 'Cash',
|
cash: 'Cash',
|
||||||
lightning: 'Lightning',
|
lightning: 'Lightning',
|
||||||
|
|
@ -57,6 +65,17 @@ export function isTradeAttestation(ev: NostrEvent): boolean {
|
||||||
return ev.tags.some(([t]) => t === 'p');
|
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).
|
* Publish a trade attestation (kind 31871, addressable).
|
||||||
*
|
*
|
||||||
|
|
@ -75,6 +94,7 @@ export function useTradeAttestation() {
|
||||||
amountSats,
|
amountSats,
|
||||||
state = 'valid',
|
state = 'valid',
|
||||||
note,
|
note,
|
||||||
|
requestCoord,
|
||||||
}: {
|
}: {
|
||||||
/** The listing being attested. */
|
/** The listing being attested. */
|
||||||
listing: { pubkey: string; identifier: string };
|
listing: { pubkey: string; identifier: string };
|
||||||
|
|
@ -85,6 +105,8 @@ export function useTradeAttestation() {
|
||||||
amountSats?: number;
|
amountSats?: number;
|
||||||
state?: 'verifying' | 'valid' | 'revoked';
|
state?: 'verifying' | 'valid' | 'revoked';
|
||||||
note?: string;
|
note?: string;
|
||||||
|
/** The `31872` request coord this attestation fulfils, if any (spec `request` tag). */
|
||||||
|
requestCoord?: string;
|
||||||
}) => {
|
}) => {
|
||||||
if (!user) throw new Error('You must be logged in.');
|
if (!user) throw new Error('You must be logged in.');
|
||||||
if (counterparty === user.pubkey) throw new Error('Pick the other party, not yourself.');
|
if (counterparty === user.pubkey) throw new Error('Pick the other party, not yourself.');
|
||||||
|
|
@ -102,6 +124,7 @@ export function useTradeAttestation() {
|
||||||
if (typeof amountSats === 'number' && amountSats > 0) {
|
if (typeof amountSats === 'number' && amountSats > 0) {
|
||||||
tags.push(['amount', String(Math.round(amountSats)), 'SATS']);
|
tags.push(['amount', String(Math.round(amountSats)), 'SATS']);
|
||||||
}
|
}
|
||||||
|
if (requestCoord) tags.push(['request', requestCoord]);
|
||||||
|
|
||||||
const name = user.pubkey.slice(0, 8);
|
const name = user.pubkey.slice(0, 8);
|
||||||
const content =
|
const content =
|
||||||
|
|
@ -306,3 +329,141 @@ export function useListingTrade(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { useToast } from '@/hooks/useToast';
|
||||||
import { useNostrLogin } from '@nostrify/react/login';
|
import { useNostrLogin } from '@nostrify/react/login';
|
||||||
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
||||||
import { useConversation, useSendDirectMessage, type DirectMessage } from '@/hooks/useDirectMessages';
|
import { useConversation, useSendDirectMessage, type DirectMessage } from '@/hooks/useDirectMessages';
|
||||||
import { usePendingTradeConfirmations, useTradeAttestation } from '@/hooks/useTradeAttestations';
|
import { usePendingTradeConfirmations, useTradeAttestation, useIncomingTradeRequests } from '@/hooks/useTradeAttestations';
|
||||||
import { useDMRead } from '@/hooks/useDMRead';
|
import { useDMRead } from '@/hooks/useDMRead';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Handshake,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { AmberApprovalHint } from '@/components/AmberApprovalHint';
|
import { AmberApprovalHint } from '@/components/AmberApprovalHint';
|
||||||
|
|
@ -105,6 +106,7 @@ export default function ConversationPage() {
|
||||||
const { data: messages, isLoading, error, refetch, isRefetching } = useConversation(otherPubkey || '');
|
const { data: messages, isLoading, error, refetch, isRefetching } = useConversation(otherPubkey || '');
|
||||||
const { mutate: sendMessage, isPending: isSending } = useSendDirectMessage();
|
const { mutate: sendMessage, isPending: isSending } = useSendDirectMessage();
|
||||||
const { data: pendingTrades } = usePendingTradeConfirmations(user?.pubkey);
|
const { data: pendingTrades } = usePendingTradeConfirmations(user?.pubkey);
|
||||||
|
const { data: incomingRequests } = useIncomingTradeRequests(user?.pubkey);
|
||||||
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
|
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
|
||||||
const { markRead } = useDMRead();
|
const { markRead } = useDMRead();
|
||||||
const author = useAuthor(otherPubkey || undefined);
|
const author = useAuthor(otherPubkey || undefined);
|
||||||
|
|
@ -171,7 +173,36 @@ export default function ConversationPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) {
|
// The other party asked us (the seller) to record a sale (kind 31872).
|
||||||
|
const incomingRequest = otherPubkey
|
||||||
|
? incomingRequests?.find((r) => r.requestor === otherPubkey)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const handleFulfillRequest = async () => {
|
||||||
|
if (!incomingRequest || !otherPubkey) return;
|
||||||
|
try {
|
||||||
|
await publishTrade({
|
||||||
|
listing: incomingRequest.listing,
|
||||||
|
role: 'sold',
|
||||||
|
counterparty: otherPubkey,
|
||||||
|
requestCoord: incomingRequest.coord,
|
||||||
|
note: `Sold to ${displayName}.`,
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: 'Sale confirmed',
|
||||||
|
description: `${displayName} can now confirm their side.`,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: 'Could not confirm the sale',
|
||||||
|
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSend();
|
handleSend();
|
||||||
}
|
}
|
||||||
|
|
@ -292,6 +323,30 @@ export default function ConversationPage() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Incoming request: the other party asked us (seller) to record the sale */}
|
||||||
|
{incomingRequest && (
|
||||||
|
<Card className="mb-3 flex-shrink-0 border-2 border-[#ff6600]/40 bg-[#ff6600]/5">
|
||||||
|
<CardContent className="flex items-center gap-3 p-3">
|
||||||
|
<Handshake className="h-5 w-5 shrink-0 text-[#ff6600]" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-bold text-white">Confirm this sale?</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{displayName} asked you to record it. Confirming builds reputation for both of you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleFulfillRequest}
|
||||||
|
disabled={confirmingTrade}
|
||||||
|
className="shrink-0 gap-1.5 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
|
||||||
|
>
|
||||||
|
{confirmingTrade ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Handshake className="h-3.5 w-3.5" />}
|
||||||
|
Confirm sale
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Messages Area */}
|
{/* Messages Area */}
|
||||||
<div
|
<div
|
||||||
className="flex-1 overflow-y-auto py-3 sm:py-4 space-y-2 sm:space-y-3 min-h-0"
|
className="flex-1 overflow-y-auto py-3 sm:py-4 space-y-2 sm:space-y-3 min-h-0"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue