@crediolabs/policy-synth 0.1.13 → 0.1.15

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.
@@ -13,9 +13,12 @@
13
13
  // (b) key material the server does not have, so we ship the simpler ONE-CALL
14
14
  // shape. The wallet signature covers the change.
15
15
  //
16
- // `buildInstallPolicyXdr` returns CALL 1 ONLY. Call 2 (interpreter.install)
17
- // needs the rule id the account assigns in call 1, so it cannot be pre-built
18
- // and is documented in the response envelope under `followUp`.
16
+ // `buildInstallPolicyXdr` installs the policy in ONE call. `add_context_rule`
17
+ // takes `policies` as a `Map<policy_address, install_param>` and the account
18
+ // forwards each install_param to that policy, so the interpreter stores the
19
+ // predicate document as part of this same transaction. Earlier revisions
20
+ // documented a second `interpreter.install` call; that was wrong, and issuing
21
+ // it fails - the account re-enters the interpreter while it is mid-install.
19
22
 
20
23
  import { createHash } from 'node:crypto'
21
24
  import {
@@ -31,6 +34,13 @@ import {
31
34
  xdr,
32
35
  } from '@stellar/stellar-sdk'
33
36
  import { buildAddContextRuleArgs, DEFAULT_GRAMMAR_VERSION } from './build-add-context-rule.ts'
37
+ import {
38
+ accountEntry,
39
+ authDigest,
40
+ authPayload,
41
+ delegatedSignerEntry,
42
+ signaturePayload,
43
+ } from './oz-auth.ts'
34
44
 
35
45
  /** Minimal Soroban RPC surface the install pipeline needs. Test seams
36
46
  * inject a stub so the builder stays deterministic; production builds
@@ -209,19 +219,17 @@ export interface BuildInstallPolicyResult {
209
219
  smartAccount: string
210
220
  /** Source account (echo) - the address that must sign. */
211
221
  sourceAccount: string
212
- /** The host-call target + fn name. Call 2 (interpreter.install) is NOT
213
- * emitted here; the account assigns the rule id in call 1 and call 2
214
- * needs that id to bind its `context_rule_ids`. See `followUp` below. */
222
+ /** The host-call target + fn name. This single call installs the policy
223
+ * outright: `add_context_rule` takes `policies` as a
224
+ * `Map<policy_address, install_param>`, and the account forwards each
225
+ * install_param to that policy. For an interpreter policy the param
226
+ * carries the predicate and its hash, so the interpreter stores the
227
+ * document as part of this transaction. There is no second call.
228
+ *
229
+ * Verified on testnet 2026-08-01: a rule created by this builder alone
230
+ * permits a matching operator call and denies a non-matching one with
231
+ * interpreter code #100 - not #206 MissingState. */
215
232
  call: { contract: string; fn: 'add_context_rule' }
216
- /** The follow-up call the caller MUST issue after this one confirms.
217
- * The rule id the account assigns in call 1 is required to bind the
218
- * interpreter's `install` auth context. */
219
- followUp: {
220
- contract: 'interpreter'
221
- fn: 'install'
222
- requiresRuleIdFromCallOne: true
223
- hint: string
224
- }
225
233
  /** Human-readable description of the install call, decoded FROM the
226
234
  * built unsigned XDR (not from the input args). The wallet signature
227
235
  * binds to bytes; the review card has to bind to the same bytes, so
@@ -236,9 +244,6 @@ export interface BuildInstallPolicyResult {
236
244
  rootInvocationXdr: string
237
245
  }
238
246
 
239
- const FOLLOWUP_HINT =
240
- 'after this tx confirms, read the rule id via account.get_context_rules_count()-1 (or get the receipt events), then call runInstallPolicy a second time with the interpreter as the target and the rule id in context_rule_ids - OR hand-build the install() call directly with the OZ auth pattern.'
241
-
242
247
  /** Build the unsigned transaction envelope for `account.add_context_rule(...)`.
243
248
  * The output XDR is signed by the wallet, not by us. */
244
249
  export async function buildInstallPolicyXdr(
@@ -265,36 +270,33 @@ export async function buildInstallPolicyXdr(
265
270
  }
266
271
  )
267
272
 
268
- // 2. Network pass: source account sequence + latest ledger.
273
+ // 2. Fetch the source sequence, then build the recording transaction with a
274
+ // bare host call. The recording pass assigns the smart-account auth nonce
275
+ // and root invocation needed to construct OZ's AuthPayload.
269
276
  const source = await args.rpc.getAccount(args.sourceAccount)
270
- const latest = await args.rpc.getLatestLedger()
271
- const validUntilLedger =
272
- latest.sequence + (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_LEDGERS)
273
-
274
- // 3. Build the unsigned transaction envelope with the bare host call.
275
- const op = Operation.invokeHostFunction({
276
- func: xdr.HostFunction.hostFunctionTypeInvokeContract(
277
- new xdr.InvokeContractArgs({
278
- contractAddress: new Address(args.smartAccount).toScAddress(),
279
- functionName: 'add_context_rule',
280
- args: [...callArgs],
281
- })
282
- ),
283
- auth: [],
284
- })
277
+ const hostFunction = xdr.HostFunction.hostFunctionTypeInvokeContract(
278
+ new xdr.InvokeContractArgs({
279
+ contractAddress: new Address(args.smartAccount).toScAddress(),
280
+ functionName: 'add_context_rule',
281
+ args: [...callArgs],
282
+ })
283
+ )
284
+ const makeOperation = (auth: xdr.SorobanAuthorizationEntry[] = []) =>
285
+ Operation.invokeHostFunction({ func: hostFunction, auth })
285
286
  const baseFee = args.baseFee !== undefined ? String(args.baseFee) : BASE_FEE
286
- const tx = buildUnsignedTx({
287
- sourceAccount: args.sourceAccount,
288
- sequence: source.sequenceNumber(),
289
- fee: baseFee,
290
- networkPassphrase: args.networkPassphrase,
291
- op,
292
- })
293
-
294
- // 4. Simulation pass. Returns the auth nonce + rootInvocation the wallet
295
- // signature binds to. We do NOT add the AuthPayload here - the wallet
296
- // injects auth entries using its own __check_auth flow.
297
- const recorded = await args.rpc.simulateTransaction(tx)
287
+ const buildTx = (op: xdr.Operation) =>
288
+ buildUnsignedTx({
289
+ sourceAccount: args.sourceAccount,
290
+ sequence: source.sequenceNumber(),
291
+ fee: baseFee,
292
+ networkPassphrase: args.networkPassphrase,
293
+ op,
294
+ })
295
+ const recordingTx = buildTx(makeOperation())
296
+
297
+ // 3. Recording simulation: capture the smart account's nonce and invocation
298
+ // tree. Nothing is signed here.
299
+ const recorded = await args.rpc.simulateTransaction(recordingTx)
298
300
  if (rpc.Api.isSimulationError(recorded)) {
299
301
  // Short, stable reason. The full `simulateTransaction` error (which
300
302
  // carries host + URL detail) stays in the SDK's own logs - never
@@ -314,24 +316,51 @@ export async function buildInstallPolicyXdr(
314
316
  )
315
317
  }
316
318
 
317
- // 5. Decode the structured description FROM the unsigned XDR. The human
318
- // approval binds to the bytes the wallet will sign; deriving this from
319
- // the input args would re-describe the caller's intent rather than the
320
- // transaction. Re-parse the envelope we just built so the description
321
- // reflects what the wallet actually signs, not what we thought it would.
322
- const describes = decodeInstallCallDescribes(tx, op, args.installNonce)
319
+ // 4. `add_context_rule` is authorised by the deploy-time admin rule (rule 0).
320
+ // The delegated signer uses source-account credentials, so its signature
321
+ // bytes stay empty and the ordinary transaction-envelope signature covers
322
+ // the call when the consumer signs it.
323
+ const validUntilLedger =
324
+ (await args.rpc.getLatestLedger()).sequence +
325
+ (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_UNTIL_LEDGERS)
326
+ const contextRuleIds = [0]
327
+ const digest = authDigest(
328
+ signaturePayload(
329
+ args.networkPassphrase,
330
+ original.credentials().address().nonce(),
331
+ validUntilLedger,
332
+ original.rootInvocation()
333
+ ),
334
+ contextRuleIds
335
+ )
336
+ const authEntries = [
337
+ accountEntry(
338
+ original,
339
+ validUntilLedger,
340
+ authPayload([args.sourceAccount], contextRuleIds, () => Buffer.alloc(0))
341
+ ),
342
+ ...contextRuleIds.map(() => delegatedSignerEntry(args.smartAccount, digest)),
343
+ ]
344
+
345
+ // 5. Simulate again with the OZ entries already attached. The SDK preserves
346
+ // existing auth during assembly and adds the simulated Soroban footprint +
347
+ // resource fee, yielding a complete unsigned envelope.
348
+ const txWithAuth = buildTx(makeOperation(authEntries))
349
+ const enforcing = await args.rpc.simulateTransaction(txWithAuth)
350
+ if (rpc.Api.isSimulationError(enforcing)) {
351
+ throw new Error('install_policy: auth simulateTransaction failed')
352
+ }
353
+ const finalTx = rpc.assembleTransaction(txWithAuth, enforcing).build()
354
+
355
+ // 6. Decode the structured description FROM the final assembled transaction.
356
+ // The human approval binds to the exact bytes the wallet will sign.
357
+ const describes = decodeInstallCallDescribes(finalTx, args.installNonce)
323
358
 
324
359
  return {
325
- unsignedXdr: tx.toEnvelope().toXDR().toString('base64'),
360
+ unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
326
361
  smartAccount: args.smartAccount,
327
362
  sourceAccount: args.sourceAccount,
328
363
  call: { contract: args.smartAccount, fn: 'add_context_rule' },
329
- followUp: {
330
- contract: 'interpreter',
331
- fn: 'install',
332
- requiresRuleIdFromCallOne: true,
333
- hint: FOLLOWUP_HINT,
334
- },
335
364
  describes,
336
365
  authNonce: original.credentials().address().nonce().toString(),
337
366
  authValidUntilLedger: validUntilLedger,
@@ -360,30 +389,26 @@ export async function buildRevokePolicyXdr(args: {
360
389
  authValidUntilLedgers?: number
361
390
  }): Promise<BuildRevokePolicyResult> {
362
391
  const source = await args.rpc.getAccount(args.sourceAccount)
363
- const latest = await args.rpc.getLatestLedger()
364
- const validUntilLedger =
365
- latest.sequence + (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_LEDGERS)
366
-
367
- const op = Operation.invokeHostFunction({
368
- func: xdr.HostFunction.hostFunctionTypeInvokeContract(
369
- new xdr.InvokeContractArgs({
370
- contractAddress: new Address(args.smartAccount).toScAddress(),
371
- functionName: 'remove_context_rule',
372
- args: [xdr.ScVal.scvU32(args.ruleId)],
373
- })
374
- ),
375
- auth: [],
376
- })
392
+ const hostFunction = xdr.HostFunction.hostFunctionTypeInvokeContract(
393
+ new xdr.InvokeContractArgs({
394
+ contractAddress: new Address(args.smartAccount).toScAddress(),
395
+ functionName: 'remove_context_rule',
396
+ args: [xdr.ScVal.scvU32(args.ruleId)],
397
+ })
398
+ )
399
+ const makeOperation = (auth: xdr.SorobanAuthorizationEntry[] = []) =>
400
+ Operation.invokeHostFunction({ func: hostFunction, auth })
377
401
  const baseFee = args.baseFee !== undefined ? String(args.baseFee) : BASE_FEE
378
- const tx = buildUnsignedTx({
379
- sourceAccount: args.sourceAccount,
380
- sequence: source.sequenceNumber(),
381
- fee: baseFee,
382
- networkPassphrase: args.networkPassphrase,
383
- op,
384
- })
385
-
386
- const recorded = await args.rpc.simulateTransaction(tx)
402
+ const buildTx = (op: xdr.Operation) =>
403
+ buildUnsignedTx({
404
+ sourceAccount: args.sourceAccount,
405
+ sequence: source.sequenceNumber(),
406
+ fee: baseFee,
407
+ networkPassphrase: args.networkPassphrase,
408
+ op,
409
+ })
410
+
411
+ const recorded = await args.rpc.simulateTransaction(buildTx(makeOperation()))
387
412
  if (rpc.Api.isSimulationError(recorded)) {
388
413
  // Short, stable reason. The full `simulateTransaction` error (which
389
414
  // carries host + URL detail) stays in the SDK's own logs - never
@@ -403,8 +428,41 @@ export async function buildRevokePolicyXdr(args: {
403
428
  )
404
429
  }
405
430
 
431
+ // Removal is master-only, so the deploy-time admin rule authorises it. The
432
+ // recorded rootInvocation still includes remove_context_rule(ruleId), binding
433
+ // the auth payload to the exact rule being removed. As with install,
434
+ // source-account credentials carry the delegated signer entry and the
435
+ // consumer supplies only the ordinary envelope signature.
436
+ const validUntilLedger =
437
+ (await args.rpc.getLatestLedger()).sequence +
438
+ (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_UNTIL_LEDGERS)
439
+ const contextRuleIds = [0]
440
+ const digest = authDigest(
441
+ signaturePayload(
442
+ args.networkPassphrase,
443
+ original.credentials().address().nonce(),
444
+ validUntilLedger,
445
+ original.rootInvocation()
446
+ ),
447
+ contextRuleIds
448
+ )
449
+ const authEntries = [
450
+ accountEntry(
451
+ original,
452
+ validUntilLedger,
453
+ authPayload([args.sourceAccount], contextRuleIds, () => Buffer.alloc(0))
454
+ ),
455
+ ...contextRuleIds.map(() => delegatedSignerEntry(args.smartAccount, digest)),
456
+ ]
457
+ const txWithAuth = buildTx(makeOperation(authEntries))
458
+ const enforcing = await args.rpc.simulateTransaction(txWithAuth)
459
+ if (rpc.Api.isSimulationError(enforcing)) {
460
+ throw new Error('revoke_policy: auth simulateTransaction failed')
461
+ }
462
+ const finalTx = rpc.assembleTransaction(txWithAuth, enforcing).build()
463
+
406
464
  return {
407
- unsignedXdr: tx.toEnvelope().toXDR().toString('base64'),
465
+ unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
408
466
  smartAccount: args.smartAccount,
409
467
  sourceAccount: args.sourceAccount,
410
468
  call: { contract: args.smartAccount, fn: 'remove_context_rule', ruleId: args.ruleId },
@@ -425,7 +483,7 @@ export interface BuildRevokePolicyResult {
425
483
  }
426
484
 
427
485
  /** ~25 minutes at 5s/ledger. */
428
- const DEFAULT_AUTH_VALID_LEDGERS = 300
486
+ const DEFAULT_AUTH_VALID_UNTIL_LEDGERS = 300
429
487
 
430
488
  // ---- internals ----
431
489
 
@@ -476,12 +534,11 @@ function buildUnsignedTx(args: {
476
534
  return tx
477
535
  }
478
536
 
479
- /** Decode the install call's structured fields directly out of the
480
- * built (transaction, operation) pair. The bytes are the source of
481
- * truth: a human reviewing the install wants to see what the wallet
482
- * will actually sign, not what we think it will. The decode is
483
- * deliberately strict - any shape we did not build ourselves throws
484
- * a ToolError, since that would mean the XDR was tampered with (or
537
+ /** Decode the install call's structured fields directly out of the final
538
+ * assembled transaction. The bytes are the source of truth: a human reviewing
539
+ * the install wants to see what the wallet will actually sign, not what we
540
+ * think it will. The decode is deliberately strict - any shape we did not
541
+ * build ourselves throws, since that would mean the XDR was tampered with (or
485
542
  * the encoder changed and this descriptor lags).
486
543
  *
487
544
  * `expectedInstallNonce` is a fallback for the rare case where the
@@ -489,11 +546,17 @@ function buildUnsignedTx(args: {
489
546
  * caller already knows it. */
490
547
  function decodeInstallCallDescribes(
491
548
  tx: Transaction,
492
- op: xdr.Operation,
493
549
  expectedInstallNonce: number
494
550
  ): InstallCallDescribes {
551
+ const operations = tx.toEnvelope().v1().tx().operations()
552
+ if (operations.length !== 1 || !operations[0]) {
553
+ throw new Error(
554
+ `install_policy: final transaction has ${operations.length} operations, expected 1`
555
+ )
556
+ }
557
+ const op = operations[0]
495
558
  const hostFn = op.body().invokeHostFunctionOp()?.hostFunction()
496
- if (!hostFn || hostFn.switch().name !== 'hostFunctionTypeInvokeContract') {
559
+ if (hostFn?.switch().name !== 'hostFunctionTypeInvokeContract') {
497
560
  throw new Error(
498
561
  'install_policy: built op is not an invokeHostFunction(invokeContract(...)) call'
499
562
  )
@@ -661,10 +724,6 @@ function decodeInstallCallDescribes(
661
724
  // `observedInstallNonce` is the nonce baked into whichever interpreter
662
725
  // policy is present; when none, fall back to the caller-supplied value.
663
726
  const installNonce = observedInstallNonce ?? expectedInstallNonce
664
- // Touch `tx` so the linter does not flag it as unused - the parameter
665
- // documents that the caller built this Transaction; we re-parse the
666
- // operation off it as a sanity check.
667
- void tx
668
727
  return {
669
728
  targetContract,
670
729
  fnName: 'add_context_rule',
@@ -0,0 +1,140 @@
1
+ // Build the authorization entries OpenZeppelin's smart account requires.
2
+ //
3
+ // This is the piece that makes an end-to-end test of the account -> policy
4
+ // path possible. Without it, every attempt to exercise `__check_auth`
5
+ // produces a false positive: mocked auth skips `__check_auth` entirely,
6
+ // direct invocation is rejected by the host, and recording-mode simulation
7
+ // does not verify auth at all.
8
+ //
9
+ // Two entries are needed per call:
10
+ //
11
+ // 1. The ACCOUNT's entry. Its `signature` slot is not a signature - OZ puts
12
+ // an `AuthPayload { signers, context_rule_ids }` there, which
13
+ // `__check_auth` receives as its `signatures` argument.
14
+ //
15
+ // 2. One entry per DELEGATED signer. `do_check_auth` calls
16
+ // `addr.require_auth_for_args((auth_digest,))` for each, which is a
17
+ // nested authorization requirement the host will not record during
18
+ // simulation (it never runs `__check_auth` in recording mode), so it has
19
+ // to be constructed by hand.
20
+ //
21
+ // The digest OZ binds is NOT the raw auth payload hash:
22
+ //
23
+ // auth_digest = sha256(signature_payload || xdr(context_rule_ids))
24
+ //
25
+ // where `signature_payload` is the standard Soroban authorization preimage
26
+ // hash. See `do_check_auth` in
27
+ // stellar-contracts/packages/accounts/src/smart_account/storage.rs.
28
+
29
+ import { Address, hash, xdr } from '@stellar/stellar-sdk'
30
+
31
+ const sym = (s: string) => xdr.ScVal.scvSymbol(s)
32
+ const u32 = (n: number) => xdr.ScVal.scvU32(n)
33
+ const vec = (i: xdr.ScVal[]) => xdr.ScVal.scvVec(i)
34
+ const bytes = (b: Buffer) => xdr.ScVal.scvBytes(b)
35
+
36
+ function struct(fields: Record<string, xdr.ScVal>): xdr.ScVal {
37
+ return xdr.ScVal.scvMap(
38
+ Object.keys(fields)
39
+ .sort()
40
+ .map((k) => new xdr.ScMapEntry({ key: sym(k), val: fields[k]! }))
41
+ )
42
+ }
43
+
44
+ /** `Signer::Delegated(addr)`. */
45
+ export const delegatedSigner = (a: string) => vec([sym('Delegated'), new Address(a).toScVal()])
46
+
47
+ /** The standard Soroban authorization preimage hash for one entry. */
48
+ export function signaturePayload(
49
+ networkPassphrase: string,
50
+ nonce: xdr.Int64,
51
+ signatureExpirationLedger: number,
52
+ invocation: xdr.SorobanAuthorizedInvocation
53
+ ): Buffer {
54
+ const preimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization(
55
+ new xdr.HashIdPreimageSorobanAuthorization({
56
+ networkId: hash(Buffer.from(networkPassphrase)),
57
+ nonce,
58
+ signatureExpirationLedger,
59
+ invocation,
60
+ })
61
+ )
62
+ return hash(preimage.toXDR())
63
+ }
64
+
65
+ /**
66
+ * `sha256(signature_payload || xdr(context_rule_ids))`.
67
+ *
68
+ * OZ binds the selected rule ids into the digest so a signature for one rule
69
+ * cannot be replayed against another.
70
+ */
71
+ export function authDigest(payload: Buffer, contextRuleIds: number[]): Buffer {
72
+ const idsXdr = vec(contextRuleIds.map(u32)).toXDR()
73
+ return hash(Buffer.concat([payload, idsXdr]))
74
+ }
75
+
76
+ /** `AuthPayload { signers, context_rule_ids }` - the account's "signature". */
77
+ export function authPayload(
78
+ signerAddresses: string[],
79
+ contextRuleIds: number[],
80
+ signatureFor: (addr: string) => Buffer
81
+ ): xdr.ScVal {
82
+ return struct({
83
+ signers: xdr.ScVal.scvMap(
84
+ signerAddresses
85
+ .map((a) => new xdr.ScMapEntry({ key: delegatedSigner(a), val: bytes(signatureFor(a)) }))
86
+ // Host maps must be sorted by key.
87
+ .sort((x, y) => Buffer.compare(x.key().toXDR(), y.key().toXDR()))
88
+ ),
89
+ context_rule_ids: vec(contextRuleIds.map(u32)),
90
+ })
91
+ }
92
+
93
+ /**
94
+ * The nested entry a `Delegated` signer needs.
95
+ *
96
+ * `require_auth_for_args` authorizes the CURRENT frame, which while
97
+ * `__check_auth` is running is the account contract executing `__check_auth`,
98
+ * with the digest as its single argument.
99
+ *
100
+ * The signer is the transaction source here, so source-account credentials
101
+ * carry it and no separate signature is required.
102
+ */
103
+ export function delegatedSignerEntry(
104
+ accountId: string,
105
+ digest: Buffer
106
+ ): xdr.SorobanAuthorizationEntry {
107
+ return new xdr.SorobanAuthorizationEntry({
108
+ credentials: xdr.SorobanCredentials.sorobanCredentialsSourceAccount(),
109
+ rootInvocation: new xdr.SorobanAuthorizedInvocation({
110
+ function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn(
111
+ new xdr.InvokeContractArgs({
112
+ contractAddress: new Address(accountId).toScAddress(),
113
+ functionName: '__check_auth',
114
+ args: [bytes(digest)],
115
+ })
116
+ ),
117
+ subInvocations: [],
118
+ }),
119
+ })
120
+ }
121
+
122
+ /** Rebuild the account's entry with the AuthPayload in the signature slot. */
123
+ export function accountEntry(
124
+ original: xdr.SorobanAuthorizationEntry,
125
+ signatureExpirationLedger: number,
126
+ payload: xdr.ScVal
127
+ ): xdr.SorobanAuthorizationEntry {
128
+ const creds = original.credentials().address()
129
+ return new xdr.SorobanAuthorizationEntry({
130
+ credentials: xdr.SorobanCredentials.sorobanCredentialsAddress(
131
+ new xdr.SorobanAddressCredentials({
132
+ address: creds.address(),
133
+ nonce: creds.nonce(),
134
+ signatureExpirationLedger,
135
+ signature: payload,
136
+ })
137
+ ),
138
+ rootInvocation: original.rootInvocation(),
139
+ })
140
+ }
package/src/run/index.ts CHANGED
@@ -300,9 +300,9 @@ export async function runVerifyPolicy(raw: unknown): Promise<ToolResponse<true>>
300
300
  * Returns the unsigned Soroban transaction envelope (base64 XDR) the
301
301
  * wallet signs. The wallet signature IS the user-confirmation step - no
302
302
  * `action_id` two-call pair (the server is stateless, see server.ts:10-12).
303
- * Per design decision 4, only CALL 1 (account.add_context_rule) is
304
- * emitted; CALL 2 (interpreter.install) requires the rule id the account
305
- * assigns in call 1 and is documented under `followUp` in the response.
303
+ * One call installs the policy outright: `add_context_rule` carries the
304
+ * predicate to the interpreter in its `policies` install_param, so no
305
+ * separate `interpreter.install` call is needed or possible.
306
306
  *
307
307
  * Default-deny: an interpreter policy address other than the pinned
308
308
  * testnet interpreter is REFUSED (the smart account would delegate to