@1sat/permission-module 0.0.49 → 0.0.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/apply.d.ts +1 -1
  2. package/dist/apply.d.ts.map +1 -1
  3. package/dist/apply.js +3 -3
  4. package/dist/apply.js.map +1 -1
  5. package/dist/apply.test.js +29 -29
  6. package/dist/apply.test.js.map +1 -1
  7. package/dist/basketAccess.d.ts +38 -0
  8. package/dist/basketAccess.d.ts.map +1 -0
  9. package/dist/basketAccess.js +237 -0
  10. package/dist/basketAccess.js.map +1 -0
  11. package/dist/buildPromptIntent.d.ts +15 -0
  12. package/dist/buildPromptIntent.d.ts.map +1 -0
  13. package/dist/buildPromptIntent.js +575 -0
  14. package/dist/buildPromptIntent.js.map +1 -0
  15. package/dist/createOneSatPermissionModule.d.ts +10 -5
  16. package/dist/createOneSatPermissionModule.d.ts.map +1 -1
  17. package/dist/createOneSatPermissionModule.js +28 -6
  18. package/dist/createOneSatPermissionModule.js.map +1 -1
  19. package/dist/enrichIntent.d.ts +121 -5
  20. package/dist/enrichIntent.d.ts.map +1 -1
  21. package/dist/enrichIntent.js +576 -137
  22. package/dist/enrichIntent.js.map +1 -1
  23. package/dist/handlers.d.ts +12 -32
  24. package/dist/handlers.d.ts.map +1 -1
  25. package/dist/handlers.js +120 -155
  26. package/dist/handlers.js.map +1 -1
  27. package/dist/index.d.ts +5 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +4 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/listOutputsScope.test.d.ts +2 -0
  32. package/dist/listOutputsScope.test.d.ts.map +1 -0
  33. package/dist/listOutputsScope.test.js +116 -0
  34. package/dist/listOutputsScope.test.js.map +1 -0
  35. package/dist/promptModel.d.ts +115 -0
  36. package/dist/promptModel.d.ts.map +1 -0
  37. package/dist/promptModel.js +7 -0
  38. package/dist/promptModel.js.map +1 -0
  39. package/dist/types.d.ts +36 -10
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/types.js.map +1 -1
  42. package/dist/verifyIntent.d.ts +4 -4
  43. package/dist/verifyIntent.d.ts.map +1 -1
  44. package/dist/verifyIntent.js +120 -40
  45. package/dist/verifyIntent.js.map +1 -1
  46. package/dist/viewScope.d.ts +51 -0
  47. package/dist/viewScope.d.ts.map +1 -0
  48. package/dist/viewScope.js +113 -0
  49. package/dist/viewScope.js.map +1 -0
  50. package/dist/viewScope.test.d.ts +2 -0
  51. package/dist/viewScope.test.d.ts.map +1 -0
  52. package/dist/viewScope.test.js +60 -0
  53. package/dist/viewScope.test.js.map +1 -0
  54. package/package.json +42 -42
@@ -7,8 +7,8 @@
7
7
  * before dispatching to permission modules — so we can't read them.
8
8
  * What's left:
9
9
  *
10
- * - `args.labels` — unencrypted; carries our `'p 1sat input <basket> <id>'`
11
- * / `'p 1sat output <basket> <id>'` lookup keys
10
+ * - `args.labels` — unencrypted; carries our `'p <scheme> input <key>'`
11
+ * lookup keys
12
12
  * - `args.outputs[i].lockingScript` — decode P2PKH for recipient address
13
13
  * - `args.outputs[i].satoshis`
14
14
  *
@@ -18,10 +18,15 @@
18
18
  * committed to the final tx). The dApp can't lie about what the user
19
19
  * is being asked to approve.
20
20
  */
21
- import { BAP_BASKET, BSOCIAL_BASKET, BSV21_AUTH_BASKET, BSV21_BASKET, LOCK_BASKET, ORDFS_HOST, OPNS_BASKET, ORDINALS_BASKET, P1SAT_BASKET_PREFIX, P1SAT_INPUT_LABEL_PREFIX, SIGMA_BASKET, parseIntentLabel, } from '@1sat/types';
22
- import { Lock, OrdLock, outpointFromBytes } from '@1sat/templates';
21
+ import { BAP_BASKET, BSOCIAL_BASKET, BSV21_BASKET, LOCK_BASKET, ORDFS_HOST, OPNS_BASKET, ORDINALS_BASKET, SIGMA_BASKET, formatOrdinalOutpoint, parseInputAssetLabels, } from '@1sat/types';
22
+ import { BSV21, Inscription, Lock, OrdLock, outpointFromBytes, Sigma, } from '@1sat/templates';
23
23
  import { parseAddress } from '@1sat/wallet';
24
24
  import { LockingScript, PushDrop, Script, Utils } from '@bsv/sdk';
