@crediolabs/policy-synth 0.1.12 → 0.1.13

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.
@@ -0,0 +1,686 @@
1
+ // src/install/build-install-policy.ts - builds the unsigned Soroban
2
+ // transaction XDR for `account.add_context_rule(...)` and
3
+ // `account.remove_context_rule(...)`.
4
+ //
5
+ // The MCP server is stateless and holds no key material (server.ts:10-12),
6
+ // so this module NEVER signs. The caller (the wallet / CLI / SDK consumer)
7
+ // wraps the returned XDR in a transaction envelope and signs that envelope
8
+ // with their wallet; the wallet signature IS the user-confirmation step.
9
+ //
10
+ // We deliberately depart from the spec in plans/phase-04 which calls for a
11
+ // two-call `install_policy`/`confirm_install` pair backed by a host-signed
12
+ // short-TTL `action_id`. That contract requires (a) a stateful store and
13
+ // (b) key material the server does not have, so we ship the simpler ONE-CALL
14
+ // shape. The wallet signature covers the change.
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`.
19
+
20
+ import { createHash } from 'node:crypto'
21
+ import {
22
+ Address,
23
+ BASE_FEE,
24
+ Contract,
25
+ Keypair,
26
+ Operation,
27
+ rpc,
28
+ scValToBigInt,
29
+ type Transaction,
30
+ TransactionBuilder,
31
+ xdr,
32
+ } from '@stellar/stellar-sdk'
33
+ import { buildAddContextRuleArgs, DEFAULT_GRAMMAR_VERSION } from './build-add-context-rule.ts'
34
+
35
+ /** Minimal Soroban RPC surface the install pipeline needs. Test seams
36
+ * inject a stub so the builder stays deterministic; production builds
37
+ * the real one via `createRpcServer` from `../record/rpc.ts`. */
38
+ export interface InstallRpcClient {
39
+ getAccount(address: string): Promise<{ sequenceNumber(): string }>
40
+ simulateTransaction(
41
+ tx: Transaction | Parameters<rpc.Server['simulateTransaction']>[0]
42
+ ): Promise<rpc.Api.SimulateTransactionResponse>
43
+ getLatestLedger(): Promise<{ sequence: number }>
44
+ /** Live `grammar_version()` lookup. Returns the deployed u32. */
45
+ getContractVersion(address: string): Promise<number>
46
+ }
47
+
48
+ /** Convert a real rpc.Server into the InstallRpcClient surface. The
49
+ * `getContractVersion` lookup uses `simulateTransaction` against
50
+ * `contract.call('grammar_version')` and decodes the returned u32.
51
+ *
52
+ * The passphrase is a REQUIRED argument rather than something read off the
53
+ * server: `rpc.Server` does not carry one, so reaching for
54
+ * `server.networkPassphrase` yielded a non-string and every version probe
55
+ * died with "Invalid passphrase provided to Transaction". The caller knows
56
+ * which network it dialled; make it say so. */
57
+ export function rpcClientFromServer(
58
+ server: rpc.Server,
59
+ networkPassphrase: string
60
+ ): InstallRpcClient {
61
+ return {
62
+ getAccount: (address) => server.getAccount(address),
63
+ simulateTransaction: (tx) => server.simulateTransaction(tx),
64
+ getLatestLedger: () => server.getLatestLedger(),
65
+ async getContractVersion(address: string): Promise<number> {
66
+ // The source account is constructed locally rather than fetched. This is
67
+ // a read-only simulation, so the sequence number is never checked, and a
68
+ // random key does NOT exist on chain - asking the network for it returns
69
+ // 404 and the version probe fails for a reason that has nothing to do
70
+ // with the contract being probed.
71
+ const account = new Account(Keypair.random().publicKey(), '0')
72
+ const tx = new TransactionBuilder(account, {
73
+ fee: BASE_FEE,
74
+ networkPassphrase,
75
+ })
76
+ .addOperation(new Contract(address).call('grammar_version'))
77
+ .setTimeout(30)
78
+ .build()
79
+ const sim = await server.simulateTransaction(tx)
80
+ if (rpc.Api.isSimulationError(sim)) {
81
+ throw new Error(`getContractVersion: simulateTransaction failed: ${sim.error}`)
82
+ }
83
+ if (!sim.result?.retval) {
84
+ throw new Error('getContractVersion: grammar_version() returned no value')
85
+ }
86
+ const native = scValToBigInt(sim.result.retval)
87
+ if (typeof native !== 'bigint') {
88
+ throw new Error('getContractVersion: grammar_version() did not return an integer')
89
+ }
90
+ return Number(native)
91
+ },
92
+ }
93
+ }
94
+
95
+ /** Inputs for the install-policy build. */
96
+ export interface BuildInstallPolicyArgs {
97
+ /** The smart account contract address (C...). */
98
+ smartAccount: string
99
+ /** The signer that authorises the install (G... wallet). */
100
+ sourceAccount: string
101
+ /** Network passphrase - pins the hash the auth digest binds. */
102
+ networkPassphrase: string
103
+ /** The new rule to install. Mirrors the core `ContextRuleDraft`. */
104
+ rule: BuildInstallPolicyRuleDraft
105
+ /** Per-rule install nonce; 1 for a fresh install. */
106
+ installNonce: number
107
+ /** Already-encoded (base64) canonical ScVal of the predicate. */
108
+ encodedPredicate: string
109
+ /** Hex sha256 of the canonical predicate XDR bytes. */
110
+ predicateHash: string
111
+ /** Per-policy oracle overrides. */
112
+ oracleParams?: {
113
+ maxStalenessSeconds?: number
114
+ maxDeviationBps?: number
115
+ maxCrossFeedDeviationBps?: number
116
+ }
117
+ /** Grammar version override; defaults to 1. */
118
+ grammarVersion?: number
119
+ /** Base fee in stroops; defaults to BASE_FEE (100). */
120
+ baseFee?: number
121
+ /** RPC client to use for the simulation pass; injected for tests. */
122
+ rpc: InstallRpcClient
123
+ /** Ledger window (in ledgers) for the auth entry's `validUntil`. */
124
+ authValidUntilLedgers?: number
125
+ }
126
+
127
+ export type BuildInstallPolicyRuleDraft = {
128
+ contextRuleType:
129
+ | { kind: 'default' }
130
+ | { kind: 'call_contract'; contract: string }
131
+ | { kind: 'create_contract'; wasmHash: string }
132
+ name: string
133
+ validUntilLedger: number | null
134
+ signers: BuildInstallPolicySignerDraft[]
135
+ policies: BuildInstallPolicyPolicyRef[]
136
+ }
137
+
138
+ export type BuildInstallPolicySignerDraft =
139
+ | { kind: 'delegated'; address: string }
140
+ | { kind: 'external'; verifier: string; keyBytes: string }
141
+
142
+ export type BuildInstallPolicyPolicyRef =
143
+ | {
144
+ kind: 'interpreter'
145
+ interpreterAddress: string
146
+ predicateBlobBase64: string
147
+ }
148
+ | {
149
+ kind: 'oz_builtin'
150
+ instanceAddress: string
151
+ primitive: {
152
+ primitive: 'spending_limit' | 'simple_threshold' | 'weighted_threshold'
153
+ params: Record<string, unknown>
154
+ }
155
+ }
156
+
157
+ /** Human-readable description of the install call, decoded FROM the built
158
+ * XDR (not from the input args). The XDR is the source of truth: a
159
+ * human approving a review card needs the description to mirror the bytes
160
+ * the wallet will sign. Deriving `describes` from the input args would
161
+ * re-describe the caller's intent rather than the transaction. */
162
+ export interface InstallCallDescribes {
163
+ /** Smart account that owns the new rule (echoed from the XDR). */
164
+ targetContract: string
165
+ /** The fn name on the target (always `add_context_rule` today). */
166
+ fnName: 'add_context_rule'
167
+ /** The new rule's display name, decoded from args[1]. */
168
+ ruleName: string
169
+ /** The `validUntil` ledger sequence decoded from args[2] (null when void). */
170
+ validUntilLedger: number | null
171
+ /** The signers attached to the rule, decoded from args[3]. The
172
+ * `verifier`/`keyBytes` payload of an external signer is omitted - it
173
+ * is not material to the review card and stays opaque to keep the
174
+ * description focused on what the human has to recognise. */
175
+ signers: Array<{ kind: 'delegated'; address: string } | { kind: 'external'; verifier: string }>
176
+ /** One entry per policy attached to the rule, decoded from the policies
177
+ * map (args[4]). The address is the map key; the kind + extras below
178
+ * describe the value. The interpreter policy also reports the
179
+ * sha256 of the predicate blob actually embedded in the XDR - so a
180
+ * mismatch between the wire bytes and the review card is detectable
181
+ * by reading `describes`. */
182
+ policies: Array<
183
+ | {
184
+ kind: 'interpreter'
185
+ address: string
186
+ installNonce: number
187
+ predicateHash: string
188
+ predicateSha256OfEmbeddedBytes: string
189
+ }
190
+ | {
191
+ kind: 'oz_builtin'
192
+ address: string
193
+ primitive: 'spending_limit' | 'simple_threshold' | 'weighted_threshold'
194
+ }
195
+ >
196
+ /** The install nonce, decoded from the interpreter policy's
197
+ * `install_nonce` field. Echoed at the top level for reviewer convenience;
198
+ * the per-policy entry is the source of truth. */
199
+ installNonce: number
200
+ }
201
+
202
+ /** Output of the install-policy build. The unsigned XDR is the wallet's
203
+ * input; the captured auth nonce + invocation root make the response
204
+ * self-describing for callers that want to inspect what they signed. */
205
+ export interface BuildInstallPolicyResult {
206
+ /** Unsigned Soroban transaction envelope, base64 XDR. */
207
+ unsignedXdr: string
208
+ /** Smart account contract address (echo). */
209
+ smartAccount: string
210
+ /** Source account (echo) - the address that must sign. */
211
+ 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. */
215
+ 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
+ /** Human-readable description of the install call, decoded FROM the
226
+ * built unsigned XDR (not from the input args). The wallet signature
227
+ * binds to bytes; the review card has to bind to the same bytes, so
228
+ * this is the only safe source. */
229
+ describes: InstallCallDescribes
230
+ /** The auth nonce the host assigned to this call (snapshot). */
231
+ authNonce: string
232
+ /** The ledger sequence + window the auth entry expires at. */
233
+ authValidUntilLedger: number
234
+ /** The captured rootInvocation so a downstream caller can verify the
235
+ * signature payload matches the one the host expects. */
236
+ rootInvocationXdr: string
237
+ }
238
+
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
+ /** Build the unsigned transaction envelope for `account.add_context_rule(...)`.
243
+ * The output XDR is signed by the wallet, not by us. */
244
+ export async function buildInstallPolicyXdr(
245
+ args: BuildInstallPolicyArgs
246
+ ): Promise<BuildInstallPolicyResult> {
247
+ // 1. Pure arg encoding first - fail closed on any limit / shape problem
248
+ // BEFORE we burn a network round-trip on a malformed call.
249
+ const callArgs = buildAddContextRuleArgs(
250
+ {
251
+ contextRuleType: args.rule.contextRuleType,
252
+ name: args.rule.name,
253
+ validUntilLedger: args.rule.validUntilLedger,
254
+ signers: args.rule.signers,
255
+ policies: args.rule.policies.map(adaptPolicyRef),
256
+ },
257
+ {
258
+ signers: args.rule.signers,
259
+ policies: args.rule.policies.map(adaptPolicyRef),
260
+ installNonce: args.installNonce,
261
+ encodedPredicate: args.encodedPredicate,
262
+ predicateHash: args.predicateHash,
263
+ ...(args.oracleParams ? { oracleParams: args.oracleParams } : {}),
264
+ ...(args.grammarVersion !== undefined ? { grammarVersion: args.grammarVersion } : {}),
265
+ }
266
+ )
267
+
268
+ // 2. Network pass: source account sequence + latest ledger.
269
+ 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
+ })
285
+ 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)
298
+ if (rpc.Api.isSimulationError(recorded)) {
299
+ // Short, stable reason. The full `simulateTransaction` error (which
300
+ // carries host + URL detail) stays in the SDK's own logs - never
301
+ // reflected back into a user-facing message where it would
302
+ // reconnoitre the RPC.
303
+ throw new Error('install_policy: simulateTransaction failed')
304
+ }
305
+ const original = (recorded.result?.auth ?? []).find(
306
+ (entry) =>
307
+ entry.credentials().switch().name === 'sorobanCredentialsAddress' &&
308
+ Address.fromScAddress(entry.credentials().address().address()).toString() ===
309
+ args.smartAccount
310
+ )
311
+ if (!original) {
312
+ throw new Error(
313
+ `install_policy: no Soroban auth entry for smart account ${args.smartAccount}; this call does not route through the smart account`
314
+ )
315
+ }
316
+
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)
323
+
324
+ return {
325
+ unsignedXdr: tx.toEnvelope().toXDR().toString('base64'),
326
+ smartAccount: args.smartAccount,
327
+ sourceAccount: args.sourceAccount,
328
+ 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
+ describes,
336
+ authNonce: original.credentials().address().nonce().toString(),
337
+ authValidUntilLedger: validUntilLedger,
338
+ rootInvocationXdr: original.rootInvocation().toXDR().toString('base64'),
339
+ }
340
+ }
341
+
342
+ /** Build an unsigned XDR for `account.remove_context_rule(ruleId)`. The
343
+ * smart account itself handles uninstalling each attached policy (calling
344
+ * `interpreter.uninstall` for the interpreter policy); this builder only
345
+ * emits the account-side removal call.
346
+ *
347
+ * Who may authorise it is the ACCOUNT's decision, and the account's source is
348
+ * not in this repo. The interpreter's own `uninstall` is master-gated, but
349
+ * that is a different entry point from this one, so do not restate it as the
350
+ * rule for this call. What is proven on testnet is that the account's
351
+ * deployer can revoke. This builder does not pre-check the signer: it would
352
+ * be guessing at a contract it cannot read, and the chain is the authority. */
353
+ export async function buildRevokePolicyXdr(args: {
354
+ smartAccount: string
355
+ sourceAccount: string
356
+ ruleId: number
357
+ networkPassphrase: string
358
+ rpc: InstallRpcClient
359
+ baseFee?: number
360
+ authValidUntilLedgers?: number
361
+ }): Promise<BuildRevokePolicyResult> {
362
+ 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
+ })
377
+ 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)
387
+ if (rpc.Api.isSimulationError(recorded)) {
388
+ // Short, stable reason. The full `simulateTransaction` error (which
389
+ // carries host + URL detail) stays in the SDK's own logs - never
390
+ // reflected back into a user-facing message where it would
391
+ // reconnoitre the RPC.
392
+ throw new Error('revoke_policy: simulateTransaction failed')
393
+ }
394
+ const original = (recorded.result?.auth ?? []).find(
395
+ (entry) =>
396
+ entry.credentials().switch().name === 'sorobanCredentialsAddress' &&
397
+ Address.fromScAddress(entry.credentials().address().address()).toString() ===
398
+ args.smartAccount
399
+ )
400
+ if (!original) {
401
+ throw new Error(
402
+ `revoke_policy: no Soroban auth entry for smart account ${args.smartAccount}; this call does not route through the smart account`
403
+ )
404
+ }
405
+
406
+ return {
407
+ unsignedXdr: tx.toEnvelope().toXDR().toString('base64'),
408
+ smartAccount: args.smartAccount,
409
+ sourceAccount: args.sourceAccount,
410
+ call: { contract: args.smartAccount, fn: 'remove_context_rule', ruleId: args.ruleId },
411
+ authNonce: original.credentials().address().nonce().toString(),
412
+ authValidUntilLedger: validUntilLedger,
413
+ rootInvocationXdr: original.rootInvocation().toXDR().toString('base64'),
414
+ }
415
+ }
416
+
417
+ export interface BuildRevokePolicyResult {
418
+ unsignedXdr: string
419
+ smartAccount: string
420
+ sourceAccount: string
421
+ call: { contract: string; fn: 'remove_context_rule'; ruleId: number }
422
+ authNonce: string
423
+ authValidUntilLedger: number
424
+ rootInvocationXdr: string
425
+ }
426
+
427
+ /** ~25 minutes at 5s/ledger. */
428
+ const DEFAULT_AUTH_VALID_LEDGERS = 300
429
+
430
+ // ---- internals ----
431
+
432
+ /** Adapter: turn the install-policy wire `PolicyRef` shape into the core
433
+ * `PolicyRef` shape `buildAddContextRuleArgs` expects. Keeps the wire
434
+ * schema hand-rolled + flat (the strict union would need a recursive
435
+ * schema) while delegating to the proven encoder for the actual bytes. */
436
+ function adaptPolicyRef(p: BuildInstallPolicyPolicyRef) {
437
+ if (p.kind === 'interpreter') {
438
+ return {
439
+ kind: 'interpreter' as const,
440
+ interpreterAddress: p.interpreterAddress,
441
+ predicateBlobBase64: p.predicateBlobBase64,
442
+ }
443
+ }
444
+ return {
445
+ kind: 'oz_builtin' as const,
446
+ instanceAddress: p.instanceAddress,
447
+ primitive: p.primitive,
448
+ }
449
+ }
450
+
451
+ /** Build an unsigned Soroban transaction envelope. The `sequence` is
452
+ * whatever `getAccount().sequenceNumber()` returned (a string of digits
453
+ * the SDK accepts). No signing. The returned Transaction is what
454
+ * `simulateTransaction` accepts. */
455
+ function buildUnsignedTx(args: {
456
+ sourceAccount: string
457
+ sequence: string
458
+ // String, not number: the SDK's BASE_FEE is a decimal string and
459
+ // TransactionBuilder wants one, so carrying a number here forced a
460
+ // conversion at every call site and the two disagreed.
461
+ fee: string
462
+ networkPassphrase: string
463
+ op: xdr.Operation
464
+ }): Transaction {
465
+ const account = new Account(args.sourceAccount, args.sequence)
466
+ const tx = new TransactionBuilder(account, {
467
+ fee: args.fee,
468
+ networkPassphrase: args.networkPassphrase,
469
+ })
470
+ .addOperation(args.op)
471
+ .setTimeout(0)
472
+ .build()
473
+ // `TransactionBuilder.build()` returns a Transaction whose envelope
474
+ // has empty signatures. The wallet re-reads the unsigned XDR, appends
475
+ // its signature, and broadcasts. We do NOT sign here.
476
+ return tx
477
+ }
478
+
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
485
+ * the encoder changed and this descriptor lags).
486
+ *
487
+ * `expectedInstallNonce` is a fallback for the rare case where the
488
+ * install has no interpreter policy (no `install_nonce` to read); the
489
+ * caller already knows it. */
490
+ function decodeInstallCallDescribes(
491
+ tx: Transaction,
492
+ op: xdr.Operation,
493
+ expectedInstallNonce: number
494
+ ): InstallCallDescribes {
495
+ const hostFn = op.body().invokeHostFunctionOp()?.hostFunction()
496
+ if (!hostFn || hostFn.switch().name !== 'hostFunctionTypeInvokeContract') {
497
+ throw new Error(
498
+ 'install_policy: built op is not an invokeHostFunction(invokeContract(...)) call'
499
+ )
500
+ }
501
+ const invokeArgs = hostFn.invokeContract()
502
+ if (!invokeArgs) {
503
+ throw new Error('install_policy: built op has no InvokeContractArgs payload')
504
+ }
505
+ const targetContract = Address.fromScAddress(invokeArgs.contractAddress()).toString()
506
+ const fnName = invokeArgs.functionName().toString()
507
+ if (fnName !== 'add_context_rule') {
508
+ throw new Error(`install_policy: built op fn name is "${fnName}", expected "add_context_rule"`)
509
+ }
510
+ const scvArgs = invokeArgs.args()
511
+ if (scvArgs.length !== 5) {
512
+ throw new Error(
513
+ `install_policy: built op has ${scvArgs.length} args, expected 5 (context_type, name, valid_until, signers, policies)`
514
+ )
515
+ }
516
+ const contextType = scvArgs[0]
517
+ const nameScv = scvArgs[1]
518
+ const validUntilScv = scvArgs[2]
519
+ const signersScv = scvArgs[3]
520
+ const policiesScv = scvArgs[4]
521
+ // The length check above pins these as defined; the local references
522
+ // satisfy noUncheckedIndexedAccess on the accessors below.
523
+ if (!nameScv || !validUntilScv || !signersScv || !policiesScv || !contextType) {
524
+ throw new Error('install_policy: built op args include an undefined slot despite length check')
525
+ }
526
+
527
+ // name
528
+ if (nameScv.switch().name !== 'scvString') {
529
+ throw new Error('install_policy: args[1] (name) is not an ScVal::String')
530
+ }
531
+ const ruleName = nameScv.str().toString()
532
+
533
+ // valid_until (Option<u32> -> void when absent)
534
+ let validUntilLedger: number | null
535
+ if (validUntilScv.switch().name === 'scvVoid') {
536
+ validUntilLedger = null
537
+ } else if (validUntilScv.switch().name === 'scvU32') {
538
+ validUntilLedger = validUntilScv.u32()
539
+ } else {
540
+ throw new Error('install_policy: args[2] (valid_until) is neither void nor u32')
541
+ }
542
+
543
+ // signers (Vec<Vec<Symbol, Address[, Bytes]>>)
544
+ if (signersScv.switch().name !== 'scvVec') {
545
+ throw new Error('install_policy: args[3] (signers) is not an ScVal::Vec')
546
+ }
547
+ const signers: InstallCallDescribes['signers'] = []
548
+ for (const signerScv of signersScv.vec() ?? []) {
549
+ if (signerScv.switch().name !== 'scvVec') {
550
+ throw new Error('install_policy: a signer entry is not an ScVal::Vec')
551
+ }
552
+ const tuple = signerScv.vec() ?? []
553
+ const tag = tuple[0]?.sym().toString()
554
+ const inner = tuple[1]
555
+ if (tag === 'Delegated') {
556
+ if (!inner || inner.switch().name !== 'scvAddress') {
557
+ throw new Error('install_policy: a Delegated signer is missing its Address')
558
+ }
559
+ signers.push({
560
+ kind: 'delegated',
561
+ address: Address.fromScAddress(inner.address()).toString(),
562
+ })
563
+ continue
564
+ }
565
+ if (tag === 'External') {
566
+ if (!inner || inner.switch().name !== 'scvAddress') {
567
+ throw new Error('install_policy: an External signer is missing its verifier Address')
568
+ }
569
+ signers.push({
570
+ kind: 'external',
571
+ verifier: Address.fromScAddress(inner.address()).toString(),
572
+ })
573
+ continue
574
+ }
575
+ throw new Error(`install_policy: signer tag "${tag ?? '<missing>'}" is not Delegated|External`)
576
+ }
577
+
578
+ // policies (Map<Address, Val>). Re-derive an interpreter-vs-OZ primitive
579
+ // classification by inspecting the value's map keys (PolicyInstallParams
580
+ // starts with `grammar_version`/`install_nonce`/`predicate`; OZ primitive
581
+ // params start with `spending_limit`/`threshold`/`period_ledgers`).
582
+ if (policiesScv.switch().name !== 'scvMap') {
583
+ throw new Error('install_policy: args[4] (policies) is not an ScVal::Map')
584
+ }
585
+ const policies: InstallCallDescribes['policies'] = []
586
+ let observedInstallNonce: number | null = null
587
+ for (const entry of policiesScv.map() ?? []) {
588
+ const key = entry.key()
589
+ if (key.switch().name !== 'scvAddress') {
590
+ throw new Error('install_policy: a policies map entry has a non-Address key')
591
+ }
592
+ const address = Address.fromScAddress(key.address()).toString()
593
+ const val = entry.val()
594
+ if (val.switch().name !== 'scvMap') {
595
+ throw new Error(
596
+ `install_policy: policies[${address}] value is not an ScVal::Map (got ${val.switch().name})`
597
+ )
598
+ }
599
+ const fields = new Map<string, xdr.ScVal>()
600
+ for (const inner of val.map() ?? []) {
601
+ const fk = inner.key()
602
+ if (fk.switch().name !== 'scvSymbol') {
603
+ throw new Error(`install_policy: policies[${address}] field key is not an ScVal::Symbol`)
604
+ }
605
+ fields.set(fk.sym().toString(), inner.val())
606
+ }
607
+ // Interpreter policy fields carry grammar_version/install_nonce/predicate.
608
+ // OZ primitives carry spending_limit/period_ledgers/threshold/signers.
609
+ if (fields.has('predicate') || fields.has('grammar_version') || fields.has('install_nonce')) {
610
+ const installNonceScv = fields.get('install_nonce')
611
+ if (!installNonceScv || installNonceScv.switch().name !== 'scvU32') {
612
+ throw new Error(
613
+ `install_policy: interpreter policy ${address} is missing a u32 install_nonce`
614
+ )
615
+ }
616
+ const installNonce = installNonceScv.u32()
617
+ const predicateScv = fields.get('predicate')
618
+ if (!predicateScv || predicateScv.switch().name !== 'scvBytes') {
619
+ throw new Error(
620
+ `install_policy: interpreter policy ${address} is missing its bytes predicate`
621
+ )
622
+ }
623
+ const predicateBytes = Buffer.from(predicateScv.bytes())
624
+ const predicateSha256OfEmbeddedBytes = createHash('sha256')
625
+ .update(predicateBytes)
626
+ .digest('hex')
627
+ const predicateHashScv = fields.get('predicate_hash')
628
+ const predicateHash =
629
+ predicateHashScv && predicateHashScv.switch().name === 'scvBytes'
630
+ ? Buffer.from(predicateHashScv.bytes()).toString('hex')
631
+ : ''
632
+ policies.push({
633
+ kind: 'interpreter',
634
+ address,
635
+ installNonce,
636
+ predicateHash,
637
+ predicateSha256OfEmbeddedBytes,
638
+ })
639
+ observedInstallNonce = installNonce
640
+ continue
641
+ }
642
+ // OZ built-in primitive. Distinguish by the parameter shape we
643
+ // emitted; matching one of the three primitives exactly pins the
644
+ // kind we built.
645
+ if (fields.has('spending_limit') && fields.has('period_ledgers')) {
646
+ policies.push({ kind: 'oz_builtin', address, primitive: 'spending_limit' })
647
+ continue
648
+ }
649
+ if (fields.has('threshold') && fields.has('signer_weights')) {
650
+ policies.push({ kind: 'oz_builtin', address, primitive: 'weighted_threshold' })
651
+ continue
652
+ }
653
+ if (fields.has('threshold')) {
654
+ policies.push({ kind: 'oz_builtin', address, primitive: 'simple_threshold' })
655
+ continue
656
+ }
657
+ throw new Error(
658
+ `install_policy: policies[${address}] value has an unknown field set; the encoder may have drifted`
659
+ )
660
+ }
661
+ // `observedInstallNonce` is the nonce baked into whichever interpreter
662
+ // policy is present; when none, fall back to the caller-supplied value.
663
+ 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
+ return {
669
+ targetContract,
670
+ fnName: 'add_context_rule',
671
+ ruleName,
672
+ validUntilLedger,
673
+ signers,
674
+ policies,
675
+ installNonce,
676
+ }
677
+ }
678
+
679
+ /** Re-export so the run-layer does not need to import from
680
+ * build-add-context-rule.ts (keeps the install/ -> install/ dependency
681
+ * direction intact). */
682
+ export { Contract, DEFAULT_GRAMMAR_VERSION }
683
+
684
+ // Local re-import to avoid pulling the class from the SDK module path
685
+ // at the top of the file (avoids the unused-import lint).
686
+ import { Account } from '@stellar/stellar-sdk'