+ {rows.length === 0
+ ? `Nothing for sale on ${networkName} yet. Connect and publish from Sell.`
+ : "Nothing in this filter. Try All or For sale."}
+
+
) : (
listings.map((listing) => (
diff --git a/src/app/sell/page.tsx b/src/app/sell/page.tsx
index 2d8aab8..d3e2d73 100644
--- a/src/app/sell/page.tsx
+++ b/src/app/sell/page.tsx
@@ -8,7 +8,9 @@ import { saveClaimSecret, getEscrowSecrets } from "@/data/escrowSecrets";
import { aliasFor } from "@/data/accountStore";
import { commitmentHashFromSecret, randomFeltSecret } from "@/lib/escrow";
import { publishRemoteListing } from "@/lib/marketplace";
+import { marketplaceNetworkFromIndex } from "@/lib/marketplaceNetwork";
import { useStoreWallet } from "@/app/components/Wallet/walletContext";
+import { useFrontendProvider } from "@/app/components/client/provider/providerContext";
import ConnectGate from "@/app/components/ghost/ConnectGate";
function compressImage(file: File): Promise {
@@ -50,6 +52,7 @@ function SellForm() {
const editId = search.get("edit");
const isConnected = useStoreWallet((s) => s.isConnected);
const address = useStoreWallet((s) => s.address);
+ const providerIndex = useFrontendProvider((s) => s.currentFrontendProviderIndex);
const [title, setTitle] = useState("");
const [price, setPrice] = useState("");
const [token, setToken] = useState("STRK");
@@ -77,7 +80,7 @@ function SellForm() {
setSeller(row.seller);
setBlurb(row.blurb);
setImage(row.image.startsWith("data:") ? row.image : row.image);
- }, [address, editId]);
+ }, [address, editId, providerIndex]);
if (!isConnected) {
return ;
@@ -131,7 +134,7 @@ function SellForm() {
}
// New listings go to the shared marketplace too; edits stay local. Best
// effort: a storage failure must not lose the local publish.
- if (!existing) publishRemoteListing(listing).catch(() => undefined);
+ if (!existing) publishRemoteListing(listing, marketplaceNetworkFromIndex(providerIndex)).catch(() => undefined);
if (existingSecret) {
router.push(`/listing/${id}`);
return;
diff --git a/src/data/listingStore.ts b/src/data/listingStore.ts
index d9cccb5..dd18495 100644
--- a/src/data/listingStore.ts
+++ b/src/data/listingStore.ts
@@ -1,14 +1,37 @@
"use client";
import { type Listing } from "@/data/listings";
-import { SEED_LISTINGS } from "@/data/seedListings";
import { fetchRemoteListings } from "@/lib/marketplace";
+import { marketplaceNetworkFromIndex, type MarketplaceNetwork } from "@/lib/marketplaceNetwork";
import { sameAddress } from "@/data/accountStore";
+import { useFrontendProvider } from "@/app/components/client/provider/providerContext";
const KEY = "ghostdeal-extra-listings";
const HIDDEN_KEY = "ghostdeal-hidden-listings";
const CHANGE_EVENT = "ghostdeal-listings";
+function currentNetwork(): MarketplaceNetwork {
+ return marketplaceNetworkFromIndex(useFrontendProvider.getState().currentFrontendProviderIndex);
+}
+
+function extrasKey(network: MarketplaceNetwork) {
+ return `${KEY}:${network}`;
+}
+
+function hiddenKey(network: MarketplaceNetwork) {
+ return `${HIDDEN_KEY}:${network}`;
+}
+
+function readJson(key: string, fallback: T): T {
+ if (typeof window === "undefined") return fallback;
+ try {
+ const raw = localStorage.getItem(key);
+ return raw ? (JSON.parse(raw) as T) : fallback;
+ } catch {
+ return fallback;
+ }
+}
+
function notifyListingsChanged() {
if (typeof window === "undefined") return;
window.dispatchEvent(new Event(CHANGE_EVENT));
@@ -24,60 +47,60 @@ export function onListingsChanged(handler: () => void) {
}
export function loadExtraListings(): Listing[] {
- if (typeof window === "undefined") return [];
- try {
- const raw = localStorage.getItem(KEY);
- return raw ? (JSON.parse(raw) as Listing[]) : [];
- } catch {
- return [];
- }
+ const network = currentNetwork();
+ const scoped = readJson(extrasKey(network), []);
+ if (network !== "sepolia") return scoped;
+ const legacy = readJson(KEY, []);
+ if (legacy.length === 0) return scoped;
+ const seen = new Set(scoped.map((row) => row.id));
+ return [...scoped, ...legacy.filter((row) => !seen.has(row.id))];
}
function loadHiddenIds(): string[] {
- if (typeof window === "undefined") return [];
- try {
- const raw = localStorage.getItem(HIDDEN_KEY);
- return raw ? (JSON.parse(raw) as string[]) : [];
- } catch {
- return [];
- }
+ const network = currentNetwork();
+ const scoped = readJson(hiddenKey(network), []);
+ if (network !== "sepolia") return scoped;
+ const legacy = readJson(HIDDEN_KEY, []);
+ return [...new Set([...scoped, ...legacy])];
}
export function saveExtraListing(listing: Listing) {
+ const network = currentNetwork();
const next = [listing, ...loadExtraListings().filter((row) => row.id !== listing.id)];
- localStorage.setItem(KEY, JSON.stringify(next));
+ localStorage.setItem(extrasKey(network), JSON.stringify(next));
notifyListingsChanged();
}
export function removeListing(id: string) {
+ const network = currentNetwork();
const extras = loadExtraListings().filter((row) => row.id !== id);
- localStorage.setItem(KEY, JSON.stringify(extras));
+ localStorage.setItem(extrasKey(network), JSON.stringify(extras));
const hidden = new Set(loadHiddenIds());
hidden.add(id);
- localStorage.setItem(HIDDEN_KEY, JSON.stringify([...hidden]));
+ localStorage.setItem(hiddenKey(network), JSON.stringify([...hidden]));
notifyListingsChanged();
}
export function allListings(): Listing[] {
+ const network = currentNetwork();
const hidden = new Set(loadHiddenIds());
- // Local listings first (they carry payment state), then the shared
- // marketplace, then the shipped demo seeds; hidden ids filter all of them.
const local = loadExtraListings().filter((row) => !hidden.has(row.id));
const seen = new Set(local.map((row) => row.id));
- const remote = remoteCache.filter((row) => !hidden.has(row.id) && !seen.has(row.id));
- remote.forEach((row) => seen.add(row.id));
- const seeds = SEED_LISTINGS.filter((row) => !hidden.has(row.id) && !seen.has(row.id));
- return [...local, ...remote, ...seeds];
+ const remote = (remoteCache[network] ?? []).filter((row) => !hidden.has(row.id) && !seen.has(row.id));
+ return [...local, ...remote];
}
-let remoteCache: Listing[] = [];
+const remoteCache: Partial> = {};
// Pulls the shared marketplace into the cache. Failures keep the previous
// cache: a storage hiccup must not blank the marketplace view.
export async function refreshRemoteListings() {
- const rows = await fetchRemoteListings();
- if (rows.length > 0 || remoteCache.length === 0) {
- remoteCache = rows;
+ const network = currentNetwork();
+ notifyListingsChanged();
+ const rows = await fetchRemoteListings(network);
+ const prev = remoteCache[network] ?? [];
+ if (rows.length > 0 || prev.length === 0) {
+ remoteCache[network] = rows;
notifyListingsChanged();
}
}
@@ -125,6 +148,6 @@ export function claimOrphanListings(address: string) {
return row;
});
if (!changed) return;
- localStorage.setItem(KEY, JSON.stringify(next));
+ localStorage.setItem(extrasKey(currentNetwork()), JSON.stringify(next));
notifyListingsChanged();
}
diff --git a/src/data/seedListings.ts b/src/data/seedListings.ts
index 94d2c75..3ddba85 100644
--- a/src/data/seedListings.ts
+++ b/src/data/seedListings.ts
@@ -1,41 +1,5 @@
import type { Listing } from "@/data/listings";
-// Demo listings shipped with the repo so any visitor sees a live marketplace
-// with no backend. Their claimHashes are real: a payment locks on-chain like
-// any other, and the GhostDeal team holds the preimages (never in this repo).
-// Paying one is indistinguishable from paying a stranger's listing.
-export const SEED_LISTINGS: Listing[] = [
- {
- id: "seed-keyboard",
- title: "Mechanical keyboard, tactile",
- price: "3",
- token: "STRK",
- seller: "@ghost",
- image: "/demo/keyboard.svg",
- blurb: "Hot-swappable switches, PBT caps, used for one hackathon season. Hand it over in person and get paid in private.",
- status: "open",
- claimHash: "0x1ef94103a8a2999ae6f189b3453e24b2522daa553ca40dd72c03c8d2bb32453",
- },
- {
- id: "seed-coffee",
- title: "Specialty coffee, 1 kg",
- price: "1",
- token: "STRK",
- seller: "@ghost",
- image: "/demo/coffee.svg",
- blurb: "Washed Ethiopian, roasted this week. The cheapest end-to-end demo on the marketplace.",
- status: "open",
- claimHash: "0x4faab32bf6bd50bf71fd7ecef6947d3281ffc89aea745723bf0186b5d968b79",
- },
- {
- id: "seed-bike",
- title: "City bike, tuned",
- price: "15",
- token: "STRK",
- seller: "@ghost",
- image: "/demo/bike.svg",
- blurb: "Single speed, new brake pads, locks to any rack. Meet at the park, scan, pay, ride away.",
- status: "open",
- claimHash: "0x2b7f05a6f1272ee370a220584e6c3b2c274d2f602a0d18a489eed19ec4e0636",
- },
-];
+export const SEED_LISTINGS: Listing[] = [];
+
+
diff --git a/src/lib/marketplace.ts b/src/lib/marketplace.ts
index 196b0f7..052707d 100644
--- a/src/lib/marketplace.ts
+++ b/src/lib/marketplace.ts
@@ -1,10 +1,11 @@
import type { Listing } from "@/data/listings";
+import type { MarketplaceNetwork } from "@/lib/marketplaceNetwork";
// Shared marketplace on the app's own API. Best-effort by design: any failure
// degrades to the local-only view and never blocks publishing or paying.
-export async function fetchRemoteListings(): Promise {
+export async function fetchRemoteListings(network: MarketplaceNetwork): Promise {
try {
- const res = await fetch("/api/listings", { cache: "no-store" });
+ const res = await fetch(`/api/listings?network=${network}`, { cache: "no-store" });
if (!res.ok) return [];
const data = (await res.json()) as { listings?: Listing[] };
return Array.isArray(data.listings) ? data.listings : [];
@@ -13,9 +14,9 @@ export async function fetchRemoteListings(): Promise {
}
}
-export async function publishRemoteListing(listing: Listing): Promise {
+export async function publishRemoteListing(listing: Listing, network: MarketplaceNetwork): Promise {
try {
- const res = await fetch("/api/listings", {
+ const res = await fetch(`/api/listings?network=${network}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(listing),
@@ -26,9 +27,13 @@ export async function publishRemoteListing(listing: Listing): Promise {
}
}
-export async function deleteRemoteListing(id: string, ownerAddress: string): Promise {
+export async function deleteRemoteListing(
+ id: string,
+ ownerAddress: string,
+ network: MarketplaceNetwork,
+): Promise {
try {
- const res = await fetch(`/api/listings/${id}`, {
+ const res = await fetch(`/api/listings/${id}?network=${network}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ownerAddress }),
diff --git a/src/lib/marketplaceNetwork.ts b/src/lib/marketplaceNetwork.ts
new file mode 100644
index 0000000..0d9e4b6
--- /dev/null
+++ b/src/lib/marketplaceNetwork.ts
@@ -0,0 +1,21 @@
+export type MarketplaceNetwork = "mainnet" | "sepolia";
+
+export function isMarketplaceNetwork(value: unknown): value is MarketplaceNetwork {
+ return value === "mainnet" || value === "sepolia";
+}
+
+export function parseMarketplaceNetwork(value: string | null): MarketplaceNetwork | null {
+ return isMarketplaceNetwork(value) ? value : null;
+}
+
+export function marketplaceNetworkFromIndex(index: number): MarketplaceNetwork {
+ return index === 0 ? "mainnet" : "sepolia";
+}
+
+export function marketplaceIndexFromNetwork(network: MarketplaceNetwork): number {
+ return network === "mainnet" ? 0 : 2;
+}
+
+export function marketplaceNetworkLabel(network: MarketplaceNetwork): string {
+ return network === "mainnet" ? "Mainnet" : "Sepolia";
+}
diff --git a/src/lib/marketplaceRedis.ts b/src/lib/marketplaceRedis.ts
new file mode 100644
index 0000000..e5aa93a
--- /dev/null
+++ b/src/lib/marketplaceRedis.ts
@@ -0,0 +1,111 @@
+import { parseMarketplaceNetwork, type MarketplaceNetwork } from "@/lib/marketplaceNetwork";
+
+const MAX_LISTINGS = 200;
+const LEGACY_IDS_KEY = "gd:ids";
+const legacyItemKey = (id: string) => `gd:l:${id}`;
+const idsKey = (network: MarketplaceNetwork) => `gd:ids:${network}`;
+const itemKey = (network: MarketplaceNetwork, id: string) => `gd:l:${network}:${id}`;
+
+type RedisCall = (body: unknown) => Promise<{ result: unknown }>;
+
+function redis(): RedisCall | null {
+ const url = process.env.UPSTASH_REDIS_REST_URL;
+ const token = process.env.UPSTASH_REDIS_REST_TOKEN;
+ if (!url || !token) return null;
+ return async (body) => {
+ const commands = Array.isArray(body) ? body : [];
+ const pipeline = commands.length > 0 && Array.isArray(commands[0]);
+ const endpoint = pipeline ? `${url.replace(/\/$/, "")}/pipeline` : url;
+ const res = await fetch(endpoint, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ cache: "no-store",
+ });
+ if (!res.ok) throw new Error(`redis ${res.status}`);
+ const json = await res.json();
+ if (pipeline) {
+ return { result: (json as { result: unknown }[]).map((row) => row.result) };
+ }
+ return json as { result: unknown };
+ };
+}
+
+export function redisConfigured(): boolean {
+ return redis() !== null;
+}
+
+export function networkFromRequest(request: Request): MarketplaceNetwork | null {
+ return parseMarketplaceNetwork(new URL(request.url).searchParams.get("network"));
+}
+
+async function listIds(call: RedisCall, key: string): Promise {
+ const idsRes = await call([["lrange", key, "0", "-1"]]);
+ return (idsRes.result as string[][])[0] ?? [];
+}
+
+async function mgetJson(call: RedisCall, keys: string[]): Promise {
+ if (keys.length === 0) return [];
+ const itemsRes = await call([["mget", ...keys]]);
+ return ((itemsRes.result as (string | null)[][])[0] ?? [])
+ .filter((v): v is string => Boolean(v))
+ .map((v) => JSON.parse(v))
+ .filter(Boolean);
+}
+
+export async function listMarketplaceListings(network: MarketplaceNetwork): Promise {
+ const call = redis();
+ if (!call) return [];
+ const scopedIds = await listIds(call, idsKey(network));
+ const scoped = await mgetJson(call, scopedIds.map((id) => itemKey(network, id)));
+ if (network !== "sepolia") return scoped;
+ const legacyIds = await listIds(call, LEGACY_IDS_KEY);
+ const seen = new Set(scopedIds);
+ const leftover = legacyIds.filter((id) => !seen.has(id));
+ const legacy = await mgetJson(call, leftover.map(legacyItemKey));
+ return [...scoped, ...legacy];
+}
+
+export async function saveMarketplaceListing(
+ network: MarketplaceNetwork,
+ listing: Record,
+): Promise {
+ const call = redis();
+ if (!call) throw new Error("marketplace storage not configured");
+ await call([
+ ["set", itemKey(network, String(listing.id)), JSON.stringify({ ...listing, status: "open" })],
+ ["lpush", idsKey(network), listing.id],
+ ["ltrim", idsKey(network), "0", String(MAX_LISTINGS - 1)],
+ ]);
+}
+
+export async function deleteMarketplaceListing(
+ network: MarketplaceNetwork,
+ id: string,
+ ownerAddress: string,
+): Promise<"ok" | "not_found" | "forbidden"> {
+ const call = redis();
+ if (!call) throw new Error("marketplace storage not configured");
+ const scopedRes = await call([["get", itemKey(network, id)]]);
+ let stored = (scopedRes.result as (string | null)[])[0];
+ let usedLegacy = false;
+ if (!stored && network === "sepolia") {
+ const legacyRes = await call([["get", legacyItemKey(id)]]);
+ stored = (legacyRes.result as (string | null)[])[0];
+ usedLegacy = Boolean(stored);
+ }
+ if (!stored) return "not_found";
+ const listing = JSON.parse(stored) as { ownerAddress?: string };
+ if (!listing.ownerAddress || listing.ownerAddress.toLowerCase() !== ownerAddress.toLowerCase()) {
+ return "forbidden";
+ }
+ const commands: unknown[] = [
+ ["del", itemKey(network, id)],
+ ["lrem", idsKey(network), "1", id],
+ ];
+ if (usedLegacy || network === "sepolia") {
+ commands.push(["del", legacyItemKey(id)], ["lrem", LEGACY_IDS_KEY, "1", id]);
+ }
+ await call(commands);
+ return "ok";
+}