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. promptly, and lock windows should be generous.
- **Native only**: the app's Cashu wallet is Capacitor-only, so escrow is too. - **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 ## Status
- ✅ **Core** (`src/lib/cashuEscrow.ts`, unit-tested): pubkey normalisation, - ✅ **Core** (`src/lib/cashuEscrow.ts`, unit-tested): pubkey normalisation,
`escrowP2PKOptions`, offline token inspection/`parseEscrowToken`, `escrowP2PKOptions` (1-of-1, optional 2-of-3), offline token
locktime/refund policy, and the injectable `createEscrowToken` mint flow. inspection/`parseEscrowToken`, locktime/refund policy, and the injectable
`createEscrowToken` mint flow.
- 🟡 **Wallet UI** (native-only): implement → verify with the **On-device test - 🟡 **Wallet UI** (native-only): implement → verify with the **On-device test
checklist** below before treating as production. Do not use real money until checklist** below before treating as production. Do not use real money until
every box passes. 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, // 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. // 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 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( const weighted = verifiedWith.reduce(
(sum, v) => (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, 0,
); );
const trustLabel = const trustLabel =

View file

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

View file

@ -87,11 +87,13 @@ export function useCashuEscrow() {
async ({ async ({
amountSats, amountSats,
sellerPubkey, sellerPubkey,
arbiterPubkey,
lockDays, lockDays,
mintUrl, mintUrl,
}: { }: {
amountSats: number; amountSats: number;
sellerPubkey: string; sellerPubkey: string;
arbiterPubkey?: string;
lockDays?: number; lockDays?: number;
mintUrl?: string; mintUrl?: string;
}): Promise<{ token: string; locktime: number; info: EscrowTokenInfo }> => { }): Promise<{ token: string; locktime: number; info: EscrowTokenInfo }> => {
@ -105,7 +107,7 @@ export function useCashuEscrow() {
const { token, locktime } = await createEscrowToken( const { token, locktime } = await createEscrowToken(
{ wallet: makeMintWallet(mint), payInvoice }, { wallet: makeMintWallet(mint), payInvoice },
{ mintUrl: mint, amountSats, sellerPubkey, buyerPubkey, lockDays }, { mintUrl: mint, amountSats, sellerPubkey, buyerPubkey, arbiterPubkey, lockDays },
); );
return { token, locktime, info: parseEscrowToken(token) }; 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.', 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.', 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.', 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.', footnote: 'Self-reported and mutually signed — a signal, not proof of payment.',
}, },
reputationPanel: { reputationPanel: {
@ -7594,6 +7594,8 @@ walletUi: {
privacyLink: 'What leaves your device', privacyLink: 'What leaves your device',
safetyTitle: 'Trade safely', 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.', 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', moderationTitle: 'Moderation is soft and reversible',
moderationBody: 'Hidden content stays on relays and can be restored; disputes between people are yours to resolve.', 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.', 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.', 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.', 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.', footnote: 'Autodeclarado y firmado mutuamente: una señal, no una prueba de pago.',
}, },
reputationPanel: { reputationPanel: {
@ -7594,6 +7594,8 @@ walletUi: {
privacyLink: 'Qué sale de tu dispositivo', privacyLink: 'Qué sale de tu dispositivo',
safetyTitle: 'Comercia con seguridad', 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.', 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', 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.', 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: 'bad', buyerPubkey: BUYER })).toThrow();
expect(() => escrowP2PKOptions({ sellerPubkey: SELLER, buyerPubkey: '' })).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', () => { describe('parseEscrowToken', () => {

View file

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

View file

@ -131,6 +131,18 @@ export default function ListingDetailPage() {
? ((metadata as Record<string, unknown>).noffer as string) ? ((metadata as Record<string, unknown>).noffer as string)
: undefined; : 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 lightboxImages: LightboxImage[] = item.images.map((url) => ({ url, isOwner: isOwnListing }));
const handleShare = async () => { const handleShare = async () => {
@ -343,15 +355,20 @@ export default function ListingDetailPage() {
) : ( ) : (
<Card className="bg-black/90 border-white/10"> <Card className="bg-black/90 border-white/10">
<CardContent className="p-4"> <CardContent className="p-4">
{(sellerInfo?.accountAge !== undefined && sellerInfo.accountAge < 30) || {showSafetyNote ? (
(sellerReputation && sellerReputation.verifiedCounterparties === 0) ? (
<div className="mb-3 space-y-1.5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3"> <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"> <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>
<p className="text-xs leading-relaxed text-gray-400"> <p className="text-xs leading-relaxed text-gray-400">
{t('marketTerms.safetyBody')} {t('marketTerms.safetyBody')}
</p> </p>
<p className="text-[11px] leading-relaxed text-amber-200/80">
{t('marketTerms.noArbitration')}
</p>
</div> </div>
) : null} ) : null}
<ListingTradeActions item={item} /> <ListingTradeActions item={item} />