@misofm/platform 0.0.0 → 0.18.0

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 (168) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +686 -3
  3. package/dist/auth.d.ts +73 -0
  4. package/dist/auth.d.ts.map +1 -0
  5. package/dist/auth.js +193 -0
  6. package/dist/auth.js.map +1 -0
  7. package/dist/catalog.d.ts +48 -0
  8. package/dist/catalog.d.ts.map +1 -0
  9. package/dist/catalog.js +92 -0
  10. package/dist/catalog.js.map +1 -0
  11. package/dist/client.d.ts +462 -0
  12. package/dist/client.d.ts.map +1 -0
  13. package/dist/client.js +788 -0
  14. package/dist/client.js.map +1 -0
  15. package/dist/contracts.d.ts +34 -0
  16. package/dist/contracts.d.ts.map +1 -0
  17. package/dist/contracts.js +49 -0
  18. package/dist/contracts.js.map +1 -0
  19. package/dist/cover.d.ts +69 -0
  20. package/dist/cover.d.ts.map +1 -0
  21. package/dist/cover.js +132 -0
  22. package/dist/cover.js.map +1 -0
  23. package/dist/credits.d.ts +200 -0
  24. package/dist/credits.d.ts.map +1 -0
  25. package/dist/credits.js +465 -0
  26. package/dist/credits.js.map +1 -0
  27. package/dist/deployments.d.ts +224 -0
  28. package/dist/deployments.d.ts.map +1 -0
  29. package/dist/deployments.js +177 -0
  30. package/dist/deployments.js.map +1 -0
  31. package/dist/execute.d.ts +30 -0
  32. package/dist/execute.d.ts.map +1 -0
  33. package/dist/execute.js +31 -0
  34. package/dist/execute.js.map +1 -0
  35. package/dist/index.d.ts +21 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +52 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/internal.d.ts +13 -0
  40. package/dist/internal.d.ts.map +1 -0
  41. package/dist/internal.js +41 -0
  42. package/dist/internal.js.map +1 -0
  43. package/dist/mix.d.ts +144 -0
  44. package/dist/mix.d.ts.map +1 -0
  45. package/dist/mix.js +656 -0
  46. package/dist/mix.js.map +1 -0
  47. package/dist/pressing.d.ts +137 -0
  48. package/dist/pressing.d.ts.map +1 -0
  49. package/dist/pressing.js +390 -0
  50. package/dist/pressing.js.map +1 -0
  51. package/dist/publication.d.ts +216 -0
  52. package/dist/publication.d.ts.map +1 -0
  53. package/dist/publication.js +872 -0
  54. package/dist/publication.js.map +1 -0
  55. package/dist/queries.d.ts +2 -0
  56. package/dist/queries.d.ts.map +1 -0
  57. package/dist/queries.js +8 -0
  58. package/dist/queries.js.map +1 -0
  59. package/dist/read/artist.d.ts +18 -0
  60. package/dist/read/artist.d.ts.map +1 -0
  61. package/dist/read/artist.js +86 -0
  62. package/dist/read/artist.js.map +1 -0
  63. package/dist/read/catalog.d.ts +93 -0
  64. package/dist/read/catalog.d.ts.map +1 -0
  65. package/dist/read/catalog.js +441 -0
  66. package/dist/read/catalog.js.map +1 -0
  67. package/dist/read/client.d.ts +37 -0
  68. package/dist/read/client.d.ts.map +1 -0
  69. package/dist/read/client.js +50 -0
  70. package/dist/read/client.js.map +1 -0
  71. package/dist/read/config.d.ts +68 -0
  72. package/dist/read/config.d.ts.map +1 -0
  73. package/dist/read/config.js +63 -0
  74. package/dist/read/config.js.map +1 -0
  75. package/dist/read/genres.d.ts +7 -0
  76. package/dist/read/genres.d.ts.map +1 -0
  77. package/dist/read/genres.js +38 -0
  78. package/dist/read/genres.js.map +1 -0
  79. package/dist/read/index.d.ts +14 -0
  80. package/dist/read/index.d.ts.map +1 -0
  81. package/dist/read/index.js +23 -0
  82. package/dist/read/index.js.map +1 -0
  83. package/dist/read/internal/scalars.d.ts +23 -0
  84. package/dist/read/internal/scalars.d.ts.map +1 -0
  85. package/dist/read/internal/scalars.js +57 -0
  86. package/dist/read/internal/scalars.js.map +1 -0
  87. package/dist/read/internal/walrus.d.ts +6 -0
  88. package/dist/read/internal/walrus.d.ts.map +1 -0
  89. package/dist/read/internal/walrus.js +29 -0
  90. package/dist/read/internal/walrus.js.map +1 -0
  91. package/dist/read/receipts.d.ts +41 -0
  92. package/dist/read/receipts.d.ts.map +1 -0
  93. package/dist/read/receipts.js +398 -0
  94. package/dist/read/receipts.js.map +1 -0
  95. package/dist/read/types.d.ts +328 -0
  96. package/dist/read/types.d.ts.map +1 -0
  97. package/dist/read/types.js +21 -0
  98. package/dist/read/types.js.map +1 -0
  99. package/dist/read/wallet.d.ts +74 -0
  100. package/dist/read/wallet.d.ts.map +1 -0
  101. package/dist/read/wallet.js +578 -0
  102. package/dist/read/wallet.js.map +1 -0
  103. package/dist/read/works.d.ts +29 -0
  104. package/dist/read/works.d.ts.map +1 -0
  105. package/dist/read/works.js +191 -0
  106. package/dist/read/works.js.map +1 -0
  107. package/dist/recording-extensions.d.ts +62 -0
  108. package/dist/recording-extensions.d.ts.map +1 -0
  109. package/dist/recording-extensions.js +159 -0
  110. package/dist/recording-extensions.js.map +1 -0
  111. package/dist/release-extensions.d.ts +93 -0
  112. package/dist/release-extensions.d.ts.map +1 -0
  113. package/dist/release-extensions.js +190 -0
  114. package/dist/release-extensions.js.map +1 -0
  115. package/dist/release-graph.d.ts +65 -0
  116. package/dist/release-graph.d.ts.map +1 -0
  117. package/dist/release-graph.js +136 -0
  118. package/dist/release-graph.js.map +1 -0
  119. package/dist/share-template.d.ts +3 -0
  120. package/dist/share-template.d.ts.map +1 -0
  121. package/dist/share-template.js +46 -0
  122. package/dist/share-template.js.map +1 -0
  123. package/dist/share.d.ts +67 -0
  124. package/dist/share.d.ts.map +1 -0
  125. package/dist/share.js +157 -0
  126. package/dist/share.js.map +1 -0
  127. package/dist/transactions.d.ts +216 -0
  128. package/dist/transactions.d.ts.map +1 -0
  129. package/dist/transactions.js +314 -0
  130. package/dist/transactions.js.map +1 -0
  131. package/dist/vault.d.ts +364 -0
  132. package/dist/vault.d.ts.map +1 -0
  133. package/dist/vault.js +507 -0
  134. package/dist/vault.js.map +1 -0
  135. package/package.json +145 -5
  136. package/src/auth.ts +275 -0
  137. package/src/catalog.ts +189 -0
  138. package/src/client.ts +1320 -0
  139. package/src/contracts.ts +54 -0
  140. package/src/cover.ts +240 -0
  141. package/src/credits.ts +906 -0
  142. package/src/deployments.ts +357 -0
  143. package/src/execute.ts +52 -0
  144. package/src/index.ts +69 -0
  145. package/src/internal.ts +46 -0
  146. package/src/mix.ts +910 -0
  147. package/src/pressing.ts +705 -0
  148. package/src/publication.ts +1214 -0
  149. package/src/queries.ts +9 -0
  150. package/src/read/artist.ts +118 -0
  151. package/src/read/catalog.ts +668 -0
  152. package/src/read/client.ts +87 -0
  153. package/src/read/config.ts +138 -0
  154. package/src/read/genres.ts +37 -0
  155. package/src/read/index.ts +85 -0
  156. package/src/read/internal/scalars.ts +59 -0
  157. package/src/read/internal/walrus.ts +42 -0
  158. package/src/read/receipts.ts +476 -0
  159. package/src/read/types.ts +415 -0
  160. package/src/read/wallet.ts +687 -0
  161. package/src/read/works.ts +297 -0
  162. package/src/recording-extensions.ts +256 -0
  163. package/src/release-extensions.ts +311 -0
  164. package/src/release-graph.ts +219 -0
  165. package/src/share-template.ts +55 -0
  166. package/src/share.ts +261 -0
  167. package/src/transactions.ts +542 -0
  168. package/src/vault.ts +1009 -0