25
+ /** Same protocol WPM uses for description/CI encryption. */
26
+ const METADATA_ENCRYPTION_PROTOCOL = [
27
+ 2,
28
+ 'admin metadata encryption',
29
+ ];
25
30
  /**
26
31
  * Presentation fields on an OpNS bind: field 0 is the identity key and the
27
32
  * last is the signature, so field 1 is the display name and field 2 the
@@ -52,7 +57,6 @@ function decodeOpnsProfile(script) {
52
57
  const ASSET_BASKETS = [
53
58
  ORDINALS_BASKET,
54
59
  BSV21_BASKET,
55
- BSV21_AUTH_BASKET,
56
60
  LOCK_BASKET,
57
61
  OPNS_BASKET,
58
62
  BSOCIAL_BASKET,
@@ -67,19 +71,27 @@ export async function enrichIntent(wallet, args, opts = {}) {
67
71
  ? (origin) => ordfs.getContentUrl?.(origin)
68
72
  : undefined;
69
73
  const labels = args.labels ?? [];
70
- const p1satIntent = parseIntentLabel(labels);
71
- const inputRefs = parseAssetLabels(labels, P1SAT_INPUT_LABEL_PREFIX);
74
+ const inputRefs = parseInputAssetLabels(labels);
72
75
  const inputs = (await Promise.all(inputRefs.map((ref) => lookupAsset(wallet, ref.basket, ref.id)))).filter((a) => a !== null);
73
- const outputs = (args.outputs ?? []).map((out, i) => decodeOutput(out, i, chain));
74
- const kind = detectKind(inputs, outputs, p1satIntent);
75
- const summary = buildSummary(kind, inputs, outputs, p1satIntent);
76
- const trust = initialTrust(p1satIntent);
76
+ const outputs = await Promise.all((args.outputs ?? []).map(async (out, i) => {
77
+ const decoded = decodeOutput(out, i, chain);
78
+ if (decoded.customInstructions) {
79
+ decoded.customInstructions = await maybeDecryptCustomInstructions(wallet, decoded.customInstructions);
80
+ }
81
+ return decoded;
82
+ }));
83
+ const kind = detectKind(inputs, outputs);
84
+ const summary = buildSummary(kind, inputs, outputs);
85
+ const trust = initialTrust(kind);
77
86
  const fee = extractIndexerFee(args, outputs);
87
+ const ordinalEdges = buildOrdinalEdges(inputs, outputs);
88
+ const legs = buildLegs(inputs, outputs, ordinalEdges);
78
89
  return {
79
90
  kind,
80
- p1satIntent,
81
91
  inputs,
82
92
  outputs,
93
+ legs,
94
+ ordinalEdges,
83
95
  labels,
84
96
  summary,
85
97
  contentUrlForOrigin: (origin) => getContentUrl?.(origin) ?? contentUrlFromOrigin(ORDFS_HOST, origin),
@@ -96,14 +108,11 @@ export async function enrichIntent(wallet, args, opts = {}) {
96
108
  * meaningless or a lie, and outputs are dApp-authored besides. Nothing
97
109
  * persisted may influence the badge.
98
110
  *
99
- * Every purchase therefore starts `unverified`; `verifyIntent` upgrades it if
100
- * and when a service positively answers.
111
+ * Purchases and BSV21 transfers start `unverified`; `verifyIntent` upgrades
112
+ * them when the overlay positively answers (token active + inputs valid).
101
113
  */
