From d1a0a04a0264fff4a06d5ff387accb1565467031 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 30 Oct 2026 16:05:54 -0700 Subject: [PATCH] Use global CoinPay wallets across publisher slots --- app/(app)/dashboard/ads/slots/page.tsx | 38 +++---- app/actions/ads.ts | 2 + app/actions/coinpayWallets.ts | 15 +++ app/api/ads/v1/slots/[id]/route.ts | 10 +- app/api/ads/v1/wallets/route.ts | 33 ++++++ app/api/v1/coinpay/oauth/callback/route.ts | 12 ++- app/api/v1/coinpay/oauth/start/route.ts | 2 +- cli/index.ts | 17 ++- components/ads/global-wallet-card.tsx | 24 +++++ components/ads/slot-manager.tsx | 102 ++---------------- docs/coinpay-global-wallets.md | 27 +++++ lib/ads/payouts.ts | 10 +- lib/coinpay-signin.ts | 18 ++-- lib/coinpay-wallet-data.ts | 29 +++++ lib/coinpay-wallets.ts | 34 ++++++ .../20261031000000_coinpay_global_wallets.sql | 12 +++ tests/ads-partner-api.test.ts | 12 +++ tests/coinpay-global-wallets.test.ts | 44 ++++++++ tests/coinpay-signin.test.ts | 14 +++ tests/coinpay-wallet-api.test.ts | 17 +++ 20 files changed, 341 insertions(+), 131 deletions(-) create mode 100644 app/actions/coinpayWallets.ts create mode 100644 app/api/ads/v1/wallets/route.ts create mode 100644 components/ads/global-wallet-card.tsx create mode 100644 docs/coinpay-global-wallets.md create mode 100644 lib/coinpay-wallet-data.ts create mode 100644 lib/coinpay-wallets.ts create mode 100644 supabase/migrations/20261031000000_coinpay_global_wallets.sql create mode 100644 tests/coinpay-global-wallets.test.ts create mode 100644 tests/coinpay-wallet-api.test.ts 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 . + slots wallets [sync] [--json] + Read or sync global wallets using your existing CoinPay CLI session. slots list [--json] Your slots. diff --git a/components/ads/global-wallet-card.tsx b/components/ads/global-wallet-card.tsx new file mode 100644 index 00000000..ae232853 --- /dev/null +++ b/components/ads/global-wallet-card.tsx @@ -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(null);const router=useRouter(); + return
+

Global CoinPay payout wallets

+

Connect once. All your current and future sites use the same global payout wallet. No addresses to paste.

