diff --git a/src/components/nostr/kinds/P2pOrderDetailRenderer.tsx b/src/components/nostr/kinds/P2pOrderDetailRenderer.tsx
new file mode 100644
index 0000000..81aa5a3
--- /dev/null
+++ b/src/components/nostr/kinds/P2pOrderDetailRenderer.tsx
@@ -0,0 +1,173 @@
+import { NostrEvent } from "@/types/nostr";
+import { UserName } from "../UserName";
+import { Copy } from "lucide-react";
+import {
+ getOrderType,
+ getFiatAmount,
+ getSatsAmount,
+ getCurrency,
+ getPaymentMethods,
+ getPlatform,
+ getPremium,
+ getOrderStatus,
+ getSource,
+ getBitcoinLayer,
+ getBitcoinNetwork,
+ getUsername,
+ getExpiration,
+} from "@/lib/nip69-helpers";
+import { toast } from "sonner";
+
+interface P2pOrderDetailRendererProps {
+ event: NostrEvent;
+}
+
+/**
+ * Detail renderer for Kind 38383 - P2P Order
+ * Shows order details and links
+ */
+export function P2pOrderDetailRenderer({ event }: P2pOrderDetailRendererProps) {
+ const orderType = getOrderType(event);
+ const fiatAmount = getFiatAmount(event);
+ const satsAmount = getSatsAmount(event);
+ const currency = getCurrency(event);
+ const paymentMethods = getPaymentMethods(event);
+ const platform = getPlatform(event);
+ const premium = getPremium(event);
+ const orderStatus = getOrderStatus(event);
+ const source = getSource(event);
+ const bitcoinLayer = getBitcoinLayer(event);
+ const bitcoinNetwork = getBitcoinNetwork(event);
+ const username = getUsername(event);
+ const expiration = getExpiration(event);
+
+ const handleCopy = (value: string) => {
+ navigator.clipboard.writeText(value);
+ toast.success(`External link copied to clipboard`);
+ };
+
+ const getTradetitle: () => string = () => {
+ if (!orderType) return "-";
+
+ let title = `${orderType?.toLocaleUpperCase()} `;
+
+ const fiat = fiatAmount
+ ? fiatAmount.length < 2
+ ? fiatAmount[0]
+ : `${fiatAmount[0]} - ${fiatAmount[1]}`
+ : "";
+
+ if (satsAmount) {
+ if (fiat) {
+ title += `${fiat} ${currency} (${satsAmount} sats)`;
+ } else {
+ title += `${fiat} sats (Premium ${premium}%)`;
+ }
+ } else {
+ title += `${fiat} ${currency} (Premium ${premium}%)`;
+ }
+
+ return title;
+ };
+
+ const getExpirationDate = () => {
+ if (!expiration) return "-";
+
+ const date = new Date(expiration * 1000);
+
+ return date.toLocaleString();
+ };
+
+ return (
+
+ {/* Header Section */}
+
+ {/* Order Title */}
+
+
+
{getTradetitle()}
+ {orderStatus === "pending" && source && (
+
+ )}
+
+
+
+
+ {/* Metadata Grid */}
+
+ {/* Publisher */}
+
+
Profile
+
+
+
+ {/* Host */}
+
+
Host
+
+ {platform ?? "-"}
+
+
+
+ {/* Status */}
+
+
Username
+
+ {username ?? "-"}
+
+
+
+ {/* Status */}
+
+
Status
+
+ {orderStatus ?? "-"}
+
+
+
+ {/* Layer */}
+
+
Layer
+
+ {bitcoinLayer ?? "-"}
+
+
+
+ {/* Network */}
+
+
Network
+
+ {bitcoinNetwork ?? "-"}
+
+
+
+
+ {/* Platforms Section */}
+ {paymentMethods && paymentMethods.length > 0 && (
+
+
Payment Methods
+
+ {paymentMethods.map((pm) => (
+
+ {pm}
+
+ ))}
+
+
+ )}
+
+ {/* Expiration */}
+
+
Expiration Date
+
{getExpirationDate()}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/P2pOrderRenderer.tsx b/src/components/nostr/kinds/P2pOrderRenderer.tsx
new file mode 100644
index 0000000..be6c9fa
--- /dev/null
+++ b/src/components/nostr/kinds/P2pOrderRenderer.tsx
@@ -0,0 +1,149 @@
+import {
+ BaseEventContainer,
+ BaseEventProps,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import {
+ Ban,
+ Check,
+ ClockAlert,
+ Copy,
+ Layers,
+ Loader,
+ Scale,
+ Tickets,
+} from "lucide-react";
+import {
+ getBitcoinLayer,
+ getCurrency,
+ getFiatAmount,
+ getOrderStatus,
+ getOrderType,
+ getPaymentMethods,
+ getPlatform,
+ getPremium,
+ getSatsAmount,
+ getSource,
+} from "@/lib/nip69-helpers";
+import { toast } from "sonner";
+
+/**
+ * Renderer for Kind 38383 - P2P Order
+ * Clean feed view with order details and links
+ */
+export function P2pOrderRenderer({ event }: BaseEventProps) {
+ const orderType = getOrderType(event);
+ const fiatAmount = getFiatAmount(event);
+ const satsAmount = getSatsAmount(event);
+ const currency = getCurrency(event);
+ const paymentMethods = getPaymentMethods(event);
+ const platform = getPlatform(event);
+ const premium = getPremium(event);
+ const orderStatus = getOrderStatus(event);
+ const source = getSource(event);
+ const bitcoinLayer = getBitcoinLayer(event);
+
+ const handleCopy = (value: string) => {
+ navigator.clipboard.writeText(value);
+ toast.success(`External link copied to clipboard`);
+ };
+
+ const getTradetitle: () => string = () => {
+ let title = `${orderType?.toLocaleUpperCase()} `;
+
+ const fiat = fiatAmount
+ ? fiatAmount.length < 2
+ ? fiatAmount[0]
+ : `${fiatAmount[0]} - ${fiatAmount[1]}`
+ : "";
+
+ if (satsAmount) {
+ if (fiat) {
+ title += `${fiat} ${currency} (${satsAmount} sats)`;
+ } else {
+ title += `${fiat} sats (Premium ${premium}%)`;
+ }
+ } else {
+ title += `${fiat} ${currency} (Premium ${premium}%)`;
+ }
+
+ return title;
+ };
+
+ const getStatusTag = () => {
+ const className = "size-3";
+ const icon = {
+ pending: ,
+ canceled: ,
+ "in-progress": ,
+ success: ,
+ expired: ,
+ };
+
+ return orderStatus ? (
+ <>
+ {icon[orderStatus]}
+ {orderStatus}
+ >
+ ) : (
+ <>>
+ );
+ };
+
+ return (
+
+ {orderType && (
+
+
+
+ {getTradetitle()}
+
+
+ {orderStatus === "pending" && source && (
+
+ )}
+
+
+
+ {orderType && (
+
+ {paymentMethods?.join(" ")}
+
+ )}
+
+ {(platform || bitcoinLayer) && (
+
+ {platform && (
+ <>
+
+
+ {platform}
+
+ >
+ )}
+ {bitcoinLayer && (
+ <>
+
+
+ {bitcoinLayer}
+
+ >
+ )}
+ {getStatusTag()}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index fcec3b3..59b4b05 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -140,6 +140,8 @@ import {
} from "./StarterPackRenderer";
import { NostrEvent } from "@/types/nostr";
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
+import { P2pOrderRenderer } from "./P2pOrderRenderer";
+import { P2pOrderDetailRenderer } from "./P2pOrderDetailRenderer";
/**
* Registry of kind-specific renderers
@@ -215,6 +217,7 @@ const kindRenderers: Record> = {
31989: HandlerRecommendationRenderer, // Handler Recommendation (NIP-89)
31990: ApplicationHandlerRenderer, // Application Handler (NIP-89)
32267: ZapstoreAppRenderer, // Zapstore App
+ 38383: P2pOrderRenderer, // P2P Orders
39000: GroupMetadataRenderer, // Group Metadata (NIP-29)
39089: StarterPackRenderer, // Starter Pack (NIP-51)
39092: MediaStarterPackRenderer, // Media Starter Pack (NIP-51)
@@ -305,6 +308,7 @@ const detailRenderers: Record<
31989: HandlerRecommendationDetailRenderer, // Handler Recommendation Detail (NIP-89)
31990: ApplicationHandlerDetailRenderer, // Application Handler Detail (NIP-89)
32267: ZapstoreAppDetailRenderer, // Zapstore App Detail
+ 38383: P2pOrderDetailRenderer, // P2P Order Detail
39089: StarterPackDetailRenderer, // Starter Pack Detail (NIP-51)
39092: MediaStarterPackDetailRenderer, // Media Starter Pack Detail (NIP-51)
};
diff --git a/src/lib/nip69-helpers.test.ts b/src/lib/nip69-helpers.test.ts
new file mode 100644
index 0000000..b5a3441
--- /dev/null
+++ b/src/lib/nip69-helpers.test.ts
@@ -0,0 +1,547 @@
+import { describe, it, expect } from "vitest";
+import {
+ getOrderType,
+ getFiatAmount,
+ getSatsAmount,
+ getCurrency,
+ getBitcoinNetwork,
+ getUsername,
+ getPaymentMethods,
+ getPlatform,
+ getExpiration,
+ getPremium,
+ getOrderStatus,
+ getSource,
+ getBitcoinLayer,
+ ORDER_STATUSES,
+ type OrderStatus,
+} from "./nip69-helpers";
+import { NostrEvent } from "@/types/nostr";
+
+// Helper to create a minimal kind 38383 event (P2P Order)
+function createP2POrderEvent(overrides?: Partial): NostrEvent {
+ return {
+ id: "test-id",
+ pubkey: "test-pubkey",
+ created_at: 1234567890,
+ kind: 38383,
+ tags: [],
+ content: "",
+ sig: "test-sig",
+ ...overrides,
+ };
+}
+
+describe("Kind 38383 (P2P Order) Helpers", () => {
+ describe("getOrderType", () => {
+ it("should extract order type from k tag (sell)", () => {
+ const event = createP2POrderEvent({
+ tags: [["k", "sell"]],
+ });
+ expect(getOrderType(event)).toBe("sell");
+ });
+
+ it("should extract order type from k tag (buy)", () => {
+ const event = createP2POrderEvent({
+ tags: [["k", "buy"]],
+ });
+ expect(getOrderType(event)).toBe("buy");
+ });
+
+ it("should return undefined if no k tag", () => {
+ const event = createP2POrderEvent({
+ tags: [],
+ });
+ expect(getOrderType(event)).toBeUndefined();
+ });
+
+ it("should return undefined for non-38383 events", () => {
+ const event = createP2POrderEvent({
+ kind: 1,
+ tags: [["k", "sell"]],
+ });
+ expect(getOrderType(event)).toBeUndefined();
+ });
+ });
+
+ describe("getFiatAmount", () => {
+ it("should extract fiat amount from fa tag (integer)", () => {
+ const event = createP2POrderEvent({
+ tags: [["fa", "100"]],
+ });
+ expect(getFiatAmount(event)).toStrictEqual(["100"]);
+ });
+
+ it("should parse large fiat amounts", () => {
+ const event = createP2POrderEvent({
+ tags: [["fa", "1000000"]],
+ });
+ expect(getFiatAmount(event)).toStrictEqual(["1000000"]);
+ });
+
+ it("should parse multiple fiat amounts", () => {
+ const event = createP2POrderEvent({
+ tags: [["fa", "50", "100"]],
+ });
+ expect(getFiatAmount(event)).toStrictEqual(["50", "100"]);
+ });
+
+ it("should return undefined if no fa tag", () => {
+ const event = createP2POrderEvent({
+ tags: [],
+ });
+ expect(getFiatAmount(event)).toStrictEqual([]);
+ });
+
+ it("should return undefined for non-38383 events", () => {
+ const event = createP2POrderEvent({
+ kind: 1,
+ tags: [["fa", "100"]],
+ });
+ expect(getFiatAmount(event)).toBeUndefined();
+ });
+
+ it("should parse integers from string values", () => {
+ const event = createP2POrderEvent({
+ tags: [["fa", "100.99"]],
+ });
+ expect(getFiatAmount(event)).toStrictEqual(["100"]);
+ });
+ });
+
+ describe("getSatsAmount", () => {
+ it("should extract sats amount from amt tag", () => {
+ const event = createP2POrderEvent({
+ tags: [["amt", "50000"]],
+ });
+ expect(getSatsAmount(event)).toBe(50000);
+ });
+
+ it("should parse large sat amounts", () => {
+ const event = createP2POrderEvent({
+ tags: [["amt", "100000000"]],
+ });
+ expect(getSatsAmount(event)).toBe(100000000);
+ });
+
+ it("should return undefined if no amt tag", () => {
+ const event = createP2POrderEvent({
+ tags: [],
+ });
+ expect(getSatsAmount(event)).toBeUndefined();
+ });
+
+ it("should return undefined if amt tag value is not a number", () => {
+ const event = createP2POrderEvent({
+ tags: [["amt", "invalid"]],
+ });
+ expect(getSatsAmount(event)).toBeUndefined();
+ });
+
+ it("should return undefined for non-38383 events", () => {
+ const event = createP2POrderEvent({
+ kind: 1,
+ tags: [["amt", "50000"]],
+ });
+ expect(getSatsAmount(event)).toBeUndefined();
+ });
+ });
+
+ describe("getCurrency", () => {
+ it("should extract currency from f tag (USD)", () => {
+ const event = createP2POrderEvent({
+ tags: [["f", "USD"]],
+ });
+ expect(getCurrency(event)).toBe("USD");
+ });
+
+ it("should extract currency from f tag (EUR)", () => {
+ const event = createP2POrderEvent({
+ tags: [["f", "EUR"]],
+ });
+ expect(getCurrency(event)).toBe("EUR");
+ });
+
+ it("should handle other ISO 4217 currencies", () => {
+ const event = createP2POrderEvent({
+ tags: [["f", "JPY"]],
+ });
+ expect(getCurrency(event)).toBe("JPY");
+ });
+
+ it("should return undefined if no f tag", () => {
+ const event = createP2POrderEvent({
+ tags: [],
+ });
+ expect(getCurrency(event)).toBeUndefined();
+ });
+
+ it("should return undefined for non-38383 events", () => {
+ const event = createP2POrderEvent({
+ kind: 1,
+ tags: [["f", "USD"]],
+ });
+ expect(getCurrency(event)).toBeUndefined();
+ });
+ });
+
+ describe("getBitcoinNetwork", () => {
+ it("should extract bitcoin network from network tag (mainnet)", () => {
+ const event = createP2POrderEvent({
+ tags: [["network", "mainnet"]],
+ });
+ expect(getBitcoinNetwork(event)).toBe("mainnet");
+ });
+
+ it("should extract bitcoin network from network tag (testnet)", () => {
+ const event = createP2POrderEvent({
+ tags: [["network", "testnet"]],
+ });
+ expect(getBitcoinNetwork(event)).toBe("testnet");
+ });
+
+ it("should return undefined if no network tag", () => {
+ const event = createP2POrderEvent({
+ tags: [],
+ });
+ expect(getBitcoinNetwork(event)).toBeUndefined();
+ });
+
+ it("should return undefined for non-38383 events", () => {
+ const event = createP2POrderEvent({
+ kind: 1,
+ tags: [["network", "mainnet"]],
+ });
+ expect(getBitcoinNetwork(event)).toBeUndefined();
+ });
+ });
+
+ describe("getUsername", () => {
+ it("should extract username from name tag", () => {
+ const event = createP2POrderEvent({
+ tags: [["name", "alice"]],
+ });
+ expect(getUsername(event)).toBe("alice");
+ });
+
+ it("should return undefined if no name tag", () => {
+ const event = createP2POrderEvent({
+ tags: [],
+ });
+ expect(getUsername(event)).toBeUndefined();
+ });
+
+ it("should return undefined for non-38383 events", () => {
+ const event = createP2POrderEvent({
+ kind: 1,
+ tags: [["name", "alice"]],
+ });
+ expect(getUsername(event)).toBeUndefined();
+ });
+ });
+
+ describe("getPaymentMethods", () => {
+ it("should extract all payment methods from pm tags", () => {
+ const event = createP2POrderEvent({
+ tags: [
+ ["pm", "revolut"],
+ ["pm", "strike"],
+ ["pm", "cash-app"],
+ ],
+ });
+ expect(getPaymentMethods(event)).toEqual([
+ "revolut",
+ "strike",
+ "cash-app",
+ ]);
+ });
+
+ it("should handle single payment method", () => {
+ const event = createP2POrderEvent({
+ tags: [["pm", "paypal"]],
+ });
+ expect(getPaymentMethods(event)).toEqual(["paypal"]);
+ });
+
+ it("should return empty array if no pm tags", () => {
+ const event = createP2POrderEvent({
+ tags: [],
+ });
+ expect(getPaymentMethods(event)).toEqual([]);
+ });
+
+ it("should return undefined for non-38383 events", () => {
+ const event = createP2POrderEvent({
+ kind: 1,
+ tags: [["pm", "paypal"]],
+ });
+ expect(getPaymentMethods(event)).toBeUndefined();
+ });
+
+ it("should handle pm tags with multiple values per tag", () => {
+ const event = createP2POrderEvent({
+ tags: [
+ ["pm", "revolut", "extra-value"],
+ ["pm", "strike"],
+ ],
+ });
+ // getTagValues uses flatMap with slice(1), so all values after pm are included
+ expect(getPaymentMethods(event)).toEqual([
+ "revolut",
+ "extra-value",
+ "strike",
+ ]);
+ });
+ });
+
+ describe("getPlatform", () => {
+ it("should extract platform from y tag", () => {
+ const event = createP2POrderEvent({
+ tags: [["y", "robosats"]],
+ });
+ expect(getPlatform(event)).toBe("robosats");
+ });
+
+ it("should handle different platforms", () => {
+ const event = createP2POrderEvent({
+ tags: [["y", "hodlhodl"]],
+ });
+ expect(getPlatform(event)).toBe("hodlhodl");
+ });
+
+ it("should return undefined if no y tag", () => {
+ const event = createP2POrderEvent({
+ tags: [],
+ });
+ expect(getPlatform(event)).toBeUndefined();
+ });
+
+ it("should return undefined for non-38383 events", () => {
+ const event = createP2POrderEvent({
+ kind: 1,
+ tags: [["y", "robosats"]],
+ });
+ expect(getPlatform(event)).toBeUndefined();
+ });
+ });
+
+ describe("getExpiration", () => {
+ it("should extract expiration timestamp from expiration tag", () => {
+ const event = createP2POrderEvent({
+ tags: [["expiration", "1704067200"]],
+ });
+ expect(getExpiration(event)).toBe(1704067200);
+ });
+
+ it("should return undefined if no expiration tag", () => {
+ const event = createP2POrderEvent({
+ tags: [],
+ });
+ expect(getExpiration(event)).toBeUndefined();
+ });
+
+ it("should return undefined if expiration tag value is not a number", () => {
+ const event = createP2POrderEvent({
+ tags: [["expiration", "invalid"]],
+ });
+ expect(getExpiration(event)).toBeUndefined();
+ });
+
+ it("should return undefined for non-38383 events", () => {
+ const event = createP2POrderEvent({
+ kind: 1,
+ tags: [["expiration", "1704067200"]],
+ });
+ expect(getExpiration(event)).toBeUndefined();
+ });
+ });
+
+ describe("getPremium", () => {
+ it("should extract premium value from premium tag (positive)", () => {
+ const event = createP2POrderEvent({
+ tags: [["premium", "5"]],
+ });
+ expect(getPremium(event)).toBe(5);
+ });
+
+ it("should extract premium value from premium tag (negative)", () => {
+ const event = createP2POrderEvent({
+ tags: [["premium", "-3"]],
+ });
+ expect(getPremium(event)).toBe(-3);
+ });
+
+ it("should extract premium value from premium tag (zero)", () => {
+ const event = createP2POrderEvent({
+ tags: [["premium", "0"]],
+ });
+ expect(getPremium(event)).toBe(0);
+ });
+
+ it("should return undefined if no premium tag", () => {
+ const event = createP2POrderEvent({
+ tags: [],
+ });
+ expect(getPremium(event)).toBeUndefined();
+ });
+
+ it("should return undefined if premium tag value is not a number", () => {
+ const event = createP2POrderEvent({
+ tags: [["premium", "invalid"]],
+ });
+ expect(getPremium(event)).toBeUndefined();
+ });
+
+ it("should return undefined for non-38383 events", () => {
+ const event = createP2POrderEvent({
+ kind: 1,
+ tags: [["premium", "5"]],
+ });
+ expect(getPremium(event)).toBeUndefined();
+ });
+ });
+
+ describe("getOrderStatus", () => {
+ it("should extract status from s tag (pending)", () => {
+ const event = createP2POrderEvent({
+ tags: [["s", "pending"]],
+ });
+ expect(getOrderStatus(event)).toBe("pending");
+ });
+
+ it("should extract status from s tag (canceled)", () => {
+ const event = createP2POrderEvent({
+ tags: [["s", "canceled"]],
+ });
+ expect(getOrderStatus(event)).toBe("canceled");
+ });
+
+ it("should extract status from s tag (in-progress)", () => {
+ const event = createP2POrderEvent({
+ tags: [["s", "in-progress"]],
+ });
+ expect(getOrderStatus(event)).toBe("in-progress");
+ });
+
+ it("should extract status from s tag (success)", () => {
+ const event = createP2POrderEvent({
+ tags: [["s", "success"]],
+ });
+ expect(getOrderStatus(event)).toBe("success");
+ });
+
+ it("should extract status from s tag (expired)", () => {
+ const event = createP2POrderEvent({
+ tags: [["s", "expired"]],
+ });
+ expect(getOrderStatus(event)).toBe("expired");
+ });
+
+ it("should return undefined if no s tag", () => {
+ const event = createP2POrderEvent({
+ tags: [],
+ });
+ expect(getOrderStatus(event)).toBeUndefined();
+ });
+
+ it("should return undefined for invalid status value", () => {
+ const event = createP2POrderEvent({
+ tags: [["s", "invalid-status"]],
+ });
+ expect(getOrderStatus(event)).toBeUndefined();
+ });
+
+ it("should return undefined for non-38383 events", () => {
+ const event = createP2POrderEvent({
+ kind: 1,
+ tags: [["s", "pending"]],
+ });
+ expect(getOrderStatus(event)).toBeUndefined();
+ });
+
+ it("should validate against ORDER_STATUSES constant", () => {
+ // Verify all valid statuses are recognized
+ ORDER_STATUSES.forEach((status) => {
+ const event = createP2POrderEvent({
+ tags: [["s", status]],
+ });
+ expect(getOrderStatus(event)).toBe(status);
+ });
+ });
+ });
+
+ describe("getSource", () => {
+ it("should extract source URL from source tag", () => {
+ const event = createP2POrderEvent({
+ tags: [["source", "https://robosats.com/order/abc123"]],
+ });
+ expect(getSource(event)).toBe("https://robosats.com/order/abc123");
+ });
+
+ it("should return undefined if no source tag", () => {
+ const event = createP2POrderEvent({
+ tags: [],
+ });
+ expect(getSource(event)).toBeUndefined();
+ });
+
+ it("should return undefined for non-38383 events", () => {
+ const event = createP2POrderEvent({
+ kind: 1,
+ tags: [["source", "https://example.com"]],
+ });
+ expect(getSource(event)).toBeUndefined();
+ });
+ });
+
+ describe("getBitcoinLayer", () => {
+ it("should extract bitcoin layer from layer tag (lightning)", () => {
+ const event = createP2POrderEvent({
+ tags: [["layer", "lightning"]],
+ });
+ expect(getBitcoinLayer(event)).toBe("lightning");
+ });
+
+ it("should extract bitcoin layer from layer tag (onchain)", () => {
+ const event = createP2POrderEvent({
+ tags: [["layer", "onchain"]],
+ });
+ expect(getBitcoinLayer(event)).toBe("onchain");
+ });
+
+ it("should return undefined if no layer tag", () => {
+ const event = createP2POrderEvent({
+ tags: [],
+ });
+ expect(getBitcoinLayer(event)).toBeUndefined();
+ });
+
+ it("should return undefined for non-38383 events", () => {
+ const event = createP2POrderEvent({
+ kind: 1,
+ tags: [["layer", "lightning"]],
+ });
+ expect(getBitcoinLayer(event)).toBeUndefined();
+ });
+ });
+
+ describe("ORDER_STATUSES constant", () => {
+ it("should contain all valid order statuses", () => {
+ expect(ORDER_STATUSES).toContain("pending");
+ expect(ORDER_STATUSES).toContain("canceled");
+ expect(ORDER_STATUSES).toContain("in-progress");
+ expect(ORDER_STATUSES).toContain("success");
+ expect(ORDER_STATUSES).toContain("expired");
+ });
+
+ it("should have exactly 5 statuses", () => {
+ expect(ORDER_STATUSES.length).toBe(5);
+ });
+
+ it("should be readonly tuple", () => {
+ // TypeScript compile-time check - this tests the type definition
+ const status: OrderStatus = "pending";
+ expect(ORDER_STATUSES.includes(status)).toBe(true);
+ });
+ });
+});
diff --git a/src/lib/nip69-helpers.ts b/src/lib/nip69-helpers.ts
new file mode 100644
index 0000000..d5ee587
--- /dev/null
+++ b/src/lib/nip69-helpers.ts
@@ -0,0 +1,185 @@
+import { NostrEvent } from "@/types/nostr";
+import { getTagValue } from "applesauce-core/helpers";
+
+/**
+ * P2P Order Helper Functions
+ */
+
+/**
+ * Status of the order
+ */
+export const ORDER_STATUSES = [
+ "pending",
+ "canceled",
+ "in-progress",
+ "success",
+ "expired",
+] as const;
+export type OrderStatus = (typeof ORDER_STATUSES)[number];
+
+/**
+ * Get all values for a tag name (plural version of getTagValue)
+ * Unlike getTagValue which returns first match, this returns all matches
+ */
+function getTagValues(event: NostrEvent, tagName: string): string[] {
+ return event.tags
+ .filter((tag) => tag[0] === tagName)
+ .flatMap((tag) => tag.slice(1));
+}
+
+// ============================================================================
+// Kind 38383 (P2P Orders) Helpers
+// ============================================================================
+
+/**
+ * Get order type from k tag (sell or buy)
+ */
+export function getOrderType(event: NostrEvent): string | undefined {
+ if (event.kind !== 38383) return undefined;
+ return getTagValue(event, "k");
+}
+
+/**
+ * Get amount in fiat from fa tag
+ */
+export function getFiatAmount(
+ event: NostrEvent,
+): (string | undefined)[] | undefined {
+ if (event.kind !== 38383) return undefined;
+ const values = getTagValues(event, "fa");
+
+ return values.map((monto) => {
+ const number = parseFloat(monto);
+
+ if (Number.isNaN(number)) return undefined;
+
+ return number < 1 ? number.toString() : parseInt(monto, 10).toString();
+ });
+}
+
+/**
+ * Get amount in sats from amt tag
+ */
+export function getSatsAmount(event: NostrEvent): number | undefined {
+ if (event.kind !== 38383) return undefined;
+ const value = getTagValue(event, "amt");
+
+ if (!value) return undefined;
+
+ const number = parseInt(value, 10);
+
+ if (Number.isNaN(number)) return undefined;
+
+ return number;
+}
+
+/**
+ * Get currency code using the ISO 4217 standard.
+ */
+export function getCurrency(event: NostrEvent): string | undefined {
+ if (event.kind !== 38383) return undefined;
+
+ return getTagValue(event, "f");
+}
+
+/**
+ * Get the bitcoin network
+ */
+export function getBitcoinNetwork(event: NostrEvent): string | undefined {
+ if (event.kind !== 38383) return undefined;
+
+ return getTagValue(event, "network");
+}
+
+/**
+ * Get the user name
+ */
+export function getUsername(event: NostrEvent): string | undefined {
+ if (event.kind !== 38383) return undefined;
+
+ return getTagValue(event, "name");
+}
+
+/**
+ * Get accepted payment methods
+ */
+export function getPaymentMethods(event: NostrEvent): string[] | undefined {
+ if (event.kind !== 38383) return undefined;
+
+ return getTagValues(event, "pm");
+}
+
+/**
+ * Get platform where the order is hosted
+ */
+export function getPlatform(event: NostrEvent): string | undefined {
+ if (event.kind !== 38383) return undefined;
+
+ return getTagValue(event, "y");
+}
+
+/**
+ * Get rating
+ */
+export function getExpiration(event: NostrEvent): number | undefined {
+ if (event.kind !== 38383) return undefined;
+
+ const value = getTagValue(event, "expiration");
+
+ if (!value) return undefined;
+
+ const number = parseInt(value, 10);
+
+ if (Number.isNaN(number)) return undefined;
+
+ return number;
+}
+
+/**
+ * Get premium value over market price
+ */
+export function getPremium(event: NostrEvent): number | undefined {
+ if (event.kind !== 38383) return undefined;
+ const value = getTagValue(event, "premium");
+
+ if (!value) return undefined;
+
+ const number = parseInt(value, 10);
+
+ if (Number.isNaN(number)) return undefined;
+
+ return number;
+}
+
+/**
+ * Get status of the order
+ */
+export function getOrderStatus(event: NostrEvent): OrderStatus | undefined {
+ if (event.kind !== 38383) return undefined;
+
+ const status = getTagValue(event, "s");
+
+ if (!status) return undefined;
+
+ if (!ORDER_STATUSES.includes(status as OrderStatus)) return undefined;
+
+ return status as OrderStatus;
+}
+
+/**
+ * Get link to the order view in host
+ */
+export function getSource(event: NostrEvent): string | undefined {
+ if (event.kind !== 38383) return undefined;
+
+ return getTagValue(event, "source");
+}
+
+/**
+ * Get bitcoin layer
+ */
+export function getBitcoinLayer(event: NostrEvent): string | undefined {
+ if (event.kind !== 38383) return undefined;
+
+ return getTagValue(event, "layer");
+}