feat: high-value safety, reputation decay, escrow 2-of-3 primitive
This commit is contained in:
parent
1d810ea964
commit
31d1630c50
9 changed files with 122 additions and 15 deletions
19
ESCROW.md
19
ESCROW.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
Loading…
Reference in a new issue