@mysten/pas 0.0.1

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 (62) hide show
  1. package/README.md +1 -0
  2. package/dist/client.d.mts +117 -0
  3. package/dist/client.d.mts.map +1 -0
  4. package/dist/client.mjs +89 -0
  5. package/dist/client.mjs.map +1 -0
  6. package/dist/constants.mjs +9 -0
  7. package/dist/constants.mjs.map +1 -0
  8. package/dist/contracts/pas/deps/std/type_name.mjs +17 -0
  9. package/dist/contracts/pas/deps/std/type_name.mjs.map +1 -0
  10. package/dist/contracts/pas/deps/sui/vec_map.mjs +37 -0
  11. package/dist/contracts/pas/deps/sui/vec_map.mjs.map +1 -0
  12. package/dist/contracts/pas/deps/sui/vec_set.mjs +26 -0
  13. package/dist/contracts/pas/deps/sui/vec_set.mjs.map +1 -0
  14. package/dist/contracts/pas/policy.mjs +33 -0
  15. package/dist/contracts/pas/policy.mjs.map +1 -0
  16. package/dist/contracts/pas/versioning.mjs +25 -0
  17. package/dist/contracts/pas/versioning.mjs.map +1 -0
  18. package/dist/contracts/ptb/ptb.mjs +162 -0
  19. package/dist/contracts/ptb/ptb.mjs.map +1 -0
  20. package/dist/contracts/sui/dynamic_field.mjs +22 -0
  21. package/dist/contracts/sui/dynamic_field.mjs.map +1 -0
  22. package/dist/contracts/utils/index.mjs +37 -0
  23. package/dist/contracts/utils/index.mjs.map +1 -0
  24. package/dist/derivation.mjs +70 -0
  25. package/dist/derivation.mjs.map +1 -0
  26. package/dist/error.d.mts +16 -0
  27. package/dist/error.d.mts.map +1 -0
  28. package/dist/error.mjs +26 -0
  29. package/dist/error.mjs.map +1 -0
  30. package/dist/index.d.mts +4 -0
  31. package/dist/index.mjs +4 -0
  32. package/dist/intents.mjs +494 -0
  33. package/dist/intents.mjs.map +1 -0
  34. package/dist/resolution.mjs +185 -0
  35. package/dist/resolution.mjs.map +1 -0
  36. package/dist/types.d.mts +34 -0
  37. package/dist/types.d.mts.map +1 -0
  38. package/package.json +59 -0
  39. package/src/client.ts +173 -0
  40. package/src/constants.ts +15 -0
  41. package/src/contracts/pas/account.ts +343 -0
  42. package/src/contracts/pas/clawback_funds.ts +114 -0
  43. package/src/contracts/pas/deps/std/type_name.ts +24 -0
  44. package/src/contracts/pas/deps/sui/vec_map.ts +33 -0
  45. package/src/contracts/pas/deps/sui/vec_set.ts +22 -0
  46. package/src/contracts/pas/keys.ts +90 -0
  47. package/src/contracts/pas/namespace.ts +207 -0
  48. package/src/contracts/pas/policy.ts +212 -0
  49. package/src/contracts/pas/request.ts +87 -0
  50. package/src/contracts/pas/send_funds.ts +174 -0
  51. package/src/contracts/pas/templates.ts +101 -0
  52. package/src/contracts/pas/unlock_funds.ts +155 -0
  53. package/src/contracts/pas/versioning.ts +69 -0
  54. package/src/contracts/ptb/ptb.ts +821 -0
  55. package/src/contracts/sui/dynamic_field.ts +171 -0
  56. package/src/contracts/utils/index.ts +235 -0
  57. package/src/derivation.ts +107 -0
  58. package/src/error.ts +29 -0
  59. package/src/index.ts +6 -0
  60. package/src/intents.ts +852 -0
  61. package/src/resolution.ts +294 -0
  62. package/src/types.ts +34 -0
