@crediolabs/policy-synth 0.1.12 → 0.1.14

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,753 @@
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
+ import {
35
+ accountEntry,
36
+ authDigest,
37
+ authPayload,
38
+ delegatedSignerEntry,
39
+ signaturePayload,
40
+ } from './oz-auth.ts'
41
+
42
+ /** Minimal Soroban RPC surface the install pipeline needs. Test seams
43
+ * inject a stub so the builder stays deterministic; production builds
44
+ * the real one via `createRpcServer` from `../record/rpc.ts`. */
45
+ export interface InstallRpcClient {
46
+ getAccount(address: string): Promise<{ sequenceNumber(): string }>
47
+ simulateTransaction(
48
+ tx: Transaction | Parameters<rpc.Server['simulateTransaction']>[0]
49
+ ): Promise<rpc.Api.SimulateTransactionResponse>
50
+ getLatestLedger(): Promise<{ sequence: number }>
51
+ /** Live `grammar_version()` lookup. Returns the deployed u32. */
52
+ getContractVersion(address: string): Promise<number>
53
+ }
54
+
55
+ /** Convert a real rpc.Server into the InstallRpcClient surface. The
56
+ * `getContractVersion` lookup uses `simulateTransaction` against
57
+ * `contract.call('grammar_version')` and decodes the returned u32.
58
+ *
59
+ * The passphrase is a REQUIRED argument rather than something read off the
60
+ * server: `rpc.Server` does not carry one, so reaching for
61
+ * `server.networkPassphrase` yielded a non-string and every version probe
62
+ * died with "Invalid passphrase provided to Transaction". The caller knows
63
+ * which network it dialled; make it say so. */
64
+ export function rpcClientFromServer(
65
+ server: rpc.Server,
66
+ networkPassphrase: string
67
+ ): InstallRpcClient {
68
+ return {
69
+ getAccount: (address) => server.getAccount(address),
70
+ simulateTransaction: (tx) => server.simulateTransaction(tx),
71
+ getLatestLedger: () => server.getLatestLedger(),
72
+ async getContractVersion(address: string): Promise<number> {
73
+ // The source account is constructed locally rather than fetched. This is
74
+ // a read-only simulation, so the sequence number is never checked, and a
75
+ // random key does NOT exist on chain - asking the network for it returns
76
+ // 404 and the version probe fails for a reason that has nothing to do
77
+ // with the contract being probed.
78
+ const account = new Account(Keypair.random().publicKey(), '0')
79
+ const tx = new TransactionBuilder(account, {
80
+ fee: BASE_FEE,
81
+ networkPassphrase,
82
+ })
83
+ .addOperation(new Contract(address).call('grammar_version'))
84
+ .setTimeout(30)
85
+ .build()
86
+ const sim = await server.simulateTransaction(tx)
87
+ if (rpc.Api.isSimulationError(sim)) {
88
+ throw new Error(`getContractVersion: simulateTransaction failed: ${sim.error}`)
89
+ }
90
+ if (!sim.result?.retval) {
91
+ throw new Error('getContractVersion: grammar_version() returned no value')
92
+ }
93
+ const native = scValToBigInt(sim.result.retval)
94
+ if (typeof native !== 'bigint') {
95
+ throw new Error('getContractVersion: grammar_version() did not return an integer')
96
+ }
97
+ return Number(native)
98
+ },
99
+ }
100
+ }
101
+
102
+ /** Inputs for the install-policy build. */
103
+ export interface BuildInstallPolicyArgs {
104
+ /** The smart account contract address (C...). */
105
+ smartAccount: string
106
+ /** The signer that authorises the install (G... wallet). */
107
+ sourceAccount: string
108
+ /** Network passphrase - pins the hash the auth digest binds. */
109
+ networkPassphrase: string
110
+ /** The new rule to install. Mirrors the core `ContextRuleDraft`. */
111
+ rule: BuildInstallPolicyRuleDraft
112
+ /** Per-rule install nonce; 1 for a fresh install. */
113
+ installNonce: number
114
+ /** Already-encoded (base64) canonical ScVal of the predicate. */
115
+ encodedPredicate: string
116
+ /** Hex sha256 of the canonical predicate XDR bytes. */
117
+ predicateHash: string
118
+ /** Per-policy oracle overrides. */
119
+ oracleParams?: {
120
+ maxStalenessSeconds?: number
121
+ maxDeviationBps?: number
122
+ maxCrossFeedDeviationBps?: number
123
+ }
124
+ /** Grammar version override; defaults to 1. */
125
+ grammarVersion?: number
126
+ /** Base fee in stroops; defaults to BASE_FEE (100). */
127
+ baseFee?: number
128
+ /** RPC client to use for the simulation pass; injected for tests. */
129
+ rpc: InstallRpcClient
130
+ /** Ledger window (in ledgers) for the auth entry's `validUntil`. */
131
+ authValidUntilLedgers?: number
132
+ }
133
+
134
+ export type BuildInstallPolicyRuleDraft = {
135
+ contextRuleType:
136
+ | { kind: 'default' }
137
+ | { kind: 'call_contract'; contract: string }
138
+ | { kind: 'create_contract'; wasmHash: string }
139
+ name: string
140
+ validUntilLedger: number | null
141
+ signers: BuildInstallPolicySignerDraft[]
142
+ policies: BuildInstallPolicyPolicyRef[]
143
+ }
144
+
145
+ export type BuildInstallPolicySignerDraft =
146
+ | { kind: 'delegated'; address: string }
147
+ | { kind: 'external'; verifier: string; keyBytes: string }
148
+
149
+ export type BuildInstallPolicyPolicyRef =
150
+ | {
151
+ kind: 'interpreter'
152
+ interpreterAddress: string
153
+ predicateBlobBase64: string
154
+ }
155
+ | {
156
+ kind: 'oz_builtin'
157
+ instanceAddress: string
158
+ primitive: {
159
+ primitive: 'spending_limit' | 'simple_threshold' | 'weighted_threshold'
160
+ params: Record<string, unknown>
161
+ }
162
+ }
163
+
164
+ /** Human-readable description of the install call, decoded FROM the built
165
+ * XDR (not from the input args). The XDR is the source of truth: a
166
+ * human approving a review card needs the description to mirror the bytes
167
+ * the wallet will sign. Deriving `describes` from the input args would
168
+ * re-describe the caller's intent rather than the transaction. */
169
+ export interface InstallCallDescribes {
170
+ /** Smart account that owns the new rule (echoed from the XDR). */
171
+ targetContract: string
172
+ /** The fn name on the target (always `add_context_rule` today). */
173
+ fnName: 'add_context_rule'
174
+ /** The new rule's display name, decoded from args[1]. */
175
+ ruleName: string
176
+ /** The `validUntil` ledger sequence decoded from args[2] (null when void). */
177
+ validUntilLedger: number | null
178
+ /** The signers attached to the rule, decoded from args[3]. The
179
+ * `verifier`/`keyBytes` payload of an external signer is omitted - it
180
+ * is not material to the review card and stays opaque to keep the
181
+ * description focused on what the human has to recognise. */
182
+ signers: Array<{ kind: 'delegated'; address: string } | { kind: 'external'; verifier: string }>
183
+ /** One entry per policy attached to the rule, decoded from the policies
184
+ * map (args[4]). The address is the map key; the kind + extras below
185
+ * describe the value. The interpreter policy also reports the
186
+ * sha256 of the predicate blob actually embedded in the XDR - so a
187
+ * mismatch between the wire bytes and the review card is detectable
188
+ * by reading `describes`. */
189
+ policies: Array<
190
+ | {
191
+ kind: 'interpreter'
192
+ address: string
193
+ installNonce: number
194
+ predicateHash: string
195
+ predicateSha256OfEmbeddedBytes: string
196
+ }
197
+ | {
198
+ kind: 'oz_builtin'
199
+ address: string
200
+ primitive: 'spending_limit' | 'simple_threshold' | 'weighted_threshold'
201
+ }
202
+ >
203
+ /** The install nonce, decoded from the interpreter policy's
204
+ * `install_nonce` field. Echoed at the top level for reviewer convenience;
205
+ * the per-policy entry is the source of truth. */
206
+ installNonce: number
207
+ }
208
+
209
+ /** Output of the install-policy build. The unsigned XDR is the wallet's
210
+ * input; the captured auth nonce + invocation root make the response
211
+ * self-describing for callers that want to inspect what they signed. */
212
+ export interface BuildInstallPolicyResult {
213
+ /** Unsigned Soroban transaction envelope, base64 XDR. */
214
+ unsignedXdr: string
215
+ /** Smart account contract address (echo). */
216
+ smartAccount: string
217
+ /** Source account (echo) - the address that must sign. */
218
+ sourceAccount: string
219
+ /** The host-call target + fn name. Call 2 (interpreter.install) is NOT
220
+ * emitted here; the account assigns the rule id in call 1 and call 2
221
+ * needs that id to bind its `context_rule_ids`. See `followUp` below. */
222
+ call: { contract: string; fn: 'add_context_rule' }
223
+ /** The follow-up call the caller MUST issue after this one confirms.
224
+ * The rule id the account assigns in call 1 is required to bind the
225
+ * interpreter's `install` auth context. */
226
+ followUp: {
227
+ contract: 'interpreter'
228
+ fn: 'install'
229
+ requiresRuleIdFromCallOne: true
230
+ hint: string
231
+ }
232
+ /** Human-readable description of the install call, decoded FROM the
233
+ * built unsigned XDR (not from the input args). The wallet signature
234
+ * binds to bytes; the review card has to bind to the same bytes, so
235
+ * this is the only safe source. */
236
+ describes: InstallCallDescribes
237
+ /** The auth nonce the host assigned to this call (snapshot). */
238
+ authNonce: string
239
+ /** The ledger sequence + window the auth entry expires at. */
240
+ authValidUntilLedger: number
241
+ /** The captured rootInvocation so a downstream caller can verify the
242
+ * signature payload matches the one the host expects. */
243
+ rootInvocationXdr: string
244
+ }
245
+
246
+ const FOLLOWUP_HINT =
247
+ '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.'
248
+
249
+ /** Build the unsigned transaction envelope for `account.add_context_rule(...)`.
250
+ * The output XDR is signed by the wallet, not by us. */
251
+ export async function buildInstallPolicyXdr(
252
+ args: BuildInstallPolicyArgs
253
+ ): Promise<BuildInstallPolicyResult> {
254
+ // 1. Pure arg encoding first - fail closed on any limit / shape problem
255
+ // BEFORE we burn a network round-trip on a malformed call.
256
+ const callArgs = buildAddContextRuleArgs(
257
+ {
258
+ contextRuleType: args.rule.contextRuleType,
259
+ name: args.rule.name,
260
+ validUntilLedger: args.rule.validUntilLedger,
261
+ signers: args.rule.signers,
262
+ policies: args.rule.policies.map(adaptPolicyRef),
263
+ },
264
+ {
265
+ signers: args.rule.signers,
266
+ policies: args.rule.policies.map(adaptPolicyRef),
267
+ installNonce: args.installNonce,
268
+ encodedPredicate: args.encodedPredicate,
269
+ predicateHash: args.predicateHash,
270
+ ...(args.oracleParams ? { oracleParams: args.oracleParams } : {}),
271
+ ...(args.grammarVersion !== undefined ? { grammarVersion: args.grammarVersion } : {}),
272
+ }
273
+ )
274
+
275
+ // 2. Fetch the source sequence, then build the recording transaction with a
276
+ // bare host call. The recording pass assigns the smart-account auth nonce
277
+ // and root invocation needed to construct OZ's AuthPayload.
278
+ const source = await args.rpc.getAccount(args.sourceAccount)
279
+ const hostFunction = xdr.HostFunction.hostFunctionTypeInvokeContract(
280
+ new xdr.InvokeContractArgs({
281
+ contractAddress: new Address(args.smartAccount).toScAddress(),
282
+ functionName: 'add_context_rule',
283
+ args: [...callArgs],
284
+ })
285
+ )
286
+ const makeOperation = (auth: xdr.SorobanAuthorizationEntry[] = []) =>
287
+ Operation.invokeHostFunction({ func: hostFunction, auth })
288
+ const baseFee = args.baseFee !== undefined ? String(args.baseFee) : BASE_FEE
289
+ const buildTx = (op: xdr.Operation) =>
290
+ buildUnsignedTx({
291
+ sourceAccount: args.sourceAccount,
292
+ sequence: source.sequenceNumber(),
293
+ fee: baseFee,
294
+ networkPassphrase: args.networkPassphrase,
295
+ op,
296
+ })
297
+ const recordingTx = buildTx(makeOperation())
298
+
299
+ // 3. Recording simulation: capture the smart account's nonce and invocation
300
+ // tree. Nothing is signed here.
301
+ const recorded = await args.rpc.simulateTransaction(recordingTx)
302
+ if (rpc.Api.isSimulationError(recorded)) {
303
+ // Short, stable reason. The full `simulateTransaction` error (which
304
+ // carries host + URL detail) stays in the SDK's own logs - never
305
+ // reflected back into a user-facing message where it would
306
+ // reconnoitre the RPC.
307
+ throw new Error('install_policy: simulateTransaction failed')
308
+ }
309
+ const original = (recorded.result?.auth ?? []).find(
310
+ (entry) =>
311
+ entry.credentials().switch().name === 'sorobanCredentialsAddress' &&
312
+ Address.fromScAddress(entry.credentials().address().address()).toString() ===
313
+ args.smartAccount
314
+ )
315
+ if (!original) {
316
+ throw new Error(
317
+ `install_policy: no Soroban auth entry for smart account ${args.smartAccount}; this call does not route through the smart account`
318
+ )
319
+ }
320
+
321
+ // 4. `add_context_rule` is authorised by the deploy-time admin rule (rule 0).
322
+ // The delegated signer uses source-account credentials, so its signature
323
+ // bytes stay empty and the ordinary transaction-envelope signature covers
324
+ // the call when the consumer signs it.
325
+ const validUntilLedger =
326
+ (await args.rpc.getLatestLedger()).sequence +
327
+ (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_UNTIL_LEDGERS)
328
+ const contextRuleIds = [0]
329
+ const digest = authDigest(
330
+ signaturePayload(
331
+ args.networkPassphrase,
332
+ original.credentials().address().nonce(),
333
+ validUntilLedger,
334
+ original.rootInvocation()
335
+ ),
336
+ contextRuleIds
337
+ )
338
+ const authEntries = [
339
+ accountEntry(
340
+ original,
341
+ validUntilLedger,
342
+ authPayload([args.sourceAccount], contextRuleIds, () => Buffer.alloc(0))
343
+ ),
344
+ ...contextRuleIds.map(() => delegatedSignerEntry(args.smartAccount, digest)),
345
+ ]
346
+
347
+ // 5. Simulate again with the OZ entries already attached. The SDK preserves
348
+ // existing auth during assembly and adds the simulated Soroban footprint +
349
+ // resource fee, yielding a complete unsigned envelope.
350
+ const txWithAuth = buildTx(makeOperation(authEntries))
351
+ const enforcing = await args.rpc.simulateTransaction(txWithAuth)
352
+ if (rpc.Api.isSimulationError(enforcing)) {
353
+ throw new Error('install_policy: auth simulateTransaction failed')
354
+ }
355
+ const finalTx = rpc.assembleTransaction(txWithAuth, enforcing).build()
356
+
357
+ // 6. Decode the structured description FROM the final assembled transaction.
358
+ // The human approval binds to the exact bytes the wallet will sign.
359
+ const describes = decodeInstallCallDescribes(finalTx, args.installNonce)
360
+
361
+ return {
362
+ unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
363
+ smartAccount: args.smartAccount,
364
+ sourceAccount: args.sourceAccount,
365
+ call: { contract: args.smartAccount, fn: 'add_context_rule' },
366
+ followUp: {
367
+ contract: 'interpreter',
368
+ fn: 'install',
369
+ requiresRuleIdFromCallOne: true,
370
+ hint: FOLLOWUP_HINT,
371
+ },
372
+ describes,
373
+ authNonce: original.credentials().address().nonce().toString(),
374
+ authValidUntilLedger: validUntilLedger,
375
+ rootInvocationXdr: original.rootInvocation().toXDR().toString('base64'),
376
+ }
377
+ }
378
+
379
+ /** Build an unsigned XDR for `account.remove_context_rule(ruleId)`. The
380
+ * smart account itself handles uninstalling each attached policy (calling
381
+ * `interpreter.uninstall` for the interpreter policy); this builder only
382
+ * emits the account-side removal call.
383
+ *
384
+ * Who may authorise it is the ACCOUNT's decision, and the account's source is
385
+ * not in this repo. The interpreter's own `uninstall` is master-gated, but
386
+ * that is a different entry point from this one, so do not restate it as the
387
+ * rule for this call. What is proven on testnet is that the account's
388
+ * deployer can revoke. This builder does not pre-check the signer: it would
389
+ * be guessing at a contract it cannot read, and the chain is the authority. */
390
+ export async function buildRevokePolicyXdr(args: {
391
+ smartAccount: string
392
+ sourceAccount: string
393
+ ruleId: number
394
+ networkPassphrase: string
395
+ rpc: InstallRpcClient
396
+ baseFee?: number
397
+ authValidUntilLedgers?: number
398
+ }): Promise<BuildRevokePolicyResult> {
399
+ const source = await args.rpc.getAccount(args.sourceAccount)
400
+ const hostFunction = xdr.HostFunction.hostFunctionTypeInvokeContract(
401
+ new xdr.InvokeContractArgs({
402
+ contractAddress: new Address(args.smartAccount).toScAddress(),
403
+ functionName: 'remove_context_rule',
404
+ args: [xdr.ScVal.scvU32(args.ruleId)],
405
+ })
406
+ )
407
+ const makeOperation = (auth: xdr.SorobanAuthorizationEntry[] = []) =>
408
+ Operation.invokeHostFunction({ func: hostFunction, auth })
409
+ const baseFee = args.baseFee !== undefined ? String(args.baseFee) : BASE_FEE
410
+ const buildTx = (op: xdr.Operation) =>
411
+ buildUnsignedTx({
412
+ sourceAccount: args.sourceAccount,
413
+ sequence: source.sequenceNumber(),
414
+ fee: baseFee,
415
+ networkPassphrase: args.networkPassphrase,
416
+ op,
417
+ })
418
+
419
+ const recorded = await args.rpc.simulateTransaction(buildTx(makeOperation()))
420
+ if (rpc.Api.isSimulationError(recorded)) {
421
+ // Short, stable reason. The full `simulateTransaction` error (which
422
+ // carries host + URL detail) stays in the SDK's own logs - never
423
+ // reflected back into a user-facing message where it would
424
+ // reconnoitre the RPC.
425
+ throw new Error('revoke_policy: simulateTransaction failed')
426
+ }
427
+ const original = (recorded.result?.auth ?? []).find(
428
+ (entry) =>
429
+ entry.credentials().switch().name === 'sorobanCredentialsAddress' &&
430
+ Address.fromScAddress(entry.credentials().address().address()).toString() ===
431
+ args.smartAccount
432
+ )
433
+ if (!original) {
434
+ throw new Error(
435
+ `revoke_policy: no Soroban auth entry for smart account ${args.smartAccount}; this call does not route through the smart account`
436
+ )
437
+ }
438
+
439
+ // Removal is master-only, so the deploy-time admin rule authorises it. The
440
+ // recorded rootInvocation still includes remove_context_rule(ruleId), binding
441
+ // the auth payload to the exact rule being removed. As with install,
442
+ // source-account credentials carry the delegated signer entry and the
443
+ // consumer supplies only the ordinary envelope signature.
444
+ const validUntilLedger =
445
+ (await args.rpc.getLatestLedger()).sequence +
446
+ (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_UNTIL_LEDGERS)
447
+ const contextRuleIds = [0]
448
+ const digest = authDigest(
449
+ signaturePayload(
450
+ args.networkPassphrase,
451
+ original.credentials().address().nonce(),
452
+ validUntilLedger,
453
+ original.rootInvocation()
454
+ ),
455
+ contextRuleIds
456
+ )
457
+ const authEntries = [
458
+ accountEntry(
459
+ original,
460
+ validUntilLedger,
461
+ authPayload([args.sourceAccount], contextRuleIds, () => Buffer.alloc(0))
462
+ ),
463
+ ...contextRuleIds.map(() => delegatedSignerEntry(args.smartAccount, digest)),
464
+ ]
465
+ const txWithAuth = buildTx(makeOperation(authEntries))
466
+ const enforcing = await args.rpc.simulateTransaction(txWithAuth)
467
+ if (rpc.Api.isSimulationError(enforcing)) {
468
+ throw new Error('revoke_policy: auth simulateTransaction failed')
469
+ }
470
+ const finalTx = rpc.assembleTransaction(txWithAuth, enforcing).build()
471
+
472
+ return {
473
+ unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
474
+ smartAccount: args.smartAccount,
475
+ sourceAccount: args.sourceAccount,
476
+ call: { contract: args.smartAccount, fn: 'remove_context_rule', ruleId: args.ruleId },
477
+ authNonce: original.credentials().address().nonce().toString(),
478
+ authValidUntilLedger: validUntilLedger,
479
+ rootInvocationXdr: original.rootInvocation().toXDR().toString('base64'),
480
+ }
481
+ }
482
+
483
+ export interface BuildRevokePolicyResult {
484
+ unsignedXdr: string
485
+ smartAccount: string
486
+ sourceAccount: string
487
+ call: { contract: string; fn: 'remove_context_rule'; ruleId: number }
488
+ authNonce: string
489
+ authValidUntilLedger: number
490
+ rootInvocationXdr: string
491
+ }
492
+
493
+ /** ~25 minutes at 5s/ledger. */
494
+ const DEFAULT_AUTH_VALID_UNTIL_LEDGERS = 300
495
+
496
+ // ---- internals ----
497
+
498
+ /** Adapter: turn the install-policy wire `PolicyRef` shape into the core
499
+ * `PolicyRef` shape `buildAddContextRuleArgs` expects. Keeps the wire
500
+ * schema hand-rolled + flat (the strict union would need a recursive
501
+ * schema) while delegating to the proven encoder for the actual bytes. */
502
+ function adaptPolicyRef(p: BuildInstallPolicyPolicyRef) {
503
+ if (p.kind === 'interpreter') {
504
+ return {
505
+ kind: 'interpreter' as const,
506
+ interpreterAddress: p.interpreterAddress,
507
+ predicateBlobBase64: p.predicateBlobBase64,
508
+ }
509
+ }
510
+ return {
511
+ kind: 'oz_builtin' as const,
512
+ instanceAddress: p.instanceAddress,
513
+ primitive: p.primitive,
514
+ }
515
+ }
516
+
517
+ /** Build an unsigned Soroban transaction envelope. The `sequence` is
518
+ * whatever `getAccount().sequenceNumber()` returned (a string of digits
519
+ * the SDK accepts). No signing. The returned Transaction is what
520
+ * `simulateTransaction` accepts. */
521
+ function buildUnsignedTx(args: {
522
+ sourceAccount: string
523
+ sequence: string
524
+ // String, not number: the SDK's BASE_FEE is a decimal string and
525
+ // TransactionBuilder wants one, so carrying a number here forced a
526
+ // conversion at every call site and the two disagreed.
527
+ fee: string
528
+ networkPassphrase: string
529
+ op: xdr.Operation
530
+ }): Transaction {
531
+ const account = new Account(args.sourceAccount, args.sequence)
532
+ const tx = new TransactionBuilder(account, {
533
+ fee: args.fee,
534
+ networkPassphrase: args.networkPassphrase,
535
+ })
536
+ .addOperation(args.op)
537
+ .setTimeout(0)
538
+ .build()
539
+ // `TransactionBuilder.build()` returns a Transaction whose envelope
540
+ // has empty signatures. The wallet re-reads the unsigned XDR, appends
541
+ // its signature, and broadcasts. We do NOT sign here.
542
+ return tx
543
+ }
544
+
545
+ /** Decode the install call's structured fields directly out of the final
546
+ * assembled transaction. The bytes are the source of truth: a human reviewing
547
+ * the install wants to see what the wallet will actually sign, not what we
548
+ * think it will. The decode is deliberately strict - any shape we did not
549
+ * build ourselves throws, since that would mean the XDR was tampered with (or
550
+ * the encoder changed and this descriptor lags).
551
+ *
552
+ * `expectedInstallNonce` is a fallback for the rare case where the
553
+ * install has no interpreter policy (no `install_nonce` to read); the
554
+ * caller already knows it. */
555
+ function decodeInstallCallDescribes(
556
+ tx: Transaction,
557
+ expectedInstallNonce: number
558
+ ): InstallCallDescribes {
559
+ const operations = tx.toEnvelope().v1().tx().operations()
560
+ if (operations.length !== 1 || !operations[0]) {
561
+ throw new Error(
562
+ `install_policy: final transaction has ${operations.length} operations, expected 1`
563
+ )
564
+ }
565
+ const op = operations[0]
566
+ const hostFn = op.body().invokeHostFunctionOp()?.hostFunction()
567
+ if (hostFn?.switch().name !== 'hostFunctionTypeInvokeContract') {
568
+ throw new Error(
569
+ 'install_policy: built op is not an invokeHostFunction(invokeContract(...)) call'
570
+ )
571
+ }
572
+ const invokeArgs = hostFn.invokeContract()
573
+ if (!invokeArgs) {
574
+ throw new Error('install_policy: built op has no InvokeContractArgs payload')
575
+ }
576
+ const targetContract = Address.fromScAddress(invokeArgs.contractAddress()).toString()
577
+ const fnName = invokeArgs.functionName().toString()
578
+ if (fnName !== 'add_context_rule') {
579
+ throw new Error(`install_policy: built op fn name is "${fnName}", expected "add_context_rule"`)
580
+ }
581
+ const scvArgs = invokeArgs.args()
582
+ if (scvArgs.length !== 5) {
583
+ throw new Error(
584
+ `install_policy: built op has ${scvArgs.length} args, expected 5 (context_type, name, valid_until, signers, policies)`
585
+ )
586
+ }
587
+ const contextType = scvArgs[0]
588
+ const nameScv = scvArgs[1]
589
+ const validUntilScv = scvArgs[2]
590
+ const signersScv = scvArgs[3]
591
+ const policiesScv = scvArgs[4]
592
+ // The length check above pins these as defined; the local references
593
+ // satisfy noUncheckedIndexedAccess on the accessors below.
594
+ if (!nameScv || !validUntilScv || !signersScv || !policiesScv || !contextType) {
595
+ throw new Error('install_policy: built op args include an undefined slot despite length check')
596
+ }
597
+
598
+ // name
599
+ if (nameScv.switch().name !== 'scvString') {
600
+ throw new Error('install_policy: args[1] (name) is not an ScVal::String')
601
+ }
602
+ const ruleName = nameScv.str().toString()
603
+
604
+ // valid_until (Option<u32> -> void when absent)
605
+ let validUntilLedger: number | null
606
+ if (validUntilScv.switch().name === 'scvVoid') {
607
+ validUntilLedger = null
608
+ } else if (validUntilScv.switch().name === 'scvU32') {
609
+ validUntilLedger = validUntilScv.u32()
610
+ } else {
611
+ throw new Error('install_policy: args[2] (valid_until) is neither void nor u32')
612
+ }
613
+
614
+ // signers (Vec<Vec<Symbol, Address[, Bytes]>>)
615
+ if (signersScv.switch().name !== 'scvVec') {
616
+ throw new Error('install_policy: args[3] (signers) is not an ScVal::Vec')
617
+ }
618
+ const signers: InstallCallDescribes['signers'] = []
619
+ for (const signerScv of signersScv.vec() ?? []) {
620
+ if (signerScv.switch().name !== 'scvVec') {
621
+ throw new Error('install_policy: a signer entry is not an ScVal::Vec')
622
+ }
623
+ const tuple = signerScv.vec() ?? []
624
+ const tag = tuple[0]?.sym().toString()
625
+ const inner = tuple[1]
626
+ if (tag === 'Delegated') {
627
+ if (!inner || inner.switch().name !== 'scvAddress') {
628
+ throw new Error('install_policy: a Delegated signer is missing its Address')
629
+ }
630
+ signers.push({
631
+ kind: 'delegated',
632
+ address: Address.fromScAddress(inner.address()).toString(),
633
+ })
634
+ continue
635
+ }
636
+ if (tag === 'External') {
637
+ if (!inner || inner.switch().name !== 'scvAddress') {
638
+ throw new Error('install_policy: an External signer is missing its verifier Address')
639
+ }
640
+ signers.push({
641
+ kind: 'external',
642
+ verifier: Address.fromScAddress(inner.address()).toString(),
643
+ })
644
+ continue
645
+ }
646
+ throw new Error(`install_policy: signer tag "${tag ?? '<missing>'}" is not Delegated|External`)
647
+ }
648
+
649
+ // policies (Map<Address, Val>). Re-derive an interpreter-vs-OZ primitive
650
+ // classification by inspecting the value's map keys (PolicyInstallParams
651
+ // starts with `grammar_version`/`install_nonce`/`predicate`; OZ primitive
652
+ // params start with `spending_limit`/`threshold`/`period_ledgers`).
653
+ if (policiesScv.switch().name !== 'scvMap') {
654
+ throw new Error('install_policy: args[4] (policies) is not an ScVal::Map')
655
+ }
656
+ const policies: InstallCallDescribes['policies'] = []
657
+ let observedInstallNonce: number | null = null
658
+ for (const entry of policiesScv.map() ?? []) {
659
+ const key = entry.key()
660
+ if (key.switch().name !== 'scvAddress') {
661
+ throw new Error('install_policy: a policies map entry has a non-Address key')
662
+ }
663
+ const address = Address.fromScAddress(key.address()).toString()
664
+ const val = entry.val()
665
+ if (val.switch().name !== 'scvMap') {
666
+ throw new Error(
667
+ `install_policy: policies[${address}] value is not an ScVal::Map (got ${val.switch().name})`
668
+ )
669
+ }
670
+ const fields = new Map<string, xdr.ScVal>()
671
+ for (const inner of val.map() ?? []) {
672
+ const fk = inner.key()
673
+ if (fk.switch().name !== 'scvSymbol') {
674
+ throw new Error(`install_policy: policies[${address}] field key is not an ScVal::Symbol`)
675
+ }
676
+ fields.set(fk.sym().toString(), inner.val())
677
+ }
678
+ // Interpreter policy fields carry grammar_version/install_nonce/predicate.
679
+ // OZ primitives carry spending_limit/period_ledgers/threshold/signers.
680
+ if (fields.has('predicate') || fields.has('grammar_version') || fields.has('install_nonce')) {
681
+ const installNonceScv = fields.get('install_nonce')
682
+ if (!installNonceScv || installNonceScv.switch().name !== 'scvU32') {
683
+ throw new Error(
684
+ `install_policy: interpreter policy ${address} is missing a u32 install_nonce`
685
+ )
686
+ }
687
+ const installNonce = installNonceScv.u32()
688
+ const predicateScv = fields.get('predicate')
689
+ if (!predicateScv || predicateScv.switch().name !== 'scvBytes') {
690
+ throw new Error(
691
+ `install_policy: interpreter policy ${address} is missing its bytes predicate`
692
+ )
693
+ }
694
+ const predicateBytes = Buffer.from(predicateScv.bytes())
695
+ const predicateSha256OfEmbeddedBytes = createHash('sha256')
696
+ .update(predicateBytes)
697
+ .digest('hex')
698
+ const predicateHashScv = fields.get('predicate_hash')
699
+ const predicateHash =
700
+ predicateHashScv && predicateHashScv.switch().name === 'scvBytes'
701
+ ? Buffer.from(predicateHashScv.bytes()).toString('hex')
702
+ : ''
703
+ policies.push({
704
+ kind: 'interpreter',
705
+ address,
706
+ installNonce,
707
+ predicateHash,
708
+ predicateSha256OfEmbeddedBytes,
709
+ })
710
+ observedInstallNonce = installNonce
711
+ continue
712
+ }
713
+ // OZ built-in primitive. Distinguish by the parameter shape we
714
+ // emitted; matching one of the three primitives exactly pins the
715
+ // kind we built.
716
+ if (fields.has('spending_limit') && fields.has('period_ledgers')) {
717
+ policies.push({ kind: 'oz_builtin', address, primitive: 'spending_limit' })
718
+ continue
719
+ }
720
+ if (fields.has('threshold') && fields.has('signer_weights')) {
721
+ policies.push({ kind: 'oz_builtin', address, primitive: 'weighted_threshold' })
722
+ continue
723
+ }
724
+ if (fields.has('threshold')) {
725
+ policies.push({ kind: 'oz_builtin', address, primitive: 'simple_threshold' })
726
+ continue
727
+ }
728
+ throw new Error(
729
+ `install_policy: policies[${address}] value has an unknown field set; the encoder may have drifted`
730
+ )
731
+ }
732
+ // `observedInstallNonce` is the nonce baked into whichever interpreter
733
+ // policy is present; when none, fall back to the caller-supplied value.
734
+ const installNonce = observedInstallNonce ?? expectedInstallNonce
735
+ return {
736
+ targetContract,
737
+ fnName: 'add_context_rule',
738
+ ruleName,
739
+ validUntilLedger,
740
+ signers,
741
+ policies,
742
+ installNonce,
743
+ }
744
+ }
745
+
746
+ /** Re-export so the run-layer does not need to import from
747
+ * build-add-context-rule.ts (keeps the install/ -> install/ dependency
748
+ * direction intact). */
749
+ export { Contract, DEFAULT_GRAMMAR_VERSION }
750
+
751
+ // Local re-import to avoid pulling the class from the SDK module path
752
+ // at the top of the file (avoids the unused-import lint).
753
+ import { Account } from '@stellar/stellar-sdk'