diff --git a/ESCROW.md b/ESCROW.md index f0aa05b..0bb3820 100644 --- a/ESCROW.md +++ b/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. diff --git a/src/components/TradeReputationCard.tsx b/src/components/TradeReputationCard.tsx index 056d35e..08363c1 100644 --- a/src/components/TradeReputationCard.tsx +++ b/src/components/TradeReputationCard.tsx @@ -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 = diff --git a/src/hooks/useTradeAttestations.ts b/src/hooks/useTradeAttestations.ts index b85666f..1fa3a9e 100644 --- a/src/hooks/useTradeAttestations.ts +++ b/src/hooks/useTradeAttestations.ts @@ -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(); const verifiedWith = new Map(); + const verifiedRecentWith = new Map(); + const verifiedLastAt = new Map(); 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, }; diff --git a/src/hooks/wallet/useCashuEscrow.ts b/src/hooks/wallet/useCashuEscrow.ts index c0d5896..cf596ea 100644 --- a/src/hooks/wallet/useCashuEscrow.ts +++ b/src/hooks/wallet/useCashuEscrow.ts @@ -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) }; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 04249ec..f4eb811 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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.', }, diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 2099489..8308777 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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.', }, diff --git a/src/lib/cashuEscrow.test.ts b/src/lib/cashuEscrow.test.ts index c6269b3..7165ecd 100644 --- a/src/lib/cashuEscrow.test.ts +++ b/src/lib/cashuEscrow.test.ts @@ -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', () => { diff --git a/src/lib/cashuEscrow.ts b/src/lib/cashuEscrow.ts index c5afa35..b06f6ea 100644 --- a/src/lib/cashuEscrow.ts +++ b/src/lib/cashuEscrow.ts @@ -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 | 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, }); diff --git a/src/pages/ListingDetailPage.tsx b/src/pages/ListingDetailPage.tsx index ffc4b3d..0b8d2e3 100644 --- a/src/pages/ListingDetailPage.tsx +++ b/src/pages/ListingDetailPage.tsx @@ -131,6 +131,18 @@ export default function ListingDetailPage() { ? ((metadata as Record).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() { ) : ( - {(sellerInfo?.accountAge !== undefined && sellerInfo.accountAge < 30) || - (sellerReputation && sellerReputation.verifiedCounterparties === 0) ? ( + {showSafetyNote ? (

- {t('marketUi.card.newSeller')} + {' '} + {isNewSeller + ? t('marketUi.card.newSeller') + : t('marketTerms.highValueTitle')}

{t('marketTerms.safetyBody')}

+

+ {t('marketTerms.noArbitration')} +

) : null}