package/src/intents.ts ADDED
@@ -0,0 +1,852 @@
1
+ // Copyright (c) Mysten Labs, Inc.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { bcs } from '@mysten/sui/bcs';
5
+ import type { ClientWithCoreApi, SuiClientTypes } from '@mysten/sui/client';
6
+ import { Inputs, Transaction, TransactionCommands } from '@mysten/sui/transactions';
7
+ import type {
8
+ Argument,
9
+ CallArg,
10
+ Command,
11
+ TransactionDataBuilder,
12
+ TransactionPlugin,
13
+ TransactionResult,
14
+ } from '@mysten/sui/transactions';
15
+ import { normalizeStructTag } from '@mysten/sui/utils';
16
+
17
+ import {
18
+ deriveAccountAddress,
19
+ derivePolicyAddress,
20
+ deriveTemplateAddress,
21
+ deriveTemplateRegistryAddress,
22
+ } from './derivation.js';
23
+ import { InvalidObjectOwnershipError, PASClientError, PolicyNotFoundError } from './error.js';
24
+ import {
25
+ buildMoveCallCommandFromTemplate,
26
+ collectTemplateObjectIds,
27
+ getCommandFromTemplate,
28
+ getRequiredApprovals,
29
+ PASActionType,
30
+ } from './resolution.js';
31
+ import type { PASPackageConfig } from './types.js';
32
+
33
+ const PAS_INTENT_NAME = 'PAS';
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Intent data types
37
+ // ---------------------------------------------------------------------------
38
+
39
+ type SendBalanceIntentData = {
40
+ action: 'sendBalance';
41
+ from: string;
42
+ to: string;
43
+ amount: string;
44
+ assetType: string;
45
+ cfg: PASPackageConfig;
46
+ };
47
+
48
+ type UnlockBalanceIntentData = {
49
+ action: 'unlockBalance';
50
+ from: string;
51
+ amount: string;
52
+ assetType: string;
53
+ cfg: PASPackageConfig;
54
+ };
55
+
56
+ type UnlockUnrestrictedBalanceIntentData = {
57
+ action: 'unlockUnrestrictedBalance';
58
+ from: string;
59
+ amount: string;
60
+ assetType: string;
61
+ cfg: PASPackageConfig;
62
+ };
63
+
64
+ type AccountForAddressIntentData = {
65
+ action: 'accountForAddress';
66
+ owner: string;
67
+ cfg: PASPackageConfig;
68
+ };
69
+
70
+ type PASIntentData =
71
+ | SendBalanceIntentData
72
+ | UnlockBalanceIntentData
73
+ | UnlockUnrestrictedBalanceIntentData
74
+ | AccountForAddressIntentData;
75
+
76
+ /**
77
+ * Creates a memoized PAS intent closure. On first call it registers the
78
+ * shared resolver and adds the $Intent command; subsequent calls return
79
+ * the cached TransactionResult.
80
+ */
81
+ function createPASIntent(data: PASIntentData): (tx: Transaction) => TransactionResult {
82
+ let result: TransactionResult | null = null;
83
+ return (tx: Transaction) => {
84
+ if (result) return result;
85
+ tx.addIntentResolver(PAS_INTENT_NAME, resolvePASIntents);
86
+ result = tx.add(
87
+ TransactionCommands.Intent({
88
+ name: PAS_INTENT_NAME,
89
+ inputs: {},
90
+ data: data as unknown as Record<string, unknown>,
91
+ }),
92
+ );
93
+ return result;
94
+ };
95
+ }
96
+
97
+ export function sendBalanceIntent(
98
+ packageConfig: PASPackageConfig,
99
+ ): (options: {
100
+ from: string;
101
+ to: string;
102
+ amount: number | bigint;
103
+ assetType: string;
104
+ }) => (tx: Transaction) => TransactionResult {
105
+ return ({ from, to, amount, assetType }) =>
106
+ createPASIntent({
107
+ action: 'sendBalance',
108
+ from,
109
+ to,
110
+ amount: String(amount),
111
+ assetType,
112
+ cfg: packageConfig,
113
+ });
114
+ }
115
+
116
+ export function unlockBalanceIntent(
117
+ packageConfig: PASPackageConfig,
118
+ ): (options: {
119
+ from: string;
120
+ amount: number | bigint;
121
+ assetType: string;
122
+ }) => (tx: Transaction) => TransactionResult {
123
+ return ({ from, amount, assetType }) =>
124
+ createPASIntent({
125
+ action: 'unlockBalance',
126
+ from,
127
+ amount: String(amount),
128
+ assetType,
129
+ cfg: packageConfig,
130
+ });
131
+ }
132
+
133
+ export function unlockUnrestrictedBalanceIntent(
134
+ packageConfig: PASPackageConfig,
135
+ ): (options: {
136
+ from: string;
137
+ amount: number | bigint;
138
+ assetType: string;
139
+ }) => (tx: Transaction) => TransactionResult {
140
+ return ({ from, amount, assetType }) =>
141
+ createPASIntent({
142
+ action: 'unlockUnrestrictedBalance',
143
+ from,
144
+ amount: String(amount),
145
+ assetType,
146
+ cfg: packageConfig,
147
+ });
148
+ }
149
+
150
+ export function accountForAddressIntent(
151
+ packageConfig: PASPackageConfig,
152
+ ): (owner: string) => (tx: Transaction) => TransactionResult {
153
+ return (owner: string) =>
154
+ createPASIntent({ action: 'accountForAddress', owner, cfg: packageConfig });
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Resolver -- holds mutable state shared across all intent builders
159
+ // ---------------------------------------------------------------------------
160
+ //
161
+ // ## How intent resolution works
162
+ //
163
+ // Each PAS intent occupies a single $Intent slot in the transaction's command
164
+ // list. At build time, the resolver replaces each $Intent with a sequence of
165
+ // concrete MoveCall commands via `replaceCommand`.
166
+ //
167
+ // The tricky part is **indexing**. Commands within a PTB reference each
168
+ // other's outputs by absolute command index (e.g. `{ Result: 5 }` means
169
+ // "the output of command #5"). When we build the replacement commands for
170
+ // an intent, we need to know what absolute index each new command will land
171
+ // at in the final PTB. That's what `baseIdx` is for:
172
+ //
173
+ // baseIdx = the position of the $Intent slot being replaced
174
+ //
175
+ // So if baseIdx is 3 and we push 2 account-creation commands before the
176
+ // new_auth call, new_auth lands at absolute index 5 (= 3 + 2).
177
+ //
178
+ // The SDK's `replaceCommand` handles index shifting automatically: after
179
+ // splicing N commands in place of 1, it adjusts all Result/NestedResult
180
+ // references in subsequent commands by (N - 1). So we iterate the live
181
+ // command list directly -- no manual offset tracking needed.
182
+ //
183
+ // Each builder returns a `BuildResult` containing:
184
+ // - `commands`: the replacement commands (local array, 0-indexed)
185
+ // - `resultOffset`: which command in that array produces the intent's
186
+ // output value (so external references to the intent can be remapped)
187
+ //
188
+
189
+ type SuiObject = SuiClientTypes.Object<{ content: true }>;
190
+
191
+ type AccountState = { kind: 'existing' } | { kind: 'created'; resultIndex: number };
192
+
193
+ /** Return value from each per-action builder. */
194
+ interface BuildResult {
195
+ commands: Command[];
196
+ /** Offset within `commands` of the command whose Result is the intent's output. */
197
+ resultOffset: number;
198
+ }
199
+
200
+ class Resolver {
201
+ /** Pre-fetched on-chain objects (accounts, rules). null = does not exist. */
202
+ readonly objects: Map<string, SuiObject | null>;
203
+ /** Pre-fetched template dynamic field objects. */
204
+ readonly templates: Map<string, SuiObject>;
205
+ /** Pre-parsed template lookup: policyId:actionType -> approval type names. */
206
+ readonly templateApprovals: Map<string, string[]>;
207
+ /** Account existence / creation tracking. */
208
+ readonly accounts: Map<string, AccountState>;
209
+
210
+ readonly #tx: TransactionDataBuilder;
211
+ readonly #inputCache = new Map<string, Argument>();
212
+ readonly #templateCommandsCache = new Map<string, ReturnType<typeof getCommandFromTemplate>[]>();
213
+ readonly #config: PASPackageConfig;
214
+
215
+ constructor({
216
+ transactionData,
217
+ objects,
218
+ templates,
219
+ templateApprovals,
220
+ accounts,
221
+ config,
222
+ }: {
223
+ transactionData: TransactionDataBuilder;
224
+ objects: Map<string, SuiObject | null>;
225
+ templates: Map<string, SuiObject>;
226
+ templateApprovals: Map<string, string[]>;
227
+ accounts: Map<string, AccountState>;
228
+ config: PASPackageConfig;
229
+ }) {
230
+ this.#tx = transactionData;
231
+ this.objects = objects;
232
+ this.templates = templates;
233
+ this.templateApprovals = templateApprovals;
234
+ this.accounts = accounts;
235
+ this.#config = config;
236
+ }
237
+
238
+ // -- Input helpers (deduplicated) ----------------------------------------
239
+
240
+ addObjectInput(objectId: string): Argument {
241
+ let arg = this.#inputCache.get(objectId);
242
+ if (!arg) {
243
+ arg = this.#tx.addInput('object', {
244
+ $kind: 'UnresolvedObject',
245
+ UnresolvedObject: { objectId },
246
+ });
247
+ this.#inputCache.set(objectId, arg);
248
+ }
249
+ return arg;
250
+ }
251
+
252
+ addPureInput(key: string, value: ReturnType<typeof Inputs.Pure>): Argument {
253
+ let arg = this.#inputCache.get(key);
254
+ if (!arg) {
255
+ arg = this.#tx.addInput('pure', value);
256
+ this.#inputCache.set(key, arg);
257
+ }
258
+ return arg;
259
+ }
260
+
261
+ addTemplateInput(type: 'object' | 'pure', arg: CallArg): Argument {
262
+ if (type === 'object' && arg.$kind === 'UnresolvedObject') {
263
+ return this.addObjectInput(arg.UnresolvedObject.objectId);
264
+ }
265
+ return this.#tx.addInput(type, arg);
266
+ }
267
+
268
+ // -- Object lookup -------------------------------------------------------
269
+
270
+ getObjectOrThrow(objectId: string, errorFactory: () => Error): SuiObject {
271
+ const obj = this.objects.get(objectId);
272
+ if (!obj) throw errorFactory();
273
+ return obj;
274
+ }
275
+
276
+ // -- Account resolution ----------------------------------------------------
277
+
278
+ /**
279
+ * Returns an Argument referencing the account for `accountId`.
280
+ *
281
+ * - Existing on-chain account: returns an object Input.
282
+ * - Already created earlier in this PTB: returns the stored Result ref.
283
+ * - Does not exist yet: **pushes** a `account::create` MoveCall into the
284
+ * caller's `commands` array (mutating it) and records the creation so
285
+ * subsequent calls for the same account reuse the same Result. The account
286
+ * will be shared at the end of the PTB via `shareNewAccounts()`.
287
+ *
288
+ * @param commands - The caller's local command array (may be mutated).
289
+ * @param baseIdx - Absolute PTB index where `commands[0]` will land.
290
+ */
291
+ resolveAccountArg(accountId: string, owner: string, baseIdx: number): [Argument, Command[]] {
292
+ const state = this.accounts.get(accountId);
293
+ const commands: Command[] = [];
294
+
295
+ if (state?.kind === 'existing') return [this.addObjectInput(accountId), commands];
296
+
297
+ if (state?.kind === 'created')
298
+ return [{ $kind: 'Result', Result: state.resultIndex }, commands];
299
+
300
+ const absoluteIndex = baseIdx + commands.length;
301
+ commands.push(
302
+ TransactionCommands.MoveCall({
303
+ package: this.#config.packageId,
304
+ module: 'account',
305
+ function: 'create',
306
+ arguments: [
307
+ this.addObjectInput(this.#config.namespaceId),
308
+ this.addPureInput(`address:${owner}`, Inputs.Pure(bcs.Address.serialize(owner))),
309
+ ],
310
+ }),
311
+ );
312
+
313
+ this.accounts.set(accountId, { kind: 'created', resultIndex: absoluteIndex });
314
+ return [{ $kind: 'Result', Result: absoluteIndex }, commands];
315
+ }
316
+
317
+ // -- Template resolution (synchronous, all data pre-fetched) -------------
318
+
319
+ resolveTemplateCommands(policyObjectId: string, actionType: PASActionType) {
320
+ const cacheKey = `${policyObjectId}:${actionType}`;
321
+ const cached = this.#templateCommandsCache.get(cacheKey);
322
+ if (cached) return cached;
323
+
324
+ const approvalTypeNames = this.templateApprovals.get(cacheKey);
325
+ if (!approvalTypeNames) {
326
+ throw new PASClientError(
327
+ `No required approvals found for action "${actionType}". The issuer has not configured this action.`,
328
+ );
329
+ }
330
+
331
+ const templatesId = deriveTemplateRegistryAddress(this.#config);
332
+ const commands = approvalTypeNames.map((tn) => {
333
+ const templateId = deriveTemplateAddress(templatesId, tn);
334
+ const template = this.templates.get(templateId);
335
+ if (!template) {
336
+ throw new PASClientError(
337
+ `Template not found for approval type "${tn}". The issuer has not set up the template command.`,
338
+ );
339
+ }
340
+ return getCommandFromTemplate(template);
341
+ });
342
+
343
+ this.#templateCommandsCache.set(cacheKey, commands);
344
+ return commands;
345
+ }
346
+
347
+ /**
348
+ * Replaces a standard action intent (transfer/unlock) with its built
349
+ * commands. The resolve call at `actualIdx + resultOffset` produces the
350
+ * intent's output value.
351
+ */
352
+ replaceIntent(actualIdx: number, commands: Command[], resultOffset: number) {
353
+ this.#tx.replaceCommand(actualIdx, commands, { Result: actualIdx + resultOffset });
354
+ }
355
+
356
+ /**
357
+ * Replaces a accountForAddress intent when the account already exists.
358
+ * The intent is removed (0 replacement commands) and external references
359
+ * are remapped to the existing account's Input argument.
360
+ *
361
+ * Note: SDK's replaceCommand signature doesn't accept Input args as
362
+ * resultIndex, but the runtime handles it correctly via ArgumentSchema.parse().
363
+ */
364
+ replaceIntentWithExistingAccount(actualIdx: number, accountArg: Argument) {
365
+ this.#tx.replaceCommand(actualIdx, [], accountArg as any);
366
+ }
367
+
368
+ /**
369
+ * Replaces a accountForAddress intent when the account needs to be created.
370
+ * The intent is replaced with the account::create command(s), and external
371
+ * references are remapped to the first command's Result (the new account).
372
+ */
373
+ replaceIntentWithCreatedAccount(actualIdx: number, commands: Command[]) {
374
+ this.#tx.replaceCommand(actualIdx, commands, { Result: actualIdx });
375
+ }
376
+
377
+ // -- Per-action builders --------------------------------------------------
378
+ //
379
+ // Each builder constructs a local `commands` array representing the
380
+ // sequence of MoveCall commands that replace the intent. Commands
381
+ // reference each other using absolute indices (baseIdx + local offset).
382
+ //
383
+ // The general pattern for a transfer is:
384
+ // [account::create (0..N)] -- only if accounts don't exist yet
385
+ // account::new_auth -- create ownership proof
386
+ // account::send_balance -- initiate the request
387
+ // [approval commands] -- issuer-defined template commands
388
+ // send_funds::resolve_balance -- finalize and produce the output
389
+ //
390
+ // `resultOffset` points at the last command (resolve), whose Result
391
+ // becomes the intent's output value.
392
+
393
+ buildSendBalance(data: SendBalanceIntentData, baseIdx: number): BuildResult {
394
+ const { from, to, assetType, amount } = data;
395
+ const fromAccountId = deriveAccountAddress(from, this.#config);
396
+ const toAccountId = deriveAccountAddress(to, this.#config);
397
+
398
+ const policyId = derivePolicyAddress(assetType, this.#config);
399
+ this.getObjectOrThrow(policyId, () => new PolicyNotFoundError(assetType));
400
+ const templateCmds = this.resolveTemplateCommands(policyId, 'send_funds');
401
+
402
+ const [toAccountArg, commands] = this.resolveAccountArg(toAccountId, to, baseIdx);
403
+ const [fromAccountArg, fromAccountCommands] = this.resolveAccountArg(
404
+ fromAccountId,
405
+ from,
406
+ baseIdx + commands.length,
407
+ );
408
+ commands.push(...fromAccountCommands);
409
+
410
+ const policyArg = this.addObjectInput(policyId);
411
+
412
+ // account::new_auth
413
+ const authIdx = baseIdx + commands.length;
414
+ commands.push(
415
+ TransactionCommands.MoveCall({
416
+ package: this.#config.packageId,
417
+ module: 'account',
418
+ function: 'new_auth',
419
+ }),
420
+ );
421
+
422
+ // account::send_balance
423
+ const requestIdx = baseIdx + commands.length;
424
+ commands.push(
425
+ TransactionCommands.MoveCall({
426
+ package: this.#config.packageId,
427
+ module: 'account',
428
+ function: 'send_balance',
429
+ arguments: [
430
+ fromAccountArg,
431
+ { $kind: 'Result', Result: authIdx },
432
+ toAccountArg,
433
+ this.addTemplateInput('pure', Inputs.Pure(bcs.u64().serialize(BigInt(amount)))),
434
+ ],
435
+ typeArguments: [normalizeStructTag(assetType)],
436
+ }),
437
+ );
438
+ const requestArg: Argument = { $kind: 'Result', Result: requestIdx };
439
+
440
+ // Issuer-defined approval commands from templates.
441
+ // Template Result/NestedResult indices are relative to the first template
442
+ // command, so we capture the absolute offset before pushing any of them.
443
+ const templateStartIdx = baseIdx + commands.length;
444
+ for (const templateCmd of templateCmds) {
445
+ commands.push(
446
+ buildMoveCallCommandFromTemplate(
447
+ templateCmd,
448
+ {
449
+ addInput: (type, arg) => this.addTemplateInput(type, arg),
450
+ senderAccount: fromAccountArg,
451
+ receiverAccount: toAccountArg,
452
+ policy: policyArg,
453
+ request: requestArg,
454
+ },
455
+ templateStartIdx,
456
+ ),
457
+ );
458
+ }
459
+
460
+ // send_funds::resolve
461
+ const resultOffset = commands.length;
462
+ commands.push(
463
+ TransactionCommands.MoveCall({
464
+ package: this.#config.packageId,
465
+ module: 'send_funds',
466
+ function: 'resolve_balance',
467
+ arguments: [requestArg, policyArg],
468
+ typeArguments: [normalizeStructTag(assetType)],
469
+ }),
470
+ );
471
+
472
+ return { commands, resultOffset };
473
+ }
474
+
475
+ /**
476
+ * Builds commands for both restricted and unrestricted unlock flows.
477
+ * Restricted: requires a Policy, runs issuer approval templates, then resolve.
478
+ * Unrestricted: no Policy needed, calls resolve_unrestricted_balance directly.
479
+ */
480
+ buildUnlockBalance(
481
+ data: UnlockBalanceIntentData | UnlockUnrestrictedBalanceIntentData,
482
+ baseIdx: number,
483
+ ): BuildResult {
484
+ const { from, assetType, amount } = data;
485
+ const fromAccountId = deriveAccountAddress(from, this.#config);
486
+ const policyId = derivePolicyAddress(assetType, this.#config);
487
+
488
+ const isRestricted = data.action === 'unlockBalance';
489
+
490
+ if (isRestricted) {
491
+ this.getObjectOrThrow(
492
+ policyId,
493
+ () =>
494
+ new PASClientError(
495
+ `Policy does not exist for asset type ${assetType}. ` +
496
+ `That means that the issuer has not yet enabled funds management for this asset. ` +
497
+ `If this is a non-managed asset, you can use the unrestricted unlock flow by calling unlockUnrestrictedBalance() instead.`,
498
+ ),
499
+ );
500
+ } else {
501
+ if (this.objects.get(policyId) !== null) {
502
+ throw new PASClientError(
503
+ `A policy exists for asset type ${assetType}. That means that the issuer has enabled funds management for this asset and you can no longer use the unrestricted unlock flow.`,
504
+ );
505
+ }
506
+ }
507
+
508
+ const [fromAccountArg, commands] = this.resolveAccountArg(fromAccountId, from, baseIdx);
509
+ const policyArg = isRestricted ? this.addObjectInput(policyId) : undefined;
510
+
511
+ // account::new_auth
512
+ const authIdx = baseIdx + commands.length;
513
+ commands.push(
514
+ TransactionCommands.MoveCall({
515
+ package: this.#config.packageId,
516
+ module: 'account',
517
+ function: 'new_auth',
518
+ }),
519
+ );
520
+
521
+ // account::unlock_funds
522
+ const requestIdx = baseIdx + commands.length;
523
+ commands.push(
524
+ TransactionCommands.MoveCall({
525
+ package: this.#config.packageId,
526
+ module: 'account',
527
+ function: 'unlock_balance',
528
+ arguments: [
529
+ fromAccountArg,
530
+ { $kind: 'Result', Result: authIdx },
531
+ this.addTemplateInput('pure', Inputs.Pure(bcs.u64().serialize(BigInt(amount)))),
532
+ ],
533
+ typeArguments: [normalizeStructTag(assetType)],
534
+ }),
535
+ );
536
+ const requestArg: Argument = { $kind: 'Result', Result: requestIdx };
537
+
538
+ if (isRestricted) {
539
+ // Issuer-defined approval commands from templates.
540
+ const templateCmds = this.resolveTemplateCommands(policyId, 'unlock_funds');
541
+ const templateStartIdx = baseIdx + commands.length;
542
+ for (const templateCmd of templateCmds) {
543
+ commands.push(
544
+ buildMoveCallCommandFromTemplate(
545
+ templateCmd,
546
+ {
547
+ addInput: (type, arg) => this.addTemplateInput(type, arg),
548
+ senderAccount: fromAccountArg,
549
+ policy: policyArg,
550
+ request: requestArg,
551
+ },
552
+ templateStartIdx,
553
+ ),
554
+ );
555
+ }
556
+
557
+ // unlock_funds::resolve
558
+ const resultOffset = commands.length;
559
+ commands.push(
560
+ TransactionCommands.MoveCall({
561
+ package: this.#config.packageId,
562
+ module: 'unlock_funds',
563
+ function: 'resolve',
564
+ arguments: [requestArg, policyArg!],
565
+ typeArguments: [normalizeStructTag(assetType)],
566
+ }),
567
+ );
568
+ return { commands, resultOffset };
569
+ }
570
+
571
+ // unlock_funds::resolve_unrestricted_balance
572
+ const resultOffset = commands.length;
573
+ commands.push(
574
+ TransactionCommands.MoveCall({
575
+ package: this.#config.packageId,
576
+ module: 'unlock_funds',
577
+ function: 'resolve_unrestricted_balance',
578
+ arguments: [requestArg, this.addObjectInput(this.#config.namespaceId)],
579
+ typeArguments: [normalizeStructTag(assetType)],
580
+ }),
581
+ );
582
+ return { commands, resultOffset };
583
+ }
584
+
585
+ // -- Finalization ---------------------------------------------------------
586
+
587
+ /**
588
+ * Appends `account::share` commands for every account that was created during
589
+ * resolution. Called once at the end, after all intents have been resolved,
590
+ * so that each account is shared exactly once regardless of how many intents
591
+ * referenced it.
592
+ */
593
+ shareNewAccounts() {
594
+ for (const state of this.accounts.values()) {
595
+ if (state.kind !== 'created') continue;
596
+ this.#tx.commands.push(
597
+ TransactionCommands.MoveCall({
598
+ package: this.#config.packageId,
599
+ module: 'account',
600
+ function: 'share',
601
+ arguments: [{ $kind: 'Result', Result: state.resultIndex }],
602
+ }),
603
+ );
604
+ }
605
+ }
606
+ }
607
+
608
+ // ---------------------------------------------------------------------------
609
+ // Data collection + fetching (pre-resolution)
610
+ // ---------------------------------------------------------------------------
611
+
612
+ type AccountOwner = { owner: string };
613
+
614
+ interface IntentDataCollection {
615
+ objectIds: Set<string>;
616
+ accountRequests: Map<string, AccountOwner>;
617
+ intentDataList: PASIntentData[];
618
+ cfg: PASPackageConfig;
619
+ }
620
+
621
+ /** Scans commands for PAS intents and collects the object IDs we need to fetch. */
622
+ function collectIntentData(commands: readonly Command[]): IntentDataCollection | null {
623
+ const objectIds = new Set<string>();
624
+ const accountRequests = new Map<string, AccountOwner>();
625
+ const intentDataList: PASIntentData[] = [];
626
+ let cfg: PASPackageConfig | null = null;
627
+
628
+ for (const command of commands) {
629
+ if (command.$kind !== '$Intent' || command.$Intent.name !== PAS_INTENT_NAME) continue;
630
+ const data = command.$Intent.data as unknown as PASIntentData;
631
+
632
+ // We intentionally initialize the configs to match the first command that uses PAS.
633
+ if (!cfg) cfg = data.cfg;
634
+ intentDataList.push(data);
635
+
636
+ switch (data.action) {
637
+ case 'sendBalance': {
638
+ const fromId = deriveAccountAddress(data.from, cfg);
639
+ const toId = deriveAccountAddress(data.to, cfg);
640
+ objectIds.add(fromId);
641
+ objectIds.add(toId);
642
+ objectIds.add(derivePolicyAddress(data.assetType, cfg));
643
+ accountRequests.set(fromId, { owner: data.from });
644
+ accountRequests.set(toId, { owner: data.to });
645
+ break;
646
+ }
647
+ case 'unlockBalance':
648
+ case 'unlockUnrestrictedBalance': {
649
+ const fromId = deriveAccountAddress(data.from, cfg);
650
+ objectIds.add(fromId);
651
+ objectIds.add(derivePolicyAddress(data.assetType, cfg));
652
+ accountRequests.set(fromId, { owner: data.from });
653
+ break;
654
+ }
655
+ case 'accountForAddress': {
656
+ const id = deriveAccountAddress(data.owner, cfg);
657
+ objectIds.add(id);
658
+ accountRequests.set(id, { owner: data.owner });
659
+ break;
660
+ }
661
+ }
662
+ }
663
+
664
+ if (intentDataList.length === 0) return null;
665
+
666
+ if (!cfg)
667
+ throw new PASClientError('No package configuration found in intents. This is an internal bug.');
668
+
669
+ return { objectIds, accountRequests, intentDataList, cfg };
670
+ }
671
+
672
+ async function initializeContext(
673
+ transactionData: TransactionDataBuilder,
674
+ client: ClientWithCoreApi,
675
+ objectIds: Set<string>,
676
+ accountRequests: Map<string, AccountOwner>,
677
+ intentDataList: PASIntentData[],
678
+ config: PASPackageConfig,
679
+ ): Promise<Resolver> {
680
+ // 1. Batch-fetch all accounts + rules
681
+ const allIds = [...objectIds];
682
+ const { objects: fetched } = await client.core.getObjects({
683
+ objectIds: allIds,
684
+ include: { content: true },
685
+ });
686
+
687
+ const objects = new Map<string, SuiObject | null>();
688
+
689
+ for (const id of allIds) {
690
+ const obj = fetched.filter((o) => 'content' in o).find((o) => o.objectId === id);
691
+ objects.set(id, obj ?? null);
692
+ }
693
+
694
+ // 2. Build initial account map (existing vs needs-creation)
695
+ const accounts = new Map<string, AccountState>();
696
+ for (const [accountId] of accountRequests) {
697
+ if (objects.get(accountId) !== null) {
698
+ accounts.set(accountId, { kind: 'existing' });
699
+ }
700
+ }
701
+
702
+ // 3. Collect template DF IDs by parsing rules
703
+ const templateApprovals = new Map<string, string[]>();
704
+ const templateIds: string[] = [];
705
+ const seen = new Set<string>();
706
+
707
+ for (const data of intentDataList) {
708
+ let actionType: PASActionType | null = null;
709
+ let assetType: string | null = null;
710
+
711
+ if (data.action === 'sendBalance') {
712
+ actionType = 'send_funds';
713
+ assetType = data.assetType;
714
+ } else if (data.action === 'unlockBalance') {
715
+ actionType = 'unlock_funds';
716
+ assetType = data.assetType;
717
+ }
718
+
719
+ if (!actionType || !assetType) continue;
720
+
721
+ const policyId = derivePolicyAddress(assetType, config);
722
+ const key = `${policyId}:${actionType}`;
723
+ if (seen.has(key)) continue;
724
+ seen.add(key);
725
+
726
+ const policyObject = objects.get(policyId);
727
+ if (!policyObject) continue;
728
+
729
+ const approvalTypeNames = getRequiredApprovals(policyObject, actionType);
730
+ if (!approvalTypeNames?.length) continue;
731
+
732
+ const templatesId = deriveTemplateRegistryAddress(config);
733
+ templateApprovals.set(key, approvalTypeNames);
734
+ templateIds.push(...approvalTypeNames.map((tn) => deriveTemplateAddress(templatesId, tn)));
735
+ }
736
+
737
+ // 4. Batch-fetch all template data
738
+ const templates = new Map<string, SuiObject>();
739
+ if (templateIds.length > 0) {
740
+ const { objects: templateObjects } = await client.core.getObjects({
741
+ objectIds: templateIds,
742
+ include: { content: true },
743
+ });
744
+
745
+ for (const obj of templateObjects.filter((o) => 'content' in o)) {
746
+ templates.set(obj.objectId, obj);
747
+ }
748
+ }
749
+
750
+ // 5. Validate that all objects referenced by templates are shared or immutable.
751
+ await validateTemplateObjects(client, Array.from(templates.values()));
752
+
753
+ return new Resolver({
754
+ transactionData,
755
+ objects,
756
+ templates,
757
+ templateApprovals,
758
+ accounts,
759
+ config,
760
+ });
761
+ }
762
+
763
+ const resolvePASIntents: TransactionPlugin = async (transactionData, buildOptions, next) => {
764
+ const client = buildOptions.client;
765
+ if (!client)
766
+ throw new PASClientError(
767
+ 'A SuiClient must be provided to build transactions with PAS intents.',
768
+ );
769
+
770
+ const requirements = collectIntentData(transactionData.commands);
771
+ if (!requirements) return next();
772
+
773
+ const { objectIds, accountRequests, intentDataList, cfg } = requirements;
774
+
775
+ const ctx = await initializeContext(
776
+ transactionData,
777
+ client,
778
+ objectIds,
779
+ accountRequests,
780
+ intentDataList,
781
+ cfg,
782
+ );
783
+
784
+ // Always advance by 1 so we never skip (e.g. a replacement that contains another intent).
785
+ // When we replace with 0 commands we decrement so the loop's increment nets 0 and we
786
+ // re-read the slot (where the next intent shifted in).
787
+ for (let index = 0; index < transactionData.commands.length; index++) {
788
+ const command = transactionData.commands[index];
789
+ if (command.$kind !== '$Intent' || command.$Intent.name !== PAS_INTENT_NAME) continue;
790
+
791
+ const data = command.$Intent.data as unknown as PASIntentData;
792
+
793
+ if (data.action === 'accountForAddress') {
794
+ const accountId = deriveAccountAddress(data.owner, cfg);
795
+ const [accountArg, commands] = ctx.resolveAccountArg(accountId, data.owner, index);
796
+
797
+ if (commands.length === 0) {
798
+ ctx.replaceIntentWithExistingAccount(index, accountArg);
799
+ index--; // Next iteration will ++, so we re-read this index (next intent moved here)
800
+ } else {
801
+ ctx.replaceIntentWithCreatedAccount(index, commands);
802
+ }
803
+ continue;
804
+ }
805
+
806
+ let result: BuildResult;
807
+ switch (data.action) {
808
+ case 'sendBalance':
809
+ result = ctx.buildSendBalance(data, index);
810
+ break;
811
+ case 'unlockBalance':
812
+ case 'unlockUnrestrictedBalance':
813
+ result = ctx.buildUnlockBalance(data, index);
814
+ break;
815
+ default:
816
+ continue;
817
+ }
818
+
819
+ ctx.replaceIntent(index, result.commands, result.resultOffset);
820
+ }
821
+
822
+ ctx.shareNewAccounts();
823
+ return next();
824
+ };
825
+
826
+ /**
827
+ * Parses all template commands, collects every object they reference
828
+ * (fully-resolved refs, shared refs, and ext lookups), batch-fetches
829
+ * their current state, and rejects any that are not shared or immutable.
830
+ */
831
+ export async function validateTemplateObjects(
832
+ client: ClientWithCoreApi,
833
+ templates: SuiObject[],
834
+ ): Promise<void> {
835
+ const allTemplateCommands = templates.map(getCommandFromTemplate);
836
+ const objectIds = collectTemplateObjectIds(allTemplateCommands);
837
+
838
+ if (objectIds.size === 0) return;
839
+
840
+ const { objects: fetchedObjects } = await client.core.getObjects({
841
+ objectIds: [...objectIds],
842
+ });
843
+
844
+ for (const obj of fetchedObjects) {
845
+ if (obj instanceof Error)
846
+ throw new PASClientError('Failed to fetch template object: ' + obj.message);
847
+
848
+ if (obj.owner.$kind !== 'Shared' && obj.owner.$kind !== 'Immutable') {
849
+ throw new InvalidObjectOwnershipError(obj.objectId, obj.owner.$kind);
850
+ }
851
+ }
852
+ }