+ {profile.connected && profile.wallets.length > 0 ? <> + +

{profile.selected?.address}

+

Synced {profile.syncedAt ? new Date(profile.syncedAt).toLocaleString() : "from CoinPay"}. Refresh after changing wallets in CoinPay; the update applies to every site.

+ :

{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."}

} + {profile.connected ? "Refresh from CoinPay" : "Connect CoinPay wallets"} + Manage global wallets + {message &&

{message}

} +
; +} diff --git a/components/ads/slot-manager.tsx b/components/ads/slot-manager.tsx index 89a4b0e7..eab84e0b 100644 --- a/components/ads/slot-manager.tsx +++ b/components/ads/slot-manager.tsx @@ -1,8 +1,9 @@ "use client"; +import type { WalletProfile } from "@/lib/coinpay-wallets"; import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; -import { createSlot, setSlotStatus, saveSlotPayout, requestPayout } from "@/app/actions/ads"; +import { createSlot, setSlotStatus, requestPayout } from "@/app/actions/ads"; import { FEED_FORMAT_ID, FEED_ITEM_LABEL, @@ -51,19 +52,6 @@ type Payout = { created_at: string; }; -// Minimal shape the CoinPay wallet extension is expected to inject on -// tronbrowser.dev. The provider is not shipped yet (extension Phase 3), so we -// feature-detect and no-op gracefully when it's absent. -type CoinPayProvider = { - connect?: () => Promise<{ address?: string } | string>; - getAddress?: () => Promise; -}; -declare global { - interface Window { - coinpay?: CoinPayProvider; - } -} - function payoutStatusClass(status: string): string { if (status === "confirmed") return "text-[var(--color-accent)]"; if (status === "failed") return "text-red-400"; @@ -86,7 +74,7 @@ export function SlotManager({ slot, origin, availableCents = 0, - coins, + globalWallet, payouts = [], stats = null, hidden = false, @@ -95,7 +83,7 @@ export function SlotManager({ slot: Slot | null; origin: string; availableCents?: number; - coins: { code: string; label: string }[]; + globalWallet?: WalletProfile; payouts?: Payout[]; stats?: { impressions: number; clicks: number; earnedCents: number } | null; /** @@ -107,8 +95,7 @@ export function SlotManager({ }) { const router = useRouter(); const [pending, start] = useTransition(); - const [addr, setAddr] = useState(slot?.payout_address ?? ""); - const [currency, setCurrency] = useState(slot?.payout_currency ?? "usdc_pol"); + const [copied, setCopied] = useState(false); // Which size's embed is currently revealed, and whether the code block is open. const [fmt, setFmt] = useState(PUBLISHER_FORMAT_IDS[0]); @@ -154,14 +141,6 @@ export function SlotManager({ router.refresh(); }); } - function savePayout() { - if (!slot) return; - start(async () => { - const res = await saveSlotPayout({ id: slot.id, payoutAddress: addr, payoutCurrency: currency }); - if (!res.ok) return alert(res.error); - router.refresh(); - }); - } function withdraw() { if (!slot) return; start(async () => { @@ -179,30 +158,6 @@ export function SlotManager({ }); } - // One-click connect via the CoinPay wallet extension (tronbrowser.dev). The - // provider isn't shipped yet, so this feature-detects and guides the user - // otherwise — and lights up automatically once window.coinpay exists. - async function connectWallet() { - const provider = typeof window !== "undefined" ? window.coinpay : undefined; - if (!provider) { - alert("Open this page in tronbrowser.dev with the CoinPay wallet extension to connect in one click. Meanwhile, paste your address below."); - return; - } - try { - let address: string | undefined; - if (provider.connect) { - const r = await provider.connect(); - address = typeof r === "string" ? r : r?.address; - } else if (provider.getAddress) { - address = await provider.getAddress(); - } - if (address) setAddr(address); - else alert("Wallet did not return an address."); - } catch (e) { - alert(e instanceof Error ? e.message : "Could not connect wallet."); - } - } - async function callInstall(body: Record) { if (!slot) throw new Error("No slot"); const res = await fetch(`/api/ads/slots/${slot.id}/install-embed`, { @@ -475,46 +430,10 @@ export function SlotManager({ )} -
- - - +
+

{globalWallet?.configured ? "Uses your global CoinPay wallet" : slot.payout_address ? "Existing payout wallet" : "Uses your global CoinPay wallet once connected"}

+ {globalWallet?.selected ?

{globalWallet.selected.currency.toUpperCase()} · {globalWallet.selected.address}

: !globalWallet?.configured && slot.payout_address ?

{slot.payout_currency?.toUpperCase()} · {slot.payout_address}

: null} +

Manage the global destination once at the top of this page; it applies to all connected sites.

@@ -563,8 +482,7 @@ export function SlotManager({ )}

- Earnings settle to this address via CoinPay. Open in tronbrowser.dev with the - CoinPay wallet extension to connect your wallet in one click. + Earnings settle via CoinPay to the payout destination shown above.

)} diff --git a/docs/coinpay-global-wallets.md b/docs/coinpay-global-wallets.md new file mode 100644 index 00000000..daed3e68 --- /dev/null +++ b/docs/coinpay-global-wallets.md @@ -0,0 +1,27 @@ +# Global CoinPay wallets for publisher slots + +Connect CoinPay wallets at `/dashboard/ads/slots` once. The authorization-code flow uses PKCE S256, state and an OIDC nonce, with `openid profile email wallet:read`. Ordinary CoinPay sign-in keeps its existing identity-only scopes. The OAuth client registration must allow `wallet:read`; this does not grant that scope to a user until they authorize it. + +The callback fetches active account-level receive addresses and linked account-level web-wallet addresses from CoinPay and stores their public metadata in a service-only table, bound to the verified CoinPay subject and CrawlProof user. It stores no access tokens, refresh tokens, signing keys or wallet secrets. No manual address fields are shown on the slots page. + +The selected global wallet is the payout default for every current and future slot owned by that user. A single selector changes it for all sites. The initial choice prefers USDC and USDT chain codes, then other returned currencies in stable order. Currency/network codes are carried unchanged apart from case. Wallet addresses retain case. Existing customers' per-slot addresses remain effective until they opt into a global profile. Once configured, a missing connection or empty wallet list blocks withdrawals instead of falling back to a stale per-slot destination. + +Wallet metadata is a snapshot, not a background live mirror. The page shows when it was synced. After changing wallets in CoinPay, choose **Refresh from CoinPay** once; that refresh updates every site. Disconnecting CoinPay invalidates global payout resolution even though the previous public metadata remains stored. CoinPay authorization only reads receive addresses; it does not initiate a payment. Existing user-triggered withdrawal controls remain responsible for payouts. + +## CLI and API + +```sh +coinpay auth login +crawlproof slots wallets sync +crawlproof slots wallets --json +``` + +The CLI reuses `COINPAY_SESSION_TOKEN` or the existing `~/.coinpay.json` session and the normal CrawlProof API token (`CRAWLPROOF_TOKEN` or `~/.crawlproof.json`). An expired or insufficiently scoped CoinPay session needs a new login; no addresses are copied or entered. + +`GET /api/ads/v1/wallets` returns the caller's profile. `POST` accepts `coinpay_token` over HTTPS, verifies it with CoinPay, reads account wallets, and links only the authenticated CrawlProof user to the provider-verified subject. Existing conflicting links are refused. The token is used for this request and is never persisted or returned. Client-supplied addresses and subject IDs are never accepted as proof. + +Slot detail GET reports the effective destination. Per-slot address PATCH/server-action writes are refused after global setup; change the global selection instead. + +## Release + +Apply `20261031000000_coinpay_global_wallets.sql` before the application update. Enable `wallet:read` in the existing CrawlProof CoinPay OAuth client registration. Verify the connection return URL retains the slots filter/page, the imported wallets belong to the consented subject, and one default applies to existing and new slots. Do not trigger real withdrawals to test setup. diff --git a/lib/ads/payouts.ts b/lib/ads/payouts.ts index b4d0e231..1c1fe2f5 100644 --- a/lib/ads/payouts.ts +++ b/lib/ads/payouts.ts @@ -8,6 +8,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { MIN_PAYOUT_CENTS } from "@/lib/ads/pricing"; +import { slotPayoutTarget } from "@/lib/coinpay-wallets"; import { createCryptoPayout } from "@/lib/coinpay"; export type PayoutResult = @@ -48,7 +49,8 @@ export async function requestSlotPayout(input: { .eq("owner_id", userId) .maybeSingle(); if (!slot) return { ok: false, error: "Slot not found." }; - if (!slot.payout_address) return { ok: false, error: "Add a payout wallet address first." }; + const target = await slotPayoutTarget(sb, userId, slot); + if (!target.ok) return target; const available = await slotAvailableCents(sb, slotId); if (available < MIN_PAYOUT_CENTS) { @@ -70,10 +72,10 @@ export async function requestSlotPayout(input: { return { ok: false, error: "Withdrawals are briefly paused for reconciliation. Please try again later." }; } - const currency = slot.payout_currency || "usdc_pol"; + const currency = target.currency; const { data: payout, error } = await sb .from("ad_payouts") - .insert({ owner_id: userId, slot_id: slotId, amount_cents: available, currency, address: slot.payout_address, status: "requested" }) + .insert({ owner_id: userId, slot_id: slotId, amount_cents: available, currency, address: target.address, status: "requested" }) .select("id") .single(); if (error || !payout) return { ok: false, error: error?.message ?? "Failed to record payout." }; @@ -84,7 +86,7 @@ export async function requestSlotPayout(input: { // later without double-paying. const result = await createCryptoPayout({ recipientEmail: input.email ?? `${userId}@users.crawlproof.com`, - recipientWallet: slot.payout_address, + recipientWallet: target.address, amountUsd: available / 100, currency, }); diff --git a/lib/coinpay-signin.ts b/lib/coinpay-signin.ts index 742dd190..da23f47c 100644 --- a/lib/coinpay-signin.ts +++ b/lib/coinpay-signin.ts @@ -15,6 +15,7 @@ // // The decision logic is written against small interfaces so tests run in // memory; supabaseCoinpayStore() is the real one. +import { fetchGlobalCoinpayWallets, type GlobalCoinpayWallet } from "./coinpay-wallet-data"; import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import type { SupabaseClient } from "@supabase/supabase-js"; @@ -54,22 +55,23 @@ const b64url = (b: Buffer) => b.toString("base64url"); export type OAuthMode = "login" | "connect"; /** What the start route keeps in the short-lived httpOnly cookie. */ -export type PendingAuth = { state: string; verifier: string; nonce: string; mode: OAuthMode; next: string }; +export type PendingAuth = { state: string; verifier: string; nonce: string; mode: OAuthMode; next: string; wallets?: boolean }; -export function newPendingAuth(mode: OAuthMode, next: string): PendingAuth & { challenge: string } { +export function newPendingAuth(mode: OAuthMode, next: string, wallets = false): PendingAuth & { challenge: string } { const verifier = b64url(randomBytes(32)); return { state: b64url(randomBytes(24)), verifier, nonce: b64url(randomBytes(24)), mode, + ...(wallets ? { wallets: true } : {}), next: safeNext(next, mode), challenge: b64url(createHash("sha256").update(verifier).digest()), }; } export function encodePending(p: PendingAuth): string { - return Buffer.from(JSON.stringify({ s: p.state, v: p.verifier, n: p.nonce, m: p.mode, r: p.next })).toString("base64url"); + return Buffer.from(JSON.stringify({ s: p.state, v: p.verifier, n: p.nonce, m: p.mode, r: p.next, ...(p.wallets ? { w: true } : {}) })).toString("base64url"); } export function decodePending(raw: string | null | undefined): PendingAuth | null { @@ -78,7 +80,7 @@ export function decodePending(raw: string | null | undefined): PendingAuth | nul const o = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")); if (!o || typeof o.s !== "string" || typeof o.v !== "string" || typeof o.n !== "string") return null; if (o.m !== "login" && o.m !== "connect") return null; - return { state: o.s, verifier: o.v, nonce: o.n, mode: o.m, next: safeNext(String(o.r ?? ""), o.m) }; + return { state: o.s, verifier: o.v, nonce: o.n, mode: o.m, ...(o.w === true ? { wallets: true } : {}), next: safeNext(String(o.r ?? ""), o.m) }; } catch { return null; } @@ -99,7 +101,7 @@ export function authorizeUrl(cfg: CoinpayConfig, p: PendingAuth & { challenge: s response_type: "code", client_id: cfg.clientId, redirect_uri: redirectUri(cfg), - scope: COINPAY_SCOPES, + scope: COINPAY_SCOPES + (p.wallets ? " wallet:read" : ""), state: p.state, nonce: p.nonce, code_challenge: p.challenge, @@ -150,9 +152,9 @@ export function checkIdToken( /** Code -> tokens -> userinfo, with the ID token checked. */ export async function fetchCoinpayClaims( cfg: CoinpayConfig, - input: { code: string; verifier: string; nonce: string }, + input: { code: string; verifier: string; nonce: string; wallets?: boolean }, doFetch: typeof fetch = fetch, -): Promise<{ ok: true; claims: CoinpayClaims } | { ok: false; error: string }> { +): Promise<{ ok: true; claims: CoinpayClaims; wallets?: GlobalCoinpayWallet[] } | { ok: false; error: string }> { const tokenRes = await doFetch(cfg.issuer + "/api/oauth/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, @@ -176,7 +178,9 @@ export async function fetchCoinpayClaims( if (!infoRes.ok || typeof info.sub !== "string" || !info.sub) return { ok: false, error: "CoinPay userinfo failed" }; const idc = checkIdToken(tok.id_token, { issuer: cfg.issuer, clientId: cfg.clientId, nonce: input.nonce, sub: info.sub }); if (!idc.ok) return idc; + const wallets = input.wallets ? await fetchGlobalCoinpayWallets(cfg.issuer, tok.access_token, doFetch) : undefined; return { + ...(wallets ? { wallets } : {}), ok: true, claims: { sub: info.sub, diff --git a/lib/coinpay-wallet-data.ts b/lib/coinpay-wallet-data.ts new file mode 100644 index 00000000..b42cac85 --- /dev/null +++ b/lib/coinpay-wallet-data.ts @@ -0,0 +1,29 @@ +/** Public receive-address metadata only. No keys or bearer tokens are stored. */ +export type GlobalCoinpayWallet = { id: string; address: string; currency: string; label: string | null }; +const PRIORITY = ["usdc_pol", "usdc_eth", "usdc_sol", "usdt_pol", "usdt_eth", "sol", "eth", "btc"]; +export function normalizeGlobalWallets(raw: unknown): GlobalCoinpayWallet[] { + if (!Array.isArray(raw)) return []; + const seen = new Set(); + return raw.flatMap((r): GlobalCoinpayWallet[] => { + if (!r || typeof r !== "object" || (r.source !== "account" && r.source !== "web_wallet") || r.business_id != null || r.is_active !== true) return []; + const address = typeof r.wallet_address === "string" ? r.wallet_address.trim() : ""; + const currency = typeof r.cryptocurrency === "string" ? r.cryptocurrency.toLowerCase() : ""; + if (!address || address.length > 200 || !/^[a-z0-9]+(?:_[a-z0-9]+)?$/.test(currency) || typeof r.id !== "string") return []; + const key = currency + ":" + address; // Base58 addresses are case-sensitive. + if (seen.has(key)) return []; + seen.add(key); + return [{ id: r.id.slice(0, 200), address, currency, label: typeof r.label === "string" ? r.label.slice(0, 120) : null }]; + }).sort((a, b) => { + const rank = (c: string) => PRIORITY.indexOf(c) < 0 ? PRIORITY.length : PRIORITY.indexOf(c); + return rank(a.currency) - rank(b.currency) || a.currency.localeCompare(b.currency) || a.id.localeCompare(b.id); + }); +} +export async function fetchGlobalCoinpayWallets(issuer: string, token: string, doFetch: typeof fetch = fetch): Promise { + const r = await doFetch(issuer.replace(/\/+$/, "") + "/api/wallets?source=all", { + headers: { authorization: `Bearer ${token}`, accept: "application/json" }, cache: "no-store", signal: AbortSignal.timeout(15_000), + }); + if (!r.ok) throw new Error(r.status === 403 ? "CoinPay wallet permission is needed. Connect CoinPay wallets again." : "CoinPay wallets are unavailable. Try again."); + const j = await r.json(); + if (!Array.isArray(j.wallets)) throw new Error("CoinPay returned an invalid wallet list."); + return normalizeGlobalWallets(j.wallets); +} diff --git a/lib/coinpay-wallets.ts b/lib/coinpay-wallets.ts new file mode 100644 index 00000000..f5f34fdf --- /dev/null +++ b/lib/coinpay-wallets.ts @@ -0,0 +1,34 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { GlobalCoinpayWallet } from "./coinpay-wallet-data"; +export type WalletProfile = { configured: boolean; connected: boolean; wallets: GlobalCoinpayWallet[]; selected: GlobalCoinpayWallet | null; syncedAt: string | null }; +export async function globalWalletProfile(sb: SupabaseClient, userId: string): Promise { + const [{ data: p, error }, { data: identity, error: identityError }] = await Promise.all([ + sb.from("coinpay_wallet_profiles").select("coinpay_sub, wallets, preferred_wallet_id, synced_at").eq("user_id", userId).maybeSingle(), + sb.from("coinpay_identities").select("coinpay_sub").eq("user_id", userId).maybeSingle(), + ]); + if (error || identityError) throw new Error("Could not load your CoinPay wallet connection."); + const connected = !!p && !!identity && p.coinpay_sub === identity.coinpay_sub; + const wallets: GlobalCoinpayWallet[] = connected && Array.isArray(p.wallets) ? p.wallets : []; + return { configured: !!p, connected, wallets, selected: wallets.find((w) => w.id === p?.preferred_wallet_id) ?? wallets[0] ?? null, syncedAt: connected ? p.synced_at : null }; +} +export async function saveGlobalWalletProfile(sb: SupabaseClient, userId: string, sub: string, wallets: GlobalCoinpayWallet[]) { + const { data: identity, error: identityError } = await sb.from("coinpay_identities").select("coinpay_sub").eq("user_id", userId).maybeSingle(); + if (identityError || identity?.coinpay_sub !== sub) throw new Error("CoinPay account does not match this connection."); + // Preserve a chosen global destination if it is still in the fresh provider list. + const { data: previous, error: readError } = await sb.from("coinpay_wallet_profiles").select("preferred_wallet_id").eq("user_id", userId).maybeSingle(); + if (readError) throw new Error("Could not load your wallet preference."); + const preferred = wallets.find((w) => w.id === previous?.preferred_wallet_id)?.id ?? wallets[0]?.id ?? null; + const { error } = await sb.from("coinpay_wallet_profiles").upsert({ user_id: userId, coinpay_sub: sub, wallets, preferred_wallet_id: preferred, synced_at: new Date().toISOString() }, { onConflict: "user_id" }); + if (error) throw new Error("Could not save your CoinPay wallets."); +} +export async function slotPayoutTarget(sb: SupabaseClient, userId: string, slot: { payout_address?: string | null; payout_currency?: string | null }) { + const profile = await globalWalletProfile(sb, userId); + if (profile.configured) { + if (!profile.connected) return { ok: false as const, error: "Reconnect CoinPay wallets before withdrawing." }; + if (!profile.selected) return { ok: false as const, error: "No active global CoinPay wallet is available. Add one in CoinPay and refresh the connection." }; + return { ok: true as const, address: profile.selected.address, currency: profile.selected.currency, source: "coinpay" as const }; + } + // Existing customers keep legacy destinations until they explicitly connect globals. + if (slot.payout_address) return { ok: true as const, address: slot.payout_address, currency: slot.payout_currency || "usdc_pol", source: "legacy" as const }; + return { ok: false as const, error: "Connect CoinPay wallets once to use them across all your sites." }; +} diff --git a/supabase/migrations/20261031000000_coinpay_global_wallets.sql b/supabase/migrations/20261031000000_coinpay_global_wallets.sql new file mode 100644 index 00000000..c921f67c --- /dev/null +++ b/supabase/migrations/20261031000000_coinpay_global_wallets.sql @@ -0,0 +1,12 @@ +-- Only verified provider receive addresses; never tokens or signing keys. +create table if not exists public.coinpay_wallet_profiles ( + user_id uuid primary key references auth.users(id) on delete cascade, + coinpay_sub text not null, + wallets jsonb not null default '[]'::jsonb check (jsonb_typeof(wallets) = 'array'), + preferred_wallet_id text, + synced_at timestamptz not null default now() +); +alter table public.coinpay_wallet_profiles enable row level security; +revoke all on public.coinpay_wallet_profiles from anon, authenticated; +grant all on public.coinpay_wallet_profiles to service_role; +notify pgrst, 'reload schema'; diff --git a/tests/ads-partner-api.test.ts b/tests/ads-partner-api.test.ts index 84006ea8..2a6dd4b9 100644 --- a/tests/ads-partner-api.test.ts +++ b/tests/ads-partner-api.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { depositCredits, isAdsPartner, parseDepositRequest, partnerUserIds } from "@/lib/ads/deposits"; +import { createCryptoPayout } from "@/lib/coinpay"; import { cleanPayoutTarget, requestSlotPayout, slotAvailableCents } from "@/lib/ads/payouts"; vi.mock("@/lib/coinpay", () => ({ @@ -81,6 +82,17 @@ describe("slot payouts", () => { expect(await slotAvailableCents(fakeDb(base()).sb, "s1")).toBe(600); }); + it("uses the global destination for the actual transfer and ledger snapshot", async () => { + const rows: Record[]> = base(); + rows.coinpay_identities = [{ user_id: "pub", coinpay_sub: "verified-sub" }]; + rows.coinpay_wallet_profiles = [{ user_id: "pub", coinpay_sub: "verified-sub", wallets: [{ id: "global", address: "global-sol-address", currency: "sol", label: null }], preferred_wallet_id: "global" }]; + const db = fakeDb(rows); + const result = await requestSlotPayout({ sb: db.sb, userId: "pub", email: null, slotId: "s1" }); + expect(result.ok).toBe(true); + expect(createCryptoPayout).toHaveBeenLastCalledWith(expect.objectContaining({ recipientWallet: "global-sol-address", currency: "sol" })); + expect(db.writes.find(w => w.table === "ad_payouts")?.row).toMatchObject({ address: "global-sol-address", currency: "sol" }); + }); + it("pays the slot's wallet and writes the ledger", async () => { const db = fakeDb(base()); const r = await requestSlotPayout({ sb: db.sb, userId: "pub", email: null, slotId: "s1" }); diff --git a/tests/coinpay-global-wallets.test.ts b/tests/coinpay-global-wallets.test.ts new file mode 100644 index 00000000..08ffd4d9 --- /dev/null +++ b/tests/coinpay-global-wallets.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { normalizeGlobalWallets, fetchGlobalCoinpayWallets } from "@/lib/coinpay-wallet-data"; +import { globalWalletProfile, saveGlobalWalletProfile, slotPayoutTarget } from "@/lib/coinpay-wallets"; +import { authorizeUrl, newPendingAuth, decodePending, encodePending } from "@/lib/coinpay-signin"; +function db(tables: Record[]>) { + return { from: (name: string) => { + const filters: [string, unknown][]=[];let write: Record|null=null; + const q:any={select:()=>q,eq:(k:string,v:unknown)=>{filters.push([k,v]);return q;},maybeSingle:async()=>({data:(tables[name]??[]).find(r=>filters.every(([k,v])=>r[k]===v))??null,error:null}), + upsert:(r:Record)=>{write=r;return q;},then:(resolve:(v:unknown)=>void)=>{if(write)tables[name]=[write];resolve({error:null});}}; + return q; + }} as any; +} +const wallet={id:"global-one",address:"0xGlobal",currency:"usdc_pol",label:null}; +describe("global CoinPay payout defaults",()=>{ + it("imports active account and global linked wallets only, preserves case-sensitive addresses and exact chains",()=>{ + const row={id:"1",source:"account",is_active:true,cryptocurrency:"SOL",wallet_address:"AbC"}; + const list=normalizeGlobalWallets([row,{...row,id:"2",wallet_address:"abc"},{...row,id:"3",source:"business"},{...row,id:"4",is_active:false},{...row,id:"5",cryptocurrency:"USDC_POL",wallet_address:"0xGlobal"},{...row,id:"6",source:"web_wallet",business_id:null,wallet_address:"Def"},{...row,id:"7",source:"web_wallet",business_id:"business",wallet_address:"Excluded"}]); + expect(list.map(w=>w.address)).toEqual(["0xGlobal","AbC","abc","Def"]); + expect(list[0].currency).toBe("usdc_pol"); + }); + it("one global profile overrides old slot addresses for every site, including new ones",async()=>{ + const sb=db({coinpay_identities:[{user_id:"u",coinpay_sub:"s"}],coinpay_wallet_profiles:[{user_id:"u",coinpay_sub:"s",wallets:[wallet],preferred_wallet_id:wallet.id}]}); + for(const slot of [{payout_address:"old-address",payout_currency:"btc"},{}])expect(await slotPayoutTarget(sb,"u",slot)).toMatchObject({ok:true,address:wallet.address,currency:"usdc_pol",source:"coinpay"}); + expect((await globalWalletProfile(sb,"other-user")).wallets).toEqual([]); + }); + it("fails closed after unlink or an empty sync instead of falling back to an old address",async()=>{ + const tables={coinpay_identities:[] as Record[],coinpay_wallet_profiles:[{user_id:"u",coinpay_sub:"s",wallets:[wallet]}]}; + expect(await slotPayoutTarget(db(tables),"u",{payout_address:"old"})).toMatchObject({ok:false}); + tables.coinpay_identities.push({user_id:"u",coinpay_sub:"s"});tables.coinpay_wallet_profiles[0].wallets=[]; + expect(await slotPayoutTarget(db(tables),"u",{payout_address:"old"})).toMatchObject({ok:false}); + }); + it("will not attach another CoinPay subject's wallets to a user",async()=>{ + const tables={coinpay_identities:[{user_id:"u",coinpay_sub:"s"}],coinpay_wallet_profiles:[]}; + await expect(saveGlobalWalletProfile(db(tables),"u","attacker",[wallet])).rejects.toThrow("does not match"); + expect(tables.coinpay_wallet_profiles).toEqual([]); + }); + it("requests wallet:read explicitly with PKCE and preserves the return page",()=>{ + const p=newPendingAuth("connect","/dashboard/ads/slots?page=3",true);const u=new URL(authorizeUrl({issuer:"https://coinpayportal.com",clientId:"fixture",clientSecret:"unused",siteUrl:"https://crawlproof.com"},p)); + expect(u.searchParams.get("scope")).toBe("openid profile email wallet:read");expect(u.searchParams.get("code_challenge_method")).toBe("S256");expect(decodePending(encodePending(p))).toMatchObject({wallets:true,next:"/dashboard/ads/slots?page=3"}); + }); + it("does not turn provider errors into a successful empty sync",async()=>{ + await expect(fetchGlobalCoinpayWallets("https://coinpayportal.com","fixture",(async()=>new Response(null,{status:403})) as typeof fetch)).rejects.toThrow("permission"); + }); +}); diff --git a/tests/coinpay-signin.test.ts b/tests/coinpay-signin.test.ts index f1bd1dbc..e1f9fdeb 100644 --- a/tests/coinpay-signin.test.ts +++ b/tests/coinpay-signin.test.ts @@ -235,3 +235,17 @@ describe("placeholder emails", () => { expect(isPlaceholderEmail("ann@example.com")).toBe(false); }); }); + +it("imports wallet metadata only after validating the OAuth subject and never returns bearer tokens", async () => { + const good = { iss: CFG.issuer, aud: CFG.clientId, sub: "s1", nonce: "n1", exp: Math.floor(Date.now()/1000)+600 }; + const fake = (async (url: string, init: RequestInit) => { + if (url.endsWith("/token")) return Response.json({ access_token: "fixture-access", id_token: jwt(good) }); + if (url.endsWith("/userinfo")) return Response.json({ sub: "s1" }); + expect(url).toBe("https://coinpayportal.com/api/wallets?source=all"); + expect((init.headers as Record).authorization).toBe("Bearer fixture-access"); + return Response.json({ wallets: [{ id: "global", source: "account", is_active: true, cryptocurrency: "USDC_POL", wallet_address: "0xGlobal" }] }); + }) as typeof fetch; + const result = await fetchCoinpayClaims(CFG,{code:"c",verifier:"v",nonce:"n1",wallets:true},fake); + expect(result).toMatchObject({ok:true,wallets:[{currency:"usdc_pol",address:"0xGlobal"}]}); + expect(JSON.stringify(result)).not.toContain("fixture-access"); +}); diff --git a/tests/coinpay-wallet-api.test.ts b/tests/coinpay-wallet-api.test.ts new file mode 100644 index 00000000..9b25e4e2 --- /dev/null +++ b/tests/coinpay-wallet-api.test.ts @@ -0,0 +1,17 @@ +import {beforeEach,describe,it,expect,vi} from 'vitest'; +const h=vi.hoisted(()=>({auth:{ok:true,userId:'user',tokenId:'key',scope:null} as Record,tables:{} as Record[]>})); +vi.mock('@/lib/sp/apiAuth',()=>({authenticateBearer:async()=>h.auth})); +vi.mock('@/lib/supabase/service',()=>({serviceClient:()=>({from:(name:string)=>{ + const filters:[string,unknown][]=[];let row:Record|null=null;let op='read';const rows=()=> (h.tables[name]??[]).filter(r=>filters.every(([k,v])=>r[k]===v)); + const q:any={select:()=>q,eq:(k:string,v:unknown)=>{filters.push([k,v]);return q;},maybeSingle:async()=>({data:rows()[0]??null,error:null}),insert:(r:Record)=>{row=r;op='insert';return q;},upsert:(r:Record)=>{row=r;op='upsert';return q;},then:(resolve:(v:unknown)=>void)=>{if(row){if(op==='upsert')h.tables[name]=[row];else(h.tables[name]??=[]).push(row);}resolve({error:null});}};return q; +}})})); +import {POST,GET} from '@/app/api/ads/v1/wallets/route'; +const request=(body:unknown)=>new Request('https://crawlproof.com/api/ads/v1/wallets',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)}) as never; +beforeEach(()=>{h.auth={ok:true,userId:'user',tokenId:'key',scope:null};h.tables={coinpay_identities:[],coinpay_wallet_profiles:[]};vi.stubGlobal('fetch',vi.fn(async(url:string)=>url.endsWith('/userinfo')?Response.json({sub:'verified-sub',email:'irrelevant@example.com'}):Response.json({wallets:[{id:'global',source:'account',is_active:true,cryptocurrency:'USDC_POL',wallet_address:'provider-address'}]})));}); +describe('global wallet API authorization',()=>{ + it('requires CrawlProof authentication before contacting CoinPay',async()=>{h.auth={ok:false,error:'Missing bearer token',status:401};expect((await POST(request({coinpay_token:'fixture'}))).status).toBe(401);expect(fetch).not.toHaveBeenCalled();}); + it('uses provider verified identity and addresses, ignoring supplied subjects and addresses',async()=>{const r=await POST(request({coinpay_token:'fixture-secret',sub:'attacker',wallet_address:'attacker-address'}));expect(r.status).toBe(200);expect(h.tables.coinpay_identities[0]).toMatchObject({user_id:'user',coinpay_sub:'verified-sub'});expect(h.tables.coinpay_wallet_profiles[0].wallets[0].address).toBe('provider-address');expect(JSON.stringify(h.tables)).not.toContain('fixture-secret');expect(JSON.stringify(await r.json())).not.toContain('fixture-secret');}); + it('refuses a provider subject already connected to another local user',async()=>{h.tables.coinpay_identities=[{user_id:'other',coinpay_sub:'verified-sub'}];expect((await POST(request({coinpay_token:'fixture'}))).status).toBe(409);expect(h.tables.coinpay_wallet_profiles).toEqual([]);}); + it('does not erase old wallets or link identities when provider access fails',async()=>{h.tables.coinpay_wallet_profiles=[{user_id:'user',coinpay_sub:'verified-sub',wallets:[{id:'old'}]}];vi.stubGlobal('fetch',vi.fn(async()=>new Response(null,{status:403})));expect((await POST(request({coinpay_token:'fixture'}))).status).toBe(400);expect(h.tables.coinpay_identities).toEqual([]);expect(h.tables.coinpay_wallet_profiles[0].wallets).toEqual([{id:'old'}]);}); + it('does not return another user’s wallet profile',async()=>{h.tables.coinpay_wallet_profiles=[{user_id:'other',coinpay_sub:'other-sub',wallets:[{address:'private-address'}]}];const r=await GET(new Request('https://crawlproof.com/api/ads/v1/wallets') as never);expect(await r.text()).not.toContain('private-address');}); +});