diff --git a/app/(app)/dashboard/ads/slots/page.tsx b/app/(app)/dashboard/ads/slots/page.tsx
index d25dd982..076d33b6 100644
--- a/app/(app)/dashboard/ads/slots/page.tsx
+++ b/app/(app)/dashboard/ads/slots/page.tsx
@@ -1,7 +1,9 @@
import Link from "next/link";
import { createClient } from "@/lib/supabase/server";
import { env } from "@/lib/env";
-import { fetchSupportedTokens } from "@/lib/coinpay-tokens";
+import { serviceClient } from "@/lib/supabase/service";
+import { globalWalletProfile } from "@/lib/coinpay-wallets";
+import { GlobalWalletCard } from "@/components/ads/global-wallet-card";
import { SlotRow } from "@/components/ads/slot-row";
import { StatsUnavailable } from "@/components/stats-unavailable";
import {
@@ -18,17 +20,6 @@ import {
type SlotTotals,
} from "@/lib/ads/series";
-// Fallback coins when CoinPay's supported-coins endpoint is unavailable, so the
-// payout dropdown is never empty. Codes match what /payments+/payouts expect.
-const FALLBACK_COINS = [
- { code: "usdc_pol", symbol: "USDC", name: "USD Coin (Polygon)", chain: "Polygon" },
- { code: "usdc_eth", symbol: "USDC", name: "USD Coin (Ethereum)", chain: "Ethereum" },
- { code: "usdt_pol", symbol: "USDT", name: "Tether (Polygon)", chain: "Polygon" },
- { code: "btc", symbol: "BTC", name: "Bitcoin", chain: "Bitcoin" },
- { code: "eth", symbol: "ETH", name: "Ethereum", chain: "Ethereum" },
- { code: "sol", symbol: "SOL", name: "Solana", chain: "Solana" },
-];
-
export const metadata = { title: "Monetize your site" };
type Project = { id: string; name: string; url: string };
@@ -52,9 +43,10 @@ type Payout = {
export default async function SlotsPage({
searchParams,
}: {
- searchParams: Promise<{ q?: string; status?: string; page?: string }>;
+ searchParams: Promise<{ q?: string; status?: string; page?: string; error?: string; coinpay?: string }>;
}) {
- const listQuery = parseListQuery(await searchParams);
+ const params = await searchParams;
+ const listQuery = parseListQuery(params);
const supabase = await createClient();
const {
data: { user },
@@ -107,11 +99,11 @@ export default async function SlotsPage({
}
}
- const tokens = await fetchSupportedTokens().catch(() => []);
- const coins = (tokens.length > 0 ? tokens : FALLBACK_COINS).map((t) => ({
- code: t.code,
- label: `${t.symbol}${t.chain ? ` · ${t.chain}` : ""}`,
- }));
+ const globalWallet = user ? await globalWalletProfile(serviceClient(), user.id) : { configured: false, connected: false, wallets: [], selected: null, syncedAt: null };
+ const returnQuery = new URLSearchParams();
+ for (const key of ["q", "status", "page"] as const) if (params[key]) returnQuery.set(key, params[key]!);
+ const returnPath = "/dashboard/ads/slots" + (returnQuery.size ? `?${returnQuery}` : "");
+ const connectUrl = `/api/v1/coinpay/oauth/start?mode=connect&wallets=1&next=${encodeURIComponent(returnPath)}`;
const slotByProject = new Map(slots.map((s) => [s.project_id, s]));
@@ -149,10 +141,14 @@ export default async function SlotsPage({
Each site you monetize is a CrawlProof project. Add a site to
- create one, drop one tag on the page, and add a payout wallet — you earn crypto
+ create one, drop one tag on the page, and connect CoinPay once — you earn crypto
for the clicks.
+ {params.error && {params.error.slice(0, 300)}
}
+ {params.coinpay === "connected" && CoinPay connected. Your global wallets apply to every site.
}
+
+
{statsFailed && }
{projects.length === 0 ? (
@@ -186,7 +182,7 @@ export default async function SlotsPage({
slot={slot}
origin={env.siteUrl}
availableCents={earned - withdrawn}
- coins={coins}
+ globalWallet={globalWallet}
payouts={slot ? payoutsBySlot.get(slot.id) ?? [] : []}
stats={
stat
diff --git a/app/actions/ads.ts b/app/actions/ads.ts
index d83f98dc..16643d30 100644
--- a/app/actions/ads.ts
+++ b/app/actions/ads.ts
@@ -1,5 +1,6 @@
"use server";
+import { globalWalletProfile } from "@/lib/coinpay-wallets";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
import { serviceClient } from "@/lib/supabase/service";
@@ -1210,6 +1211,7 @@ export async function saveSlotPayout(input: {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Not authenticated." };
+ if ((await globalWalletProfile(serviceClient(), user.id)).configured) return { ok: false, error: "Your sites inherit global CoinPay wallets; change the global destination instead." };
const addr = input.payoutAddress.trim().slice(0, 200);
const { error } = await supabase
.from("ad_slots")
diff --git a/app/actions/coinpayWallets.ts b/app/actions/coinpayWallets.ts
new file mode 100644
index 00000000..653362e3
--- /dev/null
+++ b/app/actions/coinpayWallets.ts
@@ -0,0 +1,15 @@
+"use server";
+import { revalidatePath } from "next/cache";
+import { createClient } from "@/lib/supabase/server";
+import { serviceClient } from "@/lib/supabase/service";
+import { globalWalletProfile } from "@/lib/coinpay-wallets";
+export async function selectGlobalCoinpayWallet(walletId: string) {
+ const { data: { user } } = await (await createClient()).auth.getUser();
+ if (!user) return { ok: false, error: "Sign in first." };
+ const sb = serviceClient();
+ const profile = await globalWalletProfile(sb, user.id);
+ if (!profile.connected || !profile.wallets.some((w) => w.id === walletId)) return { ok: false, error: "Choose a wallet from your connected CoinPay account." };
+ const { error } = await sb.from("coinpay_wallet_profiles").update({ preferred_wallet_id: walletId }).eq("user_id", user.id);
+ if (error) return { ok: false, error: "Could not save your global wallet." };
+ revalidatePath("/dashboard/ads/slots");return { ok: true };
+}
diff --git a/app/api/ads/v1/slots/[id]/route.ts b/app/api/ads/v1/slots/[id]/route.ts
index fed382c6..317c7be9 100644
--- a/app/api/ads/v1/slots/[id]/route.ts
+++ b/app/api/ads/v1/slots/[id]/route.ts
@@ -8,6 +8,7 @@
import { NextResponse, type NextRequest } from "next/server";
import { serviceClient } from "@/lib/supabase/service";
import { authenticateBearer } from "@/lib/sp/apiAuth";
+import { slotPayoutTarget, globalWalletProfile } from "@/lib/coinpay-wallets";
import { cleanPayoutTarget, slotAvailableCents } from "@/lib/ads/payouts";
import { MIN_PAYOUT_CENTS } from "@/lib/ads/pricing";
@@ -31,12 +32,15 @@ async function load(userId: string, id: string) {
if (!slot) return null;
const { data: accruals } = await sb.from("ad_ledger").select("amount_cents").eq("slot_id", id).eq("kind", "publisher_accrual");
const earned = (accruals ?? []).reduce((s, r) => s + (r.amount_cents ?? 0), 0);
+ const target = await slotPayoutTarget(sb, userId, slot);
return {
id: slot.id,
placement: slot.placement,
status: slot.status,
- payout_address: slot.payout_address,
- payout_currency: slot.payout_currency,
+ payout_address: target.ok ? target.address : null,
+ payout_currency: target.ok ? target.currency : null,
+ payout_source: target.ok ? target.source : "coinpay",
+ wallet_error: target.ok ? null : target.error,
earned_cents: earned,
available_cents: await slotAvailableCents(sb, id),
min_payout_cents: MIN_PAYOUT_CENTS,
@@ -64,6 +68,8 @@ export async function PATCH(req: NextRequest, ctx: Ctx) {
}
const patch: Record = {};
if ("payout_address" in body || "payout_currency" in body) {
+ const global = await globalWalletProfile(serviceClient(), auth.userId);
+ if (global.configured) return NextResponse.json({ error: "This account uses global CoinPay wallets. Sync or select its global wallet instead of entering a per-site address." }, { status: 409 });
const t = cleanPayoutTarget(body.payout_address, body.payout_currency);
if ("payout_address" in body) patch.payout_address = t.address;
if ("payout_currency" in body) patch.payout_currency = t.currency;
diff --git a/app/api/ads/v1/wallets/route.ts b/app/api/ads/v1/wallets/route.ts
new file mode 100644
index 00000000..5aae92a0
--- /dev/null
+++ b/app/api/ads/v1/wallets/route.ts
@@ -0,0 +1,33 @@
+import { NextResponse, type NextRequest } from "next/server";
+import { authenticateBearer } from "@/lib/sp/apiAuth";
+import { serviceClient } from "@/lib/supabase/service";
+import { coinpayConfig, COINPAY_ISSUER, resolveCoinpayUser, supabaseCoinpayStore } from "@/lib/coinpay-signin";
+import { fetchGlobalCoinpayWallets } from "@/lib/coinpay-wallet-data";
+import { globalWalletProfile, saveGlobalWalletProfile } from "@/lib/coinpay-wallets";
+export const runtime="nodejs";
+export async function GET(req: NextRequest) {
+ const auth=await authenticateBearer(req);if(!auth.ok)return NextResponse.json({error:auth.error},{status:auth.status});
+ return NextResponse.json(await globalWalletProfile(serviceClient(),auth.userId),{headers:{"cache-control":"no-store"}});
+}
+/** Dual authenticated proof: CrawlProof API caller + provider-verified CoinPay token. */
+export async function POST(req: NextRequest) {
+ const auth=await authenticateBearer(req);if(!auth.ok)return NextResponse.json({error:auth.error},{status:auth.status});
+ const body=await req.json().catch(()=>null);const token=body?.coinpay_token;
+ if(typeof token!=="string" || !token || token.length>16384)return NextResponse.json({error:"Sign in with the CoinPay CLI first."},{status:400});
+ const issuer=coinpayConfig()?.issuer??COINPAY_ISSUER;
+ try {
+ const headers={authorization:`Bearer ${token}`,accept:"application/json"};
+ let response=await fetch(issuer+"/api/oauth/userinfo",{headers,signal:AbortSignal.timeout(15000),cache:"no-store"});
+ let claims: {sub:string; email:null;name:null};
+ if(response.ok){const info=await response.json();if(typeof info.sub!=="string"||!info.sub)throw Error();claims={sub:info.sub,email:null,name:null};}
+ else {
+ response=await fetch(issuer+"/api/auth/me",{headers,signal:AbortSignal.timeout(15000),cache:"no-store"});
+ if(!response.ok)throw Error();const info=await response.json();if(typeof info.merchant?.id!=="string")throw Error();claims={sub:info.merchant.id,email:null,name:null};
+ }
+ const wallets=await fetchGlobalCoinpayWallets(issuer,token);
+ const sb=serviceClient();const linked=await resolveCoinpayUser({mode:"connect",currentUserId:auth.userId,claims},supabaseCoinpayStore(sb));
+ if(!linked.ok)return NextResponse.json({error:linked.error},{status:409});
+ await saveGlobalWalletProfile(sb,auth.userId,claims.sub,wallets);
+ return NextResponse.json(await globalWalletProfile(sb,auth.userId),{headers:{"cache-control":"no-store"}});
+ } catch {return NextResponse.json({error:"Could not verify and sync CoinPay wallets. Sign in again with wallet:read access."},{status:400});}
+}
diff --git a/app/api/v1/coinpay/oauth/callback/route.ts b/app/api/v1/coinpay/oauth/callback/route.ts
index ddc17119..02f942be 100644
--- a/app/api/v1/coinpay/oauth/callback/route.ts
+++ b/app/api/v1/coinpay/oauth/callback/route.ts
@@ -1,3 +1,5 @@
+import { saveGlobalWalletProfile } from "@/lib/coinpay-wallets";
+import type { GlobalCoinpayWallet } from "@/lib/coinpay-wallet-data";
import { NextResponse, type NextRequest } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { serviceClient } from "@/lib/supabase/service";
@@ -34,7 +36,7 @@ export async function GET(req: NextRequest) {
res.headers.set("cache-control", "no-store");
return res;
};
- const failPath = pending?.mode === "connect" ? "/dashboard/settings" : "/login";
+ const failPath = pending?.mode === "connect" ? (pending.wallets ? pending.next : "/dashboard/settings") : "/login";
if (!pending) return back("/login", "CoinPay sign-in expired. Try again.");
if (!stateMatches(pending.state, sp.get("state"))) {
@@ -45,10 +47,12 @@ export async function GET(req: NextRequest) {
if (!code) return back(failPath, "CoinPay sign-in failed: no code.");
let claims;
+ let wallets: GlobalCoinpayWallet[] | undefined;
try {
- const r = await fetchCoinpayClaims(cfg, { code, verifier: pending.verifier, nonce: pending.nonce });
+ const r = await fetchCoinpayClaims(cfg, { code, verifier: pending.verifier, nonce: pending.nonce, wallets: pending.wallets });
if (!r.ok) return back(failPath, `CoinPay sign-in failed: ${r.error}.`);
claims = r.claims;
+ wallets = r.wallets;
} catch (e) {
console.error("[coinpay-signin] token/userinfo", e instanceof Error ? e.message : e);
return back(failPath, "CoinPay did not answer. Try again.");
@@ -68,6 +72,10 @@ export async function GET(req: NextRequest) {
return back(failPath, "CoinPay sign-in failed. Try again.");
}
if (!resolved.ok) return back(failPath, resolved.error);
+ if (wallets) {
+ try { await saveGlobalWalletProfile(sb, resolved.userId, claims.sub, wallets); }
+ catch { return back(pending.next, "Could not save CoinPay wallets. Reconnect to try again."); }
+ }
if (pending.mode === "connect") return back(`${pending.next}${pending.next.includes("?") ? "&" : "?"}coinpay=connected`);
// Already signed in as that very user: nothing to mint.
diff --git a/app/api/v1/coinpay/oauth/start/route.ts b/app/api/v1/coinpay/oauth/start/route.ts
index 5c64f3a0..b3f9c9ea 100644
--- a/app/api/v1/coinpay/oauth/start/route.ts
+++ b/app/api/v1/coinpay/oauth/start/route.ts
@@ -31,7 +31,7 @@ export async function GET(req: NextRequest) {
return NextResponse.redirect(login, 302);
}
}
- const pending = newPendingAuth(mode, sp.get("next") ?? "");
+ const pending = newPendingAuth(mode, sp.get("next") ?? "", sp.get("wallets") === "1");
const res = NextResponse.redirect(authorizeUrl(cfg, pending), 302);
res.cookies.set(OAUTH_COOKIE, encodePending(pending), {
httpOnly: true,
diff --git a/cli/index.ts b/cli/index.ts
index 3373191b..5c6c5d8c 100644
--- a/cli/index.ts
+++ b/cli/index.ts
@@ -576,6 +576,16 @@ async function cmdStats(args: Args): Promise {
async function cmdSlots(args: Args): Promise {
const sub = args.positional[0];
+ if (sub === "wallets") {
+ const sync = args.positional[1] === "sync";
+ const coinpay = sync ? coinpayAuth(args) : null;
+ if (sync && !coinpay) { console.error("Run coinpay auth login first; no wallet addresses are needed."); return 2; }
+ const { status, json } = await apiCall(args, sync ? "POST" : "GET", "/api/ads/v1/wallets", sync ? { coinpay_token: coinpay!.token } : undefined);
+ if (status >= 400) { console.error(String(json.error ?? "Wallet sync failed.")); return 1; }
+ if (args.flags.json) console.log(JSON.stringify(json, null, 2));
+ else console.log(sync ? "CoinPay global wallets synced for all sites." : JSON.stringify(json, null, 2));
+ return 0;
+ }
if (sub === "create") {
if (!args.positional[1]) {
console.error("usage: crawlproof slots create [--placement=inline] [--format=text_link] [--formats=a,b] [--inactive] [--no-tracking] [--json]");
@@ -611,7 +621,7 @@ async function cmdSlots(args: Args): Promise {
}
return 0;
}
- console.error(`unknown: crawlproof slots ${sub} (expected: create | list)`);
+ console.error(`unknown: crawlproof slots ${sub} (expected: create | list | wallets)`);
return 2;
}
@@ -640,7 +650,8 @@ function coinpayAuth(args: Args): { token: string; baseUrl: string } | null {
try {
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
const file = process.env.COINPAY_CONFIG ?? `${home}/.coinpay.json`;
- const token = (JSON.parse(readFileSync(file, "utf8")) as { jwtToken?: string }).jwtToken;
+ const config = JSON.parse(readFileSync(file, "utf8")) as { jwtToken?: string; oauth?: { accessToken?: string } };
+ const token = config.oauth?.accessToken ?? config.jwtToken;
return token ? { token: token.trim(), baseUrl } : null;
} catch {
return null;
@@ -967,6 +978,8 @@ ${EMAIL_TRACKING_USAGE}
site's project is found or created with the stats tracker on, and
the output is the two tags to paste before