Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 17 additions & 21 deletions app/(app)/dashboard/ads/slots/page.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 };
Expand All @@ -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 },
Expand Down Expand Up @@ -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]));

Expand Down Expand Up @@ -149,10 +141,14 @@ export default async function SlotsPage({
</div>
<p className="mt-2 text-[var(--color-muted)]">
Each site you monetize is a CrawlProof <strong>project</strong>. 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.
</p>

{params.error && <p role="alert" className="mt-4 text-sm text-red-400">{params.error.slice(0, 300)}</p>}
{params.coinpay === "connected" && <p role="status" className="mt-4 text-sm">CoinPay connected. Your global wallets apply to every site.</p>}
<GlobalWalletCard profile={globalWallet} connectUrl={connectUrl} />

{statsFailed && <StatsUnavailable what="your delivery and earnings figures" />}

{projects.length === 0 ? (
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/actions/ads.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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")
Expand Down
15 changes: 15 additions & 0 deletions app/actions/coinpayWallets.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
10 changes: 8 additions & 2 deletions app/api/ads/v1/slots/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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,
Expand Down Expand Up @@ -64,6 +68,8 @@ export async function PATCH(req: NextRequest, ctx: Ctx) {
}
const patch: Record<string, unknown> = {};
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;
Expand Down
33 changes: 33 additions & 0 deletions app/api/ads/v1/wallets/route.ts
Original file line number Diff line number Diff line change
@@ -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});}
}
12 changes: 10 additions & 2 deletions app/api/v1/coinpay/oauth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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"))) {
Expand All @@ -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.");
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion app/api/v1/coinpay/oauth/start/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 15 additions & 2 deletions cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@
accept: "application/json",
...(body ? { "content-type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,

Check warning

Code scanning / CodeQL

File data in outbound network request Medium

Outbound network request depends on
file data
.
});
const text = await res.text();
let json: Record<string, unknown> = {};
Expand Down Expand Up @@ -576,6 +576,16 @@

async function cmdSlots(args: Args): Promise<number> {
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 <site> [--placement=inline] [--format=text_link] [--formats=a,b] [--inactive] [--no-tracking] [--json]");
Expand Down Expand Up @@ -611,7 +621,7 @@
}
return 0;
}
console.error(`unknown: crawlproof slots ${sub} (expected: create | list)`);
console.error(`unknown: crawlproof slots ${sub} (expected: create | list | wallets)`);
return 2;
}

Expand Down Expand Up @@ -640,7 +650,8 @@
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;
Expand Down Expand Up @@ -967,6 +978,8 @@
site's project is found or created with the stats tracker on, and
the output is the two tags to paste before </body>.

slots wallets [sync] [--json]
Read or sync global wallets using your existing CoinPay CLI session.
slots list [--json]
Your slots.

Expand Down
24 changes: 24 additions & 0 deletions components/ads/global-wallet-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import type { WalletProfile } from "@/lib/coinpay-wallets";
import { selectGlobalCoinpayWallet } from "@/app/actions/coinpayWallets";
export function GlobalWalletCard({ profile, connectUrl }: { profile: WalletProfile; connectUrl: string }) {
const [busy, start] = useTransition();const [message, setMessage] = useState<string | null>(null);const router=useRouter();
return <section className="card mt-6 p-4">
<h2 className="font-semibold">Global CoinPay payout wallets</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">Connect once. All your current and future sites use the same global payout wallet. No addresses to paste.</p>
{profile.connected && profile.wallets.length > 0 ? <>
<label className="mt-3 block text-sm">Default wallet for all sites
<select className="input mt-1 w-full" value={profile.selected?.id ?? ""} disabled={busy} onChange={(e) => { const id=e.target.value;start(async()=>{try{const r=await selectGlobalCoinpayWallet(id);setMessage(r.ok?"Global wallet saved for all sites.":r.error ?? "Could not save.");if(r.ok)router.refresh();}catch{setMessage("Could not save. Try again.");}}); }}>
{profile.wallets.map((w)=><option key={w.id} value={w.id}>{w.currency.toUpperCase()} · {w.label ? `${w.label} · ` : ""}{w.address}</option>)}
</select>
</label>
<p className="mt-2 break-all font-mono text-xs">{profile.selected?.address}</p>
<p className="mt-2 text-xs text-[var(--color-muted)]">Synced {profile.syncedAt ? new Date(profile.syncedAt).toLocaleString() : "from CoinPay"}. Refresh after changing wallets in CoinPay; the update applies to every site.</p>
</> : <p className="mt-3 text-sm">{profile.connected ? "CoinPay has no active account-level wallets. Add one in CoinPay, then refresh." : "Connect your CoinPay account to import its global receive wallets."}</p>}
<a className="btn btn-primary mt-3 inline-block text-sm" href={connectUrl}>{profile.connected ? "Refresh from CoinPay" : "Connect CoinPay wallets"}</a>
<a className="ml-3 text-sm underline" href="https://coinpayportal.com/settings/wallets" target="_blank" rel="noreferrer">Manage global wallets</a>
{message && <p role="status" className="mt-2 text-sm">{message}</p>}
</section>;
}
Loading