SkateSpots/src/hooks/wallet/useCashuEscrow.ts
SkateSpots 15901976ef feat(marketplace): wire Cashu escrow into the trade flow (native)
Completes the escrow feature on top of the tested core (src/lib/cashuEscrow.ts):
- useCashuEscrow: native-only hook wiring createEscrow (cashu-ts mint + NWC/WebLN
  funding), redeemEscrow, escrow-key derivation (coco keyring) and profile publish
- EscrowKeyCard (Wallet): advertise the kind-0 'cashu_escrow' pubkey
- EscrowRequestDialog (listing card): buyer locks funds (P2PK to seller, refundable
  after N days) and sends the 31872 carrying the cashu_token
- EscrowPanel + 'Redeem escrow' in the DM request banner (seller side); the panel
  is offline/parse-based so it renders on web too
- Buyer 'Escrow & request' supersedes the plain request when available

EXPERIMENTAL: mint-touching paths are unverified on a device (native-only wallet);
use small amounts first. Gate: 101 tests, 0 errors.
2026-09-26 21:08:36 +07:00

151 lines
5.4 KiB
TypeScript

import { useCallback } from 'react';
import { CashuMint, CashuWallet } from '@cashu/cashu-ts';
import type { WebLNProvider } from '@webbtc/webln-types';
import { useCashu } from './useCashu';
import { useNWC } from '@/hooks/useNWCContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { isNativePlatform } from '@/lib/platform';
import {
createEscrowToken,
normalizeCashuPubkey,
parseEscrowToken,
type EscrowMintWallet,
type EscrowTokenInfo,
} from '@/lib/cashuEscrow';
/**
* Cashu escrow — **native-only** (the Cashu wallet is Capacitor-gated).
*
* Funds are locked to the seller with a locktime and the buyer as refund key
* (see `ESCROW.md`). The correctness-critical flow lives in
* `src/lib/cashuEscrow.ts` (unit-tested); this hook wires it to a real
* cashu-ts wallet, the NWC/WebLN payment rail, and the coco keyring.
*
* EXPERIMENTAL: the mint-touching paths (create/redeem/refund) have not been
* verified on a device yet — exercise with small amounts first.
*/
export function useCashuEscrow() {
const { coco, isInitialized, mints } = useCashu();
const { getActiveConnection, sendPayment } = useNWC();
const { user } = useCurrentUser();
const author = useAuthor(user?.pubkey);
const { mutateAsync: publish } = useNostrPublish();
const webln = (globalThis as { webln?: WebLNProvider }).webln || null;
const available = isNativePlatform() && isInitialized && !!coco;
/** The active mint URL to fund escrow from (first trusted mint). */
const defaultMintUrl = mints[0]?.url ?? null;
/**
* The Cashu keyring pubkey used as the P2PK lock/refund key. Nostr signers
* can't produce Cashu's raw schnorr witness, so escrow uses the Cashu
* keyring — not the Nostr key.
*/
const getEscrowPubkey = useCallback(async (): Promise<string | null> => {
if (!coco) return null;
const existing = await coco.keyring.getLatestKeyPair();
const keypair = existing ?? (await coco.keyring.generateKeyPair(true));
return normalizeCashuPubkey(keypair.publicKeyHex);
}, [coco]);
/** Pay a mint invoice via NWC, then WebLN. */
const payInvoice = useCallback(
async (bolt11: string): Promise<void> => {
const nwc = getActiveConnection();
if (nwc?.connectionString && nwc.isConnected) {
await sendPayment(nwc, bolt11);
return;
}
if (webln) {
if (typeof webln.enable === 'function') await webln.enable();
await webln.sendPayment(bolt11);
return;
}
throw new Error('No Lightning wallet available to fund escrow — connect NWC or a WebLN wallet.');
},
[getActiveConnection, sendPayment, webln],
);
const makeMintWallet = useCallback((mintUrl: string): EscrowMintWallet => {
const wallet = new CashuWallet(new CashuMint(mintUrl), { unit: 'sat' });
return {
createMintQuote: (amount) => wallet.createMintQuote(amount),
checkMintQuote: async (quote) => ({ state: (await wallet.checkMintQuote(quote)).state }),
mintProofs: (amount, quote, options) =>
wallet.mintProofs(amount, quote, { p2pk: options.p2pk }),
};
}, []);
/**
* Create an escrowed token locked to `sellerPubkey` (funded by paying the
* mint's Lightning invoice). Returns the token to embed in a `31872`.
*/
const createEscrow = useCallback(
async ({
amountSats,
sellerPubkey,
lockDays,
mintUrl,
}: {
amountSats: number;
sellerPubkey: string;
lockDays?: number;
mintUrl?: string;
}): Promise<{ token: string; locktime: number; info: EscrowTokenInfo }> => {
if (!coco) throw new Error('Cashu wallet is not ready.');
const buyerPubkey = await getEscrowPubkey();
if (!buyerPubkey) throw new Error('Could not derive your escrow key.');
const mint = mintUrl ?? defaultMintUrl;
if (!mint) throw new Error('No Cashu mint available — add one in your wallet first.');
const { token, locktime } = await createEscrowToken(
{ wallet: makeMintWallet(mint), payInvoice },
{ mintUrl: mint, amountSats, sellerPubkey, buyerPubkey, lockDays },
);
return { token, locktime, info: parseEscrowToken(token) };
},
[coco, getEscrowPubkey, defaultMintUrl, makeMintWallet, payInvoice],
);
/** Redeem an escrow token (seller on delivery, or buyer after the locktime). */
const redeemEscrow = useCallback(
async (token: string): Promise<void> => {
if (!coco) throw new Error('Cashu wallet is not ready.');
await coco.wallet.receive(token);
},
[coco],
);
/**
* Publish this user's escrow pubkey to their kind 0 profile (`cashu_escrow`)
* so buyers can lock funds to them.
*/
const advertiseEscrowKey = useCallback(async (): Promise<string> => {
if (!user) throw new Error('Log in first.');
const pubkey = await getEscrowPubkey();
if (!pubkey) throw new Error('Could not derive your escrow key.');
const current = (author.data?.metadata ?? {}) as Record<string, unknown>;
await publish({
kind: 0,
content: JSON.stringify({ ...current, cashu_escrow: pubkey }),
});
return pubkey;
}, [user, getEscrowPubkey, author.data?.metadata, publish]);
return {
available,
defaultMintUrl,
getEscrowPubkey,
createEscrow,
redeemEscrow,
payInvoice,
advertiseEscrowKey,
};
}