- lib/btcpay.ts: Greenfield API client (createBtcPayInvoice, getBtcPayInvoiceStatus) - /api/btcpay/invoice: creates per-deposit invoice (USD amount + handle in metadata) - /api/btcpay/status/[id]: polls invoice status + returns BTC address/amount - add-funds page: BTCPay tab with amount picker, live address display, 10s auto-poll, settlement auto-credits localStorage ledger - WalletContext: creditBtcPayInvoice() with duplicate-invoice guard - .env.example: BTCPAY_URL, BTCPAY_API_KEY, BTCPAY_STORE_ID, NEXT_PUBLIC_BTCPAY_ENABLED Made-with: Cursor
208 lines
6.9 KiB
TypeScript
208 lines
6.9 KiB
TypeScript
"use client";
|
||
|
||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, ReactNode } from "react";
|
||
import type { VaultReceipt, VaultState } from "@/lib/metaGame";
|
||
import { addKey, addReceipt, defaultVaultState, loadVaultState, saveVaultState, setFlag } from "@/lib/metaGame";
|
||
import { getLedgerRow, migrateLegacyDeviceLedgerIfNeeded, setLedgerRow } from "@/lib/storeCreditStorage";
|
||
import { useAccount } from "@/contexts/AccountContext";
|
||
|
||
interface WalletContextType {
|
||
usdStoreCredit: number;
|
||
luxCredits: number;
|
||
spendLuxCredits: (amount: number) => boolean;
|
||
earnLuxCredits: (amount: number) => void;
|
||
spendUsdStoreCredit: (amount: number) => boolean;
|
||
verifyBtcDeposit: (txid: string) => Promise<{ ok: boolean; error?: string; creditedUsd?: number }>;
|
||
creditBtcPayInvoice: (invoiceId: string, usdAmount: number) => { ok: boolean; error?: string };
|
||
vault: VaultState;
|
||
collectKey: (key: string) => boolean;
|
||
addVaultReceipt: (receipt: VaultReceipt) => void;
|
||
setVaultFlag: (flag: string, value: boolean) => void;
|
||
}
|
||
|
||
const WalletContext = createContext<WalletContextType | undefined>(undefined);
|
||
|
||
export const useWallet = () => {
|
||
const context = useContext(WalletContext);
|
||
if (!context) {
|
||
throw new Error("useWallet must be used within WalletProvider");
|
||
}
|
||
return context;
|
||
};
|
||
|
||
interface WalletProviderProps {
|
||
children: ReactNode;
|
||
}
|
||
|
||
export const WalletProvider = ({ children }: WalletProviderProps) => {
|
||
const { user } = useAccount();
|
||
const handle = user?.username ?? null;
|
||
|
||
const [usdStoreCredit, setUsdStoreCredit] = useState(0);
|
||
const [luxCredits, setLuxCredits] = useState(0);
|
||
const [vault, setVault] = useState<VaultState>(() => defaultVaultState());
|
||
|
||
useEffect(() => {
|
||
if (!handle) {
|
||
setUsdStoreCredit(0);
|
||
setLuxCredits(0);
|
||
return;
|
||
}
|
||
migrateLegacyDeviceLedgerIfNeeded(handle);
|
||
const row = getLedgerRow(handle);
|
||
setUsdStoreCredit(row.usd);
|
||
setLuxCredits(row.lux);
|
||
}, [handle]);
|
||
|
||
useEffect(() => {
|
||
setVault(loadVaultState(handle));
|
||
}, [handle]);
|
||
|
||
useEffect(() => {
|
||
saveVaultState(handle, vault);
|
||
}, [vault, handle]);
|
||
|
||
const spendLuxCredits = useCallback((amount: number) => {
|
||
if (!handle) return false;
|
||
const safeAmount = Number.isFinite(amount) ? Math.max(0, Math.floor(amount)) : 0;
|
||
if (safeAmount <= 0) return true;
|
||
const row = getLedgerRow(handle);
|
||
if (row.lux < safeAmount) return false;
|
||
const nextLux = row.lux - safeAmount;
|
||
setLedgerRow(handle, { usd: row.usd, lux: nextLux, claimedTxids: row.claimedTxids });
|
||
setLuxCredits(nextLux);
|
||
return true;
|
||
}, [handle]);
|
||
|
||
const earnLuxCredits = useCallback(
|
||
(amount: number) => {
|
||
if (!handle) return;
|
||
const safeAmount = Number.isFinite(amount) ? Math.max(0, Math.floor(amount)) : 0;
|
||
if (safeAmount <= 0) return;
|
||
const row = getLedgerRow(handle);
|
||
const nextLux = row.lux + safeAmount;
|
||
setLedgerRow(handle, { usd: row.usd, lux: nextLux, claimedTxids: row.claimedTxids });
|
||
setLuxCredits(nextLux);
|
||
},
|
||
[handle],
|
||
);
|
||
|
||
const spendUsdStoreCredit = useCallback(
|
||
(amount: number) => {
|
||
if (!handle) return false;
|
||
const safe = Number.isFinite(amount) ? Math.max(0, Math.round(amount * 100) / 100) : 0;
|
||
if (safe <= 0) return true;
|
||
const row = getLedgerRow(handle);
|
||
if (row.usd + 1e-9 < safe) return false;
|
||
const nextUsd = Math.round((row.usd - safe) * 100) / 100;
|
||
setLedgerRow(handle, { usd: nextUsd, lux: row.lux, claimedTxids: row.claimedTxids });
|
||
setUsdStoreCredit(nextUsd);
|
||
return true;
|
||
},
|
||
[handle],
|
||
);
|
||
|
||
const verifyBtcDeposit = useCallback(
|
||
async (txid: string) => {
|
||
if (!handle) {
|
||
return { ok: false, error: "Sign in so deposits credit your handle’s balance." };
|
||
}
|
||
const normalized = String(txid || "").trim();
|
||
if (!/^[a-fA-F0-9]{64}$/.test(normalized)) {
|
||
return { ok: false, error: "Enter a valid 64-character transaction id" };
|
||
}
|
||
const row = getLedgerRow(handle);
|
||
if (row.claimedTxids.includes(normalized)) {
|
||
return { ok: false, error: "This transaction was already used to add credit" };
|
||
}
|
||
try {
|
||
const res = await fetch("/api/btc/verify", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ txid: normalized }),
|
||
});
|
||
const data = (await res.json()) as {
|
||
ok?: boolean;
|
||
error?: string;
|
||
creditedUsd?: number;
|
||
};
|
||
if (!data.ok || typeof data.creditedUsd !== "number") {
|
||
return { ok: false, error: data.error || "Verification failed" };
|
||
}
|
||
const add = Math.round(data.creditedUsd * 100) / 100;
|
||
const nextUsd = Math.round((row.usd + add) * 100) / 100;
|
||
const claimed = [...row.claimedTxids, normalized];
|
||
setLedgerRow(handle, { usd: nextUsd, lux: row.lux, claimedTxids: claimed });
|
||
setUsdStoreCredit(nextUsd);
|
||
return { ok: true, creditedUsd: add };
|
||
} catch {
|
||
return { ok: false, error: "Network error while verifying" };
|
||
}
|
||
},
|
||
[handle],
|
||
);
|
||
|
||
const creditBtcPayInvoice = useCallback(
|
||
(invoiceId: string, usdAmount: number) => {
|
||
if (!handle) return { ok: false, error: "Sign in first" };
|
||
const id = String(invoiceId || "").trim();
|
||
if (!id) return { ok: false, error: "Invalid invoice ID" };
|
||
const safe = Number.isFinite(usdAmount) ? Math.max(0, Math.round(usdAmount * 100) / 100) : 0;
|
||
if (safe <= 0) return { ok: false, error: "Invalid amount" };
|
||
const row = getLedgerRow(handle);
|
||
if (row.claimedTxids.includes(id)) return { ok: false, error: "Invoice already credited" };
|
||
const nextUsd = Math.round((row.usd + safe) * 100) / 100;
|
||
setLedgerRow(handle, { usd: nextUsd, lux: row.lux, claimedTxids: [...row.claimedTxids, id] });
|
||
setUsdStoreCredit(nextUsd);
|
||
return { ok: true };
|
||
},
|
||
[handle],
|
||
);
|
||
|
||
const collectKey = (key: string) => {
|
||
const trimmed = String(key || "").trim();
|
||
if (!trimmed) return false;
|
||
if (vault.keys.includes(trimmed)) return false;
|
||
setVault((v) => addKey(v, trimmed));
|
||
return true;
|
||
};
|
||
|
||
const addVaultReceipt = (receipt: VaultReceipt) => {
|
||
setVault((v) => addReceipt(v, receipt));
|
||
};
|
||
|
||
const setVaultFlag = (flag: string, value: boolean) => {
|
||
const f = String(flag || "").trim();
|
||
if (!f) return;
|
||
setVault((v) => setFlag(v, f, Boolean(value)));
|
||
};
|
||
|
||
const value = useMemo(
|
||
() => ({
|
||
usdStoreCredit,
|
||
luxCredits,
|
||
spendLuxCredits,
|
||
earnLuxCredits,
|
||
spendUsdStoreCredit,
|
||
verifyBtcDeposit,
|
||
creditBtcPayInvoice,
|
||
vault,
|
||
collectKey,
|
||
addVaultReceipt,
|
||
setVaultFlag,
|
||
}),
|
||
[
|
||
usdStoreCredit,
|
||
luxCredits,
|
||
spendLuxCredits,
|
||
earnLuxCredits,
|
||
spendUsdStoreCredit,
|
||
verifyBtcDeposit,
|
||
creditBtcPayInvoice,
|
||
vault,
|
||
],
|
||
);
|
||
|
||
return <WalletContext.Provider value={value}>{children}</WalletContext.Provider>;
|
||
};
|