@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.
- package/README.md +1 -0
- package/dist/client.d.mts +117 -0
- package/dist/client.d.mts.map +1 -0
- package/dist/client.mjs +89 -0
- package/dist/client.mjs.map +1 -0
- package/dist/constants.mjs +9 -0
- package/dist/constants.mjs.map +1 -0
- package/dist/contracts/pas/deps/std/type_name.mjs +17 -0
- package/dist/contracts/pas/deps/std/type_name.mjs.map +1 -0
- package/dist/contracts/pas/deps/sui/vec_map.mjs +37 -0
- package/dist/contracts/pas/deps/sui/vec_map.mjs.map +1 -0
- package/dist/contracts/pas/deps/sui/vec_set.mjs +26 -0
- package/dist/contracts/pas/deps/sui/vec_set.mjs.map +1 -0
- package/dist/contracts/pas/policy.mjs +33 -0
- package/dist/contracts/pas/policy.mjs.map +1 -0
- package/dist/contracts/pas/versioning.mjs +25 -0
- package/dist/contracts/pas/versioning.mjs.map +1 -0
- package/dist/contracts/ptb/ptb.mjs +162 -0
- package/dist/contracts/ptb/ptb.mjs.map +1 -0
- package/dist/contracts/sui/dynamic_field.mjs +22 -0
- package/dist/contracts/sui/dynamic_field.mjs.map +1 -0
- package/dist/contracts/utils/index.mjs +37 -0
- package/dist/contracts/utils/index.mjs.map +1 -0
- package/dist/derivation.mjs +70 -0
- package/dist/derivation.mjs.map +1 -0
- package/dist/error.d.mts +16 -0
- package/dist/error.d.mts.map +1 -0
- package/dist/error.mjs +26 -0
- package/dist/error.mjs.map +1 -0
- package/dist/index.d.mts +4 -0
- package/dist/index.mjs +4 -0
- package/dist/intents.mjs +494 -0
- package/dist/intents.mjs.map +1 -0
- package/dist/resolution.mjs +185 -0
- package/dist/resolution.mjs.map +1 -0
- package/dist/types.d.mts +34 -0
- package/dist/types.d.mts.map +1 -0
- package/package.json +59 -0
- package/src/client.ts +173 -0
- package/src/constants.ts +15 -0
- package/src/contracts/pas/account.ts +343 -0
- package/src/contracts/pas/clawback_funds.ts +114 -0
- package/src/contracts/pas/deps/std/type_name.ts +24 -0
- package/src/contracts/pas/deps/sui/vec_map.ts +33 -0
- package/src/contracts/pas/deps/sui/vec_set.ts +22 -0
- package/src/contracts/pas/keys.ts +90 -0
- package/src/contracts/pas/namespace.ts +207 -0
- package/src/contracts/pas/policy.ts +212 -0
- package/src/contracts/pas/request.ts +87 -0
- package/src/contracts/pas/send_funds.ts +174 -0
- package/src/contracts/pas/templates.ts +101 -0
- package/src/contracts/pas/unlock_funds.ts +155 -0
- package/src/contracts/pas/versioning.ts +69 -0
- package/src/contracts/ptb/ptb.ts +821 -0
- package/src/contracts/sui/dynamic_field.ts +171 -0
- package/src/contracts/utils/index.ts +235 -0
- package/src/derivation.ts +107 -0
- package/src/error.ts +29 -0
- package/src/index.ts +6 -0
- package/src/intents.ts +852 -0
- package/src/resolution.ts +294 -0
- 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
|
+
}
|