forked from Prompt-Hash-Stellar/prompt-hash
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlisting.ts
More file actions
388 lines (341 loc) · 12.1 KB
/
Copy pathlisting.ts
File metadata and controls
388 lines (341 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
import { xlmToStroops } from "@/lib/stellar/format";
import { z } from "zod";
export const LISTING_LIMITS = {
imageUrl: 512,
title: 120,
category: 40,
preview: 280,
previewMin: 10,
fullPrompt: 50_000,
encryptedPayload: 4096,
wrappedKey: 256,
encryptionIv: 64,
maxCoCreators: 10,
maxSplitBps: 9_500,
} as const;
export const createPromptSchema = z.object({
imageUrl: z
.string()
.url("Use a valid image URL")
.max(LISTING_LIMITS.imageUrl, `Image URL cannot exceed ${LISTING_LIMITS.imageUrl} characters`),
title: z
.string()
.min(3, "Title must be at least 3 characters")
.max(LISTING_LIMITS.title, `Title cannot exceed ${LISTING_LIMITS.title} characters`)
.nonempty("Title is required"),
category: z
.string()
.min(1, "Category is required")
.max(LISTING_LIMITS.category, `Category cannot exceed ${LISTING_LIMITS.category} characters`),
previewText: z
.string()
.min(LISTING_LIMITS.previewMin, `Preview text must be at least ${LISTING_LIMITS.previewMin} characters`)
.max(LISTING_LIMITS.preview, `Preview text cannot exceed ${LISTING_LIMITS.preview} characters`),
description: z
.string()
.min(10, "Description must be at least 10 characters")
.max(4_000, "Description cannot exceed 4000 characters")
.nonempty("Description is required"),
fullPrompt: z
.string()
.min(5, "Prompt instructions/content are required")
.max(LISTING_LIMITS.fullPrompt, `Full prompt cannot exceed ${LISTING_LIMITS.fullPrompt} characters`)
.nonempty("Full prompt is required"),
priceXlm: z
.string()
.min(1, "Price is required")
.refine((value) => !/e/i.test(value), "Enter a valid XLM amount without scientific notation")
.refine((value) => {
try {
return xlmToStroops(value) > 0n;
} catch {
return false;
}
}, "Enter a valid XLM amount greater than 0"),
tags: z.array(z.string()).default([]).optional(),
coCreators: z
.array(
z.object({
address: z.string(),
sharePercent: z.string(),
}),
)
.default([])
.optional(),
});
export type CreatePromptInput = z.infer<typeof createPromptSchema>;
export const ESTIMATED_ENCRYPTION_OVERHEAD = 1.37;
export type RevenueSplitFormInput = {
address: string;
sharePercent: string;
};
export type ListingFormInput = {
imageUrl: string;
title: string;
category: string;
previewText: string;
description?: string;
fullPrompt: string;
priceXlm: string;
tags?: string[];
coCreators: RevenueSplitFormInput[];
};
export type ListingValidationErrors = Partial<
Record<keyof ListingFormInput, string>
>;
export interface ListingValidationOptions {
/**
* When true, large encrypted payloads are stored off-chain (IPFS) and only a
* compact reference is kept on-chain, so the on-chain payload size cap no
* longer constrains how long the full prompt can be.
*/
offChainStorage?: boolean;
}
export type ChecklistStatus = "pass" | "fail" | "warn" | "info";
export interface ListingChecklistItem {
id: string;
label: string;
status: ChecklistStatus;
hint?: string;
}
const STELLAR_ADDRESS_PATTERN = /^[GC][A-Z2-7]{20,}$/i;
function trim(value: string) {
return value.trim();
}
export function validateListingForm(
input: ListingFormInput,
_options: ListingValidationOptions = {},
): ListingValidationErrors {
const errors: ListingValidationErrors = {};
const imageUrl = trim(input?.imageUrl || "");
const title = trim(input?.title || "");
const category = trim(input?.category || "");
const previewText = trim(input?.previewText || "");
const fullPrompt = trim(input?.fullPrompt || "");
const priceXlm = trim(input?.priceXlm || "");
const coCreators = input?.coCreators ?? [];
if (!imageUrl) {
errors.imageUrl = "Add an image URL so your listing has a cover on browse cards.";
} else if (imageUrl.length > LISTING_LIMITS.imageUrl) {
errors.imageUrl = `Shorten the image URL to ${LISTING_LIMITS.imageUrl} characters or fewer.`;
} else if (!/^https?:\/\/.+/i.test(imageUrl)) {
errors.imageUrl =
"Use a full URL starting with http:// or https:// so the cover image loads correctly.";
}
if (!title) {
errors.title = "Add a title that tells buyers what your prompt does.";
} else if (title.length < 3) {
errors.title = "Use at least 3 characters so the title is descriptive enough.";
} else if (title.length > LISTING_LIMITS.title) {
errors.title = `Shorten the title to ${LISTING_LIMITS.title} characters or fewer.`;
}
if (!category) {
errors.category = "Select a category so buyers can filter to your listing.";
} else if (category.length > LISTING_LIMITS.category) {
errors.category = `Choose a shorter category (max ${LISTING_LIMITS.category} characters).`;
}
if (!previewText) {
errors.previewText = "Add preview text so buyers can understand what they are unlocking.";
} else if (previewText.length < LISTING_LIMITS.previewMin) {
errors.previewText = `Use at least ${LISTING_LIMITS.previewMin} characters for the preview.`;
} else if (previewText.length > LISTING_LIMITS.preview) {
errors.previewText = `Choose a shorter preview text (max ${LISTING_LIMITS.preview} characters).`;
}
if (!fullPrompt) {
errors.fullPrompt = "Add the full prompt content that buyers will unlock.";
} else if (wouldExceedPayloadLimit(fullPrompt.length, _options)) {
errors.fullPrompt =
`Prompt is too long and would exceed the on-chain encrypted payload limit. Shorten it or enable off-chain storage.`;
}
if (!priceXlm) {
errors.priceXlm = "Enter a price in XLM — use a value greater than zero.";
} else {
if (/e/i.test(priceXlm)) {
errors.priceXlm = "Enter a valid XLM amount without scientific notation.";
} else {
try {
const price = xlmToStroops(priceXlm);
if (price <= 0n) {
errors.priceXlm = "Set a price greater than zero XLM.";
}
} catch (error) {
errors.priceXlm =
error instanceof Error
? error.message
: "Enter a valid XLM amount with up to 7 decimal places.";
}
}
}
if (coCreators.length > LISTING_LIMITS.maxCoCreators) {
errors.coCreators =
`Add up to ${LISTING_LIMITS.maxCoCreators} co-creators per listing.`;
} else if (coCreators.length > 0) {
const seenAddresses = new Set<string>();
let totalSplitBps = 0;
for (const coCreator of coCreators) {
const address = trim(coCreator.address).toUpperCase();
const sharePercent = trim(coCreator.sharePercent);
const parsedSharePercent = Number(sharePercent);
if (!address) {
errors.coCreators = "Enter a Stellar address for each co-creator.";
break;
}
if (!STELLAR_ADDRESS_PATTERN.test(address)) {
errors.coCreators =
"Use a valid Stellar public key for each co-creator address.";
break;
}
if (seenAddresses.has(address)) {
errors.coCreators =
"Each co-creator address can only appear once per listing.";
break;
}
if (!sharePercent || Number.isNaN(parsedSharePercent)) {
errors.coCreators =
"Enter a valid revenue share percentage for each co-creator.";
break;
}
if (parsedSharePercent <= 0) {
errors.coCreators =
"Each co-creator share must be greater than 0%.";
break;
}
totalSplitBps += Math.round(parsedSharePercent * 100);
seenAddresses.add(address);
}
if (!errors.coCreators && totalSplitBps > LISTING_LIMITS.maxSplitBps) {
errors.coCreators =
`Co-creator shares cannot exceed ${(LISTING_LIMITS.maxSplitBps / 100).toFixed(2)}% in total.`;
}
}
return errors;
}
export interface EncryptedPayloadInput {
encryptedPrompt: string;
wrappedKey: string;
encryptionIv: string;
}
export type PayloadValidationErrors = Partial<
Record<keyof EncryptedPayloadInput, string>
>;
export function estimateEncryptedSize(plaintextLength: number): number {
return Math.ceil(plaintextLength * ESTIMATED_ENCRYPTION_OVERHEAD);
}
export function wouldExceedPayloadLimit(
plaintextLength: number,
options: ListingValidationOptions = {},
): boolean {
return !options.offChainStorage && estimateEncryptedSize(plaintextLength) > LISTING_LIMITS.encryptedPayload;
}
export function validateEncryptedPayload(
input: EncryptedPayloadInput,
): PayloadValidationErrors {
const errors: PayloadValidationErrors = {};
if (!input.encryptedPrompt) {
errors.encryptedPrompt = "Encrypted prompt payload is missing.";
} else if (input.encryptedPrompt.length > LISTING_LIMITS.encryptedPayload) {
errors.encryptedPrompt =
`Encrypted payload is ${input.encryptedPrompt.length.toLocaleString()} characters, ` +
`exceeding the on-chain limit of ${LISTING_LIMITS.encryptedPayload.toLocaleString()}. ` +
`Shorten the full prompt and try again.`;
}
if (!input.wrappedKey) {
errors.wrappedKey = "Wrapped encryption key is missing.";
} else if (input.wrappedKey.length > LISTING_LIMITS.wrappedKey) {
errors.wrappedKey =
`Wrapped key is ${input.wrappedKey.length} characters, ` +
`exceeding the limit of ${LISTING_LIMITS.wrappedKey}.`;
}
if (!input.encryptionIv) {
errors.encryptionIv = "Encryption IV is missing.";
} else if (input.encryptionIv.length > LISTING_LIMITS.encryptionIv) {
errors.encryptionIv =
`Encryption IV is ${input.encryptionIv.length} characters, ` +
`exceeding the limit of ${LISTING_LIMITS.encryptionIv}.`;
}
return errors;
}
export function buildListingChecklistItems(
input: ListingFormInput,
options: ListingValidationOptions = {},
): ListingChecklistItem[] {
const errors = validateListingForm(input, options);
const items: ListingChecklistItem[] = [];
const fieldChecks: Array<{
id: keyof ListingFormInput;
label: string;
}> = [
{ id: "title", label: "Title" },
{ id: "category", label: "Category" },
{ id: "previewText", label: "Preview text" },
{ id: "fullPrompt", label: "Full prompt content" },
{ id: "priceXlm", label: "Price" },
{ id: "imageUrl", label: "Image URL" },
{ id: "coCreators", label: "Co-creators" },
];
for (const { id, label } of fieldChecks) {
const message = errors[id];
items.push({
id,
label,
status: message ? "fail" : "pass",
hint: message,
});
}
const titleWords = trim(input.title).split(/\s+/).filter(Boolean).length;
if (!errors.title && titleWords < 3) {
items.push({
id: "title-words",
label: "Title could be more descriptive",
status: "warn",
hint: "Aim for at least 3 words to help buyers find your listing",
});
}
const previewLen = trim(input.previewText).length;
if (!errors.previewText && previewLen > 0 && previewLen < 60) {
items.push({
id: "preview-length",
label: "Preview text is short",
status: "warn",
hint: "A longer preview (60+ characters) improves buyer confidence",
});
}
const promptLen = trim(input.fullPrompt).length;
if (!errors.fullPrompt && promptLen > 0 && promptLen < 100) {
items.push({
id: "prompt-length",
label: "Full prompt seems short",
status: "warn",
hint: "Buyers expect substantial prompt content — consider expanding it",
});
}
let priceValue = Number.NaN;
try {
if (!errors.priceXlm && trim(input.priceXlm)) {
priceValue = Number(trim(input.priceXlm));
}
} catch {
// covered by validateListingForm
}
if (!errors.priceXlm && !Number.isNaN(priceValue) && priceValue > 0 && priceValue < 0.5) {
items.push({
id: "price-low",
label: "Price is very low",
status: "warn",
hint: "Listings under 0.5 XLM may signal low quality to buyers",
});
}
const totalRevenueSharePercent = (input.coCreators ?? []).reduce(
(sum, coCreator) => sum + (Number(trim(coCreator.sharePercent)) || 0),
0,
);
if (!errors.coCreators && totalRevenueSharePercent > 0) {
items.push({
id: "revenue-share",
label: "Revenue sharing configured",
status: "info",
hint: `${totalRevenueSharePercent.toFixed(2)}% shared across co-creators.`,
});
}
return items;
}