feat: high-value safety, reputation decay, escrow 2-of-3 primitive

This commit is contained in:
SkateSpots 2026-10-31 15:13:02 +07:00
parent 1d810ea964
commit 31d1630c50
9 changed files with 122 additions and 15 deletions

View file

@ -77,11 +77,26 @@ token in a public event is spendable by anyone who sees it.
promptly, and lock windows should be generous.
- **Native only**: the app's Cashu wallet is Capacitor-only, so escrow is too.
## Optional 2-of-3 primitive
`escrowP2PKOptions` now accepts an optional `arbiterPubkey` and can express the
NUT-11 policy used by a future 2-of-3 escrow:
- normal path: seller + arbiter, `n_sigs=2`;
- refund path: buyer + arbiter, `n_sigs_refund=2`;
- no arbiter: the current 1-of-1 seller/buyer policy.
The primitive is unit-tested, but **no UI or default flow uses it yet**. There is
no arbiter policy, bond, dispute process or appeal path, so exposing it would add
friction and false confidence. Do not enable it for real money until those are
defined and the flow is verified on-device.
## Status
- ✅ **Core** (`src/lib/cashuEscrow.ts`, unit-tested): pubkey normalisation,
`escrowP2PKOptions`, offline token inspection/`parseEscrowToken`,
locktime/refund policy, and the injectable `createEscrowToken` mint flow.
`escrowP2PKOptions` (1-of-1, optional 2-of-3), offline token
inspection/`parseEscrowToken`, locktime/refund policy, and the injectable
`createEscrowToken` mint flow.
- 🟡 **Wallet UI** (native-only): implement → verify with the **On-device test
checklist** below before treating as production. Do not use real money until
every box passes.

View file

@ -48,9 +48,20 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla
// Activity factor: a fresh account with no published content contributes ~0,
// so a ring of empty accounts can't move the score even if cross-confirmed.
const activityFactor = (pk: string) => Math.min(1, (activity?.get(pk) ?? 0) / 3);
const now = Math.floor(Date.now() / 1000);
const decayFactor = (v: { lastAt: number; recentCount: number }) => {
// 180-day half-life, floored so a good historical record is still visible.
// This changes the ranking weight only; lifetime counts stay unchanged.
const ageDays = Math.max(0, (now - v.lastAt) / 86_400);
return Math.max(0.25, Math.exp(-ageDays / 180));
};
const weighted = verifiedWith.reduce(
(sum, v) =>
sum + Math.min(v.count, 3) * ((scores?.get(v.pubkey) ?? 0) / 100) * activityFactor(v.pubkey),
sum +
Math.min(v.count, 3) *
((scores?.get(v.pubkey) ?? 0) / 100) *
activityFactor(v.pubkey) *
decayFactor(v),
0,
);
const trustLabel =

View file

@ -201,7 +201,7 @@ export interface TradeReputation {
*/
verifiedCounterparties: number;
/** Per-counterparty count of mutually-confirmed trades (for WoT weighting). */
verifiedWith: { pubkey: string; count: number }[];
verifiedWith: { pubkey: string; count: number; recentCount: number; lastAt: number }[];
/** Distinct counterparties involved. */
counterparties: string[];
/** Raw trade attestations this pubkey is involved in. */
@ -255,6 +255,8 @@ export function useTradeReputation(pubkey: string | undefined) {
const counterparties = new Set<string>();
const verifiedWith = new Map<string, number>();
const verifiedRecentWith = new Map<string, number>();
const verifiedLastAt = new Map<string, number>();
let sales = 0;
let purchases = 0;
let verified = 0;
@ -275,10 +277,16 @@ export function useTradeReputation(pubkey: string | undefined) {
else purchases++;
if (isVerified) {
verified++;
if (Math.max(...attestations.map((e) => e.created_at)) >= recentCutoff) recentVerified++;
const latestAt = Math.max(...attestations.map((e) => e.created_at));
const isRecent = latestAt >= recentCutoff;
if (isRecent) recentVerified++;
// Attribute the verified trade to the other attestor(s).
for (const a of attestors) {
if (a !== pubkey) verifiedWith.set(a, (verifiedWith.get(a) ?? 0) + 1);
if (a !== pubkey) {
verifiedWith.set(a, (verifiedWith.get(a) ?? 0) + 1);
if (isRecent) verifiedRecentWith.set(a, (verifiedRecentWith.get(a) ?? 0) + 1);
verifiedLastAt.set(a, Math.max(verifiedLastAt.get(a) ?? 0, latestAt));
}
}
// A `proof` tag is a payment CLAIM, not verification — count it on its
// own tier so it is never confused with a confirmed trade.
@ -338,7 +346,12 @@ export function useTradeReputation(pubkey: string | undefined) {
withPaymentProof,
paymentVerified,
verifiedCounterparties: verifiedWith.size,
verifiedWith: [...verifiedWith.entries()].map(([pubkey, count]) => ({ pubkey, count })),
verifiedWith: [...verifiedWith.entries()].map(([pubkey, count]) => ({
pubkey,
count,
recentCount: verifiedRecentWith.get(pubkey) ?? 0,
lastAt: verifiedLastAt.get(pubkey) ?? 0,
})),
counterparties: [...counterparties],
attestations: unique,
};

View file

@ -87,11 +87,13 @@ export function useCashuEscrow() {
async ({
amountSats,
sellerPubkey,
arbiterPubkey,
lockDays,
mintUrl,
}: {
amountSats: number;
sellerPubkey: string;
arbiterPubkey?: string;
lockDays?: number;
mintUrl?: string;
}): Promise<{ token: string; locktime: number; info: EscrowTokenInfo }> => {
@ -105,7 +107,7 @@ export function useCashuEscrow() {
const { token, locktime } = await createEscrowToken(
{ wallet: makeMintWallet(mint), payInvoice },
{ mintUrl: mint, amountSats, sellerPubkey, buyerPubkey, lockDays },
{ mintUrl: mint, amountSats, sellerPubkey, buyerPubkey, arbiterPubkey, lockDays },
);
return { token, locktime, info: parseEscrowToken(token) };

View file

@ -1975,7 +1975,7 @@ marketUi: {
singlePartnerWarning: 'Every confirmation is with the same account. Mutual confirmation is self-reported, so treat this record with care.',
lowDiversityWarning: 'Most confirmations come from very few accounts. A small group vouching for each other can look busy — treat this record with care.',
ringWarning: 'These accounts mostly trade with each other — a closed cluster. Mutual confirmation inside a ring is weak evidence.',
weighted: 'Trust & activity weighted',
weighted: 'Trust, activity & recency weighted',
footnote: 'Self-reported and mutually signed — a signal, not proof of payment.',
},
reputationPanel: {
@ -7594,6 +7594,8 @@ walletUi: {
privacyLink: 'What leaves your device',
safetyTitle: 'Trade safely',
safetyBody: 'Meet in public for handovers, use escrow or a good-faith deposit for shipped deals, and never send money outside the app.',
highValueTitle: 'High-value deal',
noArbitration: 'Escrow is time-locked, not arbitration: it protects the payment, not the delivery.',
moderationTitle: 'Moderation is soft and reversible',
moderationBody: 'Hidden content stays on relays and can be restored; disputes between people are yours to resolve.',
},

View file

@ -1975,7 +1975,7 @@ marketUi: {
singlePartnerWarning: 'Todas las confirmaciones son con la misma cuenta. La confirmación mutua es autodeclarada, así que trata este registro con cuidado.',
lowDiversityWarning: 'La mayoría de las confirmaciones vienen de muy pocas cuentas. Un grupo pequeño que se avala entre sí puede parecer muy activo: trata este registro con cuidado.',
ringWarning: 'Estas cuentas comercian sobre todo entre ellas: un grupo cerrado. La confirmación mutua dentro de un círculo es una prueba débil.',
weighted: 'Ponderado por confianza y actividad',
weighted: 'Ponderado por confianza, actividad y recencia',
footnote: 'Autodeclarado y firmado mutuamente: una señal, no una prueba de pago.',
},
reputationPanel: {
@ -7594,6 +7594,8 @@ walletUi: {
privacyLink: 'Qué sale de tu dispositivo',
safetyTitle: 'Comercia con seguridad',
safetyBody: 'Quedad en lugares públicos para las entregas, usa escrow o una señal de buena fe en envíos, y nunca envíes dinero fuera de la app.',
highValueTitle: 'Trato de alto valor',
noArbitration: 'El escrow tiene bloqueo temporal, no es arbitraje: protege el pago, no la entrega.',
moderationTitle: 'La moderación es suave y reversible',
moderationBody: 'El contenido oculto sigue en los relays y puede restaurarse; las disputas entre personas las resolvéis vosotros.',
},

View file

@ -122,6 +122,25 @@ describe('escrowP2PKOptions', () => {
expect(() => escrowP2PKOptions({ sellerPubkey: 'bad', buyerPubkey: BUYER })).toThrow();
expect(() => escrowP2PKOptions({ sellerPubkey: SELLER, buyerPubkey: '' })).toThrow();
});
it('builds an optional seller+arbiter / buyer+arbiter 2-of-3 policy', () => {
const arbiter = '02' + 'c'.repeat(64);
const opts = escrowP2PKOptions({
sellerPubkey: SELLER_XONLY,
buyerPubkey: BUYER,
arbiterPubkey: arbiter,
});
expect(opts.pubkey).toEqual([SELLER, arbiter]);
expect(opts.refundKeys).toEqual([BUYER, arbiter]);
expect(opts.requiredSignatures).toBe(2);
expect(opts.requiredRefundSignatures).toBe(2);
});
it('rejects an arbiter that is also the buyer or seller', () => {
expect(() =>
escrowP2PKOptions({ sellerPubkey: SELLER, buyerPubkey: BUYER, arbiterPubkey: SELLER }),
).toThrow();
});
});
describe('parseEscrowToken', () => {

View file

@ -85,7 +85,8 @@ export function escrowLocktime(days: number, from: number = Date.now()): number
}
export interface EscrowP2PKOptions {
pubkey: string;
/** Single seller key, or seller + arbiter for an optional 2-of-3 policy. */
pubkey: string | string[];
locktime: number;
refundKeys: string[];
requiredSignatures: number;
@ -100,11 +101,14 @@ export interface EscrowP2PKOptions {
export function escrowP2PKOptions({
sellerPubkey,
buyerPubkey,
arbiterPubkey,
lockDays = ESCROW_DEFAULT_LOCK_DAYS,
from = Date.now(),
}: {
sellerPubkey: string;
buyerPubkey: string;
/** Optional third key for a 2-of-3 policy: seller+arbiter or buyer+arbiter. */
arbiterPubkey?: string;
lockDays?: number;
from?: number;
}): EscrowP2PKOptions {
@ -112,6 +116,22 @@ export function escrowP2PKOptions({
if (!seller) throw new Error('Seller key is not a valid Cashu pubkey.');
const buyer = normalizeCashuPubkey(buyerPubkey);
if (!buyer) throw new Error('Your escrow key is not a valid Cashu pubkey.');
const arbiter = arbiterPubkey ? normalizeCashuPubkey(arbiterPubkey) : null;
if (arbiterPubkey && !arbiter) throw new Error('Arbiter key is not a valid Cashu pubkey.');
if (arbiter && (arbiter === seller || arbiter === buyer)) {
throw new Error('Arbiter must be a different key from the buyer and seller.');
}
if (arbiter) {
return {
// Normal path needs seller + arbiter; refund path needs buyer + arbiter.
pubkey: [seller, arbiter],
locktime: escrowLocktime(lockDays, from),
refundKeys: [buyer, arbiter],
requiredSignatures: 2,
requiredRefundSignatures: 2,
};
}
return {
pubkey: seller,
@ -146,7 +166,11 @@ export function secretPolicy(secret: string): Partial<EscrowPolicy> | null {
const values = (name: string): string[] =>
tags.find((t) => t[0] === name)?.slice(1) ?? [];
const primary = typeof obj.data === 'string' ? [obj.data] : [];
const primary = Array.isArray(obj.data)
? obj.data.filter((value): value is string => typeof value === 'string')
: typeof obj.data === 'string'
? [obj.data]
: [];
const pubkeys = [...primary, ...values('pubkeys')];
const refund = values('refund');
const locktime = Number(values('locktime')[0] ?? 0);
@ -256,6 +280,7 @@ export async function createEscrowToken(
amountSats: number;
sellerPubkey: string;
buyerPubkey: string;
arbiterPubkey?: string;
lockDays?: number;
},
): Promise<{ token: string; amount: number; locktime: number }> {
@ -267,6 +292,7 @@ export async function createEscrowToken(
const p2pk = escrowP2PKOptions({
sellerPubkey: params.sellerPubkey,
buyerPubkey: params.buyerPubkey,
arbiterPubkey: params.arbiterPubkey,
lockDays: params.lockDays,
});

View file

@ -131,6 +131,18 @@ export default function ListingDetailPage() {
? ((metadata as Record<string, unknown>).noffer as string)
: undefined;
const priceCurrency = item.price?.currency?.toUpperCase();
const priceAmount = Number(item.price?.amount);
const isHighValue =
item.priceType === 'fixed' &&
Number.isFinite(priceAmount) &&
((['SATS', 'SAT'].includes(priceCurrency ?? '') && priceAmount >= 100_000) ||
(['USD', 'EUR', 'GBP'].includes(priceCurrency ?? '') && priceAmount >= 100));
const isNewSeller =
(sellerInfo?.accountAge !== undefined && sellerInfo.accountAge < 30) ||
(!!sellerReputation && sellerReputation.verifiedCounterparties === 0);
const showSafetyNote = isNewSeller || isHighValue;
const lightboxImages: LightboxImage[] = item.images.map((url) => ({ url, isOwner: isOwnListing }));
const handleShare = async () => {
@ -343,15 +355,20 @@ export default function ListingDetailPage() {
) : (
<Card className="bg-black/90 border-white/10">
<CardContent className="p-4">
{(sellerInfo?.accountAge !== undefined && sellerInfo.accountAge < 30) ||
(sellerReputation && sellerReputation.verifiedCounterparties === 0) ? (
{showSafetyNote ? (
<div className="mb-3 space-y-1.5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
<p className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-amber-300">
<ShieldAlert className="h-3.5 w-3.5" /> {t('marketUi.card.newSeller')}
<ShieldAlert className="h-3.5 w-3.5" />{' '}
{isNewSeller
? t('marketUi.card.newSeller')
: t('marketTerms.highValueTitle')}
</p>
<p className="text-xs leading-relaxed text-gray-400">
{t('marketTerms.safetyBody')}
</p>
<p className="text-[11px] leading-relaxed text-amber-200/80">
{t('marketTerms.noArbitration')}
</p>
</div>
) : null}
<ListingTradeActions item={item} />