Files
dark-lord/contexts/WalletContext.tsx
drjones 4354416f1c Integrate BTCPay Server: auto-settling invoice deposits
- 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
2026-04-16 01:23:35 -07:00

208 lines
6.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 handles 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>;
};