@@ -0,0 +1,687 @@
1
+ // Copyright (c) Miso Labs, Inc.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // Address-scoped reads: what a wallet owns, administers, and can spend.
5
+ //
6
+ // None of this is cacheable at a shared edge — every answer is different per
7
+ // address — but it belongs here anyway, because the VALUE of a single read layer
8
+ // is the response shape, not just the cache. The studio catalog's three-transport
9
+ // dance (owned caps over gRPC → share types over GraphQL → work contents over
10
+ // gRPC) is exactly the sort of thing that should exist once in the SDK rather
11
+ // than be reimplemented by every browser, Worker, server, or native client.
12
+
13
+ import { normalizeStructTag, normalizeSuiAddress, parseStructTag } from "@mysten/sui/utils";
14
+ import {
15
+ contracts as networkContracts,
16
+ getCompositionByShareType,
17
+ getOwnedReleaseAdminCaps,
18
+ getRecordingByShareType,
19
+ getReleaseById,
20
+ isNotFound,
21
+ } from "@misofm/protocol";
22
+ import type { MisoClient } from "./client.ts";
23
+ import { int, u64 } from "./internal/scalars.ts";
24
+ import * as vaultContract from "@misofm/protocol/contracts/vault/vault";
25
+ import * as recordContract from "@misofm/protocol/contracts/miso_record/record";
26
+ import { deriveRecordId } from "../pressing.ts";
27
+ import { requireRecordSalesDeployment } from "../deployments.ts";
28
+ import type {
29
+ Balance,
30
+ OwnedParty,
31
+ OwnedRecord,
32
+ OwnedWork,
33
+ Ownership,
34
+ PendingMembership,
35
+ WorkDetail,
36
+ } from "./types.ts";
37
+ import { getRecordingTitles, getWorkAddressesByShareTypes, getWorksByIds } from "./works.ts";
38
+
39
+ /**
40
+ * Page cap on the owned-objects scan, so a wallet holding a huge unrelated object
41
+ * set can't spin forever. 50/page × 20 = 1000 objects — far beyond any realistic
42
+ * library.
43
+ */
44
+ const MAX_PAGES = 20;
45
+
46
+ type WorkAdminCapType =
47
+ | { kind: "composition"; shareType: string }
48
+ | { kind: "recording"; shareType: string }
49
+ | { kind: "release" };
50
+
51
+ type VaultedGenericCap = {
52
+ id: string;
53
+ shareType: string;
54
+ vaultId: string;
55
+ };
56
+
57
+ type VaultedReleaseCap = {
58
+ id: string;
59
+ vaultId: string;
60
+ };
61
+
62
+ function classifyWorkAdminCapType(type: string, misoPackageId: string): WorkAdminCapType | null {
63
+ let tag: ReturnType<typeof parseStructTag>;
64
+ try {
65
+ tag = parseStructTag(type);
66
+ } catch {
67
+ return null;
68
+ }
69
+ if (tag.address !== misoPackageId) return null;
70
+
71
+ if (
72
+ tag.module === "composition" &&
73
+ tag.name === "CompositionAdminCap" &&
74
+ tag.typeParams.length === 1
75
+ ) {
76
+ const share = tag.typeParams[0]!;
77
+ return {
78
+ kind: "composition",
79
+ shareType: typeof share === "string" ? share : normalizeStructTag(share),
80
+ };
81
+ }
82
+ if (
83
+ tag.module === "recording" &&
84
+ tag.name === "RecordingAdminCap" &&
85
+ tag.typeParams.length === 1
86
+ ) {
87
+ const share = tag.typeParams[0]!;
88
+ return {
89
+ kind: "recording",
90
+ shareType: typeof share === "string" ? share : normalizeStructTag(share),
91
+ };
92
+ }
93
+ if (
94
+ tag.module === "release" &&
95
+ tag.name === "ReleaseAdminCap" &&
96
+ tag.typeParams.length === 0
97
+ ) {
98
+ return { kind: "release" };
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /** @internal Exact nested-type classifier used by the vault-aware catalog read. */
104
+ export function classifyVaultedWorkAdminCapType(
105
+ type: string,
106
+ vaultPackageId: string,
107
+ misoPackageId: string,
108
+ ): WorkAdminCapType | null {
109
+ let tag: ReturnType<typeof parseStructTag>;
110
+ try {
111
+ tag = parseStructTag(type);
112
+ } catch {
113
+ return null;
114
+ }
115
+ if (
116
+ tag.address !== vaultPackageId ||
117
+ tag.module !== "vault" ||
118
+ tag.name !== "VaultAdminCap" ||
119
+ tag.typeParams.length !== 1
120
+ ) {
121
+ return null;
122
+ }
123
+ const wrapped = tag.typeParams[0];
124
+ return wrapped && typeof wrapped !== "string"
125
+ ? classifyWorkAdminCapType(normalizeStructTag(wrapped), misoPackageId)
126
+ : null;
127
+ }
128
+
129
+ // ── Records ──────────────────────────────────────────────────────────────────
130
+
131
+ /**
132
+ * A wallet record is trusted only when it is the exact, non-generic Record type
133
+ * from the configured package. Checking the parsed tag (rather than a prefix or
134
+ * suffix) rejects retired namespaces and legacy/arbitrary generic wrappers.
135
+ */
136
+ function isCanonicalRecordType(
137
+ type: string,
138
+ recordPackageId: string,
139
+ ): boolean {
140
+ let tag: ReturnType<typeof parseStructTag>;
141
+ try {
142
+ tag = parseStructTag(type);
143
+ } catch {
144
+ return false;
145
+ }
146
+
147
+ if (
148
+ tag.address !== normalizeSuiAddress(recordPackageId) ||
149
+ tag.module !== "record" ||
150
+ tag.name !== "Record" ||
151
+ tag.typeParams.length !== 0
152
+ ) {
153
+ return false;
154
+ }
155
+ return true;
156
+ }
157
+
158
+ /**
159
+ * The records `owner` holds. Ownership is DIRECT — a record is an address-owned
160
+ * `<miso_record>::record::Record` with no pressing/license/receipt intermediary.
161
+ * The server-side type filter and exact local check deliberately exclude records
162
+ * from any retired package namespace.
163
+ */
164
+ export async function getOwnedRecords(client: MisoClient, owner: string): Promise<OwnedRecord[]> {
165
+ const out: OwnedRecord[] = [];
166
+ let cursor: string | null = null;
167
+ const sales = requireRecordSalesDeployment(client.config.recordSales);
168
+ const recordType = `${sales.recordPackageId}::record::Record`;
169
+
170
+ for (let page = 0; page < MAX_PAGES; page++) {
171
+ // Annotated: `cursor` is both an input and assigned from the result, which
172
+ // TypeScript reads as a circular initializer without an explicit type.
173
+ const res: Awaited<ReturnType<typeof client.protocol.core.listOwnedObjects>> =
174
+ await client.protocol.core.listOwnedObjects({
175
+ owner,
176
+ type: recordType,
177
+ cursor,
178
+ limit: 50,
179
+ include: { content: true },
180
+ });
181
+ for (const obj of res.objects) {
182
+ if (!isCanonicalRecordType(obj.type, sales.recordPackageId)) {
183
+ continue;
184
+ }
185
+ if (!obj.content) throw new Error(`Record ${obj.objectId} has no BCS content`);
186
+ const record = recordContract.Record.parse(obj.content);
187
+ if (normalizeSuiAddress(record.id) !== normalizeSuiAddress(obj.objectId)) {
188
+ throw new Error(`Record ${obj.objectId} has mismatched embedded UID ${record.id}`);
189
+ }
190
+ const derived = deriveRecordId(record.pressing_id, record.number, sales.recordPackageId);
191
+ if (normalizeSuiAddress(derived) !== normalizeSuiAddress(obj.objectId)) {
192
+ throw new Error(`Record ${obj.objectId} is not derived from its Pressing and number`);
193
+ }
194
+ out.push({
195
+ id: obj.objectId,
196
+ type: obj.type,
197
+ releaseId: record.release_id,
198
+ pressingId: record.pressing_id,
199
+ edition: record.edition,
200
+ number: record.number,
201
+ purchaseCurrency: normalizeStructTag(record.purchase_currency.name),
202
+ purchasePrice: record.purchase_price,
203
+ purchasedBy: record.purchased_by,
204
+ purchasedTimestampMs: record.purchased_timestamp_ms,
205
+ });
206
+ }
207
+ if (!res.hasNextPage) break;
208
+ cursor = res.cursor;
209
+ if (!cursor) break;
210
+ }
211
+ return out;
212
+ }
213
+
214
+ // ── Parties ──────────────────────────────────────────────────────────────────
215
+
216
+ /**
217
+ * The parties `owner` administers. A wallet "owns" a party iff it holds the
218
+ * party's `PartyAdminCap`, so this is one owned-objects listing filtered to the
219
+ * cap type plus a name resolve. Caps are transferable, which is why this is
220
+ * authoritative in a way that remembering created parties client-side is not.
221
+ */
222
+ export async function getOwnedParties(client: MisoClient, owner: string): Promise<OwnedParty[]> {
223
+ const capType = `${client.config.deployment.misoParty}::party::PartyAdminCap`;
224
+
225
+ // One page of 50 caps is plenty for launch-scale artists; paginate if labels
226
+ // ever start hitting the cap.
227
+ const { objects } = await client.protocol.core.listOwnedObjects({
228
+ owner,
229
+ type: capType,
230
+ limit: 50,
231
+ include: { content: true },
232
+ });
233
+
234
+ const caps = objects.flatMap((obj) => {
235
+ try {
236
+ const cap = networkContracts.party.PartyAdminCap.parse(obj.content);
237
+ return [{ capId: obj.objectId, partyId: cap.party_id }];
238
+ } catch {
239
+ return [];
240
+ }
241
+ });
242
+ if (caps.length === 0) return [];
243
+
244
+ const parties = await client.sui.miso.party.getPartiesByIds(caps.map((c) => c.partyId));
245
+ return caps.flatMap(({ capId, partyId }) => {
246
+ const p = parties[partyId];
247
+ return p ? [{ partyId, capId, name: p.name, kind: p.kind }] : [];
248
+ });
249
+ }
250
+
251
+ /**
252
+ * Pending group invitations for every individual party the wallet administers.
253
+ *
254
+ * The Party module maintains a member-side pending-membership index, so this
255
+ * reads only the wallet's controlled parties — never a global event scan. Group
256
+ * names are resolved in one batch so the API can render an inbox without extra
257
+ * browser reads.
258
+ */
259
+ export async function getPendingMemberships(
260
+ client: MisoClient,
261
+ owner: string,
262
+ ): Promise<PendingMembership[]> {
263
+ const controlled = await getOwnedParties(client, owner);
264
+ const individuals = controlled.filter((party) => party.kind === "individual");
265
+ if (individuals.length === 0) return [];
266
+
267
+ const invitations = await Promise.all(
268
+ individuals.map(async (member) => ({
269
+ member,
270
+ groupIds: await client.sui.miso.party.getPendingMemberships(member.partyId),
271
+ })),
272
+ );
273
+ const groupIds = [...new Set(invitations.flatMap(({ groupIds }) => groupIds))];
274
+ if (groupIds.length === 0) return [];
275
+
276
+ const groups = await client.sui.miso.party.getPartiesByIds(groupIds);
277
+ return invitations.flatMap(({ member, groupIds }) =>
278
+ groupIds.flatMap((groupId): PendingMembership[] => {
279
+ const group = groups[groupId];
280
+ return group?.kind === "group"
281
+ ? [{ memberPartyId: member.partyId, memberCapId: member.capId, groupId, groupName: group.name }]
282
+ : [];
283
+ }),
284
+ );
285
+ }
286
+
287
+ // ── Works (studio catalog) ───────────────────────────────────────────────────
288
+
289
+ /**
290
+ * Owned generic admin caps over gRPC. The bare type filter matches every
291
+ * instantiation of `CompositionAdminCap<T>` / `RecordingAdminCap<T>`; the share
292
+ * type `T` is parsed back out of each instance's type tag, because the cap does
293
+ * not store the work's id — only its share type.
294
+ */
295
+ async function ownedGenericCaps(client: MisoClient, owner: string, capType: string) {
296
+ const caps: { id: string; shareType: string }[] = [];
297
+ let cursor: string | null = null;
298
+ for (let page = 0; page < MAX_PAGES; page++) {
299
+ const res: Awaited<ReturnType<typeof client.protocol.core.listOwnedObjects>> =
300
+ await client.protocol.core.listOwnedObjects({ owner, type: capType, cursor, limit: 50 });
301
+ for (const obj of res.objects) {
302
+ const match = /<(.+)>$/.exec(obj.type);
303
+ if (match?.[1]) caps.push({ id: obj.objectId, shareType: match[1] });
304
+ }
305
+ if (!res.hasNextPage) break;
306
+ cursor = res.cursor;
307
+ if (!cursor) break;
308
+ }
309
+ return caps;
310
+ }
311
+
312
+ /**
313
+ * Discover owner-held VaultAdminCaps whose wrapped capability administers a
314
+ * catalog work. PressingAdminCap is deliberately ignored: Catalog is a work
315
+ * surface (composition / recording / release), not a sale-management surface.
316
+ */
317
+ async function ownedVaultedWorkCaps(client: MisoClient, owner: string) {
318
+ const out: {
319
+ compositions: VaultedGenericCap[];
320
+ recordings: VaultedGenericCap[];
321
+ releases: VaultedReleaseCap[];
322
+ } = { compositions: [], recordings: [], releases: [] };
323
+ const vaultPackageId = client.config.protocol.vault;
324
+ const misoPackageId = client.config.deployment.miso;
325
+ const capType = `${vaultPackageId}::vault::VaultAdminCap`;
326
+ let cursor: string | null = null;
327
+
328
+ for (let page = 0; page < MAX_PAGES; page++) {
329
+ const res: Awaited<ReturnType<typeof client.protocol.core.listOwnedObjects>> =
330
+ await client.protocol.core.listOwnedObjects({
331
+ owner,
332
+ type: capType,
333
+ cursor,
334
+ limit: 50,
335
+ include: { content: true },
336
+ });
337
+ for (const object of res.objects) {
338
+ if (!object.content) continue;
339
+ const work = classifyVaultedWorkAdminCapType(
340
+ object.type,
341
+ vaultPackageId,
342
+ misoPackageId,
343
+ );
344
+ if (!work) continue;
345
+ try {
346
+ const { vault_id: vaultId } = vaultContract.VaultAdminCap.parse(object.content);
347
+ if (work.kind === "composition") {
348
+ out.compositions.push({ id: object.objectId, shareType: work.shareType, vaultId });
349
+ } else if (work.kind === "recording") {
350
+ out.recordings.push({ id: object.objectId, shareType: work.shareType, vaultId });
351
+ } else {
352
+ out.releases.push({ id: object.objectId, vaultId });
353
+ }
354
+ } catch {
355
+ // A malformed object under the configured vault namespace is not usable
356
+ // authority and should not hide otherwise valid catalog entries.
357
+ }
358
+ }
359
+ if (!res.hasNextPage) break;
360
+ cursor = res.cursor;
361
+ if (!cursor) break;
362
+ }
363
+ return out;
364
+ }
365
+
366
+ /** Read the ReleaseAdminCap wrapped by each shared release vault. */
367
+ async function resolveVaultedReleaseCaps(
368
+ client: MisoClient,
369
+ caps: readonly VaultedReleaseCap[],
370
+ ): Promise<{ id: string; releaseId: string }[]> {
371
+ if (caps.length === 0) return [];
372
+ const releaseCapType = `${client.config.deployment.miso}::release::ReleaseAdminCap`;
373
+ const vaultType = normalizeStructTag(
374
+ `${client.config.protocol.vault}::vault::Vault<${releaseCapType}>`,
375
+ );
376
+ const { objects } = await client.protocol.core.getObjects({
377
+ objectIds: caps.map((cap) => cap.vaultId),
378
+ include: { content: true },
379
+ });
380
+ const releasesByVault = new Map<string, string>();
381
+ for (const object of objects) {
382
+ if (object instanceof Error || !object.content) continue;
383
+ try {
384
+ if (normalizeStructTag(object.type) !== vaultType) continue;
385
+ const vault = vaultContract.Vault(networkContracts.release.ReleaseAdminCap).parse(
386
+ object.content,
387
+ );
388
+ const releaseId = vault.cap?.value?.release_id;
389
+ if (releaseId) releasesByVault.set(object.objectId, releaseId);
390
+ } catch {
391
+ // Batch catalog discovery is best-effort per authority, matching the
392
+ // existing direct-cap path's treatment of unreadable work objects.
393
+ }
394
+ }
395
+ return caps.flatMap((cap) => {
396
+ const releaseId = releasesByVault.get(cap.vaultId);
397
+ return releaseId ? [{ id: cap.id, releaseId }] : [];
398
+ });
399
+ }
400
+
401
+ /**
402
+ * Every work the wallet administers, keyed by its ADMIN CAP id (the studio
403
+ * catalog's routing unit — caps are what control means here).
404
+ *
405
+ * Three transports, one answer: direct or vaulted cap discovery is gRPC; one aliased GraphQL
406
+ * request maps every share type to its work address (gRPC cannot ask "which
407
+ * object has type X"); one gRPC batch loads all the work contents. Releases skip
408
+ * the GraphQL hop entirely — `ReleaseAdminCap` is not generic and carries
409
+ * `release_id` directly.
410
+ */
411
+ export async function getOwnedWorks(client: MisoClient, owner: string): Promise<OwnedWork[]> {
412
+ const miso = client.config.deployment.miso;
413
+
414
+ const [directCompCaps, directRecCaps, directRelCaps, vaulted] = await Promise.all([
415
+ ownedGenericCaps(client, owner, `${miso}::composition::CompositionAdminCap`),
416
+ ownedGenericCaps(client, owner, `${miso}::recording::RecordingAdminCap`),
417
+ getOwnedReleaseAdminCaps(client.protocol, owner, miso),
418
+ ownedVaultedWorkCaps(client, owner),
419
+ ]);
420
+ const vaultedRelCaps = await resolveVaultedReleaseCaps(client, vaulted.releases);
421
+ const compCaps = [...directCompCaps, ...vaulted.compositions];
422
+ const recCaps = [...directRecCaps, ...vaulted.recordings];
423
+ const relCaps = [...directRelCaps, ...vaultedRelCaps];
424
+
425
+ const addresses = await getWorkAddressesByShareTypes(
426
+ client.graphql,
427
+ { compositions: compCaps.map((c) => c.shareType), recordings: recCaps.map((c) => c.shareType) },
428
+ miso,
429
+ );
430
+ const works = await getWorksByIds(client.protocol, {
431
+ compositions: Object.values(addresses.compositions).filter((id): id is string => id !== undefined),
432
+ recordings: Object.values(addresses.recordings).filter((id): id is string => id !== undefined),
433
+ releases: relCaps.map((c) => c.releaseId),
434
+ });
435
+ const recordingTitles = await getRecordingTitles(
436
+ client.protocol,
437
+ client.graphql,
438
+ Object.values(addresses.recordings).filter((id): id is string => id !== undefined),
439
+ miso,
440
+ );
441
+
442
+ const comps = compCaps.flatMap((cap): OwnedWork[] => {
443
+ const workId = addresses.compositions[cap.shareType];
444
+ const composition = workId ? works.compositions[workId] : undefined;
445
+ if (!workId || !composition) return [];
446
+ return [{ capId: cap.id, kind: "composition", workId, title: composition.title, state: composition.state.type }];
447
+ });
448
+ const recs = recCaps.flatMap((cap): OwnedWork[] => {
449
+ const workId = addresses.recordings[cap.shareType];
450
+ const recording = workId ? works.recordings[workId] : undefined;
451
+ if (!workId || !recording) return [];
452
+ return [{
453
+ capId: cap.id,
454
+ kind: "recording",
455
+ workId,
456
+ title: recordingTitles[workId] ?? "Untitled",
457
+ state: recording.state.type,
458
+ }];
459
+ });
460
+ const rels = relCaps.flatMap((cap): OwnedWork[] => {
461
+ const release = works.releases[cap.releaseId];
462
+ if (!release) return [];
463
+ return [{ capId: cap.id, kind: "release", workId: release.id, title: release.title, state: release.state.type }];
464
+ });
465
+
466
+ return [...comps, ...recs, ...rels];
467
+ }
468
+
469
+ /** Classify a direct or vaulted cap by its on-chain type, then resolve its work. */
470
+ export async function getWorkByCap(client: MisoClient, capId: string): Promise<WorkDetail | null> {
471
+ const miso = client.config.deployment.miso;
472
+
473
+ let type: string;
474
+ let json: Record<string, unknown> | null;
475
+ let content: Uint8Array | undefined;
476
+ try {
477
+ const { object } = await client.protocol.core.getObject({
478
+ objectId: capId,
479
+ include: { json: true, content: true },
480
+ });
481
+ type = object.type;
482
+ json = (object.json ?? null) as Record<string, unknown> | null;
483
+ content = object.content;
484
+ } catch (e) {
485
+ if (isNotFound(e)) return null;
486
+ throw e;
487
+ }
488
+
489
+ const direct = classifyWorkAdminCapType(type, miso);
490
+ if (direct?.kind === "composition") {
491
+ const { shareType } = direct;
492
+ const c = await getCompositionByShareType(client.protocol, client.graphql, shareType, miso);
493
+ return {
494
+ capId,
495
+ kind: "composition",
496
+ workId: c.id,
497
+ title: c.title,
498
+ state: c.state.type,
499
+ royaltyRateBps: int(c.royaltyRate.value),
500
+ shareType,
501
+ };
502
+ }
503
+
504
+ if (direct?.kind === "recording") {
505
+ const { shareType } = direct;
506
+ const r = await getRecordingByShareType(client.protocol, client.graphql, shareType, miso);
507
+ const titles = await getRecordingTitles(client.protocol, client.graphql, [r.id], miso);
508
+ return {
509
+ capId,
510
+ kind: "recording",
511
+ workId: r.id,
512
+ title: titles[r.id] ?? "Untitled",
513
+ state: r.state.type,
514
+ shareType,
515
+ };
516
+ }
517
+
518
+ if (direct?.kind === "release") {
519
+ const releaseId = typeof json?.release_id === "string" ? json.release_id : null;
520
+ if (!releaseId) throw new Error(`Release cap ${capId} carries no release id`);
521
+ const r = await getReleaseById(client.protocol, releaseId);
522
+ return {
523
+ capId,
524
+ kind: "release",
525
+ workId: r.id,
526
+ title: r.title,
527
+ state: r.state.type,
528
+ discCount: r.tracks.length > 0 ? 1 : 0,
529
+ trackCount: r.tracks.length,
530
+ };
531
+ }
532
+
533
+ const vaulted = classifyVaultedWorkAdminCapType(
534
+ type,
535
+ client.config.protocol.vault,
536
+ miso,
537
+ );
538
+ if (!vaulted || !content) {
539
+ // A real object, but not a supported work authority — "no such work",
540
+ // not a transport failure. PressingAdminCap intentionally lands here.
541
+ return null;
542
+ }
543
+ const { vault_id: vaultId } = vaultContract.VaultAdminCap.parse(content);
544
+
545
+ if (vaulted.kind === "composition") {
546
+ const c = await getCompositionByShareType(
547
+ client.protocol,
548
+ client.graphql,
549
+ vaulted.shareType,
550
+ miso,
551
+ );
552
+ return {
553
+ capId,
554
+ kind: "composition",
555
+ workId: c.id,
556
+ title: c.title,
557
+ state: c.state.type,
558
+ royaltyRateBps: int(c.royaltyRate.value),
559
+ shareType: vaulted.shareType,
560
+ };
561
+ }
562
+
563
+ if (vaulted.kind === "recording") {
564
+ const r = await getRecordingByShareType(
565
+ client.protocol,
566
+ client.graphql,
567
+ vaulted.shareType,
568
+ miso,
569
+ );
570
+ const titles = await getRecordingTitles(client.protocol, client.graphql, [r.id], miso);
571
+ return {
572
+ capId,
573
+ kind: "recording",
574
+ workId: r.id,
575
+ title: titles[r.id] ?? "Untitled",
576
+ state: r.state.type,
577
+ shareType: vaulted.shareType,
578
+ };
579
+ }
580
+
581
+ const [releaseCap] = await resolveVaultedReleaseCaps(client, [{ id: capId, vaultId }]);
582
+ if (!releaseCap) throw new Error(`Release vault ${vaultId} carries no release admin cap`);
583
+ const r = await getReleaseById(client.protocol, releaseCap.releaseId);
584
+ return {
585
+ capId,
586
+ kind: "release",
587
+ workId: r.id,
588
+ title: r.title,
589
+ state: r.state.type,
590
+ discCount: r.tracks.length > 0 ? 1 : 0,
591
+ trackCount: r.tracks.length,
592
+ };
593
+ }
594
+
595
+ // ── Balance ──────────────────────────────────────────────────────────────────
596
+
597
+ const decimalsByClient = new WeakMap<MisoClient, Map<string, Promise<number>>>();
598
+
599
+ /** Resolve once per client/type. Failed lookups are evicted so a transient
600
+ * fullnode error cannot poison a long-lived API worker. */
601
+ async function coinDecimals(client: MisoClient, coinType: string): Promise<number> {
602
+ let cache = decimalsByClient.get(client);
603
+ if (!cache) {
604
+ cache = new Map();
605
+ decimalsByClient.set(client, cache);
606
+ }
607
+ const key = normalizeStructTag(coinType);
608
+ const cached = cache.get(key);
609
+ if (cached) return cached;
610
+
611
+ const pending = client.protocol.core.getCoinMetadata({ coinType: key }).then(({ coinMetadata }) => {
612
+ const decimals = coinMetadata?.decimals;
613
+ if (!Number.isSafeInteger(decimals) || decimals! < 0 || decimals! > 18) {
614
+ throw new Error(`Coin metadata for ${key} has no supported decimal precision`);
615
+ }
616
+ return decimals!;
617
+ });
618
+ cache.set(key, pending);
619
+ try {
620
+ return await pending;
621
+ } catch (error) {
622
+ if (cache.get(key) === pending) cache.delete(key);
623
+ throw error;
624
+ }
625
+ }
626
+
627
+ /**
628
+ * A wallet's balance in one currency. Keep the aggregate and both Sui storage
629
+ * classes: callers using a `FundsWithdrawal` must never mistake coin-object
630
+ * value for immediately withdrawable address balance.
631
+ */
632
+ export async function getBalance(client: MisoClient, address: string, coinType?: string): Promise<Balance> {
633
+ const type = coinType ?? client.config.money.usdCoinType;
634
+ const [res, decimals] = await Promise.all([
635
+ client.protocol.core.getBalance({ owner: address, coinType: type }),
636
+ coinDecimals(client, type),
637
+ ]);
638
+ return {
639
+ address: normalizeSuiAddress(address),
640
+ coinType: type,
641
+ balance: u64(res.balance.balance),
642
+ coinBalance: u64(res.balance.coinBalance),
643
+ addressBalance: u64(res.balance.addressBalance),
644
+ decimals,
645
+ };
646
+ }
647
+
648
+ // ── Ownership ────────────────────────────────────────────────────────────────
649
+
650
+ function isAddressOwner(owner: unknown, address: string): boolean {
651
+ const o = owner as { $kind?: string; AddressOwner?: string } | undefined;
652
+ return (
653
+ o?.$kind === "AddressOwner" && !!o.AddressOwner && normalizeSuiAddress(o.AddressOwner) === normalizeSuiAddress(address)
654
+ );
655
+ }
656
+
657
+ /**
658
+ * Whether `address` controls a party — i.e. holds its `PartyAdminCap`. The cap is
659
+ * a DERIVED object of the party, so this is one `getObject` on a computed id, no
660
+ * search. The chain enforces writes regardless; this drives the UI and hands back
661
+ * the cap id that owner-gated writes need.
662
+ *
663
+ * A failed read means "can't confirm", which resolves to `false` — the editor
664
+ * stays hidden rather than being offered and then rejected on submit.
665
+ */
666
+ export async function ownsParty(client: MisoClient, address: string, partyId: string): Promise<Ownership> {
667
+ const capId = client.sui.miso.party.derivePartyAdminCapId(partyId);
668
+ const isOwner = await client.protocol.core
669
+ .getObject({ objectId: capId })
670
+ .then(({ object }) => isAddressOwner(object?.owner, address))
671
+ .catch(() => false);
672
+ return { address: normalizeSuiAddress(address), objectId: partyId, isOwner, capId };
673
+ }
674
+
675
+ /**
676
+ * Whether `address` owns a record. Records are address-owned objects, so this is
677
+ * one `getObject` and an owner compare. Presentation gating only — it decides
678
+ * whether the Snapshots tab appears, and a non-owner has no snapshot content to
679
+ * fetch regardless.
680
+ */
681
+ export async function ownsRecord(client: MisoClient, address: string, recordId: string): Promise<Ownership> {
682
+ const isOwner = await client.protocol.core
683
+ .getObject({ objectId: recordId })
684
+ .then(({ object }) => isAddressOwner(object?.owner, address))
685
+ .catch(() => false);
686
+ return { address: normalizeSuiAddress(address), objectId: recordId, isOwner };
687
+ }