102
- function initialTrust(p1satIntent) {
103
- const isPurchase = p1satIntent === 'ordinal.purchase' ||
104
- p1satIntent === 'opns.purchase' ||
105
- p1satIntent === 'bsv21.purchase';
106
- if (!isPurchase)
114
+ function initialTrust(kind) {
115
+ if (kind !== 'purchase' && kind !== 'token-transfer')
107
116
  return undefined;
108
117
  return { state: 'unverified' };
109
118
  }
@@ -143,26 +152,43 @@ function extractIndexerFee(args, outputs) {
143
152
  : `${feeSats.toLocaleString()} sats overlay fee`;
144
153
  return { indexerFeeSats: feeSats, indexerFeeNote: note };
145
154
  }
146
- function parseAssetLabels(labels, prefix) {
147
- const refs = [];
148
- for (const label of labels) {
149
- if (!label.startsWith(prefix))
150
- continue;
151
- const payload = label.slice(prefix.length).trim();
152
- const sep = payload.indexOf(' ');
153
- if (sep <= 0)
154
- continue;
155
- const suffix = payload.slice(0, sep);
156
- const id = payload.slice(sep + 1).trim();
157
- if (!suffix || !id)
158
- continue;
159
- refs.push({ basket: `${P1SAT_BASKET_PREFIX}${suffix}`, id });
160
- }
161
- return refs;
162
- }
163
155
  // ---------------------------------------------------------------------------
164
156
  // Asset lookup — indexed `listOutputs` query by `id:<id>` tag.
165
157
  // ---------------------------------------------------------------------------
158
+ /**
159
+ * WPM may encrypt customInstructions before storage / before module onRequest.
160
+ * Base-wallet listOutputs returns ciphertext. Never mutate plaintext:
161
+ * 1. If it already parses as JSON → return as-is (base-wallet writes).
162
+ * 2. Else try decrypt; on failure return original unchanged.
163
+ */
164
+ async function maybeDecryptCustomInstructions(wallet, value) {
165
+ if (!value)
166
+ return undefined;
167
+ // Fast path: plaintext JSON (or anything that is already valid JSON).
168
+ try {
169
+ JSON.parse(value);
170
+ return value;
171
+ }
172
+ catch {
173
+ // not JSON — may be WPM base64 ciphertext
174
+ }
175
+ try {
176
+ const { plaintext } = await wallet.decrypt({
177
+ ciphertext: Utils.toArray(value, 'base64'),
178
+ protocolID: METADATA_ENCRYPTION_PROTOCOL,
179
+ keyID: '1',
180
+ counterparty: 'self',
181
+ });
182
+ const text = Utils.toUTF8(plaintext);
183
+ // Only accept decrypt if result is usable JSON — avoids "decrypting"
184
+ // random strings into garbage we then treat as CI.
185
+ JSON.parse(text);
186
+ return text;
187
+ }
188
+ catch {
189
+ return value;
190
+ }
191
+ }
166
192
  async function lookupAsset(wallet, basket, id) {
167
193
  // Only query baskets the module knows about — guards against malicious
168
194
  // labels pointing at unrelated baskets.
@@ -180,13 +206,15 @@ async function lookupAsset(wallet, basket, id) {
180
206
  const match = result.outputs[0];
181
207
  if (!match)
182
208
  return null;
209
+ const rawCi = match.customInstructions;
210
+ const customInstructions = await maybeDecryptCustomInstructions(wallet, rawCi);
183
211
  return {
184
212
  basket,
185
213
  id,
186
214
  outpoint: match.outpoint,
187
215
  satoshis: match.satoshis,
188
216
  tags: match.tags ?? [],
189
- customInstructions: match.customInstructions,
217
+ customInstructions,
190
218
  };
191
219
  }
192
220
  catch {
@@ -202,6 +230,9 @@ function decodeOutput(out, index, chain) {
202
230
  satoshis: out.satoshis ?? 0,
203
231
  basket: out.basket,
204
232
  tags: out.tags ?? [],
233
+ template: 'unrecognized',
234
+ customInstructions: out.customInstructions,
235
+ ...(out.lockingScript ? { lockingScript: out.lockingScript } : {}),
205
236
  };
206
237
  if (!out.lockingScript)
207
238
  return enriched;
@@ -212,32 +243,511 @@ function decodeOutput(out, index, chain) {
212
243
  catch {
213
244
  return enriched;
214
245
  }
215
- const ordLock = OrdLock.decode(script, chain === 'mainnet');
246
+ const classified = classifyScript(script, chain, out.basket);
247
+ Object.assign(enriched, classified);
248
+ Object.assign(enriched, inscriptionPreviewFromScript(script));
249
+ if (out.basket === OPNS_BASKET || classified.template === 'pushdrop') {
250
+ Object.assign(enriched, decodeOpnsProfile(script));
251
+ }
252
+ // Tag fallbacks when script didn't carry sym/id (self-kept rows).
253
+ if (!enriched.tokenId) {
254
+ const tid = tagValue(enriched.tags, 'bsv21');
255
+ if (tid && tid !== 'deploy')
256
+ enriched.tokenId = tid;
257
+ }
258
+ if (!enriched.tokenAmt) {
259
+ const amt = tagValue(enriched.tags, 'amt');
260
+ if (amt)
261
+ enriched.tokenAmt = amt;
262
+ }
263
+ if (!enriched.tokenSym) {
264
+ const sym = displayFieldFrom('sym', enriched.tags, enriched.customInstructions);
265
+ if (sym)
266
+ enriched.tokenSym = sym;
267
+ }
268
+ if (enriched.tags.includes('fee:overlay') ||
269
+ enriched.tags.some((t) => t.startsWith('indexer-fee'))) {
270
+ enriched.isIndexerFee = true;
271
+ }
272
+ return enriched;
273
+ }
274
+ function classifyScript(script, chain, basket) {
275
+ const main = chain === 'mainnet';
276
+ const ordLock = OrdLock.decode(script, main);
216
277
  if (ordLock) {
217
- enriched.listingPriceSats = Number(ordLock.price);
218
- enriched.listingSeller = ordLock.seller;
278
+ return {
279
+ template: 'ordlock',
280
+ listingPriceSats: Number(ordLock.price),
281
+ listingSeller: ordLock.seller,
282
+ };
219
283
  }
220
- const lock = Lock.decode(script, chain === 'mainnet');
284
+ const lock = Lock.decode(script, main);
221
285
  if (lock) {
222
- enriched.lockUntilHeight = lock.until;
286
+ return {
287
+ template: 'lock',
288
+ lockUntilHeight: lock.until,
289
+ };
223
290
  }
224
- if (out.basket === OPNS_BASKET) {
225
- Object.assign(enriched, decodeOpnsProfile(script));
291
+ const bsv21 = BSV21.decode(script);
292
+ if (bsv21) {
293
+ const d = bsv21.tokenData;
294
+ // amt is the BSV21 inscription amount (base units), never satoshis.
295
+ const tokenAmt = d.amt != null && String(d.amt).length > 0 ? String(d.amt) : undefined;
296
+ return {
297
+ template: 'bsv21',
298
+ ...(d.id ? { tokenId: d.id } : {}),
299
+ ...(tokenAmt ? { tokenAmt } : {}),
300
+ ...(d.sym ? { tokenSym: d.sym } : {}),
301
+ ...(d.op ? { tokenOp: d.op } : {}),
302
+ ...p2pkhRecipient(script, chain),
303
+ };
226
304
  }
227
- const recipient = parseAddress(script, 0, chain);
228
- if (recipient) {
229
- enriched.recipient = recipient;
230
- return enriched;
305
+ if (scriptContainsAscii(script, 'SIGMA') || Sigma.countInstances(script) > 0) {
306
+ const sealPending = hasZeroedSigmaSig(script);
307
+ return {
308
+ template: 'sigma',
309
+ sealKind: 'sigma',
310
+ ...(sealPending ? { sealPending: true } : {}),
311
+ ...p2pkhRecipient(script, chain),
312
+ };
313
+ }
314
+ try {
315
+ const fields = PushDrop.decode(LockingScript.fromHex(script.toHex())).fields;
316
+ if (fields.length >= 2) {
317
+ const last = fields[fields.length - 1];
318
+ const sealPending = !!last?.length && last.every((b) => b === 0);
319
+ return {
320
+ template: 'pushdrop',
321
+ ...(sealPending
322
+ ? { sealPending: true, sealKind: 'pushdrop' }
323
+ : {}),
324
+ };
325
+ }
326
+ }
327
+ catch {
328
+ // not PushDrop
329
+ }
330
+ const recipient = p2pkhRecipient(script, chain);
331
+ if (recipient.recipient) {
332
+ return { template: 'p2pkh', ...recipient };
231
333
  }
232
- // Inscription envelope or other suffix-bearing scripts try parsing
233
- // P2PKH after OP_ENDIF.
334
+ return { template: basket ? 'unrecognized' : 'unrecognized' };
335
+ }
336
+ const PREVIEW_TEXT_MAX = 800;
337
+ function inscriptionPreviewFromScript(script) {
338
+ const insc = Inscription.decode(script);
339
+ if (!insc?.file)
340
+ return {};
341
+ const type = insc.file.type.split(';')[0]?.trim() || '';
342
+ const bytes = Array.from(insc.file.content);
343
+ if (bytes.length === 0) {
344
+ return type ? { inscriptionType: type } : {};
345
+ }
346
+ const lower = type.toLowerCase();
347
+ if (lower.startsWith('image/')) {
348
+ return {
349
+ inscriptionType: type,
350
+ inscriptionDataUrl: `data:${type};base64,${Utils.toBase64(bytes)}`,
351
+ };
352
+ }
353
+ try {
354
+ let text = Utils.toUTF8(bytes);
355
+ if (lower.includes('json')) {
356
+ try {
357
+ text = JSON.stringify(JSON.parse(text), null, 2);
358
+ }
359
+ catch {
360
+ // keep raw
361
+ }
362
+ }
363
+ return {
364
+ inscriptionType: type,
365
+ inscriptionText: text.slice(0, PREVIEW_TEXT_MAX),
366
+ };
367
+ }
368
+ catch {
369
+ return type ? { inscriptionType: type } : {};
370
+ }
371
+ }
372
+ function p2pkhRecipient(script, chain) {
373
+ const recipient = parseAddress(script, 0, chain);
374
+ if (recipient)
375
+ return { recipient };
234
376
  const endifIndex = script.chunks.findIndex((c) => c.op === 0x68);
235
377
  if (endifIndex > 0) {
236
378
  const after = parseAddress(script, endifIndex + 1, chain);
237
379
  if (after)
238
- enriched.recipient = after;
380
+ return { recipient: after };
239
381
  }
240
- return enriched;
382
+ return {};
383
+ }
384
+ function scriptContainsAscii(script, ascii) {
385
+ const needle = Utils.toArray(ascii);
386
+ const hay = script.toBinary();
387
+ if (hay.length < needle.length)
388
+ return false;
389
+ outer: for (let i = 0; i <= hay.length - needle.length; i++) {
390
+ for (let j = 0; j < needle.length; j++) {
391
+ if (hay[i + j] !== needle[j])
392
+ continue outer;
393
+ }
394
+ return true;
395
+ }
396
+ return false;
397
+ }
398
+ /**
399
+ * True when a SIGMA tape's signature push is still the zero placeholder.
400
+ * Parses the real tape (`SIGMA · algo · address · sig · vin`) via
401
+ * {@link Sigma.parseFromScript} — not a raw zero-run hunt after the marker
402
+ * (BSM + address sit between SIGMA and the sig field).
403
+ */
404
+ function hasZeroedSigmaSig(script) {
405
+ try {
406
+ const sigs = Sigma.parseFromScript(script);
407
+ for (const s of sigs) {
408
+ const bytes = Utils.toArray(s.signature, 'base64');
409
+ if (bytes.length > 0 && bytes.every((b) => b === 0))
410
+ return true;
411
+ }
412
+ }
413
+ catch {
414
+ // unparseable tape
415
+ }
416
+ return false;
417
+ }
418
+ /** Prefer case-preserving CI name over lowercased `name:` tags. */
419
+ function displayNameFrom(tags, customInstructions, scriptName) {
420
+ if (scriptName)
421
+ return scriptName;
422
+ if (customInstructions) {
423
+ try {
424
+ const n = JSON.parse(customInstructions).name;
425
+ if (typeof n === 'string' && n.trim())
426
+ return n.trim();
427
+ }
428
+ catch {
429
+ // ignore
430
+ }
431
+ }
432
+ return tagValue(tags, 'name');
433
+ }
434
+ /**
435
+ * Case-preserving field from customInstructions (BRC-100 lowercases tags).
436
+ * Falls back to the matching tag when CI is missing.
437
+ */
438
+ function displayFieldFrom(field, tags, customInstructions) {
439
+ if (customInstructions) {
440
+ try {
441
+ const v = JSON.parse(customInstructions)[field];
442
+ if (typeof v === 'string' && v.trim())
443
+ return v.trim();
444
+ }
445
+ catch {
446
+ // ignore
447
+ }
448
+ }
449
+ return tagValue(tags, field);
450
+ }
451
+ function isCollectableBasket(basket) {
452
+ return (basket === ORDINALS_BASKET ||
453
+ basket === OPNS_BASKET ||
454
+ // plain-name future + legacy
455
+ basket === '1sat' ||
456
+ !!basket?.includes('ordinal') ||
457
+ !!basket?.includes('opns'));
458
+ }
459
+ /**
460
+ * Pair collectable spends with their next tip/listing (BRC-303 shapes).
461
+ */
462
+ function buildOrdinalEdges(inputs, outputs) {
463
+ const edges = [];
464
+ const usedOut = new Set();
465
+ const takeOut = (pred) => {
466
+ const hit = outputs.find((o) => !usedOut.has(o.index) && pred(o));
467
+ if (hit)
468
+ usedOut.add(hit.index);
469
+ return hit;
470
+ };
471
+ const collectableIns = inputs.filter((i) => isCollectableBasket(i.basket) || hasListingTags(i.tags));
472
+ const usedIn = new Set();
473
+ for (const inp of collectableIns) {
474
+ usedIn.add(inp.id);
475
+ const name = displayNameFrom(inp.tags, inp.customInstructions);
476
+ const origin = tagValue(inp.tags, 'origin');
477
+ const spend = {
478
+ basket: inp.basket,
479
+ id: inp.id,
480
+ outpoint: inp.outpoint,
481
+ satoshis: inp.satoshis,
482
+ tags: inp.tags,
483
+ ...(name ? { name } : {}),
484
+ ...(origin ? { origin } : {}),
485
+ isListing: hasListingTags(inp.tags),
486
+ };
487
+ if (hasListingTags(inp.tags)) {
488
+ // cancel: listing in → held collectable out (not ordlock)
489
+ const create = takeOut((o) => isCollectableBasket(o.basket) &&
490
+ o.template !== 'ordlock' &&
491
+ o.satoshis === 1);
492
+ edges.push(edge('cancel-listing', spend, create, name ? `Cancel listing of “${name}”` : 'Cancel listing', 'Return collectable from marketplace lock'));
493
+ continue;
494
+ }
495
+ const listOut = takeOut((o) => o.template === 'ordlock' && o.satoshis === 1);
496
+ if (listOut) {
497
+ const price = listOut.listingPriceSats;
498
+ edges.push(edge('list', spend, listOut, name ? `List “${name}”` : 'List collectable', price != null
499
+ ? `List for ${price} sats`
500
+ : 'List on marketplace lock'));
501
+ continue;
502
+ }
503
+ // OpNS register / unregister.
504
+ const isOpnsSpend = inp.basket === OPNS_BASKET ||
505
+ !!inp.basket?.includes('opns') ||
506
+ inp.tags.some((t) => t.startsWith('opns:') || t === 'opns');
507
+ if (isOpnsSpend) {
508
+ const registerOut = takeOut((o) => o.template === 'pushdrop' &&
509
+ (o.sealPending ||
510
+ o.tags.includes('opns:published') ||
511
+ o.basket === OPNS_BASKET ||
512
+ !!o.basket?.includes('opns')));
513
+ if (registerOut) {
514
+ // Domain is the owned OpNS name; profile is presentation only.
515
+ const domain = name;
516
+ edges.push(edge('register', spend, registerOut, domain ? `Publish “${domain}”` : 'Publish OpNS name', registerOut.sealPending
517
+ ? 'Sign PushDrop identity bind'
518
+ : 'OpNS identity bind'));
519
+ continue;
520
+ }
521
+ // Unpublish: was bound → plain P2PKH keep in OPNS basket (drops bind).
522
+ // Self-P2PKH still decodes a recipient address — basket + dropped
523
+ // published tag is what distinguishes this from an external send.
524
+ const wasPublished = inp.tags.includes('opns:published');
525
+ if (wasPublished) {
526
+ const unregOut = takeOut((o) => o.satoshis === 1 &&
527
+ o.template === 'p2pkh' &&
528
+ (o.basket === OPNS_BASKET || !!o.basket?.includes('opns')) &&
529
+ !o.tags.includes('opns:published'));
530
+ if (unregOut) {
531
+ edges.push(edge('unregister', spend,
532
+ // Don't surface self-P2PKH address as a "To".
533
+ { ...unregOut, recipient: undefined }, name ? `Unpublish “${name}”` : 'Unpublish OpNS name', 'Remove identity bind'));
534
+ continue;
535
+ }
536
+ }
537
+ }
538
+ const next = takeOut((o) => (isCollectableBasket(o.basket) || o.satoshis === 1) &&
539
+ o.template !== 'ordlock');
540
+ if (!next) {
541
+ edges.push(edge('burn', spend, undefined, name ? `Burn “${name}”` : 'Burn collectable', 'No collectable output — tip ends'));
542
+ continue;
543
+ }
544
+ // transfer: external only when the collectable leaves the wallet basket
545
+ const external = Boolean(next.recipient) && !next.basket;
546
+ edges.push(edge('transfer', spend, external ? next : { ...next, recipient: undefined }, name
547
+ ? external
548
+ ? `Send “${name}”`
549
+ : `Move “${name}”`
550
+ : external
551
+ ? 'Send collectable'
552
+ : 'Move collectable', external && next.recipient
553
+ ? `To ${truncate(next.recipient, 18)}`
554
+ : 'Move collectable'));
555
+ }
556
+ // purchase / inscribe: collectable outs with no matching wallet spend
557
+ for (const out of outputs) {
558
+ if (usedOut.has(out.index))
559
+ continue;
560
+ if (!isCollectableBasket(out.basket) && out.template !== 'ordlock') {
561
+ // 1-sat sigma/inscribe without basket still counts
562
+ if (!(out.satoshis === 1 && (out.template === 'sigma' || out.sealPending))) {
563
+ continue;
564
+ }
565
+ }
566
+ if (out.template === 'ordlock')
567
+ continue;
568
+ const name = displayNameFrom(out.tags, out.customInstructions, out.opnsProfileName);
569
+ const origin = tagValue(out.tags, 'origin');
570
+ const hasSellerPay = outputs.some((o) => !o.basket && o.recipient && o.satoshis > 1);
571
+ const bareOrigin = out.tags.includes('origin');
572
+ if (hasSellerPay && collectableIns.length === 0) {
573
+ usedOut.add(out.index);
574
+ edges.push(edge('purchase', undefined, out, name ? `Buy “${name}”` : 'Buy collectable', 'Receive collectable from marketplace'));
575
+ continue;
576
+ }
577
+ if (collectableIns.length === 0 || bareOrigin || out.template === 'sigma') {
578
+ usedOut.add(out.index);
579
+ edges.push(edge('inscribe', undefined, out, name ? `Inscribe “${name}”` : 'Create inscription', out.sealPending
580
+ ? 'New origin (signature seal pending)'
581
+ : 'New collectable output'));
582
+ }
583
+ }
584
+ return edges;
585
+ }
586
+ function edge(operation, spend, create, title, summary) {
587
+ const name = displayNameFrom(create?.tags, create?.customInstructions, create?.opnsProfileName) ?? spend?.name;
588
+ const origin = tagValue(create?.tags, 'origin') ?? spend?.origin;
589
+ const contentType = contentTypeFromTags(create?.tags ?? spend?.tags);
590
+ return {
591
+ operation,
592
+ title,
593
+ summary,
594
+ ...(spend ? { spend } : {}),
595
+ ...(create
596
+ ? {
597
+ create: {
598
+ index: create.index,
599
+ satoshis: create.satoshis,
600
+ basket: create.basket,
601
+ tags: create.tags,
602
+ template: create.template,
603
+ sealPending: create.sealPending,
604
+ sealKind: create.sealKind,
605
+ recipient: create.recipient,
606
+ listingPriceSats: create.listingPriceSats,
607
+ listingSeller: create.listingSeller,
608
+ opnsProfileName: create.opnsProfileName,
609
+ opnsAvatarOrigin: create.opnsAvatarOrigin,
610
+ ...(name ? { name } : {}),
611
+ ...(origin ? { origin } : {}),
612
+ ...(contentType || create.inscriptionType
613
+ ? {
614
+ contentType: create.inscriptionType ?? contentType,
615
+ }
616
+ : {}),
617
+ ...(create.inscriptionText
618
+ ? { inscriptionText: create.inscriptionText }
619
+ : {}),
620
+ ...(create.inscriptionDataUrl
621
+ ? { inscriptionDataUrl: create.inscriptionDataUrl }
622
+ : {}),
623
+ },
624
+ }
625
+ : {}),
626
+ };
627
+ }
628
+ /** Prefer specific MIME (`image/png`) over bare category (`image`). */
629
+ function contentTypeFromTags(tags) {
630
+ if (!tags?.length)
631
+ return undefined;
632
+ const types = tags
633
+ .filter((t) => t.startsWith('type:'))
634
+ .map((t) => t.slice(5));
635
+ if (!types.length)
636
+ return undefined;
637
+ const specific = types.find((t) => t.includes('/'));
638
+ return specific ?? types[types.length - 1];
639
+ }
640
+ function buildLegs(inputs, outputs, ordinalEdges) {
641
+ const edgeOutIdx = new Set(ordinalEdges
642
+ .map((e) => e.create?.index)
643
+ .filter((i) => i != null));
644
+ const edgeInIds = new Set(ordinalEdges.map((e) => e.spend?.id).filter((id) => !!id));
645
+ const legs = [];
646
+ for (const [i, inp] of inputs.entries()) {
647
+ const name = displayNameFrom(inp.tags, inp.customInstructions);
648
+ const origin = tagValue(inp.tags, 'origin');
649
+ const listing = hasListingTags(inp.tags);
650
+ const inOrdinalEdge = edgeInIds.has(inp.id);
651
+ const tokenId = tagValue(inp.tags, 'bsv21');
652
+ const tokenAmt = tagValue(inp.tags, 'amt');
653
+ // sym/name-like fields: CI preserves case; tags are lowercased by BRC-100.
654
+ const tokenSym = displayFieldFrom('sym', inp.tags, inp.customInstructions);
655
+ legs.push({
656
+ side: 'input',
657
+ index: i,
658
+ satoshis: inp.satoshis,
659
+ template: listing
660
+ ? 'ordlock'
661
+ : tokenId || inp.basket === BSV21_BASKET
662
+ ? 'bsv21'
663
+ : 'unrecognized',
664
+ label: legLabelInput(inp, name, listing),
665
+ basket: inp.basket,
666
+ tags: inp.tags,
667
+ id: inp.id,
668
+ outpoint: inp.outpoint,
669
+ inOrdinalEdge,
670
+ ...(name ? { name } : {}),
671
+ ...(origin ? { origin } : {}),
672
+ ...(tokenId && tokenId !== 'deploy' ? { tokenId } : {}),
673
+ ...(tokenAmt ? { tokenAmt } : {}),
674
+ ...(tokenSym ? { tokenSym } : {}),
675
+ });
676
+ }
677
+ for (const out of outputs) {
678
+ const name = displayNameFrom(out.tags, out.customInstructions, out.opnsProfileName);
679
+ const origin = tagValue(out.tags, 'origin');
680
+ const inOrdinalEdge = edgeOutIdx.has(out.index);
681
+ legs.push({
682
+ side: 'output',
683
+ index: out.index,
684
+ satoshis: out.satoshis,
685
+ template: out.template ?? 'unrecognized',
686
+ label: legLabelOutput(out, name),
687
+ inOrdinalEdge,
688
+ ...(out.sealPending
689
+ ? { sealPending: true, sealKind: out.sealKind }
690
+ : {}),
691
+ basket: out.basket,
692
+ tags: out.tags,
693
+ recipient: out.recipient,
694
+ listingPriceSats: out.listingPriceSats,
695
+ listingSeller: out.listingSeller,
696
+ lockUntilHeight: out.lockUntilHeight,
697
+ opnsProfileName: out.opnsProfileName,
698
+ opnsAvatarOrigin: out.opnsAvatarOrigin,
699
+ ...(name ? { name } : {}),
700
+ ...(origin ? { origin } : {}),
701
+ ...(out.tokenId ? { tokenId: out.tokenId } : {}),
702
+ ...(out.tokenAmt ? { tokenAmt: out.tokenAmt } : {}),
703
+ ...(out.tokenSym ? { tokenSym: out.tokenSym } : {}),
704
+ ...(out.tokenOp ? { tokenOp: out.tokenOp } : {}),
705
+ ...(out.isIndexerFee ? { isIndexerFee: true } : {}),
706
+ });
707
+ }
708
+ return legs;
709
+ }
710
+ function legLabelInput(inp, name, listing) {
711
+ const what = name ? `“${name}”` : inp.basket;
712
+ if (listing)
713
+ return `Spend listing ${what}`;
714
+ return `Spend ${what} (${inp.satoshis} sats)`;
715
+ }
716
+ function legLabelOutput(out, name) {
717
+ const parts = [];
718
+ if (out.sealPending && out.sealKind === 'sigma') {
719
+ parts.push('Sign Sigma on output');
720
+ }
721
+ else if (out.sealPending && out.sealKind === 'pushdrop') {
722
+ parts.push('Sign PushDrop on output');
723
+ }
724
+ else if (out.template === 'ordlock' && out.listingPriceSats != null) {
725
+ parts.push(`List for ${out.listingPriceSats} sats`);
726
+ }
727
+ else if (out.template === 'lock' && out.lockUntilHeight != null) {
728
+ parts.push(`Lock until height ${out.lockUntilHeight}`);
729
+ }
730
+ else if (out.template === 'sigma') {
731
+ parts.push('Sigma-signed output');
732
+ }
733
+ else if (out.template === 'pushdrop') {
734
+ parts.push(name ? `PushDrop “${name}”` : 'PushDrop output');
735
+ }
736
+ else if (out.recipient) {
737
+ parts.push(`Pay ${truncate(out.recipient, 18)}`);
738
+ }
739
+ else if (out.basket) {
740
+ parts.push(`File to ${out.basket}`);
741
+ }
742
+ else {
743
+ parts.push(out.template === 'unrecognized'
744
+ ? 'Unrecognized script'
745
+ : 'Output');
746
+ }
747
+ if (name && !parts[0]?.includes(name))
748
+ parts.push(`“${name}”`);
749
+ parts.push(`${out.satoshis} sats`);
750
+ return parts.join(' · ');
241
751
  }
242
752
  // ---------------------------------------------------------------------------
243
753
  // Intent classification — driven by input asset basket membership.
@@ -245,30 +755,7 @@ function decodeOutput(out, index, chain) {
245
755
  function hasListingTags(tags) {
246
756
  return tags.some((t) => t === 'ordlock' || t.startsWith('price:'));
247
757
  }
248
- function detectKind(inputs, outputs, p1satIntent) {
249
- // Prefer explicit intent labels over heuristics.
250
- if (p1satIntent?.startsWith('opns.'))
251
- return 'opns';
252
- if (p1satIntent === 'ordinal.list' || p1satIntent === 'bsv21.list')
253
- return 'listing';
254
- if (p1satIntent === 'ordinal.cancel-listing' ||
255
- p1satIntent === 'opns.cancel-listing')
256
- return 'cancel-listing';
257
- if (p1satIntent === 'ordinal.purchase' ||
258
- p1satIntent === 'opns.purchase' ||
259
- p1satIntent === 'bsv21.purchase')
260
- return 'purchase';
261
- if (p1satIntent === 'ordinal.transfer')
262
- return 'ordinal-transfer';
263
- if (p1satIntent === 'bsv21.transfer')
264
- return 'token-transfer';
265
- if (p1satIntent === 'lock.lock')
266
- return 'lock';
267
- if (p1satIntent === 'lock.unlock')
268
- return 'unlock';
269
- if (p1satIntent === 'ordinal.inscribe' ||
270
- p1satIntent === 'ordinal.inscribe-sigma')
271
- return 'inscription';
758
+ function detectKind(inputs, outputs) {
272
759
  // Cancel listing: spending an ordlock-tagged input back to a P2PKH owner.
273
760
  // Listings live in the basket of the listed asset (ordinals, opns, …),
274
761
  // so the ordlock/price tags are the marker, not the basket.
@@ -309,12 +796,7 @@ function detectKind(inputs, outputs, p1satIntent) {
309
796
  return 'token-transfer';
310
797
  return 'unknown';
311
798
  }
312
- function buildSummary(kind, inputs, outputs, intentId) {
313
- if (intentId) {
314
- const named = summaryFromIntentId(intentId, inputs, outputs);
315
- if (named)
316
- return named;
317
- }
799
+ function buildSummary(kind, inputs, outputs) {
318
800
  switch (kind) {
319
801
  case 'ordinal-transfer': {
320
802
  const recipient = outputs.find((o) => o.recipient)?.recipient;
@@ -358,64 +840,22 @@ function buildSummary(kind, inputs, outputs, intentId) {
358
840
  case 'social-post':
359
841
  return `Create social post`;
360
842
  case 'opns': {
361
- const name = tagValue(inputs[0]?.tags, 'name') ??
362
- tagValue(outputs.find((o) => o.basket === OPNS_BASKET)?.tags, 'name');
363
- return name ? `Update OpNS name “${name}”` : 'OpNS operation';
843
+ const out = outputs.find((o) => o.basket === OPNS_BASKET);
844
+ const name = tagValue(inputs[0]?.tags, 'name') ?? tagValue(out?.tags, 'name');
845
+ const published = out?.tags.includes('opns:published');
846
+ if (published) {
847
+ return name ? `Publish name “${name}”` : 'Publish OpNS name';
848
+ }
849
+ return name ? `OpNS operation on “${name}”` : 'OpNS operation';
850
+ }
851
+ case 'purchase': {
852
+ const name = tagValue(outputs[0]?.tags, 'name') ?? tagValue(inputs[0]?.tags, 'name');
853
+ return name ? `Buy “${name}”` : 'Buy asset';
364
854
  }
365
855
  default:
366
856
  return `Approve transaction`;
367
857
  }
368
858
  }
369
- function summaryFromIntentId(intentId, inputs, outputs) {
370
- const name = tagValue(inputs[0]?.tags, 'name') ??
371
- tagValue(outputs.find((o) => o.basket === OPNS_BASKET)?.tags, 'name');
372
- const what = name ? `“${name}”` : undefined;
373
- switch (intentId) {
374
- case 'opns.register':
375
- return what ? `Publish name ${what}` : 'Publish OpNS name';
376
- case 'opns.deregister':
377
- return what ? `Unpublish name ${what}` : 'Unpublish OpNS name';
378
- case 'opns.list':
379
- return what ? `List name ${what} for sale` : 'List OpNS name for sale';
380
- case 'opns.transfer':
381
- return what ? `Transfer name ${what}` : 'Transfer OpNS name';
382
- case 'opns.cancel-listing':
383
- return what ? `Cancel listing of ${what}` : 'Cancel OpNS listing';
384
- case 'opns.purchase':
385
- return what ? `Buy name ${what}` : 'Buy OpNS name';
386
- case 'ordinal.transfer':
387
- return 'Send ordinal';
388
- case 'ordinal.list':
389
- return 'List ordinal for sale';
390
- case 'ordinal.cancel-listing':
391
- return 'Cancel ordinal listing';
392
- case 'ordinal.purchase':
393
- return 'Buy ordinal';
394
- case 'ordinal.burn':
395
- return 'Burn ordinal';
396
- case 'ordinal.inscribe':
397
- case 'ordinal.inscribe-sigma':
398
- return 'Create inscription';
399
- case 'ordinal.mint-collection':
400
- return 'Mint collection';
401
- case 'ordinal.mint-item':
402
- return 'Mint collection item';
403
- case 'lock.lock':
404
- return 'Lock BSV';
405
- case 'lock.unlock':
406
- return 'Unlock BSV';
407
- case 'bsv21.transfer':
408
- return 'Send tokens';
409
- case 'bsv21.purchase':
410
- return 'Buy tokens';
411
- case 'bsv21.mint':
412
- return 'Mint tokens';
413
- case 'bsv21.deploy':
414
- return 'Deploy token';
415
- default:
416
- return undefined;
417
- }
418
- }
419
859
  // ---------------------------------------------------------------------------
420
860
  // Helpers
421
861
  // ---------------------------------------------------------------------------
@@ -436,7 +876,6 @@ function truncate(s, max) {
436
876
  }
437
877
  function contentUrlFromOrigin(host, origin) {
438
878
  const trimmed = host.replace(/\/$/, '');
439
- const formatted = origin.replace('.', '_');
440
- return `${trimmed}/${formatted}`;
879
+ return `${trimmed}/${formatOrdinalOutpoint(origin)}`;
441
880
  }
442
881
  //# sourceMappingURL=enrichIntent.js.map