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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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) };
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue